動態規劃(英語:Dynamic programming,簡稱DP)是一種通過把原問題分解為相對簡單的子問題的方式求解復雜問題的方法。
動態規劃常常適用于有重疊子問題和最優子結構性質的問題,動態規劃方法所耗時間往往遠少于樸素解法。
動態規劃的基本思想:若要解一個給定問題,我們需要解其不同部分(即子問題),再合并子問題的解以得出原問題的解。
通常許多子問題非常相似,為此動態規劃法試圖僅僅解決每個子問題一次,從而減少計算量:一旦某個給定子問題的解已經算出,則將其記憶化存儲,以便下次需要同一個子問題解之時直接查表。這種做法在重復子問題的數目關于輸入的規模呈指數增長時特別有用。
背包問題(Knapsack problem)
一種組合優化的NP完全問題。問題可以描述為:給定一組物品,每種物品都有自己的重量和價格,在限定的總重量內,我們如何選擇,才能使得物品的總價格最高。問題的名稱來源于如何選擇最合適的物品放置于給定背包中。
關于各種背包問題的講解詳見:背包問題九講
這里給出01背包問題和完全背包問題的JavaScript實現
另外,“換零錢問題”也是背包問題中的一種。
01背包問題
有N件物品和一個容量為V的背包。放入第i件物品耗費的空間是Ci,得到的價值是Wi。求解將哪些物品裝入背包可使價值總和最大。
這是最基礎的背包問題。
特點:每種物品僅有一件,可以選擇放或不放。
用子問題定義狀態:即F [i, v]表示前i件物品恰放入一個容量為v的背包可以獲得的最大價值。則其狀態轉移方程便是:
【“將前i件物品放入容量為v的背包中”這個子問題,若只考慮第i件物品的策略(放或不放),那么就可以轉化為一個只和前i ? 1件物品相關的問題。如果不放第i件物品,那么問題就轉化為“前i ? 1件物品放入容量為v的背包中”,價值為F [i ? 1, v];如果放第i件物品,那么問題就轉化為“前i ? 1件物品放入剩下的容量為v ? Ci的背包中”,此時能獲得的最大價值就是F [i ? 1, v ? Ci]再加上通過放入第i件物品獲得的價值Wi?!?/p>
let dp = new Array()
for (let i = 0; i < 1000; i++) {
dp[i] = new Array()
for (let j = 0; j < 1000; j++) {
dp[i][j] = 0
}
}
function pack(n, capacity, costs, values) {
if (n < 0 || capacity < 0) return -1
if (n === 0 || capacity === 0) return 0
for (let i = 0; i <= n; ++i) {
for (let j = 0; j <= capacity; ++j) {
if (i > 0 && j >= costs[i - 1]) {
dp[i][j] = Math.max(dp[i - 1][j],
dp[i - 1][j - costs[i - 1]] + values[i - 1])
}
}
}
return dp[n][capacity]
}
console.log(pack(4, 10, [1, 3, 4, 5],
[3, 6, 2, 8]))
// 輸出
17
完全背包問題
有N種物品和一個容量為V 的背包,每種物品都有無限件可用。放入第i種物品的耗費的空間是Ci,得到的價值是Wi。求解:將哪些物品裝入背包,可使這些物品的耗費的空間總和不超過背包容量,且價值總和最大。
這個問題非常類似于01背包問題,所不同的是每種物品有無限件。也就是從每種物品的角度考慮,與它相關的策略已并非取或不取兩種,而是有取0件、取1件、取2件……直至取?V /Ci?件等很多種。
如果仍然按照解01背包時的思路,令F [i, v]表示前i種物品恰放入一個容量為v的背包的最大權值。仍然可以按照每種物品不同的策略寫出狀態轉移方程,像這樣:
這跟01背包問題一樣有O(V N)個狀態需要求解,但求解每個狀態的時間已經不是常數了,求解狀態F [i, v]的時間是O(v / Ci),總的復雜度可以認為是O(NV Σ(v / Ci)),是比較大的。
將01背包問題的基本思路加以改進,得到了這樣一個清晰的方法。這說明01背包問題的方程的確是很重要,可以推及其它類型的背包問題。但我們還是要試圖改進這個復雜度。
最少換零錢問題
如果我們有面值為1元、3元和5元的硬幣若干枚,如何用最少的硬幣湊夠22元?求出最少硬幣數。
let dp = [1]
function MinRCIter(aim, faceValueArr) {
if (aim < 0) { return 100000000 }
if (aim === 0) { return 0 }
if (dp[aim]) {
return dp[aim]
}
let localMin = 100000000
for (let i = 0; i < faceValueArr.length; i++) {
if (aim === faceValueArr[i]) {
return 1
}
let rest = MinRCIter(aim - faceValueArr[i], faceValueArr)
localMin = Math.min(localMin, rest + 1)
}
dp[aim] = localMin
return dp[aim]
}
function MinReplaceChange(aim, arr) {
console.log('Least: ' + MinRCIter(aim, arr))
}
// 測試
MinReplaceChange(22, [1, 3, 5])
// 輸出
Least: 6
最長遞增子序列(longest increasing subsequence)問題
在一個給定的數值序列中,找到一個子序列,使得這個子序列元素的數值依次遞增,并且這個子序列的長度盡可能地大。最長遞增子序列中的元素在原序列中不一定是連續的。
給定一個長度為n的數組a[0], a[1], a[2]..., a[n-1],找出一個最長的單調遞增子序列(注:遞增的意思是對于任意的i < j,都滿足a[i] < a[j],此外子序列的意思是不要求連續,順序不亂即可)。
例如:給定一個長度為6的數組: [5, 6, 7, 1, 2, 8],則其最長的單調遞增子序列為[5,6,7,8],長度為4。
用dp[i]表示以i結尾的子序列中LIS的長度。然后用
dp[j] (0 <= j < i)
來表示在i之前的LIS的長度。然后我們可以看到,只有當a[i] > a[j]的時候,我們需要進行判斷,是否將a[i]加入到dp[j]當中。
為了保證我們每次加入都是得到一個最優的LIS,有兩點需要注意:(1)每一次,a[i]都應當加入最大的那個dp[j],保證局部性質最優,也就是我們需要找到
max(dp[j] (0 <= j < i))
;(2)每一次加入之后,我們都應當更新dp[j]的值,顯然,dp[i] = dp[j] + 1。
如果寫成遞推公式,我們可以得到dp[i] = max(dp[j] (0 <= j < i)) + (a[i] > a[j] ? 1 : 0)
。
JavaScript實現
【時間復雜度:O(n ^ 2)】
let dp = [1]
let pre = [null]
function lisIter(endWith, listArr) {
if (dp[endWith]) {
return dp[endWith]
}
let localMaxLen = 1
for (let i = 0; i < endWith; i++) {
if (listArr[i] < listArr[endWith]) {
if (localMaxLen < lisIter(i, listArr) + 1) {
localMaxLen = lisIter(i, listArr) + 1
pre[endWith] = i
}
}
}
dp[endWith] = localMaxLen
return dp[endWith]
}
function LIS(arr) {
for (let i = 0; i < arr.length; i++) {
lisIter(i, arr)
}
let answer = -1
let lastNode = -1
for (let i = 0; i < dp.length; i++) {
if (answer < dp[i]) {
answer = dp[i]
lastNode = i
}
}
const seq = []
do {
seq.unshift(arr[lastNode])
lastNode = pre[lastNode]
} while(lastNode !== null)
console.log('length: ' + answer)
console.log('list: ' + seq)
}
// 測試
LIS([3, 5, 8, 2, 9, 10, 4])
// 輸出
length: 5
list: 3,5,8,9,10
斐波那契數列
詳見上一篇《斐波那契數列及其優化》。
漢諾塔問題
詳見《漢諾塔問題》