
導航
- 前言
- 什么是遞歸
- 遞歸算法通用解決思路
- 實戰演練(從初級到高階)
- 熱身賽
- 入門題
- 初級題
- 中級題
- 進階題
- 結語
遞歸是算法中一種非常重要的思想,應用也很廣。
- 有很多數學函數是遞歸定義的,如大家熟悉的階乘函數,2階Fibonacci數列和Ackerman函數。
- 有的數據結構,如二叉樹,廣義表等,由於結構本身固有的遞歸特性,則它們的操作可以遞歸描述。
- 還有一類問題,雖然問題本身沒有明顯的遞歸結構,但用遞歸求解比迭代更為簡單,如八皇后問題,Hanoi塔問題。
實際工作中也經常用到遞歸。小到文件遍歷,樹形菜單,大到Google的PageRank算法都能看到,遞歸也是面試官很喜歡的考點。
前言
最近看了不少遞歸的文章,不過我發現大部分網上講遞歸的文章都不太全面,主要的問題在於解題后大部分都沒有給出相應的時間/空間復雜度,而時間/空間復雜度是算法的重要考量!遞歸算法的時間復雜度普遍比較難(需要用到歸納法等)。換句話說,如果能解決遞歸的算法復雜度,其他算法題題的時間復雜度也基本不在話下。另外,遞歸算法的時間復雜度不少是不能接受的,如果發現算出的時間復雜度過大,則需要轉換思路,看下是否有更好的解法,這才是根本目的,不要為了遞歸而遞歸!
本文試圖從以下幾個方面來講解遞歸:
- 什么是遞歸?
- 遞歸算法通用解決思路
- 實戰演練(從初級到高階)
力爭讓大家對遞歸的認知能上一個新台階,特別會對遞歸的精華:時間復雜度作詳細剖析,會給大家總結一套很通用的求解遞歸時間復雜度的套路,相信你看完肯定會有收獲。
什么是遞歸
一個直接調用自己或者通過一系列的調用語句間接地調用自己的函數,稱作遞歸函數。遞歸是程序設計一個強有力的工具。
以階乘函數為例,如下,在 factorial 函數中存在着 factorial(n - 1) 的調用,所以此函數是遞歸函數
public int factorial(int n) {
if (n < =1) {
return 1;
}
return n * factorial(n - 1)
}
進一步剖析「遞歸」,先有「遞」再有「歸」,「遞」的意思是將問題拆解成子問題來解決, 子問題再拆解成子子問題,...,直到被拆解的子問題無需再拆分成更細的子問題(即可以求解),「歸」是說最小的子問題解決了,那么它的上一層子問題也就解決了,上一層的子問題解決了,上上層子問題自然也就解決了,....,直到最開始的問題解決,文字說可能有點抽象,那我們就以階層f(6)為例來看下它的「遞」和「歸」。是不是有點微積分的思想,哈哈...

