状态压缩简介
状态压缩指的是,通过一串0-1码保存一个集合的状态,把一个集合压缩成一个整数,所以称为状态压缩。
例如,有一行棋子,它们的排列分别是:黑 白 白 黑 黑 白 黑 白
这就可以用10011010
(
2
)
_{(2)}
(2)来表示这个状态。
一般的,我们把自然认知的第一位,如上述例子的“黑”认为是二进制里面的“1”的话,把它放在二进制权值最低的一位。第二位放在权值倒数第二低的位置,以此类推。
这么转换是为了方便在调用状态,使用(>>或<<)的位操作符时更方便。
仍以上述例子为例,按照这个规律转换的话,应该有
\qquad\qquad\qquad\qquad\qquad\quad
01011001
(
2
)
_{(2)}
(2)
接下来我们通过一些例子来熟悉和了解状态压缩在动态规划题目里面的使用。
互不侵犯
题目描述
N x N 的棋盘里面放 K 个国王,使他们互不攻击,共有多少种摆放方案。
国王能攻击到它上下左右以及左上左下右上右下八个方向上附近的各一个格子,共8个格子。
输入格式
只有一行,包含两个数 N,K
输出格式
所得的方案数
输入
3 2
输出
16
解题思路
观察可以得出,当前行的方法总数,必然是在当前行每一个合法的状态下,对上一行全部合法状态的方法数求和。
例如 第二行
101000
101000
101000的状态下,对第一行能和这个状态共存的所有状态进行枚举,把它们的方法数全部加到这个dp储存单位里面,就得到了这个状态下的最大方法数。
若
d
p
[
i
]
[
j
]
[
k
]
dp[i][j][k]
dp[i][j][k]表示前i行里面放了k个国王,并且第i行状态是j的方法数,那么,状态转移方程就是
d
p
[
i
]
[
j
]
[
k
]
=
s
u
m
(
d
p
[
i
−
1
]
[
j
1
]
[
k
−
c
o
u
n
t
(
j
)
]
)
dp[i][j][k]=sum(dp[i-1][j1][k-count(j)])
dp[i][j][k]=sum(dp[i−1][j1][k−count(j)])分别对国王数量和方法数求和
在具体代码实现过程中,把状态压缩成一个整数储存于下标,有效帮助我们快速判断两个状态之间是否合法,这就是状态压缩的意义。
示例代码
#include<stdio.h>
int sta[600],staN;
int MAX;
long long dp[10][512][100];
//先初始化第1行,由于n小于等于9,因此状态值的最大值是九位二进制数,111111111,如果用十进制来储存就是512
int count(int k){
int counting=0;
while(k>0){
if(k%2)
counting++;
k>>=1;
}
return counting;
}
int main(){
int n,kk;
scanf("%d%d",&n,&kk);
MAX=(1<<n)-1;
for(int i=0;i<=MAX;i++){
if((!(i&(i<<1)))&&count(i)<=kk){
sta[++staN]=i;//记录可行的状态
//此处状态就是i
dp[1][i][count(i)]=1;
}
}
for(int i=2;i<=n;i++){//行数
for(int j1=1;j1<=staN;j1++){//此行合法状态
int j=sta[j1];
for(int upj1=1;upj1<=staN;upj1++){//上一行合法状态
int upj=sta[upj1];
if((j&upj)||(j&(upj<<1))||(j&(upj>>1)))
continue;
for(int upNumber=0;count(j)+upNumber<=kk;upNumber++){//此行加上面的王之后不会超出总数
dp[i][j][count(j)+upNumber]+=dp[i-1][upj][upNumber];
}
}
}
}
long long sum=0;
for(int i=1;i<=staN;i++)
sum+=dp[n][sta[i]][kk];
printf("%lld\n",sum);
return 0;
}
另外,此代码还有两个优化的方向,一个是对于下标
j
j
j,可以以数组储存状态,再通过下标调用数组里面的状态,即把
j
1
,
u
p
j
1
j1,upj1
j1,upj1作为数组下标。
其二是把
d
p
[
10
]
[
512
]
[
100
]
dp[10][512][100]
dp[10][512][100]这个数组的第一维略去,仅保留
d
p
[
512
]
[
100
]
dp[512][100]
dp[512][100],并通过用temp数组复制的方法储存这一行与上一行的值,压缩空间复杂度。
这两种优化留待读者自行证明
关灯问题
题目描述
现有 n 盏灯,以及 m 个按钮。每个按钮可以同时控制这 n 盏灯——按下了第 i 个按钮,对于所有的灯都有一个效果。按下 i 按钮对于第 j 盏灯,是下面 3 种效果之一:如果
a
[
i
]
[
j
]
a[i][j]
a[i][j]为1,那么当这盏灯开了的时候,把它关上,否则不管;如果为−1 的话,如果这盏灯是关的,那么把它打开,否则也不管;如果是 0,无论这灯是否开,都不管。
现在这些灯都是开的,给出所有开关对所有灯的控制效果,求问最少要按几下按钮才能全部关掉。
输入格式
前两行两个数,n, m。
输出格式
一个整数,表示最少按按钮次数。如果没有任何办法使其全部关闭,输出-1
输入
3
2
1 0 1
-1 1 0
输出
2
解题思路
依据状态压缩的思路,把每个灯泡的状态(开或关)用二进制表示, d p [ s t a t e ] dp[state] dp[state] 存储将当前状态 s t a t e state state 转变为目标状态(所有灯泡关掉)的最小操作次数。初始状态是所有灯泡开着,目标是将所有灯泡关掉。每个按钮会根据其影响(数组 a [ i ] [ j ] a[i][j] a[i][j] 中的 1 或 -1)翻转一些灯泡的状态,代码通过遍历所有状态,尝试按下每个按钮,计算新的状态并更新 dp 数组。最终,若 dp[0] 不为 INF,则输出最小操作次数,否则输出 -1,表示无法实现目标状态。
示例代码
#include <stdio.h>
#include <string.h>
#define MAXN 11
#define MAXM 101
#define INF 0x3f3f3f3f
int n, m;
int a[MAXM][MAXN];
int dp[1<<MAXN]; // 状态压缩dp,记录每个状态需要的最小操作次数
int min(int a, int b) {
return a < b ? a : b;
}
int main() {
scanf("%d%d", &n, &m);
for(int i = 0; i < m; i++) {
for(int j = 0; j < n; j++) {
scanf("%d", &a[i][j]);
}
}
// 初始化dp数组
memset(dp, 0x3f, sizeof(dp));
dp[(1<<n)-1] = 0; // 初始状态所有灯都是开的
// 枚举所有可能的状态
for(int step = 0; step < (1<<n); step++) { // 最多需要2^n次操作
for(int state = 0; state < (1<<n); state++) {
if(dp[state] == INF) continue;
// 尝试按每个按钮
for(int i = 0; i < m; i++) {
int new_state = state;
// 计算按下按钮i后的新状态
for(int j = 0; j < n; j++) {
if((state & (1<<j)) && a[i][j] == 1) {
new_state ^= (1<<j); // 灯是开的且可以关闭
}
if(!(state & (1<<j)) && a[i][j] == -1) {
new_state ^= (1<<j); // 灯是关的且可以打开
}
}
// 更新dp值
dp[new_state] = min(dp[new_state], dp[state] + 1);
}
}
}
printf("%d\n", dp[0] == INF ? -1 : dp[0]);
return 0;
}
一些额外的练习
CF11D简单的任务
P3869宝藏
P4363一双木棋
总结
状态压缩dp的核心思路就是把一行数据用一串01码储存,通过位运算调用数据,需要使用者有比较好的循环体意识和位运算熟悉度,常与BFS,DFS等算法一同出现,难度较大。