多重背包問題的單調隊列優化
溫馨提示:先吃甜點,再進入正餐食用更佳噢~
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;
}
時間復雜度:

以上內容如有錯誤的地方,懇請指正。
