如果機房馬上要關門了,或者你急著要和MM約會,請直接跳到第六個自然段。
我們這里說的KMP不是拿來放電影的(雖然我很喜歡這個軟件),而是一種算法。KMP算法是拿來處理字符串匹配的。換句話說,給你兩個字符串,你需要回答,B串是否是A串的子串(A串是否包含B串)。比如,字符串A="I'm matrix67",字符串B="matrix",我們就說B是A的子串。你可以委婉地問你的MM:“假如你要向你喜歡的人表白的話,我的名字是你的告白語中的子串嗎?”
解決這類問題,通常我們的方法是枚舉從A串的什么位置起開始與B匹配,然后驗證是否匹配。假如A串長度為n,B串長度為m,那么這種方法的復雜度是O (mn)的。雖然很多時候復雜度達不到mn(驗證時只看頭一兩個字母就發現不匹配了),但我們有許多“最壞情況”,比如,A= "aaaaaaaaaaaaaaaaaaaaaaaaaab",B="aaaaaaaab"。我們將介紹的是一種最壞情況下O(n)的算法(這里假設 m<=n),即傳說中的KMP算法。
之所以叫做KMP,是因為這個算法是由Knuth、Morris、Pratt三個提出來的,取了這三個人的名字的頭一個字母。這時,或許你突然明白了AVL 樹為什么叫AVL,或者Bellman-Ford為什么中間是一杠不是一個點。有時一個東西有七八個人研究過,那怎么命名呢?通常這個東西干脆就不用人名字命名了,免得發生爭議,比如“3x+1問題”。扯遠了。
個人認為KMP是最沒有必要講的東西,因為這個東西網上能找到很多資料。但網上的講法基本上都涉及到“移動(shift)”、“Next函數”等概念,這非常容易產生誤解(至少一年半前我看這些資料學習KMP時就沒搞清楚)。在這里,我換一種方法來解釋KMP算法。
假如,A="abababaababacb",B="ababacb",我們來看看KMP是怎么工作的。我們用兩個指針i和j分別表示,A[i-j+ 1..i]與B[1..j]完全相等。也就是說,i是不斷增加的,隨著i的增加j相應地變化,且j滿足以A[i]結尾的長度為j的字符串正好匹配B串的前 j個字符(j當然越大越好),現在需要檢驗A[i+1]和B[j+1]的關系。當A[i+1]=B[j+1]時,i和j各加一;什么時候j=m了,我們就說B是A的子串(B串已經整完了),并且可以根據這時的i值算出匹配的位置。當A[i+1]<>B[j+1],KMP的策略是調整j的位置(減小j值)使得A[i-j+1..i]與B[1..j]保持匹配且新的B[j+1]恰好與A[i+1]匹配(從而使得i和j能繼續增加)。我們看一看當 i=j=5時的情況。
i = 1 2 3 4 5 6 7 8 9 ……
A = a b a b a b a a b a b …
B = a b a b a c b
j = 1 2 3 4 5 6 7 此時,A[6]<>B[6]。這表明,此時j不能等于5了,我們要把j改成比它小的值j'。j'可能是多少呢?仔細想一下,我們發現,j'必須要使得B[1..j]中的頭j'個字母和末j'個字母完全相等(這樣j變成了j'后才能繼續保持i和j的性質)。這個j'當然要越大越好。在這里,B [1..5]="ababa",頭3個字母和末3個字母都是"aba"。而當新的j為3時,A[6]恰好和B[4]相等。于是,i變成了6,而j則變成了 4:
i = 1 2 3 4 5 6 7 8 9 ……
A = a b a b a b a a b a b …
B = a b a b a c b
j = 1 2 3 4 5 6 7 從上面的這個例子,我們可以看到,新的j可以取多少與i無關,只與B串有關。我們完全可以預處理出這樣一個數組P[j],表示當匹配到B數組的第j個字母而第j+1個字母不能匹配了時,新的j最大是多少。P[j]應該是所有滿足B[1..P[j]]=B[j-P[j]+1..j]的最大值。
再后來,A[7]=B[5],i和j又各增加1。這時,又出現了A[i+1]<>B[j+1]的情況:
i = 1 2 3 4 5 6 7 8 9 ……
A = a b a b a b a a b a b …
B = a b a b a c b
j = 1 2 3 4 5 6 7 由于P[5]=3,因此新的j=3:
i = 1 2 3 4 5 6 7 8 9 ……
A = a b a b a b a a b a b …
B = a b a b a c b
j = 1 2 3 4 5 6 7 這時,新的j=3仍然不能滿足A[i+1]=B[j+1],此時我們再次減小j值,將j再次更新為P[3]:
i = 1 2 3 4 5 6 7 8 9 ……
A = a b a b a b a a b a b …
B = a b a b a c b
j = 1 2 3 4 5 6 7 現在,i還是7,j已經變成1了。而此時A[8]居然仍然不等于B[j+1]。這樣,j必須減小到P[1],即0:
i = 1 2 3 4 5 6 7 8 9 ……
A = a b a b a b a a b a b …
B = a b a b a c b
j = 0 1 2 3 4 5 6 7 終于,A[8]=B[1],i變為8,j為1。事實上,有可能j到了0仍然不能滿足A[i+1]=B[j+1](比如A[8]="d"時)。因此,準確的說法是,當j=0了時,我們增加i值但忽略j直到出現A[i]=B[1]為止。
這個過程的代碼很短(真的很短),我們在這里給出:
j:=0;
for i:=1 to n do
begin
while (j>0) and (B[j+1]<>A[i]) do j:=P[j];
if B[j+1]=A[i] then j:=j+1;
if j=m then
begin
writeln('Pattern occurs with shift ',i-m);
j:=P[j];
end;
end;
最后的j:=P[j]是為了讓程序繼續做下去,因為我們有可能找到多處匹配。
這個程序或許比想像中的要簡單,因為對于i值的不斷增加,代碼用的是for循環。因此,這個代碼可以這樣形象地理解:掃描字符串A,并更新可以匹配到B的什么位置。
現在,我們還遺留了兩個重要的問題:一,為什么這個程序是線性的;二,如何快速預處理P數組。
為什么這個程序是O(n)的?其實,主要的爭議在于,while循環使得執行次數出現了不確定因素。我們將用到時間復雜度的攤還分析中的主要策略,簡單地說就是通過觀察某一個變量或函數值的變化來對零散的、雜亂的、不規則的執行次數進行累計。KMP的時間復雜度分析可謂攤還分析的典型。我們從上述程序的j 值入手。每一次執行while循環都會使j減小(但不能減成負的),而另外的改變j值的地方只有第五行。每次執行了這一行,j都只能加1;因此,整個過程中j最多加了n個1。于是,j最多只有n次減小的機會(j值減小的次數當然不能超過n,因為j永遠是非負整數)。這告訴我們,while循環總共最多執行了n次。按照攤還分析的說法,平攤到每次for循環中后,一次for循環的復雜度為O(1)。整個過程顯然是O(n)的。這樣的分析對于后面P數組預處理的過程同樣有效,同樣可以得到預處理過程的復雜度為O(m)。
預處理不需要按照P的定義寫成O(m^2)甚至O(m^3)的。我們可以通過P[1],P[2],...,P[j-1]的值來獲得P[j]的值。對于剛才的B="ababacb",假如我們已經求出了P[1],P[2],P[3]和P[4],看看我們應該怎么求出P[5]和P[6]。P[4]=2,那么P [5]顯然等于P[4]+1,因為由P[4]可以知道,B[1,2]已經和B[3,4]相等了,現在又有B[3]=B[5],所以P[5]可以由P[4] 后面加一個字符得到。P[6]也等于P[5]+1嗎?顯然不是,因為B[ P[5]+1 ]<>B[6]。那么,我們要考慮“退一步”了。我們考慮P[6]是否有可能由P[5]的情況所包含的子串得到,即是否P[6]=P[ P[5] ]+1。這里想不通的話可以仔細看一下:
1 2 3 4 5 6 7
B = a b a b a c b
P = 0 0 1 2 3 ? P[5]=3是因為B[1..3]和B[3..5]都是"aba";而P[3]=1則告訴我們,B[1]、B[3]和B[5]都是"a"。既然P[6]不能由P[5]得到,或許可以由P[3]得到(如果B[2]恰好和B[6]相等的話,P[6]就等于P[3]+1了)。顯然,P[6]也不能通過P[3]得到,因為B[2]<>B[6]。事實上,這樣一直推到P[1]也不行,最后,我們得到,P[6]=0。
怎么這個預處理過程跟前面的KMP主程序這么像呢?其實,KMP的預處理本身就是一個B串“自我匹配”的過程。它的代碼和上面的代碼神似:
P[1]:=0;
j:=0;
for i:=2 to m do
begin
while (j>0) and (B[j+1]<>B[i]) do j:=P[j];
if B[j+1]=B[i] then j:=j+1;
P[i]:=j;
end;
最后補充一點:由于KMP算法只預處理B串,因此這種算法很適合這樣的問題:給定一個B串和一群不同的A串,問B是哪些A串的子串。
串匹配是一個很有研究價值的問題。事實上,我們還有后綴樹,自動機等很多方法,這些算法都巧妙地運用了預處理,從而可以在線性的時間里解決字符串的匹配。我們以后來說。
昨天發現一個特別暈的事,知道怎么去掉BitComet的廣告嗎?把界面語言設成英文就行了。
還有,金山詞霸和Dr.eye都可以去自殺了,Babylon素王道。
來自:
http://www.matrix67.com/blog/archives/115
P01: 01背包問題
題目
有N件物品和一個容量為V的背包。第i件物品的費用是c[i],價值是w[i]。求解將哪些物品裝入背包可使這些物品的費用總和不超過背包容量,且價值總和最大。
基本思路
這是最基礎的背包問題,特點是:每種物品僅有一件,可以選擇放或不放。
用子問題定義狀態:即f[i][v]表示前i件物品恰放入一個容量為v的背包可以獲得的最大價值。則其狀態轉移方程便是:f[i][v]=max{f[i-1][v],f[i-1][v-c[i]]+w[i]}。
這個方程非常重要,基本上所有跟背包相關的問題的方程都是由它衍生出來的。所以有必要將它詳細解釋一下:“將前i件物品放入容量為v的背包中”這個子問題,若只考慮第i件物品的策略(放或不放),那么就可以轉化為一個只牽扯前i-1件物品的問題。如果不放第i件物品,那么問題就轉化為“前i-1件物品放入容量為v的背包中”;如果放第i件物品,那么問題就轉化為“前i-1件物品放入剩下的容量為v-c[i]的背包中”,此時能獲得的最大價值就是f [i-1][v-c[i]]再加上通過放入第i件物品獲得的價值w[i]。
注意f[i][v]有意義當且僅當存在一個前i件物品的子集,其費用總和為v。所以按照這個方程遞推完畢后,最終的答案并不一定是f[N] [V],而是f[N][0..V]的最大值。如果將狀態的定義中的“恰”字去掉,在轉移方程中就要再加入一項f[i][v-1],這樣就可以保證f[N] [V]就是最后的答案。至于為什么這樣就可以,由你自己來體會了。
優化空間復雜度
以上方法的時間和空間復雜度均為O(N*V),其中時間復雜度基本已經不能再優化了,但空間復雜度卻可以優化到O(V)。
先考慮上面講的基本思路如何實現,肯定是有一個主循環i=1..N,每次算出來二維數組f[i][0..V]的所有值。那么,如果只用一個數組f [0..V],能不能保證第i次循環結束后f[v]中表示的就是我們定義的狀態f[i][v]呢?f[i][v]是由f[i-1][v]和f[i-1] [v-c[i]]兩個子問題遞推而來,能否保證在推f[i][v]時(也即在第i次主循環中推f[v]時)能夠得到f[i-1][v]和f[i-1][v -c[i]]的值呢?事實上,這要求在每次主循環中我們以v=V..0的順序推f[v],這樣才能保證推f[v]時f[v-c[i]]保存的是狀態f[i -1][v-c[i]]的值。偽代碼如下:
for i=1..N
for v=V..0
f[v]=max{f[v],f[v-c[i]]+w[i]};
其中的f[v]=max{f[v],f[v-c[i]]}一句恰就相當于我們的轉移方程f[i][v]=max{f[i-1][v],f[i- 1][v-c[i]]},因為現在的f[v-c[i]]就相當于原來的f[i-1][v-c[i]]。如果將v的循環順序從上面的逆序改成順序的話,那么則成了f[i][v]由f[i][v-c[i]]推知,與本題意不符,但它卻是另一個重要的背包問題P02最簡捷的解決方案,故學習只用一維數組解01背包問題是十分必要的。
總結
01背包問題是最基本的背包問題,它包含了背包問題中設計狀態、方程的最基本思想,另外,別的類型的背包問題往往也可以轉換成01背包問題求解。故一定要仔細體會上面基本思路的得出方法,狀態轉移方程的意義,以及最后怎樣優化的空間復雜度。
P02: 完全背包問題
題目
有N種物品和一個容量為V的背包,每種物品都有無限件可用。第i種物品的費用是c[i],價值是w[i]。求解將哪些物品裝入背包可使這些物品的費用總和不超過背包容量,且價值總和最大。
基本思路
這個問題非常類似于01背包問題,所不同的是每種物品有無限件。也就是從每種物品的角度考慮,與它相關的策略已并非取或不取兩種,而是有取0件、取1件、取2件……等很多種。如果仍然按照解01背包時的思路,令f[i][v]表示前i種物品恰放入一個容量為v的背包的最大權值。仍然可以按照每種物品不同的策略寫出狀態轉移方程,像這樣:f[i][v]=max{f[i-1][v-k*c[i]]+k*w[i]|0<=k*c[i]<= v}。這跟01背包問題一樣有O(N*V)個狀態需要求解,但求解每個狀態的時間則不是常數了,求解狀態f[i][v]的時間是O(v/c[i]),總的復雜度是超過O(VN)的。
將01背包問題的基本思路加以改進,得到了這樣一個清晰的方法。這說明01背包問題的方程的確是很重要,可以推及其它類型的背包問題。但我們還是試圖改進這個復雜度。
一個簡單有效的優化
完全背包問題有一個很簡單有效的優化,是這樣的:若兩件物品i、j滿足c[i]<=c[j]且w[i]>=w[j],則將物品j去掉,不用考慮。這個優化的正確性顯然:任何情況下都可將價值小費用高得j換成物美價廉的i,得到至少不會更差的方案。對于隨機生成的數據,這個方法往往會大大減少物品的件數,從而加快速度。然而這個并不能改善最壞情況的復雜度,因為有可能特別設計的數據可以一件物品也去不掉。
轉化為01背包問題求解
既然01背包問題是最基本的背包問題,那么我們可以考慮把完全背包問題轉化為01背包問題來解。最簡單的想法是,考慮到第i種物品最多選V/c [i]件,于是可以把第i種物品轉化為V/c[i]件費用及價值均不變的物品,然后求解這個01背包問題。這樣完全沒有改進基本思路的時間復雜度,但這畢竟給了我們將完全背包問題轉化為01背包問題的思路:將一種物品拆成多件物品。
更高效的轉化方法是:把第i種物品拆成費用為c[i]*2^k、價值為w[i]*2^k的若干件物品,其中k滿足c[i]*2^k<V。這是二進制的思想,因為不管最優策略選幾件第i種物品,總可以表示成若干個2^k件物品的和。這樣把每種物品拆成O(log(V/c[i]))件物品,是一個很大的改進。 但我們有更優的O(VN)的算法。 * O(VN)的算法 這個算法使用一維數組,先看偽代碼: <pre class"example"> for i=1..N for v=0..V f[v]=max{f[v],f[v-c[i]]+w[i]};
你會發現,這個偽代碼與P01的偽代碼只有v的循環次序不同而已。為什么這樣一改就可行呢?首先想想為什么P01中要按照v=V..0的逆序來循環。這是因為要保證第i次循環中的狀態f[i][v]是由狀態f[i-1][v-c[i]]遞推而來。換句話說,這正是為了保證每件物品只選一次,保證在考慮“選入第i件物品”這件策略時,依據的是一個絕無已經選入第i件物品的子結果f[i-1][v-c[i]]。而現在完全背包的特點恰是每種物品可選無限件,所以在考慮“加選一件第i種物品”這種策略時,卻正需要一個可能已選入第i種物品的子結果f[i][v-c[i]],所以就可以并且必須采用v= 0..V的順序循環。這就是這個簡單的程序為何成立的道理。
這個算法也可以以另外的思路得出。例如,基本思路中的狀態轉移方程可以等價地變形成這種形式:f[i][v]=max{f[i-1][v],f[i][v-c[i]]+w[i]},將這個方程用一維數組實現,便得到了上面的偽代碼。
總結
完全背包問題也是一個相當基礎的背包問題,它有兩個狀態轉移方程,分別在“基本思路”以及“O(VN)的算法“的小節中給出。希望你能夠對這兩個狀態轉移方程都仔細地體會,不僅記住,也要弄明白它們是怎么得出來的,最好能夠自己想一種得到這些方程的方法。事實上,對每一道動態規劃題目都思考其方程的意義以及如何得來,是加深對動態規劃的理解、提高動態規劃功力的好方法。
P03: 多重背包問題
題目
有N種物品和一個容量為V的背包。第i種物品最多有n[i]件可用,每件費用是c[i],價值是w[i]。求解將哪些物品裝入背包可使這些物品的費用總和不超過背包容量,且價值總和最大。
基本算法
這題目和完全背包問題很類似。基本的方程只需將完全背包問題的方程略微一改即可,因為對于第i種物品有n[i]+1種策略:取0件,取1件……取 n[i]件。令f[i][v]表示前i種物品恰放入一個容量為v的背包的最大權值,則:f[i][v]=max{f[i-1][v-k*c[i]]+ k*w[i]|0<=k<=n[i]}。復雜度是O(V*∑n[i])。
轉化為01背包問題
另一種好想好寫的基本方法是轉化為01背包求解:把第i種物品換成n[i]件01背包中的物品,則得到了物品數為∑n[i]的01背包問題,直接求解,復雜度仍然是O(V*∑n[i])。
但是我們期望將它轉化為01背包問題之后能夠像完全背包一樣降低復雜度。仍然考慮二進制的思想,我們考慮把第i種物品換成若干件物品,使得原問題中第i種物品可取的每種策略——取0..n[i]件——均能等價于取若干件代換以后的物品。另外,取超過n[i]件的策略必不能出現。
方法是:將第i種物品分成若干件物品,其中每件物品有一個系數,這件物品的費用和價值均是原來的費用和價值乘以這個系數。使這些系數分別為 1,2,4,...,2^(k-1),n[i]-2^k+1,且k是滿足n[i]-2^k+1>0的最大整數。例如,如果n[i]為13,就將這種物品分成系數分別為1,2,4,6的四件物品。
分成的這幾件物品的系數和為n[i],表明不可能取多于n[i]件的第i種物品。另外這種方法也能保證對于0..n[i]間的每一個整數,均可以用若干個系數的和表示,這個證明可以分0..2^k-1和2^k..n[i]兩段來分別討論得出,并不難,希望你自己思考嘗試一下。
這樣就將第i種物品分成了O(log n[i])種物品,將原問題轉化為了復雜度為O(V*∑log n[i])的01背包問題,是很大的改進。
O(VN)的算法
多重背包問題同樣有O(VN)的算法。這個算法基于基本算法的狀態轉移方程,但應用單調隊列的方法使每個狀態的值可以以均攤O(1)的時間求解。由于用單調隊列優化的DP已超出了NOIP的范圍,故本文不再展開講解。我最初了解到這個方法是在樓天成的“男人八題”幻燈片上。
小結
這里我們看到了將一個算法的復雜度由O(V*∑n[i])改進到O(V*∑log n[i])的過程,還知道了存在應用超出NOIP范圍的知識的O(VN)算法。希望你特別注意“拆分物品”的思想和方法,自己證明一下它的正確性,并用盡量簡潔的程序來實現。
P04: 混合三種背包問題
問題
如果將P01、P02、P03混合起來。也就是說,有的物品只可以取一次(01背包),有的物品可以取無限次(完全背包),有的物品可以取的次數有一個上限(多重背包)。應該怎么求解呢?
01背包與完全背包的混合
考慮到在P01和P02中最后給出的偽代碼只有一處不同,故如果只有兩類物品:一類物品只能取一次,另一類物品可以取無限次,那么只需在對每個物品應用轉移方程時,根據物品的類別選用順序或逆序的循環即可,復雜度是O(VN)。偽代碼如下:
for i=1..N
if 第i件物品是01背包
for v=V..0
f[v]=max{f[v],f[v-c[i]]+w[i]};
else if 第i件物品是完全背包
for v=0..V
f[v]=max{f[v],f[v-c[i]]+w[i]};
再加上多重背包
如果再加上有的物品最多可以取有限次,那么原則上也可以給出O(VN)的解法:遇到多重背包類型的物品用單調隊列解即可。但如果不考慮超過NOIP范圍的算法的話,用P03中將每個這類物品分成O(log n[i])個01背包的物品的方法也已經很優了。
小結
有人說,困難的題目都是由簡單的題目疊加而來的。這句話是否公理暫且存之不論,但它在本講中已經得到了充分的體現。本來01背包、完全背包、多重背包都不是什么難題,但將它們簡單地組合起來以后就得到了這樣一道一定能嚇倒不少人的題目。但只要基礎扎實,領會三種基本背包問題的思想,就可以做到把困難的題目拆分成簡單的題目來解決。
P05: 二維費用的背包問題
問題
二維費用的背包問題是指:對于每件物品,具有兩種不同的費用;選擇這件物品必須同時付出這兩種代價;對于每種代價都有一個可付出的最大值(背包容量)。問怎樣選擇物品可以得到最大的價值。設這兩種代價分別為代價1和代價2,第i件物品所需的兩種代價分別為a[i]和b[i]。兩種代價可付出的最大值(兩種背包容量)分別為V和U。物品的價值為w[i]。
算法
費用加了一維,只需狀態也加一維即可。設f[i][v][u]表示前i件物品付出兩種代價分別為v和u時可獲得的最大價值。狀態轉移方程就是:f [i][v][u]=max{f[i-1][v][u],f[i-1][v-a[i]][u-b[i]]+w[i]}。如前述方法,可以只使用二維的數組:當每件物品只可以取一次時變量v和u采用順序的循環,當物品有如完全背包問題時采用逆序的循環。當物品有如多重背包問題時拆分物品。
物品總個數的限制
有時,“二維費用”的條件是以這樣一種隱含的方式給出的:最多只能取M件物品。這事實上相當于每件物品多了一種“件數”的費用,每個物品的件數費用均為1,可以付出的最大件數費用為M。換句話說,設f[v][m]表示付出費用v、最多選m件時可得到的最大價值,則根據物品的類型(01、完全、多重)用不同的方法循環更新,最后在f[0..V][0..M]范圍內尋找答案。
另外,如果要求“恰取M件物品”,則在f[0..V][M]范圍內尋找答案。
小結
事實上,當發現由熟悉的動態規劃題目變形得來的題目時,在原來的狀態中加一緯以滿足新的限制是一種比較通用的方法。希望你能從本講中初步體會到這種方法。
P06: 分組的背包問題
問題
有N件物品和一個容量為V的背包。第i件物品的費用是c[i],價值是w[i]。這些物品被劃分為若干組,每組中的物品互相沖突,最多選一件。求解將哪些物品裝入背包可使這些物品的費用總和不超過背包容量,且價值總和最大。
算法
這個問題變成了每組物品有若干種策略:是選擇本組的某一件,還是一件都不選。也就是說設f[k][v]表示前k組物品花費費用v能取得的最大權值,則有f[k][v]=max{f[k-1][v],f[k-1][v-c[i]]+w[i]|物品i屬于第k組}。
使用一維數組的偽代碼如下:
for 所有的組k
for 所有的i屬于組k
for v=V..0
f[v]=max{f[v],f[v-c[i]]+w[i]} 我覺得這個地方寫得有誤 應該是:for 所有的組k
for v=V..0
for 所有的i屬于組k
f[v]=max{f[v],f[v-c[i]]+w[i]} by abilitytao 例題見浙大3264
另外,顯然可以對每組中的物品應用P02中“一個簡單有效的優化”。
小結
分組的背包問題將彼此互斥的若干物品稱為一個組,這建立了一個很好的模型。不少背包問題的變形都可以轉化為分組的背包問題(例如P07),由分組的背包問題進一步可定義“泛化物品”的概念,十分有利于解題。
P07: 有依賴的背包問題
簡化的問題
這種背包問題的物品間存在某種“依賴”的關系。也就是說,i依賴于j,表示若選物品i,則必須選物品j。為了簡化起見,我們先設沒有某個物品既依賴于別的物品,又被別的物品所依賴;另外,沒有某件物品同時依賴多件物品。
算法
這個問題由NOIP2006金明的預算方案一題擴展而來。遵從該題的提法,將不依賴于別的物品的物品稱為“主件”,依賴于某主件的物品稱為“附件”。由這個問題的簡化條件可知所有的物品由若干主件和依賴于每個主件的一個附件集合組成。
按照背包問題的一般思路,僅考慮一個主件和它的附件集合。可是,可用的策略非常多,包括:一個也不選,僅選擇主件,選擇主件后再選擇一個附件,選擇主件后再選擇兩個附件……無法用狀態轉移方程來表示如此多的策略。(事實上,設有n個附件,則策略有2^n+1個,為指數級。)
考慮到所有這些策略都是互斥的(也就是說,你只能選擇一種策略),所以一個主件和它的附件集合實際上對應于P06中的一個物品組,每個選擇了主件又選擇了若干個附件的策略對應于這個物品組中的一個物品,其費用和價值都是這個策略中的物品的值的和。但僅僅是這一步轉化并不能給出一個好的算法,因為物品組中的物品還是像原問題的策略一樣多。
再考慮P06中的一句話: 可以對每組中的物品應用P02中“一個簡單有效的優化”。這提示我們,對于一個物品組中的物品,所有費用相同的物品只留一個價值最大的,不影響結果。所以,我們可以對主件i的“附件集合”先進行一次01背包,得到費用依次為0..V-c[i]所有這些值時相應的最大價值f'[0..V-c[i]]。那么這個主件及它的附件集合相當于V-c[i]+1個物品的物品組,其中費用為c[i]+k的物品的價值為f'[k]+w[i]。也就是說原來指數級的策略中有很多策略都是冗余的,通過一次01背包后,將主件i轉化為 V-c[i]+1個物品的物品組,就可以直接應用P06的算法解決問題了。
更一般的問題
更一般的問題是:依賴關系以圖論中“森林”的形式給出(森林即多叉樹的集合),也就是說,主件的附件仍然可以具有自己的附件集合,限制只是每個物品最多只依賴于一個物品(只有一個主件)且不出現循環依賴。
解決這個問題仍然可以用將每個主件及其附件集合轉化為物品組的方式。唯一不同的是,由于附件可能還有附件,就不能將每個附件都看作一個一般的01 背包中的物品了。若這個附件也有附件集合,則它必定要被先轉化為物品組,然后用分組的背包問題解出主件及其附件集合所對應的附件組中各個費用的附件所對應的價值。
事實上,這是一種樹形DP,其特點是每個父節點都需要對它的各個兒子的屬性進行一次DP以求得自己的相關屬性。這已經觸及到了“泛化物品”的思想。看完P08后,你會發現這個“依賴關系樹”每一個子樹都等價于一件泛化物品,求某節點為根的子樹對應的泛化物品相當于求其所有兒子的對應的泛化物品之和。
小結
NOIP2006的那道背包問題我做得很失敗,寫了上百行的代碼,卻一分未得。后來我通過思考發現通過引入“物品組”和“依賴”的概念可以加深對這題的理解,還可以解決它的推廣問題。用物品組的思想考慮那題中極其特殊的依賴關系:物品不能既作主件又作附件,每個主件最多有兩個附件,可以發現一個主件和它的兩個附件等價于一個由四個物品組成的物品組,這便揭示了問題的某種本質。
我想說:失敗不是什么丟人的事情,從失敗中全無收獲才是。
P08: 泛化物品
定義
考慮這樣一種物品,它并沒有固定的費用和價值,而是它的價值隨著你分配給它的費用而變化。這就是泛化物品的概念。
更嚴格的定義之。在背包容量為V的背包問題中,泛化物品是一個定義域為0..V中的整數的函數h,當分配給它的費用為v時,能得到的價值就是h(v)。
這個定義有一點點抽象,另一種理解是一個泛化物品就是一個數組h[0..V],給它費用v,可得到價值h[V]。
一個費用為c價值為w的物品,如果它是01背包中的物品,那么把它看成泛化物品,它就是除了h(c)=w其它函數值都為0的一個函數。如果它是完全背包中的物品,那么它可以看成這樣一個函數,僅當v被c整除時有h(v)=v/c*w,其它函數值均為0。如果它是多重背包中重復次數最多為n的物品,那么它對應的泛化物品的函數有h(v)=v/c*w僅當v被c整除且v/c<=n,其它情況函數值均為0。
一個物品組可以看作一個泛化物品h。對于一個0..V中的v,若物品組中不存在費用為v的的物品,則h(v)=0,否則h(v)為所有費用為v的物品的最大價值。P07中每個主件及其附件集合等價于一個物品組,自然也可看作一個泛化物品。
泛化物品的和
如果面對兩個泛化物品h和l,要用給定的費用從這兩個泛化物品中得到最大的價值,怎么求呢?事實上,對于一個給定的費用v,只需枚舉將這個費用如何分配給兩個泛化物品就可以了。同樣的,對于0..V的每一個整數v,可以求得費用v分配到h和l中的最大價值f(v)。也即f(v)=max{h(k) +l(v-k)|0<=k<=v}。可以看到,f也是一個由泛化物品h和l決定的定義域為0..V的函數,也就是說,f是一個由泛化物品h和 l決定的泛化物品。
由此可以定義泛化物品的和:h、l都是泛化物品,若泛化物品f滿足f(v)=max{h(k)+l(v-k)|0<=k<=v},則稱f是h與l的和,即f=h+l。這個運算的時間復雜度是O(V^2)。
泛化物品的定義表明:在一個背包問題中,若將兩個泛化物品代以它們的和,不影響問題的答案。事實上,對于其中的物品都是泛化物品的背包問題,求它的答案的過程也就是求所有這些泛化物品之和的過程。設此和為s,則答案就是s[0..V]中的最大值。
背包問題的泛化物品
一個背包問題中,可能會給出很多條件,包括每種物品的費用、價值等屬性,物品之間的分組、依賴等關系等。但肯定能將問題對應于某個泛化物品。也就是說,給定了所有條件以后,就可以對每個非負整數v求得:若背包容量為v,將物品裝入背包可得到的最大價值是多少,這可以認為是定義在非負整數集上的一件泛化物品。這個泛化物品——或者說問題所對應的一個定義域為非負整數的函數——包含了關于問題本身的高度濃縮的信息。一般而言,求得這個泛化物品的一個子域(例如0..V)的值之后,就可以根據這個函數的取值得到背包問題的最終答案。
綜上所述,一般而言,求解背包問題,即求解這個問題所對應的一個函數,即該問題的泛化物品。而求解某個泛化物品的一種方法就是將它表示為若干泛化物品的和然后求之。
小結
本講可以說都是我自己的原創思想。具體來說,是我在學習函數式編程的 Scheme 語言時,用函數編程的眼光審視各類背包問題得出的理論。這一講真的很抽象,也許在“模型的抽象程度”這一方面已經超出了NOIP的要求,所以暫且看不懂也沒關系。相信隨著你的OI之路逐漸延伸,有一天你會理解的。
我想說:“思考”是一個OIer最重要的品質。簡單的問題,深入思考以后,也能發現更多。
P09: 背包問題問法的變化
以上涉及的各種背包問題都是要求在背包容量(費用)的限制下求可以取到的最大價值,但背包問題還有很多種靈活的問法,在這里值得提一下。但是我認為,只要深入理解了求背包問題最大價值的方法,即使問法變化了,也是不難想出算法的。
例如,求解最多可以放多少件物品或者最多可以裝滿多少背包的空間。這都可以根據具體問題利用前面的方程求出所有狀態的值(f數組)之后得到。
還有,如果要求的是“總價值最小”“總件數最小”,只需簡單的將上面的狀態轉移方程中的max改成min即可。
下面說一些變化更大的問法。
輸出方案
一般而言,背包問題是要求一個最優值,如果要求輸出這個最優值的方案,可以參照一般動態規劃問題輸出方案的方法:記錄下每個狀態的最優值是由狀態轉移方程的哪一項推出來的,換句話說,記錄下它是由哪一個策略推出來的。便可根據這條策略找到上一個狀態,從上一個狀態接著向前推即可。
還是以01背包為例,方程為f[i][v]=max{f[i-1][v],f[i-1][v-c[i]]+w[i]}。再用一個數組g[i] [v],設g[i][v]=0表示推出f[i][v]的值時是采用了方程的前一項(也即f[i][v]=f[i-1][v]),g[i][v]表示采用了方程的后一項。注意這兩項分別表示了兩種策略:未選第i個物品及選了第i個物品。那么輸出方案的偽代碼可以這樣寫(設最終狀態為f[N][V]):
i=N
v=V
while(i>0)
if(g[i][v]==0)
print "未選第i項物品"
else if(g[i][v]==1)
print "選了第i項物品"
v=v-c[i]
另外,采用方程的前一項或后一項也可以在輸出方案的過程中根據f[i][v]的值實時地求出來,也即不須紀錄g數組,將上述代碼中的g[i] [v]==0改成f[i][v]==f[i-1][v],g[i][v]==1改成f[i][v]==f[i-1][v-c[i]]+w[i]也可。
輸出字典序最小的最優方案
這里“字典序最小”的意思是1..N號物品的選擇方案排列出來以后字典序最小。以輸出01背包最小字典序的方案為例。
一般而言,求一個字典序最小的最優方案,只需要在轉移時注意策略。首先,子問題的定義要略改一些。我們注意到,如果存在一個選了物品1的最優方案,那么答案一定包含物品1,原問題轉化為一個背包容量為v-c[1],物品為2..N的子問題。反之,如果答案不包含物品1,則轉化成背包容量仍為V,物品為2..N的子問題。不管答案怎樣,子問題的物品都是以i..N而非前所述的1..i的形式來定義的,所以狀態的定義和轉移方程都需要改一下。但也許更簡易的方法是先把物品逆序排列一下,以下按物品已被逆序排列來敘述。
在這種情況下,可以按照前面經典的狀態轉移方程來求值,只是輸出方案的時候要注意:從N到1輸入時,如果f[i][v]==f[i-v]及f[i][v]==f[i-1][f-c[i]]+w[i]同時成立,應該按照后者(即選擇了物品i)來輸出方案。
求方案總數
對于一個給定了背包容量、物品費用、物品間相互關系(分組、依賴等)的背包問題,除了再給定每個物品的價值后求可得到的最大價值外,還可以得到裝滿背包或將背包裝至某一指定容量的方案總數。
對于這類改變問法的問題,一般只需將狀態轉移方程中的max改成sum即可。例如若每件物品均是01背包中的物品,轉移方程即為f[i][v]=sum{f[i-1][v],f[i-1][v-c[i]]+w[i]},初始條件f[0][0]=1。
事實上,這樣做可行的原因在于狀態轉移方程已經考察了所有可能的背包組成方案。
最優方案的總數
這里的最優方案是指物品總價值最大的方案。還是以01背包為例。
結合求最大總價值和方案總數兩個問題的思路,最優方案的總數可以這樣求:f[i][v]意義同前述,g[i][v]表示這個子問題的最優方案的總數,則在求f[i][v]的同時求g[i][v]的偽代碼如下:
for i=1..N
for v=0..V
f[i][v]=max{f[i-1][v],f[i-1][v-c[i]]+w[i]}
g[i][v]=0
if(f[i][v]==f[i-1][v])
inc(g[i][v],g[i-1][v]
if(f[i][v]==f[i-1][v-c[i]]+w[i])
inc(g[i][v],g[i-1][v-c[i]])
如果你是第一次看到這樣的問題,請仔細體會上面的偽代碼。
小結
顯然,這里不可能窮盡背包類動態規劃問題所有的問法。甚至還存在一類將背包類動態規劃問題與其它領域(例如數論、圖論)結合起來的問題,在這篇論背包問題的專文中也不會論及。但只要深刻領會前述所有類別的背包問題的思路和狀態轉移方程,遇到其它的變形問法,只要題目難度還屬于NOIP,應該也不難想出算法。