【动态规划】不信看完你还不懂动态规划
1.什么是動態(tài)規(guī)劃?
維基百科:動態(tài)規(guī)劃(Dynamic programming,簡稱DP)是一種通過把原問題分解為相對簡單的子問題的方式求解復雜問題的方法。
使用場景:動態(tài)規(guī)劃常常適用于有重疊子問題和最優(yōu)子結構性質的問題。
dynamic programming is a m·· ethod for solving a complex problem by breaking it down into a collection of simpler subproblems.
簡單來說,動態(tài)規(guī)劃其實就是,給定一個問題,我們把它拆成一個個子問題,直到子問題可以直接解決,然后把子問題答案保存起來,以減少重復計算。再根據(jù)子問題答案反推,得出原問題解的一種方法。
動態(tài)規(guī)劃最核心的思想,就在于拆分子問題,記住過往,減少重復計算。
一般這些子問題很相似,可以通過函數(shù)關系式遞推出來。動態(tài)規(guī)劃就致力于解決每個子問題一次,減少重復計算,比如`斐波那契數(shù)列`,就可以看做入門級的經(jīng)典動態(tài)規(guī)劃問題。
2.動態(tài)規(guī)劃3個核心點
- 確定邊界--退出條件
- 確定最優(yōu)子結構--拆分子問題
- 狀態(tài)轉移方程--子問題合并方式,即子問題和原問題關系,將子問題結果合并得出最終答案
對于這個3點,后面會做一一解釋,請看我道來。
3.從「錢」講起
一個算法星球的央行發(fā)行了奇葩幣,幣值分別為1、5、11,要湊夠15元最少要幾個貨幣?
它的問題其實是「給定一組面額的硬幣,我們用現(xiàn)有的幣值湊出n最少需要多少個幣」。
我們要湊夠這個 n,只要 n 不為0,那么總會有處在最后一個的硬幣,這個硬幣恰好湊成了 n,比如我們用 {11,1,1,1,1} 來湊15,前面我們拿出 {11,1,1,1},最后我們拿出 {1} 正好湊成 15。
?如果用 {5,5,5} 來湊15,最后一個硬幣就是5,我們按照這個思路捋一捋,:
- 那么假設最后一個硬幣為11的話,那么剩下4,這個時候問題又變成了,我們湊出 n-11 最少需要多少個幣,此時n=4,我們只能取出4個面值為1的幣
- 如果假設最后一個硬幣為 5 的話,這個時候問題又變成了,我們用現(xiàn)有的幣值湊出 n-5 最少需要多少個幣
大家發(fā)現(xiàn)了沒有,我們的問題提可以不斷被分解為「我們用現(xiàn)有的幣值湊出 n 最少需要多少個幣」,比如我們用 f(n) 函數(shù)代表 「湊出 n 最少需要多少個幣」.
把「原有的大問題逐漸分解成類似的但是規(guī)模更小的子問題」這就是最優(yōu)子結構,我們可以通過自底向上的方式遞歸地從子問題的最優(yōu)解逐步構造出整個問題的最優(yōu)解。
這個時候我們分別假設 1、5、11 三種面值的幣分別為最后一個硬幣的情況:
- 最后一枚硬幣的面額為 11: min = f(4) + 1
- 最后一枚硬幣的面額為 5: min = f(10) + 1
- 最后一枚硬幣的面額為 1: min = f(14) + 1
這個時候大家發(fā)現(xiàn)問題所在了嗎?最少找零 min 與 f(4)、f(10)、f(14) 三個函數(shù)解中的最小值是有關的,畢竟后面的「+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) 湊夠它的最小硬幣數(shù)量是多少,是不是又變成下面這個公式:
f(n-1) = min{f(n-1-1), f(n-1-5), f(n-1-11)} + 1以此類推...
這真是似曾相識,這不就是遞歸嗎?是的,我們可以通過遞歸來求出最少找零問題的解。
4.遞歸解法
public static int coinChange(int n) {if (n <= 0) return 0;int min = Integer.MAX_VALUE;if (n >= 1) {min = Math.min(coinChange(n - 1) + 1, min);}if (n >= 5) {min = Math.min(coinChange(n - 5) + 1, min);}if (n >= 11) {min = Math.min(coinChange(n - 11) + 1, min);}return min; }- 當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),求此種情況下的最小找零
遞歸的弊端
我們看似已經(jīng)把問題解決了,但是別著急,我們繼續(xù)測試,當n越來越大時,執(zhí)行時間幾何數(shù)增長,而且最后還出現(xiàn)棧溢出的情況。所以為什么會造成如此長的執(zhí)行耗時?歸根到底是遞歸算法的低效導致的。
我們?nèi)绻嬎鉬(70)就需要分別計算最后一個幣為1、5、11三種面值時的不同情況,而這三種不同情況作為子問題又可以被分解為三種情況,依次類推...這樣的算法復雜度有 O(3?),這是極為低效的。
我們再仔細看圖:
我們用紅色標出來的都是相同的計算函數(shù),比如有兩個f(64)、f(58)、f(54),這些都是重復的,這些只是我們整個計算體系下的冰山一角,我們還有非常多的重復計算沒辦法在圖中展示出來。
可見我們重復計算了非常多的無效函數(shù),浪費了算力。
我們不妨再舉一個簡單的例子,比如我們要計算 「1 + 1 + 1 + 1 + 1 + 1 + 1 + 1」的和。
我們開始數(shù)數(shù)...,直到我們數(shù)出上面計算的和為 8,那么,我們再在上述 「1 + 1 + 1 + 1 + 1 + 1 + 1 + 1」 后面 「+ 1」,那么和是多少?
這個時候你肯定數(shù)都不會數(shù),脫口而出「9」。
為什么我們在后面的計算這么快?是因為我們已經(jīng)在大腦中記住了之前的結果 「8」,我們只需要計算「8 + 1」即可,這避免了我們重復去計算前面的已經(jīng)計算過的內(nèi)容。
我們用的遞歸像什么?像繼續(xù)數(shù)「1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1」來計算出「9」,這是非常耗時的。
我們假設用 m 種面值的硬幣湊 n 最少需要多少硬幣,在上述問題下遞歸的時間復雜度是驚人的O(n?),指數(shù)級的時間復雜度可以說是最差的時間復雜度之一了。我們已經(jīng)發(fā)現(xiàn)問題所在了,大量的重復計算導致時間復雜度奇高,我們必須想辦法解決這個問題。
5.備忘錄與遞歸
既然已經(jīng)知道存在大量冗余計算了,那么我們可不可以建立一個備忘錄,把計算過的答案記錄在備忘錄中,再有我們需要答案的時候,我們?nèi)渫浿胁檎?如果能查找到就直接返回答案,這樣就避免了重復計算,這就是算法中典型的空間換時間的思維。
有了思路后,其實代碼實現(xiàn)非常簡單,我們只需要建立一個緩存?zhèn)渫?在函數(shù)內(nèi)部校驗校驗是否存在在結果,如果存在返回即可。
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;// 校驗是否已經(jīng)在備忘錄中存在結果,如果存在返回即可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;} }實際上利用備忘錄來解決遞歸重復計算的問題叫做「記憶化搜索」。
這個方法本質上跟回溯法的「剪枝」是一個目的,就是把上圖中存在重復的節(jié)點全部剔除,只保留一個節(jié)點即可,當然上圖沒辦法把所有節(jié)點全部展示出來,如果剔除全部重復節(jié)點最后只會留下線性的節(jié)點形式:
?帶備忘錄的遞歸算法時間復雜度只有O(n),已經(jīng)跟動態(tài)規(guī)劃的時間復雜度相差不大了。
那么這不就可以了嗎?為什么還要搞動態(tài)規(guī)劃?還記得我們上面提到遞歸的另一大問題嗎?
爆棧!編程語言棧的深度是有限的,即使我們進行了剪枝,在五位數(shù)以上的情況下就會再次產(chǎn)生爆棧的情況,這導致遞歸根本無法完成大規(guī)模的計算任務。
這是遞歸的計算形式?jīng)Q定的,我們這里的遞歸是「自頂向下」的計算思路,即從 f(70) f(69)...f(1) 逐步分解。「自頂向下」的思路在另一種算法思想中非常常見,那就是分治算法。
這個思路在這里并不完全適用,我們需要一種「自底向上」的思路來解決問題。「自底向上」就是 f(1) ... f(70) f(69)通過小規(guī)模問題遞推來解決大規(guī)模問題,動態(tài)規(guī)劃通常是用迭代取代遞歸來解決問題。
除此之外,遞歸+備忘錄的另一個缺陷就是再沒有優(yōu)化空間了,因為在最壞的情況下,遞歸的最大深度是 n。因此,我們需要系統(tǒng)遞歸堆棧使用 O(n) 的空間,這是遞歸形式?jīng)Q定的,而換成迭代之后我們根本不需要如此多的的儲存空間,我們可以繼續(xù)往下看。
6.動態(tài)規(guī)劃算法
還記得上面我們利用備忘錄緩存之后各個節(jié)點的形式是什么樣的嗎,我們把它這個「備忘錄」作為一張表,這張表就叫做 DP table,如下:
?注意: 上圖中 f[n] 代表湊夠 n 最少需要多少幣的函數(shù),方塊內(nèi)的數(shù)字代表函數(shù)的結果
我們不妨在上圖中找找規(guī)律?
我們觀察f[1]: f[1] = min(f[0], f[-5], f[-11]) + 1
由于f[-5] 這種負數(shù)是不存在的,我們都設為正無窮大,那么f[1] = 1。
再看看f[5]: f[1] = min(f[4], f[0], f[-6]) + 1,這實際是在求f[4] = 4、f[0] = 0、f[-6]=Infinity中最小的值即0,最后加上1,即1,那么f[5] = 1。
狀態(tài)轉移方程
發(fā)現(xiàn)了嗎?我們?nèi)魏我粋€節(jié)點都可以通過之前的節(jié)點來推導出來,根本無需再做重復計算,這個相關的方程是:
f[n] = min(f[n-1], f[n-5], f[n-11]) + 1還記得我們提到的動態(tài)規(guī)劃有更大的優(yōu)化空間嗎?遞歸+備忘錄由于遞歸深度的原因需要 O(n) 的空間復雜度,但是基于迭代的動態(tài)規(guī)劃只需要常數(shù)級別的復雜度。
看下圖,比如我們求解 f(70),只需要前面三個解,即 f(59) f(69) f(65) 套用公式即可求得,那么 f(0)f(1) ... f(58) 根本就沒有用了,我們可以不再儲存它們占用額外空間,這就留下了我們優(yōu)化的空間。
?上面的方程就是動態(tài)轉移方程,而解決動態(tài)規(guī)劃題目的鑰匙也正是這個動態(tài)轉移方程。
當然,如果你只推導出了動態(tài)轉移方程基本上可以把動態(tài)規(guī)劃題做出來了,但是往往很多人卻做不對,這是為什么?這就得考慮邊界問題。
邊界問題
部分的邊界問題其實我們在上面的部分已經(jīng)給出解決方案了,針對這個找零問題我們有以下邊界問題。
處理f[n]中n為負數(shù)的問題:
凡是n為負數(shù)的情況,一律將f[n]視為正無窮大。?
因為正常情況下我們是不會有下角標為負數(shù)的數(shù)組的,所以其實 n 為負數(shù)的 f[n] 根本就不存在
又因為我們要求最少找零,為了排除這種不存在的情況,也便于我們計算
我們直接將其視為正無窮大,可以最大程度方便我們的動態(tài)轉移方程的實現(xiàn)。
處理f[n]中n為0的問題
n=0 的情況屬于動態(tài)轉移方程的初始條件
初始條件也就是動態(tài)轉移方程無法處理的特殊情況
比如我們?nèi)绻麤]有這個初始條件,我們的方程是這樣的: f[0] = min(f[-1], f[-5], f[-11]) + 1
最小的也是正無窮大,這是特殊情況無法處理,因此我們只能人肉設置初始條件。
處理好邊界問題我們就可以得到完整的動態(tài)轉移方程了:
f[0] = 0 (n=0) f[n] = min(f[n-1], f[n-5], f[n-11]) + 1 (n>0)那么我們再回到這個找零問題中,這次我們假設給出不同面額的硬幣 coins 和一個總金額 amount。編寫一個函數(shù)來計算可以湊成總金額所需的最少的硬幣個數(shù)。如果沒有任何一種硬幣組合能組成總金額,返回?-1。
比如輸入: coins = [1, 2, 5], amount = 11 輸出: 3 解釋: 11 = 5 + 5 + 1 復制代碼
其實上面的找零問題就是我們一直處理的找零問題的通用化,我們的面額是定死的,即1、5、11,這次是不定的,而是給了一個數(shù)組 coins 包含了相關的面值。
我們重新理一下思路:
- 確定最優(yōu)子結構: 最優(yōu)子結構即原問題的解由子問題的最優(yōu)解構成,我們假設最少需要k個硬幣湊足總面額n,那么f(n) = min{f(n-c?)}, c? 即是硬幣的面額。
- 處理邊界問題: 依然是老套路,當n為負數(shù)的時候,值為正無窮大,當n=0時,值也為0.
- 得出動態(tài)轉移方程:?f[0] = 0 (n=0) f[n] = min(f[n-c?]) + 1 (n>0)?
我們根據(jù)上面的推導,得出以下代碼:
public int coinChange(int[] coins, int amount) {// 初始化備忘錄,用amount+1填滿備忘錄,amount+1 表示該值不可以用硬幣湊出來int[] dp = new int[amount + 1];Arrays.fill(dp,amount+1);// 設置初始條件為 0dp[0]=0;for (int coin : coins) {for (int i = coin; i <= amount; i++) {// 根據(jù)動態(tài)轉移方程求出最小值if(coin <= i) {dp[i]=Math.min(dp[i],dp[i-coin]+1);}}}// 如果 `dp[amount] === amount+1`說明沒有最優(yōu)解返回-1,否則返回最優(yōu)解return dp[amount] == amount+1 ? -1 : dp[amount]; }7.小結
我們總結一下學習歷程:
其實動態(tài)規(guī)劃本質上就是被一再優(yōu)化過的暴力破解,我們通過動態(tài)規(guī)劃減少了大量的重疊子問題,此后我們講到的所有動態(tài)規(guī)劃題目的解題過程,都可以從暴力破解一步步優(yōu)化到動態(tài)規(guī)劃。
可能你會問面試題這么多,到底哪一道應該用動態(tài)規(guī)劃?如何判斷?
其實最準確的辦法就是看題目中的給定的問題,這個問題能不能被分解為子問題,再根據(jù)子問題的解是否可以得出原問題的解。
當然上面的方法雖然準確,但是需要一定的經(jīng)驗積累,我們可以用一個雖然不那么準確,但是足夠簡單粗暴的辦法,如果題目滿足以下條件之一,那么它大概率是動態(tài)規(guī)劃題目:
- 求最大值,最小值
- 判斷方案是否可行
- 統(tǒng)計方案個數(shù)
參考文檔:
看一遍就理解:動態(tài)規(guī)劃詳解
一文搞懂動態(tài)規(guī)劃
總結
以上是生活随笔為你收集整理的【动态规划】不信看完你还不懂动态规划的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 【LeetCode每周算法】零钱兑换
- 下一篇: 如何运行一个Java文件?