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

23 · DFS 枚举:子集、组合与排列

把“枚举所有方案”写成递归:一副骨架四块内容,一套回溯三行;再用 vis、start、“选或不选”三种手段分别处理排列、组合、子集的不重不漏,并学会先估方案数再写代码。
⭐ 已学范围普及-预计 65 分钟DFS回溯字典序
本节出现的新函数 / 新语法(先看懂再用)

第 14 节用 for 和二进制数过搜索空间,第 21 节把递归拆成“出口 + 递推”。DFS 枚举 = 递归 + 枚举:把“从一堆东西里挑若干个”摊成 n 层决策,每层选一个,选完往下走,走完退回来换下一个。这个“退回来换下一个”的动作叫回溯,代码里只有一行。

DFS 枚举不保证快,只保证不重不漏。三种题型的所有技巧都在处理“不重”:排列用 vis,组合用 start,子集用“选或不选”。

先把骨架拆成四块。任何一道 DFS 枚举题,都能填进这张表里,填不进去就说明还没想清状态。

骨架部件它回答的问题全排列(n! 种)组合(C(n,k) 种)
层(step)现在在做第几层决策正在填第 step 个位置已经选了 step 个数
候选集这一层允许选哪些1..n 里所有没被用过的数start..n
出口什么状态算一个完整方案step > nstep > k
还原离开这一层要撤销什么vis[i] = 0不用撤销:start 天生递增

本节没有新关键字,只有一种必须背下来的固定顺序。把它当一个不可拆的整体记住,比理解之后再写要快。

新函数拆解 · 回溯三行(标记 → 递进 → 还原)
vis[i] = 1; dfs(step + 1); vis[i] = 0;
组成含义 / 格式要求
① vis[i] = 1标记:把 i 从候选集里划掉。下一层看到的 vis 就是“占位之后”的现场。
② dfs(step + 1)递进:带着新状态进入下一层。这句话返回时,它内部的所有标记都已经撤干净了。
③ vis[i] = 0还原:撤销 ①。不写这一行,i 会一直被认为占着,后面所有分支都选不到它。
为什么必须还原所有层共用同一份全局数组。dfs 只是“借用”它,离开时必须物归原主,否则兄弟分支看到的是上一条路径留下的脏状态。
顺序不能换先标再递进、先递进再还原。把还原写在递归前面,等于下一层拿到的是“没占位”的状态,同一个数会被重复填进不同位置。
位置在循环体内三行必须一起写在 for 的循环体里。写到循环外面就只还原了最后一次的 i,这是最典型的手误:代码看起来完全正常,答案少一半。
成对检查法调不出来时先数一遍:每个 = 1 是否都配了一个 = 0,两者是否在同一个循环体、同一个下标变量上。
最小可运行示例
示例
#include <bits/stdc++.h>
using namespace std;

int n = 3, a[10];
bool vis[10];

void dfs(int step) {                       // step:正在填第几个位置
    if (step > n) {                        // 出口:n 个位置都填满了
        for (int i = 1; i <= n; ++i) cout << a[i] << ' ';
        cout << '\n';
        return;
    }
    for (int i = 1; i <= n; ++i) {         // 这一层的候选集:1..n
        if (vis[i]) continue;              // 已被前面某层占用的数不能再用
        a[step] = i;
        vis[i] = 1;                        // ① 占位
        dfs(step + 1);                     // ② 递进
        vis[i] = 0;                        // ③ 还原
    }
}

int main() { dfs(1); }                     // 输出 6 行,正好是 3! 个排列
注意

注意 a[step] = i 不需要还原:同一层的下一次循环会直接覆盖它。要还原的是占用关系(vis),不是答案数组。

忘记还原的症状很隐蔽:不 RE、不崩,只是方案数远少于预期(常常只剩一条);如果只在某个分支上漏写,就变成“答案小一点”,最难查。

还原写在 dfs 之前,症状相反:同一个数被反复填,输出大量非法排列,n 稍大直接 TLE。

新函数拆解 · start 参数(组合的去重手段)
void dfs(int step, int start)
组成含义 / 格式要求
step已经选了几个数,等于递归层数。step == k 时到达出口。
start这一层允许使用的最小下标。它把“下标严格递增”变成一条硬约束,于是每个集合只有一种生成顺序。
为什么就够去重从 {1,2,3} 里选 2 个,排列会给出 (1,2) 和 (2,1) 两份;先选小的、后选大的,就只留 (1,2)。
传 i + 1 还是 i当前数用过就不能再用 → 传 i + 1;如果允许重复使用同一个数(如整数拆分)→ 传 i。
和 vis 的分工vis 管“哪些用过了”,start 管“只能往更大的方向选”。组合用 start 就够,不需要 vis。
出口写法step > k 与 step == k 都能用,但必须和传参起点对齐。两种风格混着改,是 off-by-one 的主要来源。
最小可运行示例
示例
#include <bits/stdc++.h>
using namespace std;

