一文學會動態規劃解題技巧

  • 2020 年 2 月 25 日
  • 筆記

來源:碼海

作者:碼海

對於動態規劃地文章,我之前也寫過兩篇,在知乎收割了4k+的贊,很多人都通過我那兩篇文章學會了動態規劃以及如何優化,沒看過地可以看,也可以看完今天這一篇再去看:

告別動態規劃,連刷40道動規演算法題,我總結了動規的套路

動態規劃該如何優化?我總結了這些套路,以後優化就是分分鐘

不過有時候看一篇文章默默糊糊,多看幾篇就會慢慢深入懂得了,今天也給大家分享一篇動態規劃地文章,正文如下:

動態規劃(dynamic programming,簡稱 dp)是工程中非常重要的解決問題的思想,從我們在工程中地圖軟體上應用的最短路徑問題,再在生活中的在淘寶上如何湊單以便利用滿減券來最大程度地達到我們合理薅羊毛的目的 ,很多時候都能看到它的身影。不過動態規劃對初學者來說確實比較難,dp狀態,狀態轉移方程讓人摸不著頭腦,網上很多人也回饋不太好學,其實就像我們之前學 【超詳細】一文學會遞歸解題那樣,任何演算法的學習都是有它的規律和套路的,只要掌握好它的規律及解題的套路,再加上大量的習題練習,相信掌握它不是什麼難事,本文將會用比較淺顯易懂地講解來幫助大家掌握動態規劃這一在工程中非常重要的思想,相信看完後,動態規劃的解題套路一定能手到擒來(文章有點長,建議先收藏再看,看完後一定會對動態規劃的認知上升到一個台階!)

本文將會從以下角度來講解動態規劃:

  • 什麼是動態規劃
  • 動態規劃從入門到進階
  • 再談動態規劃

什麼是動態規劃

以下是我綜合了動態規劃的特點給出的動態規劃的定義:動態規劃是一種多階段決策最優解模型,一般用來求最值問題,多數情況下它可以採用自下而上的遞推方式來得出每個子問題的最優解(即最優子結構),進而自然而然地得出依賴子問題的原問題的最優解。

劃重點:

  1. 多階段決策,意味著問題可以分解成子問題,子子問題,。。。,也就是說問題可以拆分成多個子問題進行求解
  2. 最優子結構,在自下而上的遞推過程中,我們求得的每個子問題一定是全局最優解,既然它分解的子問題是全局最優解,那麼依賴於它們解的原問題自然也是全局最優解。
  3. 自下而上,怎樣才能自下而上的求出每個子問題的最優解呢,可以肯定子問題之間是有一定聯繫的,即迭代遞推公式,也叫「狀態轉移方程」,要定義好這個狀態轉移方程, 我們就需要定義好每個子問題的狀態(DP 狀態),那為啥要自下而上地求解呢,因為如果採用像遞歸這樣自頂向下的求解方式,子問題之間可能存在大量的重疊,大量地重疊子問題意味著大量地重複計算,這樣時間複雜度很可能呈指數級上升(在下文中我們會看到多個這樣重複的計算導致的指數級的時間複雜度),所以自下而上的求解方式可以消除重疊子問題。

簡單總結一下,最優子結構,狀態轉移方程,重疊子問題就是動態規劃的三要素,這其中定義子問題的狀態與寫出狀態轉移方程是解決動態規劃最為關鍵的步驟,狀態轉移方程如果定義好了,解決動態規劃就基本不是問題了。

既然我們知道動態規劃的基本概念及特徵,那麼怎麼判斷題目是否可以用動態規劃求解呢,其實也很簡單,當問題的定義是求最值問題,且問題可以採用遞歸的方式,並且遞歸的過程中有大量重複子問題的時候,基本可以斷定問題可以用動態規劃求解,於是我們得出了求解動態規劃基本思路如下(解題四步曲)

  1. 判斷是否可用遞歸來解,可以的話進入步驟 2
  2. 分析在遞歸的過程中是否存在大量的重複子問題
  3. 採用備忘錄的方式來存子問題的解以避免大量的重複計算(剪枝)
  4. 改用自底向上的方式來遞推,即動態規劃

