思路
下圖描述的是從問題引出到問題變異的思維過程:
概述
本文以數制轉換為引,對遞歸進行分析。主要是從多角度分析遞歸過程及討論遞歸特點和用法。
引子
一次在完成某個程序時,突然想要實現任意進制數相互轉換,於是就琢磨,至少涉及以下參數:
- 源進制數:scr
- 目標進制:dest_d
實現的大致思路:
scr --> 數字分解 --> 按權求和 --> dest
很明顯這個過程是先正序分解,然后逆序求和,所以我就聯想到了遞歸。
遞歸
1. 遞歸的含義
遞歸就是遞歸函數。遞歸函數是直接或間接調用自身的函數。
舉個例子:
程序1: btoa.c
1 /* 2 ** 接受一個整型值(無符號),把它轉換為字符並打印它,前導零被刪除。 3 */ 4 #include <stdio.h> 5 void binary_to_ascii( unsigned int value ) { 6 unsigned int quotient; 7 quotient = value / 10; 8 if( quotient != 0) 9 binary_tc_ascii( quotient ); 10 putchar( value % 10 + '0' ); 11 }
另外遞歸還有所謂“三個條件”,“兩個階段”。我就不說了。實際應用時一般都很自然的滿足條件。
2. 遞歸過程分析
-
中斷角度
看例:
有5人從左至右坐,右邊人的年齡比相鄰左邊人大2歲,最左邊的那個人10歲。問最右邊人年齡。
-
程序2: age.c
-
1 #include <stdio.h> 2 age(int n) { 3 int c; 4 if( n == 1 ) 5 c = 10; 6 else 7 c = age( n-1 ) + 2; 8 return(c); 9 } 10 11 int main() { 12 printf("%d\n\n",age( 5 ) ); 13 return 0; 14 }
表達式:
遞推和回推過程:
這跟中斷有什么聯系呢?現在看來確實不很明顯,不過最初我就是由它想到《微機原理》中的中斷的:從age(5)開始執行,然后調用age(4),即來一個中斷,此時先保護現場,然后一直遞歸直到n=1時,中斷結束,然后層層返回,也就是不斷恢復現場的過程。
-
嵌套調用角度:
嵌套調用關系圖:
看懂了這個圖,把上面的fun_a()和fun_b()全換成一樣的fun(),就相當於是遞歸時的函數對自身的調用過程。
另外好像這幅圖更容易看出“中斷過程”吧。 -
堆棧角度
如果中斷和嵌套這兩個角度都看明白的話,這個堆棧角度就是升華一下。
還用程序1為例進行分析:
程序1的函數有兩個變量:參數value和局部變量quotient。下面的一些圖顯示了堆棧的狀態,當前可以訪問的變量位於棧頂。所有其他調用的變量飾以灰色陰影,表示它們不能被當前正在執行的函數訪問。
假定我們以4267這個值調用遞歸函數。當函數開始執行時,堆棧的內容如下圖所示。
執行除法運算之后,堆棧的內容如下:
接着,if語句判斷出 quotient 的值非零,所以對該函數執行遞歸調用。當這個函數第二次被調用之初,堆棧的內容如下:
堆棧上創建了一批新的變量,隱藏了前面的那批變量,除非當前這次遞歸調用返回,否則它們是不能被訪問的。再次執行除法運算之后,堆棧的內容如下:
quotient的值現在為42,仍然非零,所以需要繼續執行遞歸調用,並再創建一批變量。在執行完這次調用的除法運算之后,堆棧的內容如下:
此時,quotient的值還是非零,仍然需要執行遞歸調用。在執行除法運算之后,堆棧的內容如下:
不算遞歸調用語句本身,到目前為止所執行的語句只是除法運算以及對quotient的值進行測試。由於遞歸調用使這些語句重復執行,所以它的效果類似循環:當quotient的值非零時,把它的值作為初始值重新開始循環。但是,遞歸調用將會保存一些信息(這點與循環不同),也就是保存在堆棧中的變量值。這些信息很快就會變得非常重要。
現在quotient的值變成了零,遞歸函數便不再調用自身,而是開始打印輸出。然后函數返回,並開始銷毀堆棧上的變量值。
每次調用putchar得到變量value的最后一個數字,方法是對value進行模10余運算,其結果是一個0~9之間的整數。把它與字符常量'0'相加,其結果便是對應於這個數字的ASCII字符,然后把這個字符打印出來。
接着函數返回,它的變量從堆棧中銷毀。接着,遞歸函數的前一次調用重新繼續執行,它所使用的是自己的變量,它們現在位於堆棧的頂部。因為它的value值是42,所以調用putchar后打印出來的數字是2 。
接着遞歸函數的這次調用也返回,它的變量也被銷毀,此時位於堆棧頂部的是遞歸函數再前一次調用的變量。遞歸調用從這個位置繼續執行,這次打印的數字是6 。在這次調用返回之前,堆棧的內容如下:
現在我們已經展開了整個遞歸過程,並回到該函數最初的調用。這次調用打印出數字7,也就是它的value參數除10的余數。
然后,這個遞歸函數就徹底返回到其他函數調用它的地點。
如果你把打印的字符一個接一個排在一起,出現在打印機或屏幕上,你將看到正確的值4267 。 -
3. 遞歸的應用
上面從不同角度對遞歸過程進行了分析。而際應用時並不要求你搞清楚每個遞歸的內部過程,重要的是用對。
下面主要是不恰當應用遞歸的一些例子:
許多教材中都把計算階乘和菲波那契數列用來說明遞歸,然而前者中遞歸並沒有提供任何優越之處,后者中遞歸的效率非常之低。
看一下極端的菲波那契數求解:
表達式:
這種遞歸形式的定義容易誘導人們使用遞歸形式來解決問題:程序3:fib_rec.c
1 /* 2 ** 用遞歸方法計算第n個菲波那契數列的值。 3 */ 4 5 int fibonacci( int n ) { 6 if( n <= 2 ) 7 return 1; 8 return fibonacci( n - 1 ) + fibonacci( n - 2 ); 9 }
這里有一個陷阱:它使用遞歸步驟計算fibonacci( n -1)和 fibonacci( n -2)。但是,在計算 fibonacci( n -1)時也將計算 fibonacci( n -2)。這個額外的代價有多大呢?
答案是:它的代價遠遠不止一個冗余計算:每個遞歸調用都會觸發另外兩個遞歸調用,面這兩個調用的任何一個還並將觸發兩個遞歸調用,再接下去的調用也是如此。這樣,冗余計算的數量增長得非常快。例如,在遞歸計算fibonacci(10)時,fibonacci(3)的值被計算了21次。但是在遞歸計算fibonacci(30)時,fibonacci(3)的值被計算了317811次,當然,這317811次產生的結果是完全一樣的,除了其中之一外,其余的純屬浪費。
想得更極端一些,假如你在程序中遞歸時不是兩次而是3次,4次,更多次的調用自身,那我想可能會讓程序崩潰吧。
現在讓我們嘗試用循環代替遞歸:
-
程序4:fib_iter.c
1 int fibonacci( int n ) { 2 int result; 3 int previous_result; 4 int next_older_result; 5 result = previous_result = 1; 6 while(n > 2 ) { 7 n -= 1; 8 next_older_result = previous_result; 9 previous_result = result; 10 result = previous_result + next_older_result; 11 } 12 return result; 13 }
OK,說到這了,本文引子是數制轉換,總得說點數制轉換點題是吧。
嗯,把題目都忘記了,回引子看一下吧。
程序5:convert.c
1 #ifndef _CONERT_H 2 #define _CONERT_H 3 #include <stdio.h> 4 #include <math.h> 5 #endif 6 7 /* 8 **main() 9 */ 10 11 int conert2any( int scr, int dest_d, int pow_base ) { 12 /* 13 ** 調用該函數時參數pow_base必須為0 14 */ 15 int quotient, result; 16 int dest_d_base = 10; 17 quotient = scr / dest_d; 18 if( quotient != 0 ) 19 result = ( scr % dest_d ) * pow( dest_d_base, pow_base) + conert2any( quotient, dest_d, ++pow_base ); 20 else 21 result = ( scr % dest_d ) * pow( dest_d_base, pow_base); 22 return ( result ); 23 }
OK,這個數制轉換程序用遞歸實現,沒什么問題,但受上例啟發它也可以改為循環:
程序6:convert_loop.c
1 do { 2 result += (scr % dest_d ) * pow( dest_d_base, pow_base++ ); 3 } while( scr /= dest_d != 0 )
相比於遞歸,它更短小精悍,效率也高些。
經過兩個遞歸改為循環的例子,你應該發現這兩個例子有一個共同點:遞歸調用時最后執行的語句是return 。
對於這種調用時最后執行的是return的遞歸,有一種專門的稱呼:尾部遞歸。
可以發現一般情況下尾部遞歸都可以改為相應的循環形式,而且更簡潔高效。
那什么時候才必須用遞歸呢?據我目前的經驗和思考,只有程序1--逆序打印是必須的,其它好像沒有必須用遞歸的。
好了,到這遞歸也告一段落了,來個小插曲,談一下我寫程序5時的一些感受:
實現這個進制轉換函數時,對遞歸的理解還不深,犯了現在看來可笑的錯誤:其中要用遞歸實現加權求和,我還曾苦思如何實現累加呢,每一次調用完后變量都銷毀了,如何累加呢?苦思的結果是:利用靜態變量保存累加的值。如果到此為止的話我也不會進一步學習遞歸。因為我想,雖然這樣能實現,可是不完美,即便碧波函數調用完了,靜態變量依然在占着空間,而且再次調用前還得先清零。C語言的遞歸不該是如此麻煩的,一定是我哪里想差了,於是我就反復看書上的例子,終於醒悟:直接用return返回不就可以實現累加了嘛。唉,當時腦子真是灌了漿糊了。
言歸正傳,全文結束,對遞歸總結一下:
- 遞歸即是函數對自身的嵌套調用。
- 一般情況下尾部遞歸是不必要的,用循環會更好。
- 用遞歸分析重復過程層次分明,所以最好用先用遞歸分析,然后轉用循環去實現。
說明:
- 程序1,3,4 引自《C和指針》7.5
- 程序2 引自 本校教材《C語言程序設計》7.4
- “堆棧角度” 引自 《C和指針》7.5
date: 2014-12-10