聊聊不太符合常規思維的動態規劃演算法

華為雲開發者聯盟發表於2023-03-30
摘要:大部分動態規劃能解決的問題,都可以透過回溯演算法來解決,只不過回溯演算法解決起來效率比較低,時間複雜度是指數級的。動態規劃演算法,在執行效率方面,要高很多。

本文分享自華為雲社群《深入淺出動態規劃演算法》,作者:嵌入式視覺。

一,動態規劃概念

動態規劃比較適合用來求解最優問題,比如求最大值、最小值等等。它可以非常顯著地降低時間複雜度,提高程式碼的執行效率。

它和遞迴一樣都非常難學,主要學習難點在於求解問題的過程不太符合人類常規的思維方式。

二,0-1 揹包問題

對於一組不同重量、不可分割的物品,我們需要選擇一些裝入揹包,在滿足揹包最大重量限制的前提下,揹包中物品總重量的最大值是多少呢?

關於這個 0-1 揹包問題,上一節學習了回溯的解決方法,也就是窮舉搜尋所有可能的裝法(時間複雜度指數級),然後找出滿足條件的最大值。有沒有什麼規律,可以有效降低時間複雜度呢?

1,回溯法的求解過程:

直接看程式碼,規律是不好的,畫個求解過程圖(遞迴樹)會好看些。假設揹包的最大承載重量是 9,有 5 個不同的物品,每個物品的重量分別是 2,2,4,6,3。求解過程的遞迴樹如下圖所示。

聊聊不太符合常規思維的動態規劃演算法

遞迴樹中的每個節點表示一種狀態,我們用(i, cw)來表示。其中,i 表示將要決策第幾個物品是否裝入揹包,cw 表示當前揹包中物品的總重量。比如,(2,2) 表示我們將要決策第 2 個物品是否裝入揹包,在決策前,揹包中物品的總重量是 2。這裡使用回溯演算法,從遞迴樹中可以發現其中有些子問題的求解是重複的,且時間複雜度是指數級的。

使用”備忘錄”(記憶化遞迴)的解決方式,記錄已經計算好的 f(i, cw),當再次計算到重複的 f(i, cw) 的時候,可以直接從備忘錄中取出來用,就不用再遞迴計算了,這樣就可以避免冗餘計算。

int maxW = 0;
int weight[6] = {2,2,4,6,3}; // 物品重量
int n = 5; // 物品個數 
int w = 9; // 揹包承受的最大重量
bool mem[5][10]; // 備忘錄,預設值false
// 記憶化遞迴演算法實現
class SolutionBacktracking{
public:
 void f(int i, int cw){ // i 表示放第 i 個物品,cw 表示當前裝進揹包的物品的重量和
 if (cw == w || i == n) { // cw==w表示裝滿了,i==n表示物品都考察完了
 if(cw > maxW) maxW = cw;
 return;
 }
 if(mem[i][cw]) return; // 重複狀態
        mem[i][cw] = true; // 記錄狀態
 f(i+1, cw); // 不放第 i 個物品
 if(cw+weight[i] <= w)
 f(i+1, cw+weight[i]); // 放第 i 個物品
 }
};

這裡依然是基於回溯演算法實現的,但是採用了記憶化遞迴的方法,時間複雜度和空間複雜度都是 O(n∗(w+1))O(n∗(w+1)),nn 為物品個數,ww 表示揹包承受的最大重量。

2,動態規劃求解過程如下:

把整個求解過程分為 n 個階段,每個階段會決策一個物品是否放到揹包中。每個物品決策(放入或者不放入揹包)完之後,揹包中的物品的重量會有多種情況,也就是說,會達到多種不同的狀態,對應到遞迴樹中,就是有很多不同的節點。

我們把每一層重複的狀態(節點)合併,只記錄不同的狀態,然後基於上一層的狀態集合,來推導下一層的狀態集合。我們可以透過合併每一層重複的狀態,這樣就保證每一層不同狀態的個數都不會超過 w 個(w 表示揹包的承載重量),也就是例子中的 9。於是,我們就成功避免了每層狀態個數的指數級增長。動態規劃方法的計算過程如下圖:

