【電腦科學】演算法——遞迴

WarrenRyan發表於2020-03-06

遞迴

本系列文章在Github:StevenEco以及WarrenRyan同步更新

簡介

程式呼叫自身的程式設計技巧稱為遞迴 (recursion) 。遞迴做為一種演算法在程式設計語言中廣泛應用。 一個過程或函式在其定義或說明中有直接或間接呼叫自身的一種方法,它通常把一個大型複雜的問題層層轉化為一個與原問題相似的規模較小的問題來求解,遞迴策略只需少量的程式就可描述出解題過程所需要的多次重複計算,大大地減少了程式的程式碼量。遞迴的能力在於用有限的語句來定義物件的無限集合。一般來說,遞迴需要有邊界條件、遞迴前進段和遞迴返回段。當邊界條件不滿足時,遞迴前進;當邊界條件滿足時,遞迴返回。

小例子

看著很抽象?那麼我們舉一個具體的例子:假設有一天,你正在學校上課,你坐在最後一排,突然你有一件重要的事情需要和第一排的同學進行溝通,你又不能隨意走動,那麼你應該怎麼解決呢?於是你寫了一個小紙條,給你前面的同學,並且告訴他轉交給第一排的同學,於是前排同學又將小紙條遞給了他的前排,迴圈往復,直到第一排的同學收到小紙條。第一排的同學看完小紙條,寫了他要對你說的話,於是他又將紙條遞給他的後座,一直遞到你為止。這個小例子就是遞迴的本質思想,你的小紙條就是引數,而傳遞的過程,事實上都是在執行傳遞函式的本身。

如果用程式語言來體現剛才的小例子,那麼程式碼就是

string Deliver(int row,string msg)
{
    if(row == 1)
    {
        return "Read:" + msg;
    }
    return Deliver(--row,msg);
}

再舉一個例子,斐波那契數列是一個很常見的數列,它的通項公式是 \(f(n+2) = f(n) + f(n+1)\),我們可以發現,它並沒有提及斐波那契數列的表示式,而是給了一個抽象的函式遞推式,那麼這個時候我們就可以使用遞迴,將問題簡化成一個遞推的內容而不是具體的實現。用程式碼則是

int fib(int n)
{
    if(n == 1 || n == 2)
    {
        return 1
    }
    return fib(n-1) + fib(n-2);
}

通常,遞迴必須擁有遞推式和跳出條件,因為這可以保證函式不會爆棧,我們要從三個角度去做一個遞迴:

  • 遞迴的定義:接受什麼引數,返回什麼值,代表什麼意思 。當函式直接或者間接調⽤⾃⼰時,則發⽣了遞迴
  • 遞迴的拆解:每次遞迴都是為了讓問題規模變⼩
  • 遞迴的出⼝:必須有⼀個明確的結束條件。因為遞迴就是有“遞”有“歸”,所以必須又有一個明確的點,到了這個點,就不用“遞下去”,而是開始“歸來”。

總而言之,遞迴就是儘可能忽略函式內部的實現,主要關注函式整體需要做的事情。

遞迴的本質

透過上述的小例子,你可能已經理解了遞迴的含義,但是為什麼透過函式呼叫函式這種“詭異”的操作可以實現我們的內容呢?如果你在閱讀本篇文章之前已經有了一些基礎的資料結構和程式語言知識,那麼你會知道函式的呼叫是在棧中實現的,當函式巢狀呼叫時,系統會將這些函式壓入棧中,而棧是先進後出的性質,那麼當遞迴呼叫時,會一次性將函式壓棧到可以return的那個子函式,然後子函式執行完畢返回後,再將返回值帶給父函式,再執行父函式。也就是說,遞迴其實就是一個隱式的棧。

透過這個進棧出棧的過程,一個大的抽象問題就被分解成了若干個巢狀的子問題,子問題一層一層被解決,直到最後一個起始層。

簡單的解釋就是,遞迴事實上也是兩個問題

遞:將問題不斷細化直到最小,例如斐波那契數列的問題,fib(5)在程式中的遞大致是

fib(5) = fib(4) + fib(3);
fib(5) = (fib(3) + fib(2)) + (fib(2) + fib(1))
fib(5) = ((fib(2) + fib(1)) + fib(2)) + (fib(2) + fib(1));

歸過程就是將上述遞過程的子問題逐步返回到頂層。

整個過程和我們往第一排傳紙條再傳回來是完全一致的。

遞迴的用途

我們會發現遞迴非常的節省程式碼,而且看起來似乎也沒有空間損耗。但真的是這樣的嗎?答案肯定是否的。誠然,遞迴會讓程式碼的簡潔程度和可讀性大幅上漲(可讀性上升,但是並不容易被理解和Debug),但是遞迴也並不是什麼時候都是好的。

首先遞迴最常用的地方就是連結串列、樹、圖等含指標的資料結構的操作和計算,因為在這種地方,使用佇列、棧等輔助的資料結構會使得程式碼非常長,並且對於許多演算法羸弱的碼農並不容易寫出來。例如樹的中序遍歷,對於非遞迴的方法,你需要藉助棧,並且嚴格的需要保證入棧順序。而對於後序遍歷,你可能還需要藉助雜湊表來保證左右節點已經被訪問,這顯然不好。對於遞迴,只有短短的幾行

