1. 程式人生 > 實用技巧 >九種揹包問題

九種揹包問題

揹包問題

在具體實現這些問題的時候,主要是依據該文章的思路來。

揹包問題是泛指以下這一種問題:

給定一組物品,每種物品都有自己的重量和價格,在限定的總重量內,我們如何選擇,才能使得物品的總價格最高。是一個典型的多階段決策的過程,適用於動態規劃來解決。

01揹包

\(01\)揹包指的是每一種物品只有一件,可以選擇放或者不放。

【問題描述】

一個旅行者有一個最多能裝\(M\) 公斤的揹包,現在有\(n\) 件物品,它們的重量分別是\(W_1\)\(W_2,…,W_n\),它們的價值分別為\(C_1,C_2,…,C_n\),求旅行者能獲得最大總價值。

【輸入】

第一行:兩個整數,\(M\)(揹包容量,\(M ≤ 200\)

)和\(N\)(物品數量,\(N ≤ 30\));
\(2…N+1\)行:每行二個整數\(W_i,C_i\),表示每個物品的重量和價值。

【輸出】

僅一行,一個數,表示最大總價值。

【樣例輸入】

10 4
2 1
3 3
4 5
7 9

【樣例輸出】

12

這是最基礎的揹包問題,其基本特點是,每種物品僅有一件按,可以選擇或者不放

根據這一特點,可以定義這樣一個狀態,\(d[i] [j]\) 表示從前i件物品中選擇容量不超過j的,可以獲得的最大價值。根據每一件物品可以選擇放或者不放,狀態轉移方程可以這樣表示:

\(d[i] [j] = max ( d[i-1] [j], d[i-1] [j - w[i]] + v[i] )\)

上面這個方程是揹包問題的基礎方程,幾乎其他的揹包問題都是由上述方程演變而來。上述方程的含義為,考慮當前的物品放或者不放,如果不放進揹包,那麼問題可以轉換為從\(i-1\)件物品中放入容量為\(j\)的揹包,即\(d[i] [j] = d[i-1] [j]\),如果放進揹包,那麼問題轉換為從前\(i-1\)件物品中選擇放入容量為\(j - w[i]\)的揹包中,此時的最大價值就是\(d[i-1] [j - w[i]]\)在加上放入第\(i\)件物品的價值\(v[i]\)

如果還是不理解可以參照下方表格

N/W 1 2 3 4 5 6 7 8 9 10
1 0 1 1 1 1 1 1 1 1 1
2 0 1 3 3 4 4 4 4 4 4
3 0 1 3 5 5 6 8 8 9 9
4 0 1 3 5 5 6 9 9 10 12

參考程式:

#include <iostream>
#define T 1005
#define M 105
using namespace std;
int main () {
	ios::sync_with_stdio(0); 
	int s, n, t[M], v[M], d[M][T] = {0};
	cin >> s >> n;
	for (int i = 1; i <= n; i++) 
		cin >> t[i] >> v[i];
	for (int i = 1; i <= n; i++) 
		for (int j = 0; j <= s; j++) 
			if (j >= t[i])
				d[i][j] = max (d[i-1][j], d[i-1][j-t[i]] + v[i]);
			else 
				d[i][j] = d[i-1][j];
	cout << d[n][s];	
	return 0;
} 

空間優化:

上面演算法的時間和空間複雜度為\(O(NV)\),其中時間複雜度基本不能繼續優化了,但是可以考慮優化空間,複雜度可以達到\(O(V)\)

由上述的遞推公式可以得出,\(d[i] [j]\)只和\(d[i-1] [j]\)\(d[i-1] [j - w[i]]\)有關,即只和\(i-1\)時刻的狀態有關。

那麼是否可以省略第一個維度,只用一維陣列來考慮,同時又要求填充這個一維陣列時,始終保證當前的\(i\)只和\(i-1\)時刻有關?

實際上是可以的,\(d[j] = max(d[j], d[j - w[i]] + v[i])\),實際上相當於\(d[i] [j] = max ( d[i-1] [j], d[i-1] [j - w[i]] + v[i] )\),但是其中的j的變化必須是逆序推導從總的容量\(W\)開始變化到\(w[i]\)。這樣才能保證\(d[j - w[i]]\) 等價於 \(d[i-1] [j - w[i]]\)\(i\)只會被\(i-1\)的狀態影響)。如果是順序推導,那麼可能會\(d[i] [j]\)\(d[i] [j - w[i]]\)推得(\(i\)會被\(i\)的狀態影響),不符合要求。

還是通過一個表格來理解

從後往前推導:

更新次數/W 10 9 8 7 6 5 4 3 2 1
第1次更新 1 1 1 1 1 1 1 1 1 0
第2次更新 4 4 4 4 4 4 3 3 1 0
第3次更新 9 9 8 8 6 5 5 3 1 0
第4次更新 12 10 9 9 6 5 5 3 1 0

如果更夠跟著表格進行一次推導,那麼就能明白為什麼要逆推了,再來看順推(根據程式手動模擬,只需要推導一次的更新就明白為什麼順推是錯的)