求解問題f(6),由於f(6)=n*f(5),所以f(6)需要拆解成f(5)子問題進行求解,同理f(5)= n * f(4)
,也需要進一步拆分,... ,直到 f(1), 這是「遞」,f(1) 解決了,由於 f(2) = 2 f(1) = 2 也解決了,.... f(n)到最后也解決了,這是「歸」,所以遞歸的本質是能把問題拆分成具有相同解決思路的子問題...直到最后被拆解的子問題再也不能拆分,解決了最小粒度可求解的子問題后,在「歸」的過程中自然順其自然地解決了最開始的問題。
遞歸算法通用解決思路
我們在上一節仔細剖析了什么是遞歸,可以發現遞歸有以下兩個特點
- 一個問題可以分解成具有相同解決思路的子問題,子子問題,換句話說這些問題都能調用同一個函數
- 經過層層分解的子問題最后一定是有一個不能再分解的固定值的(即終止條件),如果沒有的話,就無窮無盡地分解子問題了,問題顯然是無解的
所以解遞歸題的關鍵在於我們首先需要根據以上遞歸的兩個特點判斷題目是否可以用遞歸來解。
經過判斷可以用遞歸后,接下來我們就來看看用遞歸解題的基本套路(四步曲):
- 先定義一個函數,明確這個函數的功能,由於遞歸的特點是問題和子問題都會調用函數自身,所以這個函數的功能一旦確定了, 之后只要找尋問題與子問題的遞歸關系即可
- 接下來尋找問題與子問題間的關系(即遞推公式),這樣由於問題與子問題具有相同解決思路,只要子問題調用步驟 1 定義好的函數,問題即可解決。所謂的關系最好能用一個公式表示出來,比如 f(n) = n * f(n-1) 這樣,如果暫時無法得出明確的公式,用偽代碼表示也是可以的, 發現遞推關系后,要尋找最終不可再分解的子問題的解,即(臨界條件),確保子問題不會無限分解下去。由於第一步我們已經定義了這個函數的功能,所以當問題拆分成子問題時,子問題可以調用步驟 1 定義的函數,符合遞歸的條件(函數里調用自身)
- 將第二步的遞推公式用代碼表示出來補充到步驟 1 定義的函數中
- 最后也是很關鍵的一步,根據問題與子問題的關系,推導出時間復雜度,如果發現遞歸時間復雜度不可接受,則需轉換思路對其進行改造,看下是否有更靠譜的解法
聽起來是不是很簡單,接下來我們就由淺入深地來看幾道遞歸題,看下怎么用上面的幾個步驟來套。
實戰演練(從初級到高階)
熱身賽
輸入一個正整數n,輸出n!的值。其中n!=123…n,即求階乘
套用上一節我們說的遞歸四步解題套路來看看怎么解。
- 定義這個函數,明確這個函數的功能,我們知道這個函數的功能是求n的階乘, 之后求n-1,n-2的階乘就可以調用此函數了
/**
* 求 n 的階乘
*/
public int factorial(int n) {
}
- 尋找問題與子問題的關系 階乘的關系比較簡單, 我們以 f(n) 來表示n的階乘,顯然f(n)= n * f(n - 1), 同時臨界條件是 f(1) = 1,即

- 將第二步的遞推公式用代碼表示出來補充到步驟 1 定義的函數中
**
* 求 n 的階乘
*/
public int factorial(int n) {
// 第二步的臨界條件
if (n < =1) {
return 1;
}
// 第二步的遞推公式
return n * factorial(n-1)
}
- 求時間復雜度 由於 f(n) = n * f(n-1) = n * (n-1) * .... * f(1),總共作了n次乘法,所以時間復雜度為 n。
看起來是不是有這么點眉目,當然這道題確實太過簡單,很容易套路,那我們再來看進階一點的題
入門題
一只青蛙可以一次跳 1 級台階或一次跳 2 級台階,例如:
跳上第 1 級台階只有一種跳法:直接跳 1 級即可。跳上第 2 級台階
有兩種跳法:每次跳 1 級,跳兩次;或者一次跳 2 級。
問要跳上第 n 級台階有多少種跳法?
- 定義一個函數,這個函數代表了跳上 n 級台階的跳法
/**
* 跳 n 極台階的跳法
*/
public int f(int n) {
}
尋找問題與子問題之前的關系 這兩者之前的關系初看確實看不出什么頭緒,但仔細看題目,一只青蛙只能跳一步或兩步台階,自上而下地思考,也就是說如果要跳到n級台階只能從n-1 或n-2 級跳, 所以問題就轉化為跳上n-1和n-2級台階的跳法了,如果f(n)代表跳到n級台階的跳法,那么從以上分析可得f(n)=f(n-1)+f(n-2),顯然這就是我們要找的問題與子問題的關系,而顯然當n=1,n=2,即跳一二級台階是問題的最終解,於是遞推公式系為

- 將第二步的遞推公式用代碼表示出來補充到步驟 1 定義的函數中 補充后的函數如下
/**
* 跳 n 極台階的跳法
*/
public int f(int n) {
if (n == 1) return 1;
if (n == 2) return 2;
return f(n-1) + f(n-2)
}
- 計算時間復雜度 由以上的分析可知 f(n) 滿足以下公式

斐波那契的時間復雜度計算涉及到高等代數的知識,這里不做詳細推導,我們直接結出結論

由此可知時間復雜度是指數級別,顯然不可接受,那回過頭來看為啥時間復雜度這么高呢,假設我們要計算f(6),根據以上推導的遞歸公式,展示如下

