「算法笔记」状压 DP
一、关于状压 dp
为了规避不确定性,我们将需要枚举的东西放入状态。当不确定性太多的时候,我们就需要将它们压进较少的维数内。
常见的状态:
- 天生二进制(开关、选与不选、是否出现……)
- 爆搜出状态,给它们编号
1. 状态跟某一个信息集合内的每一条都有关。(如 dp 套 dp)
2. 若干条精简而相互独立的信息压在一起处理。 (如每个数字是否出现)
在使用状压 dp 的题目当中,往往能一眼看到一些小数据范围的量,切人点明确。而有些题,这样的量并不明显,需要更深人地分析题目性质才能找到。
二、预备知识
1. 位运算
二进制数 S 从低(0-based)到高第 i 位的值:(S>>i)&1
二进制数 S 从低(0-based)到高第 i 位的值变为 1:S|(1<<i)
二进制数 S 从低(0-based)到高第 i 位的值变为 0:S&(~(1<<i)) 或 ~((~S)|(1<<i))
二进制数 S 从低(0-based)到高第 i 位的值取反:S^(1<<i)
2. 枚举子集
for(int i=S;i!=0;i=(i-1)&S)
复杂度:
(2n-1) 的子集:2n
(2n-1) 的子集的子集和:3n
(2n-1) 的子集的子集的子集和:4n
……
三、例题
1. TSP(旅行商问题)
题目大意:一个 n 个点的带权的有向图,求一条路径,使得这条路径经过每个点恰好一次,并且路径上边的权值和最小。n≤16。
Solution:
设 dp[i][S] 表示目前走到节点 i,已经经过节点的集合为 S 的最小边权。
枚举所有的边 (i,j)∈E,令 w(i,j) 表示它的边权。
if j∉S(因为要满足经过每个点恰好一次,即不能重复经过,所以要满足 j 没有被经过),dp[i][S]+w(i,j)→dp[j][S∪{j}]
若 S&(1<<j) 的值为 0,则 j 不在 S 中;若 S&(1<<j) 的值不为 0,则 j 在 S 中。也可以判断 (S>>j)&1 是否为 1。
初始化一个节点的路径 dp[i][1<<i]=0(节点编号为 0~n-1)或 dp[i][1<<(i-1)]=0(节点编号为 1~n),其他的设为 ∞。
把 j 加入到 S:S +/|/^ (1<<j)。因为已经确认了 S 的这一位是 0,则 +,|,^ 都是可以的。
时间复杂度:O(2n·n2)
2. [USACO06NOV] 玉米田 Corn Fields
题目大意:有一个 N*M 的网格图,已知若干个格子不能选、不能有相邻的格子被选。在不限制所选格子总数的前提下,求方案数。N,M≤12。
Solution:
由于限制在相邻的格子上,若按行或按列 dp,就不需要维护之前已经决策过的格子的状态,而是维护一行或一列的格子的状态。
令 dp[i][j] 表示目前 dp 到第 i 行,这一行的状态是 j,所有 1~i 行内的格子被选的方案数。
枚举 i+1 行的状态 k,判断 k 与 j 是否有冲突(是否出现某个格子的上面一个格子被选了的情况,即是否有相邻的格子被选),只需判断 j&k 是否为 1(j 的二进制数与 k 的二进制数是否有在相同的位置同时等于 1)。
dp[i][j]→dp[i+1][k]
预处理出没有相邻的两列同时被选的状态。在一行 12 个格子的网格图中,没有相邻的两列同时被选的合法的状态大概有 377 个。
int ans=0; for(int i=0;i<(1<<12);i++){ bool flag=1; for(int j=1;j<=11;j++) if(((i>>(j-1))&1)&&((i>>j)&1)) flag=0; if(flag) ++ans; } printf("%lld\n",ans); //Output:377
所以通过预处理,可以通过此题。
Code:
#include<bits/stdc++.h> #define int long long using namespace std; const int N=13,M=(1<<12)+5,mod=1e9; int n,m,a[N][N],p[N],f[N][M],ans; bool v[M]; signed main(){ scanf("%lld%lld",&m,&n),f[0][0]=1; for(int i=1;i<=m;i++) for(int j=1;j<=n;j++){ scanf("%lld",&a[i][j]); p[i]=(p[i]<<1)+a[i][j]; //记录第 i 行土地的状态 } for(int i=0;i<(1<<n);i++){ v[i]=1; for(int j=1;j<n;j++) if(((i>>(j-1))&1)&&((i>>j)&1)) v[i]=0; } //记录状态 i 是否合法(预处理出没有相邻的两列同时被选的状态) for(int i=1;i<=m;i++) //枚举行 for(int j=0;j<(1<<n);j++) //枚举这行的状态 if(v[j]&&(j&p[i])==j) //判断状态 j 是否合法以及是否在肥沃的土地上 for(int k=0;k<(1<<n);k++) //枚举上一行的状态 if(!(k&j)) f[i][j]=(f[i][j]+f[i-1][k])%mod; for(int i=0;i<(1<<n);i++) ans=(ans+f[m][i])%mod; //前 m 行的方案数累加 printf("%lld\n",ans); return 0; }
3. TopCoder RainbowGraph
题目大意:给出一张 N 个点 M 条边的无向图。每个点有一个颜色。一条路径是合法的当且仅当它按顺序经过的节点的颜色序列写下来,同种颜色出现的位置是连续的。计算合法的经过每个节点恰好一次的路径条数。N≤100,Color≤10(颜色数),对于任意的 i,Cnt(v|Colorv=i)≤10(每种颜色的节点数)。
Solution:
既然同种颜色出现的位置是连续的。我们不妨将问题划分成两个小问题考虑:
- 对于颜色 c,从 i 出发到 j 结束,同时遍历完所有该颜色节点的路径条数是多少。
- 对于全局,有多少种安排颜色的方法、及安排颜色之间“接口”的方法。
第一个问题:将同种颜色内的串起来。
要求:(1)每个点恰好出现一次(2)相邻两点之间有边相连
令 dp[i][j][S] 表示从 i 号点出发,在 j 号点结束,经过的节点集合为 S 的路径条数。
for(int i=0;i<k;i++) //k:当前颜色的节点个数 dp[i][i][1<<i]=1; //考虑单点路径的情况 for(int S=1;S<(1<<k);S++) //枚举状态 for(int i=0;i<k;i++) //枚举起点 for(int j=0;j<k;j++) //枚举终点 if(dp[i][j][S]) //判断当前的 dp 状态是否合法 for(int p=0;p<k;p++) //枚举下一个点 if(!((S>>p)&1)&&G[j][p]) //p 在状态中没有出现过(之前没有经过这个点)并且之前路径的终点 j 能够到达下一个点 p dp[i][p][S|(1<<p)]+=dp[i][j][S];
注:这里所有的节点编号,为了方便体现,直接写成了 i 之类的。事实上,需要开一个数组维护,把全局当中的颜色为当前颜色的节点转化到 0~k 的范围内。
第二个问题:将每种颜色串起来。
要求:(1)每种颜色恰好出现一次(则需记录颜色状态 2C)(2)相邻颜色的首尾节点间有边相连(则需记录终点 N)
对于一种颜色 c,我们使用 f[c][i][j] 来代表之前的 dp[全局中编号为 i 的点][全局中编号为 j 的点][(1<<k)-1]。
令 dp[i][S] 表示以节点 i 结尾,已经经过的颜色集合为 S 的总方案数。
for(int c=0;c<C;c++) //枚举颜色 for(int i=0;i<k[c];i++) //枚举起点 k[c]:颜色为 c 的节点个数 for(int j=0;j<k[c];j++) //枚举终点 dp[id[j]][1<<c]+=f[c][i][j]; for(int S=1;S<(1<<C);S++) for(int i=0;i<N;i++) if(dp[i][S]) for(int c=0;c<C;c++) //枚举下一个颜色 C:颜色数 if(!((S>>c)&1)) //这个颜色在之前的状态中没有出现过 for(int j=0;j<k[c];j++) //枚举下一个颜色的起点 if(G[i][id[j]]) //当前的终点与下一个起点有边可以到达 for(int q=0;q<k[c];q++) //枚举下一个颜色的终点 dp[id[q]][S|(1<<c)]+=dp[i][S]*f[c][j][q];
4. TopCoder CheeseRolling
题目大意:N 个人打淘汰赛。N 是 2 的幂次。给出两两单挑的胜负情况。一共有 N! 种赛程安排方案,对于每个人,你需要输出有多少种方案使他最终获胜。n≤16。
Solution:
令 dp[i][S] 表示编号为 i 的选手是选手集合 S 中的优胜者的方案数。
那么最后每个点的答案就是 dp[i][(1<<N)-1]
枚举 i 能够战胜的对手 j,然后将集合 S 等分为两个一个包含 i 一个包含 j 的集合。
for(int i=0;i<N;i++) dp[i][1<<i]=1; for(int S=1;S<(1<<n);S++) //枚举集合 S if(valid(S)) //只处理大小为 2 的幂次的集合 for(int i=0;i<N;i++) //枚举集合中的胜出者 i if((S>>i)&1) //集合 S 包含 i for(int j=0;j<N;j++) //枚举 i 所击败的对手 j if(i!=j&&G[i][j]) //i 和 j 不是同一个人并且 i 能够击败 j for(int k=(S-1)&S;k!=0;k=(k-1)&S) //枚举子集 dp[i][S]+=2LL*dp[i][k]*dp[j][S^k];
复杂度为 O(3n·n2),偏高,需要优化。
- 优化 1:valid 这部分有很多冗余状态,可以预处理出来有效的部分。
- 优化 2:把子集改成 dfs 精确枚举等分子集。
5. AtCoder 058E Iroha and Haiku
题目大意:
一个有 N 个元素且每个元素的取值都在 1~10 范围内的序列被称为好的当且仅当存在下标 x,y,z,w(0≤x<y<z<w≤N)
- ax+ax+1+...+ay-1=X
- ay+ay+1+...+az-1=Y
- az+az+1+...+aw-1=Z
计数。并对 109+7 取模。3≤N≤40,1≤X≤5,1≤Y≤7,1≤Z≤5。
Solution:
做法 1:当你不知道如何优雅地设计状态/不知道状态的规模有多大时,不妨暴力一点。
因为 X≤5,Y≤7,Z≤5,所以 X+Y+Z≤5+7+5,即 X+Y+Z≤17,ax+ax+1+...+ay-1+ay+ay+1+...+az-1+az+az+1+...+aw-1≤17。
只需维护取值 1~10,和≤17 的序列。
#include<bits/stdc++.h> #define int long long using namespace std; int cnt; void dfs(int x){ if(x>17) return ; ++cnt; for(int i=1;i<=10;i++) dfs(x+i); } signed main(){ dfs(0),printf("%lld\n",cnt); return 0; } //Output:130624
大约 105 个状态,做法就显而易见了。
一个 dp 序列当中的元素,每次只保留最靠后的值不超过 17 的元素队列。
dp[i][S][0/1] 表示当前 dp 到序列中第 i 个元素,队列的状态为 S,是否出现过题中所述条件。
一些优化:
- 优化 1:每个状态是否满足题中条件可预处理。
- 优化 2:每个状态遇到了 1~10 后转移到什么状态可预处理。(需要一个作用在 vector上的 map 或是 set、二分等)
- 优化 3:状态当中的第三维可以通过求补集而去掉。
不难观察得到,算法的复杂度瓶颈主要是由状态的 vector 形态带来的。
做法 2:思路和前面类似,只不过考虑换个形式表示“和不超过 17”这个状态。
如果我们将这“X+Y+Z”点贡献展开看成一条长度为 X+Y+Z 的格子,一个数可以占领 1~10 个格子。那么我们关心的即为位置在 X、X+Y、X+Y+Z 的三个格子是否是一个数所占领格子的末尾。我们考虑将一个数末尾标为 1,其他格子标为 0。
例子:X=Y=Z=2,后缀状态:
2+2+2 | 010101 |
1+1+2+2 | 110101 |
2+1+1+2 | 011101 |
1+2+2+1 | 101011 |
Code:
//开始的格子为 1 的写法 #include<bits/stdc++.h> #define int long long using namespace std; const int N=50,M=(1<<17)+5,mod=1e9+7; int n,x,y,z,st,dp[N][M],sum=1,k,ans; signed main(){ scanf("%lld%lld%lld%lld",&n,&x,&y,&z); st=((1<<(x+y+z-1)))|(1<<(y+z-1))|(1<<(z-1)); //目标从后往前数的第 X+Y+Z、Y+Z、Z 的位置为 1 dp[0][0]=1; for(int i=1;i<=n;i++){ sum=sum*10%mod; //计算总方案数(n 位数字,就有 10^n 种方案) for(int S=0;S<(1<<(x+y+z));S++) //枚举状态 if(dp[i-1][S]&&(S&st)!=st) for(int c=1;c<=10;c++){ //枚举第 i 位添加的数字 k=((S<<c)|(1<<(c-1)))&((1<<(x+y+z))-1); //(S<<c)|(1<<(c-1)): 比如 4 的二进制数为 1000,4 后面加上 2 为 4 2,用 100010 表示,即将 4 的二进制向前两位再加上 10。 //...&((1<<(x+y+z))-1: 防止溢出 dp[i][k]=(dp[i][k]+dp[i-1][S])%mod; } } for(int S=0;S<(1<<(x+y+z));S++) if((S&st)!=st) ans=(ans+dp[n][S])%mod; printf("%lld\n",(sum-ans)%mod); //总方案数减不符合条件的方案 return 0; }
6. AtCoder XOR Tree
题目大意:给出一棵 N 个节点的树,每条树边有一个边权。.每次操作你可以将一条路径上的边权异或上某个值。问最少进行多少次操作可以达到边权全 0。N≤105,0≤边权≤15。
Solution:
首先定义一个点的点权为与其相邻的所有边的边权异或和。
容易证明边权全为 0 等价于点权全为0。
- 边权为 0→点权为 0:根据定义显然
- 点权为 0→边权为 0:来自树的性质,总是存在叶子结点。叶子结点上的点权为 0 可推出一条边权为 0。删去后重复这一过程。
每次操作就可以看成是将任意两个点异或上同一个数。
- 如果是两个相同的数,一次操作就可以直接将这俩消去。
- 如果是两个不同的数 a,b,那么一次操作可以消去一个,剩下一个 a^b。
思考一下要不要考虑同时异或一个不为 a 也不为 b 的数 x。
因为一共只有 16 种取值,我们先将重复的直接消去。剩下的信息只有 0~15 每个数字是否出现过,可以压成一个 216 的状态。
每次枚举一下要操作哪两个数。
- x^y∉S:转移到的状态就是 S∪x^y,即 S^(1<<(x^y))。
- x^y∈S:此次操作之后就会产生一对相等的 pair,我们直接在此时将它们一并消去,增加一次操作,并将 x^y 从 S 中去掉即可。
7. Codeforces 16E Fish
题目大意:有 n 条鱼,刚开始它们都自由地生活在水里。接下来每一时刻会有两条鱼相遇。所有可能的“鱼对”之间都是等概率出现这一事件的。鱼 i 和鱼 j 相遇后,i 吃掉 j 的概率为 a[i][j]。 反之 j 吃掉 i 的概率为 1-a[i][j]。求每条鱼 i 活到最后的概率。N≤19。
Solution:
令 dp[S] 表示剩下集合中的鱼状态为 S 的概率。
初始化:dp[2n-1]=1(刚开始全部的鱼都存活)
对于任意 1≤i≤n,dp[2i] 为鱼 i 活到最后的概率。
按从大到小枚举状态 S(按时刻进行),设 c 为 S 中 1 的个数,则有 C(c,2) 个可能的“鱼对”。枚举第一条被选的鱼 i 以及第二条被选的鱼 j,这两条鱼相遇的概率为 1/C(c,2)。
- i 吃掉 j:1/C(c,2)·dp[S]·a[i][j]→dp[S-2j]
- j 吃掉 i:1/C(c,2)·dp[S]·a[j][i]→dp[S-2i]
最后输出所有的 dp[2i] 即可。
Code:C(c,2)=c*(c-1)/2
#include<bits/stdc++.h> #define int long long using namespace std; const int N=20,M=(1<<18)+5; int n,x,cnt; double a[N][N],dp[M]; signed main(){ scanf("%lld",&n),dp[(1<<n)-1]=1; for(int i=0;i<n;i++) for(int j=0;j<n;j++) scanf("%lf",&a[i][j]); for(int S=(1<<n)-1;S>=1;S--){ //枚举状态 cnt=0,x=S; while(x) cnt+=x&1,x>>=1; //计算 S 中 1 的个数 for(int i=0;i<n;i++) if(S&(1<<i)) //枚举第一条鱼 for(int j=i+1;j<n;j++) if(S&(1<<j)){ //枚举第二条鱼 dp[S-(1<<j)]+=dp[S]*a[i][j]/(1.0*cnt*(cnt-1)/2); //i 吃掉 j dp[S-(1<<i)]+=dp[S]*a[j][i]/(1.0*cnt*(cnt-1)/2); //j 吃掉 i } } for(int i=0;i<n;i++) printf("%.6lf%c",dp[1<<i],i==n-1?'\n':' '); return 0; }
8. Codeforces 165E Compatible Numbers
题目大意:给出一个长度为 N 的序列,我们称两个数 x,y 是兼容的,当且仅当 x&y=0。对于序列中的每个数,你需要求出任意一个序列中与它兼容的数,或是声明不存在。N≤106,0<a[i]≤4·106。
Solution:
暴力做法:由于数值大小可以存下。令 M 为 >=max{a(i)} 的某个 2k-1。与序列中的某个数 x 兼容的所有数可以表示为 M xor x 的子集。做一下子集枚举,我们得到了一个 O(3k) 的做法。(本题中,k≤22), 这个做法要跑 3e10。
一点改进:令 f(S) 表示 S 的子集中是否有数出现过。F(x) 表示 x 是否在序列中出现过。
那么有:f(S)=F(S)|⋁i∈S f(S\i)
从小到大枚举 S, 每次计算 f 的值时就不需要枚举子集,只用枚举 S 中的元素 v 即可。还能剪枝,当 f(S) 为真时就不需要再枚举下去了。
9. Codeforces 743E Vladik and cards
题目大意:给出一个长度为 N 个序列,序列中的元素取值为 1~8。求一个最长的子序列满足:
- 每种取值出现在子序列中的位置连续。
- 每种取值出现的次数相差不超过 1。N≤1000。
10. Codeforces 599E Sandy and Nuts
题目大意:有一棵 N 个点的树, 1 号点为根节点。现在你知道 M 条信息:
- 某些树上的边 (u,v)
- 某两个点的LCA (a,b,c)
现在你需要数满足条件的树形态数。N≤13,M≤100。