一文搞懂動態規劃

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

從面試的角度看,動態規劃是正規算法面試中不管如何都逃不掉的必考題,曾經有一個偉人說過這樣一句話:java

2020-01-19-16-30-05

那麼爲何動態規劃會在面試中這麼重要?node

其實最主要的緣由就是動態規劃很是適合面試,由於動態規劃沒辦法「背」。面試

咱們不少求職者實際上是經過背題來面試的,而以前這個作法屢試不爽,什麼翻轉二叉樹、翻轉鏈表,快排、歸併、冒泡一頓背,基本上也能在面試中渾水摸魚過去,其實這哪是考算法能力、算法思惟,這就是考誰的備戰態度好,願意花時間去背題而已,把連背都懶得背的篩出去就完事了。算法

可是隨着互聯網遇冷,人才供給進一步過熱,背題的人愈來愈多,面試的門檻被增長了,所以這個時候須要一種很是考驗算法思惟、變化無窮並且容易設計的題目類型,動態規劃就完美符合這個要求。編程

好比 LeetCode 中有1261道算法類題目,其中動態規劃題目佔據了近200道,動態規劃能佔據總題目的 1/6 的比例,可見其火熱程度。數組

更重要的是,動態規劃的題目難度以中高難度爲主:緩存

2020-01-19-21-38-06

因此,既然咱們已經知道這是算法面試的必考題了,咱們怎麼準備都不爲過,本文盡筆者最大努力把動態規劃講清楚。bash

從「錢」講起

咱們在前面內容瞭解到了貪心算法能夠解決「硬幣找零問題」,可是那只是在部分狀況下能夠解決而已,由於題目中給出的錢幣面值爲 一、五、25,咱們現實生活中咱們現行的第五套人民幣面值分別爲100、50、20、十、五、1,咱們的人民幣是能夠用貪心算法找零的。編程語言

那麼有什麼狀況下不能用貪心算法嗎?好比一個算法星球的央行發行了奇葩幣,幣值分別爲一、五、11,要湊夠15元,這個時候貪心算法就失效了。

咱們能夠算一下,按照貪心算法的策略,咱們先拿出最大面值的11,剩下的4個分別對應四個1元的奇葩幣,這總共須要五個奇葩幣才能湊夠15元。

而實際上咱們簡單一算,就知道最少狀況是拿出3個五元的奇葩幣才能湊夠15元。

這裏就有問題了,貪心算法的弊端在這種特殊面值錢幣面前展露無疑,緣由就在於「只顧眼前,無大局觀」,在先拿出最大的 11 面值的奇葩幣後就完全把周旋餘地堵死了,由於剩下的 4 要想湊足付出的代價是很是高的,咱們須要依次拿出4個面值爲1的奇葩幣。

改進計算策略

那麼既然貪心算法已經不適用於這種場景了,咱們應該如何改變計算策略呢?

當咱們面試過程當中遇到這種問題時,若是一時沒有思路,也要想到一種萬能算法--暴力破解。

咱們分析一下上述題目,它的問題實際上是「給定一組面額的硬幣,咱們用現有的幣值湊出n最少須要多少個幣」。

咱們要湊夠這個 n,只要 n 不爲0,那麼總會有處在最後一個的硬幣,這個硬幣剛好湊成了 n,好比咱們用 {11,1,1,1,1} 來湊15,前面咱們拿出 {11,1,1,1},最後咱們拿出 {1} 正好湊成 15。

2020-03-09-18-15-22

若是用 {5,5,5} 來湊15,最後一個硬幣就是5,咱們按照這個思路捋一捋,:

  • 那麼假設最後一個硬幣爲11的話,那麼剩下4,這個時候問題又變成了,咱們湊出 n-11 最少須要多少個幣,此時n=4,咱們只能取出4個面值爲1的幣
  • 若是假設最後一個硬幣爲 5 的話,這個時候問題又變成了,咱們用現有的幣值湊出 n-5 最少須要多少個幣

你們發現了沒有,咱們的問題提能夠不斷被分解爲「咱們用現有的幣值湊出 n 最少須要多少個幣」,好比咱們用 f(n) 函數表明 「湊出 n 最少須要多少個幣」.

