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

前言

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

本文將會從如下角度來說解動態規劃:web

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

什麼是動態規劃

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

劃重點:shell

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

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

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

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

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

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

動態規劃從入門到進階

入門題:斐波那契數列

接下來咱們來看看怎麼用動態規劃解題四步曲來解斐波那契數列
畫外音:斐波那契數列並非嚴格意義上的動態規劃,由於它不涉及到求最值,用這個例子旨在說明重疊子問題與狀態轉移方程函數

一、判斷是否可用遞歸來解
顯然是能夠的,遞歸代碼以下學習

public static int fibonacci(int n) {
    if (n == 1return 1;
    if (n == 2return 2;
    return fibonacci(n - 1) + fibonacci(n - 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),從原問題出發,分解成求子問題,子問題再分解成子子問題,。。。,直到不再能分解,這種從 原問題展開子問題進行求解的方式叫自頂向下

三、採用備忘錄的方式來存子問題的解以免大量的重複計算
既然以上中間子問題中存在着大量的重複計算,那麼咱們能夠把這些中間結果給緩存住(能夠用哈希表緩存),以下

public static int fibonacci(int n) {
    if (n = 1return 1;
    if (n = 2return 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)。

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

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 == 1return 1;
    if (n == 2return 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],依此類推。

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

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

若是用遞歸,就要窮舉全部的路徑和,最後再求全部路徑和的最小值,咱們來看看用遞歸怎麼作。

對於每一個節點均可以走它的左或右節點,假設咱們定義 traverse(i, j) 爲節點 a[i][j] 下一步要走的節點,則能夠得出遞歸公式的僞代碼以下

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

何時終止呢,顯然是遍歷到三角形最後一條邊的節點時終止,發現了嗎,對每一個節點來講,在往下(不管是往左仍是往右)遍歷的過程當中,問題規模不斷地在縮小,也有臨界條件(到達最後一條邊的節點時終止),分解的子問題也有相同的解決問題的思路(對於每一個節點的遍歷都是往左或往右),符合遞歸的條件!因而咱們獲得遞歸代碼以下

private static int[][] triangle = {
            {2000},
            {3400},
            {6570},
            {4183}
};

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(00) + triangle[0][0];
    System.out.println("sum = " + sum);
}

時間複雜度是多少呢,從如下僞代碼能夠看出

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

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

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

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

對於節點 3 和 4 來講,若是節點 3 往右遍歷, 節點 4 往左遍歷,都到了節點 5,節點 5 往下遍歷的話就會遍歷兩次,因此此時就會出現重複子問題

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

既然出現了,那咱們就用備忘錄把中間節點緩存下來

因而咱們的代碼改成以下所示

private static int[][] triangle = {
            {2000},
            {3400},
            {6570},
            {4183}
    };

// 記錄中間狀態的 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)。

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

重點來了,如何採用自底向上的動態規劃來解決問題呢? 咱們這麼來看,要求節點 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 = {
        {2000},
        {3400},
        {6570},
        {4183}
};
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[j].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

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

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

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

一、定義這個函數,明確這個函數的功能,函數的功能顯然是給定一個 amount,用定義好的 coins 來湊,因而咱們定義函數以下

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

二、尋找問題與子問題的關係,即遞推公式
這題的遞推關係比較難推導,咱們一塊兒看下,假設 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]  這一枚硬幣

三、將第二步的遞推公式用代碼表示出來補充到步驟 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 == -1continue;
            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);
    }
}

四、計算時間複雜度
這道題的時間複雜度很難看出來,通常看不出來的時候咱們能夠畫遞歸樹來分析,針對 amount = 11 的遞歸樹 以下

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

2、分析在遞歸的過程當中是否存在大量的重疊子問題(動態規劃第二步)
由上一節的遞歸樹可知,存在重疊子問題,上一節中的 9, 8,都重複算了,因此存在重疊子問題!

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

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

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 == -1continue;
            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);
    }
}

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

前面咱們推導出了以下遞歸公式

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]], dp[i]) + 1;
            }
        }
    }

    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

歡迎關注公衆號交流哦

相關文章
相關標籤/搜索