背包好好玩,所以把他分出來講。
寫背包問題,聽[你的背包](https://www.youtube.com/watch?v=s3AmqV6VlE0)。(注意 : 此方法有奇效。)
# [背包問題 (1)](https://dandanjudge.fdhs.tyc.edu.tw/ShowProblem?problemid=b216) (0/1背包)
首先你可以現去看[這部影片](https://www.youtube.com/watch?v=nLmhmB6NzcM&t=1s),超有用。(但是他有一個缺點,看太多的話你英文的口音會被影響。)
:::success
0/1背包 的意思就是每個物品只能選 $0$ 次或 $1$ 次。
:::
---
### 暴力枚舉
看完影片之後你會知道 0/1背包 的第一種解法,暴力枚舉。(不看好像也會知道。)
DFS枚舉在這題可以拿到 $20\%$ ,其他都 $TLE$ ,因為他的時間複雜度是 $O(2^n)$ 。
:::info
:::spoiler DFS枚舉
```cpp=
#include <list>
#include <cmath>
#include <stack>
#include <queue>
#include <deque>
#include <vector>
#include <cstdio>
#include <random>
#include <string>
#include <cstring>
#include <sstream>
#include <utility>
#include <iomanip>
#include <fstream>
#include <iostream>
#include <algorithm>
#include <unordered_map>
#define int long long
#define endl "\n"
#define F first
#define S second
#define mkp make_pair
#define UID uniform_int_distribution
#define int_range numeric_limits<int>::min(), numeric_limits<int>::max()
using namespace std ;
int n , c , best = 0;
vector<int> w , v ;
void DFS(int i, int cur_w, int cur_v){
if (i == n){
// 所有物品都考慮完,更新最優值。
best = max(best , cur_v) ;
return ;
}
// 不選第 i 件。
dfs(i + 1, cur_w, cur_v);
// 在不超重的前提下選第 i 件。
if (cur_w + w[i] <= c){
dfs(i + 1 , cur_w + w[i] , cur_v + v[i]) ;
}
}
signed main(){
ios::sync_with_stdio(0),cin.tie(0),cout.tie(0) ;
cin >> n >> c ;
w.resize(n) ;
v.resize(n) ;
for (int i = 0 ; i < n ; i++){
cin >> w[i] >> v[i] ;
}
DFS(0, 0, 0) ;
cout << best << endl ;
return 0 ;
}
```
:::
二進制遮罩枚舉送出後是 $0\%$,其中有一筆 $TLE$ ,因為他的時間複雜度也是 $O(2^n)$ 。
其他筆會看到輸出 $0$ ,是因為他來不及跑出答案,對,真的。
:::info
:::spoiler 二進制遮罩枚舉 (Bitmask)
```cpp=
#include <list>
#include <cmath>
#include <stack>
#include <queue>
#include <deque>
#include <vector>
#include <cstdio>
#include <random>
#include <string>
#include <cstring>
#include <sstream>
#include <utility>
#include <iomanip>
#include <fstream>
#include <iostream>
#include <algorithm>
#include <unordered_map>
#define int long long
#define endl "\n"
#define F first
#define S second
#define mkp make_pair
#define UID uniform_int_distribution
#define int_range numeric_limits<int>::min(), numeric_limits<int>::max()
using namespace std ;
signed main(){
ios::sync_with_stdio(0),cin.tie(0),cout.tie(0) ;
int n , c ;
cin >> n >> c ;
vector<int> w(n) , v(n) ;
for (int i = 0 ; i < n ; i++){
cin >> w[i] >> v[i] ;
}
int ans = 0 ;
// 枚舉所有可能的子集。
int total_mask = 1 << n ;
for (int mask = 0; mask < total_mask ; mask++){
int sum_w = 0 , sum_v = 0 ;
// 遍歷遮罩的每一位,判斷是否選第 i 件物品。
for (int i = 0 ; i < n ; i++){
if (mask & (1 << i)){
sum_w += w[i] ;
sum_v += v[i] ;
}
}
// 如果不超重,就嘗試更新答案。
if (sum_w <= c){
ans = max(ans, sum_v) ;
}
}
cout << ans << endl ;
return 0 ;
}
```
:::
#### 小結
就是會 $TLE$ ,因為他的 $n$ 最多到 $1000$ ,然後時間複雜度是 $O(2^n)$ ,所以是 $2^{1000} \approx 10^{301}$ 。
會炸掉很正常。
#### 提問
在我打這部分的時候,好電的 $timmy$ 問我說為什麼枚舉是用 DFS 和 二進制遮罩,其實原因就是因為我們枚舉的目的是 **列舉所有物品的選擇組合** ,會是 $O(2^n)$ 也就變得好理解了,因為每一個都有選或不選兩種選擇。
用 DFS 的原因是要遍歷一棵樹,一棵二叉樹,分為選或不選,一共有 $2^n$ 條路。
不用二進制遮罩可以改為 `bool used[n]`,但有一個很明顯的問題,記憶體佔用高。
所以改用二進制遮罩,用一個整數的二進位表示法,剛好可以完美記錄哪些物品被選過,選了是 `0` ,沒選是 `1` 。
---
### 二維DP
看完影片之後你會知道 0/1背包 的第二種解法,二維DP。在影片中細緻的解釋了他的做法和過程,並且用了表格和集合兩種方式來講解。
當你看完表格法時你就已經會二維DP的作法了。
:::info
:::spoiler 二維DP
```cpp=
#include <list>
#include <cmath>
#include <stack>
#include <queue>
#include <deque>
#include <vector>
#include <cstdio>
#include <random>
#include <string>
#include <cstring>
#include <sstream>
#include <utility>
#include <iomanip>
#include <fstream>
#include <iostream>
#include <algorithm>
#include <unordered_map>
#define int long long
#define endl "\n"
#define F first
#define S second
#define mkp make_pair
#define UID uniform_int_distribution
#define int_range numeric_limits<int>::min(), numeric_limits<int>::max()
using namespace std ;
signed main() {
ios::sync_with_stdio(0),cin.tie(0),cout.tie(0) ;
int n, c ;
cin >> n >> c ;
vector<int> w(n+1) , v(n+1) ;
for (int i = 1 ; i <= n ; i++){
cin >> w[i] >> v[i] ;
}
// dp[i][j]:前 i 件物品,容量不超過 j 時的最大價值。
vector<vector<int>> dp(n+1 , vector<int>(c+1 , 0)) ;
for (int i = 1 ; i <= n ; i++){
for (int j = 0 ; j <= c ; j++){
// 不選第 i 件。
dp[i][j] = dp[i-1][j] ;
// 在不超重的前提下選第 i 件。
if (j >= w[i]){
dp[i][j] = max(dp[i][j] , dp[i-1][j - w[i]] + v[i]) ;
}
}
}
cout << dp[n][c] << endl ;
return 0 ;
}
```
:::
然後你會發現送出會是 $0\%$ ,全是 $RE$ 。
他的時間複雜度是 $O(nc)$ ,時間的確是不會爆。
但是他空間複雜度也是 $O(nc)$,`dp[1001][100001]` 把大約 $10^8$ 個 `long long` $(\approx800 \; MB)$ 的空間放在函數堆疊裡,然後因為系統最多只能設到 $512 \; MB$ 加上我沒有特別去改所以只有 $64 \; MB$ ,因此在他宣告時就會發生 $Stack \; Overflow$,系統回報 $Segmentation \; Fault$ 就非常開心的拿到 $RE$ 了。
至於想細部的了解 $Stack \; Overflow$ 的發生過程,去學 記憶體佈局,好玩。
#### 轉移式
$dp[i][j] =
\begin{cases}dp[i-1][j], & \text{若 } j < w_i \\
\max\left(dp[i-1][j],\ dp[i-1][j - w_i] + v_i\right), & \text{若 } j \ge w_i
\end{cases}$
---
### 一維滾動DP
鋪墊那麼多,就是為了他。
很明顯,二維DP 解決不了這一題,所以我們需要把空間壓的更小。
如果你理解了 二維DP 在幹嘛並可以清楚的解釋他的流程,那你就會發現,他在運行時只會用到 **前一行的狀態 `dp[i-1][ ]`** 那就代表只要用一個一維陣列就可以了。
所以我們就:
```cpp=
for (int j = w[i] ; j <= c ; j++){
dp[j] = max(dp[j] , dp[j - w[i]] + v[i]) ;
}
```
可是眼尖的你就發現了,這時的 `dp[j]` 是在處理完前 `i` 件物品後,容量為 `j` 的最大價值。
如果你沒有發現也不了解上面一句話是什麼意思,那你就是不理解上面那句話 :+1: 。
通俗一點的說,他是讓同一個物品可以被多次選取,也就是東西可能已經被用過了,而 0/1背包 的主軸就是 **最多只能放一次** 那就不行這樣做,他變成了 完全背包。
那怎麼辦 ?
::: warning
前面過不去,我們就從後面來。
:::
這句話是 0/1背包 的精髓,對,精髓。
因為當你倒著走時,還沒被更新的 `dp[j - w[i]]` 是前一輪(上一件物品)留下來的,所以你確保了每一個物品只能用一次。
:::danger
:::spoiler 如果還是聽不懂,這裡把步驟給剖析一次。
重量 : `w = 2`
價值 : `v = 10`
背包總容量 : `c = 5`
正序:
```cpp=
dp[0] = 0 ;
for (int j = 2 ; j <= 5 ; j++){
dp[j] = max(dp[j] , dp[j - 2] + 10) ;
}
```
| j | dp\[j - 2] | 更新後的 dp\[j] |
| - | ------------- | -------------------- |
| 2 | dp\[0] = 0 | dp\[2] = 10 |
| 3 | dp\[1] = 0 | dp\[3] = 10 |
| 4 | dp\[2] = 10 | dp\[4] = 20 → 多選一次了 |
| 5 | dp\[3] = 10 | dp\[5] = 20 → 又多選一次了 |
最大價值為 $20$ 。
倒序:
```cpp=
dp[0] = 0 ;
for (int j = 5 ; j >= 2 ; j--){
dp[j] = max(dp[j] , dp[j - 2] + 10) ;
}
```
| j | dp\[j - 2] | 更新後的 dp\[j] |
| - | ---------- | ----------- |
| 5 | dp\[3] = 0 | dp\[5] = 10 |
| 4 | dp\[2] = 0 | dp\[4] = 10 |
| 3 | dp\[1] = 0 | dp\[3] = 10 |
| 2 | dp\[0] = 0 | dp\[2] = 10 |
最大價值為 $10$ 。
:::
所以你會得到:
```cpp=
for(int j = c ; j >= w[i] ; j--){
dp[j] = max(dp[j] , dp[j-w[i]]+v[i]) ;
}
```
:::info
:::spoiler 一維滾動DP
```cpp=
#include <list>
#include <cmath>
#include <stack>
#include <queue>
#include <deque>
#include <vector>
#include <cstdio>
#include <random>
#include <string>
#include <cstring>
#include <sstream>
#include <utility>
#include <iomanip>
#include <fstream>
#include <iostream>
#include <algorithm>
#include <unordered_set>
#include <unordered_map>
#define int long long
#define endl "\n"
#define F first
#define S second
#define mkp make_pair
#define UID uniform_int_distribution
#define int_range numeric_limits<int>::min(), numeric_limits<int>::max()
using namespace std ;
signed main(){
ios::sync_with_stdio(0),cin.tie(0),cout.tie(0) ;
int n , c ;
cin >> n >> c ;
vector<int> w(n) , v(n) ;
for(int i = 0 ; i < n ; i++){
cin >> w[i] >> v[i] ;
}
vector<int> dp(c+1 , 0) ;
for(int i = 0 ; i < n ; i++){
for(int j = c ; j >= w[i] ; j--){
dp[j] = max(dp[j] , dp[j-w[i]]+v[i]) ;
}
}
cout << dp[c] ;
return 0 ;
}
```
:::
時間複雜度是 $O(nc)$,空間複雜度是 $O(c)$,成功的減少了。
#### 轉移式
$\displaystyle dp[j] = \max(dp[j],\ dp[j - w_i] + v_i)$