一文講清動態規劃的本質
動態規劃 Dynamic Programming (DP) 是演算法領域的核心思想之一,卻同時也是讓許多學習者感到棘手的難點之一。動態規劃的難點在於它不是簡單的數學推導,也不單純考驗人們的程式設計能力,而更像是一種從思維方式到問題建模的一次深刻訓練。
本文將從動態規劃的定義出發,深入探討動態規劃的本質、應用場景、核心特點、解題思路以及如何逐步提升解決動態規劃問題的能力。
動態規劃的定義和應用:將大問題分解成許多小問題
動態規劃的核心思想可以被概括為一句話:將一個複雜的問題分解成多個簡單的子問題,並透過儲存子問題的解來避免重複計算,從而提高求解效率。
換句話說,動態規劃適用於解決以下兩類問題:
- 最優子結構問題:所謂的最優子結構問題,就是一個最優解可以透過其子問題的最優解而構成。
- 子問題重疊問題:多個子問題在遞迴的過程中會重複出現,並且會被多次計算到。
E.g. 01 - 斐波那契數列 \(\mathrm{I}\)
我們都知道斐波那契的通項公式是 \(F(n) = F(n-1) + F(n-2)\)。以計算斐波那契數列的第五項 \(F(5)\) 為例,很顯然,\(F(5)\) 就是我們需要求解的大問題,而這個大問題可以被逐步分解為兩個小問題,即 \(F(4)\) 和 \(F(3)\) 。只要我們知道了 \(F(4)\) 和 \(F(3)\),那麼我們就可以將這兩項相加來得到最終的結果。這個就是最優子結構的體現。
那什麼時候會出現子問題重疊呢?我們不妨用程式碼來實現斐波那契數列:
// 函式的定義
int fib(int n){
// 根據斐波那契數列的定義:
// 該數列的第零項為0,第一項為1
if (n == 0) return 0;
if (n == 1) return 1;
// 根據式子寫出遞迴程式碼。
return fib(n-1) + fib(n-2);
}
// 執行函式
cout << fib(5) << endl;
這是一個很簡單的遞迴演算法。透過模擬遞迴演算法我們可以畫出一個遞迴圖:
透過觀察這個圖可以發現,在計算 \(F(5)\) 的時候,\(F(3)\) 被 \(F(5)\) 呼叫過一次,同時又被 \(F(4)\) 呼叫一次。相同地,\(F(2)\) 被 \(F(3)\) 呼叫了兩次,被 \(F(4)\) 也呼叫了一次。我們可以發現這個圖中有很多的重複計算,這會大大浪費程式的執行時間。然而這就是所謂的子問題重疊問題。
針對這型別的問題,有什麼解決方案呢?最直接的方法就是把已經計算過的答案儲存下來,下次再呼叫的時候直接獲取結果就可以了,這種方法就是大家常說的“記憶化”。記憶化搜尋的程式碼如下:
memset(memo, -1, sizeof memo);
int fib(int n) {
// 如果已經計算過,直接返回結果
if (memo[n] != -1) return memo[n];
// 否則,遞迴計算,並將結果儲存在 memo 中
memo[n] = fib(n - 1) + fib(n - 2);
return memo[n];
}
這個程式碼與普通斐波那契數列唯一的區別就是增加一個 memo
陣列,判斷某一個值是否已經被計算過。如果已經被計算過就直接返回,而不需要繼續遞迴來消耗時間。
而另一種解決辦法就是“遞推“,我們可以從小到大,先計算 \(F(0)\) 和 \(F(1)\) 並把答案儲存下來,計算 \(F(2)\) 的時候就可以立刻透過將 \(F(0)\) 和 \(F(1)\) 相加得到結果。這種遞推的做法就是所謂的動態規劃思想。而這麼做的程式碼也更加簡潔:
int fib[10005];
fib[0] = 0, fib[1] = 1;
for (int i=2; i<=n; i++)
fib[i] = fib[i-1] + fib[i-2]
cout << f[n] << endl;
因此,動態規劃的核心思想就是透過 記錄 和 重用 子問題的解,避免重複計算,從而提高求解效率。動態規劃是一種典型的 拿空間換時間 的思想(畢竟現在的社會,時間成本比空間成本高太多了)。
動態規劃與分治思想的主要區別就是分治演算法主要應用於子問題不重疊的問題,而針對子問題重疊的問題,動態規劃是一個更好的選擇。
動態規劃的性質
動態規劃的三個基本性質是:最優子結構、子問題重疊 以及 無後效性。前兩個性質已經在第一部分提及過了,本部分主要講解無後效性。
無後效性,又稱馬爾可夫性,這意味著某階段的狀態一旦確定,就不接受該狀態之後決策的影響。即,某狀態以後的過程不會影響之前的狀態,只與當前的狀態有關。用一句話概括就是“過去的事情不會影響未來,只關注當前的狀態”。
除了無後效性,動態規劃的另一個核心在於 最優性原理,即一個最優解包含其子問題的最優解。這一原理是由理查德·貝爾曼(Richard Bellman)提出。最優性原理是動態規劃思想的理論基石。
E.g. 02 - 斐波那契數列 \(\mathrm{II}\)
回到原本斐波那契數列場景,當計算 \(F(5)\) 時,答案只依賴於當前狀態 \(F(4)\) 和 \(F(3)\) 的值,與更早的歷史狀態無關。這就是為什麼,我們在計算 \(F(5)\) 的時候可以直接使用 \(F(3)\) 和 \(F(4)\) 之前被計算出來的結果相加得到答案,而不是從 \(F(1)\) 和 \(F(0)\) 開始重新計算。
與此同時,在具有無後效性的場景中,一旦某一個狀態的值已經被計算下來並可以轉移到下一個狀態時,這個狀態將不會再被改變。換句話說,某狀態以後的過程不會影響以前計算出來的狀態,只與當前的狀態有關。
無後效性是避免重複計運算元問題的前提條件。
動態規劃和遞迴的關聯
在看到這裡,你會發現動態規劃和遞迴這兩個思想有著異曲同工之妙。事實上,大多數的動態規劃問題都可以使用記憶化搜尋(遞迴)來解決。動態規劃和暴力遞迴的關鍵區別如下:
- 遞迴:透過函式自呼叫,將問題分解為子問題,直到達到基本情況。遞迴可能導致大量重複計算,效率較低。
- 動態規劃:透過記錄已解決的子問題的解,避免重複計算。動態規劃通常採用自底向上的方式,效率更高。
因此廣義而言。帶有記憶化搜尋的遞迴演算法也可以被稱之為是動態規劃。
解決動態規劃問題的基本思路
解決動態規劃問題的基本思路如下:
步驟一:確定不同的階段和變數
根據題目的特性,我們可以把一個問題拆解成小問題,將一個小問題拆分成一個個次小問題,以此類推。那麼在這之中,每一個需要解決的問題就被稱之為一個階段(狀態)。因此對於動態規劃問題,第一步需要做的就是嘗試如何把大問題分解成許多個有關聯的問題。同時,我們需要找到每一個階段之間都是透過什麼變數關聯起來的。在斐波那契數列問題中,變數就是斐波那契數列的項數。
步驟二:確定狀態轉移方程式
在動態規劃中,我們把每一個“階段”稱之為一個狀態。例如,\(F(5)\) 就可以被稱之為是一個狀態。我們需要搞清楚大問題的狀態和小問題的狀態之間的關聯。
在斐波那契數列中,這個關聯就是:序列的第 \(i\) 項為序列的第 \(i-1\) 和第 \(i-2\) 項的和。用數學表示就是 \(F(n) = F(n-1) + F(n-2)\)。在其他的例子中(更加現實一點的),狀態轉移方程往往與決策有著密不可分的關係(例子請參考【E.g. 03 - 數樓梯】)。
在設計狀態轉移方程式時,你需要包括所有可能涉及到的變數。以確保能在後續的程式實現步驟中保留所有的可能性。
步驟三:確定初始狀態和邊界條件
將問題分解成不能夠再被分解的問題,這些問題被稱之為最小問題。對於每一個最小問題,應當可以做到直接得出答案,而不需要透過計算。
例如在斐波那契數列中,所謂的最小問題就是 \(F(0)\) 和 \(F(1)\) 的值。這兩個值的計算不需要依賴於任何的遞推式子,而是根據定義或者邏輯直接給出的。
因此我們需要設定 \(F(0)\) 和 \(F(1)\) 的初始值,並且將這兩個狀態設定成邊界條件。
E.g. 03 - 數樓梯
題目描述
一個樓梯有 \(N\) 級臺階,上樓可以一步上一階,也可以一步上二階。請你編寫一個程式,計算走到第 \(N\) 級臺階共有多少種不同的走法。
解題思路
這是一道動態規劃的經典例題,對於初學者而言可能對這道題目沒有任何的頭緒,讓我們按照【解決動態規劃問題的基本思路】一步一步來解決這道題。
首先確定不同的階段,對於這道題而言,很顯然不同的的階段就是走到不同階數的方案數,而當前樓梯的階級就是唯一的變數。每上一層樓梯,這個變數就會增加。我們不妨定義 \(dp_i\) 表示走到第 \(i\) 階樓梯的方案數。
接下來確定變數和狀態轉移方程,在這個場景中,我們思考每一個狀態之間的關聯。假設我們要知道走到第十級臺階的方案數,但是我們知道想要走到第十級臺階,我們只有兩個可能性(決策):
- 從第九級臺階邁一步走到第十級臺階。
- 從第八級臺階邁兩步走到第十級臺階。
除了這兩種方法,我們沒有選擇。換句話說,走到某一級臺階的方案數只跟走到這之前一級和之前兩級臺階的方案數有關係(無後效性),並且方案數就是這兩種可能性相加的和(邏輯上也是這樣子的)。用一個更寬泛的式子來描述就是:
而這個式子就是所謂的狀態轉移方程式。
因此,在設計狀態轉移方程式的時候我們著重觀察“可能性”和“決策”這兩個關鍵點。
最後就是確定初始狀態和邊界條件了,根據題目要求,狀態的邊界條件在 \(1\le i \le n\):
- 當只有一級臺階的時候,只有一種可能性,就是從初始平臺邁一步來到第一級臺階。因此 \(dp_1 = 1\)。
- 當有兩級臺階的時候,有兩種可能,一種是從初始平臺邁兩步直接來到第二級臺階,另外一種就是連續走兩步走到第二級臺階。因此 \(dp_2 = 2\)。
- 到了第三級臺階,套用公式可以得到 \(dp_3 = dp_1 + dp_2\),因此不再需要繼續計算初始條件了。
最後,本題的 C++ 標準程式碼如下:
#include <iostream>
using namespace std;
int n, dp[35];
int main(){
dp[1] = 1, dp[2] = 2;
cin >> n;
for (int i=3; i<=n; i++)
dp[i] = dp[i-2] + dp[i-1];
cout << dp[n];
return 0;
}
E.g. 04 - 最長公共子序列
題目描述
給定兩個字串 X
和 Y
,求出這兩個字串的最長公共子序列 LCS 的長度。注意,這裡的子序列不要求連續,但必須保持順序。
樣例輸入輸出:
輸入: X = "ABCBDAB", Y = "BDCAB"
輸出: LCS長度 = 4,LCS = "BCAB"
解題思路
這道題相比較前面的題目難度有一定的提升,我們依舊按照前文提到的基本思路來一步一步地解決這個動態規劃問題。
首先來確定不同的狀態(階段),我們的目標是找出兩個字串 X
和 Y
的最長公共子序列。為了將問題分解,我們可以定義階段和變數如下:
- 階段: 我們考察兩個字串的字首,分別為
X[0:i]
和Y[0:j]
(即X
的前i
個字元和Y
的前j
個字元)。 - 變數: 兩個變數
i
和j
用於表示字串X
和Y
的子問題範圍。
在每個階段,我們的任務是確定 X[0:i]
和 Y[0:j]
的最長公共子序列的長度。這就將整個問題分解為多個規模更小的子問題。
因此,我們考慮用 \(dp_{i, j}\) 來表示字串 X[0:i]
和 Y[0:j]
的最長公共子序列的長度。
看到這裡,很多人會有些許疑問(其實我當初也對這個狀態的定義感到困惑,但在經過大量的實踐後我發現這確實是最好的狀態定義方法。事實上,我認為狀態的定義在某種程度上確實是一種玄學,如果實在沒有辦法搞懂為什麼這麼定義的話就直接理解就行,不需要刨根問底。但我依然準備了幾點理由):
為什麼定義階段為 X[0:i]
和 Y[0:j]
?
原因 1:遞迴思想的啟發
公共子序列的性質決定了大問題可以由小問題遞迴構建:
- 假如我們已經知道了
X[0:i-1]
和Y[0:j-1]
的最長公共子序列,那麼我們可以根據X[i-1]
和Y[j-1]
的關係推匯出X[0:i]
和Y[0:j]
的結果。 - 這表明,將大問題劃分為較短的字首問題是自然的。
原因 2:易於表示動態變化
透過字首的定義,我們可以在任意時刻只關注子字串 X[0:i]
和 Y[0:j]
:
- 當前階段的解法只需要依賴之前的階段,而不用考慮整個字串。
- 動態規劃的核心是利用階段間的狀態關係來避免重複計算,透過子問題的答案逐步推匯出最終答案。
為什麼選擇子字串的字首?
原因 1:動態規劃的自底向上特性
動態規劃要求我們從最小的問題開始求解,而字首是自然的分解方式:
- 對於
X[0:i]
和Y[0:j]
,最小的子問題是i=0
或j=0
,即兩個字串中任意一個為空時,公共子序列長度為 0。 - 從空字串到完整字串的逐步擴充套件非常符合動態規劃的思路。
原因 2:控制問題規模
透過逐步擴充套件字首,我們可以在二維表格 dp[i][j]
中用已知的小問題結果構造更大的問題:
- 如果我們隨機考察
X
和Y
的任意部分(如子序列中間),那麼問題之間的關聯關係會變得複雜,動態規劃就難以實施。 - 而利用字首作為階段,每次只需要看最後一個字元的關係,並結合已有的子問題結果,問題變得簡單而清晰。
接下來就到了第二步,確定狀態轉移方程式。透過考察字串 X
和 Y
的最後一個字元,我們先把所有的可能性的找出來:
【情況一】當 X[i-1] == Y[j-1]
時(即兩個字串的最後一個字元相同):
這意味著這兩個字元可以成為最長公共子序列的一部分。因此,我們可以將這兩個字元加入到之前計算的最長公共子序列中。因此可得 \(dp_{i, j} = dp_{i-1, j-1}\)。 這裡的 dp[i][j]
表示字串 X
的前 i
個字元和字串 Y
的前 j
個字元的最長公共子序列的長度。
dp[i-1][j-1]
表示在不考慮這兩個最後字元的情況下,字串 X
的前 i-1
個字元和字串 Y
的前 j-1
個字元的最長公共子序列的長度。由於 X[i-1]
和 Y[j-1]
相同,我們將這兩個字元加入到子序列中,因此長度增加了 1。
【情況二】當 X[i-1] != Y[j-1]
時(即兩個字串的最後一個字元不同):
由於最後一個字元不同,我們無法同時將它們加入到公共子序列中。因此,我們需要決定是排除 X
的最後一個字元還是排除 Y
的最後一個字元,以找到更長的公共子序列。因此可得 \(dp_{i, j} = \max(dp_{i-1, j}, dp_{i, j-1})\)。
dp[i-1][j]
表示排除 X
的最後一個字元後,字串 X
的前 i-1
個字元和字串 Y
的前 j
個字元的最長公共子序列的長度。
dp[i][j-1]
表示排除 Y
的最後一個字元後,字串 X
的前 i
個字元和字串 Y
的前 j-1
個字元的最長公共子序列的長度。
我們取這兩者中的較大值,確保選擇了能夠形成最長子序列的路徑。
最後就是處理邊界了和初始條件了,當其中一個字串為空時(即 i==0 || j == 0
),則公共子序列的長度為零。
最後,本題的 C++ 程式碼如下:
#include <iostream>
#include <algorithm>
using namespace std;
const int N = 100005;
int a[N], b[N], dp[N][N];
int main(){
int n; cin >> n;
for (int i=1; i<=n; i++) cin >> a[i];
for (int i=1; i<=n; i++) cin >> b[i];
for (int i=1; i<=n; i++){
for (int j=1; j<=n; j++){
if (a[i] == b[j]){
dp[i][j] = dp[i-1][j-1]+1;
} else{
dp[i][j] = max(dp[i-1][j], dp[i][j-1]);
}
}
}
cout << dp[n][n] << endl;
return 0;
}
E.g. 05 - 最長上升子序列
題目描述
給定一個長度為 \(N\) 的序列 \(S = a_1, a_2, \cdots, a_n\),請求求出這個序列的最長上升子序列。子序列不要求連續,但每一個元素出現的順序需要與原序列中元素出現的順序對應。
解題思路
按照相同的方法,我們先嚐試把這個大問題分解成小問題。透過觀察題目,注意到長度為 \(N\) 的序列的最長上升子序列一定是由一個更短的序列新增一個元素得來的,因此我們只需要計算將這個元素拼接到哪一個更短的序列中可以得到一個更長的上升子序列即可。因此可以定義 \(dp_i\) 為以第 \(i\) 個元素為結尾的最長上升子序列的長度。
接下來是狀態轉移方程,根據上述的定義可以得到 \(dp_i = \max_{S_j < S_i, 1\le j < i}(dp_j + 1)\)。該如何理解呢?
為了計算 \(dp_i\),我們需要分析以 \(S_i\) 為結尾時,最長上升子序列的可能性。對於每一個 \(S_i\),我們需要考慮之前所有的元素(即 \(S_1\) 到 \(S_{i-1}\)),是否可以成為 \(S_i\) 的前驅:
-
如果 \(S_j < S_i\) 且 \(j < i\),說明 \(S_i\) 可以接在以 \(S_j\) 為結尾的最長上升子序列後面,構成一個更大的上升子序列。
-
那麼這個更大的上升子序列的長度就可以表示為:
\[dp_i = \max(dp_j + 1),對於所有 j 滿足 S_j < S_i 且 j < i $$。 \]
最後就是初始值了,我們需要一開始把 dp
陣列中的所有元素都設定為 \(1\),因為無論如何,陣列中的任意一個元素都可以自成一個長度為 \(1\) 的上升子序列。
最後 C++ 程式碼如下:
#include <iostream>
using namespace std;
const int N = 10005;
int n, count, ans;
int arr[N], dp[N];
int main(){
cin >> n;
for (int i=1; i<=n; i++){
cin >> arr[i]; dp[i] = 1;
}
for (int i=1; i<=n; i++){
for (int j=1; j<i; j++){
if (arr[i] > arr[j]){
dp[i] = max(dp[i], dp[j]+1);
}
}
}
for (int i=1; i<=n; i++)
ans = max(ans, dp[i]);
cout << ans << endl;;
return 0;
}
E.g. 06 - 鈔票問題
題目描述
Macw 在一個神奇的國家,這個國家的貨幣只有 \(1\) 元,\(5\) 元, \(11\) 元三種面值的鈔票,現在 Macw 想購買一個價值為 \(n\) 元的物品,請問 Macw 最少需要準備多少張鈔票剛好能夠湊夠 \(n\) 元?
解題思路
這道題也是動態規劃問題的經典題型。依舊按照上述動態規劃“三步曲”,我們先來將問題分解出來,既然我們不知道湊夠 \(n\) 元最少需要的鈔票數量,但是與【E.g. 03 - 數樓梯】類似,我們可以將問題分解為湊夠 \(n-1, n-2, n-3, \cdots, 1\) 元錢所需要的鈔票數量,並從小到大來解決。因此我們定義狀態 \(dp_i\) 為湊夠 \(i\) 元錢所需要的最少鈔票數量。
接下來找狀態轉移方程,依舊尋找可能的可能性。假設我們現在有 \(i\) 元錢,我不知道湊夠 \(i\) 元錢所需要的鈔票,但是我們知道:
- 我只需要在 \(i- 1\) 元錢所需要的最少面額下增加一張面額為 \(1\) 的錢即可湊到 \(i\) 元錢。
- 我只需要在 \(i- 5\) 元錢所需要的最少面額下增加一張面額為 \(5\) 的錢即可湊到 \(i\) 元錢。
- 我只需要在 \(i- 11\) 元錢所需要的最少面額下增加一張面額為 \(11\) 的錢即可湊到 \(i\) 元錢。
以上就是三種選擇性,我們只需要找到所需鈔票數量最少的那一個組合就好了,即:
化簡可得:
相同地,由於一開始我們不知道湊夠某一個特定面值所需要的最少鈔票數量,因此我們將 \(dp_i\) 全部賦值為 \(\infty\)。而對於 \(dp_0\) 我們知道湊夠 \(0\) 元錢不需要任何一張鈔票,因此我們特設 \(dp_0 = 0\) 作為我們的初始值。
最後,我們的 C++ 程式碼如下:
#include <iostream>
#include <cstring>
using namespace std;
const int N = 2e6 + 10;
int dp[N];
int c[5] = {1, 5, 11} , n;
int main() {
cin >> n;
memset(dp, 0x3f, sizeof(dp));
dp[0] = 0;
for (int i = 1; i <= n; i++) {
for (int j = 0; j < 3 ; j++){
// 如果減去數字後索引出現負數,那麼應該跳過迴圈
if (i >= c[j])
dp[i] = min((dp[i - c[j]] + 1) , dp[i]);
}
}
cout << dp[n] << endl;
return 0;
}
以上就是一些基礎的動態規劃問題的解決方案。實際上,動態規劃問題是演算法和程式設計競賽中的非常一大塊內容,難度較高的動態規劃問題通常與其他演算法一起出現,也有許多不同的最佳化方法降低動態規劃的時間複雜度和空間複雜度(例如線段樹最佳化 DP、斜率最佳化 DP、單調佇列最佳化 DP 等)。
我對動態規劃問題學習的建議
說句實話,在我剛學習動態規劃問題的時候也是一知半解,一直是一個一知半解的狀態,許多題目也都是憑感覺做的。但隨著刷題量越來越多,我也漸漸發現部分動態規劃是有跡可循的,大部分題目都可以套類似的狀態的定義和狀態轉移方程。畢竟動態規劃這個思想本身也特別難懂,還是得需要自己去多練習才可以發現規律和找到解題技巧。
總而言之,動態規劃是一種強大的演算法設計方法,適用於具有最優子結構、無後效性和子問題重疊性質的問題。該思想透過將複雜問題分解為子問題,記錄並重用子問題的解,動態規劃能夠高效地求解多階段決策問題。理解並掌握動態規劃的本質,對於解決複雜問題和最佳化演算法效能具有重要意義。
References
GeeksforGeeks. (n.d.). Dynamic Programming. Retrieved from https://www.geeksforgeeks.org/dynamic-programming/
Gupta, R. (2023). Mastering Dynamic Programming: A Step-by-Step Guide. Medium. Retrieved from https://medium.com/@connectto.guptaraghav/mastering-dynamic-programming-a-step-by-step-guide-741d9d7d142
Stanford University. (n.d.). Dynamic Programming. Retrieved from https://web.stanford.edu/class/cs97si/04-dynamic-programming.pdf
Stanford University. (2013). Dynamic Programming Lecture Notes (CS161). Retrieved from https://web.stanford.edu/class/archive/cs/cs161/cs161.1138/lectures/16/Small16.pdf
Bellman, R. (1966). Dynamic Programming. Science, 153(3731), 34–37. doi:10.1126/science.153.3731.34