把「原有的大問題逐漸分解成相似的可是規模更小的子問題」這就是最優子結構,咱們能夠經過自底向上的方式遞歸地從子問題的最優解逐步構造出整個問題的最優解。

這個時候咱們分別假設 一、五、11 三種面值的幣分別爲最後一個硬幣的狀況:

  • 最後一枚硬幣的面額爲 11: min = f(4) + 1
  • 最後一枚硬幣的面額爲 5: min = f(10) + 1
  • 最後一枚硬幣的面額爲 1: min = f(14) + 1

這個時候你們發現問題所在了嗎?最少找零 min 與 f(4)、f(10)、f(14) 三個函數解中的最小值是有關的,畢竟後面的「+1」是你們都有的。

假設湊的硬幣總額爲 n,那麼 f(4) = f(n-11)f(10) = f(n-5)f(14) = f(n-1),咱們得出如下公式:

f(n) = min{f(n-1), f(n-5), f(n-11)} + 1
複製代碼

咱們再具體到上面公式中 f(n-1) 湊夠它的最小硬幣數量是多少,是否是又變成下面這個公式:

f(n-1) = min{f(n-1-1), f(n-1-5), f(n-1-11)} + 1
複製代碼

以此類推...

這真是似曾相識,這不就是遞歸嗎?

是的,咱們能夠經過遞歸來求出最少找零問題的解,代碼以下:

function f(n) {
    if(n === 0) return 0
    let min = Infinity
    if (n >= 1) {
        min = Math.min(f(n-1) + 1, min)
    }

    if (n >= 5) {
        min = Math.min(f(n-5) + 1, min)
    }

    if (n >= 11) {
        min = Math.min(f(n-11) + 1, min)
    }

    return min
}

console.log(f(15)) // 3

複製代碼
  • 當n=0的時候,直接返回0,增長程序魯棒性
  • 咱們先設最少找零 min 爲 「無限大」,方便以後Math.min 求最小值
  • 當最後一個硬幣爲1的時候,咱們遞歸 min = Math.min(f(n-1) + 1, min),求此種狀況下的最小找零
  • 當最後一個硬幣爲5的時候,咱們遞歸 min = Math.min(f(n-5) + 1, min),求此種狀況下的最小找零
  • 當最後一個硬幣爲11的時候,咱們遞歸 min = Math.min(f(n-11) + 1, min),求此種狀況下的最小找零

遞歸的弊端

咱們看似已經把問題解決了,可是彆着急,咱們繼續測試,當n=70的時候,咱們測試要湊出這個數最少咱們須要多少個硬幣。

答案是8,可是咱們的耗時以下:

2020-01-21-23-02-13

若是n=270呢?在八代i7處理器和node.js 12.x版本的加持下我跑了這麼長時間都沒算出來:

2020-01-21-23-04-26

當n=27000的時候,咱們成功的爆棧了:

2020-01-21-23-05-56

因此爲何會形成如此長的執行耗時?歸根究竟是遞歸算法的低效致使的,咱們看以下圖:

2020-01-21-23-12-00

咱們若是計算f(70)就須要分別計算最後一個幣爲一、五、11三種面值時的不一樣狀況,而這三種不一樣狀況做爲子問題又能夠被分解爲三種狀況,依次類推...這樣的算法複雜度有 O(3ⁿ),這是極爲低效的。

咱們再仔細看圖:

2020-01-21-23-17-13

咱們用紅色標出來的都是相同的計算函數,好比有兩個f(64)、f(58)、f(54),這些都是重複的,這些只是咱們整個計算體系下的冰山一角,咱們還有很是多的重複計算沒辦法在圖中展現出來。

可見咱們重複計算了很是多的無效函數,浪費了算力,到底有多浪費咱們已經從上面函數執行時間測試上有了必定的認識。

咱們不妨再舉一個簡單的例子,好比咱們要計算 「1 + 1 + 1 + 1 + 1 + 1 + 1 + 1」的和。

咱們開始數數...,直到咱們數出上面計算的和爲 8,那麼,咱們再在上述 「1 + 1 + 1 + 1 + 1 + 1 + 1 + 1」 後面 「+ 1」,那麼和是多少?

這個時候你確定數都不會數,脫口而出「9」。

