状压动态规划

· · 算法·理论

Part.0 前言

鉴于我从学 oi 开始就不擅长喜欢动态规划,如今都以 NOIP 为目标,却连最基础的橙色动态规划题都没有思路,所以我决定从零开始学习动态规划。

这是一篇写给自己的动态规划入门学习笔记,但我也希望这能帮助到其他因没有学会动态规划而苦恼的选手。

状压动态规划,全名状态压缩动态规划,是一种较为基础的动态规划类型,由于其时间复杂度较高,故数据范围小,容易想到。然而掌握一些常用技巧,练习代码能力对于状压动态规划还是比较重要的,毕竟它不是很好写

很多状压动态规划的计数问题都存在容斥解法,而且复杂度更优,希望大家不要看到小数据范围都只往状压想。

至于更进一层的 SOS 子集动态规划,本文不进行讨论。

--- ### 记号约定 默认编号从 $1$ 开始,然而计算机中**二进制是从第 $0$ 位开始的**,所以请留意并区分文中的公式与代码。 由于常用到集合运算,而集合运算符号可能不方便理解,故我们使用一些非正式的符号,比如从集合 $S$ 中剔除元素 $x$ 的新集合表示为 $S-\{x\}$。 通常不区分时间复杂度渐进上界和渐进紧确界。时间复杂度中为显示代码流程有时不省略低阶项。 通常不严格要求各类求和等记号格式。 通常所有记号都是整数,如果没有取整符号,默认取下整。 --- ### 代码 本人代码风格一坨,就看个乐吧。 尽量保持和正文一样的变量名。通常使用`s`表示状态。 --- ### 相关链接 * [题单链接](https://www.luogu.com.cn/training/1022235) * [更多的动态规划](https://www.luogu.com.cn/training/1022231) # Part.1 模板 *本板块讲解状压动态规划基础题目和常用技巧。* --- ### [P1433 吃奶酪](https://www.luogu.com.cn/problem/P1433) 平面上 $n$ 个奶酪,从 $(0,0)$ 出发,至少要通过多少距离才能经过所有奶酪。 $n\leq15$。 --- 考虑朴素的动态规划,定义 $f_{i,S}$ 为当前在 $i$ 号奶酪,经过奶酪集合为 $S$ 时的最小距离。对于 $i\in S$,转移为: $$f_{i,S}=\min\limits_{j\in S}\{f_{j,S-\{j\}}+d_{j,i}\}$$ 然而我们遇到一个问题:怎么快速的存,修改 $S$? 所以我们有**状压**,顾名思义,就是把复杂的状态信息通过某些方法,如二进制,压成能够便于计算的信息。 由于每个奶酪要么经过,要么没有经过,故令经过为 $1$,没经过为 $0$,$n$ 个奶酪的状态就可以用一个 $n$ 位二进制表示,记作 $s$。 因为 $j\in S$,$S-\{j\}$ 可以用位移表示为`s^(1<<(j-1))`。 时间复杂度 $O(n^22^n)$。 状压动态规划的代码相较于其他基础动态规划稍微困难一些,有些问题需要注意: * 需要保证转移的状态已经计算,故建议先枚举状态 $s$。 * 还有就是在记号约定中提到的,计算机二进制是从第 $0$ 位开始的。 * 可以将初始位置作为一个奶酪,但是对复杂度影响较大,建议初始化。 ``` for(int i = 1;i <= n;++i)f[i][1<<(i-1)] = sqrt(x[i] * x[i] + y[i] * y[i]); for(int s = 0;s < (1<<n);++s){ for(int i = 1;i <= n;++i){ if(!(s&(1<<(i-1))))continue; for(int j = 1;j <= n;++j){ if(!(s&(1<<(j-1))))continue; f[i][s] = min(f[i][s],f[j][s^(1<<(i-1))] + dst[i][j]); } } } for(int i = 1;i <= n;++i)ans = min(ans,f[i][(1<<n)-1]); ``` --- ### [P1171 售货员的难题](https://www.luogu.com.cn/problem/P1171) $n$ 个村庄,从 $i$ 村庄走到 $j$ 村庄的距离为 $d_{i,j}$,求从 $1$ 号村庄出发经过每个村庄恰好一次并回到 $1$ 号村庄(不算经过)的最小距离。 $n\leq20$。 --- 旅行商问题。 由上一题我们可知,状压动态规划的特征是 $n$ 很小,在 $21$ 以下。主要方法是将 $0/1$ 状态转化为整数的不同二进制位。 故这道题就被秒了,只需要在最后用从每个节点的全部经过状态走回 $1$ 号状态即可。 然而还有一个问题:如果不要求恰好一次呢? 很简单,用 floyd 计算传递闭包,就变成原问题了。 --- ### [P3052 [USACO12MAR] Cows in a Skyscraper G](https://www.luogu.com.cn/problem/P3052)/[P10483 小猫爬山](https://www.luogu.com.cn/problem/P10483) $n$ 只牛,重量为 $w_i$。电梯载重为 $W$,求至少要用多少趟电梯才能运走所有牛。 $n\leq18$。 --- 做法挺多的,个人喜欢先计算状态代价。 我们可以先预处理出所有的一个电梯能搭乘的牛方案的代价。 ``` for(int s = 0;s < (1<<n);++s){ for(int i = 1;i <= n;++i){ if(s&(1<<(i-1)))cst[s] += w[i]; } } ``` 每个状态就可以由它的子集转移过来,即: $$f_S=\min\limits_{s\in S}f_{S-s}+1$$ 但是还有一个问题,就是怎么枚举子集?如果直接枚举复杂度直接平方。 先来看看实现十分简单的代码: ``` for(int s = S;s;s = (s - 1)&S); ``` 正确性显然,因为每次循环后 $s$ 都会变为比 $s$ 小的,且仍为 $S$ 子集的最大集合。注意没有枚举空集。 那么枚举 $S$ 在枚举子集 $s$ 的复杂度是多少呢? 答案是 $O(3^n)$。考虑一个元素 $i$,它要么在 $s$ 和 $S$ 中都出现,要么只在 $S$ 中出现,要么在 $S$ 中没有出现。 对于枚举 $k$ 层子集,其复杂度为 $O((k+1)^n)$。此问题,相当于对全集枚举一个子集 $S$,再枚举 $S$ 的子集 $s$,故 $k=2$。 ```cpp for(int S = 0;S < (1<<n);++S){ for(int s = S;s;s = (s - 1)&S){ if(cst[s] <= W)f[S] = min(f[S],f[S - s] + 1); } } ``` ### [P2396 yyy loves Maths VII](https://www.luogu.com.cn/problem/P2396) $n$ 张牌,打出一张分数加 $a_i$,打完获胜,期间分数不能等于 $m$ 个特殊数字 $b_j$,求方案数,对 $10^9+7$ 取模。 $n\leq24,m\leq2$。 --- 借这道题讲一下常用的 lowbit 优化。 状压是否使用卡牌的状态,时间复杂度 $O(2^nn)$,应该不用赘述: ```cpp f[0] = 1; for(int s = 1;s < (1<<n);++s){ long long d = 0; for(int i = 1;i <= n;++i)if(s&(1<<(i-1)))d += a[i]; if(m && (d == b[1] || (m == 2 && d == b[2])))continue; for(int i = 1;i <= n;++i)if(s&(1<<(i-1)))f[s] = (f[s] + f[s^(1<<(i-1))]) % mod; } printf("%lld",f[(1<<n)-1]); ``` 这样的复杂度是卡满的,会被卡。 由于有一些元素不在集合中,我们可以通过二进制方法跳过枚举它们,即挑出目前最高位是 $1$ 的,再去除它,重复进行。 如何挑出最高位是 $1$ 的?那就是 lowbit,代码中可以写`i&(-i)`,原理可以看看[这个](https://blog.csdn.net/lesileqin/article/details/102418143)。 因为枚举全集,故能省去一半的复杂度,为 $O(2^{n-1}n)$。 ```cpp f[0] = 1; for(int s = 1;s < (1<<n);++s){ for(int i = s;i;i -= i&(-i))d[s] += a[i&(-i)]; if(m && (d[s] == b[1] || (m == 2 && d[s] == b[2])))continue; for(int i = s;i;i -= i&(-i))f[s] = (f[s] + f[s^(i&(-i))]) % mod; } printf("%lld",f[(1<<n)-1]); ``` --- 至此,你已经基本了解状压动态规划的思想——利用二进制存储与操作 $0/1$ 集合。状压动态规划的题目特征为某数量(如节点,行列等)小于等于 $21$,围绕如何转化为 $0/1$ 以及当前状态和什么有关,如何快速转移等。 # Part.2 状压建模 *本板块主要讲解状压动态规划题目,难度主要提高及以上。* ## 逐行递推 *这种状压动态规划题目最为明显,其行列中会有一个数据范围很小,将其状压即可。* --- ### [P1539 [TJOI2011] 01矩阵](https://www.luogu.com.cn/problem/P1539)/[P1879 [USACO06NOV] Corn Fields G](https://www.luogu.com.cn/problem/P1879) $n\times m$ 的矩阵,有些为 $0$,有些为 $1$,其余可以自由填写 $0/1$,求有多少种填写 $0/1$ 方案使得不存在任意两个 $1$ 相邻,对 $10007$ 取模。 $n\times m\leq25$。 --- 首先取 $\min\{n,m\}$ 为状压的一排,实际编写时直接旋转矩阵就行,记 $n$ 为大的,$m$ 为小的。预处理当前排自己能够合法的状态,即满足`!(s&(s<<1))`,数量记作 $c$,远远小于 $2^m$。 对于当前行只和上一行有关,故枚举上一行即可。 数组再滚动一下。时间复杂度 $O(nc^2)$。 ```cpp f[0][1] = 1; //全0方案编号为1 for(int i = 1;i <= n;++i){ for(int j = 1;j <= cnt;++j){ int s = st[j]; //st是状态数组 if((s&s1[i]) != s1[i])continue; //s满足本行1的限制 if(((((1<<m)-1)^s)&s0[i]) != s0[i])continue; //s满足本行0的限制 for(int k = 1;k <= cnt;++k){ if(!(s&st[k]))f[i&1][j] = (f[i&1][j] + f[(i&1)^1][k]) % mod; } } for(int j = 1;j <= cnt;++j)f[(i&1)^1][j] = 0; //清空下一行要用的 } for(int i = 1;i <= cnt;++i)ans = (ans + f[n&1][i]) % mod; ``` --- ### [P1896 [SCOI2005] 互不侵犯](https://www.luogu.com.cn/problem/P1896) $n\times n$ 的矩阵,求有多少摆放 $k$ 个国王的方式,使得任意两个国王不能互相攻击(国王攻击时八邻格)。 $n\leq9,k\leq n^2$。 --- 依旧将一行状态状压,预处理每行单独合法的状态,数量记作 $c$,远远小于 $2^n$,以及其中国王的数量,使用`__builtin_popcount`即可。 定义 $f_{i,j,S}$ 为第 $i$ 行状态为 $S$,已经放置 $j$ 个国王的方案数,那么转移只依赖于上一行。 时间复杂度 $O(n^3c^2)$。 ```cpp f[0][0][1] = 1; for(int i = 1;i <= n;++i){ for(int cur = 1;cur <= cnt;++cur){ int s = st[cur]; for(int pre = 1;pre <= cnt;++pre){ int p = st[pre]; if((s&p)||((s<<1)&p)||(s&(p<<1)))continue; for(int j = popc[pre];j + popc[cur] <= k;++j)f[i&1][j + popc[cur]][cur] += f[(i&1)^1][j][pre]; } } for(int cur = 1;cur <= cnt;++cur){ for(int j = 0;j <= k;++j)f[(i&1)^1][j][cur] = 0; } } for(int cur = 1;cur <= cnt;++cur)ans += f[n&1][k][cur]; ``` --- ### [P2704 [NOI2001] 炮兵阵地](https://www.luogu.com.cn/problem/P2704) $n\times m$ 的矩阵,有些地方为平原,有些地方为山丘。只有平原能部署炮兵阵地,要求炮兵阵地不能互相攻击,求最多能部署多少炮兵阵地。炮兵阵地攻击范围如下所示: ![](https://cdn.luogu.com.cn/upload/pic/1881.png) $n\leq10^2,m\leq10$。 --- 把 $m$ 作为状压的维度。 先处理出每行单独合法的状态,设数量为 $c$。由于当前行不能攻击到自己,每行实际可行的状态数 $c$ 是很小的,实测大约 $c=60$。 当前行的炮兵阵地至多攻击到上两行的炮兵阵地,枚举前两行和当前行的状态即可,要求行之间不能互相攻击,并且当前行不能攻击到自己。 空间复杂度为 $O(nc^2)$。因为只关心上两行,故 $n$ 可以滚动掉;时间复杂度 $O(nc^3)$。 自己写写吧,写出来说明你已经大致掌握状压动态规划。 ## 序列 *题目通常要求每一小段区间满足某要求,将其状压即可,其切面通常一位一位移动。* ### [P1357 花园](https://www.luogu.com.cn/problem/P1357) 一个长为 $n$ 的环形序列,每个位置为 $0/1$,任意 $m$ 个相邻位置的 $1$ 数量不能超过 $k$ 个。求方案数,对 $1000000007$ 取模。 $n\leq10^{15},2\leq m\leq\min\{n,5\},1\leq k<m$。 --- 发现如果递推的话,当前状态只与前 $m-1$ 位有关,直接状压。不过无脑做时间复杂度为 $O(n2^m)$ 直接挂。 发现相同状态的转移是一致的(废话),转移还是线性的,再加上这明显的数据范围 $n\leq10^{15}$,显然就是矩阵快速幂。构造一个 $2^{m-1}\times2^{m-1}$ 的矩阵,最终答案即为矩阵对角线(第 $i$ 行 $i$ 列)之和。 时间复杂度 $O(2^{3m}\log n)$。 ``` for(int i = 0;i < (1<<(m-1));++i){ int cnt = __builtin_popcount(i); if(cnt > k)continue; int j = (i<<1)&((1<<(m-1))-1); ++mt.v[i][j]; //在后面接0 if(cnt < k)++mt.v[i][j|1]; //在后面接1 } mt = qpow(mt,n); //矩阵快速幂 for(int i = 0;i < (1<<(m-1));++i)ans = (ans + mt.v[i][i]) % mod; ``` --- ### [P2157 [SDOI2009] 学校食堂](https://www.luogu.com.cn/problem/P2157) $n$ 个学生排成队伍去打饭,第 $i$ 个学生的口味为 $t_i$。 食堂每次只能为一个学生做菜。若前一道菜的口味是 $x$,这一道为 $y$,则做这道菜所需的时间为 $x\oplus y$,做第一道菜不需要时间。 如果适当调换学生顺序,做菜的总时间会变短。但是学生不允许原来在 TA 后面的超过 $b_i$ 位同学在他之前打饭。 最小化总时间。 $n\leq10^3,b_i\leq7$。 --- 状压 $b_i$,为方便,直接用 $7$ 就行。 定义 $f_{i,S,k}$ 为到 $i$ 位置,之前已经全部打完饭,后面 $[i,i+7]$ 的状态为 $S$,上一次给 $i+k$ 号打饭。 当 $i+1$ 已经打好时,即 $S\&1$ 为真时,将状态在队伍上右移: $$f_{i+1,S>>1,k-1}\leftarrow f_{i,S,k}$$ 否则,考虑下一个给 $i+l$ 打饭,显然转移比较复杂,写不出式子,看代码吧。 由于数组不接受 $k$ 是负数,需要平移一下。 时间复杂度 $O(n2^bb^3)$。 ```cpp void upd(int &u,int v){ u = min(u,v); } f[1][0][-1 + 8] = 0; //上次完成0号(i+k=0) for(int i = 1;i <= n;++i){ for(int s = 0;s < (1<<8);++s){ for(int k = -8;k < 8;++k){ if(i + k < 0 || i + k > n + 1)continue; if(s&1)upd(f[i + 1][s>>1][k - 1 + 8],f[i][s][k + 8]); //i已完成,切面右移 else{ //i没完成,可以去完成下一个 for(int l = 0;l < 8;++l){ //枚举下一个完成i+l if(i + l > n + 1)continue; if(s&(1<<l))continue; //l已经完成 bool flag = 1; for(int x = l - 1;x >= 0;--x){ //检查是否合法 if(!((s&(1<<x)) || l - x <= b[i + x])){ flag = 0; //判断没完成的i+x是否能接受去完成l break; } } if(!flag)break; int cst = f[i][s][k + 8]; if(i + k)cst += t[i + k] ^ t[i + l]; upd(f[i][s + (1<<l)][l + 8],cst); } } } } } ``` --- ## 杂项 ### [P2150 [NOI2015] 寿司晚宴](https://www.luogu.com.cn/problem/P2150) $2$ 到 $n$ 这 $n - 1$ 个数中,挑出一些分为两组,使其两组间任意两个数互质,求有多少种合法方案,对 $p$ 取模。 $2\leq n\leq 500,p\leq10^9$。 --- 注意到两组中质因数合集不可能有交,故将组中是否有某一质因数状压,然而 $500$ 以下的质数过多。 注意到每个数的最大质因数中至多有一种超过其算数平方根,故每个数至多有一中质因数大于等于 $23$,而 $23$ 以下只有 $c=8$ 个质因数,可以状压。 预处理每个数大于 $23$ 的质因数,按其排序。定义 $dp_{s1,s2}$ 为答案。每段开始时,我们定义 $f1_{s1,s2},f2_{s1,s2}\leftarrow dp_{s1,s2}$ 分别表示这一类大质因数分给第一组还是第二组,其小质因数分配情况分别为 $s1,s2$ 时的方案数。此类大质因数段结束时让 $dp_{s1,s2}\leftarrow f1_{s1,s2}+f2_{s1,s2}$。 时间复杂度 $O(n2^{2c}+n\log n)$。 ```cpp dp[0][0] = 1; for(int i = 1;i < n;++i){ if(i == 1 || a[i].bp == -1 || a[i].bp != a[i - 1].bp) for(int j = 0;j < (1<<8);++j)for(int k = 0;k < (1<<8);++k) f1[j][k] = f2[j][k] = dp[j][k]; for(int j = (1<<8) - 1;j >= 0;--j){ for(int k = (1<<8) - 1;k >= 0;--k){ if(j&k)continue; if(!(a[i].s&k))f1[j|a[i].s][k] = (f1[j|a[i].s][k] + f1[j][k]) % mod; if(!(a[i].s&j))f2[j][k|a[i].s] = (f2[j][k|a[i].s] + f2[j][k]) % mod; } } if(i == n - 1 || a[i].bp == -1 || a[i].bp != a[i + 1].bp) for(int j = 0;j < (1<<8);++j)for(int k = 0;k < (1<<8);++k) if(!(j&k))dp[j][k] = (f1[j][k] + f2[j][k] - dp[j][k] + mod) % mod; } for(int j = 0;j < (1<<8);++j)for(int k = 0;k < (1<<8);++k) if(!(j&k))ans = (ans + dp[j][k]) % mod; ``` --- ### [P2167 [SDOI2009] Bill的挑战](https://www.luogu.com.cn/problem/P2167) $n$ 个长度为 $L$ 的字符串 $A_x$,其中一些小写字母,其它为`?`,表示随意填任何小写字母。求有多少种填写方式,使得**恰好** $k$ 个字符串相同,对 $1000003$ 取模。 $n\leq15,L\leq50$。 --- 先处理一下第 $i$ 位有哪些串能放 $c$ 字符,状压为 $st_{i,c}$。 定义 $f_{i,S}$ 表示到第 $i$ 位,匹配上的字符串状态为 $S$ 时的方案数。转移就很简单了。 时间复杂度 $O(2^nLC)$,$C$ 表示字符集大小,为 $26$。 ```cpp f[0][(1<<n) - 1] = 1; for(int i = 1;i <= len;++i){ for(int s = 0;s < (1<<n);++s){ for(char c = 'a';c <= 'z';++c)f[i][st[i][c - 'a']&s] = (f[i][st[i][c - 'a']&s] + f[i - 1][s]) % mod; } } for(int s = 0;s < (1<<n);++s){ if(__builtin_popcount(s) == k)ans = (ans + f[len][s]) % mod; } printf("%d\n",ans); ``` --- ### [P2465 [SDOI2008] 山贼集团](https://www.luogu.com.cn/problem/P2465) 给定一棵有 $n$ 个节点的树,$1$ 号节点为总部,现部署 $p$ 个分部,每一个分部的管辖范围定义为它到 $1$ 号节点的路径。给定在节点 $u$ 修建 $x$ 号分部的代价 $a_{u,x}$。 给定 $t$ 条其他信息,每条给定 $c$ 个分部编号,若它们的管辖范围有交,则**每个**都被管辖的村庄总收益加上(或减去) $v$。最大化收益。 $p\leq12,n\leq100,t\leq2^p$。 --- 状压 $p$ 维度,表示节点被多少个分部管辖。定义 $f_{u,S}$ 表示 $u$ 节点作为总部时,$u$ 被管辖情况为 $S$,其子树的最大收益。初始化为 $f_{u,\{x\}}=-a_{u,x}$,利用此信息可以 $O(2^p)$ 完成其他状态的初始化: ```cpp for(int s = 0;s < (1<<p);++s)f[u][s] = f[u][s^(s&-s)] + f[u][s&-s]; ``` 其实就是个 lowbit,很好理解。 然后上一个简单的树形背包就行。 对于额外信息,预处理一个村庄被集合 $S$ 管辖的**单一**收益 $v_S$。对于状态 $S$ 来说,总收益是其子集和,枚举子集是 $O(3^n)$。可以用高维前缀和加速,但不影响总复杂度,因为转移时也枚举子集了。 时间复杂度 $O(n3^p)$。 ```cpp void dfs(int u,int fa){ for(int s = 0;s < (1<<p);++s)f[u][s] = f[u][s^(s&-s)] + f[u][s&-s]; for(int i = 0;i < e[u].size();++i){ int v = e[u][i]; if(v == fa)continue; dfs(v,u); for(int s = (1<<p) - 1;s;--s){ for(int ss = s;ss;ss = (ss-1)&s)f[u][s] = max(f[u][s],f[u][s^ss] + f[v][ss]); } } for(int s = 0;s < (1<<p);++s){ for(int ss = s;ss;ss = (ss-1)&s)f[u][s] += val[ss]; } } ``` --- ### [P2473 [SCOI2008] 奖励关](https://www.luogu.com.cn/problem/P2473) $n$ 种宝物,价值为 $p_i$,可能为负数,能拿走宝物 $i$ 的前提是曾经已拿走过给定的宝物集合 $A_i$ 中的所有宝物至少一次。 随机抛出 $k$ 的个宝物,求若采取最优策略,平均情况能得多少分。 $k\leq100,n\leq15$。 --- 定义 $f_{i,S}$ 表示第 $i$ 次宝物抛出,已经取到状态 $S$ 的最大期望得分。 然而一件严重的事情就是,有可能第 $i$ 轮压根就到不了 $S$ 状态。 所以我们可以倒着来,定义 $f_{i,S}$ 为第 $1$ 到第 $i-1$ 次状态为 $S$,第 $i$ 轮到第 $k$ 轮中的最大期望得分。 转移方程很好写: $$f_{i,S}=\frac{1}{n}\sum_{j=1}^n\max\{f_{i+1,S},f_{i+1,S+\{j\}}+p_j|A_i\in S\}$$ 注:若 $j\in S$,这里的 $S+\{j\}$ 就表示不变。 最终答案为 $f_{1,0}$。 时间复杂度 $O(nk2^n)$。 ```cpp for(int i = k;i >= 1;--i){ for(int s = 0;s < (1<<n);++s){ for(int j = 1;it <= n;++j){ if((s&st[j]) == st[j])f[i][s] += max(f[i + 1][s],f[i + 1][s | (1<<(j-1))] + p[j]); else f[i][s] += f[i + 1][s]; } f[i][s] /= n; } } ``` --- ### [P2566 [SCOI2009] 围豆豆](https://www.luogu.com.cn/problem/P2566) $n\times m$ 的网格中有 $d$ 个豆豆,每个位置在 $(ax_i,ay_i)$,价值为 $v_i$。还有一些障碍物`#`。 从任意没有障碍物和豆豆的点出发,可以上下左右移动,不能经过障碍物和豆豆,最后回到出发点。被围住的豆豆为**路径围成的多边形**内的豆豆。 ![](https://cdn.luogu.com.cn/upload/pic/1690.png) 最终收益为围住豆豆价值和减去步数,最大化收益。 $d\leq9,n,m\leq10$。 --- 定义 $f_{x,y,S}$ 为走到 $(x,y)$,围住豆豆状态为 $S$ 的最小步数。对每个合法起点跑一个 bfs 即可。 难点在于如何在移动位置后更新 $S$。 对于最终路径,如果从一个豆豆向右引一条射线,那么它被围住当且仅当这条射线与路径(中**不与射线重合**的线段)交于奇数个点。 当竖向移动时,我们需要更新状态: ```cpp int upd(int x,int y,int s,int nx,int ny){ //从(x,y)到(nx,ny) for(int i = 1;i <= d;++i){ if(((y == ay[i] && ny < ay[i]) || (ny == ay[i] && y < ay[i])) && nx > ax[i])s ^= (1<<(i-1)); } return s; } ``` 代码就是 bfs,时间复杂度 $O(n^2d2^d)$。 ``` int dy[] = {0,1,0,-1},dx[] = {1,0,-1,0}; q.push({sx,sy,0}); f[sx][sy][0] = 0; while(!q.empty()){ int x = q.front().x,y = q.front().y,s = q.front().s;q.pop(); vst[x][y][s] = 1; for(int i = 0;i < 4;++i){ int nx = x + dx[i],ny = y + dy[i]; if(nx < 1 || ny < 1 || ny > n || nx > m || (c[ny][nx] >= '1' && c[ny][nx] <= '9') || c[ny][nx] == '#')continue; int ns = s;if(i&1)ns = upd(x,y,s,nx,ny); if(vst[nx][ny][ns])continue; if(f[x][y][s] < f[nx][ny][ns]){ vst[nx][ny][ns] = 1; f[nx][ny][ns] = f[x][y][s] + 1; q.push({nx,ny,ns}); } } } for(int s = 0;s < (1<<d);++s)ans = max(ans,st[s] - f[sx][sy][s]); ``` --- ### [P3694 邦邦的大合唱站队](https://www.luogu.com.cn/problem/P3694) $n$ 个人排成一排,第 $i$ 个人属于组 $a_i$,共 $m$ 组。求最小的出队人数,使得出列的人以选定位置回到队伍中,每个组的人是一个连续段。 $n\leq10^5,m\leq20$。 --- 这题挺有意思的。 首先无脑状压每个组是否已经连续。 关键在于,最后的组顺序先后无关,可以将以完成的组合在一起,放在最前面。这件事我百思不得其解。 假设我们这样做,如果最优方案中一个组并不是按设想的一样放在前面,那么它根本就不可能成为最优方案。 所以就解决了,每次枚举一个没有被完成连续的组,然后可以计算出它将要占用的区间,利用前缀和可以知道该区间有多少原本就是这组的,计算代价即可。 时间复杂度 $O(nm+m2^m)$。 ```cpp for(int i = 1;i <= n;++i){ int x = in(); for(int j = 1;j <= m;++j)s[i][j] = s[i - 1][j]; cnt[x]++,s[i][x]++; } memset(f,0x3f,sizeof(f)); f[0] = 0; for(int s = 1;s < (1<<m);++s){ int len = 0; for(int i = 1;i <= m;++i){ if(s&(1<<(i - 1)))len += cnt[i]; } for(int i = 1;i <= m;++i){ if(s&(1<<(i - 1)))f[s] = min(f[s],f[s^(1<<(i-1))] + cnt[i] - s[len][i] + s[len - cnt[i]][i]); } } printf("%d",f[(1<<m)-1]); ``` --- ### [P3959 [NOIP 2017 提高组] 宝藏](https://www.luogu.com.cn/problem/P3959) $n$ 个点 $m$ 条边的连通带权图。从任意一个节点 $s$ 出发,依次开通所有节点,选定一条边 $(u,v)$,一个端点已开通,另一个没有,记作 $u$,开通 $u$ 的代价为边权 $w_{u,v}$ 与 $u$ 到起点 $s$ 经过的节点数 $L$ 的乘积。求开通所有节点的最小代价。 $n\leq12,m\leq10^3$。 --- $L$ 就是深度,故按深度来转移。 状压开通节点状态为 $S$。 预处理 $\mathrm{cost}(T,S)$ 表示用边直接连接,使节点集合 $T$ 变为 $S$ 的最小代价,不合法就赋为 $+\inf$。 转移方程就很简单了: $$f_{d,S}=\min_{T\in S}f_{d-1,T}+d\times\mathrm{cost}(T,S)$$ 有一个问题就是新加入的点连接的不一定是第 $d-1$ 层的节点,难道我们还要再加一维状态记录上一层有哪些点吗? 显然不用,因为如果它连到更上层的点,现在它深度更大,代价更高,不会作为更优决策影响结果。 时间复杂度 $O(n^23^n)$,预处理时使用 lowbit 增量式可以做到 $O(n3^n)$。 注意初始化起点。 ```cpp memset(dp,0x3f,sizeof(dp)); for(int s = 0;s <= n;++s)dp[0][1<<rt] = 0; for(int d = 1;d < n;++d){ for(int s = 1;s < (1<<n);++s){ for(int t = (s-1)&s;t;t = (t-1)&s)dp[d][s] = min(dp[d][s],dp[d - 1][t] + d * cost[t][s]); } ans = min(ans,dp[d][(1<<n)-1]); } printf("%lld",ans); ``` --- ### [P4484 [BJWC2018] 最长上升子序列](https://www.luogu.com.cn/problem/P4484) 求长为 $n$ 的随机排列最长上升子序列的期望值,对 $998244353$ 取模。 $n\leq28$。 --- 设 $f_i$ 是在 $i$ 位置及之前时的最长上升子序列长度(可以不以 $i$ 结尾),那么对其进行差分为 $d_i$,则 $d_i$ 要么是 $0$,要么是 $1$,这是显然的。 既然如此,我们可以状压 $d$ 数组,定义 $dp_{i,d}$ 表示 $1$ 到 $i$ 的排列,$d$ 状态下的方案数,则答案为: $$\frac{1}{n!}\sum\limits_{d=0}^{2^{n-1}-1}dp_{n,d}\times\mathrm{len}(d)$$ 其中 $\mathrm{len}(d)$,表示 $d$ 中二进制位 $1$ 的个数,程序中可以使用`__builtin_popcount`求解。 我们可以钦定从小到大插入 $i$ 到位置 $k$,由于 $i$ 是最大的,故 $d_k=1$。而它后面第一个为 $1$ 的位置 $p$ 就应当 $d_p=0$,因为从这里开始 $i$ 对最长上升子序列的长度就没有影响了。 注意到 $d_1$ 为 $1$,可以少压一位,但时间复杂度 $O(n2^n)$ 依旧很难通过,所以——打表! 可以用[杨表(Young tableau)](https://harris.green/archives/dify-post-1774152478)高效解决,但不在本文讨论范围内,留一道要用杨表的题 [P3774 [CTSC2017] 最长上升子序列](https://www.luogu.com.cn/problem/P3774),有兴趣可以去了解一下。 ```cpp fac = dp[1][0] = 1; printf("1,\n"); for(int i = 2;i <= n;++i){ fac = (long long)fac * i % mod; for(int d = 0;d < (1<<(i-2));++d){ if(!dp[(i^1)&1][d])continue; dp[i&1][d] = ((long long)dp[i&1][d] + dp[(i^1)&1][d]) % mod; //将i插入到序列首位 for(int j = i - 2;j >= 0;--j){ //第j位 int nd = (d>>j)<<(j+1); //清空后j位 nd |= (1<<j); //第j位设置为1 int t = d&((1<<j)-1); t = rem(t); //移除后面的最高位 nd |= t; dp[i&1][nd] = ((long long)dp[i&1][nd] + dp[(i^1)&1][d]) % mod; } } long long ans = 0; for(int d = 0;d < (1<<(i-1));++d){ ans = (ans + (long long)dp[i&1][d] * (__builtin_popcount(d) + 1) % mod) % mod; dp[(i^1)&1][d] = 0; //把下一次要用的清空 } printf("%lld,\n",ans * inv(fac) % mod); } ```