更新次數/W 1 2 3 4 5 6 7 8 9 10
第1次更新 0 1 1 2 2 3 3 4 4 5

\(d[4]\)開始,就走向了錯誤的方向。第一個物品的重量為\(2\),價值為\(1\),那麼\(d[4] = d[4-2] + 1 = 2\),我們要求當前狀態只和\(i-1\)前一次有關,而\(d\)的更新是基於當前狀態\(i\)下更新的,\(d\)會慢慢疊加越來越大。因此是順推是錯誤的

參考程式

#include <iostream>
#define T 1005
#define M 105
using namespace std;
int main () {
	ios::sync_with_stdio(0); 
	int s, n, t[M], v[M], d[T] = {0};
	cin >> s >> n;
	for (int i = 1; i <= n; i++) 
		cin >> t[i] >> v[i];
	for (int i = 1; i <= n; i++) {
		for (int j = s; j >= t[i]; j --)
			d[j] = max (d[j], d[j-t[i]]+v[i]);
	}
	cout << d[s];
	return 0;
} 

完全揹包

【問題描述】

設有\(n\)種物品,每種物品有一個重量及一個價值。但每種物品的數量是無限的,同時有一個揹包,最大載重量為\(M\),今從\(n\)種物品中選取若干件(同一種物品可以多次選取),使其重量的和小於等於\(M\),而價值的和為最大。

【輸入】

第一行:兩個整數,\(M\)(揹包容量,\(M <= 200\))和\(N\)(物品數量,\(N <= 30\));
\(2…N+1\)行:每行二個整數\(W_i,C_i\),表示每個物品的重量和價值。

【輸出】

僅一行,一個數,表示最大總價值。

【樣例輸入】

10 4
2 1
3 3
4 5
7 9

【樣例輸出】

12

和剛才的\(01\)揹包不同的是,揹包中每種物品的數量是無限的,可以多次選擇,直到裝不下位置。

有一種直接的考慮是將完全揹包轉換為\(01\)揹包來處理,因為總容量是\(V\)固定的,每件物品的重量是\(w[i]\),可以將第i種物品轉換為\(V/w[i]\)\(i\)種物品,然後轉換為\(01\)揹包來處理。

同樣可以根據\(01\)揹包的思路,\(d[i] [j]\)表示從前\(i\)件物品中選擇重量不超過\(j\)的物品的價值最大值。每種物品也是有放和不放兩種思路,不過放的情況需要考慮放多少個物品i進去。

很容易想到狀態轉移方程為:

\(d[i] [j] = max ( d[i-1] [j], d[i-1] [j - k*w[i]] + k * v[i] | 0 <= k*w[i] <= j)\)

\(01\)揹包一樣有\(O(NV)\)個狀態需要求解,不過每個狀態的求解不是常數,求解\(d[i] [j]\)的時間為\(O(j/w[i])\)

參考程式

#include <iostream>
#include <algorithm>
#define N 35
#define M 205
using namespace std;

int main () {
	int m, n, w[N], c[N], d[N][M] = {0};
	cin >> m >> n;
	for (int i = 1; i <= n; i++)
		cin >> w[i] >> c[i];
	for (int i = 1; i <= n; i++) {
		for (int j = 1; j <= m; j++) {
			if (j >= w[i]) {
				int k = j / w[i];
				for (; k >= 0; k--) 
					d[i][j] = max (d[i][j], d[i-1][j - k*w[i]] + c[i]*k);
			} else 
				d[i][j] = d[i-1][j];
		}
	}
	cout << "max=" <<d[n][m];
	return 0;
}

問題優化

考慮\(01\)揹包的問題優化,\(01\)揹包逆推的原因在於第\(i\)次迴圈中的狀態必須由\(i-1\)的狀態得來,而完全揹包的特點是每種物品可以選擇無限的個數,因此在考慮選擇第\(i\)件物品的時候,需要一個可能已經入選了第\(i\)種物品的子結果,因此可以採取順推的方式進行。

參考程式

#include <iostream>
#include <algorithm>
#define N 35
#define M 205
using namespace std;

int main () {
	int m, n, w[N], c[N], d[M] = {0};
	cin >> m >> n;
	for (int i = 1; i <= n; i++)
		cin >> w[i] >> c[i];
	for (int i = 1; i <= n; i++) {
		for (int j = w[i]; j <= m; j++) 
			d[j] = max (d[j], d[j-w[i]]+c[i]);
	}
	cout << "max=" << d[m];
	return 0;
}

多重揹包

【問題描述】

設有\(n\)種物品和一個最大載重量為\(M\)的揹包,每種第i種物品最多有\(n[i]\)件,每件重量是\(w[i]\)。價值是\(c[i]\)。求解將哪些物品裝入揹包可使這些物品的費用總和不超過揹包容量,且價值總和最大。

【輸入】

第一行二個數\(n(n ≤ 500),m(m ≤ 6000)\),其中\(n\)表示物品數量,\(m\)表示揹包容量。

