第一講 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的背包中”,價值為f[i-1][v];如果放第i件物品,那么問題就轉化為“前i-1件物品放入剩下的容量為v-c[i]的背包中”,此時能獲得的最大價值就是f[i-1][v-c[i]]再加上通過放入第i件物品獲得的價值w[i]。
優化空間復雜度
以上方法的時間和空間復雜度均為O(VN),其中時間復雜度應該已經不能再優化了,但空間復雜度卻可以優化到O。
先考慮上面講的基本思路如何實現,肯定是有一個主循環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背包中的物品過程,以后的代碼中直接調用不加說明。
過程ZeroOnePack,表示處理一件01背包中的物品,兩個參數cost、weight分別表明這件物品的費用和價值。
procedure ZeroOnePack(cost,weight) for v=V..cost f[v]=max{f[v],f[v-cost]+weight}
注意這個過程里的處理與前面給出的偽代碼有所不同。前面的示例程序寫成v=V..0是為了在程序中體現每個狀態都按照方程求解了,避免不必要的思維復雜度。而這里既然已經抽象成看作黑箱的過程了,就可以加入優化。費用為cost的物品不會影響狀態f[0..cost-1],這是顯然的。
有了這個過程以后,01背包問題的偽代碼就可以這樣寫:
for i=1..N ZeroOnePack(c[i],w[i]);
初始化的細節問題
我們看到的求最優解的背包問題題目中,事實上有兩種不太相同的問法。有的題目要求“恰好裝滿背包”時的最優解,有的題目則並沒有要求必須把背包裝滿。一種區別這兩種問法的實現方法是在初始化的時候有所不同。
如果是第一種問法,要求恰好裝滿背包,那么在初始化時除了f[0]為0其它f[1..V]均設為-∞,這樣就可以保證最終得到的f[N]是一種恰好裝滿背包的最優解。
如果並沒有要求必須把背包裝滿,而是只希望價格盡量大,初始化時應該將f[0..V]全部設為0。
為什么呢?可以這樣理解:初始化的f數組事實上就是在沒有任何物品可以放入背包時的合法狀態。如果要求背包恰好裝滿,那么此時只有容量為0的背包可能被價值為0的nothing“恰好裝滿”,其它容量的背包均沒有合法的解,屬於未定義的狀態,它們的值就都應該是-∞了。如果背包並非必須被裝滿,那么任何容量的背包都有一個合法解“什么都不裝”,這個解的價值為0,所以初始時狀態的值也就全部為0了。
這個小技巧完全可以推廣到其它類型的背包問題,后面也就不再對進行狀態轉移之前的初始化進行講解。
一個常數優化
前面的偽代碼中有 for v=V..1,可以將這個循環的下限進行改進。
由於只需要最后f[v]的值,倒推前一個物品,其實只要知道f[v-w[n]]即可。以此類推,對以第j個背包,其實只需要知道到f[v-sum{w[j..n]}]即可,即代碼中的
for i=1..N for v=V..0
可以改成
for i=1..n bound=max{V-sum{w[i..n]},c[i]} for v=V..bound
這對於V比較大時是有用的。
小結
01背包問題是最基本的背包問題,它包含了背包問題中設計狀態、方程的最基本思想,另外,別的類型的背包問題往往也可以轉換成01背包問題求解。故一定要仔細體會上面基本思路的得出方法,狀態轉移方程的意義,以及最后怎樣優化的空間復雜度。
零一背包(V-O(V*M))代碼
#include<stdio.h> #include<stdlib.h> #include<bits/stdc++.h> #define FORa(i,s,e) for(int i=s;i<=e;i++) #define FORs(i,s,e) for(int i=s;i>=e;i--) #define gc pa==pb&&(pb=(pa=buf)+fread(buf,1,100000,stdin),pa==pb)?EOF:*pa++ #define File(name) freopen(name".in","r",stdin);freopen(name".out","w",stdout); using namespace std; static char buf[100000],*pa=buf,*pb=buf; inline int read(); const int N=10,M=100; int n,m; struct Node{ int w,c; }a[N+1]; inline int max(int a,int b){return a>b?a:b;} int main() { int f[N][M+1]; scanf("%d%d",&m,&n); FORa(i,1,n) scanf("%d%d",&a[i].w,&a[i].c); FORa(i,0,m) f[0][i]=0;//初始化背包的含義為f[i][j]前i個物品放到容量為j的背包中的最大價值 FORa(i,1,n) FORs(j,m,0) if(j<a[i].w) f[i][j]=f[i-1][j];//放不下,繼承 else f[i][j]=max(f[i-1][j],f[i-1][j-a[i].w]+a[i].c); printf("%d",f[n][m]); return 0; } inline int read() { register int x(0);register int f(1);register c(gc); while(c<'0'||c>'9') f=c=='-'?-1:1,c=gc; while(c>='0'&&c<='9') x=(x<<1)+(x<<3)+(c^48),c=gc; return x*f; }
零一背包(V-O(V))代碼
#include<stdio.h>
#include<stdlib.h> #include<bits/stdc++.h> #define FORa(i,s,e) for(int i=s;i<=e;i++) #define FORs(i,s,e) for(int i=s;i>=e;i--) #define gc pa==pb&&(pb=(pa=buf)+fread(buf,1,100000,stdin),pa==pb)?EOF:*pa++ #define File(name) freopen(name".in","r",stdin);freopen(name".out","w",stdout); using namespace std; static char buf[100000],*pa=buf,*pb=buf; inline int read(); const int N=10,M=100; int n,m,f[M+1];//f[i]定義為背包容量為i時的最大的價值,初始化為0,代表任何背包的價值目前為0 struct Node{ int w,c; }a[N+1]; inline int max(int a,int b){return a>b?a:b;} int main() { scanf("%d%d",&m,&n); FORa(i,1,n) scanf("%d%d",&a[i].w,&a[i].c); FORa(i,1,n) FORs(j,m,a[i].w) /*按照正常的思路是(在原先的基礎上考慮選與不選) 只繼承前i-1的信息,又要不改變現在的值(因為改變的話就會重復選擇,那就不是零一背包了) 所以只能從大的開始選*/ f[j]=max(f[j],f[j-a[i].w]+a[i].c); printf("%d",f[m]); return 0; } inline int read() { register int x(0);register int f(1);register c(gc); while(c<'0'||c>'9') f=c=='-'?-1:1,c=gc; while(c>='0'&&c<='9') x=(x<<1)+(x<<3)+(c^48),c=gc; return x*f; }
第二講 完全背包問題
題目
有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(VN)個狀態需要求解,但求解每個狀態的時間已經不是常數了,求解狀態f[i][v]的時間是O(v/c[i]),總的復雜度可以認為是O(V*Σ(V/c[i])),是比較大的。
將01背包問題的基本思路加以改進,得到了這樣一個清晰的方法。這說明01背包問題的方程的確是很重要,可以推及其它類型的背包問題。但我們還是試圖改進這個復雜度。
一個簡單有效的優化
完全背包問題有一個很簡單有效的優化,是這樣的:若兩件物品i、j滿足c[i]=w[j],則將物品j去掉,不用考慮。這個優化的正確性顯然:任何情況下都可將價值小費用高得j換成物美價廉的i,得到至少不會更差的方案。對於隨機生成的數據,這個方法往往會大大減少物品的件數,從而加快速度。然而這個並不能改善最壞情況的復雜度,因為有可能特別設計的數據可以一件物品也去不掉。
這個優化可以簡單的O(N^2)地實現,一般都可以承受。另外,針對背包問題而言,比較不錯的一種方法是:首先將費用大於V的物品去掉,然后使用類似計數排序的做法,計算出費用相同的物品中價值最高的是哪個,可以O(V+N)地完成這個優化。這個不太重要的過程就不給出偽代碼了,希望你能獨立思考寫出偽代碼或程序。
轉化為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)的算法
這個算法使用一維數組,先看偽代碼:
for i=1..N for v=0..V f[v]=max{f[v],f[v-cost]+weight}
你會發現,這個偽代碼與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的順序循環。這就是這個簡單的程序為何成立的道理。
值得一提的是,上面的偽代碼中兩層for循環的次序可以顛倒。這個結論有可能會帶來算法時間常數上的優化。
這個算法也可以以另外的思路得出。例如,將基本思路中求解f[i][v-c[i]]的狀態轉移方程顯式地寫出來,代入原方程中,會發現該方程可以等價地變形成這種形式:
f[i][v]=max{f[i-1][v],f[i][v-c[i]]+w[i]}
將這個方程用一維數組實現,便得到了上面的偽代碼。
最后抽象出處理一件完全背包類物品的過程偽代碼:
procedure CompletePack(cost,weight) for v=cost..V f[v]=max{f[v],f[v-c[i]]+w[i]}
總結
完全背包代碼
#include<stdio.h> #include<stdlib.h> #define FORa(i,s,e) for(int i=s;i<=e;i++) #define FORs(i,s,e) for(int i=s;i>=e;i--) #define gc pa==pb&&(pb=(pa=buf)+fread(buf,1,100000,stdin),pa==pb)?EOF:*pa++ #define File(name) freopen(name".in","r",stdin);freopen(name".out","w",stdout); using namespace std; static char buf[100000],*pa=buf,*pb=buf; inline int read(); const int N=100,M=100; int n,m,f[M+1]; struct Node{ int w,c; }a[N+1]; inline int max(int fa,int fb) {return fa>fb?fa:fb;} int main() { scanf("%d%d",&m,&n); FORa(i,1,n) scanf("%d%d",&a[i].w,&a[i].c); FORa(i,1,n) FORa(j,a[i].w,m)//這就與零一背包的注意事項就不一樣了,可以重復多選 f[j]=max(f[j],f[j-a[i].w]+a[i].c); printf("%d",f[m]); return 0; } inline int read() { register int x(0);register int f(1);register c(gc); while(c<'0'||c>'9') f=c=='-'?-1:1,c=gc; while(c>='0'&&c<='9') x=(x<<1)+(x<<3)+(c^48),c=gc; return x*f; }
第三講 多重背包問題
題目
有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(log amount)時間處理一件多重背包中物品的過程,其中amount表示物品的數量:
procedure MultiplePack(cost,weight,amount) if cost*amount>=V CompletePack(cost,weight) return integer k=1 while k<amount ZeroOnePack(k*cost,k*weight) amount=amount-k k=k*2 ZeroOnePack(amount*cost,amount*weight)
希望你仔細體會這個偽代碼,如果不太理解的話,不妨翻譯成程序代碼以后,單步執行幾次,或者頭腦加紙筆模擬一下,也許就會慢慢理解了。
O(VN)的算法
多重背包問題同樣有O(VN)的算法。這個算法基於基本算法的狀態轉移方程,但應用單調隊列的方法使每個狀態的值可以以均攤O(1)的時間求解。由於用單調隊列優化的DP已超出了NOIP的范圍,故本文不再展開講解。我最初了解到這個方法是在樓天成的“男人八題”幻燈片上。
小結
這里我們看到了將一個算法的復雜度由O(VΣn[i])改進到O(VΣlog n[i])的過程,還知道了存在應用超出NOIP范圍的知識的O(VN)算法。希望你特別注意“拆分物品”的思想和方法,自己證明一下它的正確性,並將完整的程序代碼寫出來。
朴素多重背包代碼
#include<stdio.h> #include<stdlib.h> #define FORa(i,s,e) for(int i=s;i<=e;i++) #define FORs(i,s,e) for(int i=s;i>=e;i--) using namespace std; const int N=1000,M=20000; int n,m,f[M+1]; struct Node{ int w,c,s; }a[N+1]; inline int max(int a,int b){return a>b?a:b;} int main() { scanf("%d%d",&n,&m); FORa(i,1,n) scanf("%d%d%d",&a[i].w,&a[i].c,&a[i].s); FORa(i,1,n) FORs(j,m,a[i].w) FORa(k,1,a[i].s) { if(j<k*a[i].w) break; f[j]=max(f[j],f[j-a[i].w*k]+a[i].c*k); } printf("%d",f[m]); return 0; }
多重背包(二進制優化)代碼
#include<stdio.h> #include<stdlib.h> #define FORa(i,s,e) for(int i=s;i<=e;i++) #define FORs(i,s,e) for(int i=s;i>=e;i--) using namespace std; const int N=1000,M=20000; int n,cnt,m,f[M+1]; struct Node{ int w,c; }a[N+1]; inline int max(int a,int b){return a>b?a:b;} int main() { int w,c,s; scanf("%d%d",&n,&m); FORa(i,1,n) { scanf("%d%d%d",&w,&c,&s); int t=1; while(s>=t) a[++cnt].w=w*t,a[cnt].c=c*t,s-=t,t*=2;//二進制優化轉化為01背包 a[++cnt].c=s*c,a[cnt].w=s*w; } FORa(i,1,cnt) FORs(j,m,a[i].w) f[j]=max(f[j],f[j-a[i].w]+a[i].c); printf("%d",f[m]); return 0; }
第四講 混合三種背包問題
問題
如果將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背包的物品的方法也已經很優了。
當然,更清晰的寫法是調用我們前面給出的三個相關過程。
for i=1..N if 第i件物品屬於01背包 ZeroOnePack(c[i],w[i]) else if 第i件物品屬於完全背包 CompletePack(c[i],w[i]) else if 第i件物品屬於多重背包 MultiplePack(c[i],w[i],n[i])
在最初寫出這三個過程的時候,可能完全沒有想到它們會在這里混合應用。我想這體現了編程中抽象的威力。如果你一直就是以這種“抽象出過程”的方式寫每一類背包問題的,也非常清楚它們的實現中細微的不同,那么在遇到混合三種背包問題的題目時,一定能很快想到上面簡潔的解法,對嗎?
小結
有人說,困難的題目都是由簡單的題目疊加而來的。這句話是否公理暫且存之不論,但它在本講中已經得到了充分的體現。本來01背包、完全背包、多重背包都不是什么難題,但將它們簡單地組合起來以后就得到了這樣一道一定能嚇倒不少人的題目。但只要基礎扎實,領會三種基本背包問題的思想,就可以做到把困難的題目拆分成簡單的題目來解決。
混合背包代碼
#include<stdio.h> #include<stdlib.h> #define FORa(i,s,e) for(int i=s;i<=e;i++) #define FORs(i,s,e) for(int i=s;i>=e;i--) using namespace std; const int N=1000,M=20000; int n,cnt,m,f[M+1]; struct Node{ int w,c; bool flag; }a[N+1]; inline int max(int a,int b){return a>b?a:b;} int main() { int w,c,s; scanf("%d%d",&m,&n); FORa(i,1,n) { scanf("%d%d%d",&w,&c,&s); if(s==0) a[++cnt].w=w,a[cnt].c=c,a[cnt].flag=1; else { a[++cnt].flag=1; int t=1; while(s>=t) a[++cnt].w=w*t,a[cnt].c=c*t,s-=t,t*=2;//二進制優化轉化為01背包 a[++cnt].c=s*c,a[cnt].w=s*w; } } FORa(i,1,cnt) { if(a[i].flag) FORa(j,a[i].w,m) f[j]=max(f[j],f[j-a[i].w]+a[i].c); else FORs(j,m,a[i].w) f[j]=max(f[j],f[j-a[i].w]+a[i].c); } printf("%d",f[m]); return 0; }
第五講 二維費用的背包問題
問題
二維費用的背包問題是指:對於每件物品,具有兩種不同的費用;選擇這件物品必須同時付出這兩種代價;對於每種代價都有一個可付出的最大值(背包容量)。問怎樣選擇物品可以得到最大的價值。設這兩種代價分別為代價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]范圍內尋找答案。
復數域上的背包問題
另一種看待二維背包問題的思路是:將它看待成復數域上的背包問題。也就是說,背包的容量以及每件物品的費用都是一個復數。而常見的一維背包問題則是實數域上的背包問題。(注意:上面的話其實不嚴謹,因為事實上我們處理的都只是整數而已。)所以說,一維背包的種種思想方法,往往可以應用於二位背包問題的求解中,因為只是數域擴大了而已。
二維背包代碼
#include<stdio.h> #include<stdlib.h> #include<string.h> #define FORa(i,s,e) for(int i=s;i<=e;i++) #define FORs(i,s,e) for(int i=s;i>=e;i--) using namespace std; const int N=100,M=200,INF=10000; int w1,w2,t,f[M+1][M+1];//f[i][j]代表需要值分別達到i值與j值時的最小代價 struct Node{ int w1,w2,c; }a[N+1]; inline int min(int a,int b){return a<b?a:b;} int main() { memset(f,63,sizeof(f));//答案求的是最小值,所以將f數組賦初值為一個極大的值 scanf("%d%d%d",&w1,&w2,&t); FORa(i,1,t) scanf("%d%d%d",&a[i].w1,&a[i].w2,&a[i].c); f[0][0]=0;//確定初始狀態,不要求任何氣體的時候,最小代價為0 FORa(k,1,t) FORs(i,w1,0) FORs(j,w2,0) { int t1=a[k].w1+i,t2=a[k].w2+j; //因為題目的特殊限制,所以超過了氣體需要的量也是沒有關系的,因為要求代價最小 //即使氣體量達到最大值也是可以的,注意看題 if(t1>w1) t1=w1; if(t2>w2) t2=w2; //因為狀態設計的要求,氣體含量超過要求的量的,統一計算為要求的量 f[t1][t2]=min(f[t1][t2],f[i][j]+a[k].c); } printf("%d",f[w1][w2]); return 0; }
第六講 分組的背包問題
問題
有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 v=V..0 for 所有的i屬於組k f[v]=max{f[v],f[v-c[i]]+w[i]}
注意這里的三層循環的順序,甚至在本文的第一個beta版中我自己都寫錯了。“for v=V..0”這一層循環必須在“for 所有的i屬於組k”之外。這樣才能保證每一組內的物品最多只有一個會被添加到背包中。
分組背包代碼
#include<stdio.h> #include<stdlib.h> #define FORa(i,s,e) for(int i=s;i<=e;i++) #define FORs(i,s,e) for(int i=s;i>=e;i--) using namespace std; const int N=30,V=200; int t,v,n,group[N+1][N+1],f[V+1];//group數組記錄一組內的成員,f[i]表示為容積小於等於i能選的最大價值 struct Node{ int w,c; }a[N+1];//記錄每一個物品的價值 inline int max(int a,int b){return a>b?a:b;} int main() { int w,c,p; scanf("%d%d%d",&v,&n,&t); FORa(i,1,n) scanf("%d%d%d",&a[i].w,&a[i].c,&p),group[p][++group[p][0]]=i; FORa(k,1,t)//先分組討論 FORs(j,v,0)//值討論這個組選與不選,所以依照零一背包的寫法 FORa(i,1,group[k][0]) if(j>=a[group[k][i]].w) f[j]=max(f[j],f[j-a[group[k][i]].w]+a[group[k][i]].c);//狀態轉移,轉為選與不選的最大值 printf("%d",f[v]); return 0; }
第七講 有依賴的背包問題
簡化的問題
這種背包問題的物品間存在某種“依賴”的關系。也就是說,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的算法解決問題了。
第八講 泛化物品
定義
考慮這樣一種物品,它並沒有固定的費用和價值,而是它的價值隨着你分配給它的費用而變化。這就是泛化物品的概念。
更嚴格的定義之。在背包容量為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/cw,其它函數值均為0。如果它是多重背包中重復次數最多為n的物品,那么它對應的泛化物品的函數有h(v)=v/cw僅當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是h與l的和。這個運算的時間復雜度取決於背包的容量,是O(V^2)。
泛化物品的定義表明:在一個背包問題中,若將兩個泛化物品代以它們的和,不影響問題的答案。事實上,對於其中的物品都是泛化物品的背包問題,求它的答案的過程也就是求所有這些泛化物品之和的過程。設此和為s,則答案就是s[0..V]中的最大值。
背包問題的泛化物品
一個背包問題中,可能會給出很多條件,包括每種物品的費用、價值等屬性,物品之間的分組、依賴等關系等。但肯定能將問題對應於某個泛化物品。也就是說,給定了所有條件以后,就可以對每個非負整數v求得:若背包容量為v,將物品裝入背包可得到的最大價值是多少,這可以認為是定義在非負整數集上的一件泛化物品。這個泛化物品——或者說問題所對應的一個定義域為非負整數的函數——包含了關於問題本身的高度濃縮的信息。一般而言,求得這個泛化物品的一個子域(例如0..V)的值之后,就可以根據這個函數的取值得到背包問題的最終答案。
綜上所述,一般而言,求解背包問題,即求解這個問題所對應的一個函數,即該問題的泛化物品。而求解某個泛化物品的一種方法就是將它表示為若干泛化物品的和然后求之。
小結
本講可以說都是我自己的原創思想。具體來說,是我在學習函數式編程的 Scheme 語言時,用函數編程的眼光審視各類背包問題得出的理論。這一講真的很抽象,也許在“模型的抽象程度”這一方面已經超出了NOIP的要求,所以暫且看不懂也沒關系。相信隨着你的OI之路逐漸延伸,有一天你會理解的。
我想說:“思考”是一個OIer最重要的品質。簡單的問題,深入思考以后,也能發現更多。
第九講 背包問題問法的變化
以上涉及的各種背包問題都是要求在背包容量(費用)的限制下求可以取到的最大價值,但背包問題還有很多種靈活的問法,在這里值得提一下。但是我認為,只要深入理解了求背包問題最大價值的方法,即使問法變化了,也是不難想出算法的。
例如,求解最多可以放多少件物品或者最多可以裝滿多少背包的空間。這都可以根據具體問題利用前面的方程求出所有狀態的值(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-1][i-v]及f[i][v]==f[i-1][f-c[i]]+w[i]同時成立,應該按照后者(即選擇了物品i)來輸出方案。
求方案總數
對於一個給定了背包容量、物品費用、物品間相互關系(分組、依賴等)的背包問題,除了再給定每個物品的價值后求可得到的最大價值外,還可以得到裝滿背包或將背包裝至某一指定容量的方案總數。
對於這類改變問法的問題,一般只需將狀態轉移方程中的max改成sum即可。例如若每件物品均是完全背包中的物品,轉移方程即為
f[i][v]=sum{f[i-1][v],f[i][v-c[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]])
如果你是第一次看到這樣的問題,請仔細體會上面的偽代碼。
求次優解、第K優解
對於求次優解、第K優解類的問題,如果相應的最優解問題能寫出狀態轉移方程、用動態規划解決,那么求次優解往往可以相同的復雜度解決,第K優解則比求最優解的復雜度上多一個系數K。
其基本思想是將每個狀態都表示成有序隊列,將狀態轉移方程中的max/min轉化成有序隊列的合並。這里仍然以01背包為例講解一下。
首先看01背包求最優解的狀態轉移方程:f[i][v]=max{f[i-1][v],f[i-1][v-c[i]]+w[i]}
。如果要求第K優解,那么狀態f[i][v]就應該是一個大小為K的數組f[i][v][1..K]。其中f[i][v][k]表示前i個物品、背包大小為v時,第k優解的值。“f[i][v]是一個大小為K的數組”這一句,熟悉C語言的同學可能比較好理解,或者也可以簡單地理解為在原來的方程中加了一維。顯然f[i][v][1..K]這K個數是由大到小排列的,所以我們把它認為是一個有序隊列。
然后原方程就可以解釋為:f[i][v]這個有序隊列是由f[i-1][v]和f[i-1][v-c[i]]+w[i]這兩個有序隊列合並得到的。有序隊列f[i-1][v]即f[i-1][v][1..K],f[i-1][v-c[i]]+w[i]則理解為在f[i-1][v-c[i]][1..K]的每個數上加上w[i]后得到的有序隊列。合並這兩個有序隊列並將結果的前K項儲存到f[i][v][1..K]中的復雜度是O(K)。最后的答案是f[N][V][K]。總的復雜度是O(VNK)。
為什么這個方法正確呢?實際上,一個正確的狀態轉移方程的求解過程遍歷了所有可用的策略,也就覆蓋了問題的所有方案。只不過由於是求最優解,所以其它在任何一個策略上達不到最優的方案都被忽略了。如果把每個狀態表示成一個大小為K的數組,並在這個數組中有序的保存該狀態可取到的前K個最優值。那么,對於任兩個狀態的max運算等價於兩個由大到小的有序隊列的合並。
另外還要注意題目對於“第K優解”的定義,將策略不同但權值相同的兩個方案是看作同一個解還是不同的解。如果是前者,則維護有序隊列時要保證隊列里的數沒有重復的。
小結
顯然,這里不可能窮盡背包類動態規划問題所有的問法。甚至還存在一類將背包類動態規划問題與其它領域(例如數論、圖論)結合起來的問題,在這篇論背包問題的專文中也不會論及。但只要深刻領會前述所有類別的背包問題的思路和狀態轉移方程,遇到其它的變形問法,只要題目難度還屬於NOIP,應該也不難想出算法。
觸類旁通、舉一反三,應該也是一個OIer應有的品質吧。
附錄:背包問題的搜索解法
《背包問題九講》的本意是將背包問題作為動態規划問題中的一類進行講解。但鑒於的確有一些背包問題只能用搜索來解,所以這里也對用搜索解背包問題做簡單介紹。大部分以01背包為例,其它的應該可以觸類旁通。
簡單的深搜
對於01背包問題,簡單的深搜的復雜度是O(2^N)。就是枚舉出所有2^N種將物品放入背包的方案,然后找最優解。基本框架如下:
procedure SearchPack(i,cur_v,cur_w) if(i>N) if(cur_w>best) best=cur_w return if(cur_v+v[i]<=V) SearchPack(i+1,cur_v+v[i],cur_w+w[i]) SearchPack(i+1,cur_v,cur_w)
其中cur_v和cur_w表示當前解的費用和權值。主程序中調用SearchPack(1,0,0)即可。
搜索的剪枝
基本的剪枝方法不外乎可行性剪枝或最優性剪枝。
可行性剪枝即判斷按照當前的搜索路徑搜下去能否找到一個可行解,例如:若將剩下所有物品都放入背包仍然無法將背包充滿(設題目要求必須將背包充滿),則剪枝。
最優性剪枝即判斷按照當前的搜索路徑搜下去能否找到一個最優解,例如:若加上剩下所有物品的權值也無法得到比當前得到的最優解更優的解,則剪枝。
搜索的順序
在搜索中,可以認為順序靠前的物品會被優先考慮。所以利用貪心的思想,將更有可能出現在結果中的物品的順序提前,可以較快地得出貪心地較優解,更有利於最優性剪枝。所以,可以考慮將按照“性價比”(權值/費用)來排列搜索順序。
另一方面,若將費用較大的物品排列在前面,可以較快地填滿背包,有利於可行性剪枝。
最后一種可以考慮的方案是:在開始搜索前將輸入文件中給定的物品的順序隨機打亂。這樣可以避免命題人故意設置的陷阱。
以上三種決定搜索順序的方法很難說哪種更好,事實上每種方法都有適用的題目和數據,也有可能將它們在某種程度上混合使用。
子集和問題
子集和問題是一個NP-Complete問題,與前述的(加權的)01背包問題並不相同。給定一個整數的集合S和一個整數X,問是否存在S的一個子集滿足其中所有元素的和為X。
這個問題有一個時間復雜度為O(2^(N/2))的較高效的搜索算法,其中N是集合S的大小。
第一步思想是二分。將集合S划分成兩個子集S1和S2,它們的大小都是N/2。對於S1和S2,分別枚舉出它們所有的2^(N/2)個子集和,保存到某種支持查找的數據結構中,例如hash set。
然后就要將兩部分結果合並,尋找是否有和為X的S的子集。事實上,對於S1的某個和為X1的子集,只需尋找S2是否有和為X-X1的子集。
假設采用的hash set是理想的,每次查找和插入都僅花費O(1)的時間。兩步的時間復雜度顯然都是O(2^(N/2))。
實踐中,往往可以先將第一步得到的兩組子集和分別排序,然后再用兩個指針掃描的方法查找是否有滿足要求的子集和。這樣的實現,在可接受的時間內可以解決的最大規模約為N=42。
搜索還是DP?
在看到一道背包問題時,應該用搜索還是動態規划呢?
首先,可以從數據范圍中得到命題人意圖的線索。如果一個背包問題可以用DP解,V一定不能很大,否則O(VN)的算法無法承受,而一般的搜索解法都是僅與N有關,與V無關的。所以,V很大時(例如上百萬),命題人的意圖就應該是考察搜索。另一方面,N較大時(例如上百),命題人的意圖就很有可能是考察動態規划了。
另外,當想不出合適的動態規划算法時,就只能用搜索了。例如看到一個從未見過的背包中物品的限制條件,無法想出DP的方程,只好寫搜索以謀求一定的分數了。
參考:
https://www.kancloud.cn/kancloud/pack