畫外音:遞歸怎麼求解,強烈建議看下這篇文章,好評如潮,總結了常見的遞歸解題套路

可能不少人看了以上的動態規劃的一些介紹還是對一些定義如 DP 狀態,狀態轉移方程,自底而上不了解,沒關係 ,接下來我們會做幾道習題來強化一下大家對這些概念及動態規劃解題四步曲的理解,每道題我們都會分別用遞歸,遞歸+備忘錄,動態規劃來求解一遍,這樣也進一步幫助大家來鞏固我們之前學的遞歸知識

動態規劃從入門到進階

入門題:斐波那契數列

接下來我們來看看怎麼用動態規劃解題四步曲來解斐波那契數列

畫外音:斐波那契數列並不是嚴格意義上的動態規劃,因為它不涉及到求最值,用這個例子旨在說明重疊子問題與狀態轉移方程

1、判斷是否可用遞歸來解 顯然是可以的,遞歸程式碼如下

public static int fibonacci(int n) {      if (n == 1) return 1;      if (n == 2) return 2;      return fibonacci(n - 1) + fibonacci(n - 2);  }  

2、分析在遞歸的過程中是否存在大量的重複子問題

怎麼分析是否有重複子問題,畫出遞歸樹

可以看到光是求 f(6),就有兩次重複的計算, f(4) 求解了兩次,f(3) 求解了兩次,時間複雜度是指數級別,遞歸時間複雜度怎麼看,解決每個子問題需要的時間乘以子問題總數,每個子問題需要的時間即 f(n) = f(n-1) + f(n-2) 只做了一次加法運算,子問題的個數有多少呢,每個問題一分為二,是個二叉樹,可以看到第一層 1 個,第二層 2 個,第三層 4 個,即 1 + 2 + 2^2 + …. 2^n,所以總的來說時間複雜度是)O(2^n),是指數級別

畫外音:求解問題 f(6),轉成求 f(5),f(4),從原問題出發,分解成求子問題,子問題再分解成子子問題,。。。,直到再也不能分解,這種從 原問題展開子問題進行求解的方式叫自頂向下

3、採用備忘錄的方式來存子問題的解以避免大量的重複計算 既然以上中間子問題中存在著大量的重複計算,那麼我們可以把這些中間結果給快取住(可以用哈希錶快取),如下

public static int fibonacci(int n) {      if (n = 1) return 1;      if (n = 2) return 2;      if (map.get(n) != null)  {          return map.get(n);      }      int result = fibonacci(n - 1) + fibonacci(n - 2);      map.put(n, result);      return result;  }  

這麼快取之後再看我們的遞歸樹

可以看到通過快取中間的數據,做了大量地剪枝的工作,同樣的f(4),f(3),f(2),都只算一遍了,省去了大量的重複計算,問題的規模從二叉樹變成了單鏈表(即 n),時間複雜度變成了 O(n),不過由於哈希錶快取了所有的子問題的結果,空間複雜度是 O(n)。

4、改用自底向上的方式來遞推,即動態規劃 我們注意到如下規律

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(3),f(4),…,自然而然地就求出了 f(n)

畫外音:從最終地不能再分解的子問題根據遞推方程(f(n) = f(n-1) + f(n-2))逐漸求它上層的問題,上上層問題,最終求得一開始的問題,這種求解問題的方式就叫自底向上。

f(n) 就是定義的每個子問題的狀態(DP 狀態),f(n) = f(n-1) + f(n-2) 就是狀態轉移方程,即 f(n) 由 f(n-1), f(n-2) 這兩個狀態轉移而來,由於每個子問題只與它前面的兩個狀態,所以我們只要定義三個變數,自底向上不斷循環迭代即可,如下

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),但空間複雜度只由於只定義了三個變數(result,pre,next)所以是常量 O(1)。

