本文爲我的學習動態規劃的筆記,歡迎指正、評論。算法
R.Bellman從1955年開始系統地研究動態規劃方法,爲這個領域奠基了堅實的數學基礎。1957年出版了他的名著《Dynamic Programming》,這是該領域的第一本著做。距今(2019)已經62年。數組
動態規劃(Dynamic programming,簡稱DP)是一種在數學、管理科學、計算機科學、經濟學和生物信息學中使用的,經過把原問題分解爲相對簡單的子問題的方式求解複雜問題的方法。(dynamic programming 中的"programming"指的是一種表格法,並不是編寫計算機程序)函數
分治法一般將問題劃分爲互不相交的子問題,遞歸求解子問題,再將他們的解組合起來。學習
與分治法不一樣的是,動態規劃應用於子問題重疊的狀況,即不一樣的子問題具備公共的子子問題。動態規劃對每一個子子問題只求解一次,把結果保存到一個表格(programming)當中,避免了像分治法同樣不斷重複計算。優化
動態規劃方法一般用來求解最優化問題,重點尋找最優值,而非全部最優解。spa
最優值:最優化的結果的值,例如商旅問題裏的最短路徑的值。設計
最優解:能構造最優值的解決方案,例如商旅問題裏達到最短路徑的行路方案。3d
如下示例都不止一種解法,本文裏只講動態規劃的解法。code
習題來自leetcode.cdn
假設你正在爬樓梯。須要 n (n是一個正整數)階你才能到達樓頂。每次你能夠爬 1 或 2 個臺階。你有多少種不一樣的方法能夠爬到樓頂呢?(必須正好到達樓頂)
寫一個程序,輸入爲n,輸出爲n階樓梯對應的方法總數。
當n=2,返回2,方法有:1步+1步; 2步; 當n=3,返回3,方法有:1步+1步+1步; 1步+2步; 2步+1步; 當n=4,返回5,方法有:1步+1步+1步+1步; 1步+1步+2步; 1步+2步+1步; 2步+1步+1步; 2步+2步;
有n階樓梯時,令C(n)爲全部能夠爬到樓頂的方法的總數,即C(n)爲n階爬樓梯問題的解。
由題已知,n > 0, C(1) = 1,C(2) = 2
如今來討論n>2時,n階樓梯的解
圖中表示了爬n階樓梯的兩種狀況,一種是最後一次選擇跨兩步,一種是最後一次選擇跨一步。(刻畫一個最優解的結構特徵)
選擇最後一次跨兩步的時候,以前還有n-2階臺階。因爲n-2階臺階怎麼爬與最後一次無關,因此在這種狀況下,n階臺階能夠當作n-2階樓梯問題的任一爬法 + 最後一次爬兩階。那麼n-2階樓梯有多少種解法,此種狀況下的n階樓梯就是多少種解法,即最後一次爬兩階時,n階樓梯的解法有C(n-2)種。
同理,選擇最後一次跨一步的時候,n階臺階能夠當作n-1階樓梯問題的任一爬法 + 最後一次爬一階,此種狀況下n階樓梯的解法就是n-1階的解法C(n-1)。
因爲以上兩種狀況加起來就是全部的狀況,因此n階的解法等於n-1階的解法加n-2階的解法。公式就是C(n) = C(n-1) + C(n-2),是一個典型的斐波那契數列。(遞歸地定義最優解的值)
斐波那契數列是有計算公式的,能夠直接利用公式求解。可是咱們如今學習動態規劃,就先僞裝不知道這個公式,利用動態規劃的思想去求解。
定義一個數組dp, dp[i]表示i階爬樓梯問題的解。
const dp = []; // 儲存子問題最優解的表格
複製代碼
如今已知初始條件
dp[1] = 1;
dp[2] = 2;
複製代碼
根據遞歸公式可得
dp[i] = dp[i-1] + dp[i-2]
複製代碼
遞歸公式加上初始條件和循環,獲得總體的程序
var climbStairs = function(n) {
const dp = new Array(n+1);
// 假設dp[0] = 1,令dp[2] = dp[1] + dp[0],這樣就構造了一個完整的斐波那契數列
dp[0] = dp[1] = 1;
for(let i = 2; i <= n; i++){
// 計算最優解的值,一般採用自底向上(從最小的子問題開始求解)的方法。
dp[i] = dp[i-1] + dp[i-2];
}
return dp[n];
}
複製代碼
可能有人會注意到,其實每次計算dp[i]只用到了dp[i-1]、dp[i-2]這兩個值,能夠考慮只用兩個變量儲存以前的計算結果,每次更新對應的兩個值就能夠了。這是能夠的,就當作另外的習題作一作吧。
至此,爬樓梯問題就經過動態規劃解決啦,算法時間複雜度爲O(n),空間複雜度爲O(n)(若是隻更新兩個值,那空間複雜度爲O(1))。
習題及解題思路來自《算法導論》。
假設出售一段長度爲i的鋼條的價格爲Pi(i = 1,2,3,...),鋼條的長度爲整數,給出了一個價格表的樣例:
長度i | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
---|---|---|---|---|---|---|---|---|---|---|
價格pi | 1 | 5 | 8 | 9 | 10 | 17 | 17 | 20 | 24 | 30 |
如今給定一段長度爲n英寸的鋼條和如上的價格表,求鋼條切割方案,使得銷售收益 rn 最大。注意,不切割的方案有多是收益最大的方案。
當n = 4時,包括不切割的方案在內,一共有八種切割方案。如圖所示,圖中數字表明該段鋼條的價格。其中將鋼條切成兩段2英寸的鋼條的方案總收益最高,爲10。
r1 = 1
r2 = 5
r3 = 8
r4 = 10
r5 = 13
...
當鋼條長度爲n時,將鋼條水平放置。
從左往右計量下刀位置,假設切第一刀,第一刀的位置範圍是[1,n],爲n表示不切割。長度爲0時,鋼條價值爲0。
第一刀左邊的鋼條長度爲i,這段鋼條再也不切割,其價值爲pi;第一刀右邊的鋼條長度爲n-i, 可切割,其價值爲rn-i。
只要遍歷全部第一刀(包括不切)的狀況,找到其中的最大值,就能找到此時鋼條的最優切割方案 rn。
如今咱們能夠獲得公式:
有了這個遞推公式,咱們就能夠實現一個遞推算法:
function cut_recursive(p, n) {
if(n===0){
return 0;
}
let q = Number.NEGATIVE_INFINITY;
for(let i = 1; i <= n; i++) {
q = Math.max(q, p[i]+ cut_recursive(p, n-i));
}
return q;
}
複製代碼
如今來考察一下這個遞推算法的複雜度
上圖裏樹的每個節點表明一次遞歸,節點的數字表明當前cut_recursive的參數n,n是鋼條長度。其子節點爲當前遞歸爲了解決問題須要再次調用的遞歸及參數n。例如根節點4,表示本次遞歸的鋼條長度爲4,爲了解決這個問題,還須要經過遞歸求解鋼條長度爲三、鋼條長度爲二、鋼條長度爲一、鋼條長度爲0的問題。
圖中的樹有24=16個節點。能夠證實(此處略)爲了解決長度爲n的鋼鐵切割問題,生成的遞歸樹一共有2n個節點,表明調用了2n次遞歸函數。隨着n增大,算法遞歸的數量呈指數型增加。
咱們在問題分析裏,找到了具備最優子結構的分解方式,以及相應的遞推公式,這已經完成了動態規劃的重要部分:刻畫最優解的結構特徵和遞歸的定義最優解的值。咱們能夠接着上面的分解方式來完成接下來的動態規劃求解。
如今已知遞歸方程式:
仔細觀察遞歸樹能夠發現,有些節點是不斷重複的:
將相同問題進行同色着色處理,其實要解決的非重複問題只有5個,大多數都是遞歸過程當中不斷求解重複的問題。
假如咱們安排一下計算的順序,將計算過的結果保存下來,再遇到相同的子問題時,能夠直接使用計算好的值,沒必要再從新計算。所以:
動態規劃方法中,會付出額外的內存空間來節省計算時間,是典型的時空權衡(time-memory trade-off)。
動態規劃通常有兩種保存結果的實現方法:
第一種是帶備忘的自頂向下法。自頂向下法能夠用遞歸實現,加上保存計算結果的備忘便可。在樸素的遞歸算法裏,只要稍微修改一下,求值時先檢查備忘裏是否已經有計算好的值,若是有,直接使用計算好的值,不然進入下一層遞歸。
第二種是自底向上法。自底向上法是預先求出小問題的解,再經過小問題的解構成大問題的解。
// 第一種:帶備忘的自頂向下法
function memory_cut(p, n) {
let r = new Array(n+1).fill(Number.NEGATIVE_INFINITY);
return memory_cut_recursive(p, n, r)
}
function memory_cut_recursive(p, n, r) {
if(r[n] >= 0){
// 檢查是否存在計算過的值
return r[n];
}
// 沒有計算過則按正常流程計算
if(n===0){
return 0;
}
let q = Number.NEGATIVE_INFINITY;
for(let i = 1; i <= n; i++) {
q = Math.max(q, p[i]+ memory_cut_recursive(p, n-i, r));
}
return q;
}
複製代碼
在原來遞歸算法的基礎上加上備忘就實現了第一種方法。如今的遞歸調用樹狀況:
圖中帶五角星的節點在遞歸過程當中直接返回了以前的計算結果。能夠看到,如今已經減小了許多沒必要要的計算過程,生成的遞歸樹一共有(n*(n+1)/2) + 1個節點。與原來的算法相比,已經從2n這種指數級別的遞歸數量降至多項式n2級別。
如今實現第二種:
// 第二種:自底向上
function buttom_up_cut(p, n) {
const dp = new Array(n+1);
dp[0] = 0;
for(let i = 1; i <= n; i++){
// 從規模小的問題開始求解
let q = Number.NEGATIVE_INFINITY;
for(let j = 1; j <= i; j++){
// 求解規模爲i的問題時,能夠直接使用比i規模更小的子問題的結果dp[i-j]
q = Math.max(q, p[j] + dp[i - j]);
}
dp[i] = q;
}
return dp[n];
}
複製代碼
自底向上的方案的思想就是先求出小規模問題,在求解大規模問題時,就能夠直接使用以前計算儲存好的結果。
兩種方案的時間複雜度是相近的,空間複雜度均爲O(n)。可是因爲自底向上的方案沒有調用遞歸函數的開銷,因此通常傾向於使用自底向上的方案。
怎麼判斷一個問題可否用動態規劃去解決呢?《算法導論》一書上提到適合的場景應該具備的兩個要素:最優子結構和子問題重疊
。
大問題的最優解能夠由其分裂出的子問題的最優解推導獲得,就稱該問題具備最優子結構。
例如爬樓梯問題,n階樓梯的最優解由子問題n-1階、子問題n-2階的最優解推導獲得。
刻畫最優解的結構時,分裂出的子問題之間應該是無關
的,不然此種子結構不能用於動態規劃。
無關的意思: 子問題 M 如何構成其最優解不會影響子問題 N 如何構成最優解,則子問題 M 與子問題 N 無關。
舉一個子問題相關的例子。求無權最長路程(無環):假設u爲起點,v爲終點,求u到v的最長路徑(無環)。位置以下圖所示:
假如將原問題u->v拆分爲子問題1 u->w和子問題2 w->v,想要經過求兩個子問題的最長路徑來求得u->v的最長路徑時,就會出現一些問題:
若是不考慮其餘子問題如何求解,直接求解本身的最長路徑時:
u->w的最長路徑是u->t->w, w->v的最長路徑是 w->t->v,出現了一個閉環w->t->w
因此求解子問題2 w->v時,必須考慮子問題1用到了哪些點,子問題1用過的點就不能再用了。子問題2就與子問題1相關了。這種子結構劃分不適合用動態規劃求解。
在動態規劃的遞歸過程當中,若是會反覆地求解相同的子問題,而不是一直生成新的子問題,這樣就稱爲最優化問題具備重疊子問題特性。
動態規劃會把每個子問題的解都存在一張表裏,這樣在求解重複子問題的時候,就能直接查表,時間代價爲常量。動態規劃雖然付出了額外的空間,可是時間上的提高多是巨大的,是典型的時空權衡。
子問題重疊性質與子問題無關並不矛盾,它們描述的是不一樣層面的性質。以鋼鐵切割問題爲例,求解長度爲4的鋼鐵切割問題時,須要考慮到第一次切割後是1+3的狀況
子問題1爲長度1的鋼鐵切割,子問題2爲長度爲3的鋼鐵切割。這兩個子問題是無關的,第一個子鋼鐵怎麼切割與第二個鋼鐵怎麼切割無關。
而求解子問題2時,會進一步拆分子問題,出現子子問題1(長度爲1的鋼鐵切割)、子子問題2(長度爲2的鋼鐵切割)。此時子子問題1和子問題1是同一個問題。 此時該結構就有重疊的子問題。
題目描述來自leetcode
問題
給定兩個單詞 word1 和 word2,計算出將 word1 轉換成 word2 所使用的最少操做數。
你能夠對一個單詞進行以下三種操做:
示例
輸入: word1 = "horse", word2 = "ros"
輸出: 3
解釋:
horse -> rorse (將 'h' 替換爲 'r')
rorse -> rose (刪除 'r')
rose -> ros (刪除 'e')
動態規劃求解
本例不作詳細的分析了,直接給出結構特徵和遞歸方程式:
M爲能夠操做的字符串,N爲目標字符串
m[i]包含字符串M中第1到第i個字符的子字符串(字符位置從1開始計數),n[j]包含字符串N中第1到第j個字符的子字符串,令dp[i][j]爲m[i]與n[j]的最小編輯距離。
第一種狀況是當M[i]與N[j]的字符相同時,不須要進行任何操做,因此dp[i][j] = dp[i-1][j-1]
第二種狀況是當M[i]與N[j]字符不相同時,可能會進行三種操做:
三種操做中,操做數最小的爲dp[i][j]的編輯距離。
延伸
《算法導論》第15章的習題15-5裏有操做步驟更爲複雜的編輯距離問題。編輯距離問題是DNA序列對齊問題的推廣。
動態規劃的核心實際上是找到具備最優子結構的特徵,以及能夠由子問題最優解構造問題最優解的遞歸方程式。只要找到這些,加上備忘的使用,就能夠將一個看起來很複雜須要遍歷的問題,轉化爲多項式時間的帶備忘的遞歸問題。
本文只是我的學習的心得體會,涉及的範圍不夠廣和深,至關於一篇入門介紹。鑑於本身是初學者,可能理解不夠到位,如有錯漏歡迎指出!