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

14 · 枚举与暴力:把搜索空间数清楚

暴力有前提:方案数 × 单方案代价 ≤ 10⁸。这一节先学会在纸上数方案,再学三个把方案数变小的工具——换枚举对象(207 次代替 10⁹ 次)、二进制枚举子集、折半搜索;顺带吃透 popcount、位运算与 next_permutation。
⭐ 已学范围普及-预计 60 分钟枚举复杂度估算二进制枚举
本节出现的新函数 / 新语法(先看懂再用)

暴力不是“随便套几层循环”。它有明确的前提:方案总数 × 每个方案的代价 ≤ 10⁸ 左右。写代码之前先把这两个数算出来——算得出来,你就知道该不该动手;算不出来,说明你还没想清楚要枚举什么。这一节练的就是这个动作,外加两个把方案数变小的工具:换枚举对象、只枚举一半。

例题 · 三连击洛谷 P1008普及-去洛谷做这题 ↗

把 1, 2, …, 9 这 9 个数字分成 3 组,每组 3 个,分别拼成 3 个三位数,要求这 3 个三位数之比为 1 : 2 : 3。求所有方案。

输出: 192 384 576 219 438 657 273 546 819 327 654 981

先别写代码,先数一下:这道题到底有多少种可能?同样一道题,三种数法给出三种命运。

枚举什么方案数每个方案要做什么总操作数
a、b、c 各自从 100 试到 999900³ = 7.3×10⁸拆 9 位查重≈ 6×10⁹,必然 TLE
1~9 的全排列9! = 362 880切 3 段 + 查重≈ 3×10⁶,能过
只枚举 a,令 b = 2a、c = 3a207拆 9 位查重≈ 2×10³,眨眼
什么时候可以放心暴力

方案数 ≤ 10⁷ 且每个方案 O(1):直接写,不用想优化。

值域小(≤ 10⁷):能开数组,就先用数组把信息存下来,再扫一遍。

答案唯一且可以“找到就退出”:平均代价远小于最坏代价,也敢写。

反面信号:n ≥ 25 还想枚举子集、n ≥ 12 还想枚举排列、n ≥ 5000 还写 O(n²)。见到这三个数,先停下来算。

这一节的三个工具分别负责“数得更少”“表示得更紧”“生成得更快”。

新函数拆解 · __builtin_popcount(数 1 的个数)
int __builtin_popcount(unsigned int x);
组成含义 / 格式要求
x待统计的整数。按无符号位模式解释,所以传负数会得到它的补码里 1 的个数。
返回值x 的二进制表示中 1 的个数,范围 0..32。
复杂度编译成一条 popcnt 指令或常数级查表,O(1)。比手写循环快一个量级。
64 位版本__builtin_popcountll(x)。对 long long 用 32 位版本只会统计低 32 位。
典型用途二进制枚举里筛“恰好选 k 个”的子集、算汉明距离、判 2 的幂(popcount == 1)。
最小可运行示例
示例
for (int mask = 0; mask < 8; ++mask)
    cout << mask << " -> " << __builtin_popcount(mask) << "\n";
// 0->0  1->1  2->1  3->2  4->1  5->2  6->2  7->3
注意

名字以两个下划线开头,是 GCC 内建函数,不是标准库函数;比赛用的编译器都有。

它只吃整数,不吃 bitset<N>(那个用 .count())。

新函数拆解 · 位运算:把一个 int 当 32 个开关
(mask >> i) & 1  mask | (1 << i)  mask & ~(1 << i)  mask ^ (1 << i)  1 << n
组成含义 / 格式要求
(mask >> i) & 1取第 i 位。位编号从 0 开始(最低位)。结果只能是 0 或 1,可直接当条件。
mask | (1 << i)把第 i 位置 1。按位或不动其它位。
mask & ~(1 << i)把第 i 位清 0。~ 之后只有第 i 位是 0,其余全 1。
mask ^ (1 << i)翻转第 i 位:0 变 1,1 变 0。
1 << n2ⁿ。n ≥ 31 时 int 装不下(1<<31 是负数),要写 1LL << n。
mask & (mask - 1)清掉最低位的 1。配合 while 可以只扫 mask 里为 1 的那些位。
最小可运行示例
示例
int mask = 0b1011;                  // 1011 = 11:第 0、1、3 位是 1
cout << ((mask >> 1) & 1) << "\n";  // 1:取第 1 位
mask |= (1 << 2);                   // 置位  → 1111
mask &= ~(1 << 0);                  // 清位  → 1110
cout << __builtin_popcount(mask) << "\n";   // 3
注意

