文章目录
一、状态压缩方式
这里所讲到的 状态压缩 是指,将若干个 01 状态借助二进制来表示。
一般用一个整数就可以表示一种状态:通过其二进制表示中的第 i 个位置是 0
还是 1
,来表示出第 i 个 “物品” 的状态。
二、状态压缩bfs
题型归纳
我所遇到的 状态压缩 bfs 分为两大类:
类型1:状态看作点
将所有的属性组合在一起构成若干种状态,将每种状态看作一个点。
通过操作,一些属性改变,也就对应着状态改变。相当于状态与状态之间连边。
每次操作都使得操作数+1,也就是边权为1;或者每次操作花费
w
i
w_i
wi,也就是边权为
w
i
w_i
wi。
求到达最终状态的最小操作次数,也就是到一个点的最小花费,bfs
或者 最短路
。
① 边权相同,可以跑
bfs
求出得到每种状态所需的最小花费,常见求最小操作数。
例题1:Flip Game
给定
4
∗
4
4*4
4∗4 的棋盘,每个格子有白棋或者黑棋。
每次可以选择一个位置进行操作:
- 将该位置以及其相邻的四个位置的棋子颜色都进行翻转 —— 白棋变为黑棋,黑棋变为白棋。
问,最少需要多少次操作能够使得棋盘中的棋子都是一种颜色?
如果无法得到,输出 -1。
思路
一共有 16 个格子,也就是 16 种属性,将这些属性进行组合得到若干种状态。那么最多有
2
16
2^{16}
216 种状态。
把每种状态看作一个点,也就有
2
16
2^{16}
216 个点。
对于一次操作,可以使一种状态变为另一种状态,也就相当于两种状态之间连边,权值为 1。 边权相同,可以跑 bfs。
Code
#include<iostream>
#include<algorithm>
#include<cstring>
#include<queue>
using namespace std;
const int N = 210, mod = 1e9+7;
int T, n, m;
int a[N], st;
int f[(1<<17)+10], endd;
int que[(1<<17)+10];
int dir[4][2] = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
int get(int x, int y){ //二维坐标转一维
return (x-1)*n + y;
}
void bfs()
{
int hh = -1, tt = 0;
que[++hh] = st;
f[st] = 0;
while(tt <= hh)
{
int s = que[tt];
tt++;
if(s == endd || s == 0) return;
for(int i=1;i<=n;i++){
for(int j=1;j<=n;j++)
{
int t = s;
t ^= (1<<get(i, j));
for(int k=0;k<4;k++){
int tx = i+dir[k][0], ty = j+dir[k][1];
if(tx < 1 || tx > n || ty < 1 || ty > n) continue;
t ^= (1<<get(tx, ty));
}
if(f[t] != 0x3f3f3f3f) continue;
f[t] = f[s] + 1;
que[++hh] = t;
}
}
}
}
signed main(){
Ios;
memset(f, 0x3f, sizeof f); //初始为正无穷
n = 4;
for(int i=1;i<=n;i++)
for(int j=1;j<=n;j++)
{
char c;cin>>c;
if(c=='b') st |= (1<<get(i, j));
}
for(int i=1;i<=n;i++)
for(int j=1;j<=n;j++)
endd |= (1<<get(i, j));
bfs();
int ans = min(f[endd], f[0]);
if(ans != 0x3f3f3f3f) cout<<ans;
else cout << "Impossible";
return 0;
}
变式1:The Pilots Brothers’ refrigerator
变式2:关灯问题II
② 边权不同,则需要跑
最短路
求到达终点(最终状态)的最小花费。
例题2:软件补丁问题
一个软件中初始有 n 个错误,现发放了 m 个补丁程序。
(
1
≤
n
≤
20
,
1
≤
m
≤
100
)
(1≤n≤20, 1≤m≤100)
(1≤n≤20,1≤m≤100)
对于每个补丁程序,只有在软件中包含某些错误而不包含另一些错误时才可以使用。
一个补丁在排除某些错误的同时,往往会加入另一些错误。使用补丁程序
i
i
i 会消耗时间
t
i
t_i
ti。
问,将所有错误修复所消耗的最少时间为多少?
如果问题无解,则输出 0。
分析
对于每次状态与状态间的转换需要一个花费,将每个状态看作一点,状态之间连边,花费即为边权。
要求得所有错误都被修复的状态,即到达目标点的最小花费,跑 dijkstra。
Code
//https://www.luogu.com.cn/problem/P2761
#include<bits/stdc++.h>
using namespace std;
#define mem(a,b) memset(a,b,sizeof a)
#define int long long
#define PII pair<int,int>
#define pb push_back
#define fi first
#define se second
const int N = 210, mod = 1e9+7;
int T, n, m;
int a[N];
vector<int> inc[N], exc[N], rep[N], add[N];
int st, en, w[N];
int dist[(1<<21)+10];
bool f[(1<<21)+10];
void dij()
{
priority_queue<PII, vector<PII>, greater<PII> > que;
que.push({0, st});
for(int i=0;i<=(1<<21);i++) dist[i] = 1e18;
// mem(dist, 0x3f3f3f3f);
dist[st] = 0;
while(que.size())
{
int state = que.top().se;
que.pop();
if(f[state]) continue;
f[state] = 1;
for(int i=1;i<=m;i++)
{
int flag = 0;
for(auto tx:inc[i]) if(!(state & (1<<tx))) flag=1;
for(auto tx:exc[i]) if(state & (1<<tx)) flag=1;
if(flag) continue;
int t = state;
for(auto tx:rep[i]) if(t & (1<<tx)) t ^= (1<<tx);
for(auto tx:add[i]) t |= (1<<tx);
if(dist[t] > dist[state] + w[i])
dist[t] = dist[state] + w[i], que.push({dist[t], t});
}
}
}
signed main(){
Ios;
cin>>n>>m;
for(int i=1;i<=m;i++)
{
cin >> w[i];
for(int j=1;j<=n;j++)
{
char c;cin>>c;
if(c == '+') inc[i].push_back(j);
if(c == '-') exc[i].push_back(j);
}
for(int j=1;j<=n;j++)
{
char c;cin>>c;
if(c == '-') rep[i].push_back(j);
if(c == '+') add[i].push_back(j);
}
}
for(int i=1;i<=n;i++) st |= (1<<i);
dij();
if(dist[0] != 1e18) cout << dist[0];
else cout << 0;
return 0;
}
类型2:根据状态跑多遍 bfs
这种类型的 状态压缩 bfs 与 普通 bfs 的区别是:
状态压缩 bfs 实现了 若干个 bfs :对每一种状态都进行一遍 bfs。
那么
如何将每一种状态都求一遍 bfs 呢?
在普通 bfs 的基础上多定义一维状态,表示在此状态下,到达每个点的最小操作次数。
然后在加入队列的时候,将每个点此时的状态也加进入,类似捆绑。
注: 这里的状态一般也是一个整数。
借助其二进制的第 i 位是否为 1,来表示出第 i 个 “物品” 是否拿了。
而第二种类型根据使用的情况不同又分成了两小种。
什么情况下需要 每种状态都求一遍 bfs 呢?
需要用不同的状态来应对 题目中操作的限制;
可以用这种方法求得 到达想要的状态 所需的最小操作次数。
1、第一种情况
需要用不同的状态来应对题目中操作的限制。
题目中的操作有状态限制(一般为:如果要执行某种操作的话,需要具备某种属性),需要用到不同的状态。
只有有了这种状态,才能执行到达目标的操作。
而为了使得到达目标的操作次数最小,就要使得每种状态到达每个位置的操作次数最小,也就是每种状态都求一遍 bfs。
例题1:胜利大逃亡(续)
给定一个 n*m 的矩阵
(
2
≤
n
,
m
≤
20
)
(2≤n,m≤20)
(2≤n,m≤20),每个格子有以下类型:
.
代表路,*
代表障碍物
@
代表起点,^
代表终点
A-J
代表带锁的门,对应的钥匙分别为a-j
a-j
代表钥匙,对应的门分别为A-J
从起点出发,每次走相邻的四个格子。
遇到障碍物不可走;遇到门如果所走路径中已经拿到对应钥匙那么可走,否则不可走。
问到达终点的最小步数?
分析
在这道题中,对于一些操作有状态限制:走到门的格子就需要有对应的钥匙。
所以容易发现,钥匙就是要维护的状态。要对每一种状态都进行 bfs。
定义 f[x][y][state]
表示到达 (x, y)
点,状态为 state
时的最小操作次数。
每走到一点:
- 如果该位置为空地的话,当前状态不变,扩展到该位置。如果此位置的此状态没有出现过的话,那么在此状态下到达该位置的最小操作次数就为当前位置的最小操作次数+1,将该状态下的该位置放入队列(状态和位置捆绑);
- 如果该位置有钥匙的话,把钥匙拿上,也就是将状态更新。如果没出现过,步数+1,加入队列;
- 如果该位置为门的话,判断当前的状态是否有该钥匙,如果有,扩展。如果没出现过,步数+1,加入队列。
Code:
//https://acm.hdu.edu.cn/showproblem.php?pid=1429
#include<bits/stdc++.h>
using namespace std;
const int N = 30, mod = 1e9+7;
int T, n, m, t;
char a[N][N];
int stx, sty, enx, eny;
int f[N][N][(1<<11) + 10];
int ans;
int dir[4][2] = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
struct node{
int x, y, state;
};
void bfs()
{
queue<node> que;
que.push({stx, sty, 0});
f[stx][sty][0] = 1;
while(que.size())
{
int x = que.front().x, y = que.front().y, state = que.front().state;
que.pop();
if(a[x][y] == '^'){
ans = f[x][y][state];
return;
}
for(int i=0;i<4;i++)
{
int tx = x + dir[i][0], ty = y + dir[i][1];
if(tx < 1 || tx > n || ty < 1 || ty > m || a[tx][ty] == '*') continue;
if(a[tx][ty] >= 'a' && a[tx][ty] <= 'j') //钥匙,更新状态
{
int st = state; //注意不要直接将该状态修改,后面的点还要用
st |= (1<<(a[tx][ty] - 'a'+1));
if(f[tx][ty][st]) continue;
f[tx][ty][st] = f[x][y][state] + 1;
que.push({tx, ty, st});
}
else if(a[tx][ty] >= 'A' && a[tx][ty] <= 'J') //门
{
if(state & (1<<(a[tx][ty]-'A'+1)))
{
if(f[tx][ty][state]) continue;
f[tx][ty][state] = f[x][y][state] + 1;
que.push({tx, ty, state});
}
}
else{ //空地
if(f[tx][ty][state]) continue;
f[tx][ty][state] = f[x][y][state] + 1;
que.push({tx, ty, state});
}
}
}
}
signed main(){
Ios;
while(cin>>n>>m>>t)
{
mem(f, 0);
for(int i=1;i<=n;i++)
for(int j=1;j<=m;j++){
cin>>a[i][j];
if(a[i][j] == '@') stx = i, sty = j;
}
ans = 1e18;
bfs();
if(ans != 1e18 && ans - 1 < t) cout << ans-1 << endl;
else cout << -1 << endl;
}
return 0;
}
变式:拯救大兵瑞恩
例题2:宝藏
给定一个
n
∗
m
n*m
n∗m 的迷宫,字符'.'
表示可以通过,字符'#'
表示不能通过,起点和终点分别为S
,T
。
迷宫中有 k k k 个机关,每个机关在一个位置 ( i , j ) (i, j) (i,j),会对 ( x i , y i ) (x_i, y_i) (xi,yi) 位置造成影响:每当小明走到格子 ( i , j ) (i, j) (i,j) 时, 格子 ( x i , y i ) (x_i, y_i) (xi,yi) 就会转换状态(此时可以通过变为不可通过;此时不可通过变为可通过)。
问,从起点出发,最少走多少步能够到达终点?
(
5
≤
n
,
m
≤
30
,
0
≤
k
≤
10
)
(5 ≤ n, m ≤ 30, 0 ≤ k ≤ 10)
(5≤n,m≤30,0≤k≤10)
思路
两种状态定义方式。
方式1:定义状态 f[x, y, state]
表示走到点 (x, y) 并且走过的机关状态为 state 时,最小步数。
更新的时候遍历当前的 state 的二进制位,如果发现要去的位置 当前不通并且由机关转换了奇数次
或者 当前通且由机关转换了偶数次
,那么就是可走的。否则不可走。如果走到机关了,那么把其二进制位异或上 1。
(注意是异或,不是或。在下面一种情况,要求得到达想要状态的最小操作次数时,走过就说明这个状态拿到了,走几次都没关系;而在第一种情况,在这题中,一个点走过两次意义就不同了。第一次走过的时候会将机关实施的格子转换,而第二次走过则会再次转换,那么就相当于没有实施该机关。如果用或操作,走两次二进制位置还是1,后面判断的时候就会实施该机关,发生错误)
方式2:定义状态 f[x, y, state]
表示走到点 (x, y) 并且机关实施位置的状态为 state 时,最小步数。
这种定义方式就是直接表示出每个机关实施位置的状态了,机关最多10个,那么实施位置最多也为10个。更新的时候如果说要走的位置 当前不通并且当前状态 state 二进制表示中该位置为1
或者 当前通且当前状态中该位置为0
,那么就可走。否则不可走。如果走到机关了,那么把其实施位置的二进制位异或上1。
方式1 Code
#include<bits/stdc++.h>
using namespace std;
#define int long long
#define PII pair<int,int>
#define pb push_back
#define fi first
#define se second
const int N = 210, mod = 1e9+7;
int T, n, m;
char a[N][N];
PII mp[N];
int stx, sty, enx, eny;
int f[35][35][(1<<11)];
int cnt[15];
vector<int> id[N][N];
int dir[4][2] = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
struct node{
int x, y, state;
};
void bfs()
{
queue<node> que;
que.push({stx, sty, 0});
f[stx][sty][0] = 1;
while(que.size())
{
int x = que.front().x, y = que.front().y, state = que.front().state;
que.pop();
if(x == enx && y == eny){
cout << f[x][y][state] - 1;
return;
}
for(int i=0;i<4;i++)
{
int tx = x+dir[i][0], ty = y+dir[i][1];
if(tx < 1 || ty < 1 || tx > n || ty > m) continue;
int cnt = 0;
for(int j=1;j<=10;j++) //遍历每一个机关看当前点转换了几次
{
if(!(state & (1<<j))) continue;
int ttx = mp[j].fi, tty = mp[j].se;
if(ttx == tx && tty == ty) cnt++;
}
if(a[tx][ty] != '#' && cnt%2 == 0 || a[tx][ty] == '#' && cnt%2)
{
if(id[tx][ty].size())
{
int st = state;
for(auto it : id[tx][ty]) st ^= (1<<it); //注意这里是 异或^,不是 或|。
if(f[tx][ty][st]) continue;
f[tx][ty][st] = f[x][y][state] + 1;
que.push({tx, ty, st});
}
else
{
if(f[tx][ty][state]) continue;
f[tx][ty][state] = f[x][y][state] + 1;
que.push({tx, ty, state});
}
}
}
}
}
signed main(){
Ios;
cin >> n >> m;
for(int i=1;i<=n;i++)
for(int j=1;j<=m;j++){
cin >> a[i][j];
if(a[i][j] == 'S') stx = i, sty = j;
if(a[i][j] == 'T') enx = i, eny = j;
}
int k; cin >> k;
for(int i=1;i<=k;i++)
{
int x, y, tx, ty;
cin >> x >> y >> tx >> ty;
id[x][y].push_back(i); //每个位置可能有多个机关
mp[i] = {tx, ty}; //标记第i个机关施加位置
}
bfs();
return 0;
}
2、第二种情况
可以用这种方法求得 到达想要的状态 所需的最小操作次数。
我们想要得到一种最终状态的最小操作次数,该状态需要由其他状态转移过来,所以需要对每种状态都进行一遍 bfs。
得到了前面状态的最小操作次数,那么可能由某种操作,使得那个状态转移到新的状态了,于是就得到了新的状态的最小操作次数。
如此下去,便能得到想要的状态的最小操作次数。
例题1:CSL的校园卡
给定 n*m 的矩阵,每个格子有两种类型:障碍物和空地。
A 和 B 两人同时从起点出发,问走遍所有空地的最小步数?
分析
问走遍所有空地的最小操作次数,那么走的空地就可以看作状态。最终的状态为所有空地都走遍。
因为有两个人,所以需要表示出到达两个点时的状态。
定义状态 f[x_1][y_1][x_2][y_2][state]
,表示 A 到达
(
x
1
,
y
1
)
(x_1, y_1)
(x1,y1)点,B 到达
(
x
2
,
y
2
)
(x_2, y_2)
(x2,y2)点,走到的空地状态为 state
时,所用的最小操作次数。
用当前的状态的最小操作次数,根据两个人走的位置不同,更新新的状态,得到新状态的最小操作次数。最终便能求出走遍所有空地这个状态的最小操作次数。
Code:
#include<bits/stdc++.h>
using namespace std;
const int N = 210, mod = 1e9+7;
int T, n, m;
int a[N][N];
int stx, sty;
struct node{
int state, x1, y1, x2, y2;
};
int f[(1<<18)+10][5][5][5][5];
int get(int x, int y){
return (x-1)*m + y;
}
int dir[4][2] = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
int endd, ans = 1e9;
void bfs()
{
queue<node> que;
que.push({1<<get(stx, sty), stx, sty, stx, sty});
f[(1<<get(stx, sty))][stx][sty][stx][sty] = 1;
while(que.size())
{
int state = que.front().state, x1 = que.front().x1, x2 = que.front().x2, y1 = que.front().y1, y2 = que.front().y2;
que.pop();
for(int i=0;i<4;i++)
{
int tx1 = x1 + dir[i][0], ty1 = y1 + dir[i][1];
if(tx1 < 1 || ty1 < 1 || tx1 > n || ty1 > m || a[tx1][ty1]) continue;
for(int j=0;j<4;j++)
{
int tx2 = x2 + dir[j][0], ty2 = y2 + dir[j][1];
if(tx2 < 1 || ty2 < 1 || tx2 > n || ty2 > m || a[tx2][ty2]) continue;
int st = state | (1<<get(tx1, ty1)) | (1<<get(tx2, ty2));
if(f[st][tx1][ty1][tx2][ty2]) continue;
f[st][tx1][ty1][tx2][ty2] = f[state][x1][y1][x2][y2] + 1;
que.push({st, tx1, ty1, tx2, ty2});
if(st == endd) {ans = f[st][tx1][ty1][tx2][ty2]; return;}
}
}
}
}
signed main(){
cin>>n>>m;
for(int i=1;i<=n;i++)
for(int j=1;j<=m;j++)
{
char c;cin>>c;
if(c=='X') a[i][j] = 1;
if(c=='S') stx = i, sty = j;
}
for(int i=1;i<=n;i++)
for(int j=1;j<=m;j++)
if(!a[i][j]) endd += (1<<get(i, j));
bfs();
if(n==1 && m==1) cout<<0;
else cout << ans-1;
return 0;
}
例题2:萃香抱西瓜
给定一个
n
∗
m
n*m
n∗m 的棋盘。
萃香初始(第1个时刻)站在方格
(
s
x
,
s
y
)
(sx,sy)
(sx,sy)。
西瓜可能在任意一个方格出现,在每个时间单位,可能向任何一个方向移动,也可能静止不动。西瓜的位置和移动的轨迹是已知的。
西瓜的总数为 n 个,但只有 m 个西瓜可以被萃香抱走,其他都太大了,可能会砸伤她。
整个过程会持续 T 个时刻。萃香希望可以抱走全部的 m 个西瓜,并且在任何时候避免与任何一个过大的西瓜处在同一位置。抱走的方式为在某个时刻,与该西瓜处于同一位置。
另外,萃香每个时刻可以选择静止不动,也可以选择移动到相邻的四个格子之一,只要不越出环境边界。如果选择移动到相邻格子,则算作移动了一次。(第1个时刻萃香刚站定,无法移动)
求在T时刻中,不被任何一个大西瓜砸中,并得到所有的 m 个小西瓜的情况下,最少移动次数。
思路
此题在上题的基础上增加了时间维度。
虽然在现实中时间是递增的,但是在题目中完全可以将时间看作一个普通的状态,每次更新时间+1即可,出队入队还是按照 到达该状态的移动次数,不必按照时间先后出队。
定义状态:f[t][x][y][state]
表示,在时间点
t
t
t 时,到达点
(
x
,
y
)
(x, y)
(x,y),所拿物品状态为
s
t
a
t
e
state
state 时的最小移动次数。
观察到这题对于一个时刻人物可以选择静止不动,所以是没有操作次数更新的,但是时间变化了,整个状态更新了,还是要入队的,那么此时花费就为 0。所以边权可能为 0 或 1,边权不同,就不能直接用 bfs 跑了。要跑 最短路。
回顾之前学过的 01bfs,对于边权为 0 和 1 的图,借助 双端队列 实现 bfs 求解最短路。
此外还有一些细节要注意,代码中已标识。
Code
//https://www.luogu.com.cn/problem/P3786
#include<bits/stdc++.h>
using namespace std;
#define int long long
#define pb push_back
const int N = 200010, mod = 1e9+7;
int T, n, m;
int a[110][6][6][2];
int stx, sty;
int sum, k;
int f[110][6][6][(1<<11)];
int gett[30];//因为一共20个西瓜,编号1-20,如果用 1 - 1<<20 表示拿的10个物品的状态的话,数组开不下。
//而因为只需要拿10个,所以可以将10个物品重新编号,用 1 - 1<<10 表示这10个物品的状态。
struct node{
int time, x, y, state;
};
int dir[4][2] = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
void bfs()
{
deque<node> que;
que.push_front({1, stx, sty, 0});
f[1][stx][sty][0] = 1;
while(que.size())
{
auto it = que.front(); //取队首元素
int time = it.time, x = it.x, y = it.y, state = it.state;
que.pop_front();
//下个时刻还在当前位置,移动次数不更新,插入双端队列队首
if(time + 1 <= T && !a[time + 1][x][y][0]) //保证当前点下个时刻没有大西瓜
{
if(a[time + 1][x][y][1]) //下个时刻有小西瓜可拿
{
int st = state | (1 << gett[a[time + 1][x][y][1]]); //状态更新
if(!f[time + 1][x][y][st]){
f[time + 1][x][y][st] = f[time][x][y][state];
que.push_front({time + 1, x, y, st}); //插入队首
}
}
else if(!f[time + 1][x][y][state]){ //单纯移动
f[time + 1][x][y][state] = f[time][x][y][state];
que.push_front({time + 1, x, y, state}); //插入队首
}
}
//下个时刻移动到相邻位置,移动次数+1,插入双端队列队尾
for(int i=0; i<4; i++)
{
int tx = x + dir[i][0], ty = y + dir[i][1];
if(tx < 1 || tx > n || ty < 1 || ty > m) continue;
if(a[time + 1][tx][ty][0]) continue; //保证下个时刻没有大西瓜
if(time + 1 > T) continue; //超过T个时刻
if(a[time + 1][tx][ty][1]) //下个时刻有小西瓜可拿
{
int st = state | (1 << gett[a[time + 1][tx][ty][1]]); //状态更新
if(f[time + 1][tx][ty][st]) continue;
f[time + 1][tx][ty][st] = f[time][x][y][state] + 1; //移动次数+1
que.push_back({time + 1, tx, ty, st}); //队尾
}
else{ //单纯移动
if(f[time + 1][tx][ty][state]) continue;
f[time + 1][tx][ty][state] = f[time][x][y][state] + 1;
que.push_back({time + 1, tx, ty, state});
}
}
}
}
signed main(){
Ios;
cin >> m >> n >> T >> stx >> sty;
cin >> sum >> k;
int cnt = 0;
for(int i=1;i<=sum;i++)
{
int t1, t2; cin >> t1 >> t2;
int flag; cin >> flag;
if(flag) gett[i] = ++cnt; //将小西瓜标记,重新编号
for(int j=t1;j<t2;j++)
{
int x, y; cin >> x >> y;
a[j][x][y][flag] = i; //记录每个时刻下每个位置上是否有西瓜
}
}
bfs();
int endd = 0;
for(int i=1;i<=k;i++) endd += (1<<i);
int ans = 1e9;
// for(int i=1;i<=T;i++) //注意要判断最后的时间T时的移动次数,不是拿到m个西瓜时就结束了,可能后面躲避还需要移动
// {
for(int x = 1;x <= n;x ++){
for(int y = 1;y <= m;y ++){
if(f[T][x][y][endd]) ans = min(ans, f[T][x][y][endd]);
}
}
// }
if(ans == 1e9) cout << -1;
else cout << ans-1;
return 0;
}
三、状态压缩DP
题型归纳
类型1:棋盘式(基于连通性)DP
例题1:蒙德里安的梦想
把
N
×
M
N×M
N×M 的棋盘分割成若干个
1
×
2
1×2
1×2 的长方形,有多少种方案。
例如当 N=2,M=4 时,共有 5 种方案。当 N=2,M=3 时,共有 3 种方案。
1
≤
N
,
M
≤
11
1≤N,M≤11
1≤N,M≤11
思路
首先考虑思路转化:
当棋盘中所有横着的长方形都放上后,竖着的长方形就只能挨个放在空余位置,即竖着的长方形就只有一种摆放方案。
所以,整个棋盘的摆放方案只需要考虑横着的长方形的合法摆放方案。
什么样的合法的?要保证横着的长方形摆放完之后,剩余的空间能够放得下竖着的长方形。也就是,每一列的连续空闲位置都为偶数。
我们一列一列看, 将每一列的状态定义为 所有 1 ∗ 2 1*2 1∗2 的小方格后半部在当前列的摆放状态(前半部延伸到上一列)。那么每一列的状态只和前一列的状态有关:当上一列的第 x x x 行有 1 ∗ 2 1*2 1∗2 的小方格后半部摆放时,当前列的第 x 行就不能有 1*2 的小方格后半部摆放(否则当前列后半部小方格的前半部延伸到上一列就会有冲突)。
从左往右一列一列走,每次更新从第一列到当前列这个子矩形的答案,走到最后一列便能求出整个棋盘的答案。
状态定义:
f[i, j]
表示走到第 i
列,第 i-1
列延伸到第 i
列的小方格放置状态为 j
的方案数。
在状态 j
的二进制表示中,通过位置 i
是否为 1 来表示当前列的第 i 行是否摆放棋子。
状态转移:
从 0
到 m-1
遍历所有列,遍历当前列 i
的状态 j
,遍历上一列 i-1
的状态 k
,从第 i-1
列向第 i
列转移。
对于从第 i-1
列向第 i
列的状态转移,要满足两个条件:
- 对于每一行,第
i-2
列延伸到第i-1
列和第i-1
列延伸到第i
列不能同时满足;
即要满足k&j
要为0
时才能转移。 - 对于每一列,要满足空闲的连续的格子数为偶数,这样才能让竖着的长方形放下。
即要满足k | j
的二进制表示中,连续0
的个数为偶数。
满足之后,则前 i
列,当前列状态为 j
时的方案数 f[i, j] += f[i-1, k];
初始化, f[0][0]=0
,走到起始列,上一列延伸到起始列的方格状态为 0
的方案数为 1
。
最终的答案为最后一列所有状态的答案之和,需要遍历所有的状态将其答案累加。但如果再往后走一列的话,那么这一列状态为 0 的时候,便是最后一列所有状态的答案之和。
所以最终的答案可以简便为:走到第 m
列,第 m-1
列延伸到第 m
列的方格状态为 0
的方案数。(列编号从0开始)
(同理,也可以一行一行来看,f[i, j]
表示走到第 i
行,第 i-1
行延伸到第 i
行的小方格放置状态为 j
的方案数)
Code
#include<bits/stdc++.h>
using namespace std;
#define int long long
#define endl '\n'
const int N = 12, mod = 1e9+7;
int T, n, m;
int a[N];
int st[(1<<12)];
int f[15][(1<<12)];
signed main(){
while(cin >> n >> m && (n || m))
{
for(int i=0;i<(1<<n);i++) //预处理出满足连续0的个数为偶数的状态
{
st[i] = 1;
int cnt = 0;
for(int j=0;j<n;j++)
{
if(i&(1<<j)){
if(cnt & 1) st[i] = 0;
cnt = 0;
}
else cnt ++;
}
if(cnt & 1) st[i] = 0;
}
mem(f, 0);
f[0][0] = 1;
for(int i=1;i<=m;i++)
{
for(int j=0;j<(1<<n);j++)
{
for(int k=0;k<(1<<n);k++) //如果超时的话可以将此循环预处理出来。
{
if((j & k) == 0 && st[j | k]) f[i][j] += f[i-1][k];
}
}
}
cout << f[m][0] << endl;
}
return 0;
}
变式 1:小国王
在
n
×
n
n×n
n×n 的棋盘上放
k
k
k 个国王,国王可攻击相邻的
8
8
8 个格子,求使它们无法互相攻击的方案总数。
1 ≤ n ≤ 10 , 0 ≤ k ≤ n 2 1≤n≤10,0≤k≤n^2 1≤n≤10,0≤k≤n2
思路
和上一题类似,只不过加上了摆放棋子的个数限制,所以状态要多加一维,转移状态时要多一重循环遍历摆放的个数。
同样,因为题意要求,两种状态转移的时候要有限制。假设当前列的状态为j
,上一列的状态为 tx
,那么:
- 当前列的国王之间不能相互攻击到,即状态
j
中两个 1 之间至少存在一个 0,不存在相邻的 1。可以预处理判断出每个状态是否合法,st[]
数组标记; - 当前列和上一列,不能有同一行的状态同时为 1,即
j & tx
= 0; - 当前列和上一列,不能有相邻行中存在1,即
st[j|tx]
= 1。
状态表示:
f[i, k, j]
表示前 i 列中摆放 k 个国王,第 i 列的状态为 j 时的方案数。
状态转移:
f[i, k, j] += f[i-1, k-cnt[j], tx]
前 i 列
中 摆放 k
个国王,第 i 列的状态为 j
时的方案数 += 前 i-1 列
中 摆放 k-第i列的摆放数
个国王,第 i-1 列的状态为 tx
时 的方案数。
复杂度计算
定义了三维数组,遍历 n 列,遍历前 i 列的摆放个数 k,每一列遍历
2
n
2^n
2n 种状态,遍历上一列的
2
n
2^n
2n 种状态,这样算的话时间复杂度为
O
(
n
∗
k
∗
2
2
n
)
O(n*k*2^{2n})
O(n∗k∗22n) = 1e9,过高。
但是满足条件的每一列的状态有限制,而状态之间的转移也有限制,将满足限制的状态预处理出来发现合法状态和合法转移的状态却很少,所以可行。
Code
#include<bits/stdc++.h>
using namespace std;
#define int long long
#define pb push_back
#define endl '\n'
const int N = 12, mod = 1e9+7;
int T, n, m;
int st[(1<<N)], cnt[(1<<N)];
vector<int> v[(1<<N)];
int f[N][N*N][(1<<N)];
signed main(){
cin >> n >> m;
for(int i=0;i<(1<<n);i++) //判断一种状态是否合法,即是否存在相邻两个1;统计每种状态中1的个数。
{
st[i] = 1;
for(int j=0;j<n-1;j++)
{
if(i & (1<<j) && i & (1<<(j+1))) st[i] = 0;
if(i & (1<<j)) cnt[i] ++;
}
if(i & (1<<(n-1))) cnt[i] ++;
}
//找出每种状态可转移的合法状态
for(int i=0;i<(1<<n);i++)
{
for(int j=0;j<(1<<n);j++)
{
if((i&j) == 0 && st[i | j]) v[i].push_back(j);
}
}
f[0][0][0] = 1;
for(int i=1;i<=n+1;i++) //遍历每一列
{
for(int k=0;k<=m;k++) //k从0开始,遍历前i列放置的个数
{
for(int j=0;j<(1<<n);j++) //遍历当前列的所有状态
{
if(k < cnt[j]) continue;
for(auto tx : v[j]) //遍历前一列可以转移的状态
{
f[i][k][j] += f[i-1][k-cnt[j]][tx];
}
}
}
}
cout << f[n+1][m][0];
return 0;
}
变式2:玉米田
例2:炮兵阵地
在一个大小为
N
∗
M
N*M
N∗M 的标记为 P, H 的棋盘上摆放炮兵部队,部队只能坐落于 P 的位置。
每个炮兵部队有攻击范围,如下图黑色区域所示:
问,在保证部队之间不相互攻击到的前提下,整个棋盘最多能够放置多少部队?
N
≤
100
,
M
≤
10
N≤100,M≤10
N≤100,M≤10
思路
注意到这里的 N 和 M 的大小不同,如果还用每一列的 N 个放置位置压缩状态的话,
2
N
2^N
2N 太大了,数组开不下。所以反过来用每一行的 M 个放置位置压缩状态,数组开
2
M
2^M
2M 大小。
和前面的题目不同的是,这里的冲突范围为两个格子,也就是对于当前行的放置状态,受前面两行放置状态的影响。所以对比前面题目的状态定义,这里要多定义一维表示前一行的放置状态。
状态定义:
f[i, j, k]
:对于前 i 行,第 i 行的状态为 j,第 i-1行的状态为 k 时,最多能放置的部队个数。
状态转移:
首先遍历当前行 i
,遍历当前行i的状态 j
,遍历上一行的状态 k
,遍历上上行的状态 tx
:
当前行 i
为状态 j
,上一行为状态 k
时的答案 由 上一行 i-1
为状态 k
,上一行的上一行为状态 tx
时的答案 + 当前行的状态 j
中 1 的个数 来更新。
f[i, j, k] = max(f[i, j, k], f[i-1, k, tx] + cnt[j]);
时间复杂度
同样,这道题的时间复杂度在估计的时候也很大,所以需要先预处理出合法状态,降低复杂度。
空间复杂度
这道题的要定义状态转移数组 f,所用的空间为
N
∗
2
2
M
N*2^{2M}
N∗22M,会爆空间。由于第一维状态是一行一行转移,当前状态 i 的更新只会用到 i-1,所以考虑将第一维改为大小为 2 的滚动数组:将原来的第一维的值 &1 即可。
(如果是大小为 x 的滚动数组,在原来基础上 %x 即可)
Code
#include<bits/stdc++.h>
using namespace std;
#define int long long
#define pb push_back
#define endl '\n'
const int N = 10, mod = 1e9+7;
int T, n, m;
int a[N];
vector<int> v[(1<<N)];
vector<int> state;
int g[110], cnt[1<<N];
int f[2][1<<N][1<<N];
bool check(int i) //判断是否满足任意两个1之间都至少有两个0.
{
for(int j=0;j<m;j++) //要循环到最后,不是循环到m-3就停了,因为可能最后两个都是1,没有判断到
if((i & 1<<j) && (i & 1<<j+1 || i & 1<<j+2)) return 0; //当前是1并且后面两个位置有一个是1
return 1;
// O(1)方法:右移1后和原来数比对,如果相与之后不为0,说明存在相邻两个位置为1。右移2同理。
// if((i & (i >> 1)) || (i & (i>>2))) return 0; //存在两个1相邻或者两个1之间隔一个0
// return 1;
}
int count(int st)
{
int cnt = 0;
for(int j=0;j<m;j++) if(st & 1<<j) cnt ++;
return cnt;
}
signed main(){
cin >> n >> m;
for(int i=1;i<=n;i++)
for(int j=0;j<m;j++)
{
char c;cin >> c;
if(c=='H') g[i] |= 1<<j;
}
for(int i=0;i<(1<<m);i++)
{
if(check(i))
{
state.pb(i);
cnt[i] = count(i);
}
}
for(int i=1;i<=n+2;i++)
{
for(auto j : state)
{
if((j & g[i])) continue;
for(auto k : state)
{
if((k & g[i-1]) || (k & j)) continue;
for(auto tx : state)
{
if((tx & k) || (tx & j)) continue;
if(i>=2 && tx & g[i-2]) continue;
f[i & 1][j][k] = max(f[i & 1][j][k], f[i-1 & 1][k][tx] + cnt[j]);
}
}
}
}
cout << f[n+2 & 1][0][0];
return 0;
}
这种类型的状压DP,简单来说就是按行或者按列,通过上一行的状态来转移。
此外,通过题目条件限制转移的状态。
类型2:集合式 DP
例题1:最短Hamilton路径
题意
给定一张 n 个点的带权无向图,点从 0∼n−1 标号,两两之间有距离
d
i
s
[
i
,
j
]
dis[i, j]
dis[i,j]。
求起点 0 到终点 n−1 的最短 Hamilton 路径。
Hamilton 路径的定义是从 0 到 n−1 不重不漏地经过每个点恰好一次。
1 ≤ n ≤ 20 , 0 ≤ d i s [ i , j ] ≤ 1 0 7 1≤n≤20,\ 0≤dis[i,j]≤10^7 1≤n≤20, 0≤dis[i,j]≤107
思路
定义状态 f[i, j]
表示,从起点走到点 i 且走过点的状态为 j 时的最短距离。
状态更新:
遍历所有状态,对于每种状态来说,遍历起点,遍历下一个要走的点(保证之前没走过),用当前集合的状态来更新加上下一个点后的集合状态。
f[k][i | 1<<k] = min(f[k][i | 1<<k], f[j][i] + a[j][k]);
初始化:从起点出发,那么到起点且走过点的状态为 1<<0 = 1
时的最短距离,即 f[0, 1] = 0
,其余值赋为正无穷。
Code
#include<bits/stdc++.h>
using namespace std;
#define Ios ios::sync_with_stdio(false),cin.tie(0)
const int N = 200010, mod = 1e9+7;
int T, n, m;
int a[21][21];
int f[21][1<<20];
int ans = 1e9, sum;
signed main(){
Ios;
cin >> n;
for(int i=0;i<n;i++)
for(int j=0;j<n;j++)
cin >> a[i][j];
for(int i=0;i<n;i++)
for(int j=0;j<1<<n;j++)
f[i][j] = 1e9;
f[0][1] = 0;
for(int i=0;i<1<<n;i++) //枚举所有状态
{
for(int j=0;j<n;j++) //枚举当前节点
{
if(!(i >> j & 1)) continue; //保证当前节点走过
for(int k=0;k<n;k++) //枚举下一个节点
{
if(i >> k & 1) continue; //保证下一个节点没走过
f[k][i | 1<<k] = min(f[k][i | 1<<k], f[j][i] + a[j][k]);
}
}
}
cout << f[n-1][(1<<n) - 1];
return 0;
}