遞歸函數具備很好的可讀性和可維護性,可是大部分狀況下程序效率不如非遞歸函數,因此在程序設計中通常喜歡先用遞歸解決問題,在保證方法正確的前提下再轉換爲非遞歸函數以提升效率。html
函數調用時,須要在棧中分配新的幀,將返回地址,調用參數和局部變量入棧。因此遞歸調用越深,佔用的棧空間越多。若是層數過深,確定會致使棧溢出,這也是消除遞歸的必要性之一。遞歸函數又能夠分爲尾遞歸和非尾遞歸函數,前者每每具備很好的優化效率,下面咱們分別加以討論。web
尾遞歸函數算法
尾遞歸函數是指函數的最後一個動做是調用函數自己的遞歸函數,是遞歸的一種特殊情形。尾遞歸具備兩個主要的特徵:數組
1. 調用自身函數(Self-called);函數
2. 計算僅佔用常量棧空間(Stack Space)。oop
爲何尾遞歸能夠作到常量棧空間,咱們用著名的fibonacci數列做爲例子來講明。性能
fibonacci數列實現方法通常是這樣的,優化
int FibonacciRecur(int n) { if (0==n) return 0; if (1==n) return 1; return FibonacciRecur(n-1)+FibonacciRecur(n-2); }
不過須要注意的是這種實現方法並非尾遞歸,由於尾遞歸的最後一個動做必須是調用自身,這裏最後的動做是加法運算,因此咱們要修改一下,ui
int FibonacciTailRecur(int n, int acc1, int acc2) { if (0==n) return acc1; return FibonacciTailRecur(n-1, acc2, acc1+acc2); }
好了,如今符合尾遞歸的定義了,用gcc分別加-O和-O2選項編譯,下面是部分彙編代碼,lua
-O2彙編代碼
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彙編代碼
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數列很容易推導出循環實現方式,
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選項優化編譯,運行時間以下(單位微秒)
n |
fibonacciNonRecur |
FibonacciTailRecur |
FibonacciRecur |
20 |
1 |
1 |
123 |
30 |
1 |
1 |
14144 |
將fibonacci函數的迭代,尾遞歸和遞歸函數性能比較,能夠發現迭代和尾遞歸時間幾乎一致,n的大小對迭代和尾遞歸運行時間影響很小,由於只是多執行O(n)條機器指令而已。可是n對遞歸函數影響很是大,這是因爲遞歸須要頻繁分配回收棧空間所致。正是因爲尾遞歸的高效率,在一些語言如lua中就明確建議使用尾遞歸(參照《lua程序設計第二版》第6章)。
非尾遞歸函數
編譯器沒法自動優化通常的遞歸函數,不過經過模擬遞歸函數的過程,咱們能夠藉助於棧將任何遞歸函數轉換爲迭代函數。直觀點,遞歸的過程實際上是編譯器幫咱們處理了壓棧和出棧的操做,轉換爲迭代函數就須要手動地處理壓棧和出棧。
下面咱們以經典的快速排序爲例子。
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); }
其實不難看出快速排序的遞歸算法就是一個二叉樹的先序遍歷過程,先處理當前根節點,而後依次處理左子樹和右子樹。將快速排序遞歸算法轉換爲非遞歸至關於將二叉樹先序遍歷遞歸算法轉爲非遞歸算法。
二叉樹先序遍歷遞歸算法僞碼
void PreorderRecursive(Bitree root){ if (root) { visit(root); PreorderRecursive(root->lchild); PreorderRecursive(root->rchild); } }
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將數組分爲左右兩部分,至關與處理當前節點,接下來要作的就是將左右子樹入棧,那麼左右子樹須要保存什麼信息呢?這個是處理非遞歸函數的關鍵,由於被調用函數信息須要壓入棧中。快速排序只須要保存子數組的邊界便可。
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