爲何咱們在後面的計算這麼快?是由於咱們已經在大腦中記住了以前的結果 「8」,咱們只須要計算「8 + 1」便可,這避免了咱們重複去計算前面的已經計算過的內容。

咱們用的遞歸像什麼?像繼續數「1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1」來計算出「9」,這是很是耗時的。

咱們假設用 m 種面值的硬幣湊 n 最少須要多少硬幣,在上述問題下遞歸的時間複雜度是驚人的O(nᵐ),指數級的時間複雜度能夠說是最差的時間複雜度之一了。

咱們已經發現問題所在了,大量的重複計算致使時間複雜度奇高,咱們必須想辦法解決這個問題。

備忘錄與遞歸

既然已經知道存在大量冗餘計算了,那麼咱們可不能夠創建一個備忘錄,把計算過的答案記錄在備忘錄中,再有咱們須要答案的時候,咱們去備忘錄中查找,若是能查找到就直接返回答案,這樣就避免了重複計算,這就是算法中典型的空間換時間的思惟,咱們用備忘錄佔用的額外內存換取了更高效的計算。

有了思路後,其實代碼實現很是簡單,咱們只須要創建一個緩存備忘錄,在函數內部校驗校驗是否存在在結果,若是存在返回便可。

javascript 代碼展現
function f(n) {
    function makeChange(amount) {
        if(amount <= 0) return 0
    
        // 校驗是否已經在備忘錄中存在結果,若是存在返回便可
        if(cache[amount]) return cache[amount]
    
        let min = Infinity
        if (amount >= 1) {
            min = Math.min(makeChange(amount-1) + 1, min)
        }
    
        if (amount >= 5) {
            min = Math.min(makeChange(amount-5) + 1, min)
        }
    
        if (amount >= 11) {
            min = Math.min(makeChange(amount-11) + 1, min)
        }
    
        return (cache[amount] = min)
    }
    // 備忘錄
    const cache = []
    return makeChange(n)
}
console.log(f(70)) // 8
複製代碼
java 代碼展現
public class Cions {

    public static void main(String[] args) {
        int a = coinChange(70);
        System.out.println(a);
    }
    private static HashMap<Integer,Integer> cache = new HashMap<>();
    public static int coinChange(int amount) {
        return makeChange(amount);
    }
    public static int makeChange(int amount) {
        if (amount <= 0) return 0;

        // 校驗是否已經在備忘錄中存在結果,若是存在返回便可
        if(cache.get(amount) != null) return cache.get(amount);

        int min = Integer.MAX_VALUE;
        if (amount >= 1) {
            min = Math.min(makeChange(amount-1) + 1, min);
        }

        if (amount >= 5) {
            min = Math.min(makeChange(amount-5) + 1, min);
        }

        if (amount >= 11) {
            min = Math.min(makeChange(amount-11) + 1, min);
        }

        cache.put(amount, min);
        return min;

    }
}
複製代碼

咱們的執行時間只有:

2020-01-22-09-52-37

實際上利用備忘錄來解決遞歸重複計算的問題叫作「記憶化搜索」。

2020-01-21-23-17-13

這個方法本質上跟回溯法的「剪枝」是一個目的,就是把上圖中存在重複的節點所有剔除,只保留一個節點便可,固然上圖沒辦法把全部節點所有展現出來,若是剔除所有重複節點最後只會留下線性的節點形式:

2020-01-22-10-04-33

這個帶備忘錄的遞歸算法時間複雜度只有O(n),已經跟動態規劃的時間複雜度相差不大了。

那麼這不就能夠了嗎?爲何還要搞動態規劃?

還記得咱們上面提到遞歸的另外一大問題嗎?

爆棧!

這是咱們備忘錄遞歸計算 f(27000) 的結果:

2020-01-22-10-07-19

編程語言棧的深度是有限的,即便咱們進行了剪枝,在五位數以上的狀況下就會再次產生爆棧的狀況,這致使遞歸根本沒法完成大規模的計算任務。

這是遞歸的計算形式決定的,咱們這裏的遞歸是「自頂向下」的計算思路,即從 f(70) f(69)...f(1) 逐步分解,這個思路在這裏並不徹底適用,咱們須要一種「自底向上」的思路來解決問題。