接下來\(n\)行,每行\(3\)個數,\(w、c、s\),分別表示第\(i\)種物品的重量、價值(價格與價值是不同的概念)和該種物品的最大數量(買\(0\)件到\(s\)件均可),其中\(v ≤ 100,w ≤ 1000,s ≤ 10\)

【輸出】

輸出一行表示最大價值

【樣例輸入】

5 1000
80 20 4
40 50 9
30 50 7
40 30 6
20 20 1

【樣例輸出】

1040

多重揹包和完全揹包思路類似,只是從無限獲取變成了有限制的獲取,稍微改一下狀態轉移方程即可:

\(d[i] [j] = max ( d[i-1] [j], d[i-1] [j - k*w[i]] + k * v[i] | 0 <= k*w[i] <= j \,\, and \,\, k <= n[i])\)

參考程式

#include <iostream>
#include <algorithm>
#define N 505
#define M 6005
using namespace std;

int m, n, w[N], c[N], num[N], d[N][M];

int main () {
	cin >> n >> m;
	for (int i = 1; i <= n; i++)
		cin >> w[i] >> c[i] >> num[i];
	for (int i = 1; i <= n; i++) {
		for (int j = 1; j <= m; j++) {
			if (j >= w[i]) {
				int t = j / w[i];
				for (int k = 0; k <= t && k <= num[i]; k++) 
					d[i][j] = max (d[i][j], d[i-1][j - k*w[i]] + c[i]*k);	
			}else 
				d[i][j] = d[i-1][j];
		} 
	}
	cout << d[n][m];
	return 0;
}

進一步優化:

多重揹包也可以轉換為\(01\)揹包求解,將第i類物品轉換為\(n[i]\)\(01\)揹包中的物品,那麼就變成了總物品數量為\(\sum n[i]\)\(01\)揹包。

轉化為\(01\)揹包後,要是有方法能夠降低複雜度就好了,實際上可以通過二進位制來進行優化。將第\(i\)種物品分為若干件物品,其中每一個物品有一個係數,表示該物品的費用和價值是這個係數乘以原物品的費用和價值。這些係數可以用\(2\)的冪來表示,也就是\(1、2、4、8.....2^{(k-1)}、n[i]-2^k+1\)。這個序列中\(k\)是滿足\(n[i]-2^{(k)}+1>0\)的最大整數。

將這若干件有係數的物品組合起來,能表示出\(0~n[i]\)範圍內的任意一個數字,這一點很重要,是能夠進行二進位制優化的關鍵所在。例如\(n[i]=13\)時,原本是\(13\)件物品,可以通過二進位制優化成係數為\(1、2、4、6\)\(4\)件物品。並且\(1、2、4、6\)的組合能夠表示出\(0~13\)中間任意的數字。

合理性證明

可以將係數分成兩段來簡單證明,第一段是\(0\)\(2^k-1\).第二段是\(2^k\)\(n[i]\)。拿\(13\)來舉例分成第一段\(0\)\(7\)和第二段\(8\)\(13\),對於第一段來說,\(7\)的二進位制為\(111\),對於這三個位置的\(1\)可以通過取或者不取,這樣就可以表示出\(0\)\(7\)之間的所有數字。而第二段則可以整體減去\(6\),變成\(2\)\(7\),這樣就可以通過第一段的數字加上\(6\)來表示\(8\)\(13\)區間內的所有數字

因此,\(0\)\(13\)之間的任意數字都可以被\(1、2、4、6\)的組合表示出來。時間複雜度就會降低為\(O(V*\)\(\sum log(n[i])\))。

參考程式(二進位制優化)

#include <iostream>
#include <algorithm>
#define N 20005
using namespace std;
int w[N], c[N], num[N];
long long d[N];

inline void read (int &x) {
	char c; x = 0; bool f = 0;
	c = getchar();
	while (c > '9' || c < '0') {if(c == '-') f = 1; c = getchar();}
	while (c >= '0' and c <= '9') {x = (x << 3) + (x << 1) + c - 48; c = getchar();} 
	if (f == 1)	x = -x;
}
int main () {
	int n, v, cnt = 1, x, y, z;
	read(n), read(v);
	for (int i = 1; i <= n; i++) {
		read(x), read(y), read(z);
		int t = 1;
		while (t <= z) {  // 轉換為二進位制
			w[cnt] = t * x;
			c[cnt] = t * y;
			cnt ++;
			z -= t;
			t *= 2;
		}
		if (z > 0) {
			w[cnt] = z * x;
			c[cnt] = z * y;
			cnt ++;
		}
	}
	for (int i = 1; i <= cnt; i++)
		for (int j = v; j >= w[i]; j--)
			d[j] = max (d[j], d[j-w[i]] + c[i]);
	cout << d[v];
	return 0;
}

單調佇列優化:

實際上多重揹包的複雜度還可以進一步降低,可以考慮使用單調佇列

首先來看樸素的多重揹包

\(f[j] = max (f[j], f[j - k * v] + k * w) | k <= s \,\,and\,\, k * v <= j\)