可以看到有大量的重復計算, f(3)計算了3次,隨着n的增大,f(n)的時間復雜度自然呈指數上升了
- 優化
既然有這么多的重復計算,我們可以想到把這些中間計算過的結果保存起來,如果之后的計算中碰到同樣需要計算的中間態,直接在這個保存的結果里查詢即可,這就是典型的 以空間換時間,改造后的代碼如下
public int f(int n) {
if (n == 1) return 1;
if (n == 2) return 2;
// map 即保存中間態的鍵值對, key 為 n,value 即 f(n)
if (map.get(n)) {
return map.get(n)
}
return f(n-1) + f(n-2)
}
那么改造后的時間復雜度是多少呢,由於對每一個計算過的f(n)我們都保存了中間態,不存在重復計算的問題,所以時間復雜度是O(n),但由於我們用了一個鍵值對來保存中間的計算結果,所以空間復雜度是 O(n)。問題到這里其實已經算解決了,但身為有追求的程序員,我們還是要問一句,空間復雜度能否繼續優化?
使用循環迭代來改造算法 我們在分析問題與子問題關系(f(n) = f(n-1) + f(n-2))的時候用的是自頂向下的分析方式,但其實我們在解 f(n) 的時候可以用自下而上的方式來解決,通過觀察我們可以發現以下規律
f(1) = 1
f(2) = 2
f(3) = f(1) + f(2) = 3
f(4) = f(3) + f(2) = 5
....
f(n) = f(n-1) + f(n-2)
最底層f(1),f(2)的值是確定的,之后的f(3),f(4),...等問題都可以根據前兩項求解出來,一直到f(n)。所以我們的代碼可以改造成以下方式
public int f(int n) {
if (n == 1) return 1;
if (n == 2) return 2;
int result = 0;
int pre = 1;
int next = 2;
for (int i = 3; i < n + 1; i ++) {
result = pre + next;
pre = next;
next = result;
}
return result;
}
改造后的時間復雜度是 O(n), 而由於我們在計算過程中只定義了兩個變量(pre,next),所以空間復雜度是O(1)
簡單總結一下:分析問題我們需要采用自上而下的思維,而解決問題有時候采用自下而上的方式能讓算法性能得到極大提升,思路比結論重要
初級題
接下來我們來看下一道經典的題目:反轉二叉樹 將左邊的二叉樹反轉成右邊的二叉樹

接下來讓我們看看用我們之前總結的遞歸解法四步曲如何解題
public static class TreeNode {
int val;
TreeNode left;
TreeNode right;
TreeNode(int x) { val = x; }
}
public TreeNode invertTree(TreeNode root) {
}
- 定義一個函數,這個函數代表了翻轉以 root 為根節點的二叉樹
public static class TreeNode {
int val;
TreeNode left;
TreeNode right;
TreeNode(int x) { val = x; }
}
public TreeNode invertTree(TreeNode root) {
}
- 查找問題與子問題的關系,得出遞推公式 我們之前說了,解題要采用自上而下的思考方式,那我們取前面的1,2,3結點來看,對於根節點1來說,假設2,3結點下的節點都已經翻轉,那么只要翻轉2,3節點即滿足需求

