状压 + 覆盖
愤怒的小鸟·宝藏
本课摘要
状压 + 覆盖课程回答“几何覆盖选择怎样预处理成可转移的状态集合”。内容以愤怒的小鸟·宝藏为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。
- 判断状压 + 覆盖的适用条件与状态边界
- 围绕“愤怒的小鸟·宝藏”推导转移与计算顺序
- 用演示、复杂度分析和配套题目校验实现
本课目录 · 0 节
正在整理目录…
当「一步」能盖住一批元素
TSP 里,走一步只到达一个新点。但很多问题里,一次「选择」能一口气覆盖一批元素:一条抛物线砸下去打掉好几只猪、按一个开关翻转好几盏灯、修一条路连通好几个城市。目标不再是「排好顺序」,而是「用最小代价把全部元素覆盖掉」。
先看一个抽象的小例子:全集有 5 个元素 ,有若干「选择」,每个选择覆盖其中一部分、各有代价。要选出一组选择,让它们的覆盖并起来等于全集,总代价最小。这就是集合覆盖——它是 NP 难的,但当元素个数 时,状压给出可行解。
关键的预处理:把每个选择「覆盖了哪些元素」压成一个 mask。于是「加入一个选择」就是把当前已覆盖集合 和这个选择的 mask 做按位或—— 只会变大或不变,永远单调朝全集靠拢。「覆盖满」就是 。
状态与转移:dp[S] = 覆盖 S 的最小代价
定状态。这里的集合 含义变了——不再是 TSP 的「已访问点」,而是「已被覆盖的元素」。设 = 让 里所有元素都被覆盖所需的最小代价。注意状态只有一维,没有「当前点」——因为覆盖问题不关心顺序。
转移。从 出发,选第 个选择(覆盖 mask 记 、代价 ),新覆盖集合是 :
边界:(什么都没覆盖,代价 0),其余 。答案:。按 从小到大枚举即可,因为 ,依赖的子状态先算好。
本质
状压把「覆盖进度」编码成一个整数:「还差哪些没盖」一目了然,「加一个选择」就是一次按位或。TSP 的 关心「停在哪」,覆盖的 只关心「盖到哪」——同样是 个集合状态,少一维。预处理每个选择的覆盖 mask,是这类题的题眼。
跟着算一遍
用 4 个元素的小例子:选择 A 覆盖 代价 2、B 覆盖 代价 2、C 覆盖全部 代价 5。看 怎么填:
看覆盖一步步填满全集
- 来源参与当前转移的依赖状态
- 当前正在计算或观察的状态
- 确定 / 最优已进入当前最优解或确定结果
- 已处理已经扫描、当前不再活跃的状态
- 非法越界、冲突或不可达状态
状压不止 TSP:从覆盖到「逐层生成树」
集合覆盖让我们看清:状压的 可以是任何「一批东西的选取状态」,转移的核心是用按位或把 变大。顺着这条路,「宝藏」(P3959)把状压推得更远——它求的是一棵生成树的最小代价,边权 = 深度 × 长度。
它的状态是 :已经连成的点集为 、当前生成树最大深度为 的最小代价。转移时,从已连通的 向外「长一层」——枚举 补集的一个子集作为新接入的点,每个新点用「它到 的最短边 × 当前深度」计费。这里既用到覆盖式的按位或扩展,又要枚举子集(下一类的核心技巧)。
常见陷阱:覆盖 mask 的预处理别算错、别漏
愤怒的小鸟里,两点定一条抛物线要求横坐标不同、开口朝下(),还要用浮点误差 判点是否落在线上——漏判或精度不当会让某条线的覆盖 mask 出错,答案随之全错。稳妥的骨架是:先固定「第一只还没打的猪」 ,再枚举过 的所有抛物线去覆盖,避免重复与遗漏。
例题
#include <iostream>
#include <cstring>
#include <cmath>
#include <algorithm>
using namespace std;
const double EPS = 1e-6;
int T, n, m;
double X[20], Y[20];
int line[20][20]; // line[i][j]:过点 i、j 的抛物线能打掉的猪 mask
int f[1 << 18];
int main()
{
cin >> T;
while (T--)
{
cin >> n >> m;
for (int i = 0; i < n; i++) cin >> X[i] >> Y[i];
memset(line, 0, sizeof line);
for (int i = 0; i < n; i++)
for (int j = 0; j < n; j++)
{
if (fabs(X[i] - X[j]) < EPS) continue; // 竖直,无法定抛物线
// 由 (X[i],Y[i])、(X[j],Y[j]) 解 y=a x^2 + b x(过原点)
double a = (Y[i] / X[i] - Y[j] / X[j]) / (X[i] - X[j]);
double b = Y[i] / X[i] - a * X[i];
if (a > -EPS) continue; // 开口必须朝下
int s = 0;
for (int k = 0; k < n; k++) // ★这条线覆盖哪些猪
if (fabs(a * X[k] * X[k] + b * X[k] - Y[k]) < EPS)
s |= (1 << k);
line[i][j] = s;
}
memset(f, 0x3f, sizeof f);
f[0] = 0;
for (int S = 0; S < (1 << n); S++)
{
if (f[S] == 0x3f3f3f3f) continue;
int p = 0;
while (p < n && (S >> p & 1)) p++; // 找第一只没打的猪 p
if (p == n) continue;
f[S | (1 << p)] = min(f[S | (1 << p)], f[S] + 1); // 单点一发
for (int j = 0; j < n; j++) // 选一条过 p 的抛物线
f[S | line[p][j]] = min(f[S | line[p][j]], f[S] + 1);
}
cout << f[(1 << n) - 1] << endl;
}
return 0;
}#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
int n, m;
int road[15][15]; // 两点间道路长度(无边为 INF)
int cost[1 << 12][15]; // cost[S][j]:从集合 S 向外接一步到 j 的最小边权
int f[13][1 << 12]; // f[dep][S]:已连成集合 S、最大深度 dep 的最小代价
int main()
{
memset(road, 0x3f, sizeof road);
cin >> n >> m;
for (int i = 0; i < m; i++)
{
int a, b, c; cin >> a >> b >> c;
a--; b--;
road[a][b] = road[b][a] = min(road[a][b], c);
}
// 预处理:集合 S 之外的点 j,到 S 的最短单边
for (int S = 0; S < (1 << n); S++)
for (int j = 0; j < n; j++)
{
if (S >> j & 1) continue;
int mn = 0x3f3f3f3f;
for (int i = 0; i < n; i++)
if ((S >> i & 1) && road[i][j] < mn) mn = road[i][j];
cost[S][j] = mn;
}
memset(f, 0x3f, sizeof f);
for (int i = 0; i < n; i++) f[1][1 << i] = 0; // 任一点单独作根,深度 1
for (int dep = 2; dep <= n; dep++)
for (int S = 1; S < (1 << n); S++)
{
if (f[dep - 1][S] == 0x3f3f3f3f) continue;
int rest = ((1 << n) - 1) ^ S; // S 外的点
// ★枚举 rest 的非空子集 sub,作为这一层新接入的点
for (int sub = rest; sub; sub = (sub - 1) & rest)
{
int w = 0; bool ok = true;
for (int j = 0; j < n; j++)
if (sub >> j & 1)
{
if (cost[S][j] == 0x3f3f3f3f) { ok = false; break; }
w += cost[S][j]; // 每个新点边权 × 当前深度
}
if (!ok) continue;
f[dep][S | sub] = min(f[dep][S | sub],
f[dep - 1][S] + w * (dep - 1));
}
}
int ans = 0x3f3f3f3f;
for (int dep = 1; dep <= n; dep++)
ans = min(ans, f[dep][(1 << n) - 1]);
cout << ans << endl;
return 0;
}
