1. 程式人生 > 其它 >動態規劃演算法原理與實踐

動態規劃演算法原理與實踐

動態規劃(Dynamic Programming)是解決多階段決策問題常用的最優化理論,動態規劃和分治法一樣,也是通過定義子問題,先求解子問題,然後在由子問題的解組合出原問題的解。但是它們之間的不同點是分治法的子問題之間是相互獨立的,而動態規劃的子問題之間存在堆疊關係(遞推關係式確定的遞推關係)。動態規劃方法的原理就是把多階段決策過程轉化為一系列的單階段決策問題,利用各個階段之間的遞推關係,逐個確定每個階段的最優化決策,最終堆疊出多階段決策的最優化決策結果。動態規劃問題有很多模型,常見的有線性模型、(字元或數字)串模型、區間模型、狀態壓縮模型,等等,本節課後面介紹的最長公共子序列問題,就是一個典型的串模型。

動態規劃比窮舉高效,這一點在很多情況下都得到了印證,這常常給人一種錯覺,以為它是高效的多項式時間演算法,但是事實並非如此。動態規劃法對所有子問題求解的內在機制其實是一種廣域搜尋,其效率在很大程度上還是取決於問題本身。每種方法都有自身的侷限性,動態規劃法也不是萬能的。動態規劃適合求解多階段(狀態轉換)決策問題的最優解,也可用於含有線性或非線性遞推關係的最優解問題,但是這些問題都必須滿足最優化原理和子問題的“無後向性”。

  • 最優化原理:最優化原理其實就是問題的最優子結構的性質,如果一個問題的最優子結構是不論過去狀態和決策如何,對前面的決策所形成的狀態而言,其後的決策必須構成最優策略。也就是說,不管之前的決策是否是最優決策,都必須保證從現在開始的決策是在之前決策基礎上的最優決策,則這樣的最優子結構就符合最優化原理。

  • 無後向性(無後效性):所謂“無後向性”,就是當各個階段的子問題確定以後,對於某個特定階段的子問題來說,它之前各個階段的子問題的決策隻影響該階段的決策,對該階段之後的決策不產生影響。

這裡需要解釋一下無後向性。在解釋之前,我們先淡化一下階段的概念,只強調狀態(決策狀態),事實上,這是我本人學動態規劃法過程中的一點經驗。多階段決策過程中,隨著子問題的劃分會產生很多狀態,對於某一個狀態 S 來說,只要 S 狀態確定了以後,S 以後的那些依靠 S 狀態做最優選擇的狀態也就都確定了,S 之後的狀態只受 S 狀態的影響。也就是說,無論之前是經過何種決策途徑來到了 S 狀態,S 狀態確定以後,其後續狀態的演化結果都是一樣的,不會因為到達 S 狀態的決策路徑的不同而產生不同的結果,這就是無後向性。

動態規劃的基本思想

和分治法一樣,動態規劃解決複雜問題的思路也是先對問題進行分解,然後通過求解小規模的子問題再反推出原問題的結果。但是動態規劃分解子問題不是簡單地按照“大事化小”的方式進行的,而是沿著決策的階段來劃分子問題,決策的階段可以隨時間劃分,也可以隨著問題的演化狀態來劃分。分治法要求子問題是互相獨立的,以便分別求解並最終合併出原始問題的解。分治法對所有的子問題都“一視同仁”地進行計算求解,如果分解的子問題中存在相同子問題,就會存在重複求解子問題的情況。

比如某個問題 A,第一次分解為 A1 和 A2 兩個子問題,A1 又可分解為 A11 和 A12 兩個子問題,A2 又分解為 A21 和 A22 兩個子問題,分治法會分別求解 A11、A12、A21 和 A22 四個子問題,即便 A12 和 A21 是相同的子問題,分治法也依然會計算四次子問題的解,這就存在重複計算的問題,重複計算相同的子問題會影響求解的效率。

