遞迴函式具有很好的可讀性和可維護性,但是大部分情況下程式效率不如非遞迴函式,所以在程式設計中一般喜歡先用遞迴解決問題,在保證方法正確的前提下再轉換為非遞迴函式以提高效率。
函式呼叫時,需要在棧中分配新的幀,將返回地址,呼叫引數和區域性變數入棧。所以遞迴呼叫越深,佔用的棧空間越多。如果層數過深,肯定會導致棧溢位,這也是消除遞迴的必要性之一。遞迴函式又可以分為尾遞迴和非尾遞迴函式,前者往往具有很好的優化效率,下面我們分別加以討論。
尾遞迴函式
尾遞迴函式是指函式的最後一個動作是呼叫函式本身的遞迴函式,是遞迴的一種特殊情形。尾遞迴具有兩個主要的特徵:
1. 呼叫自身函式(Self-called);
2. 計算僅佔用常量棧空間(Stack Space)。
為什麼尾遞迴可以做到常量棧空間,我們用著名的fibonacci數列作為例子來說明。
fibonacci數列實現方法一般是這樣的,
1 2 3 4 5 |
int FibonacciRecur(int n) { if (0==n) return 0; if (1==n) return 1; return FibonacciRecur(n-1)+FibonacciRecur(n-2); } |
不過需要注意的是這種實現方法並不是尾遞迴,因為尾遞迴的最後一個動作必須是呼叫自身,這裡最後的動作是加法運算,所以我們要修改一下,
1 2 3 4 |
int FibonacciTailRecur(int n, int acc1, int acc2) { if (0==n) return acc1; return FibonacciTailRecur(n-1, acc2, acc1+acc2); } |
好了,現在符合尾遞迴的定義了,用gcc分別加-O和-O2選項編譯,下面是部分彙編程式碼,
-O2彙編程式碼
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
FibonacciTailRecur: .LFB12: testl %edi, %edi movl %esi, %eax movl %edx, %esi je .L4 .p2align 4,,7 .L7: leal (%rax,%rsi), %edx decl %edi movl %esi, %eax testl %edi, %edi movl %edx, %esi jne .L7 // use jne .L4: rep ; ret |
-O彙編程式碼
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
FibonacciTailRecur: .LFB2: pushq %rbp .LCFI0: movq %rsp, %rbp .LCFI1: subq $16, %rsp .LCFI2: movl %edi, -4(%rbp) movl %esi, -8(%rbp) movl %edx, -12(%rbp) cmpl $0, -4(%rbp) jne .L2 movl -8(%rbp), %eax movl %eax, -16(%rbp) jmp .L1 .L2: movl -12(%rbp), %eax movl -8(%rbp), %edx addl %eax, %edx movl -12(%rbp), %esi movl -4(%rbp), %edi decl %edi call FibonacciTailRecur //use call movl %eax, -16(%rbp) .L1: movl -16(%rbp), %eax leave ret |
可以看到-O2時用了jne命令,每次呼叫下層遞迴併沒有申請新的棧空間,而是更新當前幀的區域性資料,重複使用當前幀,所以不管有多少層尾遞迴呼叫都不會棧溢位,這也是使用尾遞迴的意義所在。
而-O使用的是call命令,這會申請新的棧空間,也就是說gcc預設狀態下並沒有優化尾遞迴,這麼做的一個主要原因是有時候我們需要保留幀資訊用於除錯,而加-O2優化後,不管多少層尾遞迴呼叫,使用的都是第一層幀,是得不到當前幀的資訊的,大家可以用gdb除錯下就知道了。
除了尾遞迴,Fibonacci數列很容易推匯出迴圈實現方式,
1 2 3 4 5 6 7 8 9 |
int fibonacciNonRecur(int n) { int acc1 = 0, acc2 = 1; for(int i=0; i<n; i++){ int t = acc1; acc1 = acc2; acc2 += t; } return acc1; } |
在我的機器上,全部加-O2選項優化編譯,執行時間如下(單位微秒)
將fibonacci函式的迭代,尾遞迴和遞迴函式效能比較,可以發現迭代和尾遞迴時間幾乎一致,n的大小對迭代和尾遞迴執行時間影響很小,因為只是多執行O(n)條機器指令而已。但是n對遞迴函式影響非常大,這是由於遞迴需要頻繁分配回收棧空間所致。正是由於尾遞迴的高效率,在一些語言如lua中就明確建議使用尾遞迴(參照《lua程式設計第二版》第6章)。
非尾遞迴函式
編譯器無法自動優化一般的遞迴函式,不過通過模擬遞迴函式的過程,我們可以藉助於棧將任何遞迴函式轉換為迭代函式。直觀點,遞迴的過程其實是編譯器幫我們處理了壓棧和出棧的操作,轉換為迭代函式就需要手動地處理壓棧和出棧。
下面我們以經典的快速排序為例子。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
int partition(int *array, int low, int high) { int val = array[low]; while(low < high) { while(low<high && array[high]>=val) --high; swap(&array[low], &array[high]); while(low<high && array[low]<=val) ++low; swap(&array[low], &array[high]); } return low; } void Quicksort(int *array, int b, int e) { if (b >= e) return; int p = partition(array, b, e); Quicksort(array, b, p-1); Quicksort(array, p+1, e); } |
其實不難看出快速排序的遞迴演算法就是一個二叉樹的先序遍歷過程,先處理當前根節點,然後依次處理左子樹和右子樹。將快速排序遞迴演算法轉換為非遞迴相當於將二叉樹先序遍歷遞迴演算法轉為非遞迴演算法。
二叉樹先序遍歷遞迴演算法偽碼
1 2 3 4 5 6 7 |
void PreorderRecursive(Bitree root){ if (root) { visit(root); PreorderRecursive(root->lchild); PreorderRecursive(root->rchild); } } |
二叉樹先序遍歷非遞迴偽碼
1 2 3 4 5 6 7 8 9 10 11 |
void PreorderNonRecursive(Bitree root){ stack stk; stk.push(root); while(!stk.empty()){ p = stk.top(); visit(p); stk.pop(); if(p.rchild) stk.push(stk.rchild); if(p.lchild) stk.push(stk.lchild); } } |
每次處理完當前節點後將右子樹和左子樹分別入棧,類似地,我們也很容易得到快速排序的非遞迴演算法實現。partition將陣列分為左右兩部分,相當與處理當前節點,接下來要做的就是將左右子樹入棧,那麼左右子樹需要儲存什麼資訊呢?這個是處理非遞迴函式的關鍵,因為被呼叫函式資訊需要壓入棧中。快速排序只需要儲存子陣列的邊界即可。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
void QuicksortNonRecur(int *array, int b, int e) { if (b >= e) return; std::stack< std::pair<int, int> > stk; stk.push(std::make_pair(b, e)); while(!stk.empty()) { std::pair<int, int> pair = stk.top(); stk.pop(); if(pair.first >= pair.second) continue; int p = partition(array, pair.first, pair.second); if(p < pair.second) stk.push(std::make_pair(p+1, e)); if(p > pair.first) stk.push(std::make_pair(b, p-1)); } } |
總結
雖然將遞迴函式轉換為迭代函式可以提高程式效率,但是轉換後的迭代函式往往可讀性差,難以理解,不易維護。所以只有在特殊情況下,比如對棧空間有嚴格要求的嵌入式系統,才需要轉換遞迴函式。大部分情況下,遞迴併不會成為系統的效能瓶頸,一個程式碼簡單易讀的遞迴函式常常比迭代函式更易維護。
Reference:
http://en.wikipedia.org/wiki/Tail_Recursion