int n = 5, k = 3, a[25];

// 从 1..n 中选 k 个,按字典序输出每一种组合
void dfs(int step, int start) {            // step:已选个数;start:本层最小可选值
    if (step > k) {                        // 选够了,输出一种组合
        for (int i = 1; i <= k; ++i) printf("%3d", a[i]);
        printf("\n");
        return;
    }
    for (int i = start; i <= n; ++i) {     // 只从 start 往后选
        a[step] = i;
        dfs(step + 1, i + 1);              // ★ 下一层起点是 i+1,不是 start+1
    }
}

int main() { dfs(1, 1); }                  // C(5,3) = 10 行
注意

写成 dfs(step + 1, start + 1) 是经典错误:那样只会生成 {(1,2,3),(2,3,4),…} 这类连续区间,方案数少一大截,而且小数据很难发现。

把 i + 1 改成 i,同一个数可以被重复选,输出就变成“可重复组合”,第 32 节完全背包的枚举就长这样。

新函数拆解 · printf 定宽输出
printf("%5d", x);
组成含义 / 格式要求
%5d十进制整数,右对齐,至少占 5 个字符位,不够的在左边补空格。
%-5d负号 = 左对齐,空格补在右边。
宽度小于实际位数有宽度的格式符不截断。x = 123456 时 %5d 照旧输出 6 位,整行就错位了。
一行的分隔printf("%d%c", x, i == n ? '\n' : ' '); 用三元表达式让行末换行、其余打空格,省掉 if。
和 cout 混用两者都能用,但一个程序里最好只用一种。混用时不要写 ios::sync_with_stdio(false),否则输出顺序可能乱。
最小可运行示例
示例
#include <bits/stdc++.h>
using namespace std;

int main() {
    for (int i = 1; i <= 3; ++i) printf("%5d", i);   // "    1    2    3"
    printf("\n");
    printf("[%5d][%-5d]\n", 42, 42);                 // "[   42][42   ]"
    printf("%5d\n", 123456);                        // 宽度不够时不截断,输出 123456
    return 0;
}
注意

P1706 要求“每个数字占 5 个字符宽度”,用 cout 要写 setw(5) 且必须每题都记得重新设置;printf 一个格式符解决,更不容易漏。

对 long long 要用 %5lld,用 %5d 会把高 32 位丢掉,输出一个随机数。

下面的例题是本节所有内容的最小检验:它同时用到 step、vis、回溯三行和定宽输出,而它的方案数刚好大到能让你第一次感受到“搜索空间”这个词的重量。

例题 · 全排列问题洛谷 P1706普及-去洛谷做这题 ↗

输入一个整数 n,按字典序输出 1 到 n 的所有全排列,每个排列占一行。

输出时每个数字占 5 个字符宽度,右对齐;同一行内数字之间不额外加空格。

数据范围:1 ≤ n ≤ 9,方案数为 n!,最大 362880。

输入: 3 输出: 1 2 3 1 3 2 2 1 3 2 3 1 3 1 2 3 2 1
把“输出所有排列”翻译成“填 n 个空格”

换一种说法。一个排列就是把 1..n 各用一次,依次放进第 1、2、…、n 个格子。于是“输出所有排列”变成“枚举所有填法”,而填法可以一层一层决定:第 1 格填谁,第 2 格填谁……

状态记什么。只需要两样:已经填好的 a[1..step-1],以及哪些数字已经被用掉。后者可以直接扫一遍 a 得到,但每次是 O(n);用一个 vis[] 布尔数组把它压成 O(1)。这是“空间换时间”出现得最朴素的地方。

出口在哪。step 从 1 开始,填完第 n 格后进入第 n+1 层,此时 step > n,a[1..n] 就是一个完整排列,输出后 return。把出口写成 step == n 会漏掉最后一格,是本题第一大坑。

为什么不用排序就是字典序。每层循环 i 都从 1 到 n 递增。字典序比较的是“第一个不同的位置”,递归保证前面的位置先定下来、而且按从小到大尝试,于是输出的先后顺序就是字典序。

规模。节点数 = 1 + n + n(n−1) + … + n! ≈ e·n!,n = 9 时约 98.6 万个节点、362880 个叶子。每个叶子输出 9 个数 × 5 字符 ≈ 46 字节,总输出约 16 MB——本题的瓶颈是输出,不是搜索。