位编号从 0 开始:第 0 位的权值是 1,第 i 位的权值是 2ⁱ。

& 和 && 不是一回事:mask && 3 是逻辑与,结果是 0/1,写成它就会变成“全选”或“全不选”。

循环上界写成 1 << 31 会让条件永真,直接死循环 → TLE。

新函数拆解 · next_permutation(下一个字典序排列)
bool next_permutation(iterator first, iterator last);
组成含义 / 格式要求
first, last要重排的区间,左闭右开。
前提区间当前必须升序,否则只能拿到“从当前排列往后”的那部分,前面的全丢。函数不会帮你排序。
返回值还有下一个排列 → 返回 true 并原地改好;已经是最后一个排列 → 返回 false,同时把区间重置为升序(即最小的那个排列)。
个数n 个互不相同的元素共有 n! 个排列,do-while 正好执行 n! 次。
写法必须 do-while,不能 while:第一个排列也要输出。
最小可运行示例
示例
int a[] = {1, 2, 3};                 // 升序 = 字典序最小的排列
do {
    for (int i = 0; i < 3; ++i) cout << a[i] << " ";
    cout << "\n";
} while (next_permutation(a, a + 3));  // 还有下一个就继续
// 输出 6 行:1 2 3 / 1 3 2 / 2 1 3 / 2 3 1 / 3 1 2 / 3 2 1
注意

元素重复时按“不重复的排列”输出,总数少于 n!。

求上一个排列用 prev_permutation,初值要写成降序。

上面为了短,每行末尾多打了一个空格;真要提交时改成 if (i) cout << " "; 的写法。

枚举的三件事:枚举对象决定方案数的量级,表示方法决定一个方案要存多少信息,生成顺序决定你能不能边生成边剪掉不可能的分支。三者改一个,复杂度就可能差几个数量级。
例题怎么想:先砍掉一个数量级,再动手

最省事的写法是三层的循环 a, b, c 各 100..999,再拆位检查——7.3×10⁸ 个三元组,每个还要拆 9 位,总计 6×10⁹ 次操作,1 秒的题必死。

关键观察:题目给了比例。只要 a 定了,b = 2a、c = 3a 就定了。枚举对象从“三个数”换成“一个数”,方案数从 10⁹ 掉到 10²。这就是“数清楚”的第一层收益:不用算法,只换视角。

a 的范围也不是 100..999:a 自己得是三位数、不含 0、数字互不相同,最小就是 123;c = 3a 也得是三位数(3a ≤ 987),所以 a ≤ 329。能算出边界就不要硬写 999。

剩下的活是判重:三个三位数一共 9 个数字,要求恰好是 1..9 各一次。用 cnt[10] 数每个数字出现的次数,出现 0 或某个数字第二次出现就否决,最后检查 1..9 是否都恰好一次。

完整代码:只枚举 a,共 207 次
#include <bits/stdc++.h>
using namespace std;

int main() {
    for (int a = 123; a <= 329; ++a) {          // 3a ≤ 987 ⇒ a ≤ 329
        int cnt[10] = {0};                      // cnt[d]:数字 d 出现了几次
        int p[3] = {a, 2 * a, 3 * a};           // 由比例生成,不用三层循环
        bool ok = true;

        for (int t = 0; t < 3 && ok; ++t) {
            int x = p[t];                       // 拆 x 的三位,x 是副本,不动 p[t]
            for (int j = 0; j < 3; ++j) {
                int d = x % 10;
                x /= 10;
                if (d == 0 || ++cnt[d] > 1) {   // 出现 0 或重复,立刻否决
                    ok = false;
                    break;
                }
            }
        }
        if (!ok) continue;

        bool all = true;                        // 1~9 是否每个恰好出现一次
        for (int d = 1; d <= 9; ++d)
            if (cnt[d] != 1) { all = false; break; }
        if (all) cout << a << " " << 2 * a << " " << 3 * a << "\n";
    }
    return 0;
}
逐行拆解
代码片段含义与理由
a <= 3293a 必须还是三位数,987 / 3 = 329;下界 123 保证 a 本身是三位数。区间 [123, 329] 共 207 个数,这就是全部方案数。
int cnt[10] = {0}数字只可能是 0..9,用值当下标,10 个格子够。初始化写成 {0} 是关键,否则数组里是随机值。
int p[3] = {a, 2 * a, 3 * a}由比例直接算出另外两个数。这一步把三层循环压成一层,是整个解法的核心。
int d = x % 10; x /= 10;取末位、砍掉末位,三次拆完一个三位数。x 是副本,所以循环结束后 p[t] 不变。
d == 0 || ++cnt[d] > 1左边先短路:出现 0 直接否决,不碰 cnt[0]。右边先自增再比较:第一次出现得 1 不触发,第二次得 2 触发。一句代码同时完成“范围检查”和“判重”。
cnt[d] != 19 个数字两两不同且都在 1..9 的充要条件:每个数字恰好出现一次。
cout << a << " " << 2 * a ...a 从小到大枚举,输出天然按第一个数升序,不需要额外排序。
二进制枚举:一个 int 就是一个子集