如果能將求\(f[j]\)的過程在\(O(1)\)的時間內算出來,那麼整體複雜度為\(O(NV)\)

如果將所有的體積進行分類,\(j \% v\)的結果進行分類,所有結果為\(j \% v == 0\)的分為一類,所有結果為\(j \% v == 1\)的分為一類,每一類完全沒有交集。可以將整體體積\(m\)分為\(v\)類。

觀察上述樸素的多重揹包狀態轉移方程,\(f[j]\)只會從\(f[j - k * v]\)中轉移過來,按照分類,只會從同一類轉移過來。

因此我們分別去考慮每一類,考慮的過程中,假定在算\(f[j]\)的時候,需要知道一共要用多少個第\(i\)個物品,因此有

\(f[j] = max (f[j], f[j - v] + w, f[j - 2 * v] + 2 * w, f[j - k * v] + k * w)\)

接下來按照分類,將f[0] 到 f[m] 總體積的過程寫成如下表達

\(f[0] , f[v], f[2 * v], f[3 * v], ......, f[k * v]\)

\(f[1], f[v + 1], f[2 * v + 1], f[3 * v + 1], ......, f[k * v + 1]\)

\(f[2], f[v + 2], f[2 * v + 2], f[3 * v + 2], ......, f[k * v + 2]\)

......

\(f[j], f[v + j], f[2 * v + j], f[3 * v + j], ......, f[k * v + j]\)

將全部的體積分為上面的v個類別,其中\(m = k * v + j, 0 <= j < v\)。每一類中的值都是轉換的

\(f[k * v + j]\)只依賴於 {\(f[j], f[v + j], f[2 * v + j],......, f[k * v + j]\)}中的最大值,因此可以通過單調佇列來維護這個序列中的最大值,這樣能在\(O(1)\)的時間找出最大值。

因此,我們可以得到
\(f[j] = f[j]\)
\(f[j + v] = max(f[j] + w, f[j + v])\)
\(f[j + 2 * v] = max(f[j] + 2 * w, f[j + v] + w, f[j + 2 * v])\)
\(f[j + 3 * v] = max(f[j] + 3 * w, f[j + v] + 2 * w, f[j + 2 * v] + w, f[j + 3 * v])\)

......

但是,這個佇列中前面的數,每次都會增加一個 w ,所以我們需要做一些轉換,方便單調佇列的運算

\(f[j] = f[j]\)
\(f[j + v] = max(f[j], f[j + v] - w) + w\)
\(f[j + 2 * v] = max(f[j], f[j + v] - w, f[j + 2 * v] - 2 * w) + 2 * w\)
\(f[j + 3 * v] = max(f[j], f[j + v] - w, f[j + 2 * v] - 2 * w, f[j + 3 * v] - 3 * w) + 3 * w\)
......
這樣,每次入隊的值是\(f[j + k * v] - k * w\)

單調佇列問題,最重要的兩點
1)維護佇列元素的個數,如果不能繼續入隊,彈出隊頭元素
2)維護佇列的單調性,即:尾值\(>= f[j + k * v] - k * w\)

參考程式

#include <iostream>
#include <cstring> 
#include <algorithm>
#include <vector>

using namespace std;

const int N = 2e4 + 10;

int m, n;
int f[N], g[N], q[N];

int main () {
	int v, w, s;
	cin >> n >> m;
	for (int i = 1; i <= n; i ++) {
		cin >> v >> w >> s;
		memcpy (g, f, sizeof f);
		// g[] 表示f[i-1]
		for (int j = 0; j < v; j ++) { 
		/**
			體積為c 分為c類,列舉一下所有的餘數
			每一類相互獨立的 
		**/ 
			int hh = 0, tt = -1;
			// hh 表示隊首位置,tt表示隊尾位置
			for (int k = j; k <= m; k += v) { 
			/**
				列舉同一類中的揹包體積
			**/
				// f[k] = g[k];
				if (hh <= tt and k - s * v > q[hh]) hh ++; 
				/*
					如果區間不合法則取出隊首
					該區間維護的是不超過s長度的區間 
					k - s * v > q[hh] (表示當前編號減去佇列最大容積大於隊首,隊首出隊)
				*/
				if (hh <= tt) f[k] = max (f[k], g[q[hh]] + (k - q[hh]) / v * w); 
				/**
					用最大數更新當前的數
					(k - q[hh]) / v * w 相當於k * w,
					(k- q[hh])為位置長度,除以體積c表示可以放c體積的數量
				**/ 
				while (hh <= tt and g[q[tt]] - (q[tt] - j) / v * w <= g[k] - (k - j) / v * w) tt --; 
				/**
					插入當前數字,並保證單調性
					保證隊尾的值剛好大於當前的值
					凡是比隊尾(g[q[tt]]-(q[tt] - j) / v*w)的值比當前值小(g[k]-(k - j) / v*w)的都捨去
				**/ 
				q[ ++ tt ] = k; // 當前位置資訊加入進佇列
			}
		}
	}
	cout << f[m];
	return 0;
}

混合揹包

【問題描述】