完整可编译代码(C++17)
#include <bits/stdc++.h>
using namespace std;

int n;
int a[10];       // a[1..n]:正在拼的这一个排列
bool vis[10];    // vis[i]:数字 i 是否已被前面的位置用掉

void dfs(int step) {                 // step:正在填第几个位置
    if (step > n) {                  // 出口:n 个位置全填好,a[1..n] 就是一个排列
        for (int i = 1; i <= n; ++i) printf("%5d", a[i]);
        printf("\n");
        return;                      // 必须 return,否则会继续往下选
    }
    for (int i = 1; i <= n; ++i) {   // 这一层的候选:1..n 中还没用过的
        if (vis[i]) continue;        // 已被占用,跳过
        a[step] = i;                 // 填进第 step 格(不用还原,下次循环会覆盖)
        vis[i] = 1;                  // ① 占位
        dfs(step + 1);               // ② 递进
        vis[i] = 0;                  // ③ 还原,把 i 还给后面的兄弟分支
    }
}

int main() {
    cin >> n;       // 输入只有一个整数,不必关同步
    dfs(1);         // 从第 1 个位置开始填
    return 0;
}
逐行拆解
代码片段含义与理由
int a[10]; bool vis[10];n ≤ 9,下标要用到 9,所以开 10 格。开 9 格是“差一格越界”:n = 9 时写 a[9] 正好踩在数组外面,有时不报错、有时答案随机,是最难查的一类 WA。
void dfs(int step)返回 void:答案不是“算出来返回上去”,而是“走到叶子就地输出”。搜索题的函数几乎都是 void + 全局状态。
if (step > n)出口写成 step > n(等价于 step == n+1)。因为进入第 n+1 层才说明第 n 格已经填完;写成 step == n 时,最后一格还没填就输出。
printf("%5d", a[i])每个数字占 5 格。用 cout 需要写 setw(5) 并且每次重新设置,容易漏。
if (vis[i]) continue;判重放在候选集处,而不是在出口处回头检查。出口再判重会白白走完 n! 条路,复杂度高一个量级。
a[step] = i;只写不还原。a 是“当前答案”,同一层的下一次循环会覆盖它;把它也还原反而多此一举。
vis[i] = 1; dfs(step + 1); vis[i] = 0;回溯三行。中间这句返回时,深层所有分支都已经把标记撤干净,所以这里只负责撤自己这一层的。
dfs(1);从第 1 层进入。若从 0 开始,出口条件和 a 的下标全都要跟着改,两种风格不要混。
三个模板:只差“候选集”那一行

把子集、组合、排列写成同一副骨头,差别只在循环怎么写。这三段建议直接背下来,之后所有搜索题都是往里面填东西。

子集:选或不选
#include <bits/stdc++.h>
using namespace std;

int n = 3, a[25], top = 0;

void dfs(int i) {
    if (i > n) {                          // 每个元素的去留都定了
        for (int k = 1; k <= top; ++k) cout << a[k] << ' ';
        cout << '\n';
        return;
    }
    dfs(i + 1);                           // 分支一:不选 i
    a[++top] = i;                         // 分支二:选 i,先占位
    dfs(i + 1);                           //        再往下
    --top;                                //        回来时弹出(这就是还原)
}

int main() { dfs(1); }                    // 2^3 = 8 行,第一行是空集
组合:start 递增
#include <bits/stdc++.h>
using namespace std;

int n = 5, k = 3, a[25];

void dfs(int step, int start) {
    if (step > k) {
        for (int i = 1; i <= k; ++i) cout << a[i] << ' ';
        cout << '\n';
        return;
    }
    for (int i = start; i <= n; ++i) {    // 起点是 start,不是 1
        a[step] = i;
        dfs(step + 1, i + 1);             // 下一层起点右移一格
    }
}

int main() { dfs(1, 1); }                 // C(5,3) = 10 行

③ 排列就是例题:候选集是“1..n 中所有没被用过的数”,用 vis 判重。三者对照一下:子集的分支数固定为 2,不需要循环;组合的循环起点是变的;排列的循环起点固定、靠 vis 排除。

还有一条容易忽略:子集模板里 --top 就是“回溯三行”的第三行,只不过这里占位的是一个栈指针而不是 vis。还原的对象永远是“你刚才改动的那份状态”,把它找出来,回溯就不会写错。

这一节的 6 个坑,每个都有具体症状

出口写成 if (step == n):最后一格没填就输出,n = 3 时每行只有两个数 → WA,样例就挂。

忘记 vis[i] = 0:只输出一条排列就结束,或方案数严重偏少 → WA,且程序不崩、不好查。

