前面几节的 DP 状态都是一个下标或一对下标。这一节的两种 DP 都换了“状态的形状”:状压把“用过哪些东西”压进一个 int 的二进制位;数位 DP把数字当成字符串,从高位往低位一位一位填。记忆化的套路完全一样,只是“状态长什么样”变了。
| 状态压缩 DP | 数位 DP | |
|---|---|---|
| 状态 | 一个 int 的低 n 位表示“选过哪些元素” | 填到第几位、上一位是什么、有没有顶到上界 |
| 转移 | 往集合里加 / 删一个元素,或把集合拆成两个子集 | 这一位填 0..9,往低位递推 |
| 顺序 | S 从小到大(新状态一定更大) | 记忆化 dfs,从高位往低位 |
| 规模 | 2ⁿ × n,n ≤ 20 | 位数 × 附加状态 × 10,一般不到 10⁴ |
| 典型题 | TSP、关灯、棋盘放置 | 区间内满足数位条件的计数 |
这一节的全部代码都建立在位运算上。三个工具先拆开:位运算本身、数 1 的个数、枚举子集。
| 组成 | 含义 / 格式要求 |
|---|---|
(x >> k) & 1 | 取第 k 位(k 从 0 数),结果一定是 0 或 1。直接写 x & (1<<k) 得到的是 0 或 2ᵏ,当布尔用还行,拿去做下标就会越界。 |
x | (1 << k) | 置 1:把 k 加入集合。本来就在也不会出错,所以可以无脑用。 |
x & ~(1 << k) | 清 0:把 k 移出集合。不能写 x − (1<<k),那只在该位本来是 1 时才对。 |
x ^ (1 << k) | 翻转:0↔1。只适合“按一次就取反”的效果,别拿去当删除用。 |
x & (x − 1) | 去掉最低位的 1。可以用来数 1 的个数,也能判 2 的幂:x && !(x & (x−1))。 |
x & (−x) | lowbit:单独取出最低位的 1,树状数组靠它定位。 |
优先级 | x & 1 == 0 是错的:== 先算,等价于 x & (1==0) = 0,条件恒假。必须写 (x & 1) == 0。 |
1 << k 的类型 | 字面量 1 是 int,k ≥ 31 时溢出(未定义行为)。数组下标用不到那么大,但做哈希或乘法时要写 1LL << k。 |
表示范围 | n = 15 时掩码最大 2¹⁵−1 = 32767,int 绰绰有余;n ≥ 31 就得换 unsigned 或 bitset。 |
#include <bits/stdc++.h>
using namespace std;
int main() {
int S = 0b0101; // 集合 {0, 2}
S |= 1 << 3; // 加入 3 → 0b1101
S &= ~(1 << 0); // 移出 0 → 0b1100
printf("%d\n", (S >> 2) & 1); // 第 2 位:1
printf("%d\n", S & (S - 1)); // 去掉最低位的 1 → 0b1000 = 8
for (int T = S; T; T &= T - 1) // 逐位遍历所有 1
printf("%d ", __builtin_ctz(T));// 输出 2 3
return 0;
}掩码的位是从 0 数的:第 0 位对应第 1 个元素。所有“差一位”的 bug 都从这里来,写完先打印一次 2ⁱ 对照。
用 unsigned 做位运算可以避开符号位的麻烦;本节 n ≤ 15,int 不会有问题。
| 组成 | 含义 / 格式要求 |
|---|---|
__builtin_popcount(S) | 返回 S 的二进制里 1 的个数,也就是集合大小。状压里常用来按集合大小分层,或判断 S 是否只有一个元素。 |
参数是 unsigned | 传负数会按 2³² 补码解释,结果大得离谱。只在非负掩码上用。 |
__builtin_popcountll | 数 64 位掩码用这个,多两个字母 l 表示 long long。传 int 进 popcountll 也没问题。 |
__builtin_ctz(S) | count trailing zeros:末尾 0 的个数,正好是最低位 1 的下标。S = 0 时未定义,调用前必须保证 S ≠ 0。 |
__builtin_clz(S) | 前缀 0 的个数,配合 31 可以反推最高位 1 的位置。同样要求 S ≠ 0。 |
复杂度 | 有 popcnt 指令时是 1 条指令;没有时是几次位运算。与位数无关,放进最内层循环也不怕。 |
手写等价 | pc[S] = pc[S >> 1] + (S & 1),先算小的 S,就能 O(1) 算出 0..2ⁿ−1 的全部 popcount。 |
可移植性 | 这是 GCC 扩展,洛谷用 GCC 可以直接用;换 MSVC 要写 __popcnt,比赛前先确认编译器。 |
#include <bits/stdc++.h>
using namespace std;
int main() {
for (int S = 0; S < 8; ++S)
printf("%d -> %d\n", S, __builtin_popcount(S)); // 0..3 个 1
printf("%d\n", __builtin_popcountll(1LL << 40)); // 1(64 位里只有一个 1)
printf("%d\n", __builtin_ctz(12)); // 2(12 = 0b1100)
int pc[8]; // 手写版:一遍递推
pc[0] = 0;
for (int S = 1; S < 8; ++S) pc[S] = pc[S >> 1] + (S & 1);
printf("%d\n", pc[7]); // 3
return 0;
}popcount 只回答“有几个”,不回答“是哪些”。要取元素本身,用 __builtin_ctz 或 lowbit 循环。
别在循环条件里反复调用 __builtin_popcount 去判断集合大小,先在状态设计上分层更快。
| 组成 | 含义 / 格式要求 |
|---|---|
sub | 依次取出 mask 的每一个非空子集,按数值从大到小。 |
sub = (sub − 1) & mask | 核心一句。sub−1 把最低位的 1 变 0、更低位全变 1;再 & mask 把不属于 mask 的位清掉,得到逻辑上的“下一个更小的子集”。 |
循环条件写 sub | 不要写 sub >= 0:sub 变 0 时循环自动结束,所以循环体里永远见不到空集。 |
要空集怎么办 | 循环外单独处理 sub = 0,或写成 for (int sub = mask;; sub = (sub−1) & mask) { ...; if (!sub) break; }。 |
总次数 | 对所有 mask 求和是 Σ 2^popcount(mask) = 3ⁿ。n = 15 时 1435 万次,可以;n = 20 时 3.5×10⁹,不行。 |
典型用途 | 子集 DP:把当前集合拆成 sub 与 mask^sub 两半各自最优再合并,例如集合划分、最小覆盖。 |
#include <bits/stdc++.h>
using namespace std;
int main() {
int mask = 0b1011; // 集合 {0, 1, 3}
for (int sub = mask; sub; sub = (sub - 1) & mask)
printf("%d(%d) ", sub, __builtin_popcount(sub));
// 输出:11(3) 10(2) 9(2) 8(1) 3(2) 2(1) 1(1)
printf("\n");
return 0;
}sub = (sub−1) & mask 里的 mask 是母集,不是 sub。写成 (sub−1) & sub 就是普通的“去掉最低位 1”,只得到单条链,漏掉绝大多数子集。
这个循环的状态数只有 3ⁿ 而不是 4ⁿ,是因为每个元素只有“在 mask 外 / 在 sub 里 / 在 mask 但不在 sub 里”三种身份。MATH 里有推导。
第一道题是状压 DP 的本体:n 个东西、每个只能取一次、要排一个最优顺序。先想清楚“暴力为什么必死”,再想“哪一部分信息其实没用”。
房间里放着 n 块奶酪,一只小老鼠要把它们都吃掉,问至少要跑多少距离?老鼠一开始在 (0,0) 点处。
数据范围:1 ≤ n ≤ 15,|xᵢ|,|yᵢ| ≤ 200(实数,小数点后最多 3 位)。
输入格式:第一行整数 n;第 2 到第 n+1 行,每行两个实数 xᵢ、yᵢ,表示第 i 块奶酪的坐标。
输出格式:一行一个实数,最少距离,保留 2 位小数。
先算暴力。奶酪的吃法有 n! 种,n = 15 时约 1.3×10¹²。每条路都要算 15 段距离,直接死刑。
再看信息量。走到中途时,“接下来怎么走最省”只取决于两件事:还有哪些没吃(集合),以及现在站在哪(最后吃的那块)。至于之前是什么顺序吃的,与未来完全无关——这就是可以合并的状态。
集合怎么存。n ≤ 15,用一个 int 的低 n 位表示:第 i 位为 1 表示第 i 块已经吃过。全部吃完是 full = (1<<n) − 1,状态总数 2¹⁵ = 32768。
状态与转移。f[S][i]:已吃集合是 S、最后一口是 i 的最小路程。从 (S,i) 出发,挑一块没吃的 j,转移 f[S | 1<<j][j] = min(…, f[S][i] + d(i,j))。
为什么不用管顺序。新状态 S | (1<<j) 的数值一定大于 S(多了一个 1),所以按 S 从小到大枚举,每个状态在被用到之前一定已经算完。这就是状压 DP 不需要拓扑排序的原因:二进制数值本身就是拓扑序。
规模。状态 2¹⁵ × 15 = 491520,每状态转移 15 次 → 7.4×10⁶ 次加法。开方只在预处理两两距离时做,所以整个程序在一瞬间跑完。
#include <bits/stdc++.h>
using namespace std;
int n;
double x[16], y[16];
double d[16][16]; // d[i][j]:编号 i 与 j 的直线距离
double f[1 << 15][15]; // f[S][i]:已吃集合 S、最后一口在 i 的最小路程
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n;
x[0] = y[0] = 0; // 0 号点就是原点,当作起点
for (int i = 1; i <= n; ++i) cin >> x[i] >> y[i];
for (int i = 0; i <= n; ++i) // 两两距离只跟坐标有关,先算成表
for (int j = 0; j <= n; ++j) {
double dx = x[i] - x[j], dy = y[i] - y[j];
d[i][j] = sqrt(dx * dx + dy * dy); // 开方只做 (n+1)² 次,别放进转移里
}
int full = (1 << n) - 1; // 全部吃完的集合
for (int S = 0; S <= full; ++S)
for (int i = 0; i < n; ++i) f[S][i] = 1e18; // 求最小,INF 要足够大
for (int i = 0; i < n; ++i)
f[1 << i][i] = d[0][i + 1]; // 第一口:从原点直接过去(注意 +1 的下标错位)
for (int S = 1; S <= full; ++S) // S 递增天然就是拓扑序
for (int i = 0; i < n; ++i) {
if (!(S >> i & 1)) continue; // i 不在集合里,这个状态无意义
for (int j = 0; j < n; ++j) {
if (S >> j & 1) continue; // j 已经吃过,不能重复吃
int T = S | (1 << j); // 新集合:把 j 加进去
double nd = f[S][i] + d[i + 1][j + 1];
if (nd < f[T][j]) f[T][j] = nd; // 松弛
}
}
double ans = 1e18;
for (int i = 0; i < n; ++i) ans = min(ans, f[full][i]);
printf("%.2f\n", ans);
return 0;
}| 代码片段 | 含义与理由 |
|---|---|
x[0] = y[0] = 0 | 把起点也放进坐标数组的第 0 号位置,于是“奶酪编号”统一变成“坐标编号 −1”,转移时只需要 +1,不必到处写特判。 |
d[i][j] = sqrt(dx*dx + dy*dy) | 两两距离是定值。放进转移里算会多做 7×10⁶ 次开方,白白慢十倍。 |
double f[1 << 15][15] | 2¹⁵ × 15 × 8 字节 ≈ 3.9 MB。放全局(静态区),别放 main 里,否则可能爆栈。 |
f[S][i] = 1e18 | 求最小值必须初始化为极大的数。1e18 大于任何合法答案,又不会和别的数相加后溢出成负数。 |
f[1 << i][i] = d[0][i + 1] | 只吃一块的初始状态。i 是 0 基的奶酪下标,坐标数组是 1 基,这个 +1 是全题最容易错的地方。 |
if (!(S >> i & 1)) continue; | S 里没有 i,f[S][i] 这个状态根本没有定义,跳过。 |
int T = S | (1 << j); | 加入 j。因为 T > S,外层 S 从小到大枚举时,T 一定在 S 之后才被用到。 |
S >> j & 1 | 等价于 (S >> j) & 1:>> 的优先级高于 &,所以不用加括号也是对的;但为了可读性建议加。 |
printf("%.2f\n", ans) | 题目要求 2 位小数。最多累加 15 次,double 的误差远小于 0.005,不需要 long double。 |
第二类问题换了个问法:不问“最优”,问“有多少个”。n 可以到 10¹⁸,但位数只有 19 位——所以不枚举数,枚举“填法”。
| 组成 | 含义 / 格式要求 |
|---|---|
pos | 还剩几位没填。从最高位往低位填,pos == 0 表示所有位都填完,返回 1(这一条填法算一个合法数)。 |
limit | 是否贴着上界。为真时这一位最多填到 a[pos];为假时 0..9 全放开。 |
lead | 前缀是否全为 0(还没开始真正的数字)。为真时不检查“上一位”,也不把前导零算进数字里。 |
int up = limit ? a[pos] : 9; | limit 唯一的作用点。这一行决定了整棵搜索树的宽度。 |
limit && d == up | 只有本来就顶格、且这一位也顶到上界,下一位才继续顶格。写反或漏写 → 统计到超过 n 的数。 |
lead && d == 0 | 一直填 0 才是前导零。一旦填了非零数字,lead 永久变 false,之后再出现的 0 就是真正的 0。 |
!limit && !lead 才记忆 | 只有“自由身”的状态才与具体上界无关,可以共用答案。带 limit 的状态依赖 n 的某几位,一共只有 O(位数) 个,不记也不亏。 |
f 用 −1 做哨兵 | 用 0 分不清“没算过”和“答案是 0”。所以记忆化数组要 memset 成 −1。 |
#include <bits/stdc++.h>
using namespace std;
int a[15]; // n 的各位,低位在下标 1
long long f[15]; // f[pos]:还剩 pos 位、不顶格不限前导零时的填法数
long long dfs(int pos, bool limit, bool lead) {
if (pos == 0) return 1; // 填完了,一条填法
if (!limit && !lead && f[pos] != -1) return f[pos]; // 自由身才查表
int up = limit ? a[pos] : 9; // 顶格只能到上界
long long res = 0;
for (int d = 0; d <= up; ++d)
res += dfs(pos - 1, limit && (d == up), lead && (d == 0));
if (!limit && !lead) f[pos] = res; // 自由身才写表
return res;
}
int main() {
int n = 123, len = 0;
for (int t = n; t; t /= 10) a[++len] = t % 10; // 拆位,低位在前
memset(f, -1, sizeof f);
cout << dfs(len, true, true) << "\n"; // 124 = n + 1,因为 0 也被数进去了
return 0;
}这个骨架不限于“数数”,把出口和“这一位能不能填 d”的判断换掉,就是不同的题。
lead 不是可有可无的装饰:没有它,首位会被拿去和“上一位 = 0”比较,正确答案会差一大截。
不含前导零、且相邻两个数字之差至少为 2 的正整数称为 windy 数。例如 1、13、24 是,而 12、10 不是。
给定 a 和 b,求区间 [a, b] 内 windy 数的个数。
数据范围:1 ≤ a ≤ b ≤ 2×10⁹。
输入格式:一行两个整数 a、b。
输出格式:一行一个整数,表示答案。
区间转前缀。记 solve(n) 为 [0, n] 内 windy 数的个数,答案就是 solve(b) − solve(a−1)。把“区间”变成“前缀”,是把上界从两个降到一个,这一步是所有数位 DP 的固定开场。
逐位确定。把 n 的各位拆出来,从最高位往低位填。填到某一位时只有两种处境:前缀和 n 一模一样(limit = true,这一位最多填到 n 的对应位),或者前缀已经比 n 小了(limit = false,这一位 0..9 随便填)。
为什么还要 lead。windy 数不允许前导零。更实际的问题是:首位没有“上一位”,没法算 |d − prev|。lead = true 表示前缀全 0、真正的数字还没开始,这时跳过相邻差检查,也可以继续填 0。
状态与记忆化。附加状态只有 prev(上一位数字),于是 f[pos][prev] 表示“还剩 pos 位、上一位是 prev、且不顶格不限前导零”的合法填法数。带 limit 或 lead 的状态不记忆——它们依赖具体前缀,数量只有 O(位数) 个。
规模。状态 10 × 10 = 100 个,每个转 10 次 → 一千次出头。b 到 2×10⁹(10 位)也能瞬间跑完,这正是数位 DP 存在的意义:复杂度只看位数,不看数值大小。
0 的处理。上面这套会顺带把“0”数进去(全填 0 也算一条填法)。因为 a ≥ 1,solve(b) 和 solve(a−1) 各含 0 一次,相减时正好抵消,不需要额外特判。
#include <bits/stdc++.h>
using namespace std;
int a[15]; // n 的各位,a[1] 是个位
long long f[15][10]; // f[pos][prev]:还剩 pos 位、上一位是 prev 的自由填法数
// pos:还剩几位;prev:上一位数字;limit:是否贴住上界;lead:前缀是否全 0
long long dfs(int pos, int prev, bool limit, bool lead) {
if (pos == 0) return 1; // 每一位都填完了
if (!limit && !lead && f[pos][prev] != -1) return f[pos][prev];
int up = limit ? a[pos] : 9; // 顶格时最多到上界那一位
long long res = 0;
for (int d = 0; d <= up; ++d) {
if (!lead && abs(d - prev) < 2) continue; // 不是首位,且与上一位差不足 2
res += dfs(pos - 1, d, limit && (d == up), lead && (d == 0));
}
if (!limit && !lead) f[pos][prev] = res; // 只有自由身才写表
return res;
}
long long solve(int n) {
if (n < 0) return 0;
int len = 0;
while (n) { a[++len] = n % 10; n /= 10; }
return dfs(len, 0, true, true); // n = 0 时 len = 0,dfs 立刻返回 1,正好把 0 数上
}
int main() {
int A, B;
cin >> A >> B;
memset(f, -1, sizeof f);
cout << solve(B) - solve(A - 1) << "\n"; // 0 在两边各出现一次,相减自动抵消
return 0;
}| 代码片段 | 含义与理由 |
|---|---|
while (n) { a[++len] = n % 10; n /= 10; } | 取模得到最低位,所以 a[1] 是个位、a[len] 是最高位。dfs 从 len 往 1 走,就是“从高位往低位填”。 |
dfs(len, 0, true, true) | 四个初值各有含义:还剩 len 位、上一位随便给 0(反正 lead 为真、不用它)、贴着上界、还是前导零。 |
if (pos == 0) return 1; | 所有位填完,累计出一个合法数。返回 1 表示“这里有一种填法”,父亲把它累加进 res。 |
if (!limit && !lead && f[pos][prev] != -1) | 查表条件必须和写表条件完全一致。只查不写是纯暴力,只写不查会污染答案,两者都慢或错。 |
int up = limit ? a[pos] : 9; | 顶格时这一位最多填 a[pos];一旦不顶格,后面的位再大也不会超过 n,所以全放开。 |
if (!lead && abs(d - prev) < 2) continue; | windy 的定义。lead 为真时跳过,因为前面还没有真正的数字可比。 |
limit && (d == up) | 两个条件缺一不可:本来就得顶格,且这一位刚好填到上限,下一位才继续受限制。 |
if (!limit && !lead) f[pos][prev] = res; | 只在自由身时写表。把顶格状态也写进去,后面自由状态会查到错值 → WA,而且样例可能照样过。 |
状压的三处可换零件:①集合里放什么(吃过哪些 / 每列放到第几行 / 哪些点已连通);②转移动作(加一个元素、换一个元素、把集合拆成两半);③答案取哪个状态(全部吃完 / 某个终点 / 所有状态的极值)。
数位 DP 的三处可换零件:①附加状态(上一位是什么、数字和、是否出现过某数字、是否已有连续 3 个相同);②出口返回什么(个数 1、和、还是要返回一对值);③非法填法在哪里剪掉(循环里 continue 最快)。
带“前导零”和“上界”的题一定有两个坑:lead 传错会让首位被误判;limit 记忆化会让答案被一个“贴着上界”的错值污染。写完先拿 solve(9)、solve(10) 这类小值手动对一遍。
什么时候用枚举子集:当转移需要“把当前集合分成两块、各自最优再合并”时。如果题目只是“一个一个往里加元素”,那就是普通的 2ⁿ·n 状压,用不上 3ⁿ 的那层循环。
掩码位记错一位(把第 i 个元素写成 1<<(i+1)):WA 或答案少一半;n = 15 时还可能访问 1<<15 这一个越界状态 → RE。写完先打印每个单元素掩码对照。
状压 f 没初始化成 INF(全局数组默认 0):min 永远得到 0,输出 0.00 → WA。
把 1<<n 当成常量写死在数组里:n 变了就少一半空间;按 n 的最大值开(如 1<<15),不要按输入。
数位 DP 把带 limit 的状态也写进 f:之后自由状态查到这个被上界限制过的错值 → WA,且小数据往往查不出来。
lead 处理错(该跳过时去比较,或忘记 lead 直接算):前者答案偏小,后者把首位当 0 比较,连 [1,10] 这种样例都会错。
把 1<<k 用在 k ≥ 31 的地方,或给 __builtin_popcount 传负数:前者未定义行为,后者按补码数出一堆 1 → 拿去做数组下标直接 RE。
关掉页面,把 P1433 的完整代码从零敲一遍,n = 4 时确认输出 7.41。
改一改①:把 f[S][i] 的第二维去掉,只留 f[S],然后跑样例。它会输出一个偏小的错值——亲手体验“最后一口在哪”为什么非记不可。
改一改②:把外层循环换成“按 __builtin_popcount(S) 从小到大”枚举(外层 k = 1..n,内层 S,popcount 不等于 k 就 continue),答案不变。这说明真正的依赖关系是“每次只多一个元素”,数值递增只是实现它的最省事写法。
改一改③:在 P2657 的 dfs 开头加一句 ++cnt,输出 cnt 和“状态总数 × 10”对照,亲眼看到它只有几千而不是 2×10⁹。
状压 DP 的费用永远是“状态数 × 每个状态的转移数”。状态数 = 2ⁿ ×(状态里那几维),转移数通常是 n,所以总量是 2ⁿ·n² 或 2ⁿ·n。先把这张表记住:
| n | 2ⁿ | 2ⁿ·n | 3ⁿ | 判决 |
|---|---|---|---|---|
| 10 | 1024 | 1.0×10⁴ | 59049 | 随便做 |
| 15 | 32768 | 4.9×10⁵ | 1.4×10⁷ | 2ⁿ 轻松;3ⁿ 也还行 |
| 18 | 262144 | 4.7×10⁶ | 3.9×10⁸ | 2ⁿ 稳;3ⁿ 超时 |
| 20 | 1048576 | 2.1×10⁷ | 3.5×10⁹ | 2ⁿ·n² 已 4×10⁸,要压常数 |
| 24 | 16777216 | 4×10⁸ | 2.8×10¹¹ | 内存和时间都爆 |
3ⁿ 是怎么来的。枚举子集的总次数是 Σmask 2popcount(mask)。按 popcount 分组:有 C(n,k) 个 mask 含 k 个 1,每个枚举 2ᵏ 个子集,于是
Σk C(n,k)·2ᵏ = (1 + 2)ⁿ = 3ⁿ(二项式定理,取 x = 1、y = 2)
这也解释了 3ⁿ 比 4ⁿ 便宜在哪:所有 (sub, mask) 对是 4ⁿ 种,加上 sub ⊆ mask 这条约束后只剩 3ⁿ 种——每个元素只有三种身份:不在 mask 里、在 mask 里但不在 sub 里、在 sub 里。
另一个常用恒等式。所有子集的元素个数之和 ΣS |S| = n·2n−1:每个元素恰好出现在一半的子集里。所以“对所有子集求大小之和”不需要枚举,直接套公式。
数位 DP 的费用则是另一个量级。状态数 = 位数 × 附加状态,转移固定 10 次。以 windy 为例:10 位 × 10 种 prev = 100 个状态,总转移约 10³ 次。b 从 10⁹ 涨到 10¹⁸ 只是位数从 10 变成 19,费用翻一倍都不到——这就是“复杂度只看位数”的含义。
__builtin_popcount 换成手写递推 pc[S] = pc[S>>1] + (S&1),在 2ⁿ 全部预处理时哪个更快?为什么?x & 1 == 0 错在哪。现有 n 盏灯和 m 个按钮。按下第 i 个按钮会同时作用在所有灯上,对第 j 盏灯的效果 aᵢⱼ 是三种之一:为 1 时,若这盏灯亮着就关掉,否则不动;为 −1 时,若这盏灯灭着就点亮,否则不动;为 0 时不动。
开始时所有灯都是亮的。求最少按几次按钮能让所有灯都灭掉;做不到输出 −1。
输入格式:第一行两个整数 n、m;接下来 m 行,每行 n 个整数,第 i 行第 j 个数是 aᵢⱼ。
输出格式:一行一个整数,表示最少次数,或 −1。
数据范围:1 ≤ n ≤ 10,1 ≤ m ≤ 100。
“所有灯的开关情况”一共只有 2ⁿ 种,n ≤ 10,可以用一个 0..1023 的整数把它表示出来。
每个按钮的效果可以在一开始就翻译成两个掩码:哪些灯会被强制关掉、哪些会被强制点亮。之后按一次就是一次位运算。
每次按按钮的代价都是 1,要求最少次数——这是无权图上的最短路。
状态。用 int S 表示当前灯的情况,第 j 位为 1 表示第 j 盏灯亮着。初始 S = (1<<n) − 1,目标 S = 0。
把按钮翻译成掩码。对第 i 个按钮预处理两个整数:clr[i] 记下所有 aᵢⱼ = 1 的位(这些灯按完一定是灭的),st[i] 记下所有 aᵢⱼ = −1 的位(这些灯按完一定是亮的)。aᵢⱼ = 0 的位谁也不记,保持不变。
转移就是一句位运算。T = (S & ~clr[i]) | st[i]:先强制清掉该灭的位,再强制置上该亮的位。为什么“强制”是对的:a = 1 时无论灯原来亮还是灭,结果都是灭;a = −1 时无论原来如何,结果都是亮。
算法。每条边权为 1,用 BFS 从 full 出发,dist 数组记最少按几次。dist 初始化为 −1,没被访问到的点自然是 −1,正好对上“无解输出 −1”。
复杂度。状态 2ⁿ ≤ 1024 个,每个状态试 m ≤ 100 个按钮 → 10⁵ 次位运算,可以忽略不计。
为什么不能贪心。按下按钮可能先把某些灯点亮,暂时变差但后面更省;BFS 把所有状态都试过,才敢说“最少”。
#include <bits/stdc++.h>
using namespace std;
int n, m;
int clrMask[105], setMask[105]; // 每个按钮翻译成的两个掩码
int dist[1 << 10]; // dist[S]:从“全亮”走到 S 最少按几次
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> m;
for (int i = 1; i <= m; ++i)
for (int j = 0; j < n; ++j) {
int a;
cin >> a;
if (a == 1) clrMask[i] |= 1 << j; // 按完这盏一定是灭 → 记住“要清掉”
else if (a == -1) setMask[i] |= 1 << j; // 按完这盏一定是亮 → 记住“要置上”
// a == 0:这一位两边都不记,按完保持不变
}
int full = (1 << n) - 1; // 初始:所有灯都亮
memset(dist, -1, sizeof dist); // −1 同时表示“没访问过”,省掉 vis 数组
queue<int> q;
dist[full] = 0;
q.push(full);
while (!q.empty()) {
int S = q.front();
q.pop();
if (S == 0) break; // 已经全灭,不用再扩展
for (int i = 1; i <= m; ++i) {
int T = (S & ~clrMask[i]) | setMask[i]; // 一次位运算完成“该灭的灭、该亮的亮”
if (dist[T] == -1) { // 每个状态只入队一次
dist[T] = dist[S] + 1;
q.push(T);
}
}
}
cout << dist[0] << "\n"; // 走不到就是 −1,正好是题目要的输出
return 0;
}| 片段 | 作用 |
|---|---|
clrMask[i] |= 1 << j | 把“按第 i 个按钮会让第 j 盏灯灭”记成一个掩码。预处理的好处是运行时只做位运算,不再判分支。 |
(S & ~clrMask[i]) | setMask[i] | 整个转移只有这一句。先 & ~clr 强制清位,再 | set 强制置位,顺序不能反——反了会被后一步覆盖。 |
memset(dist, -1, sizeof dist) | int 数组按字节填 0xFF 得到 −1。用 0 初始化就分不清“没访问过”和“距离是 0”,会重复入队到 TLE。 |
int full = (1 << n) - 1 | n 盏灯全亮。起点是它、终点是 0,写成反向也行,但要记得输出换成 dist[full]。 |
if (S == 0) break; | 到达终点就能收工。BFS 第一次弹出 0 时,dist[0] 已经是答案。 |
cout << dist[0] << "\n"; | 无解时 dist[0] 一直是 −1,输出 −1 与题目要求完全一致,不需要额外判断。 |
T ^= 1 << j):原题是“亮着才关”,本来就灭的灯被翻成亮,状态全错 → WA。(S | setMask) & ~clrMask:该亮的位被清掉 → WA。1 << 9:n = 10 时最大状态是 1023,正好越界 → RE。按 n 的上界开。