\(N\) 種物品和一個容量是 \(V\)的揹包。

物品一共有三類:

  • 第一類物品只能用\(1\)次(\(01\)揹包);
  • 第二類物品可以用無限次(完全揹包);
  • 第三類物品最多隻能用\(s_i\)次(多重揹包);

每種體積是 \(v_i\),價值是 \(w_i\)

求解將哪些物品裝入揹包,可使物品體積總和不超過揹包容量,且價值總和最大。
輸出最大價值。

【輸入格式】

第一行兩個整數\(N,V\),用空格隔開,分別表示物品種數和揹包容積。

接下來有 \(N\)行,每行三個整數\(v_i,w_i,s_i\),用空格隔開,分別表示第\(i\)種物品的體積、價值和數量。

  • \(s_i=−1\) 表示第\(i\) 種物品只能用\(1\)次;
  • \(s_i=0\)表示第\(i\) 種物品可以用無限次;
  • \(s_i>0\) 表示第\(i\)種物品可以使用\(s_i\) 次;

【輸出格式】

輸出一個整數,表示最大價值。

【資料範圍】

\(0<N,V≤1000\)
\(0<v_i,w_i≤1000\)
\(−1≤s_i≤1000\)

【輸入樣例】

4 5
1 2 -1
2 4 1
3 4 0
4 5 2

【輸出樣例】

8


實際上混合揹包就是將前\(3\)種揹包放在了一起,這裡就不說二維的方法了,考慮優化成一維的解法,實際上可以將多重揹包用二進位制優化成\(01\)揹包,這樣問題就轉換成立\(01\)揹包和完全揹包的問題,那麼在列舉揹包容量的時候,只需要一個正序一個逆序即可。

參考程式

#include <iostream>
#include <algorithm>
#include <vector>
#define N 1005
using namespace std;

struct Pack {
	int v, w, kind;
};

vector <Pack> p;
int f[N];

int main () {
	
	int n, V;
	int v, w, z;
	cin >> n >> V;	
	for (int i = 1; i <= n; i++) {
		cin >> v >> w >> z;
		if (z == -1) p.push_back ({v, w, -1});
		else if (z == 0) p.push_back ({v, w, 0});
		else {  // 轉換為01揹包
			for (int k = 1; k <= z; k *= 2) {
				p.push_back ({k*v, k*w, -1});
				z -= k;
			}
			if (z > 0) p.push_back ({z*v, z*w, -1});
		}
	}
	for (auto pack : p) {
		if (pack.kind == -1) 
			for (int j = V; j >= pack.v; j--)
				f[j] = max (f[j], f[j-pack.v] + pack.w);
		else 
			for (int j = pack.v; j <= V; j++)
				f[j] = max (f[j], f[j-pack.v] + pack.w);
	}
	cout << f[V];
	return 0;
}


二維費用的揹包問題

【問題描述】

\(N\)件物品和一個容量是\(V\)的揹包,揹包能承受的最大重量是 \(M\)

每件物品只能用一次。體積是 \(v_i\),重量是 \(m_i\),價值是 \(w_i\)

求解將哪些物品裝入揹包,可使物品總體積不超過揹包容量,總重量不超過揹包可承受的最大重量,且價值總和最大。
輸出最大價值。

【輸入格式】

第一行三個整數,\(N,V,M\),用空格隔開,分別表示物品件數、揹包容積和揹包可承受的最大重量。

接下來有\(N\)行,每行三個整數 \(v_i,m_i,w_i\),用空格隔開,分別表示第\(i\)件物品的體積、重量和價值。

【輸出格式】

輸出一個整數,表示最大價值。

【資料範圍】

\(0<N≤1000\)
\(0<V,M≤100\)
\(0<v_i,m_i≤100\)
\(0<w_i≤1000\)

【輸入樣例】

4 5 6
1 2 3
2 4 4
3 4 5
4 5 6

【輸出樣例】

8


對於一維的揹包問題\(f[j]\)表示重量不超過\(j\)的物品的最大價值,那麼二維的可以直接擴充套件一個維度

\(f[i][j]\)表示體積是\(i\)重量是\(j\)的情況下物品的最大價值

參考程式

#include <iostream>
#include <algorithm>
#define N 1005
using namespace std;

int v[N], m[N], w[N];
int f[N][N];


int main () {
	int n, V, M;
	cin >> n >> V >> M;
	for (int i = 1; i <= n; i++) 
		cin >> v[i] >> m[i] >> w[i];
	for (int i = 1; i <= n; i++)
		for (int j = V; j >= v[i]; j--)
			for (int k = M; k >= m[i]; k--)
				f[j][k] = max (f[j][k], f[j-v[i]][k-m[i]] + w[i]); 
	cout << f[V][M];
	
	return 0;
}


分組揹包問題

【問題描述】

\(N\) 組物品和一個容量是\(V\)的揹包。

每組物品有若干個,同一組內的物品最多隻能選一個。
每件物品的體積是 \(v_{ij}\),價值是\(w_{ij}\),其中 \(i\)是組號,\(j\)是組內編號。

