33.状态压缩动态规划
一、算法内容
1.简介
若元素数量比较小(不超过 20 20 20)时,想要存储每个元素取或不取的状态时, 可以借助位运算将状态压缩。 需要借助状态压缩过程的动态规划就是状态压缩 DP(很多地方会简称为状压 DP)。
取若干元素,也就是对应的位置记为 1 1 1,其余位置记为 0 0 0。例如,一共有 5 5 5 个元素 a , b , c , d , e a,b,c,d,e a,b,c,d,e ,我们分别用二进制的 1 , 10 , 100 , 1000 , 10000 1,10,100,1000,10000 1,10,100,1000,10000 表示这五个元素,则集合 { a , c , e } \{a,c,e\} {a,c,e} 可以用 ( 10101 ) 2 = 21 (10101)_2=21 (10101)2=21 来表示,而集合 { b , c , d } \{b,c,d\} {b,c,d} 可以用 ( 01110 ) 2 = 14 (01110)_2=14 (01110)2=14 表示。
对于元素个数为 n n n 的情况,其空间复杂度为 O ( 2 n ) O(2^n) O(2n) 。 如果不用状态压缩,那么我们状态需要开 5 5 5 维数组 d p [ 2 ] [ 2 ] [ 2 ] [ 2 ] [ 2 ] dp[2][2][2][2][2] dp[2][2][2][2][2] ,这样不仅使得代码的实现变的很复杂,并且当 n n n 的大小不一样的时候,状态维度也不一样,不是很容易实现。
2.子集与二进制
由于状压 DP 经常涉及到枚举子集的情况,所以我们介绍一种代码编写非常方便的枚举子集方法,也就是用二进制来模拟。这也是我们状压 DP 的基础。我们可以用二进制的一位表示集合对应某一元素的选取状态, 1 1 1 表示选取, 0 0 0 表示未选取。举个例子,我们拥有一个集合 { 0 , 1 , 2 , 3 , 4 , 5 , 6 } \{0,1,2,3,4,5,6\} {0,1,2,3,4,5,6}。那么二进制 0101101 0101101 0101101 就代表子集合 { 0 , 2 , 3 , 5 } \{0,2,3,5\} {0,2,3,5}。
而在集合选取尤其是动态规划的状态转移方程中,我们需要用位运算来帮助我们实现子集的枚举。例如: i & (1 << j)
就可以用来判断 i i i 的二进制展开所代表的集合里面,是否包含第 j j j 个元素;(1 << n) - 1
就可以表示所以元素都被选取的情况,也就是全集。
由二进制展开的情况不难发现,子集的大小是指数级别的,所以数据范围一定不会很大。因此这也是一条判断是否是状压 DP 的一大方式。
二、实例分析
1. P2622 关灯问题
(1)题目大意
现有 n n n 盏灯,以及 m m m 个按钮。每个按钮可以同时控制这 n n n 盏灯,按下 i i i 按钮对于第 j j j 盏灯,具体的操作结果如下:
- 如果 a i , j a_{i,j} ai,j 为 1 1 1,那么当这盏灯开了的时候,把它关上,否则不管;
- 如果 a i , j a_{i,j} ai,j 为 − 1 -1 −1,如果这盏灯是关的,那么把它打开,否则也不管;
- 如果 a i , j a_{i,j} ai,j 为 0 0 0,无论这灯是否开,都不管。
现在这些灯都是开的,给出所有开关对所有灯的控制效果,求问最少要按几下按钮才能全部关掉。
(2)题目分析
-
本题灯只有开关两种状态,所以可以用 1 1 1 表示开灯状态, 0 0 0 表示关灯状态
-
因此可以用一个长度为 n n n 的二进制数唯一地表示每个状态
-
题目提供的开关灯方式即可作为一个状态转移的方式,我们以灯全开也就是
(1 << n) - 1
为起点 -
如果我们设当前状态为
now
,则now & (1 << j)
就可以判断 j j j 这个灯是否是打开状态。 -
那么我们就可以对于每个状态遍历所有开关的情况。对于
a[i][j]
来说,我们有状态转移如下所示:a[i][j] = 1 && (now & (1 << j))
:now ^= (1 << j)
a[i][j] == -1 && !(now & (1 << j))
:now |= (1 << j)
-
我们用迷宫问题的思路,设全开为迷宫入口,全关为迷宫出口。记录每个状态的步数,即可在第一次找到出口的时候退出程序。
(3)正解程序
#include <iostream>
#include <queue>using namespace std;
typedef long long ll;
struct node
{ll status;ll step;
};
ll n, m;
ll a[110][20];
bool vis[2010];
ll bfs()
{queue<node> q;q.push((node) {(1 << n) - 1, 0});vis[(1 << n) - 1] = true;while(!q.empty()){node u = q.front();q.pop();if(u.status == 0)return u.step;for(ll i = 0; i < m; i++){ll now = u.status;for(ll j = 0; j < n; j++)if((a[i][j] == 1 && (now & (1 << j))) || (a[i][j] == -1 && !(now & (1 << j))))now ^= (1 << j);if(!vis[now]){q.push((node){now, u.step + 1});vis[now] = true;}}}return -1;
}
int main()
{cin >> n >> m;for(ll i = 0; i < m; i++)for(ll j = 0;j < n; j++)cin >> a[i][j];cout << bfs() << endl;return 0;
}
2. P1441 砝码称重
(1)题目大意
现有 n n n 个砝码,重量分别为 a i a_i ai,在去掉 m m m 个砝码后,问最多能称量出多少不同的重量(不包括 0 0 0)。砝码只能放在其中一边。
(2)题目分析
- 设我们当前选择的砝码集合是 T T T,它能称出的重量的集合为 d p dp dp
- 我们设 M M M 对应的二进制表示为 ( m t , m t − 1 , . . . , m 0 ) 2 (m_t,m_{t-1},...,m_0)_2 (mt,mt−1,...,m0)2,其中 m i = 1 m_i=1 mi=1 表示 i i i 这个重量是可以被称出来的;
- 那么再添加一个砝码之后,能称出的重量就是
dp |= (dp << w[i])
(3)正解代码
#include <iostream>
#include <cstdio>
#include <bitset>using namespace std;
typedef int ll;
const ll maxn = 30;
ll n, m, a[maxn];
ll my_count(ll x)
{ll res = 0;while(x){res += (x & 1);x >>= 1;}return res;
}
int main()
{scanf("%d%d", &n, &m);m = n - m;for(ll i = 0; i < n; i++)scanf("%d", &a[i]);ll ans = 0;for(ll i = 1; i < (1 << n); i++){if(my_count(i) != m)continue;bitset<2010> dp;dp[0] = 1;for(ll j = 0; j <= n - 1; ++j)if(i & (1 << j))dp = dp | dp << a[j];ans = max(ans, (ll)dp.count());}printf("%d\n", ans - 1);return 0;
}
3. P1896 互不侵犯
(1)题目大意
在 N × N N\times N N×N 的棋盘里面放 K K K 个国王,使他们互不攻击,共有多少种摆放方案。国王能攻击到它上下左右,以及左上左下右上右下八个方向上附近的各一个格子,共 8 8 8 个格子。
(2)题目分析
-
有由题目意思可以知道,每个格子只有能摆放和不能拜访两种状态,所以我们可以用状态压缩的方式来表示整个棋盘
-
现在考虑应该用哪些内容来完整的表示棋盘。因为国王会影响上下行,所以我们可以考虑从上往下遍历状态,这样就只需要考虑每一行的上一行是否会发生冲突即可。
-
考虑每一行的合法状态,因为对于同一行来说,不能有两个连续的格子存在国王,那也就是说对于状态 i i i 来说,需要满足
i & (i << 1) == 0
。 -
设总共有 M a x Max Max 种合法状态,如果我们设当前行的状态为 k k k,上一行的状态为 x x x。那么它们应该满足以下关系:
(suit[k] & suit[x]) == 0 && ((suit[k] << 1) & suit[x]) == 0 && ((suit[k] >> 1) & suit[x]) == 0
。 -
因此设 d p [ i ] [ j ] [ k ] dp[i][j][k] dp[i][j][k] 表示在前 i i i 行中已经放置 j j j 个国王,第 i i i 行的状态为第 k k k 种合法状态时,题目的合法方案数。
-
如果我们设置前 i − 1 i-1 i−1 行的第 i − 1 i-1 i−1 行为第 x x x 种合法状态,则我们有状态转移方程:
d p [ i ] [ j ] [ k ] = ∑ x = 1 M a x d p [ i − 1 ] [ j − n u m [ k ] ] [ x ] ; dp[i][j][k]=\sum_{x=1}^{Max}dp[i - 1][j - num[k]][x]; dp[i][j][k]=x=1∑Maxdp[i−1][j−num[k]][x];其中 n u m [ k ] num[k] num[k] 表示状态 k k k 的国王数量。
(3)正解代码
#include <iostream>
#include <algorithm>
#include <cstdio>using namespace std;
typedef long long ll;
ll n, K;
ll suit[1 << 10], Max = 0;
ll num[1 << 10];
ll dp[100][100][1 << 10];
void prework()
{for(ll i = 0; i <= (1 << n) - 1; i++)if((i & (i << 1)) == 0)suit[++Max] = i;for(ll i = 1; i <= Max; i++){ll tmp = suit[i];while(tmp){num[i] += (temp & 1);tmp >>= 1;}}
}
int main()
{scanf("%lld%lld", &n, &K);prework();dp[0][0][1] = 1;for(ll i = 1; i <= n; i++)for(ll j = 0; j <= K; j++)for(ll k = 1; k <= Max; k++)for(ll x = 1; x <= Max; x++)if(j - num[k] >= 0 && (suit[k] & suit[x]) == 0 && ((suit[k] << 1) & suit[x]) == 0 && ((suit[k] >> 1) & suit[x]) == 0)dp[i][j][k] += dp[i - 1][j - num[k]][x];ll ans = 0;for(ll i = 1; i <= Max; i++)ans += dp[n][K][i];printf("%lld", ans);return 0;
}
三、作业
1.绿题
P1441 砝码称重
P1879 [USACO06NOV] Corn Fields G
2.蓝题
P1896 [SCOI2005] 互不侵犯
P2704 [NOI2001] 炮兵阵地
P2831 [NOIP 2016 提高组] 愤怒的小鸟
P3959 [NOIP2017 提高组] 宝藏