看動畫輕鬆理解「遞歸」與「動態規劃」

在學習「數據結構和算法」的過程當中,由於人習慣了平鋪直敘的思惟方式,因此「遞歸」與「動態規劃」這種帶循環概念(繞來繞去)的每每是相對比較難以理解的兩個抽象知識點。html

程序員小吳打算使用動畫的形式來幫助理解「遞歸」,而後經過「遞歸」的概念延伸至理解「動態規劃」算法思想。程序員

什麼是遞歸

先下定義:遞歸算法是一種直接或者間接調用自身函數或者方法的算法。算法

通俗來講,遞歸算法的實質是把問題分解成規模縮小的同類問題的子問題,而後遞歸調用方法來表示問題的解。它有以下特色:數組

    1. 一個問題的解能夠分解爲幾個子問題的解
    1. 這個問題與分解以後的子問題,除了數據規模不一樣,求解思路徹底同樣
    1. 存在遞歸終止條件,即必須有一個明確的遞歸結束條件,稱之爲遞歸出口

遞歸動畫

經過動畫一個一個特色來進行分析。數據結構

1.一個問題的解能夠分解爲幾個子問題的解

子問題就是相對與其前面的問題數據規模更小的問題。數據結構和算法

在動圖中①號問題(一塊大區域)劃分爲②號問題,②號問題由兩個子問題(兩塊中區域)組成。ide

2. 這個問題與分解以後的子問題,除了數據規模不一樣,求解思路徹底同樣

「①號劃分爲②號」與「②號劃分爲③號」的邏輯是一致的,求解思路是同樣的。函數

3. 存在遞歸終止條件,即存在遞歸出口

把問題分解爲子問題,把子問題再分解爲子子問題,一層一層分解下去,不能存在無限循環,這就須要有終止條件。學習

①號劃分爲②號,②號劃分爲③號,③號劃分爲④號,劃分到④號的時候每一個區域只有一個不能劃分的問題,這就代表存在遞歸終止條件。優化

從遞歸的經典示例開始

一.數組求和

數組求和

Sum(arr[0...n-1]) = arr[0] + Sum(arr[1...n-1])

後面的 Sum 函數要解決的就是比前一個 Sum 更小的同一問題。

Sum(arr[1...n-1]) = arr[1] + Sum(arr[2...n-1])

以此類推,直到對一個空數組求和,空數組和爲 0 ,此時變成了最基本的問題。

Sum(arr[n-1...n-1] ) = arr[n-1] + Sum([])

二.漢諾塔問題

漢諾塔(Hanoi Tower)問題也是一個經典的遞歸問題,該問題描述以下:

漢諾塔問題:古代有一個梵塔,塔內有三個座A、B、C,A座上有64個盤子,盤子大小不等,大的在下,小的在上。有一個和尚想把這個盤子從A座移到B座,但每次只能容許移動一個盤子,而且在移動過程當中,3個座上的盤子始終保持大盤在下,小盤在上。

兩個盤子

三個盤子

  • ① 若是隻有 1 個盤子,則不須要利用 B 塔,直接將盤子從 A 移動到 C 。
  • ② 若是有 2 個盤子,能夠先將盤子 2 上的盤子 1 移動到 B ;將盤子 2 移動到 C ;將盤子 1 移動到 C 。這說明了:能夠藉助 B 將 2 個盤子從 A 移動到 C ,固然,也能夠藉助 C 將 2 個盤子從 A 移動到 B 。
  • ③ 若是有 3 個盤子,那麼根據 2 個盤子的結論,能夠藉助 C 將盤子 3 上的兩個盤子從 A 移動到 B ;將盤子 3 從 A 移動到 C ,A 變成空座;藉助 A 座,將 B 上的兩個盤子移動到 C 。

  

  • ④ 以此類推,上述的思路能夠一直擴展到 n 個盤子的狀況,將將較小的 n-1個盤子看作一個總體,也就是咱們要求的子問題,以藉助 B 塔爲例,能夠藉助空塔 B 將盤子A上面的 n-1 個盤子從 A 移動到 B ;將A 最大的盤子移動到 C , A 變成空塔;藉助空塔 A ,將 B 塔上的 n-2 個盤子移動到 A,將 C 最大的盤子移動到 C, B 變成空塔。。。

三.爬臺階問題

問題描述:

一我的爬樓梯,每次只能爬1個或2個臺階,假設有n個臺階,那麼這我的有多少種不一樣的爬樓梯方法?