求解將哪些物品裝入揹包,可使物品總體積不超過揹包容量,且總價值最大。

輸出最大價值。

【輸入格式】

第一行有兩個整數\(N,V\),用空格隔開,分別表示物品組數和揹包容量。

接下來有\(N\)組資料:

  • 每組資料第一行有一個整數\(S_i\),表示第 \(i\) 個物品組的物品數量;
  • 每組資料接下來有\(S_i\) 行,每行有兩個整數\(v_{ij},w_{ij}\),用空格隔開,分別表示第\(i\) 個物品組的第\(j\) 個物品的體積和價值;

【輸出格式】

輸出一個整數,表示最大價值。

【資料範圍】

\(0<N,V≤100\)
\(0<S_i≤100\)
\(0<v_{ij},w_{ij}≤100\)

【輸入樣例】

3 5
2
1 2
2 4
1
3 4
1
4 5

【輸出樣例】

8

分組揹包的特點就是將揹包中的物品進行了分組,同一組中的互斥,只能選擇一個。每組只能選一個物品或者不選。可以考慮如下的狀態轉移方程

假定\(d[i] [j]\) 表示從前i組中選擇重量不超過j的最大價值,則有

\(d[i] [j] = max(d[i-1] [j], d[i-1] [j-v[i]] + w[i])\),當前這一組是由上一組轉移過來的。簡單想一下就能明白一維陣列的優化:

\(d[j] = max (d[j], d[j-v[k]] + w[k])\) 其中\(k\)列舉當前第\(i\)個分組的所有物品,同時\(j\)需要逆序列舉。

參考程式

#include <iostream>
#include <algorithm>
using namespace std;

const int N = 105;
int f[N], v[N], m[N];

int main () {
	int n, V, s;
	cin >> n >> V;
	for (int i = 1; i <= n; i++) {
		cin >> s;
		for (int j = 1; j <= s; j++) {
			cin >> v[j] >> m[j];
		}
		for (int j = V; j >= 0; j --) {
			for (int k = 1; k <= s; k++) {
				if (j >= v[k])
					f[j] = max (f[j], f[j-v[k]]+m[k]);
			}
		}
	}
	cout << f[V];
	return 0;
}

分組揹包是很多變形的揹包問題的基礎,可以參考分組揹包的模型。


有依賴的揹包

【題目描述】

\(N\) 個物品和一個容量是\(V\) 的揹包。

物品之間具有依賴關係,且依賴關係組成一棵樹的形狀。如果選擇一個物品,則必須選擇它的父節點。

如下圖所示:

如果選擇物品\(5\),則必須選擇物品1和2。這是因為2是5的父節點,1是2的父節點。

每件物品的編號是\(i\),體積是 \(v_i\),價值是\(w_i\),依賴的父節點編號是 \(p_i\)。物品的下標範圍是 \(1…N\)

求解將哪些物品裝入揹包,可使物品總體積不超過揹包容量,且總價值最大。

輸出最大價值。

【輸入格式】

第一行有兩個整數\(N,V\),用空格隔開,分別表示物品個數和揹包容量。

接下來有\(N\) 行資料,每行資料表示一個物品。
\(i\) 行有三個整數 \(v_i,w_i,p_i\),用空格隔開,分別表示物品的體積、價值和依賴的物品編號。
如果 \(p_i=−1\),表示根節點。 資料保證所有物品構成一棵樹。

【輸出格式】

輸出一個整數,表示最大價值。

【資料範圍】

\(1≤N,V≤100\)
\(1≤v_i,w_i≤100\)

父節點編號範圍:

  • 內部結點:\(1≤p_i≤N;\)
  • 根節點 \(p_i=−1;\)

【輸入樣例】

5 7
2 3 -1
2 2 1
3 5 1
4 7 2
3 6 2

【輸出樣例】

11


參考文章來源

