尾遞歸和編譯器優化


最近看到尾遞歸,所謂的尾遞歸wiki解釋如下:

尾部遞歸是一種編程技巧。遞歸函數是指一些會在函數內調用自己的函數,如果在遞歸函數中,遞歸調用返回的結果總被直接返回,則稱為尾部遞歸。尾部遞歸的函數有助將算法轉化成函數編程語言,而且從編譯器角度來說,亦容易優化成為普通循環。這是因為從電腦的基本面來說,所有的循環都是利用重復移跳到代碼的開頭來實現的。如果有尾部歸遞,就只需要疊套一個堆棧,因為電腦只需要將函數的參數改變再重新調用一次。利用尾部遞歸最主要的目的是要優化,例如在Scheme語言中,明確規定必須針對尾部遞歸作優化。[1][2]可見尾部遞歸的作用,是非常依賴於具體實現的。(http://zh.wikipedia.org/wiki/%E5%B0%BE%E9%80%92%E5%BD%92)

舉個例子,求一個數的階乘:

long FactorialCal(long x)
{
    if(x==1)
        return x;
    return FactorialCal(x-1)*x;
}
long FactorialCal2(int x, int ncount, long lresult)
{
    if(x>ncount)
        return lresult;
    return FactorialCal2(x+1, ncount, x*lresult);
}

FactorialCal就是一般遞歸,而FactorialCal2則是尾遞歸調用,因為這種調用總是在函數末尾執行,並且不會用到調用函數里的任何局部變量。所以有些編譯器對此進行優化,在被調用函數執行時,直接利用調用函數的堆棧,不需要重新開辟堆棧空間,所以一般不會導致遞歸中出現的棧溢出。而一般遞歸因為調用過程中會存儲局部變量,所以調用次數太多時就會發生溢出。但是並不是所有編譯器都會對尾遞歸進行優化,一般在函數式編程語言中會優化(可以參考這篇博文:http://www.cnblogs.com/JeffreyZhao/archive/2009/04/01/tail-recursion-explanation.html)。而我們使用的c,c++編譯器默認不會對此優化,需要指定優化選項編譯器才會主動優化尾遞歸代碼。

這里列舉一個利用遞歸求鏈表長度的例子:

 1 struct Node
 2 {
 3     int data;
 4     Node *pnext;
 5 };
 6 class List
 7 {
 8 private:
 9     Node *m_phead;
10 public:
11     List()
12     {
13         m_phead = new Node;
14         m_phead->data = 0;
15         m_phead->pnext = NULL;
16     }
17     ~List()
18     {
19         //.........;
20     }
21     const Node* GetHead() const
22     {
23         return m_phead;
24     }
25     void add(unsigned ncount)
26     {
27         unsigned index;
28         Node *ptail = m_phead;
29         Node *ptemp = m_phead;
30         while(ptemp!=NULL)
31         {
32             ptail = ptemp;
33             ptemp = ptemp->pnext;
34         }
35         for(index=0; index<ncount; index++)
36         {            
37             ptemp = new Node;
38             ptemp->data = index;
39             ptemp->pnext = NULL;
40             
41             ptail->pnext = ptemp;
42             ptail = ptemp;
43         }
44     }
45 };
46 int GetListLen(const Node *plist)
47 {
48     
49     if(plist == NULL)
50         return 0;
51     return GetListLen(plist->pnext)+1;
52 }
53 int GetListLen2(const Node *plist, int nlen)
54 {
55     if(plist == NULL)
56         return nlen;
57     return GetListLen2(plist->pnext, nlen+1);
58 }
59 int main(int argc, char **argv)
60 {
61     List ltest;
62     ltest.add(100000);
63     
64     //int nresult1 = GetListLen(ltest.GetHead());
65     int nresult2 = 0;
66     nresult2 = GetListLen2(ltest.GetHead(),nresult2);
67     //cout<<"nresult1="<<nresult1<<endl;
68     cout<<"nresult2="<<nresult2<<endl;
69     
70     return 0;
71 }

上面程序的編譯器是g++。List的析構函數這里沒有寫,因為只是想驗證一下,一般遞歸調用方式和尾遞歸調用方式下,編譯器有沒有區別對待。如果編譯器能對尾遞歸進行優化,那么GetListLen2不會產生棧溢出,從而能正確求出鏈表長度。試驗中我們構造了一個擁有1萬個節點的鏈表,而兩種遞歸方式都無法求出其長度(產生棧溢出)。所以編譯器並沒有對尾遞歸進行優化。在平時的編程過程中,盡可能的用循環代替遞歸,可以防止遞歸調用過程中的棧溢出。

 2013/9/2 ps: 感謝一樓的回復,我重新試了一下,對於vs的編譯器,在指定優化選項為-O1及以上時,遞歸深度較大時,尾遞歸不會崩潰,而一般的遞歸就不行了,但是還有一個問題沒有搞清楚,我看了匯編代碼(下圖所示,左邊為優化后的,后邊為沒有優化的,可以看到編譯器只是對寄存器的讀寫進行了優化),發現優化后的匯編里面並沒有將尾遞歸轉化成循環,這很奇怪。另外,在StackOverFlow上有人告訴我,判斷編譯器是否對尾遞歸進行了優化,你可以在遞歸里面查看它的局部變量的地址是否改變,如果改變了就沒有優化,如果沒變則說明優化了,所以我按照這個辦法試了一下,但是發現無論是否優化,尾遞歸里面的局部變量地址都發生了變化。對於gcc編譯器,-O2優化選項會優化尾遞歸。

其他相關內容介紹的博客:

http://blog.csdn.net/liu111qiang88/article/details/9255011

http://blog.csdn.net/gnuhpc/article/details/4368831

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM