状态压缩dp实际上比其他dp要容易想到,使用状压那么数据就必须很小比如int32位,longlong64位,数据大小最多那么大。并且当 n < = 20 n<=20 n<=20时,指数级别算法 O ( 2 n ) 为 1 0 6 O(2^n)为10^6 O(2n)为106。
状态压缩动态规划(Dynamic Programming with State Compression)
是一种在解决某些特定类型的问题时非常有效的技术,特别是在涉及到限制状态或配置数量较多,但可以用整数或位操作来紧凑表示的情况。这种方法通常用于解决优化问题,特别是在图论、搜索问题、排列组合问题中非常常见,如旅行商问题(TSP)、位掩码问题等。
注意:位运算的优先级比条件表达式的优先级低。因此以下语句并不能达到预期效果,它会先判等。
if(state & (1 << j ) == target) {}
应当是:
if((state & (1 << j )) == target) {}
状态压缩DP的基本思想
状态压缩DP的核心思想是使用整数的二进制表示来表示复杂的状态集合。每一个比特位可以代表一个元素的某种状态(通常是存在或缺失)。这样,一个整数可以存储多个独立状态的信息,使得状态的转移和查询都非常高效。
1、如何实施
-
状态定义:
- 定义状态通常涉及将问题的各个组成部分映射到一个整数的各个位上。例如,在一个图的顶点覆盖问题中,可以用一个整数的每一位来代表一个顶点是否被选择。
-
状态转移:
- 根据问题的规则定义状态之间的转移。例如,在旅行商问题中,状态表示当前已访问的城市集合和最后一个访问的城市,状态转移则是考虑下一个要访问的城市。
- 使用位操作(如位与、位或、位移)来实现状态的快速转移。
-
初始化和边界条件:
- 确定初始状态和边界条件,这些通常依赖于问题的具体要求。例如,一个没有访问任何城市的初始状态可能表示为全0。
-
计算最终结果:
- 从初始状态开始,递推或递归地计算出最终状态的值。通常需要对所有可能的状态进行遍历,这在使用状态压缩时通常是可行的,因为状态总数被大大减少。
2、例题:Acwing:91. 最短Hamilton路径
a.哈希优化的暴力搜索
本题实际上就是求从起点到终点,经过所有顶点的最短路。因此我们很容易想到的求解方式:
d
[
i
]
=
m
i
n
(
d
[
j
]
+
e
(
u
,
v
)
)
d[i]=min(d[j]+e(u,v))
d[i]=min(d[j]+e(u,v))其中j
表示当前路径中没有经过v
的最短路,i
表示经过v
的最短路。因此直接使用bfs
剪去不必要的重复情况:
bfs
的状态是指数级别增长的。这里完全是状态压缩存储状态,然后一层一层遍历使用哈希表保证最优的状态进入,暴力搜索,并未用到dp
的想法。
时间复杂度:
O
(
c
×
2
n
n
)
O(c×2^nn)
O(c×2nn)
状态数:
O
(
2
n
)
O(2^n)
O(2n)
但是常数太大了,比如当
n
=
20
n=20
n=20时,
2
n
n
=
20971520
2^nn=20971520
2nn=20971520,限时
5
s
5s
5s。使用
u
n
o
r
d
e
r
e
d
_
m
a
p
unordered\_map
unordered_map,并且释放空间,常数太大无法控制导致超时。
#include<bits/stdc++.h>
using namespace std;
int A[21][21];
struct Node{
int dis;
int state;
int start;
};
Node dp[21];
int main(void){
ios_base::sync_with_stdio(false);
cin.tie(0);
int n;cin>>n;
int target=0;
int ans = INT_MAX;
for(int i=0;i<n;++i)
target = (target << 1) + 1;
for(int i = 0;i < n;++i)
for(int j = 0;j < n;++j)
cin>>A[i][j];
queue< Node > q;
q.push({0,1,0});
while(!q.empty()){
int size=q.size();
unordered_map< int,int > mp[21];
for(int i = 0;i < size; ++i){//一层一层遍历,保证mp可以准确存储
int dis = q.front().dis;
int state = q.front().state;
int start = q.front().start;
q.pop();
if(state == target){
ans = min(ans,dis);
break;
}
for(int j = 0; j < n; ++j){
if((state & (1 << j)) != 0) continue;//已经经过的点不能再经过
int cur_state = state | (1 << j);
if((cur_state & (1 << (n-1))) !=0 && cur_state != target) continue;//只有最后可以访问n-1
int dist = dis + A[start][j];
if(dist >= ans) continue;
if(mp[j].find(cur_state) == mp[j].end()){
mp[j][cur_state] = dist;
}else{
mp[j][cur_state] = min(mp[j][cur_state],dist);
}
}
}
for(int i = 0; i < n ; ++i){
for(auto & j : mp[i]){
q.push({j.second,j.first,i});
}
}
}
cout<<ans;
return 0;
}
Time Limit Exceeded
20时,计算基数:49545237
20时,状态数:2359298
b.状压dp
想要使用动态规划的思想,使用状态压缩存储当前状态,就必须取消掉bfs
的暴搜想法。我们发现使用
(
2
n
n
)
(2^nn)
(2nn)的时间复杂度并不会导致超时。因此我们可以直接定义全部状态。
现在我们来仔细回顾题目要求,并思考动态规划方法:
求解目标:
0
到
n
−
1
经过所有结点的最短路径
求解目标:0到n-1经过所有结点的最短路径
求解目标:0到n−1经过所有结点的最短路径
状态定义:
d
p
[
i
]
[
j
]
表示状态为
i
,且终点为
j
的最短路径。这里的状态状态压缩表示,表示的是路经了哪些顶点
状态定义:dp[i][j] 表示状态为i,且终点为j的最短路径。这里的状态 状态压缩表示,表示的是路经了哪些顶点
状态定义:dp[i][j]表示状态为i,且终点为j的最短路径。这里的状态状态压缩表示,表示的是路经了哪些顶点
状态转移:
d
p
[
i
]
[
j
]
=
m
i
n
(
d
p
[
i
−
(
1
<
<
j
)
]
[
k
]
+
A
[
k
]
[
j
]
)
状态转移:dp[i][j] = min(dp[i - (1 << j)][k] + A[k][j])
状态转移:dp[i][j]=min(dp[i−(1<<j)][k]+A[k][j])
时间复杂度:
O
(
2
n
n
2
)
O(2^nn^2)
O(2nn2),并且是严格的,因为都进行的是基本运算。
空间复杂度:
O
(
2
n
)
O(2^n)
O(2n)
#include<bits/stdc++.h>
using namespace std;
#define N 21
int main(void){
int dp[1 << N][N];
int A[N][N];
int n;cin>>n;
int num_state = 1 << n;
memset(dp,0x3f,sizeof(dp));
for(int i = 0;i < n;++i)
for(int j = 0;j< n; ++j)
cin>>A[i][j];
dp[1][0]=0;//从起点0开始
for(int i = 1;i < num_state;++i){
for(int j = 0;j < n; ++j){
if((i & (1 << j)) != 0)//i状态必须 经过 j
for(int k = 0;k < n;++k)
if(j != k && (i & (1 << k)) != 0)//i - (1 << j) 必须经过 k
dp[i][j] = min(dp[i][j],dp[i - (1 << j)][k] + A[k][j]);
}
}
cout << dp[num_state -1][n - 1];
return 0;
}
Accepted
20时,计算基数:99614720
20时,状态数:2097152
因此,在考虑状压dp问题时,尽量先考虑这样定义dp
数组。如果不行则需要根据题目变通。
可行性解释:
n
<
=
20
n<=20
n<=20时:指数级别算法
O
(
2
n
)
为
1
0
6
O(2^n)为10^6
O(2n)为106。
1MB可以存储
1024
∗
1024
B
4
B
=
2.6
∗
1
0
5
\frac{1024*1024B}{4B}=2.6*10^5
4B1024∗1024B=2.6∗105个int,512MB可以存储
1.3
∗
1
0
8
1.3*10^8
1.3∗108个int。
O
(
2
n
n
)
O(2^nn)
O(2nn)的空间复杂度也才
2
∗
1
0
7
2*10^7
2∗107个int因此是可行的。
3、例题:Acwing:291. 蒙德里安的梦想
291. 蒙德里安的梦想
这是一个状压dp,初学者不可能想得到好吧。初学者看到这个题也不可能认为是一个状压dp,以为是一个简单dp,但是细想一下简单dp确实不行。
因此,以见识见识的目的,来学习本题吧,伸出来的状态!
本题
N
<
=
11
,
2
N
=
2048
N<=11,2^N = 2048
N<=11,2N=2048
本题思想:
定义状态:
d
p
[
i
]
[
j
]
dp[i][j]
dp[i][j] 表示已经放满了前i-1列,第i列的状态为j。状态j的含义是,当数j的第k位为1,则表示前i-1列放的时候第k行被占用了(伸出来了)
状态转移:
d
p
[
i
]
[
j
]
+
=
d
p
[
i
−
1
]
[
k
]
dp[i][j]+=dp[i-1][k]
dp[i][j]+=dp[i−1][k]
注意:k
必须保证和j
不冲突,也就是k & j ==0
。第i
列的状态j
的情况,伸出来的那部分的放法是固定的。转移的区别在于,第i-1
列剩下部分,有多少是i-2
列伸出来的。对于k
并不伸出来的部分,且不是j
固定的部分即:k | j
连续的位置必须是偶数,即必须可以使用竖着的小方块放满。
以下是我的代码实现:
#include<bits/stdc++.h>
using namespace std;
int main(void){
ios_base::sync_with_stdio(false);
cin.tie(0);
long long dp[12][1 << 12]={};
int M,N;
while(cin >> M >> N){
if(M == 0 && N == 0) break;
int total = 1 << M;
memset(dp,0,sizeof(dp));
dp[0][0]=1;
for(int i = 1;i <= N;++i){
for(int j = 0;j < total ; ++j){//遍历第i列的状态
for(int k = 0;k < total;++k){//遍历第i-1列的状态
if((j & k) == 0){//只考察不是被j固定的位置,即第i-1列的状态必须要留出位置给第i列伸出来。
int pos = j | k;
int num = 0;
for(int p = M;p >= 1;){//判断剩余的位置是否能够用竖条填满
if((pos & 1) != 0) {pos >>= 1;--p;}
else{
while(p >= 1){
if((pos & 1) == 0)
num++;
else break;//break没写。
pos >>= 1;
--p;
}
if(num % 2 != 0) break;
}
}
if(num % 2 == 0) dp[i][j] += dp[i - 1][k];
}
}
}
}
cout << dp[N][0] << endl;
}
return 0;
}
代码实现的过程中遇到了几个问题:(debug 一小时!)
①正如开篇所提到的,位运算没有用括号括起来,导致if条件运算不符合预期。
②求解是否连续空位是偶数时,太久没打代码,导致求解的时候条件判断逻辑混乱,一整个错误。break没写
③初始化错误,导致走了很多弯路。
- 正确的初始化应该是
dp[0][0]=1
,表示第1列,只能在没有伸出来的情况下有一种可能的方案。(有人会说了,但是行数可能是奇数啊?那怎么能有方案呢?我们这个方案并不是为了当前使用,而是为了之后转移使用,当之后转移需要用到的时候,发现行数是奇数,并不会把它考虑进去!但是第1列必然是没有伸出来时,才是可能的!有伸出来的都是非法的!) - 我开始的初始化是
dp[0][i]=1
,所有状态都赋值为1,但是经过仔细考虑,发现第一列不可能伸出来,伸出来的情况都是不可能的因此最后还是去掉了。 - 实际上得理解,一次性考虑两列:
i-1
列和i
列,先横着放方块,得到了i
列的状态j
,然后考虑i-1
列的情况,首先得避开横着放的方块后,避开后,只要剩下空着的块能保证放下竖着的块就行。比如在考虑第1
列和第2
列时,横着放完后,第1
列可行的就是自己没有伸出来的,在这个状态下避开横着放的块后,看看剩下的能不能放竖块,因此对于第2
列来说,第1
列就一种状态是可行的,也就是没有伸出来的。
复盘一下,发现这个动态规划并不求每一次状态都是达到题目要求的状态,中间可以参差不齐,只需要我们可以求出最后的答案就行。