先從簡單的開始,以 4 個臺階爲例,能夠經過每次爬 1 個臺階爬完樓梯:

每次爬 1 個臺階

能夠經過先爬 2 個臺階,剩下的每次爬 1 個臺階爬完樓梯

先爬 2 個臺階

在這裏,能夠思考一下:能夠根據第一步的走法把全部走法分爲兩類:

  • ① 第一類是第一步走了 1 個臺階
  • ② 第二類是第一步走了 2 個臺階

因此 n 個臺階的走法就等於先走 1 階後,n-1 個臺階的走法 ,而後加上先走 2 階後,n-2 個臺階的走法。

用公式表示就是:

f(n) = f(n-1)+f(n-2)

有了遞推公式,遞歸代碼基本上就完成了一半。那麼接下來考慮遞歸終止條件。

當有一個臺階時,咱們不須要再繼續遞歸,就只有一種走法。

因此 f(1)=1

經過用 n = 2n = 3 這樣比較小的數試驗一下後發現這個遞歸終止條件還不足夠。

n = 2 時,f(2) = f(1) + f(0)。若是遞歸終止條件只有一個 f(1) = 1,那 f(2) 就沒法求解,遞歸沒法結束。
因此除了 f(1) = 1 這一個遞歸終止條件外,還要有 f(0) = 1,表示走 0 個臺階有一種走法,從思惟上以及動圖上來看,這顯得的有點不符合邏輯。因此爲了便於理解,把 f(2) = 2 做爲一種終止條件,表示走 2 個臺階,有兩種走法,一步走完或者分兩步來走。

總結以下:

  • ① 假設只有一個臺階,那麼只有一種走法,那就是爬 1 個臺階
  • ② 假設有兩個個臺階,那麼有兩種走法,一步走完或者分兩步來走

遞歸終止條件

經過遞歸條件:

f(1) = 1;
f(2) = 2;
f(n) = f(n-1)+f(n-2)

很容易推導出遞歸代碼:

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

經過上述三個示例,總結一下如何寫遞歸代碼:

  • 1.找到如何將大問題分解爲小問題的規律
  • 2.經過規律寫出遞推公式
  • 3.經過遞歸公式的臨界點推敲出終止條件
  • 4.將遞推公式和終止條件翻譯成代碼

什麼是動態規劃

介紹動態規劃以前先介紹一下分治策略(Divide and Conquer)。

分治策略

將原問題分解爲若干個規模較小但相似於原問題的子問題(Divide),「遞歸」的求解這些子問題(Conquer),而後再合併這些子問題的解來創建原問題的解。

由於在求解大問題時,須要遞歸的求小問題,所以通常用「遞歸」的方法實現,即自頂向下。

動態規劃(Dynamic Programming)

動態規劃其實和分治策略是相似的,也是將一個原問題分解爲若干個規模較小的子問題,遞歸的求解這些子問題,而後合併子問題的解獲得原問題的解。
區別在於這些子問題會有重疊,一個子問題在求解後,可能會再次求解,因而咱們想到將這些子問題的解存儲起來,當下次再次求解這個子問題時,直接拿過來就是。
其實就是說,動態規劃所解決的問題是分治策略所解決問題的一個子集,只是這個子集更適合用動態規劃來解決從而獲得更小的運行時間。
即用動態規劃能解決的問題分治策略確定能解決,只是運行時間長了。所以,分治策略通常用來解決子問題相互對立的問題,稱爲標準分治,而動態規劃用來解決子問題重疊的問題。

與「分治策略」「動態規劃」概念接近的還有「貪心算法」「回溯算法」,因爲篇幅限制,程序員小吳就不在這進行展開,在後續的文章中將分別詳細的介紹「貪心算法」、「回溯算法」、「分治算法」,敬請關注:)

將「動態規劃」的概念關鍵點抽離出來描述就是這樣的:

  • 1.動態規劃法試圖只解決每一個子問題一次
  • 2.一旦某個給定子問題的解已經算出,則將其記憶化存儲,以便下次須要同一個子問題解之時直接查表。

從遞歸到動態規劃

仍是以 爬臺階 爲例,若是以遞歸的方式解決的話,那麼這種方法的時間複雜度爲O(2^n),具體的計算能夠查看筆者以前的文章 《冰與火之歌:時間複雜度與空間複雜度》。