通過簡單地斐波那契的例子,相信大家對自底向上,DP 狀態, DP 轉移方程應該有了比較深入地認識,細心的同學一定發現了最優子結構怎麼沒有,因為前面我們也說了,斐波那契數列並不是嚴格意義上的動態規劃,只是先用這個簡單地例子來幫助大家了解一下一些基本的概念。在之後的習題中我們將會見識到真正的動態規劃

小試牛刀:三角形的最小路徑和

如圖示,以上三角形由一連串的數字構成,要求從頂點 2 開始走到最底下邊的最短路徑,每次只能向當前節點下面的兩個節點走,如 3 可以向 6 或 5 走,不能直接走到 7。

如圖示:從 2 走到最底下最短路徑為 2+3+5+1 = 11,即為我們所求的

首先我們需要用一個二維數組來表示這個三個角形的節點,用二維數組顯然可以做到, 第一行的 2 用 a[0][0] 表示,第二行元素 3, 4 用 a[1][0],a[1][1],依此類推。

定義好數據結構之後,接下來我們來看看如何套用我們的動態規劃解題套路來解題

1、 判斷是否可用遞歸來解

如果用遞歸,就要窮舉所有的路徑和,最後再求所有路徑和的最小值,我們來看看用遞歸怎麼做。

對於每個節點都可以走它的左或右節點,假設我們定義 traverse(i, j) 為節點 a[i][j] 下一步要走的節點,則可以得出遞歸公式的偽程式碼如下

traverse(i, j) = {      traverse(i+1, j);    向節點i,j 下面的左節點走一步      traverse(i+1, j+1);    向節點i,j 下面的右節點走一步  }  

什麼時候終止呢,顯然是遍歷到三角形最後一條邊的節點時終止,發現了嗎,對每個節點來說,在往下(無論是往左還是往右)遍歷的過程中,問題規模不斷地在縮小,也有臨界條件(到達最後一條邊的節點時終止),分解的子問題也有相同的解決問題的思路(對於每個節點的遍歷都是往左或往右),符合遞歸的條件!於是我們得到遞歸程式碼如下

