1. 程式人生 > 其它 >多重揹包問題的單調佇列優化

多重揹包問題的單調佇列優化

多重揹包問題的單調佇列優化

溫馨提示:先吃甜點,再進入正餐食用更佳噢~

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;
}

時間複雜度:

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

參考

https://oi-wiki.org/ds/monotonous-queue/