多年前就聽過這個動態規划,最近在復習常用算法的時候才認真學習了一下,發現蠻有意思,和大家安利一波。
定義:
准確來說,動態規划師吧一個復雜問題分解成若干個子問題,並且尋找最優子問題的一種思想,而不是一種特定的算法。
聽上去和我們常用的遞歸有點類似,但是注意:其中子問題的解被重復使用。也就是利用這個特性,我們可以把一個復雜的問題抽象轉換成一個簡單二維表來進行推演。
動態規划的解題關鍵在於:
- 1.根據問題可能性進行拆分。
從最簡單的情況下進行分析,從下往上逐步分析。 - 2.找到狀態轉移方程式,保存最優解。
方程式其實就是在滿足某個條件下的遞推通項公式,同時也要注意條件范圍和邊界處理。
最有名的是背包問題:將N種類型的物件放到一個容量為M的背包里面,尋找最優解。
一般來說可以用暴力枚舉的方式去算近似最優,但是從空間復雜度和時間復雜度來看使用動態規划更好,因為每一步的結果會保存下來給下一步計算使用,節約了不少時間消耗,最終算法性能極高。
下面舉一個典型的例子,來自牛客網的一道"湊整題":
給你六種面額 1、5、10、20、50、100 元的紙幣,假設每種幣值的數量都足夠多,編寫程序求組成N元(N為0~10000的非負整數)的不同組合的個數。
仔細分析后發現,這是一個用不同類型的面額組合拼湊固定金額的組合最優解問題。由於N為0 ~ 10000的非負數,我們可以假設N取10來分析。
分析結果如圖:
偽代碼如下:
if (j - price[i] >= 0) {
Fn(amount) = Fn(j - price[i]) + Fn-1(amount);
} else {
Fn(amount) = Fn-1(amount);
}
其中的Fn函數可以用一個二維數組來實現,第二維為面額個數(即n),第一維度為amount+1種,用於對應的組合數。
用golang實現如下:
func getTheNum(num int) int {
var dp [5][10000]int
moneys = int[...] {1, 5, 10 ,20 , 50, 100}// 面額數組
for i := 0; i < 5; i++ { // 用前i種面額拼湊1rmb的方法數均為1
dp[i][0] = 1
}
for i := 0; i <= num; i++ { // 用1rmb面額拼湊0金額的方法數都為1
dp[0][i] = 1
}
for i := 1; i < 5; i++ { // 每一種面額組合遞推
for j := 1; j <= num; j++ {
if j - moneys >= 0 { // 當當前金額大於這次循環的面額值,則組合數等於當前i種面額拼湊j金額的組合數+前i+1種面額拼湊j - moneys[i]金額的組合數
dp[i][j] = dp[i-1][j] + dp[i][j - moneys[i]]
} else {
dp[i][j] = dp[i-1][j]
}
}
}
return dp[4][num] // 返回最后一項
}
還可以進一步簡化,因為二維數組保存的結果在每一次循環判斷中都被保存下來了,所以用一維數組也可以保留。改進實現如下:
func SimpleGetNum(num int) int {
var dp [10000]int
moneys = int[...] {1, 5, 10 ,20 , 50, 100}// 面額數組
for i := 0; i <= num; i++ { // 用1rmb面額拼湊0金額的方法數都為1
dp[i] = 1
}
for j := 1; j <= 5; j++ {
for i := 1; i <= num; i++ {
if i >= moneys[j] { // 當前面金額大於面額的時候需要計算前i種面額組合出i - moneys[j]的方法數
dp[i] += dp[i - moneys[j]]
}
}
}
return dp[num]
}
探索的過程就如同RPG游戲,算法真的是一個很有趣的事情,很多都像精巧的數學問題的代碼化。