與之相反,動態規劃法的子問題不是互相獨立的,子問題之間通常有包含關係,甚至兩個子問題可以包含相同的子子問題。比如,子問題 A 的解可能由子問題 C 的解遞推得到,同時,子問題 B 的解也可能由子問題 C 的解遞推得到。對於這種情況,動態規劃法對子問題 C 只求解一次,然後將其結果儲存在一張表中(此表也被稱為備忘錄)。當求解子問題 A 或子問題 B 的時候,如果發現子問題 C 已經求解過(在備忘錄表中能查到),則不再求解子問題 C,而是直接使用從表中查到的子問題 C 的解,避免了子問題 C 被重複計算求解的問題。

動態規劃法不像貪婪法或分治法那樣有固定的演算法實現模式,線性規劃問題和區間動態規劃問題的實現方法就完全不同。作為解決多階段決策最優化問題的一種思想,可以用帶備忘錄的窮舉方法實現,也可以根據堆疊子問題之間的遞推公式用遞推的方法實現。但是從演算法設計的角度分析,使用動態規劃法一般需要四個步驟,分別是:

  1. 定義最優子問題(最優解的子結構)

  2. 定義狀態(最優解的值)

  3. 定義決策和狀態轉換方程(定義計算最優解的值的方法)

  4. 確定邊界條件

這四個問題解決了,演算法也就確定了。接下來就結合兩個例項分別介紹這四個步驟,這兩個例子分別是經典的 0-1 揹包問題和最長公共子序列問題。

定義最優子問題

定義最優子問題,也就是最優解的子結構,它確定問題的優化目標以及如何決策最優解,並對決策過程劃分階段。所謂階段,可以理解為一個問題從開始到解決需要經過的環節,這些環節前後關聯。

劃分階段沒有固定的方法,根據問題的結構,可以是按照時間或動作的順序劃分階段,比如《演算法導論》書中介紹的“裝配線與工作站問題“;也可以是按照問題的組合狀態劃分階段,比如經典的“凸多邊形三角剖分問題”。階段劃分以後,對問題的求解就變成了各個階段分別進行最優化決策,問題的解就變成按照階段順序依次選擇的一個決策序列。

對於 0-1 揹包問題,每選擇裝一個物品可以看做是一個階段,其子問題就可以定義為每次向包中裝(選擇)一個物品,直到超過揹包的最大容量為止。最長公共子序列問題可以按照問題的演化狀態劃分階段,這就需要先定義狀態,有了狀態的定義,只要狀態發生了變化,就可以認為是一個階段。

定義狀態

狀態既是決策的物件,也是決策的結果,對於每個階段來說,對起始狀態施加決策,使得狀態發生改變,得到決策的結果狀態。初始狀態經過每一個階段的決策(狀態改變)之後,最終得到的狀態就是問題的解。當然,不是所有的決策序列施加於初始狀態後都可以得到最優解,只有一個決策序列能得到最優解。狀態的定義是建立在子問題定義基礎之上的,因此狀態必須滿足無後向性要求。必要時,可以增加狀態的維度,引入更多的約束條件,使得狀態定義滿足無後向性要求。

0-1 揹包問題本身是個線性過程,但是如果簡單將狀態定義為裝入的物品編號,也就是定義 s[i] 為裝入第 i 件物品後獲得的最大價值,則子問題無法滿足無後向性要求,原因是之前的任何一個決策都會影響到所有的後序決策(因為裝入物品後背包容量發生變化了),因此需要增加一個維度的約束。

考慮到每裝入一個物品,揹包的剩餘容量就會減少,故而選擇將揹包容量也包含在狀態定義中。最終揹包問題的狀態 s[i,j] 定義為將第 i 件物品裝入容量為 j 的揹包中所能獲得的最大價值。對於最長公共子序列問題,如果定義 str1[1…i] 為第一個字串前 i 個字元組成的子串,定義 str2[1…j] 為第二個字串的前 j 個字元組成的子串,則最長公共子序列問題的狀態 s[i,j] 定義為 str1[1…i] 與 str2[1…j] 的最長公共子序列長度。

定義決策和狀態轉換方程