n 个元素“选或不选”,一共 2ⁿ 种结果。把每种结果编码成一个整数:第 i 位是 1 表示选第 i 个元素。于是 for (int mask = 0; mask < (1 << n); ++mask) 这一层循环就把所有子集走完了。

它比 n 层嵌套循环短得多,也比递归早 7 节就能用(第 21 节才讲递归)。代价是不能剪枝:2ⁿ 个方案一个不落。

子集枚举模板
// 演示:n ≤ 20,统计元素和恰好等于 S 的子集个数(不含空集)
int n = 5, S = 10;
int a[5] = {1, 2, 3, 4, 5};
int ans = 0;

for (int mask = 0; mask < (1 << n); ++mask) {   // 2⁵ = 32 个 mask,一个 mask 一个子集
    int sum = 0;
    for (int i = 0; i < n; ++i)
        if ((mask >> i) & 1) sum += a[i];       // 第 i 位是 1 ⇒ 选 a[i]
    if (mask != 0 && sum == S) ++ans;           // 空集的和是 0,本题不要
}
cout << ans << "\n";                            // 3
逐行拆解
代码片段含义与理由
1 << n左移 n 位就是 2ⁿ。n = 20 时是 1048576,mask 从 0 到 2ⁿ−1,正好一个子集一个编号。
mask < (1 << n)上界写 1 << n 且不带等号。写成 <= 会多枚举一个不存在的子集。
(mask >> i) & 1把第 i 位移到最低位再与 1 相与,结果是 0 或 1,可以直接当条件用。
sum += a[i]这一位是 1,说明子集包含 a[i]。注意位编号对应的是下标 i,不是 a[i] 的值。
mask != 0mask = 0 表示一个都不选。很多题不允许空集,这一句必须写。
n2ⁿ内层 O(n) 时的总操作数结论
101 024≈ 10⁴随便过
1665 536≈ 10⁶过
201 048 576≈ 2×10⁷过
2533 554 432≈ 8×10⁸超时,改折半
301 073 741 824≈ 3×10¹⁰不可能

把这张表记住比记住代码有用:看到 n ≤ 20 就想二进制枚举,看到 n ≥ 25 就换方法。如果题目还要求“恰好选 k 个”,用 __builtin_popcount(mask) == k 筛掉大部分方案——C(n, k) / 2ⁿ 的比例可以从 C(20, 10) / 2²⁰ ≈ 18% 手算出来。

折半搜索:2⁴⁰ 太大,就做两次 2²⁰

n = 40 时 2⁴⁰ ≈ 1.1×10¹²,一秒跑不完。但把数组切成两半,每半 20 个,各枚举 2²⁰ ≈ 1.05×10⁶ 个子集和;再让“左半取 s、右半取 S − s”配对就行。

配对靠排序 + 二分,正好用上第 13 节的 lower_bound 与 upper_bound。这就是折半搜索(meet in the middle):把一次 2ⁿ 的枚举,换成两次 2^(n/2) 的枚举加一次二分。

折半:枚举两半 + 排序一半 + 另一半二分
int h = n / 2;                                 // 切成 20 + 20
vector<long long> L, R;

for (int mask = 0; mask < (1 << h); ++mask) {  // 左半:2²⁰ 个子集和
    long long s = 0;
    for (int i = 0; i < h; ++i)
        if ((mask >> i) & 1) s += a[i];
    L.push_back(s);
}
for (int mask = 0; mask < (1 << (n - h)); ++mask) {   // 右半:2²⁰ 个子集和
    long long s = 0;
    for (int i = 0; i < n - h; ++i)
        if ((mask >> i) & 1) s += a[h + i];           // 原数组下标是 h + i
    R.push_back(s);
}

