一文學懂動態規劃

前言

在以前的一篇文章詳解遞歸的正確打開方式中,咱們詳細講解了經典的斐波那契數列問題從遞歸到 DP 的優化過程,java

$$ f(n) = f(n-1)+f(n-2) $$算法

class Solution {
    public int fib(int N) {
        if (N == 0) {
            return 0;
        } else if (N == 1) {
            return 1;
        }
        return fib(N-1) + fib(N-2);
    }
}

體會了遞歸的思想,數組

即:函數

遞歸的實質是可以把一個大問題分解成比它小點的問題,而後咱們拿到了小問題的解,就能夠用小問題的解去構造大問題的解oop

但缺點就是隨着 n 值的增大,遞歸樹 Recursion Tree 變的愈來愈深,相應須要計算的節點也愈來愈多。優化

recursion tree

且好多節點值進行了重複的計算,經過分析咱們知道其時間複雜度爲:spa

$$ O(2^n) $$code

指數級別的時間複雜度對超算來講都是噩夢...遞歸

上一篇講遞歸的文章咱們也給出了相應優化的方案,即用一個數組,最後只用兩個變量來保存計算過的節點結果。ci

class Solution {
    public int fib(int N) {
        int a = 0;
        int b = 1;
        if(N == 0) {
            return a;
        }
        if(N == 1) {
            return b;
        }
        for(int i = 2; i <= N; i++) {
            int tmp = a + b;
            a = b;
            b = tmp;
        }
        return b;
    }
}

這樣咱們瞬間將時間複雜度降到了 O(n),空間複雜度也變成了 O(1)。(具體時空複雜度分析請戳這裏)

思路分析

那麼回顧一下咱們怎麼作的呢?

咱們將自頂向下的遞歸,變成了自底向上的 for loop。

咱們將每次計算出來的節點值予以保存,這樣就再也不須要重複計算一些已經計算過的節點,這也稱爲剪枝 pruning

這樣咱們便引出了動態規劃的核心思想:

動態規劃(英語:Dynamic programming,簡稱 DP)是一種經過把原問題分解爲相對簡單的子問題的方式求解複雜問題的方法。

動態規劃經常適用於有重疊子問題最優子結構性質的問題,動態規劃方法所耗時間每每遠少於樸素解法。

動態規劃背後的基本思想很是簡單。

大體上,若要解一個給定問題,咱們須要解其不一樣部分(即子問題),再根據子問題的解以得出原問題的解。

