多重揹包問題的單調佇列優化
溫馨提示:先吃甜點,再進入正餐食用更佳噢~
0-1揹包問題(餐前甜點)
https://www.acwing.com/problem/content/2/
樸素解法
#include <iostream>
using namespace std;
const int N = 1010;
int n, m; //n物品個數 m揹包最大容量
int dp[N][N]; //dp[i][j]表:考慮前i個物品並且揹包容量為j個體積單位的最大價值
int v[N], w[N];
int main() {
cin >> n >> m;
for (int i = 1; i <= n; i ++) cin >> v[i] >> w[i];
for (int i = 1; i <= n; i ++) {
for (int j = 0; j <= m; j ++) {
//不選第i個,dp[i][j] = dp[i - 1][j];
//選第i個,dp[i][j] = dp[i - 1][j - v[i]] + w[i];
dp[i][j] = dp[i - 1][j];
if (j >= v[i]) dp[i][j] = max(dp[i][j], dp[i - 1][j - v[i]] + w[i]);
}
}
cout << dp[n][m] << endl; //考慮前n個(所有)物品,揹包體積容量為m的最大價值即為答案
}
空間降維
dp第一維實際上多餘,因為i只需要用到i-1的狀態,但實際上剛開始第i輪列舉的時候dp【i][j]的第二維表示的都是i-1時的狀態,可以降維(下圖所示)。
但是我們不能按照體積從小到大列舉,不然後續的狀態更新會用到i的狀態(下圖所示)。
降序列舉,則可以避免(下圖所示)。
降維壓縮之後的程式碼:
#include <iostream>
using namespace std;
const int N = 1010;
int n, m; //n物品個數 m揹包最大容量
int dp[N]; //dp[j]表:揹包容量為j個體積單位的最大價值
int main() {
cin >> n >> m;
for (int i = 0; i < n; i ++) {
int v, w; //第i個物品的體積和價值
cin >> v >> w;
//不選第i個,dp[j] = dp[j];
//選第i個,dp[j] = dp[j - v] + w;
for (int j = m; j >= v; j --) dp[j] = max(dp[j], dp[j - v] + w); //從大到小列舉
}
cout << dp[m] << endl;
}
多重揹包問題(正餐)
https://www.acwing.com/problem/content/4/
與0-1揹包的唯一區別在於,多重揹包的物品可能有多件s。
選法不像0-1揹包那樣:對於第i件物品要麼選0件要麼選1件,只有兩種選法:
而是,一共有s+1種選法[0,s]:
樸素(暴力)解法
在0-1揹包的程式碼基礎上加一層迴圈:
#include <iostream>
#include <algorithm>
using namespace std;
const int N = 110;
int n, m;
int f[N];
int main() {
cin >> n >> m;
for (int i = 0; i < n; i ++) {
int v, w, s;
cin >> v >> w >> s;
for (int j = m; j >= v; j --) {
for (int k = 1; k <= s && j >= k * v; k ++) { //列舉選[1,s]件的s種選法和不選的情況一起比較
f[j] = max(f[j], f[j - k * v] + k * w);
}
}
}
cout << f[m] << endl;
}
時間複雜度O(NVS) = O(N^3) 複雜度很高,考慮優化一下。
二進位制優化
https://www.acwing.com/problem/content/5/
實際上我們考慮將每種物品堆(s個)分組一下,把每一組看成1個物品,當成0-1揹包來求解。
為了使得時間複雜度儘可能的小,我們分得的組別數必須儘可能地少,而且這些組別隨機組合能夠連續表示[0,s],即做一個等價類。
例如s=7,按照上文的樸素方法,等價於分成了7組:1、1、1、1、1、1、1
這裡我們考慮二進位制拆分,拆分成:1、2、4
0 = 不選
1 = 選1
2 = 選2
3 = 選1、2
4 = 選4
5 = 選1、4
6 = 選2、4
7 = 選1、2、4
實際上是分成:
s+1如果不是2的某次冪,例如10的拆法:
那就拆分成:1 2 4 3
其中:1 2 4 可表示[0, 7]
所以1 2 4 3可表示[0, 10]
思路講解完,上程式碼:
#include <iostream>
#include <algorithm>
#include <vector>
using namespace std;
int dp[2010];
struct Good{
int v, w; //物品體積和價值
};
int main(){
int n, m; //物品個數和揹包最大容量
cin >> n >> m;
vector<Good> goods; //儲存分組後的物品
for(int i = 0; i < n; i++){
int v, w, s;
cin >> v >> w >> s;
for(int j = 1; j <= s; j *= 2){ //二進位制拆分
s -= j;
goods.push_back({v * j, w * j});
}
if(s) goods.push_back({v * s, w * s}); //拆完還餘的也要存進去(這裡的s相當於10拆成1 2 4後還餘下的那個3)
}
for(auto good : goods){ //做等價拆分(二進位制拆分)後的物品組們按照0-1揹包解法
for(int j = m; j >= good.v; j --) //注意從大到小列舉
dp[j] = max(dp[j], dp[j - good.v] + good.w);
}
cout << dp[m] << endl;
return 0;
}
時間複雜度O(NV×log(S))=O(N^2×log(N)),實際上,複雜度還是不可觀。
究極優化之單調佇列優化
https://www.acwing.com/problem/content/6/
v[i](下面都簡寫成v)表示第i個物品體積,其中j=v-1,m表示揹包最大容量。這裡我們假設m=kv+j,其實也有可能是kv+j-1,...,kv+1,kv 只是為了方便下面的這個矩形演示,不妨假設成m=kv+j。
dp[0] | dp[v] | dp[2v] | dp[3v] | ... | dp[(k-1)v] | dp[kv] |
dp[1] | dp[v+1] | dp[2v+1] | dp[3v+1] | ... | dp[(k-1)v+1] | dp[kv+1] |
dp[2] | dp[v+2] | dp[2v+2] | dp[3v+2] | ... | dp[(k-1)v+2] | dp[kv+2] |
dp[3] | dp[v+3] | dp[2v+3] | dp[3v+3] | ... | dp[(k-1)v+3] | dp[kv+3] |
... | ... | ... | ... | ... | ... | ... |
dp[j-1] | dp[v+j-1] | dp[2v+j-1] | dp[3v+j-1] | ... | dp[(k-1)v+j-1] | dp[(kv+j-1)] |
dp[j] | dp[v+j] | dp[2v+j] | dp[3v+j] | ... | dp[(k-1)v+j] | dp[kv+j] |
回顧一下上文所提及的解法,在程式碼中的實現的第二層迴圈的dp都是這個狀態轉移流程:對於每一個物品i,都會從大到小列舉值在[v,m]的所有情況都進行一遍更新(標藍的元素),列舉的順序如下圖示:
下面做具體分析:
其中標藍元素代表待更新的狀態(需要取max),粗體代表能轉移到待更新狀態的狀態(當然,由於物品個數的限制,可能沒有k個,不會是這麼長,這裡只是為了方便演示,暫不考慮物品個數)
dp[kv+j]=max( dp[(k-1)v+j] + w , dp[(k-2)v+j] + 2w , ... , dp[3v+j] + (k-3)w , dp[2v+j] + (k-2)w , dp[v+j] + (k-1)w , dp[j] + kw )
......
......
dp[(k-1)v+j]=max( dp[(k-2)v+j] + w , ... , dp[3v+j] + (k-4)w , dp[2v+j] + (k-3)w , dp[v+j] + (k-2)w , dp[j] + (k-1)w )
到這裡的時候對比上圖和下圖,細心的你突然發現這裡好像進行了很多沒必要(貌似重複冗餘但又不得不做的工作)的比較,下面進行分析:
而我們在進行dp[(k-1)v+j]的狀態更新(取max)的時候又重新將它們再遍歷了一遍。
問題出在:我們每次取max都需要從“0”開始對集合(同一行)內的所有元素比較,而不能在之前的比較結果的基礎上進行。
導致問題的原因:我們是從大到小列舉的。舉個例子:這就相當於我們遍歷一個正整數集合,得到這個集合的最大值,然後我們從集合中剔除一個元素,新集合的最大值對於我們來說不是確定的(細品),我們無法利用上一次的遍歷所做的工作(勞動成果不能為這次所用)。
思考:如果做逆向思維,我們遍歷一個正整數集合,得到這個集合的最大值,然後我們往集合中增加一個元素,新集合的最大值對於我們來說是確定的,我們可以利用上一次的遍歷所做的工作(勞動成果能夠為這次所用)。
解決方法:所以我們應該摒棄前文描述的“從大到小列舉壓縮空間”的思想,選擇從小到大列舉,並且利用一種資料結構來模擬這個“變大的集合”,並且在此基礎上做一些限制條件實現物品個數的限制。由於只有差值為v的時候狀態才能轉移,我們可以把整個集合以模v的餘數為劃分規則做一個等價劃分,可以劃分成為v個子集(模v餘[0, v-1] 則每行代表一個子集,這也是本文設計這個矩形的目的),這個時候我們分別對每個集合從小到大(狀態更新,在下表中從左往右)進行列舉更新,還要考慮物品的個數。
具體實施:以一行(同餘的一個子集)為例,設定一個滑動視窗,視窗大小設定為該物品的個數+1,並在視窗內部維護一個單調佇列。
至於為什麼視窗大小是該物品的個數+1,舉個例子:如果該物品只有2個,dp[3v+j]從dp[j]狀態轉移過來需要裝進來3個該物品,所以不可能從dp[j]轉移過來,因此也就沒有必要去將dp[j]考慮進來,只需要維護視窗大小為3範圍內的單調佇列。
首先解釋一下單調佇列:
顧名思義,單調佇列的重點分為 "單調" 和 "佇列"
"單調" 指的是元素的的 "規律"——遞增(或遞減)
"佇列" 指的是元素只能從隊頭和隊尾進行操作,但是此"佇列" 非彼佇列。
如果要求每連續的k個數中的最大值,很明顯,當一個數進入所要 "尋找" 最大值的範圍中時,若這個數比其前面(先進隊)的數要大,顯然,前面的數會比這個數先出隊且不再可能是最大值。
也就是說——當滿足以上條件時,可將前面的數 "踢出",再將該數push進隊尾。
這就相當於維護了一個遞減的佇列,符合單調佇列的定義,減少了重複的比較次數(前面的“勞動成果”能夠為後面所用),不僅如此,由於維護出的隊伍是查詢範圍內的且是遞減的,隊頭必定是該查詢區域內的最大值,因此輸出時只需輸出隊頭即可。顯而易見的是,在這樣的演算法中,每個數只要進隊與出隊各一次,因此時間複雜度被降到了O(N)。
如果對於文字解釋看不懂也沒關係,結合模擬來介紹:假設物品個數為2,則視窗大小為3,進行模擬。在這個過程中,因為我們是從小到大進行更新,所以需要對dp的i-1狀態備份一份到g中(空間換時間)。
首先給g[j]入佇列尾,此時,單調佇列中只有g[j],用隊頭g[j]更新dp[j]:
dp[j]更新之後變成i時候的狀態,這裡我們假定(g[j]+w > g[v+j])。
g[v+j]入隊之前,先從隊尾起,把統統不比它大的都踢出佇列,然後再入隊尾(g[j]+w比它大,踢不掉)。
取隊頭g[j]+w更新dp[v+j]:
dp[v+j]更新之後變成i時候的狀態。
(情況一)如果(g[j]+2w > g[v+j]+w > g[2v+j] )。
g[2v+j]入隊之前,先從隊尾起比較,發現隊尾比它大,踢不了,然後乖乖入隊尾。
此時,取隊頭g[j]+2w更新dp[2v+j]:
(情況二)如果(g[j]+2w > g[2v+j] >= g[v+j]+w)。
g[2v+j]入隊之前,發現隊尾的g[v+j]+w不比它大,踢掉了,然後再比較此時的隊尾g[j]+2w,比它大,乖乖入隊尾。
此時,還是取隊頭g[j]+2w更新dp[2v+j]:
(情況三)如果(g[2v+j] >= g[j]+2w > g[v+j]+w)。
g[2v+j]入隊之前,發現隊尾的g[v+j]+w不比它大,踢掉了,然後再比較此時的隊尾g[j]+2w,也不比它大,踢掉。此時佇列為空,它進入佇列。
此時,則取隊頭g[2v+j]更新dp[2v+j]:
假定我們是以上面三種中的第一種情況( g[j]+2w > g[v+j]+w > g[2v+j] )結束的:
dp[2v+j]更新之後變成i時候的狀態。
g[2v+j]入隊之前,檢查單調佇列內的元素是否都在視窗(長度為3)之內,發現g[j]+3w不在,則踢掉,然後......
至此,在本次問題中單調佇列維護的規則和思路都已經演示清楚,下面直接上程式碼:
#include <iostream>
#include <algorithm>
#include <cstring>
using namespace std;
//多重揹包問題: 限制每種物品可取次數
//究極優化:單調佇列
const int M = 20010, N = 1010;
int n, m;
int dp[M], g[M];
int que[M]; //佇列只儲存在同餘的集合中是第幾個,不儲存對應值
int main() {
cin >> n >> m;
for(int i = 0; i < n; i ++){
int v, w, s;
cin >> v >> w >> s;
//複製一份副本g,因為這裡會是從小到大,不能像0-1揹包那樣從大到小,所以必須申請副本存i-1狀態的,不然會被影響
memcpy(g, dp, sizeof dp);
for(int r = 0; r < v; r ++) { //因為只有與v同餘的狀態 相互之間才會影響,餘0,1,...,v-1 分為v組
int head = 0, tail = -1;
for(int k = 0; r + k * v <= m; k ++) { //每一組都進行處理,就相當於對所有狀態都處理了
//隊頭不在視窗裡面就踢出(隊頭距離要更新的dp超過了最大個數s,儘管它再大也要捨去,因為達不到)
if(head <= tail && k - que[head] > s) head++;
//這第k個準備進來,把不大於它的隊尾統統踢掉,也是為了保持佇列的單調降(判斷式實際上是兩邊同時減去了k * w)
//實際意義應該是 g[r + k * v] >= g[r + que[tail] * v] + (k - que[tail]) * w 為判斷條件
while(head <= tail && g[r + k * v] - k * w >= g[r + que[tail] * v] - que[tail] * w) tail --;
que[++ tail] = k; //將第k個入列,佇列只儲存在同餘中是第幾個,不儲存對應值
//餘r的這組的第k個取隊頭更新,隊頭永遠是使之max的決策
dp[r + k * v] = g[r + que[head] * v] + (k - que[head]) * w;
}
}
}
cout << dp[m] << endl;
return 0;
}
時間複雜度:
以上內容如有錯誤的地方,懇請指正。