一文教你學會遞歸解題

前言

遞歸是算法中一種很是重要的思想,應用也很廣,小到階乘,再在工做中用到的好比統計文件夾大小,大到 Google 的 PageRank 算法都能看到,也是面試官很喜歡的考點程序員

最近看了很多遞歸的文章,收穫不小,不過我發現大部分網上的講遞歸的文章都不太全面,主要的問題在於解題後大部分都沒有給出相應的時間/空間複雜度,而時間/空間複雜度是算法的重要考量!遞歸算法的時間複雜度廣泛比較難(須要用到概括法等),換句話說,若是能解決遞歸的算法複雜度,其餘算法題題的時間複雜度也基本不在話下。另外,遞歸算法的時間複雜度很多是不能接受的,若是發現算出的時間複雜度過大,則須要轉換思路,看下是否有更好的解法 ,這纔是根本目的,不要爲了遞歸而遞歸!面試

本文試圖從如下幾個方面來說解遞歸算法

  1. 什麼是遞歸?
  2. 遞歸算法通用解決思路
  3. 實戰演練(從初級到高階)

力爭讓你們對遞歸的認知能上一個新臺階,特別會對遞歸的精華:時間複雜度做詳細剖析,會給你們總結一套很通用的求解遞歸時間複雜度的套路,相信你看完確定會有收穫bash

什麼是遞歸

簡單地說,就是若是在函數中存在着調用函數自己的狀況,這種現象就叫遞歸。函數

以階乘函數爲例,以下, 在 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)到最後也解決了,這是「歸」,因此遞歸的本質是能把問題拆分紅具備相同解決思路的子問題,。。。直到最後被拆解的子問題不再能拆分,解決了最小粒度可求解的子問題後,在「歸」的過程當中天然順其天然地解決了最開始的問題。優化

遞歸算法通用解決思路

咱們在上一節仔細剖析了什麼是遞歸,能夠發現遞歸有如下兩個特色ui

  • 一個問題能夠分解成具備相同解決思路的子問題,子子問題,換句話說這些問題都能調用同一個函數
  • 通過層層分解的子問題最後必定是有一個不能再分解的固定值的(即終止條件),若是沒有的話,就無窮無盡地分解子問題了,問題顯然是無解的。

因此解遞歸題的關鍵在於咱們首先須要根據以上遞歸的兩個特色判斷題目是否能夠用遞歸來解。spa

通過判斷能夠用遞歸後,接下來咱們就來看看用遞歸解題的基本套路(四步曲):

  1. 先定義一個函數,明確這個函數的功能,因爲遞歸的特色是問題和子問題都會調用函數自身,因此這個函數的功能一旦肯定了, 以後只要找尋問題與子問題的遞歸關係便可
  2. 接下來尋找問題與子問題間的關係(即遞推公式),這樣因爲問題與子問題具備相同解決思路,只要子問題調用步驟 1 定義好的函數,問題便可解決。所謂的關係最好能用一個公式表示出來,好比 f(n) = n * f(n-) 這樣,若是暫時沒法得出明確的公式,用僞代碼表示也是能夠的, 發現遞推關係後,要尋找最終不可再分解的子問題的解,即(臨界條件),確保子問題不會無限分解下去。因爲第一步咱們已經定義了這個函數的功能,因此當問題拆分紅子問題時,子問題能夠調用步驟 1 定義的函數,符合遞歸的條件(函數裏調用自身)
  3. 將第二步的遞推公式用代碼表示出來補充到步驟 1 定義的函數中
  4. 最後也是很關鍵的一步,根據問題與子問題的關係,推導出時間複雜度,若是發現遞歸時間複雜度不可接受,則需轉換思路對其進行改造,看下是否有更靠譜的解法

聽起來是否是很簡單,接下來咱們就由淺入深地來看幾道遞歸題,看下怎麼用上面的幾個步驟來套

實戰演練(從初級到高階)

熱身賽

輸入一個正整數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)

簡單總結一下:分析問題咱們須要採用自上而下的思惟,而解決問題有時候採用自下而上的方式能讓算法性能獲得極大提高,思路比結論重要

初級題

接下來咱們來看下一道經典的題目: 反轉二叉樹 將左邊的二叉樹反轉成右邊的二叉樹

接下來讓咱們看看用咱們以前總結的遞歸解法四步曲如何解題

  • 定義一個函數,這個函數表明了翻轉以 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;
}
複製代碼
  • 時間複雜度分析 因爲咱們會對每個節點都去作翻轉,因此時間複雜度是 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 柱上去,能夠按如下三步來分析 * 將 上面的 n-1 個圓盤當作是一個圓盤,這樣分析思路就與上面提到的只有兩塊圓盤的思路一致了 * 將上面的  n-1 個圓盤經由 C 移到 B * 此時將 A 底下的那塊最大的圓盤移到 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)) 更復雜的,因此顯然也是指數級別的

總結

大部分遞歸題其實仍是有跡可尋的, 按照以前總結的解遞歸的四個步驟能夠比較順利的解開遞歸題,一些比較複雜的遞歸題咱們須要勤動手,畫畫圖,觀察規律,這樣能幫助咱們快速發現規律,得出遞歸公式,一旦知道了遞歸公式,將其轉成遞歸代碼就容易多了,不少大廠的遞歸考題並不能簡單地看出遞歸規律,每每會在遞歸的基礎上多加一些變形,不過萬遍不離其宗,咱們多采用自頂向下的分析思惟,多練習,相信遞歸不是什麼難事

相關文章
相關標籤/搜索