尾遞歸

關於遞歸操作,相信大家都已經不陌生。簡單地說,一個函數直接或間接地調用自身,是爲直接或間接遞歸。例如,我們可以使用遞歸來計算一個單向鏈表的長度:

public class Node
{
    public Node(int value, Node next)
    {
        this.Value = value;
        this.Next = next;
    }

    public int Value { get; private set; }

    public Node Next { get; private set; }
}

編寫一個遞歸的GetLength方法:

public static int GetLengthRecursively(Node head)
{
    if (head == null) return 0;
    return GetLengthRecursively(head.Next) + 1;
}

在調用時,GetLengthRecursively方法會不斷調用自身,直至滿足遞歸出口。對遞歸有些瞭解的朋友一定猜得到,如果單項鍊表十分長,那麼上面這個方法就可能會遇到棧溢出,也就是拋出StackOverflowException。這是由於每個線程在執行代碼時,都會分配一定尺寸的棧空間(Windows系統中爲1M),每次方法調用時都會在棧裏儲存一定信息(如參數、局部變量、返回地址等等),這些信息再少也會佔用一定空間,成千上萬個此類空間累積起來,自然就超過線程的棧空間了。不過這個問題並非無解,我們只需把遞歸改成如下形式即可(在這篇文章裏我們不考慮非遞歸的解法):

public static int GetLengthTailRecursively(Node head, int acc)
{
    if (head == null) return acc;
    return GetLengthTailRecursively(head.Next, acc + 1);
}

GetLengthTailRecursively方法多了一個acc參數,acc的爲accumulator(累加器)的縮寫,它的功能是在遞歸調用時“積累”之前調用的結果,並將其傳入下一次遞歸調用中——這就是GetLengthTailRecursively方法與GetLengthRecursively方法相比在遞歸方式上最大的區別:GetLengthRecursive方法在遞歸調用後還需要進行一次“+1”,而GetLengthTailRecursively的遞歸調用屬於方法的最後一個操作。這就是所謂的“尾遞歸”。與普通遞歸相比,由於尾遞歸的調用處於方法的最後,因此方法之前所積累下的各種狀態對於遞歸調用結果已經沒有任何意義,因此完全可以把本次方法中留在堆棧中的數據完全清除,把空間讓給最後的遞歸調用。這樣的優化1便使得遞歸不會在調用堆棧上產生堆積,意味着即時是“無限”遞歸也不會讓堆棧溢出。這便是尾遞歸的優勢。

有些朋友可能已經想到了,尾遞歸的本質,其實是將遞歸方法中的需要的“所有狀態”通過方法的參數傳入下一次調用中。對於GetLengthTailRecursively方法,我們在調用時需要給出acc參數的初始值:

GetLengthTailRecursively(head, 0)

爲了進一步熟悉尾遞歸的使用方式,我們再用著名的“菲波納鍥”數列作爲一個例子。傳統的遞歸方式如下:

public static int FibonacciRecursively(int n)
{
    if (n < 2) return n;
    return FibonacciRecursively(n - 1) + FibonacciRecursively(n - 2);
}

而改造成尾遞歸,我們則需要提供兩個累加器:

public static int FibonacciTailRecursively(int n, int acc1, int acc2)
{
    if (n == 0) return acc1;
    return FibonacciTailRecursively(n - 1, acc2, acc1 + acc2);
}

於是在調用時,需要提供兩個累加器的初始值:

FibonacciTailRecursively(10, 0, 1)
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章