
很多人在學C語言的時候一看到請將以下程序段輸入編輯器記錄兩個程序段的輸出結果并分析每個程序段結果這種題第一反應就是趕緊把代碼復制進編譯器跑一遍然后把屏幕上的輸出抄下來。這在我看來等于把一道好題做廢了。程序段分析題真正練的不是手速而是你在沒有運行環境的情況下能不能靠大腦把代碼的執行過程一步步推理出來。我今天就拿一道很有代表性的練習題來演示這個過程編號就叫7-3。這道題包含兩個程序段一個涉及遞歸調用一個涉及指針自增運算都是C語言考試和面試里反復出現的經典考點。1. 題面原型兩道看似簡單卻藏了四個考點的程序段7-3這類題在不少教材和練習平臺里很常見題干一般不復雜就一句話將以下程序段輸入編輯器記錄輸出結果并分析每個程序段結果。但真正動手寫的人會意識到這兩段代碼幾乎把C語言的函數調用、遞歸、運算符優先級、指針運算這幾個核心知識點全串起來了。1.1 程序段一遞歸求階乘和第一段代碼長這樣#include stdio.h int fact(int n) { if (n 1) { return 1; } return n * fact(n - 1); } int main(void) { int i; int sum 0; for (i 1; i 5; i) { sum fact(i); } printf(sum %d\n, sum); return 0; }乍一看這段代碼很短結構也很清晰先定義了一個fact函數主函數里用一個for循環從1到5把fact(1)、fact(2)、fact(3)、fact(4)、fact(5)的值累加進sum最后打印出來。難點在于fact這個函數本身是遞歸的你得搞清楚它在每一輪循環里到底返回了什么。1.2 程序段二指針自增運算的連環陷阱第二段代碼是這個#include stdio.h int main(void) { int a[5] {1, 3, 5, 7, 9}; int *p a; printf(%d\n, *p); printf(%d\n, *(p 1)); printf(%d\n, *p); printf(%d\n, *p--); return 0; }這段代碼沒有函數調用看起來更簡單實際上卻更容易翻車。四條printf語句長得幾乎一樣無非是星號、括號、加號和減號的排列組合但每一條對指針p的作用都不一樣。尤其是*p和*p這兩個寫法在C語言里是出了名的坑。1.3 記錄輸出的正確姿勢先手算別急著運行我的建議是拿到這樣的題目第一件事不是開編譯器而是找張紙把兩個程序段的輸出結果先手算一遍。算完再運行對照。這樣做的好處是運行結果只能告訴你輸出是什么但手算過程能逼你搞清楚為什么是這個結果。如果一開始就依賴編譯器你可能永遠分不清*p和*p的區別下次遇到還是錯。為了方便對照我把最終答案先放在這里。程序段一輸出的結果是sum 153程序段二輸出的結果是四行數字分別是1、5、5、5。程序段輸出結果涉及考點程序段一sum 153遞歸調用、函數返回值、循環累加程序段二1 \n 5 \n 5 \n 5運算符優先級、指針自增自減、數組訪問如果你第一次手算就能完全算對這個答案說明你對函數遞歸和指針運算的基礎已經相當扎實了。如果哪里對不上下面兩章就是為對不上的人準備的逐行拆解。2. 程序段一的執行推演遞歸不再靠背而是靠畫棧遞歸是C語言函數部分的核心也是很多人從入門到放棄的地方。程序段一里的fact函數是個非常典型的遞歸函數它求的是n的階乘也就是n!。數學上的定義是n! n × (n-1) × ... × 1并且0! 1。代碼里的實現思路是當n小于等于1時直接返回1否則返回n乘以fact(n-1)。2.1 fact(5)的遞歸展開從調用到返回的完整順序先單獨看fact(5)是怎么執行的。遞歸最難的地方在于你得從調用順序和返回順序兩個維度同時理解它。調用的時候是一層一層往下鉆返回的時候是一層一層往上爬。我把fact(5)的完整過程展開如下fact(5) 被調用 - 5 1因此執行 5 * fact(4) - fact(4) 被調用 - 4 1因此執行 4 * fact(3) - fact(3) 被調用 - 3 1因此執行 3 * fact(2) - fact(2) 被調用 - 2 1因此執行 2 * fact(1) - fact(1) 被調用 - 1 1return 1 - fact(1) 返回 1所以 2 * 1 2return 2 - fact(2) 返回 2所以 3 * 2 6return 6 - fact(3) 返回 6所以 4 * 6 24return 24 - fact(4) 返回 24所以 5 * 24 120return 120 fact(5) 最終返回值是 120這個過程用生活中的場景類比就像玩疊積木你先把一塊積木放上去再放一塊層層往上堆堆到第五層的時候發現最頂上那一塊就是終止條件然后你從最上面開始一塊一塊抽下來每抽一塊都要乘上一個數最后回到地面得到最終結果。2.2 sum的變化軌跡每一輪循環后到底加了什么單獨搞懂fact(5)還不夠因為主函數里的for循環是把fact(1)到fact(5)全部累加起來。很多人的誤區就在這里他們算出了fact(5)等于120就以為答案是120完全忽略了sum是五個值相加的結果。我把每一輪循環的執行情況列成一張表i的值fact(i)的返回值累加后sum的值111223369424335120153從這張表能很清楚地看到當i等于1的時候fact(1)直接命中終止條件返回1i等于2的時候fact(2)要先調用fact(1)拿到1之后乘以2返回2i等于3的時候fact(3)要先經過fact(2)再到fact(1)一層層返回后得到6。到i等于5的時候前面已經積累了33再加上120最終結果是153。2.3 為什么sum不等于120也不等于15我讓不少同學手算過這道題最常見的兩個錯誤答案分別是120和15。說120的人是只算了fact(5)丟掉for循環的累加過程說15的人是誤以為fact(i)返回的是i本身也就是12345完全把fact函數當成恒等函數了。這兩個錯誤本質上是同一個問題沒有搞清楚函數調用的返回值到底是什么。fact(i)不是返回i而是返回i的階乘。只有當i1或i0時fact(i)才恰好等于1i2時它等于2i3時它等于6i4時等于24i5時等于120。所以sum是這五個數的和這個和是153跟單算5!的120差了一個33也就是fact(1)到fact(4)之和。這里有個細節值得多說一句fact函數在n1時返回1這個寫法其實是對數學定義做了簡化。嚴謹的階乘定義中0! 1但這段代碼在n1時也返回1所以如果你把循環改成從0開始fact(0)依然返回1不會出錯。這種寫法在題目里常見不算Bug但你要能看出它的邊界行為。3. 程序段二的反直覺輸出*p 和 *p 到底誰先算程序段二比程序段一更容易讓人懷疑人生因為它考的是C語言里最讓人頭疼的運算符優先級和指針自增自減的組合。四條printf語句每條看起來都差不多實際運行結果卻是四行獨立的數字而且里面有連續兩個5。如果不仔細推理很容易算出一堆亂七八糟的結果。3.1 優先級與結合性的逐條拆解先明確第一條語句*p。在C語言里后綴自增運算符的優先級比解引用運算符*高而且后綴自增的結合方向是從左到右。所以*p不是(*p)而是*(p)。關鍵點在于后綴自增表達式的值是自增之前的值。也就是說p這個表達式的值是p原本指向的地址副作用是讓p指向下一個元素。因此*p做的事情是先取出p當前指向的元素再把p往后挪一格。第二條語句*(p 1)相對溫和。括號里的p 1計算的是p指向位置的下一個地址但這次加減操作不會修改p本身。它只是臨時算出一個新地址來讀取數據。第三條語句*p的規則跟前綴自增有關。前綴自增p的優先級比*略低還是高準確地說前綴自增和*的優先級相同結合方向從右到左所以*p先執行p也就是讓p先自增再對p解引用。跟后綴版本最大的區別是前綴自增表達式的值是自增之后的新地址。第四條語句*p--的處理方式跟第一條類似。后綴自減優先級高整個表達式等價于*(p--)先取出p當前指向的元素然后p再減一。3.2 p指針在第4條printf后的精確指向光講規則不夠我把指針p的變化過程完整跟蹤一遍。數組a在內存中的布局是連續的五個int初始時p指向a[0]也就是元素1。語句表達式值執行后p指向的位置對應元素初始狀態—a[0]1printf(%d\n, *p);1a[1]3printf(%d\n, *(p 1));5a[1]3printf(%d\n, *p);5a[2]5printf(%d\n, *p--);5a[1]3逐條解釋一下這張表。初始狀態下p指向a[0]所以第一條語句輸出a[0]的值1緊接著p自增指向a[1]。第二條語句執行時p指向a[1]所以p 1指向a[2]解引用后輸出a[2]的值5。注意這條語句從頭到尾沒有修改pp依然指向a[1]。第三條語句是*pp先自增從a[1]變成a[2]再解引用a[2]輸出5。第四條語句是*p--先解引用p當前指向的a[2]輸出5然后p自減回到a[1]。這里的自減屬于副作用后續代碼如果還用到p會拿到a[1]的地址但在這段程序里已經沒有任何用處了。所以最終屏幕上的輸出是1 5 5 53.3 常見的誤判結果和最容易被忽略的陷阱我在現實中見到最多的錯誤版本是把這四條語句的輸出理解成1, 3, 5, 5也就是認為第二條語句*(p 1)輸出了a[1]的值3。這個誤判的核心在于他們沒有意識到*(p 1)中的p在第一條語句執行后已經指向a[1]了所以p加1指向的是a[2]而不是a[1]。還有一種錯誤是認為第四行輸出3理由是p--讓p先減再取值這是把后綴自減跟前綴自減搞混了。后綴版本永遠先取當前值再移動指針。另一個容易被忽略的陷阱是如果你把第二條語句的括號去掉寫成*p 1那意思就完全變了它變成了取出p指向的值再加1輸出結果會變成6因為a[1]是5等等不對第二條執行時p指向a[1]a[1]的值是33加1等于4。你看一個括號的差異就能改變整個輸出。C語言里像這樣差之毫厘謬以千里的地方太多了這就是為什么程序段分析題要求你不僅看結果還要分析原因。4. 動手改造實驗改一個條件輸出就可能天翻地覆程序段分析題還有一個很有價值的玩法就是改代碼。把條件改一改看看輸出會發生什么變化再想想變化的原因。這個過程中形成的直覺比單純做一百道題都管用。我用幾個有代表性的改造來說明。4.1 把遞歸終止條件從 n 1 改成 n 0第一個改動很隱蔽把程序段一里fact函數的if (n 1)改成if (n 0)其他都不動。此時fact(1)不再直接返回1而是執行1 * fact(0)。由于fact(0)滿足n0返回1所以fact(1)的結果仍然是1。整個for循環從1到5sum依然等于153輸出不變。但如果終止條件改成if (n 1)也就是只對n等于1做處理那問題就來了。當循環從i1開始時沒問題fact(1)直接返回1后面的fact(2)、fact(3)等都能一層層遞歸到fact(1)結束。可一旦你某天把循環改成從i0開始fact(0)就會調用fact(-1)fact(-1)調用fact(-2)無限遞歸直到棧溢出崩潰。這就是邊界條件的威力。4.2 把循環改成從 i 0 開始累加接著說循環起點。如果把程序段一的for循環改成for (i 0; i 5; i)在原來的n 1條件下fact(0)返回1所以sum會從1開始累加最終結果是154。只是起點從1變成0答案就從153變成了154。這說明做題時不能只盯著函數體循環的邊界、初值、終值都必須一起看。4.3 指針程序段換成等價寫法用下標方式重塑邏輯程序段二也可以改造。把四條printf改成完全等價的數組下標寫法幫助理解背后到底發生了什么printf(%d\n, a[0]); p p 1; printf(%d\n, a[2]); p p 1; printf(%d\n, a[2]); p p - 1;這段改寫跟原代碼的輸出是一致的第一條輸出a[0]第二條輸出a[2]第三條輸出a[2]第四條原樣也是a[2]。通過這種指針寫法到下標寫法的轉換你會發現指針自增運算的本質就是讓指針在數組里游走配合下標訪問能做到同樣效果。不同之處在于指針寫法更貼近底層內存地址的概念也更考驗你對運算符的理解。這個改造實驗很有實際價值。在真實項目里指針運算經常出現在字符串處理、數組遍歷、緩沖區操作等場景中如果你能一眼看出*p到底干了什么調試代碼的速度會快很多。5. 這種題的通用解法把看答案變成驗答案最后我想總結一套我用了很多年的手算方法。不管是7-3這種練習題還是考試里的程序閱讀題都可以用這套方法來應對。核心思路就一句話在代碼運行之前先強迫自己當一回人肉編譯器。5.1 手算五步法第一步把代碼抄到紙上一行一行看不要跳。第二步遇到復雜表達式先標出運算符優先級把高優先級的運算先括起來。比如*p就寫成*(p)*p就寫成*(p)。第三步給關鍵變量畫狀態表像上面那張指針變化表一樣。第四步口算出每一步的結果并把每一次函數調用和返回都記錄下來。第五步運行程序對照結果。如果手算和運行不一致說明你對某個知識點的理解有偏差這時候才是真正進步的機會。這套方法看著繁瑣但熟練之后像程序段二那樣的題三十秒內就能手算出結果。因為指針自增自減就那么幾個規律見多了自然就記住。5.2 驗證時別用dev工具偷懶要用 -Wall 編譯實際操作中我建議用GCC驗證。如果你的環境是VS Code配的C語言開發環境在終端里執行這樣一條命令就能編譯并運行gcc -stdc11 -Wall -Wextra 7-3.c -o 7-3 ./7-3-Wall -Wextra的作用是顯示所有警告。對于程序段二這種代碼雖然邏輯正確但有些編譯器在開啟警告時可能會提示未使用變量之類的信息。學會看這些警告對培養代碼敏感度很有幫助。很多人初學的時候覺得警告無所謂只要能跑就行這個習慣其實很危險。5.3 函數與指針專題的強化路徑如果你正在學C語言函數章節我覺得系統強化路徑可以是這樣先把課本上的遞歸練習題做一遍不限于階乘和斐波那契還要做漢諾塔、字符串逆序這類經典題。然后做指針專題把自增自減、優先級、數組名和指針的關系全部理清楚。最后做綜合題也就是像7-3這樣把函數、循環、指針混在一起考的題。評價標準只有一個你能不能在不運行代碼的情況下百分之百確定輸出結果。如果能那這部分內容基本就過關了。我個人在做這類題目的時候最大的體會是把每一行代碼的執行過程拆開來看尤其是搞清楚誰變了、誰沒變比背十個規律都管用。遞歸里的參數、指針里的地址、循環里的累加變量本質上都是這個道理。你不需要一次記住一百個結論只需要掌握一種嚴謹的推理習慣剩下的題目都能用這套習慣慢慢推出來。