這個問題由NOIP2006 中“金明的預算方案”一題擴充套件而來。遵從該題的提法,將不依賴於別的物品的物品稱為“主件”,依賴於某主件的物品稱為“附件”。由這個問題的簡化條件可知所有的物品由若干主件和依賴於每個主件的一個附件集合組成。
按照揹包問題的一般思路,僅考慮一個主件和它的附件集合。可是,可用的策略非常多,包括:一個也不選,僅選擇主件,選擇主件後再選擇一個附件,選擇主件後再選擇兩個附件……無法用狀態轉移方程來表示如此多的策略。事實上,設有n 個附件,則策略有2^n + 1 個,為指數級。
考慮到所有這些策略都是互斥的(也就是說,你只能選擇一種策略),所以一個主件和它的附件集合實際上對分組揹包中的一個物品組,每個選擇了主件又選擇了若干個附件的策略對應於這個物品組中的一個物品,其費用和價值都是這個策略中的物品的值的和。但僅僅是這一步轉化並不能給出一個好的演算法,因為物品組中的物品還是像原問題的策略一樣多。
再考慮對每組內的物品優化。這提示我們,對於一個物品組中的物品,所有費用相同的物品只留一個價值最大的,不影響結果。所以,我們可以對主件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的演算法解決問題了。

	#include <iostream>
	#include <cstdio>
	#include <cstring>
	#include <algorithm>

	using namespace std;

	const int N = 110;

	int n, m, root;
	int h[N], e[N], ne[N], idx;
	int v[N], w[N], f[N][N]; // f[i][j]表示選擇節點i為根,所用的體積是j的情況下整棵子樹的最大收益是多少。

	/**
		求一棵樹的關係,可以從上往下用遞迴來做,每做到一個點的時候,先把所有子節點的f[i][j]算出來
		每一個子節點都對應了在不同體積下的一個價值,因此可以當成分組揹包,每一個子節點都是一個物品組
		不同體積對應一個物品組,整個組只能選擇一個物品
	**/
	void add (int a, int b) { // 有向圖加入一條邊,起點為a終點為b
		e[idx] = b; 
		ne[idx] = h[a];
		h[a] = idx ++;
	}

	void dfs (int u) {
		for (int i = h[u]; i != -1; i = ne[i]) { // 迴圈物品組
			int son = e[i]; // 遞迴求每一個物品時先將子節點算出來
			dfs (son);
			for (int j = m - v[u]; j >= 0; j --) {  
			/** 
				迴圈體積 (對比01揹包 for (int j = V; j >= v[i]; j--))
			 	必須選擇物品u,u作為根節點(因此體積為m-v[u] 先空出來v[u]大小的空間)
			**/
				for (int k = 0; k <= j; k++)  
				/** 
					列舉分組(可選可不選) 當前子節點為根的子樹看做一個組
					不同的體積看做是組內的不同物品,算出該子節點用哪個體積收益最大
					求出當前體積j下的最大收益f[u][j]
				 **/
					f[u][j] = max (f[u][j], f[u][j - k] + f[son][k]);
			}
		}
		for (int i = m; i >= v[u]; i --) f[u][i] = f[u][i - v[u]] + w[u]; // 如果體積大於v[u] 那麼要填補空出來的位置,將根節點加入
		for (int i = 0; i < v[u]; i ++) f[u][i] = 0;  // 如果體積小於v[u]說明根節點不可選,那麼子樹也不可選,都賦值成0
	}

	int main () {
		memset (h, -1, sizeof(h));
		cin >> n >> m;
		for (int i = 1; i <= n; i++) {
			int p;
			cin >> v[i] >> w[i] >> p;
			if (p == -1) root = i;
			else add (p, i); // p表示父結點,i是當前第幾個結點
		}
		dfs (root);
		cout << f[root][m] << endl; 

		return 0;
	}

揹包問題求方案數

【問題描述】

\(N\)件物品和一個容量是 \(V\)的揹包。每件物品只能使用一次。

\(i\)件物品的體積是 \(v_i\),價值是\(w_i\)

求解將哪些物品裝入揹包,可使這些物品的總體積不超過揹包容量,且總價值最大。

輸出 最優選法的方案數。注意答案可能很大,請輸出答案模\(10^9+7\)的結果。

【輸入格式】

第一行兩個整數,\(N,V\),用空格隔開,分別表示物品數量和揹包容積。

接下來有\(N\) 行,每行兩個整數\(v_i,w_i\),用空格隔開,分別表示第\(i\)件物品的體積和價值。

【輸出格式】

輸出一個整數,表示 方案數\(10^9+7\)的結果。

【資料範圍】

\(0<N,V≤1000\)
\(0<v_i,w_i≤1000\)

【輸入樣例】

4 5
1 2
2 4
3 4
4 6

【輸出樣例】

2


問題分析

按照\(01\)揹包一維表示方法,\(f[j]\)表示重量不超過j的情況下的最大價值。

狀態轉移為\(f[j] = max (f[j], f[j-w[i]] + c[i])\)

定義 \(cnt[j]\)表示重量不超過\(j\)的情況下,所產生的最大價值的最大方案數。

顯然,\(cnt\)應該初始化為\(1\),什麼都不選也是一種方案。

如果選擇了當前的第\(i\)個物品,那麼\(cnt[j] = cnt[j-w[i]]\),方案數和轉移過來的那個狀態的方案數相同。

還有一種情況則是當\(f[j] == f[j-w[i]]+c[i]\)時,此時的\(f\)不需要轉移,選擇或者不選第\(i\)個物品當前容量下的最大價值都沒有變化,但是由此產生的方案數發生了變化。因此\(cnt[j] = cnt[j] + cnt[j-w];\)

參考程式

#include <iostream>
#include <algorithm>

using namespace std;

const int N = 1005;
const int mod = 1e9 + 7;

int f[N], cnt[N];

int main () {
    int n, v, c, w;
    cin >> n >> v;
    for (int i = 0; i <= v; i++) cnt[i] = 1;
    for (int i = 1; i <= n; i++) {
        cin >> w >> c;
        for (int j = v; j >= w; j--) {
            if (f[j] < f[j-w]+c) {
                f[j] = f[j-w] + c;
                cnt[j] = cnt[j-w];
            } else if (f[j] == f[j-w] + c)
                cnt[j] = (cnt[j] + cnt[j-w]) % mod;
        }
    }
    cout << cnt[v] << endl;
    return 0;
}