void InOrder(Tree tree)
{
    if (tree == null)
        return;
    InOrder(tree.Left);
    Console.WriteLine(tree.Value);
    InOrder(tree.Right);
}
void PostOrder(Tree tree)
{
    if (tree == null)
        return;
    PostOrder(tree.Left);
    PostOrder(tree.Right);
    Console.WriteLine(tree.Value);
}

相比於普通的程式碼顯得更加簡潔明瞭。

自頂向下與自底向上

但是有時候遞迴會造成嚴重的效能問題,尤其會導致棧溢位的問題,事實上函式本身壓棧是並不消耗什麼空間的,因為本身只是一個指標,並不需要儲存任何內容。但是存在返回值的時候,函式需要將返回值儲存,因此一同申請空間。當函式棧過深的時候,儲存的子函式的返回值也會越來越多,你可以試試將上述斐波那契數列的程式碼引數設定為一個很大的數字,你會發現程式非常慢,並且有可能會導致棧溢位從而強制退出。因為你從上述分析的遞迴過程你會發現,有些函式被重複運算了,例如fib(2)就被計算了多次,而這是不需要的。因此浪費了時間和空間。

自頂向下

啥是自頂向下的方法?頂就是頂層任務,也就是我們的預期結果,向下就是指分解成小任務。自頂向下就是講大任務拆解成若干小任務,隨後將小任務組合起來的過程。

通常來說自頂向下有時會造成嚴重的效能問題,例如我們舉的例字,假設你只是想讓第一排的同學把橡皮給你,資訊卻傳遞了整整一個來回。假設第一排的同學一開始就知道要把橡皮給你,那麼就能節省不少時間。

事實上對於斐波那契數列而言,我們並不關心他的前面項的結果,並且在前文的敘述中你也發現了有重複計算的問題。例如fib(10)的值,你完全沒有必要關心fib(5)之類的是多少,你只需要關心fib(8)+fib(9)而已,因此對於fib(5)的值你也是完全沒有必要壓棧的。遞迴的斐波那契數列時間複雜度達到了驚人的\(O(2^n)\),空間也用了\(S(n)\)

假設一個任務可以拆分成互相不干擾,沒有直接聯絡的多個子任務,那麼自頂向下的方法則是最優的方法,例如樹的遍歷,對於一個節點而言,它的兄弟節點必然不會是他的子節點(子函式的結果),那麼你就可以大膽的用自頂向下的遞迴。而對於斐波那契數列,你會發現他的子任務顯然會建立聯絡,那麼自頂向下的方法必然會導致重複的運算,甚至爆棧。

自底向上

為了解決子任務相關聯導致的自頂向下的效能問題,我們引出自底向上的方法。自底向上則是將最小的子任務往大任務組合,這樣就不會有重複計算的過程,因為子任務組合過程是單向的。

對於下面這個改良版的斐波那契數列,儘管程式碼顯得並不是那麼可讀和方便,但是時間複雜度卻降到了\(O(n)\),並且只使用了常數個的空間。顯然我們的複雜度下降了。

int fib(int n)
{
    int rs = 0;
    int[] temp = new int []{ 1, 1 };
    for (int i = 2; i < n; i++)
    {
        rs = temp[0] + temp[1];
        temp[0] = temp[1];
        temp[1] = rs;
    }
    return rs;
}

並且對於斐波那契數列這種存在通項公式的遞迴,使用通項公式會使得你的時間複雜度進一步下降至\(O(logn)\)以下。因此可見遞迴雖好,但可不要濫用。

但是自底向上並不是任何時候都是有效的,例如最小子任務不可知的情況下,樹還是一個很好的例子,對於樹的葉子結點,在父節點未知的情況下必然無法確定,因此自底向上失效。

小題目

為了加深各位對遞迴的理解,這裡選取了幾個使用遞迴解決的小題目,希望你能獨立解決難題,答案將會在文末解析。請使用遞迴解決嗷!你可以將程式碼在評論中留下,我會仔細審閱,輸入特殊用例來判斷你的正確性。

  • Code1 - 反轉字串:
//給你一個字串,請將其反轉。
//輸入 Hello
//輸出 olleH
public static string Reverse(string str)
{
}
  • Code2 - 三個一組交換單連結串列
//給你一個單連結串列,請返回三個一組反轉後單連結串列的表頭
//輸入:1->2->3->4->5
//返回:3->2->1->4->5
class LinkNode
{
    public int Value { get; set;}
    public LinkNode Next { get; set;}
}
public LinkNode Reverse(LinkNode head)
{
}
  • Code3 - 斐波那契數列
//使用遞迴計算斐波那契數列
//要求時間複雜度降為O(n)
//Tip:驗證時間複雜度可以輸入一個50000去跑
public int Fib(int n)
{
}

如果我的文章幫助了你,請幫我點個贊,給個star,關注三連走一波。

Github

BiliBili主頁

部落格園

相關文章