C#中的尾遞歸與Continuation詳解
這幾天恰好和朋友談起了遞歸,忽然發(fā)現(xiàn)不少朋友對于“尾遞歸”的概念比較模糊,網(wǎng)上搜索一番也沒有發(fā)現(xiàn)講解地完整詳細的資料,于是寫了這么一篇文章,權(quán)當一次互聯(lián)網(wǎng)資料的補充。:P
遞歸與尾遞歸
關(guān)于遞歸操作,相信大家都已經(jīng)不陌生。簡單地說,一個函數(shù)直接或間接地調(diào)用自身,是為直接或間接遞歸。例如,我們可以使用遞歸來計算一個單向鏈表的長度:
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;
}
在調(diào)用時,GetLengthRecursively方法會不斷調(diào)用自身,直至滿足遞歸出口。對遞歸有些了解的朋友一定猜得到,如果單項鏈表十分長,那么上面這個方法就可能會遇到棧溢出,也就是拋出StackOverflowException。這是由于每個線程在執(zhí)行代碼時,都會分配一定尺寸的棧空間(Windows系統(tǒng)中為1M),每次方法調(diào)用時都會在棧里儲存一定信息(如參數(shù)、局部變量、返回地址等等),這些信息再少也會占用一定空間,成千上萬個此類空間累積起來,自然就超過線程的??臻g了。不過這個問題并非無解,我們只需把遞歸改成如下形式即可(在這篇文章里我們不考慮非遞歸的解法):
public static int GetLengthTailRecursively(Node head, int acc)
{
if (head == null) return acc;
return GetLengthTailRecursively(head.Next, acc + 1);
}
GetLengthTailRecursively方法多了一個acc參數(shù),acc的為accumulator(累加器)的縮寫,它的功能是在遞歸調(diào)用時“積累”之前調(diào)用的結(jié)果,并將其傳入下一次遞歸調(diào)用中——這就是GetLengthTailRecursively方法與GetLengthRecursively方法相比在遞歸方式上最大的區(qū)別:GetLengthRecursive方法在遞歸調(diào)用后還需要進行一次“+1”,而GetLengthTailRecursively的遞歸調(diào)用屬于方法的最后一個操作。這就是所謂的“尾遞歸”。與普通遞歸相比,由于尾遞歸的調(diào)用處于方法的最后,因此方法之前所積累下的各種狀態(tài)對于遞歸調(diào)用結(jié)果已經(jīng)沒有任何意義,因此完全可以把本次方法中留在堆棧中的數(shù)據(jù)完全清除,把空間讓給最后的遞歸調(diào)用。這樣的優(yōu)化1便使得遞歸不會在調(diào)用堆棧上產(chǎn)生堆積,意味著即時是“無限”遞歸也不會讓堆棧溢出。這便是尾遞歸的優(yōu)勢。
有些朋友可能已經(jīng)想到了,尾遞歸的本質(zhì),其實是將遞歸方法中的需要的“所有狀態(tài)”通過方法的參數(shù)傳入下一次調(diào)用中。對于GetLengthTailRecursively方法,我們在調(diào)用時需要給出acc參數(shù)的初始值:
GetLengthTailRecursively(head, 0)
為了進一步熟悉尾遞歸的使用方式,我們再用著名的“菲波納鍥”數(shù)列作為一個例子。傳統(tǒng)的遞歸方式如下:
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);
}
于是在調(diào)用時,需要提供兩個累加器的初始值:
FibonacciTailRecursively(10, 0, 1)
尾遞歸與Continuation
Continuation,即為“完成某件事情”之后“還需要做的事情”。例如,在.NET中標準的APM調(diào)用方式,便是由BeginXXX方法和EndXXX方法構(gòu)成,這其實便是一種Continuation:在完成了BeginXXX方法之后,還需要調(diào)用EndXXX方法。而這種做法,也可以體現(xiàn)在尾遞歸構(gòu)造中。例如以下為階乘方法的傳統(tǒng)遞歸定義:
public static int FactorialRecursively(int n)
{
if (n == 0) return 1;
return FactorialRecursively(n - 1) * n;
}
顯然,這不是一個尾遞歸的方式,當然我們輕易將其轉(zhuǎn)換為之前提到的尾遞歸調(diào)用方式。不過我們現(xiàn)在把它這樣“理解”:每次計算n的階乘時,其實是“先獲取n - 1的階乘”之后再“與n相乘并返回”,于是我們的FactorialRecursively方法可以改造成:
public static int FactorialRecursively(int n)
{
return FactorialContinuation(n - 1, r => n * r);
}
// 6. FactorialContinuation(n, x => x)
public static int FactorialContinuation(int n, Func<int, int> continuation)
{
...
}
FactorialContinuation方法的含義是“計算n的階乘,并將結(jié)果傳入continuation方法,并返回其調(diào)用結(jié)果”。于是,很容易得出,F(xiàn)actorialContinuation方法自身便是一個遞歸調(diào)用:
public static int FactorialContinuation(int n, Func<int, int> continuation)
{
return FactorialContinuation(n - 1,
r => continuation(n * r));
}
FactorialContinuation方法的實現(xiàn)可以這樣表述:“計算n的階乘,并將結(jié)果傳入continuation方法并返回”,也就是“計算n - 1的階乘,并將結(jié)果與n相乘,再調(diào)用continuation方法”。為了實現(xiàn)“并將結(jié)果與n相乘,再調(diào)用continuation方法”這個邏輯,代碼又構(gòu)造了一個匿名方法,再次傳入FactorialContinuation方法。當然,我們還需要為它補充遞歸的出口條件:
public static int FactorialContinuation(int n, Func<int, int> continuation)
{
if (n == 0) return continuation(1);
return FactorialContinuation(n - 1,
r => continuation(n * r));
}
很明顯,F(xiàn)actorialContinuation實現(xiàn)了尾遞歸。如果要計算n的階乘,我們需要如下調(diào)用FactorialContinuation方法,表示“計算10的階乘,并將結(jié)果直接返回”:
FactorialContinuation(10, x => x)
再加深一下印象,大家是否能夠理解以下計算“菲波納鍥”數(shù)列第n項值的寫法?
public static int FibonacciContinuation(int n, Func<int, int> continuation)
{
if (n < 2) return continuation(n);
return FibonacciContinuation(n - 1,
r1 => FibonacciContinuation(n - 2,
r2 => continuation(r1 + r2)));
}
在函數(shù)式編程中,此類調(diào)用方式便形成了“Continuation Passing Style(CPS)”。由于C#的Lambda表達式能夠輕松構(gòu)成一個匿名方法,我們也可以在C#中實現(xiàn)這樣的調(diào)用方式。您可能會想——汗,何必搞得這么復(fù)雜,計算階乘和“菲波納鍥”數(shù)列不是一下子就能轉(zhuǎn)換成尾遞歸形式的嗎?不過,您試試看以下的例子呢?
對二叉樹進行先序遍歷(pre-order traversal)是典型的遞歸操作,假設(shè)有如下TreeNode類:
public class TreeNode
{
public TreeNode(int value, TreeNode left, TreeNode right)
{
this.Value = value;
this.Left = left;
this.Right = right;
}
public int Value { get; private set; }
public TreeNode Left { get; private set; }
public TreeNode Right { get; private set; }
}
于是我們來傳統(tǒng)的先序遍歷一下:
public static void PreOrderTraversal(TreeNode root)
{
if (root == null) return;
Console.WriteLine(root.Value);
PreOrderTraversal(root.Left);
PreOrderTraversal(root.Right);
}
您能用“普通”的方式將它轉(zhuǎn)換為尾遞歸調(diào)用嗎?這里先后調(diào)用了兩次PreOrderTraversal,這意味著必然有一次調(diào)用沒法放在末尾。這時候便要利用到Continuation了:
public static void PreOrderTraversal(TreeNode root, Action<TreeNode> continuation)
{
if (root == null)
{
continuation(null);
return;
}
Console.WriteLine(root.Value);
PreOrderTraversal(root.Left,
left => PreOrderTraversal(root.Right,
right => continuation(right)));
}
我們現(xiàn)在把每次遞歸調(diào)用都作為代碼的最后一次操作,把接下來的操作使用Continuation包裝起來,這樣就實現(xiàn)了尾遞歸,避免了堆棧數(shù)據(jù)的堆積。可見,雖然使用Continuation是一個略有些“詭異”的使用方式,但是在某些時候它也是必不可少的使用技巧。
Continuation的改進
看看剛才的先序遍歷實現(xiàn),您有沒有發(fā)現(xiàn)一個有些奇怪的地方?
PreOrderTraversal(root.Left,
left => PreOrderTraversal(root.Right,
right => continuation(right)));
關(guān)于最后一步,我們構(gòu)造了一個匿名函數(shù)作為第二次PreOrderTraversal調(diào)用的Continuation,但是其內(nèi)部直接調(diào)用了continuation參數(shù)——那么我們?yōu)槭裁床恢苯影阉唤o第二次調(diào)用呢?如下:
PreOrderTraversal(root.Left,
left => PreOrderTraversal(root.Right, continuation));
我們使用Continuation實現(xiàn)了尾遞歸,其實是把原本應(yīng)該分配在棧上的信息丟到了托管堆上。每個匿名方法其實都是托管堆上的對象,雖然說這種生存周期短的對象不會對內(nèi)存資源方面造成多大問題,但是盡可能減少此類對象,對于性能肯定是有幫助的。這里再舉一個更為明顯的例子,求二叉樹的大?。⊿ize):
public static int GetSize(TreeNode root, Func<int, int> continuation)
{
if (root == null) return continuation(0);
return GetSize(root.Left,
leftSize => GetSize(root.Right,
rightSize => continuation(leftSize + rightSize + 1)));
}
GetSize方法使用了Continuation,它的理解方法是“獲取root的大小,再將結(jié)果傳入continuation,并返回其調(diào)用結(jié)果”。我們可以將其進行改寫,減少Continuation方法的構(gòu)造次數(shù):
public static int GetSize2(TreeNode root, int acc, Func<int, int> continuation)
{
if (root == null) return continuation(acc);
return GetSize2(root.Left, acc,
accLeftSize => GetSize2(root.Right, accLeftSize + 1, continuation));
}
GetSize2方法多了一個累加器參數(shù),同時它的理解方式也有了變化:“將root的大小累加到acc上,再將結(jié)果傳入continuation,并返回其調(diào)用結(jié)果”。也就是說GetSize2返回的其實是一個累加值,而并非是root參數(shù)的實際尺寸。當然,我們在調(diào)用時GetSize2時,只需將累加器置零便可:
GetSize2(root, 0, x => x)
不知您清楚了嗎?
結(jié)束
在命令式編程中,我們解決一些問題往往可以使用循環(huán)來代替遞歸,這樣便不會因為數(shù)據(jù)規(guī)模造成堆棧溢出。但是在函數(shù)式編程中,要實現(xiàn)“循環(huán)”的唯一方法便是“遞歸”,因此尾遞歸和CPS對于函數(shù)式編程的意義非常重大。了解尾遞歸,對于編程思維也有很大幫助,因此大家不妨多加思考和練習(xí),讓這樣的方式為自己所用。
注1:事實上,在C#中,即使您實現(xiàn)了尾遞歸,編譯器(包括C#編譯器及JIT)也不會進行優(yōu)化,也就是說還是無法避免StackOverflowException。我會在不久之后單獨討論一下這方面問題。
相關(guān)文章
C#事件中的兩個參數(shù)詳解(object sender,EventArgs e)
這篇文章主要介紹了C#事件中的兩個參數(shù)詳解(object sender,EventArgs e),具有很好的參考價值,希望對大家有所幫助。如有錯誤或未考慮完全的地方,望不吝賜教2022-09-09