揹包問題求具體方案

【問題描述】

\(N\)件物品和一個容量是\(V\) 的揹包。每件物品只能使用一次。

\(i\) 件物品的體積是\(v_i\),價值是 \(w_i\)

求解將哪些物品裝入揹包,可使這些物品的總體積不超過揹包容量,且總價值最大。

輸出 字典序最小的方案。這裡的字典序是指:所選物品的編號所構成的序列。物品的編號範圍是\(1…N\)

【輸入格式】

第一行兩個整數\(N,V\),用空格隔開,分別表示物品數量和揹包容積。

接下來有\(N\) 行,每行兩個整數\(v_i,w_i,\)用空格隔開,分別表示第\(i\)件物品的體積和價值。

【輸出格式】

輸出一行,包含若干個用空格隔開的整數,表示最優解中所選物品的編號序列,且該編號序列的字典序最小。

物品編號範圍是 \(1…N。\)

【資料範圍】

\(0<N,V≤1000\)
\(0<v_i,w_i≤1000\)

【輸入樣例】

4 5
1 2
2 4
3 4
4 6

【輸出樣例】

1 4


由於是要考慮字典序最小,可以重新考慮如下狀態轉移方程

\(f[i][j]\)表示從第\(i\)個物品到第\(n\)個物品中選擇重量不超過\(j\)的最大價值。

那麼稍加思考可以得出狀態轉移方程如下:(逆推)

\(f[i][j] = max (f[i+1] [j], f[i+1] [j - w] + c); i\)\(n\)\(1\)列舉

這樣做的目的是為了方便我們進行查詢。

如果存在一個選了物品1的的最優方案,那麼答案中一定包含\(1\),原本容量\(v\),現在變成容量為\(v-w[1]\)的揹包,物品為\(2...N\)的子問題。同樣如果物品不包含物品\(1\),那麼同樣變成容量為\(v-w[1]\)的揹包,物品為\(2...N\)的子問題。

具體操作表現為(順推)

如果\(f[i] [v] == f[i+1] [v-w[i]] + c[i]\),那麼一定要選擇物品i,說明選了物品能夠得到最大價值(根據\(f[i] [j]\)的狀態定義來看)

如果\(f[i] [v] == f[i+1] [j]\),說明不選i也能得到最優解。

如果\(f[i] [v] == f[i+1] [j] == f[i+1] [v-w[i]] + c[i]\),那麼根據字典序最小原則,應該按照選擇物品\(i\)來輸出方案

參考程式

#include <iostream>
#include <algorithm>

using namespace std;

const int N = 1005;
int w[N], c[N], f[N][N];
int main () {
    int n, v;
    cin >> n >> v;
    for (int i = 1; i <= n; i++) {
        cin >> w[i] >> c[i];
    }
    for (int i = n ; i >= 1; i --) {
        for (int j = 0; j <= v; j++) {
            if (j >= w[i]) 
                f[i][j] = max (f[i+1][j], f[i+1][j-w[i]]+c[i]);
            else   f[i][j] = f[i+1][j];
        }
    }
    int stor = v;
    for (int i = 1; i <= n; i++) {
        if (stor <= 0) break;
        if (stor >= w[i] && f[i][stor] == f[i+1][stor-w[i]]+c[i]) { //選擇i可以得到最優解,輸出
            cout << i << " ";
            stor -= w[i];
        }
        
    }
    return 0;
}


小結

揹包問題的列舉解決方式一般都是

1、列舉迴圈物品

2、列舉迴圈體積

3、列舉迴圈策略

狀態的定義需要靈活使用,不同的狀態需要進行不同的初始化,例如對於01揹包來講。有的題目要求“恰好裝滿揹包”時的最優解,有的題目則並沒有要求必須把揹包裝滿。一種區別這兩種問法的實現方法是在初始化的時候有所不同。

如果是第一種問法,要求恰好裝滿揹包,那麼在初始化時除了\(f[0]\)為0,其它\(f[1 : v]\)均設為\(-inf\),這樣就可以保證最終得到的\(f[V]\) 是一種恰好裝滿揹包的最優解。

如果並沒有要求必須把揹包裝滿,而是隻希望價格儘量大,初始化時應該將\(f[0 : v]\)全部設為0。

初始化的f陣列事實上就是在沒有任何物品可以放入揹包時的合法狀態。如果要求揹包恰好裝滿,那麼此時只有容量為0的揹包可以在價值為0的情況下被“恰好裝滿”,其它容量的揹包均沒有合法的解,屬於未定義的狀態,應該被賦值為\(-inf\)了。(f[v]是通過max函式來選擇,-inf是不會被選中的)

如果揹包並非必須被裝滿,那麼任何容量的揹包都有一個合法解“什麼都不裝”,這個解的價值為0,所以初始時狀態的值也就全部為0了。