还原写在 dfs 之前:同一个数被反复填入不同位置,输出大量非法排列,n = 9 时输出量爆炸 → TLE。

组合里把 dfs(step + 1, i + 1) 写成 start + 1:只能生成连续区间,方案数少一大截 → WA。

数组开小(bool vis[9] 却要访问 vis[9]):越界写,结果随机 → RE 或莫名其妙的 WA。习惯是“题目上界 + 1~5 格余量”。

用 endl 输出 36 万行:每行强制刷新一次缓冲区 → TLE。循环里一律用 "\n" 或 printf。

自己动手复现

关掉这一页,把 27 行全排列程序从零敲一遍,交到 P1706,确认 n = 3 的输出和样例一字不差。

改一改①:把 for (int i = 1; i <= n; ++i) 改成从 n 递减到 1,先预测输出顺序,再跑一遍——它正好是字典序的完全倒序。

改一改②:在 dfs 开头加一个全局计数器 ++cnt,输出 n = 5 时的 cnt,和公式 1 + 5 + 20 + 60 + 120 + 120 = 326 对照。对不上就说明你的回溯没撤干净。

改一改③:把 a[step] = i; 后面补一句 a[step] = 0; 再跑,答案不会变——亲手验证“答案数组不需要还原”。

数学补给 · 方案数估算:n! 和 2ⁿ 的失控点

DFS 枚举的时间 = 搜索树节点数 × 每个节点的工作量。节点数是可以提前算出来的,这就是“能不能做”的唯一判据。

排列:A(n,k) = n!/(n−k)! 组合:C(n,k) = n!/(k!(n−k)!) 子集:2ⁿ = Σ C(n,k)

nn!2ⁿC(n, n/2)能不能枚举
9362880512126轻松
11399168002048462n! 已到 4×10⁷,勉强
124790016004096924n! 超 10⁸,只能枚举组合/子集
202.4×10¹⁸1048576184756只能枚举子集
23—8388608—2ⁿ 接近 10⁷,还能做
25—33554432—子集已吃力

判据:一般评测机 1 秒约做 10⁸ 次简单操作,但搜索的每个节点还要输出或判断,所以节点数控制在 10⁷ 以内才稳。于是 n! 的失控点在 n = 11~12,2ⁿ 的失控点在 n = 24~25,C(n, n/2) 在 n = 30 附近(1.5×10⁸)。

输出也会失控。P1706 的 n = 9 输出约 16 MB 还行;换成 n = 12 就是 4.8×10⁸ 个排列、约 28 GB,哪怕搜索一秒跑完,输出也要跑几分钟。看到“输出所有方案”先估输出量,这一条比估复杂度更早生效。

递归深度不用担心:深度等于层数,排列是 n 层、组合是 k 层,每层栈帧几十字节,n ≤ 10⁴ 都不会爆栈。下一节的网格搜索才会遇到“深度 = 状态数”导致的爆栈。

举一反三 · 想清楚这些再往下走
  1. n ≤ 9 时求“字典序第 k 个排列”,能不能不枚举全部 362880 个?提示:第一位定下之后,后面还有 (n−1)! 种,用这个数就能一位一位地定位 k。
  2. 如果题目改成“输出 1..n 的所有排列,但每个排列末尾要跟一个空格”,改一行 printf 能做到吗?如果改成“每个数字占 5 格、且两数之间固定一个空格”,宽度又该填多少?
  3. 子集枚举有“选/不选”递归版和第 14 节的二进制版,两者都是 2ⁿ。分别在什么情况下必须用递归版?(提示:想想“中途判断一下这个分支还有没有救”这件事在二进制版里好不好写。)
  4. 如果候选集合每层都在变,例如“第 i 位只能填比前一位更大的数”,start 参数该怎么传?这样生成的方案数是多少(用组合数表示)?
  5. 什么时候可以不用 vis?给你两个例子:网格里走过的格子、数组里两个位置的交换。它们各自用什么代替 vis?
  6. C(30, 15) ≈ 1.55×10⁸,直接 DFS 会超时。如果只需要“第 k 小的方案”,怎么利用 C(n,k) 的数值跳过整片子树?这个思路叫什么?(第 34 节数位 DP 会正式用到。)
自测:能全部回答再进下一节
挑战题 · 选数
洛谷 P1036普及-去洛谷做这题 ↗

给出 n 个整数 x₁…xₙ 和一个整数 k(k < n)。从这 n 个数中任选 k 个相加,会得到一系列和。求其中和为素数的方案数。例如 n = 4、k = 3,四个数为 3、7、12、19,四种组合的和分别是 22、29、38、34,只有 29 是素数。