sort(R.begin(), R.end());                      // 只排一半

long long ans = 0;
for (long long s : L)                          // 左半每个和,去右半里数 S−s 出现几次
    ans += upper_bound(R.begin(), R.end(), S - s)
         - lower_bound(R.begin(), R.end(), S - s);
关键几行
代码片段含义与理由
int h = n / 2把 40 个数切成 20 + 20。两半不必等长,差 1 无所谓。
1 << h2²⁰ ≈ 1.05×10⁶,比 2⁴⁰ 小了六个数量级。折半的全部收益就在这个指数上。
a[h + i]右半第 i 个元素在原数组里是 a[h+i]。下标错位是折半搜索最常见的 WA。
sort(R.begin(), R.end())只排右半。左半保持无序,靠二分去查。
upper_bound - lower_bound相减 = 等于 S−s 的元素个数,也就是能和 s 配成 S 的方案数。
n直接枚举 2ⁿ折半后的枚举量排序 + 二分总量级
201.0×10⁶2.1×10³≈ 2×10⁴10⁴
301.07×10⁹6.6×10⁴≈ 1×10⁶10⁶
401.1×10¹²2.1×10⁶≈ 4×10⁷10⁷

代价是内存:两个数组各存约 10⁶ 个 long long,各 8 MB,还扛得住。再大就得换个思路。记住这个手势:指数太大就砍一半——枚举一半、排序一半、另一半去查。

这一节最容易犯的 7 个错

循环上界写 1 << 31:它是 int 的负数(−2147483648),条件永真 → 死循环 TLE。n ≥ 31 时必须写 1LL << n,循环变量也要 long long。

next_permutation 之前忘了排序:初值是 {3, 2, 1} 时函数第一次就返回 false,6 个排列只输出 1 个 → WA。

用 while 代替 do-while:第一个排列被跳过。n = 1 时程序一行都不输出 → WA(样例就挂)。

__builtin_popcount 用错宽度:对 long long 用它只统计低 32 位,1LL << 40 会得到 0 → WA。64 位要用 __builtin_popcountll。

把“第 i 个元素”和“值 i”搞混:写 if (mask & (1 << a[i])) 是把 a[i] 当位号,值一过 30 就移出有效位,答案全错 → WA。位号永远是下标。

把 2ⁿ 个方案的结果全存下来:n = 25 时是 3.3×10⁷ 个 int,光数组就要 130 MB → MLE,很多平台紧接着就是 RE。边枚举边统计,别存。

没估量级就开写:n = 30 直接枚举子集,2³⁰ ≈ 1.07×10⁹ 个方案,每个还要 O(n),实际 3×10¹⁰ 次操作 → 必然 TLE。先数一遍就不会有这一条。

自己动手复现

关掉页面,把 P1008 的代码敲一遍并提交,确认输出正好 4 行。

先把输出换成计数器,打印候选数应是 207;再把 a <= 329 改成 a <= 987,候选数变成 865,答案仍是那 4 行——说明缩小枚举范围不改变正确性,只改变耗时。

改一改:用 next_permutation 枚举 1~9 的全排列(9! = 362 880 种),每 3 位切成一段验证 1 : 2 : 3,用 clock() 量一下两种写法的耗时。你会看到 207 次和 3.6×10⁵ 次的差距——这就是“数清楚”的收益。

数学补给 · 2ⁿ、n! 与 10⁸:把方案数折成时间

评测机 1 秒大约做 10⁸ 次简单操作(比较、加法、赋值)。所以判断能不能暴力,只需要算一个乘积:方案数 × 每个方案的代价,和 10⁸ 比大小。这一步在纸上做,比在评测机上做便宜得多。

n2ⁿ(子集)n!(排列)能不能暴力
101 0243 628 800都行
1532 7681.3×10¹²子集行,排列不行
201 048 5762.4×10¹⁸子集行(还要看每方案代价)
2533 554 432—勉强,2ⁿ·n 已到 8×10⁸
301 073 741 824—不行

折半的账:2ⁿ = 2^(n/2) × 2^(n/2)。两半各枚举 2^(n/2),再花 O(2^(n/2) · n/2) 排序、O(2^(n/2) · n/2) 配对,总量从 2ⁿ 降到约 n·2^(n/2)。n = 40 时:1.1×10¹² → 4×10⁷,快约 2.7 万倍。指数上的 n 变成 n/2,比任何常数优化都值钱。