「自底向上」就是 f(1) ... f(70) f(69)經過小規模問題遞推來解決大規模問題,動態規劃一般是用迭代取代遞歸來解決問題。

「自頂向下」的思路在另外一種算法思想中很是常見,那就是分治算法

除此以外,遞歸+備忘錄的另外一個缺陷就是再沒有優化空間了,由於在最壞的狀況下,遞歸的最大深度是 n。

所以,咱們須要系統遞歸堆棧使用 O(n) 的空間,這是遞歸形式決定的,而換成迭代以後咱們根本不須要如此多的的儲存空間,咱們能夠繼續往下看。

動態轉移方程

還記得上面咱們利用備忘錄緩存以後各個節點的形式是什麼樣的嗎,咱們把它這個「備忘錄」做爲一張表,這張表就叫作 DP table,以下:

2020-01-22-22-06-59

注意: 上圖中 f[n] 表明湊夠 n 最少須要多少幣的函數,方塊內的數字表明函數的結果

咱們不妨在上圖中找找規律?

咱們觀察f[1]: f[1] = min(f[0], f[-5], f[-11]) + 1

因爲f[-5] 這種負數是不存在的,咱們都設爲正無窮大,那麼f[1] = 1

再看看f[5]: f[1] = min(f[4], f[0], f[-6]) + 1,這實際是在求f[4] = 4f[0] = 0f[-6]=Infinity中最小的值即0,最後加上1,即1,那麼f[5] = 1

發現了嗎?咱們任何一個節點均可以經過以前的節點來推導出來,根本無需再作重複計算,這個相關的方程是:

f[n] = min(f[n-1], f[n-5], f[n-11]) + 1
複製代碼

還記得咱們提到的動態規劃有更大的優化空間嗎?遞歸+備忘錄因爲遞歸深度的緣由須要 O(n) 的空間複雜度,可是基於迭代的動態規劃只須要常數級別的複雜度。

看下圖,好比咱們求解 f(70),只須要前面三個解,即 f(59) f(69) f(65) 套用公式便可求得,那麼 f(0)f(1) ... f(58) 根本就沒有用了,咱們能夠再也不儲存它們佔用額外空間,這就留下了咱們優化的空間。

2020-03-09-19-10-43

上面的方程就是動態轉移方程,而解決動態規劃題目的鑰匙也正是這個動態轉移方程。

固然,若是你只推導出了動態轉移方程基本上能夠把動態規劃題作出來了,可是每每不少人卻作不對,這是爲何?這就得考慮邊界問題。

邊界問題

部分的邊界問題其實咱們在上面的部分已經給出解決方案了,針對這個找零問題咱們有如下邊界問題。

處理f[n]中n爲負數的問題: 針對這個問題咱們的解決方案是凡是n爲負數的狀況,一概將f[n]視爲正無窮大,由於正常狀況下咱們是不會有下角標爲負數的數組的,因此其實 n 爲負數的 f[n] 根本就不存在,又由於咱們要求最少找零,爲了排除這種不存在的狀況,也便於咱們計算,咱們直接將其視爲正無窮大,能夠最大程度方便咱們的動態轉移方程的實現。

處理f[n]中n爲0的問題n=0 的狀況屬於動態轉移方程的初始條件,初始條件也就是動態轉移方程沒法處理的特殊狀況,好比咱們若是沒有這個初始條件,咱們的方程是這樣的: f[0] = min(f[-1], f[-5], f[-11]) + 1,最小的也是正無窮大,這是特殊狀況沒法處理,所以咱們只能人肉設置初始條件。

處理好邊界問題咱們就能夠獲得完整的動態轉移方程了:

f[0] = 0 (n=0)
f[n] = min(f[n-1], f[n-5], f[n-11]) + 1 (n>0)
複製代碼

找零問題完整解析

那麼咱們再回到這個找零問題中,此次咱們假設給出不一樣面額的硬幣 coins 和一個總金額 amount。編寫一個函數來計算能夠湊成總金額所需的最少的硬幣個數。若是沒有任何一種硬幣組合能組成總金額,返回 -1。

好比:

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

其實上面的找零問題就是咱們一直處理的找零問題的通用化,咱們的面額是定死的,即一、五、11,此次是不定的,而是給了一個數組 coins 包含了相關的面值。

