的運用:從for/while到嵌套循環(huán)的完整實戰(zhàn)指南)
作為帶過三屆編程課助教的人每次改到“作業(yè)一循環(huán)的運用”這道題心情都很復雜。題目本身平淡無奇無非是讓初學者用循環(huán)做點求和、打印圖形、遍歷數(shù)據(jù)之類的事。但就是這道看似簡單的題每年都能逼瘋一批人——不是寫不出代碼而是寫出來的代碼經(jīng)常一跑就卡死、一換邊界就出錯、一加需求就崩潰。我后來想明白了這道題真正的價值不在語法而在思維。循環(huán)是編程里第一個真正意義上的“結(jié)構(gòu)化控制”概念你學會的不只是for和while怎么寫而是怎么讓計算機替你重復做大量機械勞動。后面你碰到循環(huán)隊列、循環(huán)冗余校驗、RNN循環(huán)神經(jīng)網(wǎng)絡、Spring循環(huán)依賴甚至Minecraft服務器主循環(huán)底層都是同一套“重復執(zhí)行直到滿足條件”的邏輯。這篇文章就是寫給正在被這道作業(yè)折磨的人。不管是大學里C語言課的作業(yè)還是Python入門課的練習甚至是你自學編程時自己給自己布置的任務都可以照著這篇文章的思路來。我會把循環(huán)的三種基本形態(tài)、嵌套循環(huán)、循環(huán)控制、常見調(diào)試問題全過一遍配合完整的代碼示例和運行結(jié)果保證你看完能自己寫出不卡死、不報錯、改得動的循環(huán)代碼。1. 這個作業(yè)到底在考什么1.1 循環(huán)思維的三個層次我批改作業(yè)時習慣把學生的代碼分成三個層次。第一層是只會背模板。知道for后面要寫三個表達式while后面要加括號但里面的變量為什么這么寫、改了條件會怎樣完全沒概念。這種代碼基本屬于“運氣編程”運氣好能跑通運氣不好就死循環(huán)。第二層是理解了循環(huán)的執(zhí)行流程。知道初始化、條件判斷、循環(huán)體、迭代更新這四步是按什么順序走的能解釋清楚每一步發(fā)生了什么也具備基本的調(diào)試能力。第三層是具備循環(huán)思維。看到一個重復性的任務能下意識地想“這里是不是可以用循環(huán)”并且能快速判斷用什么循環(huán)結(jié)構(gòu)、循環(huán)條件怎么定、循環(huán)體里做什么。這才是這道作業(yè)真正想考察的東西。大部分人的循環(huán)問題都卡在從第二層到第三層的跨越上。語法早就記住了但遇到實際問題就是不知道從哪里下手。這篇文章的實操部分會重點幫你解決這個跨越問題。1.2 三種循環(huán)結(jié)構(gòu)的適用邊界不同編程語言里循環(huán)的具體寫法不一樣但歸根結(jié)底就三種結(jié)構(gòu)計數(shù)循環(huán)、條件循環(huán)、直到型循環(huán)。計數(shù)循環(huán)就是最常見的for循環(huán)適合“我知道要執(zhí)行多少次”的場景。比如求1到100的和、打印九九乘法表、遍歷數(shù)組元素都是一眼能看到次數(shù)上限的任務。條件循環(huán)是while適合“我不知道要執(zhí)行多少次但我知道什么時候該停”的場景。比如讀用戶輸入直到碰到空行、模擬擲骰子直到擲出6、從隊列里取數(shù)據(jù)直到隊列為空這類任務的終止條件往往跟運行時的數(shù)據(jù)有關(guān)。直到型循環(huán)是do-while它在C語言和Java里有Python里沒有。特點是不管條件成不成立先執(zhí)行一次再說。適合“至少要執(zhí)行一次”的場景比如菜單程序里先顯示一次菜單再等用戶選擇或者游戲里先渲染一幀再看要不要退出。很多人會糾結(jié)什么時候用哪個我的建議很簡單能數(shù)清楚次數(shù)就用for數(shù)不清楚但知道什么時候停就用while必須至少做一次就用do-while。把這個判斷思路記住比背十遍語法都管用。2. 循環(huán)的核心細節(jié)拆解2.1 for循環(huán)的三個關(guān)鍵部分for循環(huán)是這道作業(yè)里的重頭戲幾乎所有題都能用它解決。以C語言為例標準的for循環(huán)長這樣for (初始化; 條件判斷; 迭代更新) { // 循環(huán)體 }三個部分的分工很明確。初始化負責設(shè)置循環(huán)變量的初始值比如int i 0條件判斷決定循環(huán)是否繼續(xù)比如i 10迭代更新負責改變循環(huán)變量比如i。這里最關(guān)鍵也最容易出錯的地方有兩個。一個是條件判斷的邊界i 10和i 9效果一樣但i 11就多了一次循環(huán)這屬于典型的off-by-one錯誤。另一個是迭代更新很多人以為只能在循環(huán)體最后寫其實第三部分就是干這個的寫在第三部分能讓你清晰地看出循環(huán)變量的變化軌跡也方便排查問題。Python里的for循環(huán)長得不一樣它本質(zhì)上是遍歷可迭代對象for i in range(10): print(i)range(10)生成的是0到9的整數(shù)序列等價于C語言里i 10的情況。如果你需要從1開始就寫range(1, 11)注意第二個參數(shù)是開區(qū)間取不到11。這個細節(jié)我每年都能看到有人踩坑寫range(1, 10)然后問為什么只到9。2.2 while循環(huán)的邊界處理while循環(huán)的寫法比for簡單但出問題概率反而更高因為它把條件判斷和循環(huán)變量的更新完全交給了開發(fā)者。int count 0; while (count 10) { printf(%d\n, count); count; }這段代碼能正常工作前提是循環(huán)體里必須有一行更新count的語句。如果忘了寫count條件永遠為真程序就直接卡死在死循環(huán)里。我見過太多人第一次跑while循環(huán)時終端瘋狂刷數(shù)字然后卡死最后只能強制關(guān)閉。Python的while寫法類似count 0 while count 10: print(count) count 1也是一個道理循環(huán)體里必須想辦法讓條件趨向于False。一個有效的自查口訣是問自己“循環(huán)體執(zhí)行一次后條件判斷會不會發(fā)生變化”。如果答案是永遠不會那這個循環(huán)必死無疑。另外提一個實戰(zhàn)技巧如果確實需要死循環(huán)比如服務器主循環(huán)、事件監(jiān)聽循環(huán)可以用while True或while(1)然后在循環(huán)體里用break跳出。這種用法在寫Minecraft服務器主循環(huán)、消息隊列消費者這類常駐程序時非常常見但作業(yè)階段不太建議用容易讓老師覺得你思路不夠清晰。2.3 嵌套循環(huán)與循環(huán)控制語句嵌套循環(huán)是“循環(huán)的運用”這道作業(yè)里拉開分數(shù)的地方。很多題目都會要求打印圖形比如直角三角形、九九乘法表這些都需要用兩層循環(huán)。以九九乘法表為例用C語言寫是這樣for (int i 1; i 9; i) { for (int j 1; j i; j) { printf(%d*%d%-2d , j, i, i * j); } printf(\n); }外層循環(huán)控制行數(shù)內(nèi)層循環(huán)控制每一行打印多少列。關(guān)鍵點是內(nèi)層循環(huán)的邊界用的是j i而不是j 9這樣才能形成三角形的形狀而不是矩形。除了嵌套還有兩個循環(huán)控制語句要掌握break和continue。break是跳出整個循環(huán)continue是跳過本次循環(huán)剩余的部分進入下一次迭代。這兩個詞的區(qū)分很基礎(chǔ)但在實際調(diào)試時經(jīng)常會讓人犯迷糊我見過有人把continue當成break用結(jié)果循環(huán)根本跳不出去。3. 實操從題目到代碼的全過程3.1 題目分析與思路設(shè)計為了把“循環(huán)的運用”講透我用一道經(jīng)典的綜合題來走一遍完整流程輸入一個正整數(shù)n計算1到n之間所有奇數(shù)的和、所有偶數(shù)的和以及能被3整除的數(shù)的個數(shù)。這道題本身不難但很典型涵蓋了循環(huán)、條件判斷、累加器、計數(shù)器的組合使用。拿到題目第一步不是寫代碼而是拆解需求。這道題需要三個輸出奇數(shù)和、偶數(shù)和、能被3整除的個數(shù)。那就至少需要三個變量來保存結(jié)果然后遍歷1到n的每個數(shù)逐個判斷。順著這個思路偽代碼就出來了1. 讀入n 2. 初始化 odd_sum 0, even_sum 0, count 0 3. i從1到n循環(huán) 如果 i 是奇數(shù)odd_sum i 如果 i 是偶數(shù)even_sum i 如果 i 能被3整除count 1 4. 輸出三個結(jié)果把偽代碼寫出來再翻譯成代碼整個過程就順多了。這也是我反復跟學生強調(diào)的先寫偽代碼再寫真代碼。直接上手寫代碼很容易在細節(jié)里迷路尤其是循環(huán)和條件嵌套的時候。3.2 代碼實現(xiàn)與運行結(jié)果用Python實現(xiàn)一遍n int(input(請輸入一個正整數(shù): )) odd_sum 0 even_sum 0 count_3 0 for i in range(1, n 1): if i % 2 1: odd_sum i else: even_sum i if i % 3 0: count_3 1 print(f1到{n}之間奇數(shù)和: {odd_sum}) print(f1到{n}之間偶數(shù)和: {even_sum}) print(f1到{n}之間能被3整除的個數(shù): {count_3})用C語言寫一遍#include stdio.h int main() { int n; int odd_sum 0, even_sum 0, count_3 0; printf(請輸入一個正整數(shù): ); scanf(%d, n); for (int i 1; i n; i) { if (i % 2 1) { odd_sum i; } else { even_sum i; } if (i % 3 0) { count_3; } } printf(1到%d之間奇數(shù)和: %d\n, n, odd_sum); printf(1到%d之間偶數(shù)和: %d\n, n, even_sum); printf(1到%d之間能被3整除的個數(shù): %d\n, n, count_3); return 0; }假設(shè)輸入n10兩個程序的運行結(jié)果都一樣奇數(shù)之和: 25 偶數(shù)之和: 30 能被3整除的個數(shù): 325是1加3加5加7加930是2加4加6加8加103是3、6、9這三個數(shù)。結(jié)果完全正確。這里有幾個細節(jié)要說明。Python的range(1, n 1)第二個參數(shù)必須是n 1因為range是左閉右開區(qū)間取不到末尾。C語言里是i n這是閉區(qū)間。用兩種語言寫同一個邏輯能幫你加深對邊界條件的理解。還有一點判斷奇偶用的是取模運算i % 2 1。這里有個坑在C語言里如果n是負數(shù)n % 2可能得到-1而不是1所以更穩(wěn)妥的寫法是判斷i % 2 ! 0。Python的取模運算則保證了結(jié)果始終為非負數(shù)所以i % 2 1可以放心用。這種語言特性上的差異平時不覺得真到了跨語言寫代碼的時候就會暴露出來。3.3 循環(huán)思想的遷移從累加到更復雜場景這道作業(yè)做完之后循環(huán)思想是可以直接遷移到很多更復雜的場景里的。這里舉幾個常被搜索的例子說明它們本質(zhì)上也是在用循環(huán)。第一個是循環(huán)隊列。數(shù)據(jù)結(jié)構(gòu)課程里的循環(huán)隊列核心操作是入隊和出隊而隊列指針的移動就是(rear 1) % capacity。這里的取模運算讓指針在到達末尾后自動折回開頭實現(xiàn)了一個邏輯上的循環(huán)。如果你把隊列初始化和入隊出隊放進一個for或while循環(huán)里就能模擬出連續(xù)操作的效果。第二個是在做循環(huán)數(shù)據(jù)采集時遇到的UI刷新問題。比如C#里做硬件數(shù)據(jù)采集采集線程用while循環(huán)不斷讀取數(shù)據(jù)但UI主線程來不及刷新界面就會卡頓。本質(zhì)上是兩個循環(huán)的節(jié)奏不匹配解決方案要么是用定時器代替while持續(xù)輪詢要么是用異步方式把數(shù)據(jù)采集循環(huán)和UI刷新循環(huán)分開。這已經(jīng)超出了基礎(chǔ)循環(huán)的范疇但根子還是在“循環(huán)的運用”這道作業(yè)里打下的底子。第三個是Tcl、JavaScript這類腳本語言的for循環(huán)語法差異。Tcl的for循環(huán)長得跟C語言很像但參數(shù)全被當成字符串處理JavaScript的forEach則是回調(diào)式的循環(huán)結(jié)束后沒有同步的返回信號。這些差異在你理解了循環(huán)的本質(zhì)之后學起來會非常快。所以不要小看這個作業(yè)你在里面形成的循環(huán)思維、邊界意識、調(diào)試感覺會成為后面所有編程任務的底層支撐。4. 常見問題與排查技巧實錄4.1 死循環(huán)最常見的翻車現(xiàn)場死循環(huán)絕對是這道作業(yè)里出現(xiàn)頻率最高的問題沒有之一。原因無非就是循環(huán)條件永遠為真或者循環(huán)變量根本沒有更新。排查死循環(huán)我的習慣是先看三處初始化、條件判斷、循環(huán)體末尾。按順序檢查這三個位置九成的死循環(huán)都能定位。看一個典型的錯誤代碼int i 0; while (i 10) { printf(%d\n, i); }條件i 10永遠成立因為i從沒變過。修正很簡單在循環(huán)體里加一行i就行。但很多時候錯誤沒那么明顯比如你寫了while (i 10)沒寫i或者把i寫在了continue后面導致i永遠執(zhí)行不到。還有一個隱蔽的場景是浮點數(shù)比較。比如while (x ! 1.0)由于浮點數(shù)精度問題x累加0.1永遠不可能精確等于1.0這個循環(huán)就成了死循環(huán)。碰到浮點數(shù)循環(huán)條件不要用相等判斷改成x 1.0這類范圍判斷。4.2 邊界錯誤差一個的經(jīng)典困惑第二個高頻問題是邊界錯誤就是常說的off-by-one。多跑一次或者少跑一次結(jié)果差一點但就是不對。比如打印1到10有人寫for (int i 1; i 10; i)結(jié)果只打印到9。原因是對和的邊界理解不到位。排查這類問題最有效的辦法是“代入法”手動模擬循環(huán)變量的值從初始值開始一步一步推看條件判斷在哪個值上由True變False。1到9都小于1010不小于10循環(huán)結(jié)束所以最后一次是9。這種問題用調(diào)試器看也很清晰但最可靠的方式還是心算或者加打印語句。我個人的習慣是凡是不確定的邊界條件先加一行printf(i%d\n, i)看看實際循環(huán)到了哪里確認沒問題再刪掉。這個土辦法比任何調(diào)試工具都管用。4.3 性能與可讀性別只顧跑通作業(yè)階段大家只關(guān)心能不能跑通很少考慮性能和可讀性但這兩個點恰恰是評分的重要維度。性能方面嵌套循環(huán)的復雜度是乘起來的兩層循環(huán)就是O(n2)。如果你發(fā)現(xiàn)可以只用一層循環(huán)解決就別硬寫兩層。比如上面的奇偶和問題其實可以不用遍歷每個數(shù)直接用等差數(shù)列公式算但作業(yè)階段重點是練習循環(huán)所以遍歷法更合適。只有當你理解循環(huán)的代價之后才有資格去優(yōu)化它。可讀性方面有兩點要強推。第一是變量命名不要用i、j、k混一堆在簡單循環(huán)里沒問題但嵌套循環(huán)一多就分不清了建議外層用i內(nèi)層用j有明確語義就用row、col這類名字。第二是循環(huán)體不要寫得過長如果循環(huán)體超過十幾行嘗試把一部分邏輯提取成函數(shù)這樣循環(huán)結(jié)構(gòu)一眼就能看懂改起來也不容易出錯。4.4 遇到“循環(huán)冗余檢查”錯誤不要慌最后說一個看起來跟編程作業(yè)完全無關(guān)、但很多人都會遇到的循環(huán)術(shù)語err:23數(shù)據(jù)錯誤循環(huán)冗余檢查也就是CRC錯誤。我第一次遇到時以為是循環(huán)代碼寫錯了折騰了半天才發(fā)現(xiàn)是文件拷貝出問題了。CRCCyclic Redundancy Check循環(huán)冗余校驗是一種數(shù)據(jù)校驗算法核心是一個移位和異或的循環(huán)過程。當你在解壓文件或者拷貝數(shù)據(jù)時提示這個錯誤通常是數(shù)據(jù)損壞、磁盤壞道或者存儲介質(zhì)有問題跟你的程序沒關(guān)系。處理辦法一般是重新下載文件、換數(shù)據(jù)線或者檢查硬盤健康狀態(tài)。這里提它是因為搜索“循環(huán)”熱詞時經(jīng)常有人被這個錯誤詞帶偏以為自己的循環(huán)代碼有bug實際上完全是兩回事。做作業(yè)的時候如果程序運行正常只是某些工具報了這個錯先檢查文件來源和設(shè)備別在代碼里找不存在的錯誤。5. 作業(yè)之外的三個擴展方向5.1 用“循環(huán)”理解更強力的工具寫完這次的作業(yè)很多人會覺得已經(jīng)掌握得差不多了其實才剛?cè)腴T。后面還有很多建立在循環(huán)之上的概念理解了它們的共同點編程水平會迅速上一個臺階。拿循環(huán)神經(jīng)網(wǎng)絡RNN舉例。RNN處理序列數(shù)據(jù)時本質(zhì)上就是對序列的每個元素做重復計算同時把上一步的狀態(tài)帶進下一步。for t in range(seq_len): hidden fn(input[t], hidden)這不就是一個標準的帶狀態(tài)更新的循環(huán)嗎理解了循環(huán)的本質(zhì)再看深度學習里的序列模型視角會完全不同。還有循環(huán)依賴這是Spring框架開發(fā)中著名的坑。A依賴B、B又依賴A形成一個環(huán)。雖然這跟for循環(huán)的寫法沒關(guān)系但背后的邏輯循環(huán)概念是相通的。理解“環(huán)”意味著什么能幫你更快地定位這類問題。5.2 在真實項目中觀察循環(huán)的形態(tài)如果你有機會接觸實際項目的代碼可以去GitHub上找一些開源項目用“找循環(huán)”的方式去讀代碼。你會發(fā)現(xiàn)真實項目里的循環(huán)和作業(yè)里的不太一樣它們往往會在循環(huán)體里做錯誤處理、調(diào)用外部接口、處理復雜狀態(tài)。但核心骨架仍然是那三步初始化、條件判斷、更新。個人經(jīng)驗是讀代碼時先把每個循環(huán)抽出來單獨看問自己三個問題這個循環(huán)在遍歷什么循環(huán)體里做了什么循環(huán)什么時候結(jié)束三個問題都答上來這段代碼你就基本讀懂了。同理如果你以后要寫自動化腳本、批處理任務、定時任務核心骨架也離不開循環(huán)。5.3 嘗試用多種語言實現(xiàn)同一個循環(huán)最后一個擴展建議比較實用拿這次作業(yè)的題目用Python、C、JavaScript各寫一遍。比如求和Python用rangeC用for三表達式JavaScript有for...of和forEach。你會發(fā)現(xiàn)每種語言的循環(huán)風格差異很大但核心邏輯完全一致。有一個典型的熱門問題“js forEach怎么判斷循環(huán)完了”。這是因為JavaScript的forEach是回調(diào)風格的循環(huán)結(jié)束后沒有一個同步的返回信號。解決方法通常是計數(shù)法每次回調(diào)里遞增計數(shù)器當計數(shù)器等于數(shù)組長度時說明循環(huán)完畢然后執(zhí)行后續(xù)操作。這是典型的“循環(huán)思想跨語言遷移”問題如果你在作業(yè)階段就把循環(huán)的本質(zhì)想透了遇到這種語言特性問題一點就通。說到底循環(huán)就是編程世界里的“復讀機”但你得知道讓它讀什么、讀幾次、什么時候停。把這三件事想清楚所有循環(huán)類的問題都迎刃而解。這篇文章寫到這里基本把“循環(huán)的運用”這道作業(yè)從思路到實操再到排查全部過了一遍。我個人改作業(yè)這幾年的體會是那些最終把循環(huán)學到位的同學都有一個共同習慣就是在寫代碼之前先花幾分鐘想清楚“循環(huán)的邊界在哪里”這個習慣比背多少語法都值錢。如果你現(xiàn)在正在做這道作業(yè)不妨先按文章里的偽代碼方法寫一遍思路再動手打代碼我相信你會比直接開寫順利得多。