本文首發於我的公眾號 Linux雲計算網絡(id: cloud_dev) ,專注於干貨分享,號內有 10T 書籍和視頻資源,后台回復 「1024」 即可領取,歡迎大家關注,二維碼文末可以掃。
一:遞歸的思想
之前面試騰訊,面試官問了一個問題:說說遞歸和循環的區別?當時沒有答出問題的本質,只是簡單地解釋了這兩個詞的意思,囧,今天就借由這篇文章來談談自己對遞歸的理解。
我們一般對遞歸的印象就是一個函數反復的“自己調用自己”,代碼精煉,便於閱讀。但是,從本質上來說,遞歸並不是簡單的自己調用自己,而是一種分析和解決問題的方法和思想。簡單來說,遞歸思想就是:把問題分解成規模更小,但和原問題有着相同解法的問題。典型的問題有漢諾塔問題,斐波那契數列,二分查找問題,快速排序問題等。PS:其實像我們常見的分治法和動態規划法都是遞歸思想的經典應用。
既然的遞歸的思想是把問題分解成規模更小但和原問題有着相同解法的問題,那是不是所有具有這樣特性的問題都能用遞歸來解決呢?答案是否定的。除了這個特性,能用遞歸解決的問題還必須具有一個特性:存在一種簡單情境,能讓遞歸在簡單情境下退出,也就是要有一個遞歸出口。總結一下就是,能用遞歸解決的問題,必須滿足以下兩個條件:
- 一個問題能夠分解成規模更小,且與原問題有着相同解的問題;
- 存在一個能讓遞歸調用退出的簡單出口。
比如,階乘問題:fact(n) = n*fact(n-1),當n = 1時,存在簡單情境:fact(1) = 1。斐波那契數列問題:fib(n) = fib(n-1) + fib(n-2),當n = 1和n = 2時,存在簡單情境:fib(1) = 1, fib(2) = 1。上述兩個問題僅存在一種簡單情境,有些問題可能存在兩種以上的簡單情境(在寫代碼時務必都要考慮到),比如:二分查找問題:第一種簡單情境是需要查找的元素與中值元素相等;第二種簡單情境是:待查找的元素沒在序列中,則通過比較待查找元素和最后一個划分的元素來確定結果。
1 bool BinarySearch(int *arr, int n, int key) 2 { 3 4 if (n == 1) //第二種簡單情境 5 return (arr[0] == key); 6 else { 7 int mid = n/2; 8 if (key == arr[mid-1]) //第一種簡單情境 9 return true; 10 else if (key < arr[mid-1]) 11 return BinarySearch(arr, mid, key); 12 else 13 return BinarySearch(arr+mid, n-mid, key); 14 } 15 }
再說一個例子,判斷一個序列是否是回文串(形如“level”, "abba"這樣的字符串),這個問題也可以分解成解相同的子問題(去掉首尾的字符),仔細分析可以看出,同樣也存在兩種遞歸的簡單情境,分別為當字符個數為奇數和偶數的情況下,當n=even時,簡單情境為空字符串,空字符串也是回文串,當n=odd,簡單情境為只有一個字符,同樣也為回文串。
1 //遞歸判斷一個字符串是否為回文串level, abba; 2 bool isPalinString(int n, char* str) 3 { 4 if (n == 1 || n == 0) //兩種簡單情境 5 return true; 6 else return str[0] == str[n-1] ? isPalinString(n-2, str+1): false; 7 }
二:遞歸的效率
遞歸導致一個函數反復調用自己,我們知道函數調用是通過一個工作棧來實現的,在大多數機器上,每次調用函數時大致要做三個工作:調用前先保存寄存器,並在返回時恢復;復制實參;程序必須轉向一個新位置執行。其中,具體要保存的內容包括:局部變量、形參、調用函數地址、返回值。那么,如果遞歸調用N次,就要分配N*局部變量、N*形參、N*調用函數地址、N*返回值。這勢必是影響效率的。在C++中,inline函數就是為了改善函數調用所帶來的效率問題而做的一種優化。遞歸就是利用系統的堆棧保存函數當中的局部變量來解決問題的,說白了就是利用堆棧上的一堆指針指向內存中的對象,並且這些對象一直不被釋放,直到遇到簡單情境時才一一出棧釋放,所以總的開銷就很大。棧空間都是有限的,如果沒有設置好出口,或者調用層級太多,有可能導致棧空間不夠,而出現棧溢出的問題。為了防止無窮遞歸現象,有些語言是規定棧的長度的,比如python語言規定堆棧的長度不能超過1000。還有就是當規模很大的時候,盡量不使用遞歸,而改為非遞歸的形式,或者優化成尾遞歸的形式(后面講)。
與遞歸相關聯的有幾個詞,分別是循環,迭代和遍歷。咋一看,都有重復的意思,但有的好像又不只是重復,具體它們之間有什么區別呢?我的理解是這樣的:
- 遞歸:一個函數反復調用自身的行為,特指函數本身;
- 循環:滿足一定條件下,重復執行某些行為,如while結構;
- 迭代:按某種規則執行一個序列中的每一項,如for結構;
- 遍歷:按某種規則訪問圖形結構中每一個節點,特指圖形結構。
遞歸由於效率低的問題,經常要求轉換成循環結構的非遞歸形式。
三:遞歸轉尾遞歸
有些簡單的遞歸問題,可以不借助堆棧結構而改成循環的非遞歸問題。這里說的簡單,是指可以通過一個簡單的數學公式來進行推導,如階乘問題和斐波那契數列數列問題。這些可以轉換成循環結構的遞歸問題,一般都可以優化成尾遞歸的形式。很多編譯器都能夠將尾遞歸的形式優化成循環的形式。那什么是尾遞歸呢?
我們先討論一個概念:尾調用。顧名思義,一個函數的調用返回都集中在尾部,單個函數調用就是最簡單的尾調用。如果兩個函數調用:函數A調用函數B,當函數B返回時,函數A也返回了。同理,多個函數也是同樣的情況。這就相當於執行完函數B后,函數A也執行完了,從數據結構上看,在執行函數B時,函數A的堆棧已經大部分被函數B修改或替換了,所以,棧空間沒有遞增或者說遞增的程度沒有普通遞歸那么大。這樣在效率上就大大降低了。
尾遞歸就是基於尾調用形式的遞歸,只不過上述的函數B就是函數A本身。可見,尾遞歸其實是將普通遞歸轉換成一種迭代的形式,下一層遞歸所用的棧幀可以與上一層有重疊,局部變量可重復利用,不需要額外消耗棧空間,也沒有push和pop。 這樣就大大減少了遞歸調用棧的開銷。下面舉兩個簡單的例子,看看怎么將遞歸轉換成尾遞歸?
1、階乘函數:fact(n) = n*fact(n-1)
前面說過,尾遞歸其實是具有迭代特性的遞歸,時間復雜度為O(n)。我們可以抓住這個特點進行轉化,既然是迭代形式,那么一定要有迭代的統計步數。我們記錄當統計步數到達臨界條件時,就退出(臨界條件可以有兩種,一種是遞增到n;一種是遞減到簡單情境)。所以,對應就有兩種轉化思路:
思路一:統計步數從簡單情境遞增到n。
int fact(int n, int i, int ret) { if (n < 1) return -1; if (n == 1) return n; else if (i == n) return i*ret; else return fact(n, i + 1, ret*i); }
用5!來舉例子:
fact(5, 1, 1) fact(5, 2, 1) fact(5, 3, 2) fact(5, 4, 6) fact(5, 5, 24)
思路二:統計步數從n遞減到簡單情境。
int fact1(int n, int i, int ret) { if (n == 0) return ret; else return fact1(n-1, i + 1, ret * i); }
fact1(5, 1, 1) fact1(4, 2, 1) fact1(3, 3, 2) fact1(2, 4, 6) fact1(1, 5, 24) fact1(0, 6, 120)
2、斐波那契數列:fib(n) = fib(n-1) + fib(n-2)
同階乘函數,該問題也有兩種轉化思路。
思路一:統計步數從簡單情境遞增到n。
int fib(int n, int i, int pre, int cur) { if (n <= 2) return 1; else if (i == n) return cur; else return fib(n, i + 1, cur, pre+cur); } fib(5, 2, 1, 1) fib(5, 3, 1, 2) fib(5, 4, 2 ,3) fib(5, 5, 3, 5)
思路二:統計步數從n遞減到簡單情境。
int fib1(int i, int pre, int cur) { if (i <= 2) return cur; else return fib1(i-1, cur, pre+cur); } fib1(5, 1, 1) fib1(4, 1, 2) fib1(3, 2, 3) fib1(2, 3, 5)
四:遞歸轉非遞歸
不可否認,遞歸便於算法的理解,代碼精煉,容易閱讀,但遞歸的效率往往是我們最在意的問題。如果能用循環解決遞歸問題,就盡可能使用循環;如果用循環解決不了,或者能解決但代碼很冗長且晦澀,則盡可能使用遞歸。另外,有些低級語言(如匯編)一般不支持遞歸。很多時候我們需要把遞歸轉化成非遞歸形式,這不僅能讓我們加深對遞歸的理解,而且能提升問題解決的效率。這時候就需要掌握一些轉化的技巧,便於我們在用到時信手捏來。
一般來說,遞歸轉化為非遞歸有兩種情況:
第一種情況:正如第三節所說的遞歸轉尾遞歸的問題,這類問題可以不借助堆棧結構將遞歸轉化為循環結構。
第二種情況:借助堆棧將遞歸轉化為非遞歸(PS:任何遞歸都可以借助堆棧轉化成非遞歸,第一種情況嚴格意義上來說不能看做是一種情況)。
其中,第二種情況又可以進一步分為兩種轉化方法:
第一種方法:借助堆棧模擬遞歸的執行過程。這種方法幾乎是通用的方法,因為遞歸本身就是通過堆棧實現的,我們只要把遞歸函數調用的局部變量和相應的狀態放入到一個棧結構中,在函數調用和返回時做好push和pop操作,就可以了(后面有一個模擬快排的例子)。
第二種方法:借助堆棧的循環結構算法。這種方法常常適用於某些局部變量有依賴關系,且需要重復執行的場景,例如二叉樹的遍歷算法,就采用的這種方法。
最后,通過一個用堆棧模擬快排的例子來結束本文。通過一個結構體record來記錄函數的局部變量和相應的狀態。

1 void qsort(int a[],int l,int r){ 2 //boundary case 3 if(l>=r) 4 return; 5 //state 0 6 int mid=partition(a,l,r); 7 qsort(a,l,mid-1); 8 //state 1 9 qsort(a,mid+1,r); 10 //state 2 11 } 12 struct recorc{ 13 int l,r,mid; //local virables. 14 int state; 15 }stack[100000]; 16 17 void nun_recursive_qsort(int a[],int l,int r){ 18 int state=0,top=0; 19 int mid; 20 while(1){ 21 if(l>=r){ //boundary case, return previous level 22 if(top == 0) 23 break;//end of recursion 24 top--; 25 l=stack[top].l;//end of function, update to previous state 26 r=stack[top].r; 27 mid=stack[top].mid; 28 state=stack[top].state; 29 } 30 else if(state == 0){ 31 mid=partition(a,l,r); 32 stack[top].l=l; //recutsive call, push current state into stack 33 stack[top].r=r; 34 stack[top].mid=mid; 35 stack[top].state=1; 36 top++; 37 r=mid-1; 38 state=0; //don't forget to update state value 39 } 40 else if(state == 1){ 41 stack[top].l=l; 42 stack[top].r=r; //recursive call, push current state into stack 43 stack[top].mid=mid; 44 stack[top].state=2; 45 top++; 46 l=mid+1; 47 state=0; 48 } 49 else if(state == 2){ 50 if(top == 0) 51 break; //end of recursion 52 top--; 53 l=stack[top].l; 54 r=stack[top].r; 55 mid=stack[top].mid; 56 state=stack[top].state; 57 } 58 } 59 }
引申:這個地方跟前面的主題沒有直接關系,屬於斐波那契數列的引申問題。在斐波那契數列中,如果兔子永遠不死,一直繁衍下去,則怎么解?很明顯,這是個大數問題,有興趣的同學可以嘗試去寫寫代碼,下面貼上我自己寫的。

1 #include<iostream> 2 using namespace std; 3 const int M=1000; 4 int main() 5 { 6 void func(int n); 7 int n; 8 while((scanf("%d",&n))!=EOF) 9 { 10 if(n<3) 11 cout<<1<<endl; 12 else 13 func(n); 14 cout<<endl; 15 } 16 return 0; 17 } 18 void func(int n) 19 { 20 int a[M]={0},b[M]={0},c[M]={0}; 21 int nCurNum[M]={0},nPreNum[M]={0},nResult[M]={0}; 22 a[M-1]=1; 23 b[M-1]=1; 24 for(int i=0;i<M/2;i++) 25 { 26 nCurNum[i]=a[M-i-1]; 27 nPreNum[i]=b[M-i-1]; 28 } 29 for(int idx=3;idx<=n;idx++) 30 { 31 32 /*for(int i=0;i<M;i++) 33 { 34 nr[i]=nc[i]+np[i]+nr[i]; 35 nr[i+1]=nr[i+1]+nr[i]/10; 36 nr[i]=nr[i]%10; 37 38 }*/ 39 for(int i=0;i<M;i++) 40 { 41 nResult[i]=nCurNum[i]+nPreNum[i];} 42 for(int i=0;i<M;i++) 43 { 44 nResult[i+1]=nResult[i+1]+nResult[i]/10; 45 nResult[i]%=10; 46 } 47 48 for(int i=0;i<M;i++) 49 { 50 nCurNum[i]=nPreNum[i]; 51 nPreNum[i]=nResult[i]; 52 } 53 } 54 for(int i=M-1;i>=0;i--) 55 { 56 c[i]=nResult[M-i-1]; 57 } 58 int k=0; 59 while(!c[k]) k++; 60 for(int i=k;i<M;i++) 61 cout<<c[i]; 62 }
參考:Veda原型:漫談遞歸
我的公眾號 「Linux雲計算網絡」(id: cloud_dev),號內有 10T 書籍和視頻資源,后台回復 「1024」 即可領取,分享的內容包括但不限於 Linux、網絡、雲計算虛擬化、容器Docker、OpenStack、Kubernetes、工具、SDN、OVS、DPDK、Go、Python、C/C++編程技術等內容,歡迎大家關注。