對於2,3結點來說,也是翻轉其左右節點即可,依此類推,對每一個根節點,依次翻轉其左右節點,所以我們可知問題與子問題的關系是 翻轉(根節點) = 翻轉(根節點的左節點)+翻轉(根節點的右節點) 即
invert(root) = invert(root->left) + invert(root->right)
而顯然遞歸的終止條件是當結點為葉子結點時終止(因為葉子節點沒有左右結點
- 將第二步的遞推公式用代碼表示出來補充到步驟 1 定義的函數中
public TreeNode invertTree(TreeNode root) {
// 葉子結果不能翻轉
if (root == null) {
return null;
}
// 翻轉左節點下的左右節點
TreeNode left = invertTree(root.left);
// 翻轉右節點下的左右節點
TreeNode right = invertTree(root.right);
// 左右節點下的二叉樹翻轉好后,翻轉根節點的左右節點
root.right = left;
root.left = right;
return root;
}
4.時間復雜度分析 由於我們會對每一個節點都去做翻轉,所以時間復雜度是O(n),那么空間復雜度呢,這道題的空間復雜度非常有意思,我們一起來看下,由於每次調用invertTree函數都相當於一次壓棧操作,那最多壓了幾次棧呢,仔細看上面函數的下一段代碼
TreeNode left = invertTree(root.left);
從根節點出發不斷對左結果調用翻轉函數,直到葉子節點,每調用一次都會壓棧,左節點調用完后,出棧,再對右節點壓棧....,下圖可知棧的大小為3,即樹的高度,如果是完全二叉樹,則樹的高度為logn,即空間復雜度為O(logn)

最壞情況,如果此二叉樹是如圖所示(只有左節點,沒有右節點),則樹的高度即結點的個數n,此時空間復雜度為 O(n),總的來看,空間復雜度為O(n)

說句題外話,這道題當初曾引起轟動,因為Mac下著名包管理工具homebrew的作者Max Howell 當初解不開這道題,結果被 Google 拒了,也就是說如果你解出了這道題,就超越了這位世界大神,想想是不是很激動
中級題
接下來我們看一下大學時學過的漢諾塔問題:如下圖所示,從左到右有A、B、C三根柱子,其中A柱子上面有從小疊到大的n個圓盤,現要求將A柱子上的圓盤移到C柱子上去,期間只有一個原則:一次只能移到一個盤子且大盤子不能在小盤子上面,求移動的步驟和移動的次數

接下來套用我們的遞歸四步法看下這題怎么解
- 定義問題的遞歸函,明確函數的功能,我們定義這個函數的功能為:把A上面的n個圓盤經由B移到C
// 將 n 個圓盤從 a 經由 b 移動到 c 上
public void hanoid(int n, char a, char b, char c) {
}
- 查找問題與子問題的關系 首先我們看如果A柱子上只有兩塊圓盤該怎么移

前面我們多次提到,分析問題與子問題的關系要采用自上而下的分析方式,要將n個圓盤經由B移到C柱上去,可以按以下三步來分析
a. 將上面的n-1個圓盤看成是一個圓盤,這樣分析思路就與上面提到的只有兩塊圓盤的思路一致了
b. 將上面的n-1個圓盤經由C移到B,此時將 A 底下的那塊最大的圓盤移到C
c. 再將B上的n-1個圓盤經由A移到C上
有人問第一步的n-1怎么從C移到B,重復上面的過程,只要把上面的n-2個盤子經由A移到B,再把A最下面的盤子移到C,最后再把上面的n-2的盤子經由A移到B下..., 怎么樣,是不是找到規律了,不過在找問題的過程中切忌把子問題層層展開,到漢諾塔這個問題上切忌再分析n-3,n-4 怎么移,這樣會把你繞暈,只要找到一層問題與子問題的關系得出可以用遞歸表示即可。
由以上分析可得
move(n from A to C) = move(n-1 from A to B) + move(A to C) + move(n-1 from B to C`)
一定要先得出遞歸公式,哪怕是偽代碼也好!這樣第三步推導函數編寫就容易很多,終止條件我們很容易看出,當 A上面的圓盤沒有了就不移了
- 根據以上的遞歸偽代碼補充函數的功能
// 將 n 個圓盤從 a 經由 b 移動到 c 上
public void hanoid(int n, char a, char b, char c) {
if (n <= 0) {
return;
}
// 將上面的 n-1 個圓盤經由 C 移到 B
hanoid(n-1, a, c, b);
// 此時將 A 底下的那塊最大的圓盤移到 C
move(a, c);
// 再將 B 上的 n-1 個圓盤經由A移到 C上
hanoid(n-1, b, a, c);
}
public void move(char a, char b) {
printf("%c->%c\n", a, b);
}
從函數的功能上看其實比較容易理解,整個函數定義的功能就是把 A 上的 n 個圓盤 經由 B 移到 C,由於定義好了這個函數的功能,那么接下來的把 n-1 個圓盤 經由 C 移到 B 就可以很自然的調用這個函數,所以明確函數的功能非常重要,按着函數的功能來解釋,遞歸問題其實很好解析,切忌在每一個子問題上層層展開死摳,這樣這就陷入了遞歸的陷阱,計算機都會棧溢出,何況人腦
時間復雜度分析 從第三步補充好的函數中我們可以推斷出
f(n) = f(n-1) + 1 + f(n-1) = 2f(n-1) + 1 = 2(2f(n-2) + 1) + 1 = 2 * 2 * f(n-2) + 2 + 1 = 22 * f(n-3) + 2 + 1 = 22 * f(n-3) + 2 + 1 = 22 * (2f(n-4) + 1) = 23 * f(n-4) + 22 + 1 = .... // 不斷地展開 = 2n-1 + 2n-2 + ....+ 1
顯然時間復雜度為O(2n),很明顯指數級別的時間復雜度是不能接受的,漢諾塔非遞歸的解法比較復雜,大家可以去網上搜一下
進階題
實際面試中,很多遞歸題都不會用上面這些相對比較容易理解的題,更加地是對遞歸問題進行相應地變形,來看下面這道題
細胞分裂 有一個細胞 每一個小時分裂一次,一次分裂一個子細胞,第三個小時后會死亡。那么n個小時候有多少細胞?
照例,我們用前面的遞歸四步曲來解
- 定義問題的遞歸函數,明確函數的功能 我們定義以下函數為n個小時后的細胞數
public int allCells(int n) {
}
- 接下來尋找問題與子問題間的關系(即遞推公式)首先我們看一下一個細胞出生到死亡后經歷的所有細胞分裂過程

圖中的A代表細胞的初始態, B代表幼年態(細胞分裂一次),C代表成熟態(細胞分裂兩次),C再經歷一小時后細胞死亡 以f(n)代表第n小時的細胞分解數fa(n) 代表第n小時處於初始態的細胞數,fb(n)代表第n小時處於幼年態的細胞數fc(n) 代表第 n 小時處於成熟態的細胞數 則顯然 f(n) = fa(n) + fb(n) + fc(n) 那么 fa(n) 等於多少呢,以n = 4 (即一個細胞經歷完整的生命周期)為例
仔細看上面的圖,可以看出 fa(n)=fa(n-1) + fb(n-1) + fc(n-1), 當 n = 1 時,顯然 fa(1) = 1
fb(n) 呢,看下圖可知 fb(n) = fa(n-1)。當 n = 1 時 fb(n) = 0

fc(n)呢,看下圖可知 fc(n) = fb(n-1)。當n = 1,2 時fc(n) = 0

綜上,我們得出的遞歸公式如下

根據以上遞歸公式我們補充一下函數的功能
public int allCells(int n) {
return aCell(n) + bCell(n) + cCell(n);
}
/**
* 第 n 小時 a 狀態的細胞數
*/
public int aCell(int n) {
if(n==1){
return 1;
}else{
return aCell(n-1)+bCell(n-1)+cCell(n-1);
}
}
/**
* 第 n 小時 b 狀態的細胞數
*/
public int bCell(int n) {
if(n==1){
return 0;
}else{
return aCell(n-1);
}
}
/**
* 第 n 小時 c 狀態的細胞數
*/
public int cCell(int n) {
if(n==1 || n==2){
return 0;
}else{
return bCell(n-1);
}
}
只要思路對了,
,另一方面也告訴我們,可能一時的遞歸關系我們看不出來,此時可以借助於畫圖來觀察規律
- 求時間復雜度 由第二步的遞推公式我們知道 f(n) = 2aCell(n-1) + 2aCell(n-2) + aCell(n-3)
之前青蛙跳台階時間復雜度是指數級別的,而這個方程式顯然比之前的遞推公式(f(n) = f(n-1) + f(n-2)) 更復雜的,所以顯然也是指數級別的
結語
大部分遞歸題其實還是有跡可尋的,按照之前總結的解遞歸的四個步驟可以比較順利的解開遞歸題,一些比較復雜的遞歸題我們需要勤動手,畫畫圖,觀察規律,這樣能幫助我們快速發現規律,得出遞歸公式,一旦知道了遞歸公式,將其轉成遞歸代碼就容易多了,很多大廠的遞歸考題並不能簡單地看出遞歸規律,往往會在遞歸的基礎上多加一些變形,不過萬遍不離其宗,我們多采用自頂向下的分析思維,多練習,相信遞歸不是什么難事。
- [x] 好文同步更新到我的站點智客坊
參考