決策就是能使狀態發生轉變的選擇動作,如果選擇動作有多個,則決策就是取其中能使得階段結果最優的那一個。狀態轉換方程是描述狀態轉換關係的一系列等式,也就是從 n-1 階段到 n 階段演化的規律。狀態轉換取決於子問題的堆疊方式,如果狀態定義得不合適,會導致子問題之間沒有重疊,也就不存在狀態轉換關係了。沒有狀態轉換關係,動態規劃也就沒有意義了,實際演算法就退化為像分治法那樣的樸素遞迴搜尋演算法了。

0-1 揹包問題的決策很簡單,那就是決定是否選擇第 i 件物品,即判斷裝入第 i 件物品獲得的收益最大還是不裝入第 i 件物品獲得的收益最大。如果不裝入第 i 件物品,則揹包內物品的價值仍然是 s[i-1,j] 狀態,如果裝入第 i 件物品,則揹包內物品的價值就變成了 s[i,j-Vi] + Pi 狀態,其中 Vi 和 Pi 分別是第 i 件物品的容積和價值,決策的狀態轉換方程就是:

s[i,j]=max(s[i−1,j],s[i,j−Vi]+Pi)

最長公共子序列問題的決策方式就是判斷 str1[i] 和 str2[j] 的關係,如果 str1[i] 與 str2[j] 相同,則公共子序列的長度應該是 s[i-1,j-1] + 1,否則就分別嘗試匹配 str1[1…i+1] 與 str2[1…j] 的最長公共子序列,以及 str1[1…i] 與 str2[1…j+1] 的最長公共子序列,然後取二者中較大的那個值作為 s[i,j] 的值。最長公共子序列問題的狀態轉換方程就是:

s[i,j]=s[i−1,j−1]+1

其中,str1[i] 與 str2[j] 相同。

s[i,j]=max(s[i,j−1],s[i−1,j])

其中,str1[i] 與 str2[j] 不相同。

確定邊界條件

對於遞迴加備忘錄方式(記憶搜尋)實現的動態規劃方法,邊界條件實際上就是遞迴終結條件,無需額外的計算。對於使用遞推關係直接實現的動態規劃方法,需要確定狀態轉換方程的遞推式的初始條件或邊界條件,否則無法開始遞推計算。

0-1 揹包問題的邊界條件很簡單,就是沒有裝入任何物品的狀態:

s[0,Vmax]=0

若要確定最長公共子序列問題的邊界條件,要從其決策方式入手,當兩個字串中的一個長度為 0 時,其公共子序列長度肯定是 0,因此其邊界條件就是:

s[i,j]=0

其中,i=0 或 j=0。

動態問題分類

對問題進行分類,主要有以下幾種:

達到目標的最小(最大)路徑

問題列表:https://leetcode.com/list/55ac4kuc

宣告

給定目標,找到達到目標的最小(最大)成本/路徑/總和。

方法

在當前狀態之前的所有可能路徑中選擇最小(最大)路徑,然後為當前狀態新增值。

routes[i] = min(routes[i-1], routes[i-2], ... , routes[i-k]) + cost[i]

為目標中的所有值生成最佳解決方案,然後返回目標的值。

for (int i = 1; i <= target; ++i) {
   for (int j = 0; j < ways.size(); ++j) {
       if (ways[j] <= i) {
           dp[i] = min(dp[i], dp[i - ways[j]] + cost / path / sum) ;
       }
   }
}
 
return dp[target]

類似問題

746.最低價攀登樓梯Easy

for (int i = 2; i <= n; ++i) {
   dp[i] = min(dp[i-1], dp[i-2]) + (i == n ? 0 : cost[i]);
}
 
return dp[n]

64.最小路徑總和Medium

for (int i = 1; i < n; ++i) {
   for (int j = 1; j < m; ++j) {
       grid[i][j] = min(grid[i-1][j], grid[i][j-1]) + grid[i][j];
   }
}
 
return grid[n-1][m-1]

322.硬幣找零Medium

for (int j = 1; j <= amount; ++j) {
   for (int i = 0; i < coins.size(); ++i) {
       if (coins[i] <= j) {
           dp[j] = min(dp[j], dp[j - coins[i]] + 1);
       }
   }
}