聊聊不太符合常規思維的動態規劃演算法聊聊不太符合常規思維的動態規劃演算法

我們用一個二維陣列 states[n][w+1],來記錄每層可以達到的不同狀態。0-1 揹包問題的動態規劃解法的 C++ 程式碼如下:

class SolutionDP1{
public:
 // weight:物品重量,n:物品個數,w:揹包可承載重量
    int knapsack1(int weight[], int n, int w){
        vector<vector<bool> >states(n, vector<bool>(w+1, false));
 // 初始化 states 第一個階段的狀態
        states[0][0] = true; // 第一個物品不放進揹包
 if(weight[0] <= w) states[0][weight[0]] = true; // 第一個物品放進揹包
 // 動態規劃-分階段
 for(int i=1; i<n;i++){
 for(int j=0; j<w; j++) { // 第 i 個物品不放進揹包{}
 if(states[i-1][j]) states[i][j] = states[i-1][j];
 }
 for(int j=0; j<=w-weight[i];j++){ // 第 i 個物品放入揹包
 if(states[i-1][j]) states[i][j+weight[i]] = true;
 }
 }
 // 在最後一層變數找到最接近 w 的重量並輸出結果
 for(int i=w; i>0; i--){ 
 if(states[n-1][i]) return i;
 }
 return 0;
 }
};

這就是一種用動態規劃解決問題的思路。我們把問題分解為多個階段,每個階段對應一個決策。我們記錄每一個階段可達的狀態集合(去掉重複的),然後透過當前階段的狀態集合,來推導下一個階段的狀態集合,動態地往前推進。這也是動態規劃這個名字的由來,你可以自己體會一下

首先,可以分解為多階段,其次,狀態去重,最後當前階段可以利用上一個階段來獲取。這是動態規劃的關鍵。

我們知道回溯演算法解決這個問題的時間複雜度是 O(2n)O(2n)、指數級,那動態規劃解決方案的時間複雜度是多少呢?來分析一下,這個程式碼的時間複雜度非常好分析,耗時最多的部分就是程式碼中的兩層 for 迴圈,所以時間複雜度是 O(n∗w)O(nw)。nn 表示物品個數,ww 表示揹包可以承載的總重量。

雖然動態規劃的時間效率較高,但是空間複雜度為 O(n∗w)O(nw),對空間消耗比較大。我們可以考慮用一個大小為 w+1w+1 的一維陣列代替二維陣列,減少記憶體消耗。程式碼如下:

class SolutionDP2{
public:
 // weight:物品重量,n:物品個數,w:揹包可承載重量
    int knapsack2(int weight[], int n, int w){
        vector<bool> states(w+1, false);
 // int *states=new int [m+1]; // 動態分配,陣列長度為 m
        states[0] = true; // 第一個物品不放進揹包
 if(weight[0] < w) states[weight[0]] = true; // 第一個物品放進揹包
 // 動態規劃-分階段
 for(int i=1; i<n;i++){
 for(int j=w-weight[i]; j>=0; j--) { // 第 i 個物品放進揹包
 if(states[j]) states[j+weight[i]] = true;
 }
 }
 // 在最後一層變數找到最接近 w 的重量並輸出結果
 for(int i=w;i>0;i--){ 
 if(states[i]) return i;
 }
 return 0;
 }
};

程式分析:遍歷每個物品,將該物品放入揹包時,在不超過最大重量的前提下,再遍歷檢視之前的放入記錄,將之前可能出現的重量的和當前物品的重量相加再記錄下來,等所有方案都嘗試過後,可能出現的揹包重量也都被記錄下來了,最後,從中選擇一個最大值返回。

三,0-1 揹包問題升級版