一般許多子問題很是類似,爲此動態規劃法試圖僅僅解決每一個子問題一次,從而減小計算量:一旦某個給定子問題的解已經算出,則將其[記憶化存儲,以便下次須要同一個子問題解之直接查表。這種作法在重複子問題的數目關於輸入的規模呈指數增加時特別有用。

好多人說動態規劃是處理複雜問題優化算法的二向箔,而我想說,

說的沒毛病...

小插曲:

你們應該都聽過 NP=?P 問題,這是美國克雷數學研究院百萬美金懸賞的七個千僖數學難題之首(關於 NP 問題我以後還會詳細的寫文章描述)。

那這跟咱們今天說的動態規劃有什麼關係呢?

動態規劃有一類典型的問題叫揹包問題的題目,而揹包問題就是典型的NPC問題(非肯定性多項式完備問題 _Non-deterministic Polynomial_),這個你們先作個瞭解,只是一個引子,咱們提到這些,無非是要說明動態規劃做爲能處理 NPC 問題的最優化算法仍是屬實有點東西的。(固然這跟數學證實 NP=?P 是兩碼事,畢竟頭號千禧難題尚未被攻破...)因此你們必定先要學好動態規劃呀~

動態規劃核心

回到上文描述的動態規劃定義,咱們根據定義提煉出動態規劃最主要的核心,即:

  1. 重疊子問題
  2. 最優子結構

這裏我再加個

  1. 狀態轉移方程

1. 重疊子問題

根據斐波那契數列的例子,咱們知道,當前的數只與它前面兩個數有關,即文章開頭的

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

咱們要想求出 f(n),就得求出 f(n-1)和 f(n-2)

這個就是屬於重疊子問題,即:

將一個問題拆成幾個子問題,分別求解這些子問題,便可推斷出大問題的解。

這裏捎帶強調一個概念:

無後效性:要求出 f(n),只需求出 f(n-1)和 f(n-2)的值,而 f(n-1)和 f(n-2)是如何算出來的,對以後的問題沒有影響,即「將來與過去無關」,這就是無後效性。

上述的重疊子問題子問題也必須知足無後效性這個概念。

2. 最優子結構

那麼什麼是最優子結構呢?

最優即最值,斐波那契數列例子裏並無說起和最值相關的字眼,故該例子嚴格來講並不能算徹底的動態規劃問題。

下面咱們介紹另外一個經典例題 :

零錢兌換

給定不一樣面額的硬幣 coins 和一個總金額 amount。計算能夠湊成總金額所需的最少的硬幣個數。若是沒有任何一種硬幣組合能組成總金額,返回 -1。

能夠認爲每種硬幣的數量是無限的。

示例 1:

輸入:coins = [1, 2, 5], amount = 11
輸出:3
解釋:11 = 5 + 5 + 1

示例 2:

輸入:coins = [2], amount = 3
輸出:-1

這個題乍一看大概思路好像是:那咱們就每次先找出最大面額的硬幣試試呀,而後在總面額裏減去,再依次取到較小的面額,直到湊夠 amount(貪心思路)。

這個想法好像看似徹底可行,但若是給你如下這組數據呢?

示例 3:

輸入:coins = [1, 5, 11], amount = 15
輸出:3
解釋:15 = 5 + 5 + 5

可是若是咱們沿用上述貪心算法

輸出:5

解釋:11 + 1 + 1 + 1 + 1

然後一種得出的結果顯而錯誤,因此咱們發現貪心算法對此題不一樣的數據竟不是一通百通,因此說明咱們這種策略不對。

那策略哪裏出問題了呢?

貪心只顧眼前,先找最大面額 11,而忽略了後續找 4 個 1 塊硬幣的代價,1 + 4 總共須要 5 枚,貪心算法在這種問題面前就有點鼠目寸光了...

而這個題就是典型的動態規劃問題,下面咱們進一步分析:

上文提到,動態規劃最核心的條件和性質就只有三個,咱們依次按這三點開始分析。

1. 重疊子問題

思考:要湊出 15 塊錢,咱們能不能先湊出 15 塊錢以前(比當前問題更小的問題)的事情?

那麼小問題是什麼呢?

咱們發現,面額分別爲 1,5,11。

  1. 咱們能夠先湊出 11 塊(此時咱們已經用了一枚 11 塊的硬幣,硬幣數+1),而後再湊出 15 - 11 = 4 塊。

    假設 f(n)表示湊出 n 元所須要的最少硬幣數, 那麼這樣咱們湊出 15 塊所須要的硬幣總數爲 f(15) = f(4) + 1

  2. 咱們也能夠先湊出 5 塊,而後再湊出 15 - 5 = 10 塊。

    f(15) = f(10) + 1

  3. 咱們也能夠先湊出 1 塊,而後再湊出 15 - 1 = 14 塊。

    f(15) = f(14) + 1

咱們發現想要湊出一個較大數目的金額,能夠先湊出較小數目的金額。

這不就是重疊子問題嗎?

將一個問題拆成幾個子問題,分別求解這些子問題,便可推斷出大問題的解。

咱們進一步發現,這些子問題一樣知足無後效性,即我先湊出 11 塊,還剩 4 塊要湊,我即將湊出 4 塊的策略與你已經湊出 11 塊的策略並不存在半毛錢的關係。。

即上文所說的:「將來與過去無關」,這就是無後效性。

2.最優子結構

由上咱們發現:

f(n) 只與 f(n-1)f(n-5)f(n-11) 的值相關。

而後題目是求:問湊出金額所需最少的硬幣數量。

咱們的 f(n) 也是這麼定義的:f(n) 表示湊出 n 元所須要的最少硬幣數。

即根據 f(n - 1),f(n - 5),f(n - 11) 的最優解,咱們便可獲得 f(15) 的最優解。

大問題的最優解能夠由小問題的最優解獲得,這不就是最優子結構性質嗎?

根據以上咱們就能夠瓜熟蒂落的寫出動態規劃問題裏最難寫出的狀態轉移方程

3.狀態轉移方程

$$ f(n) = min[f(n -1),f(n -5),f(n - 11)] + 1 $$

聽上去高大上,實則,就這???

沒錯,就這。

細心的讀者會發現:這不就跟 斐波那切數列 的遞推公式

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

相似嗎?

對,它們倆大致上就是一個東西,即:遞歸方程

遞歸表明着重複,重複,再重複...

說白了計算機天生就是乾重復事情的,這也是屬於計算機惟一的美,暴力美,之因此計算機看似那麼「聰明」,實則是人類智慧的結晶在告訴計算機:你應該的暴力,優雅的暴力,而不是直接暴力的暴力,這就是算法的力量。

因此,當咱們循序漸進的分析出動態規劃問題的前兩個性質,寫出狀態轉移方程其實也不怎麼難,因此也不要被任何高大上的術語嚇到,盤它就完事兒了。

而後回憶咱們前文的內容,斐波那切數列的例子,咱們如何將暴力的遞歸改形成優雅的動態規劃的呢?

  • 將自頂向下的遞歸,變成了自底向上的 for loop。
  • 將每次計算出來的節點值予以保存。

將斐波那切數列稍加改造,咱們便可寫出此題的代碼。

Fibonacci 例子中,咱們用 notes[n] 來表示輸入 n 時的返回值答案,這裏咱們統一用 dp table.

即用 dp[amount] 表示:當輸入金額爲 amount 時,可兌換的最少硬幣數。

因此咱們首先先建立一個 dp table 用來存儲對應解,即:

int[] dp = new int[amount+1];

爲何大小是amount + 1不是amount呢?

答:由於咱們 dp[amount] 的含義是當金額爲amount時,湊出amount的最少硬幣數量。

假設amount = 10,若是咱們 new 一個 size 爲 10 的數組,那麼咱們取 dp[amount] 時就越界了,故當咱們要取到 dp[amount] 時,數組大小得爲 int[amount + 1]。

而後將

dp[0] = 0;

解釋:當金額爲0元時,找出0個硬幣。

緊接着最主要的問題來了,也是最難寫的一部分代碼,前文提到說,將遞歸改成動態規劃最顯著的一個特色是:

咱們將自頂向下的遞歸,變成了自底向上的 for loop。

自頂向下很好寫,由於直接遞歸嘛:

// Fibonacci
public int fib(int N) {
        if (N == 0) {
            return 0;
        } else if (N == 1) {
            return 1;
        }
        return fib(N-1) + fib(N-2);
    }

咱們只須要在 base case 處判斷,並返回相應的值,而後直接進行遞歸函數調用。

那麼咱們如何轉變成動態規劃,自底向上呢?
這個時候就須要 for 循環(簡單但又強大的 for loop)。

上文裏咱們已經得出了狀態轉移方程:

$$ f(n) = min[f(n -1),f(n -5),f(n - 11)] + 1 $$

假設一組數據是這樣:

coins = {1,2,5,7,10} amount = 14

首先咱們創建 dp table:

int[] dp = int[15];

初始化 dp[0] = 0

咱們考慮這樣自底向上 寫:

用變量 i 從 1 循環至 amount,依次計算金額 1 至 amount 的最優解,即 dp[amount]。

咱們能夠寫出第一層 for 循環:

for(int i = 1;i <= amount;i++){

            ...

}

而後:對於每一個金額 i,使用變量 j 遍歷硬幣面值 coins[] 數組:

對於全部小於等於 i 的面值 coins[j],找出最小的 i - coins[j] 金額的最優解 dp[i - coins[j]]。

那麼 dp[i] 的最優解即爲 dp[i - coins[j]] + 1。

以下圖所示:

假如當前 i 指向金額 6

對於全部小於等於 6 的面額 coins[j],即coins[0],coins[1],coins[2] 分別爲 1,2,5。

找出最小的 6 - coins[j] 金額的最優解 dp[i - coins[j]]

6 - 1 = 5 dp[5] = 1

6 - 2 = 4 dp[4] = 2

6 - 5 = 1 dp[1] = 1

那麼 dp[i]的最優解即爲 dp[i - coins[j]] + 1

dp[6] = min(dp[1],dp[4],dp[5]) + 1

由以上可知:dp[6] = 1 + 1 = 2

後續依次計算...

由此,咱們能夠寫出裏層的 for 循環:

//來一個整型最大數,保證其它數第一次和這個數比較時都比這個數小
//變量名叫min,這是對應外層i循環,即:求每一個dp[i]的最優解
int min = Integer.MAX_VALUE;
for(int j = 0;j < coins.length;j++){
    //全部小於等於i的面值coins[j],而且最優解小於默認最大值
    if(coins[j] <= i && dp[i - coins[j]] < min){
            min = dp[i - coins[j]] + 1;//更新dp
    }
}
    dp[i] = min;

這個 for 循環,這也是此題思想代碼的核心。

咱們將兩個 for 循環寫一塊兒,即寫出了完整代碼:

class Solution {
  public int coinChange(int[] coins, int amount) {
      if(coins.length == 0) return -1;
      int[] dp = new int[amount + 1];
      dp[0] = 0;
      for(int i = 1;i <= amount;i++){
        int min = Integer.MAX_VALUE;
        for(int j = 0;j < coins.length;j++){
          if(coins[j] <= i && dp[i - coins[j]] < min){
            min = dp[i - coins[j]] + 1;
          }
        }
        dp[i] = min;
      }
      return dp[amount] == Integer.MAX_VALUE ? -1 : dp[amount];
  }
}

這就是自底向上的寫法,也是動態規劃的核心。

總結

咱們再回顧整個流程,如未嘗試去處理一個動態規劃問題?

  1. 首先分析這個問題符不符合動態規劃最重要的前兩個性質(重疊子問題,最優子結構);
  2. 若是知足前兩個性質,那麼咱們嘗試寫出狀態轉移方程,也即遞歸式
  3. 優化:將自頂向下的遞歸式(_函數調用_)改成自底向上動態規劃(_for loop_)

好了,今天的動態規劃問題到此就結束啦,固然動態規劃的威力還遠不止於此,關於動態規劃更多的內容,咱們後續再見~

堅持看到這兒的小夥伴,必定要給本身點個贊呀~

相關文章
相關標籤/搜索