931.最小下降路徑總和Medium

983.最低票價Medium

650. 2鍵鍵盤Medium

279.完美正方形Medium

1049.最後一塊石頭的重量IIMedium

120.三角形Medium

474.一和零Medium

221.最大廣場Medium

322.硬幣找零Medium

1240.用最小的正方形平鋪一個矩形Hard

174.地下城遊戲Hard

871.最小加油站數Hard

不同路徑

問題列表:Problem List:https://leetcode.com/list/55ajm50i

宣告

給定目標,可以找到許多不同的路勁到達目標​​。

方法

總結所有可能的方法以達到當前狀態。

routes[i] = routes[i-1] + routes[i-2], ... , + routes[i-k]

為目標中的所有值生成總和,然後返回目標的值。

for (int i = 1; i <= target; ++i) {
   for (int j = 0; j < ways.size(); ++j) {
       if (ways[j] <= i) {
           dp[i] += dp[i - ways[j]];
       }
   }
}
 
return dp[target]

類似問題

70.爬樓梯easy

for (int stair = 2; stair <= n; ++stair) {
   for (int step = 1; step <= 2; ++step) {
       dp[stair] += dp[stair-step];   
   }
}

62.獨特的道路Medium

for (int i = 1; i < m; ++i) {
   for (int j = 1; j < n; ++j) {
       dp[i][j] = dp[i][j-1] + dp[i-1][j];
   }
}

1155.目標總數的骰子卷數Medium

for (int rep = 1; rep <= d; ++rep) {
   vector<int> new_ways(target+1);
   for (int already = 0; already <= target; ++already) {
       for (int pipe = 1; pipe <= f; ++pipe) {
           if (already - pipe >= 0) {
               new_ways[already] += ways[already - pipe];
               new_ways[already] %= mod;
           }
       }
   }
   ways = new_ways;
}

PS: 一些問題指出了重複的次數,在這種情況下,還要增加一個迴圈來模擬每個重複。

688.國際象棋騎士的概率Medium

494.目標總和Medium

377.組合和IVMedium

935.騎士撥號器Medium

1223.骰子滾動模擬Medium

416.分割槽相等子集總和Medium

808.湯服務Medium

790. Domino和Tromino平鋪Medium

801.使序列增加的最小掉期

673.最長遞增子序列數Medium

63.獨特之路IIMedium

576.超越界限Medium

1269.經過一些步驟後留在同一個地方的方式數量Hard

1220.母音排列Hard

合併問題

問題列表:https://leetcode.com/list/55aj8s16


此類問題的陳述模式如下:

給定一組數字,考慮到當前數字以及從左側和右側可獲得的最佳數值,找到解決問題的最佳方案。

方法

找到每個間隔的所有最佳解決方案,並返回最佳答案。

// from i to j dp[i][j] = dp[i][k] + result[k] + dp[k+1][j]

從左側和右側獲得最佳效果,併為當前位置新增解決方案。

for(int l = 1; l<n; l++) {
   for(int i = 0; i<n-l; i++) {
       int j = i+l;
       for(int k = i; k<j; k++) {
           dp[i][j] = max(dp[i][j], dp[i][k] + result[k] + dp[k+1][j]);
       }
   }
}
 
return dp[0][n-1]

類似問題

1130.從葉值得出的最小成本樹Medium

for (int l = 1; l < n; ++l) {
   for (int i = 0; i < n - l; ++i) {
       int j = i + l;
       dp[i][j] = INT_MAX;
       for (int k = i; k < j; ++k) {
           dp[i][j] = min(dp[i][j], dp[i][k] + dp[k+1][j] + maxs[i][k] * maxs[k+1][j]);
       }
   }
}

96.唯一二進位制搜尋樹Medium

1039.多邊形的最小分數三角剖分Medium

546.刪除盒子Medium

1000.合併石頭的最低成本Medium

312.爆裂氣球Hard

375.猜猜數字更高或更低IIMedium

字串上的dp

此模式的一般問題陳述可能會有所不同,但大多數情況下會給您兩個字串,而這些字串的長度並不大

問題描述