前面講的揹包問題,只涉及揹包重量和物品重量。現在引入物品價值這一變數。對於一組不同重量、不同價值、不可分割的物品,我們選擇將某些物品裝入揹包,在滿足揹包最大重量限制的前提下,揹包中可裝入物品的總價值最大是多少呢?

1,這個問題依舊可以先用回溯演算法來解決,程式碼如下:

// 0-1 揹包問題升級版的回溯解法
int maxV = 0; // 結果放到maxV中
int weight[] = {22463}; // 物品的重量
int value[] = {34896}; // 物品的價值
int n = 5; // 物品個數
int w = 9; // 揹包承受的最大重量
class Solution{
public:
 void f(int i, int cw, int cv) { // 呼叫f(0, 0, 0)
 if (cw == w || i == n) { // cw==w表示裝滿了,i==n表示物品都考察完了
 if(cv > maxV) maxV = cv;
 return;
 }
 if(cv > maxV) maxV = cv;
 f(i+1, cw, cv); // 不放第 i 個物品
 if(cw+weight[i] <= w) f(i+1, cw+weight[i], cv+value[i]) // 放第 i 個物品
 }
};

2,使用動態規劃解決這個問題更高效。把整個求解過程分為 nn 個階段,每個階段會決策一個物品是否放到揹包中。每個階段決策完之後,揹包中的物品的總重量以及總價值,會有多種情況,也就是會達到多種不同的狀態。我們用一個二維陣列 states[n][w+1],來記錄每層可以達到的不同狀態。

class SolutionDP3{
    int knapsack3(int weight[], int value[], int n, int w) {
        vector<vector<int> > states(n, vector<int>(w+1, -1));
        states[0][0] = 0; // 不放入第 0 個物品
 if(weight[0] < w) states[0][weight[0]] = value[0]; // 放入第 0 個物品
 for(int i=1; i<n; i++){
 for(int j=0; j< w; j++){ // 不放入第 i 個物品
 if(states[i-1][j])  states[i][j] = states[i-1][j];
 }
 for(int j=0; j< w-weight[i]; j++){ // 放入第 i 個物品
                int v = states[i-1][j] + values;
 if(v > states[i][j+weight[i]]) 
                    states[i][j+weight[i]] = v;
 }
 }
        int maxV = -1;
 for(int j = w; j>=0; j--){
 if(states[n-1][j] > maxV) maxV = states[n-1][j];
 }
 return maxV;
 }
}

程式碼的時間複雜度是 O(n⋅w)O(nw),空間複雜度也是 O(n⋅w)O(nw)。

四,總結

大部分動態規劃能解決的問題,都可以透過回溯演算法來解決,只不過回溯演算法解決起來效率比較低,時間複雜度是指數級的。動態規劃演算法,在執行效率方面,要高很多。儘管執行效率提高了,但是動態規劃的空間複雜度也提高了,所以,很多時候,我們會說,動態規劃是一種空間換時間的演算法思想。

五,練習題

5.1,leetcode322 零錢兌換

給你一個整數陣列 coins ,表示不同面額的硬幣;以及一個整數 amount ,表示總金額。計算並返回可以湊成總金額所需的 最少的硬幣個數 。如果沒有任何一種硬幣組合能組成總金額,返回 -1 。

你可以認為每種硬幣的數量是無限的。

動態規劃解法的 C++ 程式碼如下:

class Solution {
public:
    int coinChange(vector<int>& coins, int amount) {
        int Max = amount + 1;
        vector<int> dp(amount + 1, Max);
 dp[0] = 0;
 for (int i = 1; i <= amount; ++i) {
 for (int j = 0; j < (int)coins.size(); ++j) {
 if (coins[j] <= i) {
 dp[i] = min(dp[i], dp[i - coins[j]] + 1);
 }
 }
 }
 return dp[amount] > amount ? -1 : dp[amount];
 }
};

參考資料

初識動態規劃:如何巧妙解決“雙十一”購物時的湊單問題?

 

點選關注,第一時間瞭解華為雲新鮮技術~

相關文章