說明
前面用動態規划
解決了正則表達式的問題,感覺還是不過癮,總覺得對於動態規划
的理解還沒有到位,所以趁熱打鐵,繼續研究幾個動態規划
的經典問題,希望能夠借此加深對動態規划
的理解。在此之前,還需要說兩個跟動態規划有關的理論知識。
最優化原理
最優化原理
指的最優策略具有這樣的性質:不論過去狀態和決策如何,對前面的決策所形成的狀態而言,余下的諸決策必須構成最優策略。簡單來說就是一個最優策略的子策略也是必須是最優的,而所有子問題的局部最優解將導致整個問題的全局最優。如果一個問題能滿足最優化原理
,就稱其具有最優子結構性質
。
這是判斷問題能否使用動態規划解決的先決條件,如果一個問題不能滿足最優化原理,那么這個問題就不適合用動態規划來求解。
這樣說可能比較模糊,來舉個栗子吧:
如上圖,求從A點到E點的最短距離,那么子問題就是求從A點到E點之間的中間點到E點的最短距離,比如這里的B點。
那么這個問題里,怎么證明最優化原理呢?
我們假設從A點到E點的最短距離為d,其最優策略的子策略假設經過B點,記該策略中B點到E點的距離為d1
,A點到B點的距離為d2
。我們可以使用反證法,假設存在B點到E點的最短距離d3
,並且d3 < d1
,那么 d3 + d2 < d1 + d2 = d
,這與d是最短距離相矛盾,所以,d1
是B點到E點的最短距離。
為了增加理解,這里再舉一個反例:
圖中有四個點,A、B、C、D,相鄰兩點有兩條連線,代表兩條通道,d1,d2,d3,d4,d5,d6代表的是道路的長度,求A到D的所有通道中,總長度除以4得到的余數最小的路徑為最優路徑,求一條最優路徑。
這里如果還是按照上面的思路去求解,就會誤入歧途了。按照之前的思路,A的最優取值應該可以由B的最優取值來確定,而B的最優取值為(3+5)mod 4 = 0。所以應該選d2
和d6
這兩條道路,而實際上,全局最優解是d4+d5+d6
或者d1+d5+d3
。所以這里子問題的最優解並不是原問題的最優解,即不滿足最優化原理。所以就不適合使用動態規划來求解了。
無后效性
無后效性
指的是某狀態下決策的收益,只與狀態和決策相關,與到達該狀態的方式無關。某個階段的狀態一旦確定,則此后過程的演變不再受此前各種狀態及決策的影響。換句話說,未來與過去無關,當前狀態是此前歷史狀態的完整總結,此前歷史決策只能通過影響當前的狀態來影響未來的演變。再換句話說,過去做的選擇不會影響現在能做的最優選擇,現在能做的最優選擇只與當前的狀態有關,與經過如何復雜的決策到達該狀態的方式無關。
這也是用來驗證問題是否可以使用動態規划來解答的重要方法。
我們再回頭看看上面的最短路徑問題,如果在原來的基礎上加上一個限制條件:同一個格子只能通過一次。那么, 這個題就不符合無后效性了,因為前一個子問題的解會對后面子問題的選擇策略有影響,比如說,如果從A到B選擇了一條如下圖中綠色表示的路線,那么從B點出發到達E點的路線就只有一條了。也就是說從A點到B點的路徑選擇會影響B點到E點的路徑選擇。
理論部分就此打住,接下來我們實戰一下。
01背包問題
假設你是一名經驗豐富的探險家,背着背包來到野外進行日常探險。天氣晴朗而不燥熱,山間的風夾雜着花香,正當你欣賞這世外桃源般的美景時,突然,你發現了一個洞穴,這個洞穴外表看起來其貌不揚,但憑借着驚為天人的直覺,這個洞穴不簡單。
於是,你開始往洞穴內探索,希望能發現一些有意思的東西。終於,皇天不負有心人,你在洞穴的盡頭,發現了一堆不世出的珠寶,憑借你驚人的閱歷,一眼便看出了它們各自的價值,心想着下下下下下下下下半輩子都有着落了。
然而,天有不測風雲,正准備將它們收入囊中,卻不小心觸碰到一個防御機關,洞穴馬上就要崩塌了。在此危機時刻,你只有一個背包,你必須盡快做出抉擇,從中選擇最值錢的珠寶塞到你的背包,讓背包中珠寶的總價值最大。
好了好了,啰里啰嗦了大半天,我還是來精簡一下問題吧。簡而言之,你只有一個容量有限的背包,總容量為c,有n個可待選擇的物品,每個物品只有一件,它們都有各自的重量和價值,你需要從中選擇合適的組合來使得你背包中的物品總價值最大。
問題分析
那還不簡單,不管是什么,先往背包里塞,塞滿趕緊走,狗命要緊,狗命要緊。。。
好了好了,開個玩笑,言歸正傳。
簡單起見,我們來將上面的問題具體化,舉一個更具體的栗子:
假設有4個物品,它們的價值(v)和重量(w)如下圖:
背包總容量為10,現在要從中選擇物品裝入背包中,要求物品的重量不能超過背包的容量,並且最后放在背包中物品的總價值最大。
emmm,等等,為什么叫做0/1背包
呢?為什么不叫1/2背包
,2/3背包
???
仔細想想,這里每個物品只有一個,對於每個物品而言,只有兩種選擇,盤它或者不盤,盤它記為1,不盤記為0,我們不能將物品進行分割,比如只拿半個是不允許的。這就是這個問題被稱為0/1背包
問題的原因。
所以究竟選還是不選,這是個問題。
讓我們先來體驗一下將珠寶裝入背包的感覺,為了方便起見,用xi
代表第i個珠寶的選擇(xi = 1
代表選擇該珠寶,0則代表不選),vi
代表第i個珠寶的價值,wi
代表第i個珠寶的重量。於是我們就有了這樣的限制條件:
我們的初始狀態是背包容量為10,背包內物品總價值為0,接下來,我們就要開始做選擇了。對於1號珠寶,當前容量為10,容納它的重量2綽綽有余,因此有兩種選擇,選它或者不選。我們選擇一個珠寶的時候,背包的容量會減少,但是里面的物品總價值會增加。就像下面這樣:
這樣就分出了兩種情況,我們繼續進行選擇,如果我們選擇了珠寶1,那么對於珠寶2,當前剩余容量為8,大於珠寶2的容量3,因此也有兩種選擇,選或者不選。
現在,我們得到了四個可能結果,我們每做出一個選擇,就會將上面的每一種可能分裂成兩種可能,后續的選擇也是如此,最終,我們會得到如下的一張決策圖:
這里被塗上色的方框代表我們的最終待選結果,本來應該有16個待選結果,但有三個結果由於容量不足以容納下最后一個珠寶,所以就沒有繼續進行裂變。
然后,我們從這些結果中,找出價值最大的那個,也就是13
,這就是我們的最優選擇,根據這個選擇,依次找到它的所有路徑,便可以知道該選哪幾個珠寶,最終結果是:珠寶4,珠寶2,珠寶1。
分治法
接下來,我們就來分析一下,如何將它擴展到一般情況。為了實現這個目的,我們需要將問題進行抽象並建模,然后將其划分為更小的子問題,找出遞推關系式,這是分治思想中很重要的一步。
- 抽象問題,背包問題抽象為尋找組合(x1,x2,x3...xn,其中xi取0或1,表示第i個物品取或者不取),vi代表第i個物品的價值,wi代表第i個物品的重量,總物品數為n,背包容量為c。
- 建模,問題即求max(x1v1 + x2v2 + x3v3 + ... + xnvn)。
- 約束條件,x1w1 + x2w2 + x3w3 + ... + xnwn < c
- 定義函數KS(i,j):代表當前背包剩余容量為j時,前i個物品最佳組合所對應的價值;
那這里的遞推關系式是怎樣的呢?對於第i個物品,有兩種可能:
- 背包剩余容量不足以容納該物品,此時背包的價值與前i-1個物品的價值是一樣的,KS(i,j) = KS(i-1,j)
- 背包剩余容量可以裝下該商品,此時需要進行判斷,因為裝了該商品不一定能使最終組合達到最大價值,如果不裝該商品,則價值為:KS(i-1,j),如果裝了該商品,則價值為KS(i-1,j-wi) + vi,從兩者中選擇較大的那個,所以就得出了遞推關系式:
對於這個問題的子問題,這里有必要詳細說明一下。原問題是,將n件物品放入容量為c的背包,子問題則是,將前i件物品放入容量為j的背包,所得到的最優價值為KS(i,j),如果只考慮第i件物品放還是不放,那么就可以轉化為一個只涉及到前i-1個物品的問題。如果不放第i個物品,那么問題就轉化為“前i-1件物品放入容量為j的背包中的最優價值組合”,對應的值為KS(i-1,j)。如果放第i個物品,那么問題就轉化成了“前i-1件物品放入容量為j-wi的背包中的最優價值組合”,此時對應的值為KS(i-1,j-wi)+vi。
所以,就可以很容易的寫出遞歸解法了:
public class Solution{
int[] vs = {0,2,4,3,7};
int[] ws = {0,2,3,5,5};
@Test
public void testKnapsack1() {
int result = ks(4,10);
System.out.println(result);
}
private int ks(int i, int c){
int result = 0;
if (i == 0 || c == 0){
// 初始條件
result = 0;
} else if(ws[i] > c){
// 裝不下該珠寶
result = ks(i-1, c);
} else {
// 可以裝下
int tmp1 = ks(i-1, c);
int tmp2 = ks(i-1, c-ws[i]) + vs[i];
result = Math.max(tmp1, tmp2);
}
return result;
}
}
這里為了方便處理,將數組ws和vs都增加了一個補位數0,防止數組越界,輸出結果:
13
這樣,我們就輕松加愉快的解決了這個問題。
動態規划解法
驗證可行性
既然開頭已經說了兩個驗證問題是否可以使用動態規划求解的方法,那么為何不試一試呢?
先來看看最優化原理
。同樣,我們使用反證法:
假設(x1,x2,…,xn)是01背包問題的最優解,則有(x2,x3,…,xn)是其子問題的最優解,假設(y2,y3,…,yn)是上述問題的子問題最優解,則有(v2y2+v3y3+…+vnyn)+v1x1 > (v2x2+v3x3+…+vnxn)+v1x1。說明(X1,Y2,Y3,…,Yn)才是該01背包問題的最優解,這與最開始的假設(X1,X2,…,Xn)是01背包問題的最優解相矛盾,故01背包問題滿足最優性原理
。
至於無后效性
,其實比較好理解。對於任意一個階段,只要背包剩余容量和可選物品是一樣的,那么我們能做出的現階段的最優選擇必定是一樣的,是不受之前選擇了什么物品所影響的。即滿足無后效性
。
自上而下記憶法
就像上一篇里的解法一樣,自上而下的解法與分治法的區別就是增加了一個數組用來存儲計算的中間結果來減少重復計算。這里,我們只需要多定義一個二維數組。
表格中,每一個格子都代表着一個子問題,我們最終的問題是求最右下角的格子的值,也就是i=4,j=10
時的值。這里,我們的初始條件便是i=0或者j=0時對應的ks值為0,這很好理解,如果可選物品為0,或者剩余容量為0,那么最大價值自然也是0。代碼如下:
public class Solution{
int[] vs = {0,2,4,3,7};
int[] ws = {0,2,3,5,5};
Integer[][] results = new Integer[5][11];
@Test
public void testKnapsack2() {
int result = ks2(4,10);
System.out.println(result);
}
private int ks2(int i, int c){
int result = 0;
// 如果該結果已經被計算,那么直接返回
if (results[i][c] != null) return results[i][c];
if (i == 0 || c == 0){
// 初始條件
result = 0;
} else if(ws[i] > c){
// 裝不下該珠寶
result = ks(i-1, c);
} else {
// 可以裝下
int tmp1 = ks(i-1, c);
int tmp2 = ks(i-1, c-ws[i]) + vs[i];
result = Math.max(tmp1, tmp2);
results[i][c] = result;
}
return result;
}
}
可以看到,其實只比分治多了三行代碼。
自下而上填表法
接下來,我們用自下而上的方法來解一下這道題,思路很簡單,就是不斷的填表,回想一下上一篇中的斐波拉契數列的自下而上解法,這里將使用同樣的方式來解決。還是使用上面的表格,我們開始一行行填表。
當i=1時,即只有珠寶1可供選擇,那么如果容量足夠的話,最大價值自然就是珠寶1的價值了。
當i=2時,有兩個物品可供選擇,此時應用上面的遞推關系式進行判斷即可。這里以i=2,j=3為例進行分析:
剩下的格子使用相同的方法進行填充即可:
這樣,我們就得到了最后的結果:13。根據結果,我們可以反向找出各個物品的選擇,尋找的方法很簡單,就是從i=4,j=10
開始尋找,如果ks(i-1,j)=ks(i,j)
,說明第i個物品沒有被選中,從ks(i-1,j)
繼續尋找。否則,表示第i個物品已被選中,則從ks(i-1,j-wi)
開始尋找。
轉化成代碼:
public class Solution{
int[] vs = {0,2,4,3,7};
int[] ws = {0,2,3,5,5};
Integer[][] results = new Integer[5][11];
@Test
public void testKnapsack3() {
int result = ks3(4,10);
System.out.println(result);
}
private int ks3(int i, int j){
// 初始化
for (int m = 0; m <= i; m++){
results[m][0] = 0;
}
for (int m = 0; m <= j; m++){
results[0][m] = 0;
}
// 開始填表
for (int m = 1; m <= i; m++){
for (int n = 1; n <= j; n++){
if (n < ws[m]){
// 裝不進去
results[m][n] = results[m-1][n];
} else {
// 容量足夠
if (results[m-1][n] > results[m-1][n-ws[m]] + vs[m]){
// 不裝該珠寶,最優價值更大
results[m][n] = results[m-1][n];
} else {
results[m][n] = results[m-1][n-ws[m]] + vs[m];
}
}
}
}
return results[i][j];
}
}
嗯,完美解決。時間復雜度即填表耗時O(n * c)
,這里用了一個二維數組來存儲子問題的解,所以空間復雜度為O(n * c)
;
總結
回過頭再看看上面的分析,會發現動態規划里最關鍵的問題其實是尋找原問題的子問題,並寫出遞推表達式,只要完成了這一步,代碼部分都是水到渠成的事情了。
那么問題來了,怎樣把問題拆分成子問題呢?
emmm,這個問題有點超綱了,說實話,我也沒有掌握到訣竅,還是得具體情況具體分析,但是很多經典的問題都有其經典的套路,其它問題都可以歸結到這些問題上面來,可以看做是它們的變種和延伸,把這些經典的問題吃透的話,自然能舉一反三。比如采葯問題,本質上就是01背包問題,而硬幣問題,本質上就是我們之后要介紹的完全背包問題。
個人認為,算法不在於刷多少個,而在於歸納總結,就跟做數學題一樣,總有一些范式和套路,不管形式如何變化,其本質是一樣的,萬變不離其宗,說的就是這么回事。
本篇到此就告一段落了,如果覺得有收獲,不要吝嗇你的贊哦,也歡迎關注我的公眾號留言交流。