输入格式:第一行两个整数 n、k(1 ≤ n ≤ 20,k < n);第二行 n 个整数 xᵢ(1 ≤ xᵢ ≤ 5×10⁶)。

输出格式:一行一个整数,表示和为素数的方案数。

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

k 是输入的,写不出 k 层 for 循环。想一个“层数由输入决定”的写法。

题面说的是“选 k 个”,3 + 7 + 12 和 7 + 3 + 12 是同一个方案,只能算一次。这个“去重”上一节有两套手段。

和是不是素数,只跟 k 个数加起来的结果有关,跟选的顺序无关——所以判断放在“选够 k 个”那一刻做一次就够。

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

模型。每个方案是一个 k 元下标集合 {i₁ < i₂ < … < i_k},答案是其中 sum 为素数的集合个数。

去重。用 start 参数保证下标递增:第 step 层从 start 开始枚举,下一层传 i + 1。这样 (3,7,12) 只会以 3→7→12 的顺序被生成一次,(7,3,12) 永远不会出现。

出口。step == k 时手里已经有 k 个数的和 sum,判一次素数,是就 ++ans,然后 return。为了省一个参数,也可以把和做成 dfs 的第三个参数往下传,进入下一层时直接 sum + x[i]。

判素数。只需试除到 √v:若 v 有大于 √v 的因子,那它必然还有一个小于 √v 的配对因子。本题 sum ≤ k × 5×10⁶ ≤ 9.5×10⁷,√sum < 10⁴,单次判断最多一万次取模。

再省一点。还剩 n − i + 1 个位置可选,如果不够 k − step 个了,后面的 i 全都不必试,直接 break。这是下一节要讲的“可行性剪枝”,在这里先见一次。

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

int n, k, ans;
int x[25];

// 试除到 sqrt(v) 就够:大于 sqrt 的因子一定配一个小于 sqrt 的
bool isPrime(int v) {
    if (v < 2) return false;
    for (int d = 2; d * d <= v; ++d)     // d*d 用 int 不会溢出,因为 d <= 10^4
        if (v % d == 0) return false;
    return true;
}

// step:已选个数;start:本层最小可选下标;sum:已选数之和
void dfs(int step, int start, int sum) {
    if (step == k) {                     // 选够 k 个,判一次
        if (isPrime(sum)) ++ans;
        return;
    }
    for (int i = start; i <= n; ++i) {
        if (n - i + 1 < k - step) break; // 剩下的数不够选了,剪枝
        dfs(step + 1, i + 1, sum + x[i]);   // 下标递增 → 不重
    }
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> n >> k;
    for (int i = 1; i <= n; ++i) cin >> x[i];

    dfs(0, 1, 0);                        // 选了 0 个,从下标 1 开始,和为 0
    cout << ans << "\n";
    return 0;
}
关键片段拆解
片段作用
dfs(int step, int start, int sum)三个参数分别是“进度、候选下界、当前和”。把 sum 顺着参数传,比用全局变量再回退更好写——少一次还原,就少一个出错的地方。
if (step == k)出口是“选够 k 个”,此时 sum 就是这 k 个数的和。注意和上一节的全排列不同:那里的出口在 n+1 层,这里的出口正好在第 k 层。
dfs(step + 1, i + 1, sum + x[i])整个去重就靠这个 i + 1。它让下标严格递增,于是每个集合只有一种生成顺序。
if (n - i + 1 < k - step) break;位置 i..n 共 n−i+1 个,还需要 k−step 个。装不下就 break(不是 continue),因为 i 再大只会更不够。
for (int d = 2; d * d <= v; ++d)试除到 √v。v ≤ 9.5×10⁷ 时 d 不超过 9747,d*d 远在 int 范围内。写成 d <= sqrt(v) 要调用浮点开方,慢且可能因精度差一,用乘法比较更稳。
if (v < 2) return false;1 不是素数。本题 sum 至少为 k 个正整数之和,不会小于 2,但把这个判断写上,函数才能通用。
这题最容易错的地方
  • 方案数按“排列”算:忘了 i+1 的递增约束,同一组数被重复计数 k! 次 → 答案偏大 k! 倍。
  • 判素数时循环写到 d < v:最坏 9.5×10⁷ 次取模,乘上 18 万次判断必然 TLE;写到 √v 才行。
  • 把 break 写成 continue:只是白跑,答案仍对但慢;反过来把剪枝条件写反(> 写成 <)会漏方案 → WA。
  • sum 用 int 是够的(≤ 9.5×10⁷),但如果把上界估成 20 × 10⁷ 并继续放大,就要换 long long,先估再用。
延伸阅读 / 参考