相同顏色表明着 爬臺階問題 在遞歸計算過程當中重複計算的部分。

爬臺階的時間複雜度

經過圖片能夠發現一個現象,咱們是 自頂向下 的進行遞歸運算,好比:f(n)f(n-1)f(n-2)相加,f(n-1)f(n-2)f(n-3)相加。

思考一下:若是反過來,採起自底向上,用迭代的方式進行推導會怎麼樣了?

下面經過表格來解釋 f(n)自底向上的求解過程。

臺階數 1 2 3 4 5 6 7 8 9
走法數 1 2

表格的第一行表明了樓梯臺階的數目,第二行表明了若干臺階對應的走法數。
其中f(1) = 1f(2) = 2是前面明確的結果。

第一次迭代,若是臺階數爲 3 ,那麼走法數爲 3 ,經過 f(3) = f(2) + f(1)得來。

臺階數 1 2 3 4 5 6 7 8 9
走法數 1 2 3

第二次迭代,若是臺階數爲 4 ,那麼走法數爲 5 ,經過 f(4) = f(3) + f(2)得來。

臺階數 1 2 3 4 5 6 7 8 9
走法數 1 2 3 5

因而可知,每一次迭代過程當中,只須要保留以前的兩個狀態,就能夠推到出新的狀態。

show me the code
int f(int n) {
    if (n == 1) return 1;
    if (n == 2) return 2;
    // a 保存倒數第二個子狀態數據,b 保存倒數第一個子狀態數據, temp 保存當前狀態的數據
    int a = 1, b = 2;
    int temp = a + b;
    for (int i = 3; i <= n; i++) {
        temp = a + b;
        a = b;
        b = temp; 
    }
    return temp; 
}

程序從 i = 3 開始迭代,一直到 i = n 結束。每一次迭代,都會計算出多一級臺階的走法數量。迭代過程當中只需保留兩個臨時變量 a 和 b ,分別表明了上一次和上上次迭代的結果。爲了便於理解,引入了temp變量。temp表明了當前迭代的結果值。

看一看出,事實上並無增長太多的代碼,只是簡單的進行了優化,時間複雜度便就降爲O(n),而空間複雜度也變爲O(1),這,就是「動態規劃」的強大!

詳解動態規劃

「動態規劃」中包含三個重要的概念:

  • 【最優子結構】
  • 【邊界】
  • 【狀態轉移公式】

在「 爬臺階問題 」中

f(10) = f(9) + f(8) 是【最優子結構】
f(1) 與 f(2) 是【邊界】
f(n) = f(n-1) + f(n-2) 【狀態轉移公式】

「 爬臺階問題 」 只是動態規劃中相對簡單的問題,由於它只有一個變化維度,若是涉及多個維度的話,那麼問題就變得複雜多了。

難點就在於找出 「動態規劃」中的這三個概念。

好比「 國王和金礦問題 」。

國王和金礦問題

有一個國家發現了 5 座金礦,每座金礦的黃金儲量不一樣,須要參與挖掘的工人數也不一樣。參與挖礦工人的總數是 10 人。每座金礦要麼全挖,要麼不挖,不能派出一半人挖取一半金礦。要求用程序求解出,要想獲得儘量多的黃金,應該選擇挖取哪幾座金礦?
 5 座金礦

找出 「動態規劃」中的這三個概念

國王和金礦問題中的【最優子結構】

國王和金礦問題中的【最優子結構】

國王和金礦問題中的【最優子結構】有兩個:

  • ① 4 金礦 10 工人的最優選擇
  • ② 4 金礦 (10 - 5) 工人的最優選擇

4 金礦的最優選擇與 5 金礦的最優選擇之間的關係是

MAX[(4 金礦 10 工人的挖金數量),(4 金礦 5 工人的挖金數量 + 第 5 座金礦的挖金數量)]

國王和金礦問題中的【邊界】

國王和金礦問題中的【邊界】 有兩個:

  • ① 當只有 1 座金礦時,只能挖這座惟一的金礦,獲得的黃金數量爲該金礦的數量
  • ② 當給定的工人數量不夠挖 1 座金礦時,獲取的黃金數量爲 0
國王和金礦問題中的【狀態轉移公式】