給定兩個字串s1s2,返回某種結果。

方法

這種模式中的大多數問題都需要一個可以接受O(n^2)複雜度的解決方案。

// i - indexing string s1
// j - indexing string s2
for (int i = 1; i <= n; ++i) {
   for (int j = 1; j <= m; ++j) {
       if (s1[i-1] == s2[j-1]) {
           dp[i][j] = /*code*/;
       } else {
           dp[i][j] = /*code*/;
       }
   }
}

如果給你一個字串s,方法可能幾乎沒有變化

for (int l = 1; l < n; ++l) {
   for (int i = 0; i < n-l; ++i) {
       int j = i + l;
       if (s[i] == s[j]) {
           dp[i][j] = /*code*/;
       } else {
           dp[i][j] = /*code*/;
       }
   }
}

1143.最長公共子序列Medium

for (int i = 1; i <= n; ++i) {
   for (int j = 1; j <= m; ++j) {
       if (text1[i-1] == text2[j-1]) {
           dp[i][j] = dp[i-1][j-1] + 1;
       } else {
           dp[i][j] = max(dp[i-1][j], dp[i][j-1]);
       }
   }
}

647.迴文子串Medium

for (int l = 1; l < n; ++l) {
   for (int i = 0; i < n-l; ++i) {
       int j = i + l;
       if (s[i] == s[j] && dp[i+1][j-1] == j-i-1) {
           dp[i][j] = dp[i+1][j-1] + 2;
       } else {
           dp[i][j] = 0;
       }
   }
}

516.最長迴文序列Medium

1092.最短的公共超序列Medium

72.編輯距離Hard

115.不同的子序列Hard

712.兩個字串的最小ASCII刪除總和Medium

5.最長迴文子串Medium

做決定


問題列表:https://leetcode.com/list/55af7bu7

對於這種模式的一般問題陳述是在該情況下是否決定使用當前狀態。因此,問題需要您在當前狀態下做出決定。

宣告

給定一組值,找到答案,並提供選擇或忽略當前值的選項。

方法

如果決定選擇當前值,請使用先前的結果並且需要考慮當前的狀態;反之亦然,如果您決定忽略當前值,請使用使用值的先前結果。

// i - indexing a set of values
// j - options to ignore j values
for (int i = 1; i < n; ++i) {
   for (int j = 1; j <= k; ++j) {
       dp[i][j] = max({dp[i][j], dp[i-1][j] + arr[i], dp[i-1][j-1]});
       dp[i][j-1] = max({dp[i][j-1], dp[i-1][j-1] + arr[i], arr[i]});
   }
}

198.強盜屋Easy

for (int i = 1; i < n; ++i) {
   dp[i][1] = max(dp[i-1][0] + nums[i], dp[i-1][1]);
   dp[i][0] = dp[i-1][1];
}

121.買賣股票的最佳時間Easy

714.帶有交易費的最佳買賣股票時間Medium

309.有冷卻時間買賣股票的最佳時機Medium

123.最佳買賣股票時間Hard

188.最佳買賣股票時間IVHard

總結

1、動態規劃其實就是窮舉遍歷,並從中找出一個最優的解。因此,各種最短,最長問題都可以考慮動態規劃。動態規劃的窮舉有點特別,因為這類問題存在「重疊⼦問題」,如果

暴⼒窮舉的話效率會極其低下,所以需要「備忘錄」或者「DP table」來優化窮舉過程,避免不必要的計算。

2、雖然動態規劃的核⼼思想就是窮舉求最值,但是問題可以千變萬化,窮舉所有可⾏解其實並不是⼀件容易的事,只有列出正確的「狀態轉移⽅程」才能正確地窮舉。

3、對於狀態轉移方程,很多時候我們不知道 dp 是幾維陣列,其實只要看題目有多少變數,有幾個變數就是幾維,結果是要求的解。其實就是 f(x,y,z)=m; x, y, z, 和 m 的含義都理清楚了,這個狀態方程就對了。

參考文章

為什麼你學不過動態規劃?告別動態規劃,談談我的經驗

Dynamic Programming Patterns