有了以前的經驗,這種問題天然就再也不話下了,咱們再整理一下思路。

肯定最優子結構: 最優子結構即原問題的解由子問題的最優解構成,咱們假設最少須要k個硬幣湊足總面額n,那麼f(n) = min{f(n-cᵢ)}, cᵢ 便是硬幣的面額。

處理邊界問題: 依然是老套路,當n爲負數的時候,值爲正無窮大,當n=0時,值也爲0.

得出動態轉移方程:

f[0] = 0 (n=0)
f[n] = min(f[n-cᵢ]) + 1 (n>0)
複製代碼

咱們根據上面的推導,得出如下代碼:

javascript 代碼展現
const coinChange = (coins, amount) => {
  // 初始化備忘錄,用Infinity填滿備忘錄,Infinity說明該值不能夠用硬幣湊出來
  const dp = new Array(amount + 1).fill(Infinity)

  // 設置初始條件爲 0
  dp[0] = 0

  for (var i = 1; i <= amount; i++) {
    for (const coin of coins) {
      // 根據動態轉移方程求出最小值
      if (coin <= i) {
        dp[i] = Math.min(dp[i], dp[i - coin] + 1)
      }
    }
  }

  // 若是 `dp[amount] === Infinity`說明沒有最優解返回-1,不然返回最優解
  return dp[amount] === Infinity ? -1 : dp[amount]
}
複製代碼
java 代碼展現
class Solution {
    public int coinChange(int[] coins, int amount) {
        // 初始化備忘錄,用amount+1填滿備忘錄,amount+1 表示該值不能夠用硬幣湊出來
        int[] dp = new int[amount + 1];
        Arrays.fill(dp,amount+1);
        // 設置初始條件爲 0
        dp[0]=0;
        for (int coin : coins) {
            for (int i = coin; i <= amount; i++) {
                // 根據動態轉移方程求出最小值
                if(coin <= i) {
                    dp[i]=Math.min(dp[i],dp[i-coin]+1);
                }
            }
        }
        // 若是 `dp[amount] === amount+1`說明沒有最優解返回-1,不然返回最優解
        return dp[amount] == amount+1 ? -1 : dp[amount];
    }
}
複製代碼

小結

咱們總結一下學習歷程:

  1. 從貪心算法入手來解決找零問題,發現貪心算法並非在任何狀況下都能找到最優解
  2. 咱們決定換一種思路來解決存在的問題,咱們最終發現了關鍵點,即「最優子結構」
  3. 藉助上面的兩個發現,咱們用遞歸的方式解決了最少找零問題
  4. 可是通過算法複雜度分析和實際測試,咱們發現遞歸的方法效率奇低,咱們必須用一種方法來解決當前問題
  5. 咱們用備忘錄+遞歸的形式解決了時間複雜度問題,可是自頂向下的思路致使咱們沒法擺脫爆棧的陰霾,咱們須要一種「自底向上」的全新思路
  6. 咱們經過動態轉移方程以迭代的方式高效地解出了此題

其實動態規劃本質上就是被一再優化過的暴力破解,咱們經過動態規劃減小了大量的重疊子問題,此後咱們講到的全部動態規劃題目的解題過程,均可以從暴力破解一步步優化到動態規劃。

本文咱們學習了動態規劃究竟是怎麼來的,在此後的解題過程當中咱們若是沒有思路能夠在腦子裏把這個過程再過一遍,可是咱們以後的題解就不會再把整個過程走一遍了,而是直接用動態規劃來解題。

可能你會問面試題這麼多,到底哪一道應該用動態規劃?如何判斷?

其實最準確的辦法就是看題目中的給定的問題,這個問題能不能被分解爲子問題,再根據子問題的解是否能夠得出原問題的解。

固然上面的方法雖然準確,可是須要必定的經驗積累,咱們能夠用一個雖然不那麼準確,可是足夠簡單粗暴的辦法,若是題目知足如下條件之一,那麼它大機率是動態規劃題目:

  • 求最大值,最小值
  • 判斷方案是否可行
  • 統計方案個數

歡迎關注公衆號:

相關文章
相關標籤/搜索