状态压缩 DP
“状态压缩动态规划”中的状态,通常与集合相关联。集合本身具有确定性、互异性和无序性 3 个性质,这也就决定了集合只关心每个元素的存在状态,而这通常可以使用 0 或者 1 表示存在或者不存在。例如,有 8 个物品,对这 8 个物品的选取方案,必然是某个子集。令 1 表示选了,0 表示没选,那么像 10000001 就表示选了两端的两个物品,01010101 也是类似的道理。
由此可见,可以通过一串 0、1 码来清晰地表示一个集合的状态。同时,在确定了位数的前提下,一串 0、1 码与一个二进制数一一对应。这种表示状态的方式被称为状态压缩,简称状压。所谓“压缩”,即是用一个二进制数来保存一组状态,将一个集合的状态压缩进了一个二进制数中,而通常这个二进制数在十进制下的大小可以作为其编号。状态压缩本质上是进行了两步操作:
- 给这个集合的每个状态一个编号
- 通过该编号,轻易地访问该状态
例题:P1171 售货员的难题
本题是著名的旅行商问题(Traveling Salesman Problem, TSP),是一个经典的 NP-Hard 问题,目前还没找到多项式时间复杂度解法,其常见复杂度表示中往往包含 \(n!\) 或 \(2^n\)(其中的 \(n\) 为输入数据的规模)等非多项式因子,所以这类问题的解法通常是暴力搜索或状态压缩动态规划。
本题中,需要记录“现在走到了哪个村庄”。但是考虑这样的情况:如果售票员某一情况下“走完了 1、2、3,来到了 4,还需要走到 5”,而在另一情况下“走完了 1,来到了 4,还需要走 2、3、5”,虽然都到达了 4 号村庄,但这两个状态不等价。因此,需要进一步扩展状态的维数,多设计一个状态 \(s\)。\(f_{i,s}\) 表示现在走到 \(i\) 号村庄,且经过集合 \(s\) 内所有村庄的最短路程。状态 \(s\) 可以被表示为一个长度为 \(n\) 的二进制串,其中 \(s_i=0\) 表示第 \(i\) 个村庄还未被访问,\(s_i=1\) 表示第 \(i\) 个村庄已被访问。初始状态是 \(f_{1, \{ 1 \}} = 0\),\(f_{x,y} = \infty\)(\(x \ne 1\) 且 \(y \ne \{ 1 \}\)),其中 \(\{ x,y,z,\dots \}\) 表示一个包含元素 \(x,y,z,\dots\) 的集合,因此 \(\{ 1 \}\) 表示一个仅包含元素 1 的集合。最终结果即为走到某个节点且走完了全部状态的 \(f\) 的最小值(因为需要从 \(i\) 再走回到 1,所以需要加上 \(\text{dist}(i,1)\)),也就是 \(\min \limits_i \{ \text{dist}(i,1) + f_{i,\{ 1,2,3,\dots,n \}} \}\)。
基于以上定义,可以写出转移方程 \(f_{i,s} = \min\limits_j \{ f_{j,s-\{ i \}} + \text{dist}(j,i) \}\)。
转移的意义是,当需要在集合中新加入一个点 \(i\),且“已经过点的集合”为 \(s\) 时,点 \(i\) 是从某个点 \(j\) 走过来的,枚举 \(j\),取路程的最小值即可,总的时间复杂度是 \(O(n^2 2^n)\)。
参考代码
#include <cstdio>
#include <vector>
#include <algorithm>
using namespace std;
const int N = 20;
const int INF = 1e9;
int s[N][N]; // 邻接矩阵,存储村庄间的距离
// f[mask][i]: 状态压缩DP数组
// mask: 一个二进制位掩码,表示已经访问过的村庄集合。如果第j位为1,表示村庄j已访问。
// i: 当前路径的终点是村庄i。
// f[mask][i] 存储的是从起点出发,访问了mask集合中的所有村庄,并最终停在村庄i的最短路径长度。
int f[1 << N][N];
int main()
{
int n;
scanf("%d", &n);
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
scanf("%d", &s[i][j]);
}
}
// 初始化DP数组为无穷大
for (int i = 0; i < (1 << n); i++) {
for (int j = 0; j < n; j++) {
f[i][j] = INF;
}
}
// 设置DP的 base case
// 从村庄0(即题目中的村庄1)出发,只访问了村庄0,路径长度为0。
// (1 << 0) = 1,是只包含村庄0的集合的位掩码。
f[1][0] = 0;
// 遍历所有可能的访问集合 mask (从包含两个村庄的集合开始)
for (int i = 1; i < (1 << n); i++) {
// 遍历当前集合 mask 中的所有村庄 j,作为路径的终点
for (int j = 0; j < n; j++) {
// 如果村庄 j 在集合 i 中
if ((i >> j) & 1) {
// 遍历所有可能的上一个村庄 k
for (int k = 0; k < n; k++) {
// k 必须是 j 的前一个节点,所以 k 也必须在集合 i 中,且 k != j
// 并且,从 k 转移到 j 的前提是,不包含 j 的路径是存在的
if (((i >> k) & 1) && k != j) {
// 状态转移方程:
// f[i][j] = min(f[i][j], f[i_without_j][k] + dist(k, j))
// i ^ (1 << j) 表示从集合 i 中去掉村庄 j
f[i][j] = min(f[i][j], f[i ^ (1 << j)][k] + s[k][j]);
}
}
}
}
}
// 计算最终答案
int ans = INF;
// (1 << n) - 1 是包含了所有村庄的集合的位掩码
// 遍历所有可能的终点 i
for (int i = 0; i < n; i++) {
// 完整回路的长度 = (访问所有村庄并停在i的最短路) + (从i返回起点0的距离)
ans = min(ans, f[(1 << n) - 1][i] + s[i][0]);
}
printf("%d\n", ans);
return 0;
}
习题:P1433 吃奶酪
解题思路
相当于不用回到起始点的 TSP 问题,DP 状态设计类似于 P1171 售货员的难题。
为了避免在 DP 过程中反复调用 sqrt 函数计算距离,可以提前计算出任意两块奶酪 \(i\) 和 \(j\) 之间的欧几里得距离,并存储在一个二维数组中。
参考代码
#include <cstdio>
#include <cmath>
#include <algorithm>
using namespace std;
const int N = 15;
const double INF = 1e9;
double x[N], y[N]; // 存储奶酪的坐标
double dis[N][N]; // dis[i][j] 预存储奶酪i和奶酪j之间的距离
// dp[mask][j] 表示访问过的奶酪集合为 mask,且当前停留在奶酪 j 时的最短路径
// mask 是一个二进制数,第 i 位为1表示第 i 块奶酪已被访问
double dp[1 << N][N];
// 计算两点之间的欧几里得距离
double distance(int i, int j) {
double dx = x[i] - x[j], dy = y[i] - y[j];
return sqrt(dx * dx + dy * dy);
}
int main()
{
int n; scanf("%d", &n);
for (int i = 0; i < n; i++) {
scanf("%lf%lf", &x[i], &y[i]);
}
// 预处理,计算所有奶酪对之间的距离
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
dis[i][j] = distance(i, j);
}
}
// 初始化dp数组为无穷大
for (int i = 0; i < (1 << n); i++) {
for (int j = 0; j < n; j++)
dp[i][j] = INF;
}
for (int s = 1; s < (1 << n); s++) {
// 寻找集合s中的任意一个元素,这里代码取了索引最小的那个
int pos = -1;
for (int i = 0; i < n; i++) {
if ((s >> i) & 1) {
pos = i; break;
}
}
// Base Case: 如果集合s中只有一个元素pos
// 路径长度即为从原点到该点的距离
if ((1 << pos) == s) {
dp[s][pos] = sqrt(x[pos] * x[pos] + y[pos] * y[pos]);
continue;
}
// 对于包含多个元素的状态s,进行状态转移
// 遍历s中的每个元素i,尝试将其作为当前路径的终点
for (int i = pos; i < n; i++) {
if ((s >> i) & 1) {
int p = s ^ (1 << i); // p是s去掉i之后的前一个状态
// 遍历前一个状态p中的所有元素j,j是路径倒数第二个点
for (int j = pos; j < n; j++) {
if ((p >> j) & 1) {
// 状态转移方程:dp[s][i]可由dp[p][j]加上j到i的距离更新而来
dp[s][i] = min(dp[s][i], dp[p][j] + dis[j][i]);
}
}
}
}
}
double ans = INF;
int full = (1 << n) - 1; // full mask 表示所有奶酪都被访问
// 遍历所有最终可能的停留点 i,找到最短的路径
for (int i = 0; i < n; i++) ans = min(ans, dp[full][i]);
printf("%.2f\n", ans);
return 0;
}
例题:CF11D A Simple Task
这是一个在图中进行路径计数的问题,由于顶点数 \(N\) 非常小(\(N \le 19\)),这强烈地暗示了需要使用状态压缩动态规划来解决。
直接通过 DFS 来寻找所有环,会面临两个主要问题:重复计算(例如,一个环从不同顶点出发或沿不同方向遍历会被多次计数)和超时。
动态规划可以有效地解决子问题重叠的情况,需要定义一个能够描述“路径”的状态,并通过扩展路径来进行状态转移。为了解决重复计数的问题,需要引入一个固定的计数规则。一个常用的技巧是:对于任何一个环,只在它标号最小的顶点处开始和结束计数,并且只沿着标号增大的方向进行探索。
设 \(f_{S,i}\) 表示图中所有满足以下条件的简单路径的数量:
- 路径经过的顶点集合恰好是 \(S\)(用一个 \(n\) 位的二进制数,即位掩码来表示)。
- 路径的起点集合 \(S\) 中标号最小的顶点。
- 路径的终点是顶点 \(i\)。
遍历所有位掩码来计算 DP 值。
为了计算 \(f_{S,i}\),考虑所有能够一步转移到该状态的前置状态。一个能够转移到 \(f_{S,i}\) 的路径,其终点必定是 \(i\) 的某个邻居 \(j\),并且其经过的顶点集合是 \(S\) 去掉 \(i\),即 \(S' = S - \{ i \}\)。
因此,可以从所有 \(f_{S', j}\) 状态进行转移,其中 \(j\) 是 \(i\) 的邻居,且 \(j\) 属于集合 \(S'\),\(f_{S,i} = \sum f_{S',j}\)。
初始化:对于每个顶点 \(i\),都存在一条只包含它自己的、长度为 0 的路径。因此,\(f_{\{ i \}, i} = 1\)。
在 DP 过程中,当处于状态 \(f_{S,i}\) 时,检查路径的终点 \(i\) 是否与路径的起点(集合中标号最小的顶点)有边相连。如果两者之间有边,说明找到了一条可以闭合成环的路径,将 \(f_{S,i}\) 的值累加到总答案中。
这样一来,累加的结果会包含一些不想要的东西:
- 重复计数:每个环 \(v_1 \to v_2 \to \cdots \to v_k \to v_1\)(设 \(v_1\) 是最小节点),通过路径 \(v_1 \to v_2 \to \cdots \to v_k\) 找到边 \((v_k,v_1)\) 计数了一次,也通过路径 \(v_1 \to v_k \to \cdots \to v_2\) 找到边 \((v_2, v_1)\) 计数了一次。因此,每个长度 \(\ge 3\) 的环被正反两个方向各计算了一次。
- 长度为 2 的环:每条边 \((u,v)\)(设 \(u \lt v\)),都会被路径 \(u \to v\) 在连接回 \(u\) 时算作一个环,总共被计算一次。图中共有 \(m\) 条边,所以这部分贡献了 \(m\)。
因此,刚才计算的累加结果实际上等于 2 倍的真正答案再加上 \(m\)。
状态总数为 \(O(n \cdot 2^n)\),对于每个状态,需要 \(O(n)\) 的时间来尝试所有可能的转移,总时间复杂度为 \(O(n^2 \cdot 2^n)\)。
参考代码
#include <cstdio>
using ll = long long;
const int N = 19;
int adj[N][N]; // 邻接矩阵
// f[mask][i]: 状态压缩DP数组
// mask: 一个二进制位掩码,表示路径经过的顶点集合
// i: 路径的终点是顶点i
// f[mask][i] 存储的是:所有从 mask 中标号最小的顶点出发,经过 mask 中所有顶点,
// 最终到达顶点 i 的简单路径的数量。
ll f[1 << N][N];
int main()
{
int n, m;
scanf("%d%d", &n, &m);
for (int i = 1; i <= m; i++) {
int a, b;
scanf("%d%d", &a, &b);
a--; b--; // 转换为0-indexed
adj[a][b] = adj[b][a] = 1;
}
ll ans = 0;
// 遍历所有状态集合 i (mask)
for (int i = 1; i < (1 << n); i++) {
// --- Base Case 处理 ---
// 如果 i 只有一个 bit 为 1 (i是2的幂),说明是路径的起点
if (!(i & (i - 1))) {
for (int j = 0; j < n; j++) {
if ((1 << j) == i) {
f[i][j] = 1; // 初始化路径数量为1
break;
}
}
continue; // 单个点的路径无法构成环,直接处理下一个状态
}
// --- DP 状态转移 ---
// 找到当前集合 i 中标号最小的顶点 start,作为路径的固定起点以去重
int start = -1;
for (int j = 0; j < n; j++) {
if ((i >> j) & 1) {
start = j;
break;
}
}
// 遍历路径的终点 j,只需要计算终点标号比起点大的路径
for (int j = start + 1; j < n; j++) {
// 如果 j 不在当前集合中,跳过
if (!((i >> j) & 1)) continue;
// p 是 i 中去掉 j 后的集合,代表到达 j 之前路径的状态
int p = i ^ (1 << j);
// 遍历 p 中的所有顶点 k,作为到达 j 的上一个顶点
for (int k = 0; k < n; k++) {
if (!((p >> k) & 1)) continue;
// 从 f[p][k] 进行转移
if (adj[k][j]) {
f[i][j] += f[p][k];
}
}
// --- 环的判断与计数 ---
// 在计算完所有到达 f[i][j] 的路径后,检查终点 j 是否能回到起点 start
if (adj[j][start]) {
ans += f[i][j];
}
}
}
// 结果处理
// ans 中包含了所有环的计数,但有重复:
// 1. 每个长度 >= 3 的环,都按顺时针和逆时针两个方向被分别统计了一次。
// 2. 每条边 (u,v) (设u<v),都被路径 u->v 再连接回 u 时,算作一个长度为2的“环”,贡献了m次。
// 题目要求的简单环长度至少为3,所以要去掉长度为2的环(即边)
// 所以 ans = 2 * (真实环数) + m
// 真实环数 = (ans - m) / 2
printf("%lld\n", (ans - m) / 2);
return 0;
}
例题:P1896 [SCOI2005] 互不侵犯
简单模拟后不难发现,答案可能会很大。而对于较复杂的计数,动态规划的思想往往是解决此类问题的有力武器。通过观察性质,不难发现按一定顺序,例如行号递增的方式摆放棋子时,当前棋子能不能放到某个位置上完全取决于上一行的棋子是怎么摆放的。因此,设计状态时可以将当前行、上一行的状态都记录下来,使用 0 或者 1 来表示某一行的状态,考虑用二进制数来表示。例如 \(101010_{(2)} = 34_{(10)}\),那么,对于这一行的状态,就可以用 34 这个十进制数值来记录。此时需要注意,一般情况下状压之后,状态中的每一位都是倒着存储的,因为这符合二进制的定义规则。例如一行的状态为 110101,那么二进制下应该是 101011。可以类比,从左至右列标逐渐增大,但是二进制位从左到右权值逐渐减小。
这种访问方式可以实现比数组寻址更快速、但同样准确的访问,如 (1<<(k-1))&S 可以询问状态 S 的第 k 位上是 1 还是 0。而 (S>>k)<<k 则可以表示把状态 S 的二进制表示下最右边几位清零,而数组不能以 \(O(1)\) 的复杂度直接清零。
摆放国王需要满足两个约束条件:
- 行内约束:在同一行中,任意两个国王不能相邻。对于一个状态
S,这意味着不能存在两个相邻的 1,用位运算可以表示为S & (S << 1) == 0。 - 行间约束:第 \(i\) 行的国王不能攻击到第 \(i-1\) 行的国王,假设第 \(i\) 行的状态是 \(S\),第 \(i-1\) 行的状态是 \(P\),那么:
(S & P) == 0(正上方不能有国王)(S & (P << 1)) == 0(左上方不能有国王)(S & (P >> 1)) == 0(右上方不能有国王)
基于以上分析,可以设计出 DP 方案。
需要记录三个关键信息:当前处理到哪一行、已经放了多少个国王、以及当前行的布局状态。因此,可以定义一个三维 DP 数组,令 \(f_{i,j,S}\) 表示在棋盘的前 \(i\) 行,总共放置了 \(j\) 个国王,并且第 \(i\) 行的布局状态为 \(S\) 的方案数。
从上一行 \(i-1\) 的状态 \(P\) 转移到当前行 \(i\) 的状态 \(S\)。
\(f_{i,j,S}\) 的值,等于所有可以转移到它的、上一行状态的方案数之和。具体来说,对于一个合法的当前行状态 \(S\),它包含 \(\text{count}(S)\) 个国王,这个状态可以由任何一个与它兼容的上一行状态 \(P\) 转移而来。从 \(P\) 转移时,上一行及之前已经放了 \(j-\text{count}(S)\) 个国王。
因此,状态转移方程为 \(f_{i,j,S} = \sum f_{i-1,j-\text{count}(S),P}\),其中求和的范围是所有与 \(S\) 兼容的上一行状态 \(P\)。
初始化\(f_{0,0,0}=1\),这可以理解为在第 0 行(一个虚拟的空行),放置 0 个国王,状态为 0(空),有 1 种方案,这是整个 DP 的起点。
当所有行都处理完毕后,需要的总方案数是所有在第 \(N\) 行、总国王数为 \(K\) 的方案之和。
这样时间复杂度为 \(O(NK 4^N)\)。
为了提高 DP 的效率,可以预处理一些信息:
- 合法单行状态:筛选出所有满足“行内约束”的状态 \(S\),并将它们存入一个数组。同时,可以预计算每个合法状态 \(S\) 中包含的国王数量(即二进制中 1 的个数)。
- 兼容状态对:对于每一个合法的单行状态 \(P\),可以预处理出所有能够作为其下一行的合法状态 \(S\)(即满足“行间约束”),并将这些兼容关系存储起来。
预处理合法单行状态后时间复杂度为 \(O(NKC^2)\),其中 \(C\) 是单行合法状态的数量。通过预处理兼容状态对,可以将复杂度优化为 \(O(NKT)\),其中 \(T\) 是所有兼容状态对的总数。对于 \(N=9\),\(C\) 在 100 左右,\(T\) 在 700 左右。
参考代码
#include <cstdio>
typedef long long LL;
const int MAXN = 550; // 状态数的上限,2^9=512
int c[MAXN]; // c[s]: 状态s中1的个数(即国王数量)
int s[MAXN]; // s[i]: 第i个合法的单行状态
int valid[MAXN][MAXN]; // valid[i][j]: 与状态s[i]兼容的第j个状态
int len[MAXN]; // len[i]: 与状态s[i]兼容的状态总数
// dp[i][j][s]: 在第i行,放置了j个国王,且第i行状态为s时的方案数
// 使用滚动数组优化第一维
LL dp[2][100][MAXN];
// 计算二进制数中1的个数 (population count)
int popcnt(int x) {
int res = 0;
while (x) {
res++;
x &= (x - 1); // 每次去掉最后一个1
}
return res;
}
int main()
{
int n, k;
scanf("%d%d", &n, &k);
// 1. 预处理:找出所有合法的单行状态
int cnt = 0; // 合法状态的总数
for (int i = 0; i < (1 << n); i++) {
// 行内约束:国王不能左右相邻。 (i & (i >> 1)) == 0
if (!(i & (i >> 1))) {
s[cnt++] = i; // 存入合法状态数组
c[i] = popcnt(i); // 预计算该状态的国王数
}
}
// 2. 预处理:找出所有兼容的两行状态对
for (int i = 0; i < cnt; i++) { // 枚举上一行的状态 s[i]
for (int j = 0; j < cnt; j++) { // 枚举当前行的状态 s[j]
// 行间约束:国王不能正上、左上、右上攻击
// !(s[i] & s[j]): 正上方不能有国王
// !(s[i] & (s[j] >> 1)): 左上方不能有国王
// !(s[i] & (s[j] << 1)): 右上方不能有国王
if (!(s[i] & s[j]) && !(s[i] & (s[j] << 1)) && !(s[i] & (s[j] >> 1))) {
valid[i][len[i]++] = s[j]; // 记录兼容状态
}
}
}
// 3. 动态规划
// 初始化 base case: 第0行,放0个国王,状态为0(空),方案数为1
dp[0][0][0] = 1;
for (int i = 1; i <= n; i++) { // 枚举当前行 i
int cur = i % 2; // 当前行在滚动数组中的索引
int pre_idx = 1 - cur; // 上一行在滚动数组中的索引
// 清空当前行的dp值(如果滚动数组不清空,会沿用上上轮的结果)
for (int j = 0; j <= k; j++) {
for (int x = 0; x < cnt; x++) {
dp[cur][j][s[x]] = 0;
}
}
for (int j = 0; j <= k; j++) { // 枚举到当前行i为止,总共放置的国王数 j
for (int x = 0; x < cnt; x++) { // 枚举上一行(i-1)的合法状态 pre_state = s[x]
int pre_state = s[x];
for (int y = 0; y < len[x]; y++) { // 枚举与 pre_state 兼容的当前行(i)的状态 cur_state
int cur_state = valid[x][y];
int kings_in_cur = c[cur_state]; // 当前行放置的国王数
if (j >= kings_in_cur) { // 确保总国王数足够
// 状态转移方程:
// 当前方案数 += 上一行(状态为pre_state,国王数为j-kings_in_cur)的方案数
dp[cur][j][cur_state] += dp[pre_idx][j - kings_in_cur][pre_state];
}
}
}
}
}
// 4. 统计最终答案
// 最终答案是第 n 行,总共放了 k 个国王的所有状态的方案数之和
LL ans = 0;
for (int i = 0; i < cnt; i++) {
ans += dp[n % 2][k][s[i]];
}
printf("%lld\n", ans);
return 0;
}
习题:P1879 [USACO06NOV] Corn Fields G
解题思路
类似于 P1896 [SCOI2005] 互不侵犯,定义 \(f_{i,s}\) 表示已经处理完前 \(i\) 行,且第 \(i\) 行的种植状态为 \(s\) 时的总方案数。同样,可以预处理土地肥沃状态、行内合法状态、行间兼容关系以加速计算。
参考代码
#include <cstdio>
#include <vector>
using namespace std;
const int N = 15;
const int MOD = 100000000;
int f[N][N]; // 临时存储输入的土地肥沃状态
int line[N]; // line[i] 用一个整数存储第i行的土地肥沃状态(1为肥沃)
// dp[i][s] 表示处理完前i行,且第i行的种植状态为s时的总方案数
int dp[N][1 << 12];
// g[s1] 存储所有与状态s1不冲突(不垂直相邻)的行内合法状态s2
vector<int> g[1 << 12];
int main()
{
int m, n;
scanf("%d%d", &m, &n);
// 读取土地数据,并将其转换为每行一个的整数(位掩码)
for (int i = 1; i <= m; i++) {
line[i] = 0;
for (int j = 0; j < n; j++) {
scanf("%d", &f[i][j]);
line[i] = line[i] * 2 + f[i][j];
}
}
// 预处理1: 找出所有“行内合法”的状态
// 一个状态是行内合法的,当且仅当它没有水平相邻的1
vector<int> valid;
for (int i = 0; i < (1 << n); i++) {
if (i & (i >> 1)) continue; // 如果i和i右移一位有重叠,说明有'11'这样的相邻位,不合法
valid.push_back(i);
}
// 预处理2: 找出所有“行间兼容”的状态对
// 状态s1和s2是行间兼容的,当且仅当它们在垂直方向上没有冲突
for (int s1 : valid) {
for (int s2 : valid) {
if (s1 & s2) continue; // 如果s1和s2在同一位上都为1,则垂直冲突,不兼容
g[s1].push_back(s2);
}
}
// 初始化/Base Case: 第0行(虚拟行)不种任何东西的方案数为1
dp[0][0] = 1;
for (int i = 1; i <= m; i++) { // 遍历行
for (int s1 : valid) { // 遍历当前行(i)的所有行内合法状态 s1
// 检查s1是否能在当前行(i)的土地上种植
if ((s1 & line[i]) != s1) continue;
// 遍历所有与s1兼容的上一行(i-1)的状态 s2
for (int s2 : g[s1]) {
// 检查s2是否能在上一行(i-1)的土地上种植。注:代码中line[0]未初始化,但dp[0][s2]当s2!=0时为0,所以i=1时此检查不影响结果
if ((s2 & line[i - 1]) != s2) continue;
// 状态转移:dp[i][s1] 的值,由所有合法的 dp[i-1][s2] 累加而来
dp[i][s1] += dp[i - 1][s2];
dp[i][s1] %= MOD;
}
}
}
// 计算最终答案:累加最后一行所有合法状态的方案数
int ans = 0;
for (int s : valid) {
// 此处无需再次检查 line[m],因为在DP计算dp[m][s]时已经保证了s在第m行是可种植的
ans = (ans + dp[m][s]) % MOD;
}
printf("%d\n", ans);
return 0;
}
习题:P2704 [NOI2001] 炮兵阵地
解题思路
由于炮兵的攻击范围是上下两格,所以第 \(i\) 行的部署方案不仅受到第 \(i-1\) 行的影响,还会受到第 \(i-2\) 行的影响。因此,动态规划的状态设计需要同时记录当前行和上一行的状态信息。
定义 \(f_{i,j,k}\) 为已经处理到第 \(i\) 行,其中第 \(i-1\) 行的部署状态为 \(j\),第 \(i\) 行的部署状态为 \(k\) 时,前 \(i\) 行能够部署的最多炮兵数量。
由于 \(M \le 10\),如果直接用 10 位二进制数去表示 \(j\) 和 \(k\),那么整个状态数会达到 \(100 \times 2^{20}\)。因此,为了降低状态数,可以提前预处理单行内合法的部署状态,这个数量大约在 60 左右,这样一来 \(j\) 和 \(k\) 的含义可以转变为合法状态列表中的索引。
假设枚举的第 \(i-2,i-1,i\) 行状态在合法状态列表中的索引分别为 \(j,k,l\),则有状态转移 \(f_{i,k,l} = \max \{ f_{i-1,j,k} + \text{popcount}(s_i) \}\),其中 \(s_i\) 表示第 \(i\) 行枚举到的合法状态,\(\text{popcount}\) 为其炮兵数量(1 的个数)。
最终的答案应该是 \(f\) 中的最大值,因为最优解可能在任何一行的决策后产生。
时间复杂度为 \(O(N \cdot S^3)\),其中 \(S\) 是单行合法状态的数量,在本题的数据范围下最大约为 60。
参考代码
#include <cstdio>
#include <vector>
#include <algorithm>
using namespace std;
const int N = 105;
const int M = 15;
char buf[M];
int line[N]; // line[i]: 存储第i行的地形状态,1为平原,0为山地
// dp[i][k][l]: 第i行状态为valid[l], 第i-1行状态为valid[k]时,前i行的最大炮兵数
// 注意:代码实现中,dp[i-1][j][k]表示i-1行的状态是k, i-2行的状态是j
int dp[N][65][65];
int cnt[1 << 10]; // cnt[s]: 状态s的炮兵数量 (二进制中1的个数)
vector<int> valid; // 存储所有单行内合法的状态
// 计算一个整数二进制表示中1的个数
int popcount(int x) {
int res = 0;
while (x > 0) {
res++;
x = x & (x - 1);
}
return res;
}
int main()
{
int n, m; scanf("%d%d", &n, &m);
// 读入地形,并转换为二进制状态
for (int i = 1; i <= n; i++) {
scanf("%s", buf);
line[i] = 0;
for (int j = 0; j < m; j++) line[i] = line[i] * 2 + (buf[j] == 'P');
}
// 预处理:找出所有单行合法的状态
// 单行合法条件:不能有两个炮兵的水平距离小于等于2
for (int i = 0; i < (1 << m); i++) {
cnt[i] = popcount(i);
if (i & (i >> 1)) continue; // 检查与右边第一个位置的冲突
if (i & (i >> 2)) continue; // 检查与右边第二个位置的冲突
valid.push_back(i);
}
int ans = 0;
// 初始化DP数组:处理第1行
for (int i = 0; i < valid.size(); i++) {
int s = valid[i];
// 状态s必须是地形line[1]的子集 (只能在平原上放)
if ((s & line[1]) != s) continue;
// dp[1][0][i]表示第1行状态为valid[i], 第0行(虚拟)状态为0
dp[1][0][i] = cnt[s];
ans = max(ans, dp[1][0][i]);
}
// 状态转移:从第2行开始
for (int i = 2; i <= n; i++) {
// 枚举第i-2行的状态 s1 (索引为j)
for (int j = 0; j < valid.size(); j++) {
int s1 = valid[j];
// 检查地形兼容性
if (i > 2 && (s1 & line[i - 2]) != s1) continue;
// 特殊处理:当i=2时,i-2是第0行,我们假设其状态为0,所以只循环一次
if (i == 2 && j > 0) continue;
// 枚举第i-1行的状态 s2 (索引为k)
for (int k = 0; k < valid.size(); k++) {
int s2 = valid[k];
if ((s2 & line[i - 1]) != s2) continue;
// 检查s1和s2是否冲突
if (s1 & s2) continue;
// 枚举第i行的状态 s3 (索引为l)
for (int l = 0; l < valid.size(); l++) {
int s3 = valid[l];
if ((s3 & line[i]) != s3) continue;
// 检查s3是否与s1或s2冲突
if ((s1 & s3) || (s2 & s3)) continue;
// 状态转移方程
dp[i][k][l] = max(dp[i][k][l], dp[i - 1][j][k] + cnt[s3]);
ans = max(ans, dp[i][k][l]);
}
}
}
}
printf("%d\n", ans);
return 0;
}
习题:P2831 [NOIP 2016 提高组] 愤怒的小鸟
解题思路
由于小猪的数量 \(n\) 最多为 \(18\),暗示时间复杂度中可能有指数项,考虑状压 DP。
用一个整数的二进制位来表示小猪们的存活状态,一个 \(n\) 位的二进制数,第 \(i\) 位为 \(1\) 表示第 \(i\) 只小猪已经被消灭,为 \(0\) 则表示还存在。
设 \(dp_S\) 表示要达到状态 \(S\)(即消灭 \(S\) 这个二进制数所代表的小猪集合)所需的最少小鸟数量。最终目标是计算 \(dp_{2^n-1}\) 的,其中 \(2^n-1\) 是一个所有位都为 \(1\) 的 \(n\) 位二进制数,代表所有小猪都被消灭的状态。
为了加速动态规划的过程,可以预先计算出任意两只小猪(或一只小猪)能确定的抛物线可以一次性消灭哪些小猪。
一条经过原点的抛物线 \(y=ax^2+bx\) 可以由两个不同的小猪坐标 \((x_i,y_i)\) 和 \((x_j,y_j)\) 唯一确定(前提是 \(x_i\) 不等于 \(x_j\)),通过解二元一次方程组可以求出 \(a\) 和 \(b\)。如果只选一只小猪 \((x_i,y_i)\),它自己就能构成一条抛物线(可以有无数条,但这里只关心它能打掉自己)。
用一个二维数组 \(c_{i,j}\) 来存储这个预处理结果,\(c_{i,j}\) 是一个整数(同样用作位掩码),表示由第 \(i\) 只和第 \(j\) 只小猪确定的抛物线所能消灭的所有小猪的集合。
初始化 \(dp_0 = 0\)(消灭 \(0\) 只小猪需要 \(0\) 只小鸟),其他 \(dp_S\) 初始化为一个较大的值,表示初始时不可达。
从小到大遍历所有状态 \(S\),对于每个状态 \(S\),找到第一个还未被消灭的小猪 \(p\),发射一只小鸟来消灭 \(p\)。这只小鸟可以只打 \(p\),也可以再打另一只小猪 \(j\),那么这次打击会消灭 \(c_{p,j}\) 所代表的小猪集合。新的状态就是当前状态和这次打击所消灭的小猪集合的并集,用当前的 \(dp\) 值加 \(1\) 尝试更新新状态的 \(dp\) 值。
参考代码
#include <cstdio>
#include <cmath>
#include <algorithm>
using namespace std;
const int N = 18;
const double EPS = 1e-6;
double x[N], y[N];
int c[N][N]; // c[i][j] 存储由猪i和猪j决定的抛物线能覆盖的小猪集合(位掩码)
int dp[1 << N]; // dp[S] 存储达到状态S所需的最少小鸟数
bool match(double a, double b, int i) { // 检查点(x[i],y[i])是否在由a和b定义的抛物线上
double res = a * x[i] * x[i] + b * x[i];
return fabs(res - y[i]) < EPS; // 考虑浮点误差
}
int main()
{
int t;
scanf("%d", &t); // 读取测试用例数量
while (t--) {
int n, m; scanf("%d%d", &n, &m); // 正解用不到m
for (int i = 0; i < n; i++) scanf("%lf%lf", &x[i], &y[i]);
// 预处理阶段:计算 c 数组,存储所有可能的单次打击结果
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
if (i == j) {
c[i][j] = 1 << i; // 如果只选一只猪,抛物线只经过它自己
continue;
} else if (x[i] == x[j]) continue; // 如果两只猪的x坐标相同,无法形成抛物线
c[i][j] = 0;
// 解方程组 y = ax^2 + bx
// y_i = a*x_i^2 + b*x_i
// y_j = a*x_j^2 + b*x_j
// 消去 b,解出 a 和 b
double denom = x[i] * x[j] * (x[i] - x[j]);
double a = (x[j] * y[i] - x[i] * y[j]) / denom;
double b = (y[j] * x[i] * x[i] - y[i] * x[j] * x[j]) / denom;
if (a > -EPS) continue; // a 必须是负数
// 找到这条抛物线能打掉的所有猪
for (int k = 0; k < n; k++) {
if (match(a, b, k)) c[i][j] |= (1 << k); // 使用位运算来构建集合
}
}
}
// 动态规划阶段
for (int i = 0; i < (1 << n); i++) dp[i] = n;
dp[0] = 0;
for (int i = 0; i < (1 << n) - 1; i++) { // 遍历所有可能的状态
if (dp[i] == n) continue;
// 找到第一个还没被打掉的猪
int p = 0;
for (int j = 1; j < n; j++)
if (!((i >> j) & 1)) { // 检查第j位是否为0
p = j; break;
}
// 尝试用一只小鸟打掉这只猪 p
// 这只小鸟可以顺便打掉另一只猪 j
for (int j = 0; j < n; j++) {
if (p != j && x[p] == x[j]) continue;
int nxt = i | c[p][j]; // 新状态是当前状态 i 和 c[p][j] 的并集
dp[nxt] = min(dp[nxt], dp[i] + 1); // 状态转移:用更少的鸟数更新到达nxt状态的记录
}
}
printf("%d\n", dp[(1 << n) - 1]);
}
return 0;
}
习题:P3694 邦邦的大合唱站队
解题思路
题目要求“最少出列人数”,这等价于求解最多可以不动的人数,总人数减去它就是答案。
最终的队列中,所有来自同一乐队的偶像都站在一起,形成一个连续的区块,整个队列就是这 \(M\) 个乐队区块的一个排列。例如,如果 \(M=3\),最终队形可能是 [乐队1区块,乐队2区块,乐队3区块],也可能是 [乐队2区块,乐队1区块,乐队3区块] 等等,总共有 \(M!\) 种可能的排列顺序。
一个偶像能够留在原地不动,必须满足其当前的位置恰好在其所属乐队在最终队形里应该在的那个区块内。
需要找到一个最优的乐队排列顺序,使得按照这个顺序形成的最终队形,能够容纳最多数量的原地不动的偶像。
由于乐队数量 \(M\) 很小(\(\le 20\)),而 \(M!\) 非常大,不能暴力枚举所有乐队的排列。这是一个典型的旅行商问题的变种,可以使用状态压缩动态规划来解决。
定义 \(f_S\) 为:已经将 \(S\) 所代表的乐队集合排好序,形成一个连续的区块后,最多能够停留在原位的偶像数量。
为了计算 \(f_S\),可以考虑这个区块是如何形成的。它一定是由一个较小的区块(\(S\) 中除掉一个乐队 \(i\) 之外的所有乐队)再加上乐队 \(i\) 的区块形成的。
枚举 \(S\) 中的每一个乐队 \(i\),假设它是这个区块中最后一个被排列的乐队。那么,在它之前排列的乐队集合是 \(P = S - \{ i \}\),这些 \(P\) 中的乐队已经占据了队列的前 \(t_{P}\) 个位置(其中 \(t\) 是预处理出的乐队规模之和)。因此,为乐队 \(i\) 分配的位置区间为 \([t_P + 1, t_S]\)。只需要计算,在原队列的这个区间里,有多少个成员本来就属于乐队 \(i\)。这些人就是当乐队 \(i\) 作为最后一个区块时,能够新增的“不动”的人,这个数量可以通过预处理前缀和来快速查询。
状态转移方程:\(f_S = \max \{ f_P + \text{stay}_i \}\),其中 \(i\) 是 \(S\) 中的一个乐队,\(P = S - \{i\}\),\(\text{stay}_i\) 是在为 \(i\) 分配的区间内,原队列中 \(i\) 乐队成员的数量,对所有 \(i \in S\) 取最大值。
参考代码
#include <cstdio>
#include <vector>
#include <algorithm>
using namespace std;
const int N = 1e5;
const int M = 20;
int a[N + 1]; // 存储输入的原始队列
int cnt[M]; // cnt[i] 存储乐队 i 的总人数
int prefix_sum[M][N + 1]; // prefix_sum[i][j] 存储前 j 个人中乐队 i 的人数
int total_size[1 << M]; // total_size[mask] 存储 mask 中所有乐队的总人数
int dp[1 << M]; // dp[mask] 存储在排好 mask 中乐队后,最多不动的人数
int main() {
int n, m;
scanf("%d%d", &n, &m);
for (int i = 1; i <= n; ++i) {
scanf("%d", &a[i]);
a[i]--; // 将乐队编号转为 0-indexed,方便位运算
cnt[a[i]]++;
}
// 1. 预处理前缀和
for (int i = 0; i < m; ++i) {
for (int j = 1; j <= n; ++j) {
prefix_sum[i][j] = prefix_sum[i][j - 1] + (a[j] == i);
}
}
// 2. 预处理 total_size[mask]
for (int mask = 1; mask < (1 << m); ++mask) {
// 找到 mask 中最低位的 1 对应的乐队编号 (lsb: least significant bit)
int lsb_idx = 0;
for (int i = 0; i < m; ++i) {
if ((mask >> i) & 1) {
lsb_idx = i;
break;
}
}
int prev_mask = mask ^ (1 << lsb_idx);
total_size[mask] = total_size[prev_mask] + cnt[lsb_idx];
}
// 3. 动态规划
// 遍历所有乐队子集 mask
for (int mask = 1; mask < (1 << m); ++mask) {
// 遍历 mask 中的每个乐队 i,假设 i 是最后一个被排列的乐队
for (int i = 0; i < m; ++i) {
if ((mask >> i) & 1) {
// 在 j 加入之前,已排列的乐队集合是 prev_mask
int prev_mask = mask ^ (1 << i);
// 计算为乐队 i 分配的区间 [start_pos+1, end_pos]
int start_pos = total_size[prev_mask];
int end_pos = total_size[mask];
// 计算该区间内,原队列中本就属于乐队 i 的人数
int stay_i = prefix_sum[i][end_pos] - prefix_sum[i][start_pos];
// 状态转移:尝试用 i 作为最后一个乐队来更新 dp[mask] 的最优解
dp[mask] = max(dp[mask], dp[prev_mask] + stay_i);
}
}
}
// 最多可以不动的人数
int max_stayers = dp[(1 << m) - 1];
// 最少需要移动的人数 = 总人数 - 最多不动的人数
int min_movers = n - max_stayers;
printf("%d\n", min_movers);
return 0;
}
例题:P3959 [NOIP 2017 提高组] 宝藏
这是一个构建最小代价生成树的问题,但边的代价与节点的深度有关,这使得传统的 Prim 或 Kruskal 算法不再适用,需要找到一个最优的根节点和最优的树结构。
\(n\) 的范围非常小(\(n \le 12\)),这是使用状态压缩动态规划的强烈信号。
可以尝试一层一层地构建这棵树,整个 DP 的核心思想是,将节点集划分为“已连接”和“未连接”两部分,然后计算将一批“未连接”的节点连接到“已连接”的节点上,作为树的新的一层,所产生的最小代价。
定义 \(f_{s,h}\) 表示已连接的节点集合为 \(s\),且所构成生成树的深度为 \(h\)(这里假设只有一个根节点是 \(0\) 层),所需要的最小总代价。
当两个部分连接时,涉及到一个代价。用 \(d_{s_1,s_2}\) 表示将 \(s_2\) 集合中的所有节点,连接到 \(s_1\) 集合上的最小花费(不包括深度乘数)。这个花费的计算方式是,对于 \(s_2\) 中的每一个节点 \(p\),找出 \(s_1\) 中与它相连的、边权最小的节点 \(k\),然后将这些最小边权全部加起来,即 \(d_{s_1,s_2}=\sum\limits_{p \in s_2} (\min\limits_{k \in s_1} \{ \text{dist}(p,k) \})\)。
这个 \(d\) 数组可以通过 \(O(n \cdot 3^n)\) 的复杂度预处理出来。
\(f\) 数组一开始全部初始化为无穷大,而对于任意一个节点 \(i\),它都可以作为根节点,当树中只有一个根节点 \(i\) 时,深度为 0(按照上文定义),代价为 0。所以,\(f_{\{i\},0} = 0\),其中 \(i\) 从 \(0\) 到 \(n-1\)。
由浅到深、由小到大地构建树,枚举树的层数 \(h\),已连接的节点集为 \(s\),以及 \(s\) 的子集 \(\text{sub}\)。
这里的关键在于理解 \(f_{s,h}\) 的含义和如何计算它。
\(f_{s,h}\) 代表连接集合 \(s\),深度为 \(h\) 的最小代价。这种情况是通过一个深度为 \(h\) 的树(节点集为 \(s_{\text{prev}}\)),连接上一批新的节点(节点集为 \(\text{sub}\))作为第 \(h\) 层而形成的,新连接的边的新端点深度为 \(h\),所以代价乘数为 \(h\)。
\(f_{s,h} = \min \{ f_{s_{\text{prev}}, h-1} + h \cdot d_{s_{\text{prev}}, \text{sub}} \}\)
当所有节点都被连接后,集合为 \(\{ 1,2,\dots,n \}\)。由于不知道最优树的深度是多少,所以需要遍历所有可能的深度 \(h\),取其中的最小值。
总时间复杂度为 \(O(n \cdot 3^n)\),对于 \(n=12\),\(12 \cdot 3^{12} \approx 6.4 \times 10^6\),可以在时间限制内完成。
参考代码
#include <cstdio>
#include <algorithm>
#include <vector>
using namespace std;
const int N = 12;
const int INF = 1e9;
// a: 邻接矩阵存图; f: DP数组; d: 预处理花费数组; Log2: 预处理log2值
int a[N][N], f[1 << N][N], d[1 << N][1 << N], Log2[1 << N];
int lowbit(int x) {
return x & -x;
}
int main()
{
int n, m; scanf("%d%d", &n, &m);
// 预处理 Log2[2^i] = i,用于快速查找一个数最低位的1是在第几位
for (int i = 0; i < n; i++) {
Log2[1 << i] = i;
}
// 初始化邻接矩阵
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
a[i][j] = (i == j ? 0 : INF);
}
}
for (int i = 1; i <= m; i++) {
int x, y, v; scanf("%d%d%d", &x, &y, &v);
x--; y--;
a[x][y] = a[y][x] = min(a[x][y], v);
}
// 预处理d[i][j]: 表示已连通集合i到未连通集合j的最小连接花费(不含深度乘数)
for (int i = 1; i < (1 << n); i++) {
int sup = ((1 << n) - 1) ^ i; // sup是i的补集
vector<int> sub_vec; // 用vector存储子集是为了按从小到大的顺序计算
for (int j = sup; j > 0; j = (j - 1) & sup) {
sub_vec.push_back(j);
}
reverse(sub_vec.begin(), sub_vec.end());
for (int j : sub_vec) {
int p = Log2[lowbit(j)]; // 取出j中的标号最小元素p
int mindis = INF;
// 找到p到集合i的最短直接连接距离
for (int k = 0; k < n; k++) {
if ((i >> k) & 1) {
mindis = min(mindis, a[p][k]);
}
}
// 通过递推计算d[i][j]
if (d[i][j ^ lowbit(j)] != INF && mindis != INF) {
d[i][j] = d[i][j ^ lowbit(j)] + mindis;
} else {
d[i][j] = INF;
}
}
}
// 初始化DP数组
for (int i = 0; i < (1 << n); i++) {
for (int j = 0; j < n; j++) {
f[i][j] = INF;
}
}
// Base Case: 任何一个单点都可以作为根,代价为0,层数为0(深度为1)
for (int i = 0; i < n; i++) {
f[1 << i][0] = 0;
}
// DP主循环
// h: 父节点的深度,也作为代价乘数
for (int h = 1; h < n; h++) {
// s: 当前已连接的节点集合
for (int s = 1; s < (1 << n); s++) {
// sub: s的子集,作为新加入的节点集合
for (int sub = (s - 1) & s; sub > 0; sub = (sub - 1) & s) {
int s_prev = s ^ sub; // s_prev 是之前的节点集合
if (f[s_prev][h - 1] != INF && d[s_prev][sub] != INF) {
// 状态转移:尝试用一个层数为h-1的树,加上一个新层sub,来更新s的最小代价
f[s][h] = min(f[s][h], f[s_prev][h - 1] + h * d[s_prev][sub]);
}
}
}
}
// 最终答案:遍历所有节点都被连接的状态,在所有可能的深度中取最小值
int ans = INF;
for (int i = 0; i < n; i++) {
ans = min(ans, f[(1 << n) - 1][i]);
}
printf("%d\n", ans);
return 0;
}

浙公网安备 33010602011771号