组合恒等式:对固定的 n,C(n, 0) + C(n, 1) + … + C(n, n) = 2ⁿ。也就是说“按子集大小分类枚举”和二进制枚举覆盖的是同一批对象,只是访问顺序不同。二进制枚举省掉了 k 层循环的写法成本,代价是不能提前剪枝——这正是第 23 节 DFS 要补上的东西。

排列的增长:10! = 3.6×10⁶,11! = 4.0×10⁷,12! = 4.8×10⁸,13! = 6.2×10⁹。所以 next_permutation 全排列的实用上限大约在 n = 11;n = 12 就算每轮只做 O(1) 也已经踩线。

举一反三 · 想清楚这些再往下走
  1. n = 25,要统计“元素和等于 S 的子集个数”。直接枚举和折半各要多少次基本操作?分别能不能过?
  2. 一道题的暴力是 O(n²)。n = 5000 与 n = 10⁵ 各是多少次操作?写出你判断“够不够”的过程。
  3. 用 next_permutation 枚举 n = 12 的全排列,光生成就要 4.8×10⁸ 次。为什么即使每轮只做 O(1) 也不该这么写?
  4. 二进制枚举里,怎样只处理“恰好选了 k 个”的子集?被筛掉的比例是多少?用 C(n, k) / 2ⁿ 说话。
  5. “枚举 a 再用比例推出 b、c”比“枚举 a、b、c”快约 350 万倍。省掉的到底是哪一层因子?换成别的题,你能找到同样的比例关系吗?
自测:能全部回答再进下一节
挑战题 · 全排列问题
洛谷 P1706普及-去洛谷做这题 ↗

按字典序输出 1 到 n 的所有不重复排列,即 n 的全排列,同一个序列中不允许出现重复的数字。

输入格式:一个整数 n(1 ≤ n ≤ 9)。

输出格式:共 n! 行,每行一个由 1~n 组成的序列,每个数字保留 5 个场宽。

引导性提示(先只看这里)

n = 9 时共有 9! = 362 880 行。递归生成当然可以,但标准库里已经有一个“按字典序吐出下一个排列”的函数。

这个函数要求区间一开始是升序,否则你只能从当前排列往后走,前面的全丢了。

场宽用 setw(5)。它只对紧随其后的那一个输出生效,所以要写在循环里。

想看思路了?点开完整思路

把数组 a 初始化成 1, 2, …, n——这是字典序最小的排列。

用 do-while 反复调用 next_permutation:它把 a 改成字典序的下一个排列,最后一个排列的下一次调用返回 false。

每轮输出 a 的 n 个数,每个用 setw(5) 补齐到 5 个字符,行末只换行。

为什么输出天然是字典序:next_permutation 的定义就是“下一个字典序”,从最小排列出发一路调用,顺序必然正确。

复杂度 O(n · n!):n = 9 时约 3.3×10⁶ 次输出,远在 10⁸ 之内。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    int n;
    cin >> n;

    vector<int> a(n);
    for (int i = 0; i < n; ++i) a[i] = i + 1;   // 字典序最小的排列

    do {
        for (int i = 0; i < n; ++i)
            cout << setw(5) << a[i];            // 每个数字占 5 个场宽
        cout << "\n";
    } while (next_permutation(a.begin(), a.end()));   // 还有下一个就继续

    return 0;
}
关键片段拆解
片段作用
a[i] = i + 1初始化为 1..n 的升序,即字典序最小的排列。不从最小处出发,前面的排列就全都拿不到。
do {先输出再判断。写成 while 会跳过第一个排列;n = 1 时程序将一行都不输出。
setw(5)设置“下一次输出”的最小宽度为 5,右对齐、左边补空格。它只作用于紧跟其后的一个值。
next_permutation(a.begin(), a.end())把区间改成下一个字典序排列,还有下一个则返回 true;已经是最后一个时返回 false,并把区间重置为升序。
cout << "\n"每行末尾只换行。场宽已经补齐,不要再手动加空格,否则行末多出尾随空格。
这题最容易错的地方
  • 忘了把初值写成升序,导致输出的排列不完整(例如初值是 3 2 1 时只会输出 1 行)。
  • 用 while 而不是 do-while,少输出第一行 1 2 … n。
  • 用 cout << a[i] << ' ' 代替场宽,格式不符直接 WA。
  • 把 setw 写在循环外面,或误以为它会一直生效——它只影响下一个输出。
  • 想先把所有排列存进数组再输出:9! 行 × 9 个数会撑爆内存,边生成边输出才对。
延伸阅读 / 参考