咱們把金礦數量設爲 N,工人數設爲 W,金礦的黃金量設爲數組G[],金礦的用工量設爲數組P[],獲得【狀態轉移公式】:

  • 邊界值:F(n,w) = 0 (n <= 1, w < p[0])
  • F(n,w) = g[0] (n==1, w >= p[0])
  • F(n,w) = F(n-1,w) (n > 1, w < p[n-1])
  • F(n,w) = max(F(n-1,w), F(n-1,w-p[n-1]) + g[n-1]) (n > 1, w >= p[n-1])

國王和金礦問題中的【實現】

先經過幾幅動畫來理解 「工人」 與 「金礦」 搭配的方式

1.只挖第一座金礦

只挖第一座金礦

在只挖第一座金礦前面兩個工人挖礦收益爲 零,當有三個工人時,纔開始產生收益爲 200,然後即便增長再多的工人收益不變,由於只有一座金礦可挖。

2.挖第一座與第二座金礦

挖第一座與第二座金礦

在第一座與第二座金礦這種狀況中,前面兩個工人挖礦收益爲 零,由於 W < 3,因此F(N,W) = F(N-1,W) = 0。

當有 三 個工人時,將其安排挖第 一 個金礦,開始產生收益爲 200。

當有 四 個工人時,挖礦位置變化,將其安排挖第 二 個金礦,開始產生收益爲 300。

當有 5、六 個工人時,因爲多於 四 個工人的人數不足以去開挖第 一 座礦,所以收益仍是爲 300。

當有 七 個工人時,能夠同時開採第 一 個和第 二 個金礦,開始產生收益爲 500。

3.挖前三座金礦

這是「國王和金礦」 問題中最重要的一個動畫之一,能夠多看幾遍
挖前三座金礦

4.挖前四座金礦

這是「國王和金礦」 問題中最重要的一個動畫之一,能夠多看幾遍

挖前四座金礦

國王和金礦問題中的【規律】

仔細觀察上面的幾組動畫能夠發現:

  • 對比「挖第一座與第二座金礦」和「挖前三座金礦」,在「挖前三座金礦」中,3 金礦 7 工人的挖礦收益,來自於 2 金礦 7 工人和 2 金礦 4 工人的結果,Max(500,300 + 350) = 650;
  • 對比「挖前三座金礦」和「挖前四座金礦」,在「挖前四座金礦」中,4 金礦 10 工人的挖礦收益,來自於 3 金礦 10 工人和 3 金礦 5 工人的結果,Max(850,400 + 300) = 850;

國王和金礦問題中的【動態規劃代碼】

代碼來源:https://www.cnblogs.com/SDJL/archive/2008/08/22/1274312.html

//maxGold[i][j] 保存了i我的挖前j個金礦可以獲得的最大金子數,等於 -1 時表示未知
int maxGold[max_people][max_n];

int GetMaxGold(int people, int mineNum){
    int retMaxGold;                            //聲明返回的最大金礦數量
    //若是這個問題曾經計算過
    if(maxGold[people][mineNum] != -1){
        retMaxGold = maxGold[people][mineNum]; //得到保存起來的值
    }else if(mineNum == 0) {                   //若是僅有一個金礦時 [ 對應動態規劃中的"邊界"]
        if(people >= peopleNeed[mineNum])      //當給出的人數足夠開採這座金礦
            retMaxGold = gold[mineNum];        //獲得的最大值就是這座金礦的金子數
        else                                   //不然這惟一的一座金礦也不能開採
            retMaxGold = 0;                    //獲得的最大值爲 0 個金子
    }else if(people >= peopleNeed[mineNum])    // 若是人夠開採這座金礦[對應動態規劃中的"最優子結構"]
    {
        //考慮開採與不開採兩種狀況,取最大值
        retMaxGold = max(
                         GetMaxGold(people - peopleNeed[mineNum],mineNum - 1) + gold[mineNum],
                         GetMaxGold(people,mineNum - 1)
                         );
    }else//不然給出的人不夠開採這座金礦 [ 對應動態規劃中的"最優子結構"]
    {
        retMaxGold = GetMaxGold(people,mineNum - 1);     //僅考慮不開採的狀況
        maxGold[people][mineNum] = retMaxGold;
    }
    return retMaxGold;
}

動態規劃代碼

但願經過這篇文章,你們能對「遞歸」與「動態規劃」有必定的理解。後續將以「動態規劃」爲基礎研究多重揹包算法、迪杰特斯拉算法等更高深的算法問題,同時「遞歸」的更多概念也會在「分治算法」章節再次延伸,敬請對程序員小吳保持關注:)

相關文章
相關標籤/搜索