DP專題-專項訓練:狀壓 DP
一些 update
update on 2021/7/19:發現第二道題目的程式碼貼錯了,現已更正。
1. 前言
本篇博文是狀壓 DP 的練習題博文。
沒有學過狀壓 DP?
傳送門:演算法學習筆記:狀態壓縮 DP
狀壓 DP 非常之靈活,這裡選了 3 道經典題。
更多的題目?請前往洛谷使用者 @StudyingFather 的 一個動態更新的洛谷綜合題單 檢視。
2. 練習題
題單:
P2704 [NOI2001] 炮兵陣地
這道題是一道簡單題,相信各位掌握了互不侵犯那題後很容易解決。
設 \(f_{i,j,k}\) 表示 \(1-i\) 行,第 \(i\) 行狀態為 \(j\),第 \(i-1\) 行狀態為 \(k\) 的方案數。
那麼轉移方程如下:
\[f_{i,j,k}=\max\{f_{i-1,k,l}+Sum_j\} \]保證 \(j,k,l\) 狀態合法。
判斷狀態合法?\((j \& k) || (k \& l) || (l \& j)\)
於是就結束了……
等一等!我 MLE 了!
這也就是在上一篇博文中作者特別提及過的問題。
這道題需要壓縮空間。
兩種方法:
- 採用滾動陣列的方式,減少第一維的空間。
因為這道題的轉移當前行只和上一行有關係,因此第一位只需要開 2,然後利用 \(i \mod 2\) 來轉移即可。 - 上篇博文中作者提到過,這道題如果我們將合法狀態輸出,會發現 最多隻有 60 個。 所以完全可以直接壓縮後兩位狀態到 60。
當然可以兩個一起,但是感覺沒什麼用處。
程式碼:
/* ========= Plozia ========= Author:Plozia Problem:P2704 [NOI2001] 炮兵陣地 Date:2021/3/2 ========= Plozia ========= */ #include <bits/stdc++.h> #define Max(a, b) ((a > b) ? a : b) typedef long long LL; const int MAXN = (1 << 10) + 10, MAXP = 60 + 10; int n, m, cnt, State[MAXN], Sum[MAXN], a[100 + 10][100 + 10], Map[100 + 10], f[100 + 10][MAXP][MAXP], ans = 0; int read() { int sum = 0, fh = 1; char ch = getchar(); for (; ch < '0' || ch > '9'; ch = getchar()) fh -= (ch == '-') << 1; for (; ch >= '0' && ch <= '9'; ch = getchar()) sum = (sum << 3) + (sum << 1) + (ch ^ 48); return sum * fh; } void dfs(int pos, int sum, int num) { if (pos >= m) {State[++cnt] = sum; Sum[cnt] = num; return ;} dfs(pos + 1, sum, num); dfs(pos + 3, sum + (1 << pos), num + 1); } int main() { n = read(), m = read(); Map[0] = (1 << m) - 1; for (int i = 1; i <= n; ++i) for (int j = 1; j <= m; ++j) { char ch; std::cin >> ch; if (ch == 'P') a[i][j] = 1; Map[i] += a[i][j] * (1 << (m - j)); } dfs(0, 0, 0); for (int i = 1; i <= cnt; ++i) for (int j = 1; j <= cnt; ++j) f[1][i][j] = Sum[i]; for (int i = 2; i <= n; ++i) { for (int j = 1; j <= cnt; ++j) { if (!((Map[i] & State[j]) == State[j])) continue; for (int k = 1; k <= cnt; ++k) { if (!((Map[i - 1] & State[k]) == State[k])) continue; for (int l = 1; l <= cnt; ++l) { if (!((Map[i - 2] & State[l]) == State[l])) continue; if ((State[j] & State[k]) || (State[k] & State[l]) || (State[j] & State[l])) continue; f[i][j][k] = Max(f[i][j][k], f[i - 1][k][l] + Sum[j]); } } } } for (int i = 1; i <= cnt; ++i) for (int j = 1; j <= cnt; ++j) ans = Max(ans, f[n][i][j]); printf("%lld\n", ans); return 0; }
P2157 [SDOI2009]學校食堂
先補充一個式子:
\[a \text{ or } b \text{ - } a \text{ and } b = a \text{ xor } b \]不過不知道這個結論好像也可以做
神仙狀壓 DP 題。
第一眼看上去的時候我傻了:\(1\leq n \leq 1000\).
這怎麼狀壓啊?沒法狀壓啊?
然後我又看了一眼資料:\(0 \leq B_i \leq 7\)。
哦那沒事了。
於是我們首先有了一個狀態的雛形:\(f_{i,j}\) 表示當前做到第 \(i\) 個人而且 \([1,i-1]\) 的人全部都拿過了飯的最小等待時間,其中當前第 \(i\) 個人以及其後面 7 個人拿飯組成的狀態為 \(j\)。
於是你會發現狀態轉移方程寫不出來。
寫不出來嗎?我們試著寫一寫:
- 如果第 \(i\) 個人拿了飯,也就是 \(j \& 1\) 為真,那麼此時 \(i\) 就可以走人,直接轉移時間到 \(f_{i+1,j>>1}\)。
- 如果第 \(i\) 個人不拿飯,那麼我們需要從後面挑一個人出來拿飯,於是就。。。。。。
你會發現,如果我們不知道上一個拿飯的人是誰,是無法算出轉移新增的時間的!
於是我們引入第三維變數 \(k\) 來記錄上一個拿飯的人,\(k \in [-8,7]\) 表示距離 \(i\) 的位置,也就是上一個拿飯的人是 \(i+k\)。
那麼再次寫轉移方程:
- 如果第 \(i\) 個人拿了飯,也就是 \(j \& 1\) 為真,那麼此時 \(i\) 就可以走人,直接轉移時間到 \(f_{i+1,j>>1,k-1}\)。
- 如果第 \(i\) 個人不拿飯,也就是 \(j \& 1\) 為假,此時我們需要從後面挑一個人出來拿飯,假設這個人是 \(i+l\),那麼他將影響到的是 \(f_{i,j|(1<<l),l}\)。
什麼意思呢?由於第 \(i\) 個人不拿飯,那麼沒法轉移到第 \(i+1\),但是第 \(l\) 個人先拿了飯,此時的狀態就會變成 \(j|(1<<l)\),上一個人編號為 \(i+l\)。
但是需要注意的是,考慮到 \(i\) 後面的人可能會有更小的容忍值,那麼此時我們需要變數 \(r\) 來記錄當前最多能夠使誰拿飯(也就是編號最大的),如果超出這個值就說明有人不能容忍了,要立刻停止轉移。
初值:\(f_{1,0,0}=0\),其餘為正無窮。答案:\(\min\{f_{n+1,0,i}|i \in [-8,0]\}\)。
需要注意的是,考慮到陣列維度不能開負數,\(k\) 都要加 8,而這就導致了很多細節性的問題,需要注意。
程式碼:
#include <bits/stdc++.h>
#define Min(a, b) ((a < b) ? a : b)
using namespace std;
typedef long long LL;
const int MAXN = 1000 + 10, MAXP = (1 << 8) - 1;
int n, t[MAXN], b[MAXN], f[MAXN][MAXP][20];
int read()
{
int sum = 0, fh = 1; char ch = getchar();
for (; ch < '0' || ch > '9'; ch = getchar()) fh -= (ch == '-') << 1;
for (; ch >= '0' && ch <= '9'; ch = getchar()) sum = (sum << 3) + (sum << 1) + (ch ^ 48);
return (fh == 1) ? sum : -sum;
}
namespace Plozia
{
void main()
{
n = read();
memset(t, 0, sizeof(t)); memset(b, 0, sizeof(b));
for (int i = 1; i <= n; ++i) t[i] = read(), b[i] = read();
memset(f, 0x3f, sizeof(f));
f[1][0][7] = 0;
for (int i = 1; i <= n; ++i)
for (int j = 0; j <= MAXP; ++j)
for (int k = -8; k <= 7; ++k)
{
if (f[i][j][k + 8] != 0x3f3f3f3f)
{
if (j & 1) f[i + 1][j >> 1][k - 1 + 8] = Min(f[i + 1][j >> 1][k - 1 + 8], f[i][j][k + 8]);
else
{
int r = 0x3f3f3f3f;
for (int l = 0; l <= 7; ++l)
{
if ((j >> l) & 1) continue;
if (i + l > r) break;
r = Min(r, i + l + b[i + l]);
f[i][j | (1 << l)][l + 8] = Min(f[i][j | (1 << l)][l + 8], f[i][j][k + 8] + ((i + k) ? (t[i + k] ^ t[i + l]) : 0));
}
}
}
}
int ans = 0x3f3f3f3f;
for (int i = 0; i <= 8; ++i)
ans = Min(ans, f[n + 1][0][i]);
printf("%d\n", ans);
return ;
}
}
int main()
{
int t = read();
while (t--) Plozia::main();
return 0;
}
P5005 中國象棋 - 擺上馬
相信自己的做法,大喊一聲 :I won't MLE!你就會過這道題。
於是我就 MLE 了。
先假設空間限制為 256 MB,然後來想這道題。
這是一道二維的狀壓 DP,模仿第一題不難想到設 \(f_{i,j,k}\) 表示當前做到第 \(i\) 行,當前行狀態為 \(j\),上一行狀態為 \(k\) 的方案數。
因為馬攻擊範圍可以到上下兩行,所以需要列舉上上行狀態 \(l\)。
那麼轉移方程如下:
\[f_{i,j,k}=\sum f_{i-1,k,l} \]其中保證 \(j,k,l\) 不會互相沖突。
初值:\(f_{1,i,0}=1\),第二行需要特別處理,因為沒有上上行。
於是我們可以先寫下面這樣的程式碼:
for (int i = 0; i < (1 << m); ++i)
for (int j = 0; j < (1 << m); ++j)
if (i 與 j 不衝突) f[2][j][i] = (f[2][j][i] + f[1][i][0]) % P;
for (int i = 3; i <= n; ++i)
for (int j = 0; j < (1 << m); ++j)
for (int k = 0; k < (1 << m); ++k)
{
f[i][j][k] = 0;
if (j 與 k 衝突) continue;
for (int l = 0; l < (1 << m); ++l)
{
if (k 與 l 衝突) continue;
if (j 與 l 衝突) continue;
f[i][j][k] = (f[i][j][k] + f[i - 1][k][l]) % P;
}
}
ans = 0;
for (int i = 0; i < (1 << m); ++i)
for (int j = 0; j < (1 << m); ++j)
if (i 與 j 不衝突) ans = (ans + f[n][i][j]) % P;
然後來考慮怎麼處理衝突問題。
衝突分兩種:兩行衝突(Two_attack
)和三行衝突(Three_attack
)。
- 兩行衝突:也就是單行對下一行的攻擊是否與下一行衝突。
- 三行衝突:也就是第一行的跨行攻擊是否對第三行衝突。
不好理解?那就對了,反正我說的也不是人話, 上圖!
兩行衝突:
從右往左考慮。記當前狀態為 10110110
第一個格子沒有馬,跳過。
第二個格子有馬,那麼這個格子右邊有馬嗎?沒有。於是可以向右攻擊。但是他左邊有馬,於是不能想左攻擊。
那麼就變成了這樣:
第三個格子,右邊有馬,左邊沒有馬,那麼可以向左邊攻擊。
這麼迴圈反覆,最後就變成了這樣:
三行衝突(暫且不考慮兩行衝突):
還是考慮第一行,發現右數第二格有馬,而且沒被擋住,那麼可以向下面兩行攻擊。
然後右數第三個,有馬且沒被擋住,可以向下攻擊。
那麼繼續做下去,發現只有第一列的馬被擋住,那麼最後結果如下:
於是就做完了。
關於程式碼實現:
首先我們需要兩個基礎函式:
int Getbit(int x, int a)//返回 x 的二進位制下第 a 位且保留右側 0
{
if (a < 1) return 0;
return x & (1 << (a - 1));
}
int check(int x, int a)//查詢 x 的二進位制下第 a 位
{
if (a < 1) return 0;
if (x & (1 << (a - 1))) return 1;
return 0;
}
然後就可以愉快的打程式碼了。
這裡有一個小技巧:-1
的補碼是 11111111111111111111111111111111
(32 個1),可以利用這個來處理位運算。
Two_attack
和 Three_attack
如下:
int Two_attack(int k)//k 是上面一行狀態
{
int State = 0;
for (int i = 1; Getbit(-1, i) <= k; ++i)
{
if (!check(k, i)) continue;
if (!check(k, i - 1)) State |= Getbit(-1, i - 2);
if (!check(k, i + 1)) State |= Getbit(-1, i + 2);
}
return State;
}
int Three_attack(int k, int l)//k 是第一行,l 是第二行
{
int State = 0;
for (int i = 1; Getbit(-1, i) <= k; ++i)
{
if (!check(k, i)) continue;
if (!check(l, i)) State |= Getbit(-1, i - 1), State |= Getbit(-1, i + 1);
}
return State;
}
那麼就做完了。
特別提醒:因為本題毒瘤的空間限制,必須使用滾動陣列壓縮空間。
程式碼:
/*
========= Plozia =========
Author:Plozia
Problem:P5005 中國象棋 - 擺上馬
Date:2021/3/5
========= Plozia =========
*/
#include <bits/stdc++.h>
typedef long long LL;
const int P = 1e9 + 7;
int n, m;
LL f[3][64][64], ans;
int read()
{
int sum = 0, fh = 1; char ch = getchar();
for (; ch < '0' || ch > '9'; ch = getchar()) fh -= (ch == '-') << 1;
for (; ch >= '0' && ch <= '9'; ch = getchar()) sum = (sum << 3) + (sum << 1) + (ch ^ 48);
return (fh == 1) ? sum : -sum;
}
int Getbit(int x, int a)//返回 x 的二進位制下第 a 位且保留右側 0
{
if (a < 1) return 0;
return x & (1 << (a - 1));
}
int check(int x, int a)//查詢 x 的二進位制下第 a 位
{
if (a < 1) return 0;
if (x & (1 << (a - 1))) return 1;
return 0;
}
int Two_attack(int k)//k 是上面一行狀態
{
int State = 0;
for (int i = 1; Getbit(-1, i) <= k; ++i)
{
if (!check(k, i)) continue;
if (!check(k, i - 1)) State |= Getbit(-1, i - 2);
if (!check(k, i + 1)) State |= Getbit(-1, i + 2);
}
return State;
}
int Three_attack(int k, int l)//k 是第一行,l 是第二行
{
int State = 0;
for (int i = 1; Getbit(-1, i) <= k; ++i)
{
if (!check(k, i)) continue;
if (!check(l, i)) State |= Getbit(-1, i - 1), State |= Getbit(-1, i + 1);
}
return State;
}
int main()
{
n = read(), m = read();
for (int i = 0; i < (1 << m); ++i) f[1][i][0] = 1;
for (int i = 0; i < (1 << m); ++i)
for (int j = 0; j < (1 << m); ++j)
if ((!(Two_attack(i) & j)) & (!(Two_attack(j) & i))) f[2][j][i] = (f[2][j][i] + f[1][i][0]) % P;
for (int i = 3; i <= n; ++i)
for (int j = 0; j < (1 << m); ++j)
for (int k = 0; k < (1 << m); ++k)
{
f[i % 3][j][k] = 0;
if (Two_attack(j) & k) continue;
if (Two_attack(k) & j) continue;
for (int l = 0; l < (1 << m); ++l)
{
if (Two_attack(l) & k) continue;
if (Two_attack(k) & l) continue;
if (Three_attack(l, k) & j) continue;
if (Three_attack(j, k) & l) continue;
f[i % 3][j][k] = (f[i % 3][j][k] + f[(i - 1) % 3][k][l]) % P;
}
}
ans = 0;
for (int i = 0; i < (1 << m); ++i)
for (int j = 0; j < (1 << m); ++j)
if ((!(Two_attack(i) & j)) & (!(Two_attack(j) & i))) ans = (ans + f[n % 3][i][j]) % P;
printf("%lld\n", ans);
return 0;
}
3. 總結
狀壓 DP 還是非常靈活的,非常考驗思維能力以及程式碼能力,需要多加練習。