private static int[][] triangle = {              {2, 0, 0, 0},              {3, 4, 0, 0},              {6, 5, 7, 0},              {4, 1, 8, 3}  };    public static int traverse(int i, int j) {      int totalRow = 4; // 總行數      if (i >=  totalRow - 1) {          return 0;      }      // 往左下節點走時      int leftSum = traverse(i+1, j) + triangle[i+1][j];      // 往右下節點走時      int rightSum = traverse(i+1, j+1) + triangle[i+1][j+1];      // 記錄每個節點往左和往右遍歷的路徑和的最小值      return Math.min(leftSum, rightSum);  }    public static  void main(String[] args)  throws Throwable {      int sum = traverse(0, 0) + triangle[0][0];      System.out.println("sum = " + sum);  }  

時間複雜度是多少呢,從以下偽程式碼可以看出

traverse(i, j) = {      traverse(i+1, j);    向節點i,j 下面的左節點走一步      traverse(i+1, j+1);    向節點i,j 下面的右節點走一步  }  

對於每個節點,要麼向左或向右,每個問題都分解成了兩個子問題,和斐波那契數列一樣,如果畫出遞歸樹也是個二叉樹,所以時間複雜度是 O(2^n),也是指數級別。

2、分析在遞歸的過程中是否存在大量的重複子問題

為啥時間複雜度是指數級別呢,我們簡單分析一下:

對於節點 3 和 4 來說,如果節點 3 往右遍歷, 節點 4 往左遍歷,都到了節點 5,節點 5 往下遍歷的話就會遍歷兩次,所以此時就會出現重複子問題

3、採用備忘錄的方式來存子問題的解以避免大量的重複計算(剪枝)

既然出現了,那我們就用備忘錄把中間節點快取下來

於是我們的程式碼改為如下所示

private static int[][] triangle = {              {2, 0, 0, 0},              {3, 4, 0, 0},              {6, 5, 7, 0},              {4, 1, 8, 3}      };    // 記錄中間狀態的 map  private static HashMap<String, Integer> map = new HashMap();    public static int traverse(int i, int j) {      String key = i + "" + j;      if (map.get(key) != null) {          return map.get(key);      }        int totalRow = 4; // 總行數      if (i >=  totalRow - 1) {          return 0;      }      // 往左下節點走時      int leftSum = traverse(i+1, j) + triangle[i+1][j];      // 往右下節點走時      int rightSum = traverse(i+1, j+1) + triangle[i+1][j+1];      // 記錄每個節點往左和往右遍歷的路徑和的最小值      int result = Math.min(leftSum, rightSum);      map.put(key, result);      return result;  }  

這麼一來,就達到了剪枝的目的,避免了重複子問題,時間複雜度一下子下降到 O(n), 空間複雜度呢,由於我們用哈希表存儲了所有的節點的狀態,所以空間複雜度是 O(n)。

4、改用自底向上的方式來遞推,即動態規劃

重點來了,如何採用自底向上的動態規劃來解決問題呢? 我們這麼來看,要求節點 2 到底部邊的最短路徑,只要先求得節點 3 和 節點 4 到底部的最短路徑值,然後取這兩者之中的最小值再加 2 不就是從 2 到底部的最短路徑了嗎,同理,要求節點 3 或 節點 4 到底部的最小值,只要求它們的左右節點到底部的最短路徑再取兩者的最小值再加節點本身的值(3 或 4)即可。

我們知道對於三角形的最後一層節點,它們到底部的最短路徑就是其本身,於是問題轉化為了已知最後一層節點的最小值怎麼求倒數第二層到最開始的節點到底部的最小值了。先看倒數第二層到底部的最短路徑怎麼求

同理,第二層對於節點 3 ,它到最底層的最短路徑轉化為了 3 到 7, 6 節點的最短路徑的最小值,即 9, 對於節點 4,它到最底層的最短路徑轉化為了 4 到 6, 10 的最短路徑兩者的最小值,即 10。

接下來要求 2 到底部的路徑就很簡單了,只要求 2 到節點 9 與 10 的最短路徑即可,顯然為 11。

於是最終的 11 即為我們所求的值,接下來我們來看看怎麼定義 DP 的狀態與狀態轉移方程。我們要求每個節點到底部的最短路徑,於是 DP 狀態 DP[i,j] 定義為 i,j 的節點到底部的最小值,DP狀態轉移方程定義如下:

DP[i,j] = min(DP[i+1, j], D[i+1, j+1]) + triangle[i,j]  

這個狀態轉移方程代表要求節點到最底部節點的最短路徑只需要求左右兩個節點到最底部的最短路徑兩者的最小值再加此節點本身!仔細想想我們上面的推導過程是不是都是按這個狀態轉移方程推導的,實在不明白建議多看幾遍上面的推導過程,相信不難明白。

DP 狀態 DP[i,j] 有兩個變數,需要分別從下而上,從左到右循環求出所有的 i,j, 有了狀態轉移方程求出程式碼就比較簡單了,如下

private static int[][] triangle = {          {2, 0, 0, 0},          {3, 4, 0, 0},          {6, 5, 7, 0},          {4, 1, 8, 3}  };  public static int traverse() {      int ROW = 4;      int[] mini = triangle[ROW - 1];      // 從倒數第二行求起,因為最後一行的值本身是固定的      for (int i = ROW - 2; i >= 0; i--) {          for (int j = 0; j < triangle[i].length; j++) {              mini[j] = triangle[i][j] + Math.min(mini[j], mini[j+1]);          }      }      return mini[0];  }    public static  void main(String[] args)  throws Throwable {      int minPathSum = traverse();      System.out.println("sum = " + minPathSum);  }  

可能有一些人對 mini 數組的定義有疑問,這裡其實用了一個比較取巧的方式,首先我們定義 mini 的初始值為最後一行的節點,因為最後一行的每個節點的 DP[i,j] 是固定值,只要從倒數第二行求起即可,其次我們知道每個節點到底部的最短路徑只與它下一層的 D[I+1,j], D[i+1, j] 有關,所以只要把每一層節點的 DP[i,j] 求出來保存到一個數組裡即可,就是為啥我們只需要定義一下 mini 一維數組的原因

如圖示:要求節點 2 到底部的最短路徑,它只關心節點 9, 10,之前層數的節點無需再關心!因為 9,10 已經是最優子結構了,所以只保存每層節點(即一維數組)的最值即可!

當自下而上遍歷完成了,mini[0] 的值即為 DP[0,0],即為節點 2 到 底部的最短路徑。mini 的定義可能有點繞,大家可以多思考幾遍,當然,你也可以定義一個二維數組來保存所有的 DP[i,j],只不過多耗些空間罷了。

這裡我們再來談談最優子結構,在以上的推導中我們知道每一層節點到底部的最短路徑依賴於它下層的左右節點的最短路徑,求得的下層兩個節點的最短路徑對於依賴於它們的節點來說就是最優子結構,最優子結構對於子問題來說屬於全局最優解,這樣我們不必去求節點到最底層的所有路徑了,只需要依賴於它的最優子結構即可推導出我們所要求的最優解,所以最優子結構有兩層含義,一是它是子問題的全局最優解,依賴於它的上層問題只要根據已求得的最優子結構推導求解即可得全局最優解,二是它有快取的含義,這樣就避免了多個依賴於它的問題的重複求解(消除重疊子問題)。

總結:仔細回想一下我們的解題思路,我們先看了本題是否可用遞歸來解,在遞歸的過程中發現了有重疊子問題,於是我們又用備忘錄來消除遞歸中的重疊子問題,既然我們發現了此問題可以用遞歸+備忘錄來求解,自然而然地想到它可以用自底向上的動態規劃來求解。是的,求解動態規劃就按這個套路來即可,最重要的是要找出它的狀態轉移方程,這需要在自下而上的推導中仔細觀察。

進階:湊零錢

給定不同面額的硬幣 coins 和一個總金額 amount。編寫一個函數來計算可以湊成總金額所需的最少的硬幣個數。如果沒有任何一種硬幣組合能組成總金額,返回 -1。輸入: coins = [1, 2, 5], amount = 11,輸出: 3 解釋: 11 = 5 + 5 + 1 輸入: coins = [2], amount = 3,輸出: -1

來套用一下我們的動態規劃解題四步曲

一、判斷是否可用遞歸來解

對於 amount 來說,如果我們選擇了 coins 中的任何一枚硬幣,則問題的規模(amount)是不是縮小了,再對縮小的 amount 也選擇 coins 中的任何一枚硬幣,直到再也不能選擇(amount <= 0, amount = 0 代表有符合條件的解,小於0代表沒有符合條件的解),從描述中我們可以看出問題可以分解成子問題,子問題與原問題具有相同的解決問題的思路,同時也有臨界條件,符合遞歸的條件,由此可證可以用遞歸求解,接下來我們來看看,如何套用遞歸四步曲來解題

1、定義這個函數,明確這個函數的功能,函數的功能顯然是給定一個 amount,用定義好的 coins 來湊,於是我們定義函數如下

private static int f(int amount, int[] coins) {  }  

2、尋找問題與子問題的關係,即遞推公式 這題的遞推關係比較難推導,我們一起看下,假設 f(amount, coins) 為零錢 amount 的所需要的最少硬幣數,當選中了coins 中的第一枚硬幣之後(即為 coins[0]),則需再對剩餘的 amount – coins[0] 金額求最少硬幣數,即調用 f(amount – coins[0], coins) ,由此可知當選了第一枚硬幣後的遞推公式如下

f(amount, coins) = f(amount-coins[0]) + 1 (這裡的 1 代表選擇了第一枚硬幣)  

如果選擇了第二,第三枚呢,遞推公式如下

f(amount, coins) = f(amount-coins[1]) + 1 (這裡的 1 代表選擇了第二枚硬幣)  f(amount, coins) = f(amount-coins[2]) + 1 (這裡的 1 代表選擇了第三枚硬幣)  

我們的目標是求得所有以上 f(amount, coins) 解的的最小值,於是可以得到我們的總的遞推公式如下

f(amount, coins) = min{ f(amount - coins[i]) + 1) }, 其中 i 的取值為 0 到 coins 的大小,coins[i] 表示選擇了此硬幣, 1 表示選擇了coins[i]  這一枚硬幣  

3、將第二步的遞推公式用程式碼表示出來補充到步驟 1 定義的函數中

得出了遞推公式用程式碼實現就簡單了,來簡單看一下

public class Solution {        private static int exchange(int amount, int[] coins) {            // 說明零錢剛好湊完          if (amount == 0) {              return 0;          }            // 說明沒有滿足的條件          if (amount < 0) {              return -1;          }            int result = Integer.MAX_VALUE;          for (int i = 0; i < coins.length; i++) {              int subMin = exchange(amount - coins[i], coins);              if (subMin == -1) continue;              result = Math.min(subMin + 1, result);          }            // 說明沒有符合問題的解          if (result == Integer.MAX_VALUE) {              return -1;          }            return result;      }        public static  void main(String[] args)  throws Throwable {          int amount = 11;          int[] coins = {1,2,5};          int result = exchange(amount, coins);          System.out.println("result = " + result);      }  }  

4、計算時間複雜度 這道題的時間複雜度很難看出來,一般看不出來的時候我們可以畫遞歸樹來分析,針對 amount = 11 的遞歸樹 如下

前文我們說到斐波那契的遞歸樹是一顆二叉樹,時間複雜度是指數級別,而湊零錢的遞歸樹是一顆三叉樹 ,顯然時間複雜度也是指數級別!

二、分析在遞歸的過程中是否存在大量的重疊子問題(動態規劃第二步)

由上一節的遞歸樹可知,存在重疊子問題,上一節中的 9, 8,都重複算了,所以存在重疊子問題!

三、採用備忘錄的方式來存子問題的解以避免大量的重複計算(剪枝)

既然我們知道存在重疊子問題,那麼就可以用備忘錄來存儲中間結果達到剪枝的目的

public class Solution {        // 保存中間結果      private static HashMap<Integer, Integer> map = new HashMap();        // 帶備忘錄的遞歸求解      private static int exchangeRecursive(int amount, int[] coins) {          if (map.get(amount) != null) {              return map.get(amount);          }          // 說明零錢已經湊完          if (amount == 0) {              return 0;          }            // 說明沒有滿足的條件          if (amount < 0) {              return -1;          }            int result = Integer.MAX_VALUE;          for (int i = 0; i < coins.length; i++) {              int subMin = exchangeRecursive(amount - coins[i], coins);              if (subMin == -1) continue;              result = Math.min(subMin + 1, result);          }            // 說明沒有符合問題的解          if (result == Integer.MAX_VALUE) {              return -1;          }            map.put(amount, result);          return result;      }        public static  void main(String[] args)  throws Throwable {          int amount = 11;          int[] coins = {1,2,5};          int result = exchangeRecursive(amount, coins);          System.out.println("result = " + result);      }  }  

四、改用自底向上的方式來遞推,即動態規劃

前面我們推導出了如下遞歸公式

f(amount, coins) = min{ f(amount - coins[i]) + 1) }, 其中 i 的取值為 0 到 coins 的大小,coins[i] 表示選擇了此硬幣, 1 表示選擇了coins[i]  這一枚硬幣  

從以上的遞推公式中我們可以獲取 DP 的解題思路,我們定義 DP(i) 為湊夠零錢 i 需要的最小值,狀態轉移方程如下

DP[i] =  min{ DP[ i - coins[j] ] + 1 } = min{ DP[ i - coins[j] ]} + 1,  其中 j 的取值為 0 到 coins 的大小,i 代表取了 coins[j] 這一枚硬幣。  

於是我們只要自底向上根據以上狀態轉移方程依次求解 DP[1], DP[2],DP[3].,….DP[11],最終的 DP[11],即為我們所求的解

// 動態規劃求解  private static int exchangeDP(int amount, int[] coins) {      int[] dp = new int[amount + 1];      // 初始化每個值為 amount+1,這樣當最終求得的 dp[amount] 為 amount+1 時,說明問題無解      for (int i = 0; i < amount + 1; i++) {          dp[i] = amount + 1;      }        // 0 硬幣本來就沒有,所以設置成 0      dp[0] = 0;        for (int i = 0; i < amount + 1; i++) {          for (int j = 0; j < coins.length; j++) {              if (i >= coins[j]) {                  dp[i] = Math.min(dp[i- coins[j]] + 1, dp[i]);              }          }      }        if (dp[amount] == amount + 1) {          return -1;      }      return dp[amount];  }  

畫外音:以上只是求出了湊成零錢的的最小數量,但如果想求由哪些面值的硬幣構成的,該如何修改呢?

湊零錢這道題還可以用另外一道經典的青蛙跳台階的思路來考慮,從最底部最少跳多少步可以跳到第 11 階,一次可以跳 1,2,5步 。由此可知最後一步一定是跳 1 或 2 或 5 步,於是如果用 f(n) 代表跳台階 n 的最小跳數,則問題轉化為了求 f(n-1),f(n-2) ,f(n-5)的最小值。

如圖示:最後一跳一定是跳 1 或 2 或 5 步,只要求 f(n-1),f(n-2) ,f(n-5)的最小值即可

寫出遞推表達式, 即:

 f(n) = min{ f(n-1),f(n-2),f(n-5)} + 1 (1代表最後一跳)  

我們的 DP 狀態轉移方程對比一下,可以發現兩者其實是等價的,只不過這種跳台階的方式可能更容易理解。

總結

本文通過幾個簡單的例子強化了大家動態規劃的三要素:最優子結構,狀態轉移方程,重疊子問題的理解,相信大家對動態規劃的理解應該深刻了許多,怎麼看出是否可以用動態規劃來解呢,先看題目是否可以用遞歸來推導,在用遞歸推導的過程如果發現有大量地重疊子問題,則有兩種方式可以優化,一種是遞歸 + 備忘錄,另一種就是採用動態規划了,動態規劃一般是自下而上的, 通過狀態轉移方程自下而上的得出每個子問題的最優解(即最優子結構),最優子結構其實也是窮舉了所有的情況得出的最優解,得出每個子問題的最優解後,也就是每個最優解其實是這個子問題的全局最優解,這樣依賴於它的上層問題根據狀態轉移方程自然而然地得出了全局最優解。動態規劃自下而上的求解方式還有一個好處就是避免了重疊子問題,因為依賴於子問題的上層問題可能有很多,如果採用自頂而下的方式來求解,就有可能造成大量的重疊子問題,時間複雜度會急劇上升。

參考:

動態規劃詳解:https://mp.weixin.qq.com/s/Cw39C9MY9Wr2JlcvBQZMcA

我整理了幾百本CS相關的電子書,全部都放在了這個Github:https://github.com/iamshuaidi/CS-Book(點擊閱讀原文直達,電腦打開更佳)