電腦科學 演算法 遞迴

2022-03-14 02:47:10 字數 3863 閱讀 9810

本系列文章在github:steveneco以及warrenryan同步更新

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

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

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

string deliver(int row,string msg)

return deliver(--row,msg);

}

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

int fib(int n)

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)

void postorder(tree tree)

相比於普通的**顯得更加簡潔明瞭。

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

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

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

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

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

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

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

int fib(int n)

; for (int i = 2; i < n; i++)

return rs;

}

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

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

//給你乙個字串,請將其反轉。

//輸入 hello

//輸出 olleh

public static string reverse(string str)

//給你乙個單鏈表,請返回三個一組反轉後單鏈表的表頭

//輸入:1->2->3->4->5

//返回:3->2->1->4->5

class linknode

public linknode next

}public linknode reverse(linknode head)

//使用遞迴計算斐波那契數列

//要求時間複雜度降為o(n)

//tip:驗證時間複雜度可以輸入乙個50000去跑

public int fib(int n)

github

bilibili主頁

電腦科學

電腦科學就是研究計算 如何表示和處理資訊。解決問題 你將學會各種演算法策略,比如分而治之法 遞迴 探索法 貪婪搜尋和隨機演算法,它們可以幫你分解和解決任何一種問題。邏輯 你開始使用更準確和正式的方式進行思考,比如抽象 布林邏輯 數字理論和集合理論,你因此能夠以一種嚴謹的方式來解決問題。資料 你接觸到...

計算機與電腦科學初識

1.為何要學習計算機與計算科學?這是乙個智慧型化與資料化的時代,計算解決自然社會問題,已經成為這個資料時代的基本需求了。而計算機自動化高效處理大量問題這種機器計算也已成為一種常態,為實現機器更好的自動計算,計算科學這門藝術便誕生了。計算科學從一種思維高度來決定我們對於計算的認知,讓我們更好把握計算的...

這是電腦科學

演算法對於計算機的發明和發展,真的是.太重要了。我們永遠都不會忘記,是數學家們的不斷努力,才將計算機的構想變為現實。而計算機也是通過數學 邏輯 運算,幫助人們解決現實問題的。所以能把演算法搞到noi和icpc金銀牌的程度,了不起哉 前途光明哉 但是,也應該看到,電腦科學並不是僅僅有演算法,非演算法的...