← 目录 第 34 / 60 节 全部 60 节

34 · 状态压缩 DP 与数位 DP

两种“变形”的 DP:状压把集合压进一个 int 的低 n 位,按 S 递增枚举就自带拓扑序;数位 DP 把数字当字符串逐位填,用 pos / limit / lead 三个参数把 10¹⁸ 的区间压成一千次转移。
提高预计 75 分钟状压 DP数位 DP位运算
本节出现的新函数 / 新语法(先看懂再用)

前面几节的 DP 状态都是一个下标或一对下标。这一节的两种 DP 都换了“状态的形状”:状压把“用过哪些东西”压进一个 int 的二进制位;数位 DP把数字当成字符串,从高位往低位一位一位填。记忆化的套路完全一样,只是“状态长什么样”变了。

状压 DP 的复杂度只看两个数:状态数 2ⁿ × 状态里那“最后一个是谁”,以及每个状态往外转几次。n ≤ 15 是舒适区,n = 20 是天花板。
状态压缩 DP数位 DP
状态一个 int 的低 n 位表示“选过哪些元素”填到第几位、上一位是什么、有没有顶到上界
转移往集合里加 / 删一个元素,或把集合拆成两个子集这一位填 0..9,往低位递推
顺序S 从小到大(新状态一定更大)记忆化 dfs,从高位往低位
规模2ⁿ × n,n ≤ 20位数 × 附加状态 × 10,一般不到 10⁴
典型题TSP、关灯、棋盘放置区间内满足数位条件的计数

这一节的全部代码都建立在位运算上。三个工具先拆开:位运算本身、数 1 的个数、枚举子集。

新函数拆解 · 位运算六件套(把 int 当集合用)
(x >> k) & 1 取第 k 位 x | (1 << k) 第 k 位置 1(加入集合) x & ~(1 << k) 第 k 位清 0(移出集合) x ^ (1 << k) 第 k 位翻转 x & (x - 1) 去掉最低位的 1 x & (-x) 取出最低位的 1(lowbit)
组成含义 / 格式要求
(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(数 1 的个数)
int __builtin_popcount(unsigned int x); int __builtin_popcountll(unsigned long long x); int __builtin_ctz(unsigned int x); // 最低位 1 的下标
组成含义 / 格式要求
__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 去判断集合大小,先在状态设计上分层更快。

新函数拆解 · 枚举子集(submask 遍历)
for (int sub = mask; sub; sub = (sub - 1) & mask) { /* 处理 sub */ }
组成含义 / 格式要求
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 个东西、每个只能取一次、要排一个最优顺序。先想清楚“暴力为什么必死”,再想“哪一部分信息其实没用”。

例题 · 吃奶酪洛谷 P1433普及/提高-去洛谷做这题 ↗

房间里放着 n 块奶酪,一只小老鼠要把它们都吃掉,问至少要跑多少距离?老鼠一开始在 (0,0) 点处。

数据范围:1 ≤ n ≤ 15,|xᵢ|,|yᵢ| ≤ 200(实数,小数点后最多 3 位)。

输入格式:第一行整数 n;第 2 到第 n+1 行,每行两个实数 xᵢ、yᵢ,表示第 i 块奶酪的坐标。
输出格式:一行一个实数,最少距离,保留 2 位小数。

输入: 4 1 1 1 -1 -1 1 -1 -1 输出: 7.41
状态里必须记“吃了哪些”和“最后一口在哪”

先算暴力。奶酪的吃法有 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⁶ 次加法。开方只在预处理两两距离时做,所以整个程序在一瞬间跑完。

完整可编译代码(C++17)
#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 位——所以不枚举数,枚举“填法”。

新函数拆解 · 数位 DP 的三参数(pos / limit / lead)
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; }
组成含义 / 格式要求
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”比较,正确答案会差一大截。

例题 · windy 数洛谷 P2657提高去洛谷做这题 ↗

不含前导零、且相邻两个数字之差至少为 2 的正整数称为 windy 数。例如 1、13、24 是,而 12、10 不是。

给定 a 和 b,求区间 [a, b] 内 windy 数的个数。

数据范围:1 ≤ a ≤ b ≤ 2×10⁹。

输入格式:一行两个整数 a、b。
输出格式:一行一个整数,表示答案。

输入: 1 10 输出: 9
把区间拆成两个前缀,再一位一位填

区间转前缀。记 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 一次,相减时正好抵消,不需要额外特判。

完整可编译代码(C++17)
#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ⁿ 的那层循环。

这一节的 6 个坑,按症状记

掩码位记错一位(把第 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⁹。

数学补给 · 2ⁿ 与 3ⁿ:状压的两条复杂度线

状压 DP 的费用永远是“状态数 × 每个状态的转移数”。状态数 = 2ⁿ ×(状态里那几维),转移数通常是 n,所以总量是 2ⁿ·n² 或 2ⁿ·n。先把这张表记住:

n2ⁿ2ⁿ·n3ⁿ判决
1010241.0×10⁴59049随便做
15327684.9×10⁵1.4×10⁷2ⁿ 轻松;3ⁿ 也还行
182621444.7×10⁶3.9×10⁸2ⁿ 稳;3ⁿ 超时
2010485762.1×10⁷3.5×10⁹2ⁿ·n² 已 4×10⁸,要压常数
24167772164×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,费用翻一倍都不到——这就是“复杂度只看位数”的含义。

举一反三 · 想清楚这些再往下走
  1. n = 20 时 f[2²⁰][20] 要 168 MB,已经危险。如果题目只问最短总长度、不问路径,最后一维能不能砍掉?为什么不能轻易砍?
  2. 如果题目改成“必须按给定顺序吃奶酪”,还需要状压吗?状态能简化成什么?
  3. 若奶酪之间只能沿网格走曼哈顿距离,状态要改吗?这题的状态设计里,哪一部分依赖“距离的定义”?
  4. 求 [a,b] 中“各位数字之和 mod 3 = 0”的数的个数:附加状态加什么?记忆化数组开多大?为什么不能只记 pos?
  5. 求 [a,b] 中“含数字 4 或含子串 62”的数的个数,用补集怎么算?为什么直接算比补集麻烦?
  6. 上一节的石子合并 n = 100,能不能用状压做?把状态写成“哪些堆已经合并”需要多少位?这说明选题时要先估什么?
  7. 把 __builtin_popcount 换成手写递推 pc[S] = pc[S>>1] + (S&1),在 2ⁿ 全部预处理时哪个更快?为什么?
自测:能全部回答再进下一节
挑战题 · 关灯问题 II
洛谷 P2622普及/提高-去洛谷做这题 ↗

现有 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) - 1n 盏灯全亮。起点是它、终点是 0,写成反向也行,但要记得输出换成 dist[full]。
if (S == 0) break;到达终点就能收工。BFS 第一次弹出 0 时,dist[0] 已经是答案。
cout << dist[0] << "\n";无解时 dist[0] 一直是 −1,输出 −1 与题目要求完全一致,不需要额外判断。
这题最容易错的地方
  • 把 a = 1 当成“翻转”(写 T ^= 1 << j):原题是“亮着才关”,本来就灭的灯被翻成亮,状态全错 → WA。
  • 两个掩码的作用顺序写反成 (S | setMask) & ~clrMask:该亮的位被清掉 → WA。
  • dist 用 0 初始化而不是 −1:无法区分未访问,队列里反复入队 → TLE,或者直接输出 0。
  • 数组只开 1 << 9:n = 10 时最大状态是 1023,正好越界 → RE。按 n 的上界开。
延伸阅读 / 参考