ACM 一个月学习计划 · DAY 18

Day 18 · DFS 入门:枚举所有方案

一条路走到黑,走不通就退回来换一条路 —— 今天学会「把答案一个一个试出来」

今日目标:能独立写出「选或不选」「组合」「全排列」三类搜索框架
预计用时:约 3.5 小时(含做题)  难度:普及-
题目来源:洛谷 P1157 / P1706 / P1036 / P2404
← Day 17 · 递推与记忆化

⏱今日时间安排

0 - 25 min理解 DFS 是「走迷宫」(第 1 节)
25 - 40 min背下搜索三要素:状态 / 选择 / 边界(第 2 节)
40 - 70 min两个核心框架:选或不选、下标递增(第 3、4 节)
70 - 90 min排列框架 + 回溯的对称性(第 5、6 节)
90 - 180 min做 4 道题,每题限时 30 分钟
180 - 210 min写题解笔记 + 做自测题
💡 和昨天内容的衔接

昨天你学的是「求出一个数」—— 方案总数、最大价值等等。今天要面对另一种需求:把每一个具体方案都列出来。

比如「输出 1 到 n 的所有全排列」「从 n 个数中选 k 个的所有选法」。这类问题答案本身就是一大堆序列, 没法用一个公式算出来,只能一个一个搜。

好消息是:你昨天写的递归,就是 DFS 的全部语法基础。今天只是换个用法。

1DFS 到底是什么:走迷宫

一个所有人都能懂的场景

你站在一个迷宫入口,目标是从出口出去。你手上没有地图,怎么办?

最朴素也最有效的办法是:

  1. 先随便挑一条岔路,一直往前走
  2. 走到死胡同了?原路退回到最近的那个岔路口
  3. 在那个岔路口换一条还没走过的路,继续走
  4. 重复上面三步,直到走到出口(或者所有路都试完)
🎯 这就是深度优先搜索(DFS)

一条路走到黑,走不通就退回来换路。

「退回来换路」这个动作有个正式名字,叫 回溯(backtracking)。

DFS 和普通递归的区别在哪

普通递归(比如昨天的斐波那契)DFS 搜索
目的算出一个数值结果枚举出一大堆具体方案
分支数固定(比如 2 个)每层不一样,靠 for 循环枚举
有没有 for通常没有✅ 必须有,这是最大区别
返回值返回一个值通常是 void,边走边输出/边统计
要不要撤销不需要⚠️ 需要(这就是回溯)
⚠️ 一句话辨认 DFS 题

题干里出现这些词,基本就是 DFS:

「输出所有……」「求出所有方案」「有多少种不同的选法」「按字典序输出全部……」

DFS 的代码骨架长这样

void dfs(参数){
    if(到达边界 / 目标){        // ① 边界:该停了
        记录 / 输出答案;
        return;
    }
    for(枚举这一层所有可能的选择){   // ② 选择:下一步能做什么
        if(这个选择不合法) continue;   // 剪枝
        做出选择;                     // 修改状态
        dfs(更新后的参数);             // ③ 递归:进入下一层
        撤销选择;                     // ★ 回溯:恢复状态
    }
}

就这十几行,今天 4 道题全是它的变体。请你现在就把这个骨架抄到笔记本上。

2写搜索先想清楚三件事

新手写 DFS 最容易卡在「不知道参数该传什么」。其实只要按顺序回答下面三个问题,参数自然就出来了。

要素要回答的问题通常变成什么
状态「我现在走到哪一步了?」一个 step / pos 参数
选择「下一步我能做什么?」一层 for 循环
边界「什么时候算完成?什么时候死了?」开头的 if ... return;

用 P1706 全排列举个例子

要素在这道题里是什么
状态「已经填好了前 step-1 个位置,现在要填第 step 个」
选择「第 step 个位置可以填 1~n 里还没被用过的任意一个数」
边界「step > n,说明 n 个位置都填满了 → 输出这个排列,return」

想清楚这三条,代码几乎是自动写出来的:

void dfs(int step){                 // 状态:当前填到第 step 位
    if(step > n){ 输出; return; }    // 边界
    for(int i = 1; i <= n; i++){     // 选择:这一位填 i
        if(!vis[i]){ ... }
    }
}
🎯 一个万能自问法

「我站在第 step 层,手里有哪些信息,下一步有几种走法,走到哪算完?」

把这四个问题用中文答一遍,写下来,然后逐条翻译成代码 —— 比对着空屏幕发呆有效一百倍。

⚠️ 参数太多怎么办

如果状态很多,参数列表会变得很长(比如 dfs(int step, int sum, int last, int cnt))。这是正常的,别怕。

另一种做法是把这些变量都写成全局变量,dfs 就不需要传参了。两种写法都可以,新手先用「需要什么就传什么」更清楚。

3框架 A:选或不选(最本质的一种搜索)

思想:对每一个元素,只有两个决定

假设有 n 个物品,要从中挑出一些。站在第 idx 个物品面前,你只有两种选择:

所以整个搜索树是一棵二叉树:每一层分成两支,一共 2n 个叶子,每个叶子对应一种子集。

模板(这个必须背下来)

void dfs(int idx, 其余状态){
    if(idx > n){          // 所有元素都考虑完了
        处理答案;
        return;
    }
    // 分支 1:选第 idx 个
    dfs(idx + 1, 状态加上 idx 的贡献);

    // 分支 2:不选第 idx 个
    dfs(idx + 1, 状态不变);
}
💡 为什么它这么重要

「选或不选」是搜索里最基础的思想。后面的0/1 背包(Day 26)本质上就是给这个搜索加了个记忆化, 子集枚举、折半搜索、状态压缩全是它的变体。

你今天写的 P1036 就是最标准的「选或不选」+ 一点剪枝。

「选或不选」的两个常见变体

变体长什么样什么时候用
纯二分叉dfs(idx+1, ...) 写两遍元素个数 ≤ 20 左右(220 ≈ 100 万)
带剪枝的二分叉进函数先判断「还有没有救」能提前排除大量无效分支时
⚠️ 规模必须估算

2n 增长极快:n=20 是 100 万(能跑),n=25 是 3300 万(悬),n=30 是 10 亿(必 TLE)。

写搜索之前先算一下最坏情况有多少个叶子节点,超过 10⁷ 就要考虑剪枝或者换算法。

4框架 B:组合搜索 —— 用「下标递增」杜绝重复

这是今天最重要的一节,P1157 和 P2404 都靠它。

先看问题:为什么「选或不选」不够用

题目要求「从 1~5 里选出 3 个数」,输出所有这些 3 个数的组合。正确答案是 10 种:

123 124 125 134 135 145 234 235 245 345

注意:123 和 321 和 213 是同一个组合(题目说「不分顺序」),只能算一次。

🚨 如果直接照搬「全排列」的写法会怎样

你会输出 5×4×3 = 60 行,其中每种组合被重复输出了 3! = 6 次。
比如 1 2 3、1 3 2、2 1 3、2 3 1、3 1 2、3 2 1 —— 六行全都是同一个组合。

答案正好多了 6 倍,全错。

解法:强制「后面选的数一定比前面的大」

关键洞察是:对每一种组合,我们约定只按「从小到大」这一种顺序来生成它。

那 1 2 3 会被生成,3 2 1 就不会 —— 因为它不满足递增。每种组合恰好只被生成一次。

实现方法:给 dfs 多加一个参数 start,表示「这一位最小能填几」。下一层递归时传 i + 1:

void dfs(int step, int start){
    if(step == r){ 输出; return; }
    for(int i = start; i <= n; i++){     // 从 start 开始,不许回头
        a[step] = i;
        dfs(step + 1, i + 1);            // ★ 关键:下一层从 i+1 起
    }
}
🎯 一句话记住这个框架

「这一位从 start 开始试,下一位从 i+1 开始。」

start 就像一条「只能往右走」的边界线,每次选择都把它往右推一格,这样永远不可能出现「回头选」的情况, 重复方案就被从源头上消灭了。

画一棵搜索树看看(n=5, r=3)

dfs(step=0, start=1)
│  这一位从 1 开始试
├── 放 1 → dfs(step=1, start=2)
│   ├── 放 2 → dfs(step=2, start=3)
│   │   ├── 放 3 → 输出 1 2 3
│   │   ├── 放 4 → 输出 1 2 4
│   │   └── 放 5 → 输出 1 2 5
│   ├── 放 3 → dfs(step=2, start=4)
│   │   ├── 放 4 → 输出 1 3 4
│   │   └── 放 5 → 输出 1 3 5
│   └── 放 4 → dfs(step=2, start=5)
│       └── 放 5 → 输出 1 4 5
├── 放 2 → dfs(step=1, start=3)
│   ├── 放 3 → ... 输出 2 3 4、2 3 5
│   └── 放 4 → ... 输出 2 4 5
├── 放 3 → ... 输出 3 4 5
├── 放 4 → dfs(step=1, start=5),第 3 位没得选,自然停住
└── 放 5 → 直接停住

数一下叶子:3 + 2 + 1 + 2 + 1 + 1 = 10 个,正好是 C(5,3) = 10。一个不多一个不少。

✅ 顺便白送一个好处:天然字典序

因为每一位都是从小到大试的,所以输出顺序天然就是字典序(1 2 3 → 1 2 4 → 1 2 5 → 1 3 4 ……)。

题目一般都会要求「按字典序输出」,用这个框架就自动满足了,不需要额外排序。

同一个框架换个用法:P2404 自然数拆分

P2404 要求把 n 拆成若干个加数之和,加数要「从小到大排序」,且不能出现重复的拆分。

你会发现思路一模一样,只是把「选第几个数」换成了「这一位填多大的加数」:

P1157 组合P2404 拆分
要填多少位固定 r 位位数不定(凑够 n 为止)
这一位填什么一个下标 i一个加数的大小 i
边界step == rremain == 0
避免重复dfs(step+1, i+1)dfs(pos+1, remain-i, i) ← 注意是 i 不是 i+1
⚠️ 唯一要动脑筋的地方:传 i 还是 i+1

P1157 传 i+1:因为组合里同一个数不能重复出现(选过 3 就不能再选 3)。

P2404 传 i:因为拆分里同一个加数可以重复出现(1+1+2 里的两个 1 是允许的)。

判断标准只有一条:题目允许同一个值被重复选用吗?允许就传 i(保持「不小于」),不允许就传 i+1(强制「严格递增」)。

5框架 C:排列搜索 —— 用 vis 标记防止重复用同一个数

组合和排列的区别

组合排列
顺序重要吗不重要(123 = 321)重要(123 ≠ 321)
防重复靠什么start 下标递增vis[] 标记已用
方案数(n 个数选 r 个)C(n, r)A(n, r) = n!/(n−r)!
例子(n=3, r=3)1 种6 种

模板

bool vis[N];        // vis[i] = true 表示数字 i 已经被用掉了

void dfs(int step){
    if(step > n){                    // 边界:n 个位置都填好了
        输出 a[1..n];
        return;
    }
    for(int i = 1; i <= n; i++){
        if(vis[i]) continue;         // ★ 用过了就跳过
        vis[i] = true;               // ① 进入时标记
        a[step] = i;
        dfs(step + 1);
        vis[i] = false;              // ② 退出时撤销(回溯)
    }
}
🎯 回溯的本质:进入时标记,退出时撤销

vis[i] = true; 和 vis[i] = false; 这两行必须成对出现,中间夹着 dfs 调用。

脑子里可以这样念:
「我要用 i 了 → 占住它 → 让下面的层接着走 → 下面的层走完了,我必须把 i 还回去,因为我要去试别的 i 了。」

如果忘了撤销,会怎样?第一个分支(比如 1 2 3)走完之后,vis[1]、vis[2]、vis[3] 全还是 true, 回到最外层想试 2 ... 的时候发现 2 已经被「占用」了,于是后面所有方案全部丢失, 最终只能输出 1 行。

画一遍 n=3 的搜索树(对照着敲一遍)

dfs(1)                       vis 全 false
├── i=1: vis[1]=true → dfs(2)
│   ├── i=2: vis[2]=true → dfs(3)
│   │   └── i=3: vis[3]=true → dfs(4) → 输出 1 2 3
│   │       (返回后 vis[3]=false)
│   │   (返回后 vis[2]=false)
│   └── i=3: vis[3]=true → dfs(3)
│       └── i=2: vis[2]=true → dfs(4) → 输出 1 3 2
│           (返回后 vis[2]=false)
│   (返回后 vis[1]=false)   ← ★ 把 1 还回去,才能开始试 2 开头
├── i=2: vis[2]=true → dfs(2)
│   ├── i=1 → 输出 2 1 3
│   └── i=3 → 输出 2 3 1
└── i=3: vis[3]=true → dfs(2)
    ├── i=1 → 输出 3 1 2
    └── i=2 → 输出 3 2 1

6 个叶子 = 3! = 6,且输出顺序天然是字典序。

6剪枝与复杂度估算

先学会估规模(这一步比写代码重要)

搜索类型方案数n=10n=15n=20n=25
子集(选/不选)2n102432768104857633554432
组合 C(n, n/2)C(n,n/2)25264351847565200300
全排列n!36288001.3×10¹²2.4×10¹⁸爆表
⚠️ 一条经验红线

一般 OJ 每秒能跑 10⁷ ~ 10⁸ 次简单操作。

所以:子集搜索 n ≤ 20 左右可以裸搜;全排列只能到 n ≤ 9~10。今天 4 道题的数据范围正好都卡在这个界限内 —— 出题人是算好了的。

剪枝:提前判断「这条路没救了」

剪枝就是「在还没走到边界的时候,就发现这条路不可能产生答案,于是立刻 return」。

P1036 里我用了这样一个剪枝:

if(n - idx + 1 < k - chosen) return;   // 剩下的数不够选了,直接放弃

翻译成人话:从第 idx 个到第 n 个一共还剩 n-idx+1 个数, 而我还需要再选 k-chosen 个。如果剩下的数不够选,那这条路必然失败,不用再往下走了。

💡 常见的三种剪枝思路

① 可行性剪枝:这条路走下去不够 / 超出了(比如上面这个)。
② 最优性剪枝:即使走到底也不可能比当前找到的最优解更好,直接放弃。
③ 顺序剪枝:先试「更可能有解」的分支,让最优性剪枝更早生效。

今天只需要掌握 ①,② 会在 Day 19 学。

🚫 剪枝千万别剪过头

剪枝的判断条件如果写得太激进,会把本来有解的方案也剪掉,结果答案偏小。

写完剪枝后,一定要用最小规模的数据手算对一遍,确认没剪错。

7四道例题详解

下面每道题的代码我都用 g++ -std=c++14 -O2 -Wall 实际编译运行过,输出是你看到的真实结果。

P1157 组合的输出 普及- · 组合搜索框架

题意:从 1, 2, …, n 里任取 r 个数,输出所有组合。每个组合占一行、元素按从小到大排列、每个数字占 3 个字符宽度,所有组合按字典序输出。

⚠️ 数据范围

1 < n < 21,0 ≤ r ≤ n。

n 最大 20、r 取 10 时方案数是 C(20,10) = 184756 —— 这是最大的输出量, 10 万行级别的输出,别用 endl。

思路

就是第 4 节讲的「下标递增」框架,一个字都不用改:

  • 状态:step(当前填第几位,从 0 开始)+ start(这一位最小能填几)
  • 选择:这一位填 start ~ n 里的任意一个
  • 边界:step == r → 输出这一行,return
💡 输出宽度用 setw()

题目明确要求「每个数字需要 3 个场宽」,并提示用 cout << setw(3) << x;。

别忘了 #include <iomanip> —— 不包含这个头文件,setw 会直接编译错误。

还有一个细节:setw 只对紧接着的下一次输出生效,所以每输出一个数都要写一次 setw(3)。

样例输入
5 3
样例输出
  1  2  3
  1  2  4
  1  2  5
  1  3  4
  1  3  5
  1  4  5
  2  3  4
  2  3  5
  2  4  5
  3  4  5

说明:题面样例在网上流传的纯文本版本里,第一行的前导空格常被吃掉。 但题目明确要求「每个元素占三个字符的位置」,所以每一行开头都有两个空格(个位数占不满 3 格,前面补空格)。

完整代码

#include <iostream>
#include <iomanip>
using namespace std;

int n, r;
int a[25];      // 存放当前正在拼的组合

// step:现在要填第几个数(从 0 开始)
// start:这一位最小能填几(★ 保证组合内部严格递增,从而不重复)
void dfs(int step, int start){
    if(step == r){                       // 边界:已经选满 r 个数
        for(int i = 0; i < r; i++) cout << setw(3) << a[i];
        cout << "\n";
        return;
    }
    for(int i = start; i <= n; i++){     // 只能从 start 往后选
        a[step] = i;
        dfs(step + 1, i + 1);            // ★ 下一层从 i+1 开始,不许回头
    }
}

int main(){
    cin >> n >> r;
    dfs(0, 1);
    return 0;
}
✅ 实测结果

官方样例 5 3 → 输出 10 行,第一行 [ 1 2 3],最后一行 [ 3 4 5], 与上面的样例输出逐行完全一致(包括每行开头的两个空格)。

补充测试(真实输出):

  • 20 10 → 184756 行,正好等于 C(20,10) = 184756,一个不重一个不漏。
    首行 [ 1 2 3 4 5 6 7 8 9 10],末行 [ 11 12 13 14 15 16 17 18 19 20],字典序正确。
  • 5 0 → 输出 1 行空行(r = 0 的空组合)。这是题面允许 r = 0 时程序的自然行为; 洛谷实际测评对 r = 0 的期望输出我无法离线确认(我没有洛谷账号,没法提交验证),好在正式比赛数据里几乎不会出现 r = 0。

易错点

  • 忘了 #include <iomanip>:setw 编译不过。
  • 把 dfs(step+1, i+1) 误写成 dfs(step+1, i):会出现 1 1 1、1 2 2 这种非法组合, 而且方案数暴涨。
  • start 传错成 1:等价于全排列,每种组合重复 3! 倍。
  • 边界写成 step == n:应该是 step == r(选够 r 个就停,不是填满 n 个位置)。
  • 用 endl:18 万行输出时会慢一截,用 "\n"。
  • 数组开小了:r 最大 20,a[25] 够用,但别只开 a[20]。
P1706 全排列问题 普及- · 排列搜索 + 回溯

题意:按字典序输出 1 ~ n 的所有不重复排列,每行一个序列,每个数字占 5 个字符宽度。

⚠️ 数据范围

1 ≤ n ≤ 9。

为什么只能到 9?因为 9! = 362880 行输出已经是极限了,而 10! = 3628800 行, 再加上每行 5 个场宽,输出文件会大到 OJ 不收。数据范围本身就是「搜索规模」的提示。

思路

用第 5 节的排列框架。关键是那个 vis 数组:它记录的不是「用了几个」,而是「具体哪一个被用了」。

为什么不能用 start 递增来防重复?因为排列里顺序是有意义的:1 2 3 和 2 1 3 都是合法答案。 所以「不许回头」这条规则在这里是错的,只能靠标记「这个数用过了没有」。

样例输入
3
样例输出
    1    2    3
    1    3    2
    2    1    3
    2    3    1
    3    1    2
    3    2    1

同上:每行开头有 4 个空格(个位数占 5 格宽度的补位)。

完整代码

#include <iostream>
#include <iomanip>
using namespace std;

int n;
int a[15];          // 当前排列
bool vis[15];       // vis[i] = true 表示数字 i 已经用过了

void dfs(int step){
    if(step > n){                       // 边界:n 个位置都填好了
        for(int i = 1; i <= n; i++) cout << setw(5) << a[i];
        cout << "\n";
        return;
    }
    for(int i = 1; i <= n; i++){        // 从小到大试每一个还没用过的数
        if(!vis[i]){
            vis[i] = true;              // 进入时标记
            a[step] = i;
            dfs(step + 1);
            vis[i] = false;             // ★ 退出时撤销(回溯)
        }
    }
}

int main(){
    cin >> n;
    dfs(1);
    return 0;
}
✅ 实测结果

官方样例 3 → 输出 6 行,第一行 [ 1 2 3],最后一行 [ 3 2 1], 与样例输出逐行完全一致。

补充测试(真实输出):9 → 362880 行,正好等于 9! = 362880, 首行 [ 1 2 3 4 5 6 7 8 9],末行 [ 9 8 7 6 5 4 3 2 1]。 行数正确 + 首末行是字典序的两个端点,说明既没漏也没重。

易错点

  • 忘了写 vis[i] = false;:这是全排列题的头号杀手。忘了撤销,n=3 时只会输出 1 行 1 2 3,其余全丢。
  • 用了 start 递增那套:那是组合的套路。排列里 2 1 3 是合法答案,用 start 会把一半方案砍掉。
  • 边界写成 step == n:那最后一个位置就没填。要用 step > n,搭配从 dfs(1) 开始。
  • 输出宽度写错:题目要 5 个场宽,写成 3 或 4 都会 PE(格式错误)。
  • 数组开小了:a[15] 和 vis[15] 对应 n ≤ 9,注意 vis 下标到 n 为止,n=9 时刚好够;如果题目 n 到 10 就要开到 11 以上。
  • 忘了 #include <iomanip>。
💡 一个重要观察:vis 是「全局共享」的

所有层共用同一个 vis 数组,这正是它能表达「哪些数已经被用了」的原因。 但反过来说,每层结束时必须把自己改的东西改回去,否则会污染兄弟分支 —— 这就是「回溯」存在的理由。

P1036 [NOIP 2002 普及组] 选数 普及- · 选或不选 + 素数判断

题意:给出 n 个整数和整数 k(k < n)。从 n 个数里任选 k 个相加, 能得到一堆和。问:这些和里面,素数有多少个?

例如 n=4, k=3, 数是 3 7 12 19,四种选法的和分别是 22、29、38、34, 只有 29 是素数,所以答案是 1。

⚠️ 数据范围(两个都重要)

1 ≤ n ≤ 20,k < n;1 ≤ xi ≤ 5×10⁶。

由 n ≤ 20 可以算出方案数:最多 C(20,10) = 184756 种,裸搜完全没问题(这正是题目把 n 卡在 20 的原因)。

由 xi ≤ 5×10⁶ 可以算出和的上限:最大 20 × 5×10⁶ = 10⁸, 所以「和」要存在 int 里没问题,但素性判断的循环变量建议用 long long(用 d*d <= v 判断时 d 的平方不会溢出)。

思路

这是最标准的「选或不选」框架,加一个剪枝:

  • 站在第 idx 个数面前,chosen 表示已经选了几个,sum 是当前的和
  • 选:dfs(idx+1, chosen+1, sum + x[idx])
  • 不选:dfs(idx+1, chosen, sum)
  • 边界 1:chosen == k → 判断 sum 是不是素数,是就 ans++,return
  • 边界 2:idx > n → 数用完了还没选够,return
  • 剪枝:n - idx + 1 < k - chosen → 剩下的数不够选,return
🎯 选或不选的搜索树有多大

n=20 时最多 2²⁰ = 1048576 个叶子。每个叶子都要做一次素性判断, 如果每个都老老实实除到 √10⁸ = 10⁴,那就是 10¹⁰ 次运算 —— 会超时。

但实际不会:因为判断合数时只要找到一个因子就立刻 return。绝大多数和都能被 3、5、7、11 之类的小素数很快整除掉, 平均只需要十几次除法。这就是为什么这题敢用试除法。

样例输入
4 3
3 7 12 19
样例输出
1

完整代码

#include <iostream>
using namespace std;

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

bool isPrime(long long v){
    if(v < 2) return false;                  // 0 和 1 都不是素数
    for(long long d = 2; d * d <= v; d++){   // 试除到 √v 就够
        if(v % d == 0) return false;         // ★ 找到因子立刻退出
    }
    return true;
}

// idx:正在考虑第 idx 个数(1 开始)
// chosen:已经选了几个
// sum:已选数字之和
void dfs(int idx, int chosen, long long sum){
    if(chosen == k){                    // 选够了,判断和是不是素数
        if(isPrime(sum)) ans++;
        return;
    }
    if(idx > n) return;                 // 数用完了还没选够
    if(n - idx + 1 < k - chosen) return; // ★ 剪枝:剩下的数不够选了

    dfs(idx + 1, chosen + 1, sum + x[idx]);   // 选第 idx 个
    dfs(idx + 1, chosen, sum);                // 不选第 idx 个
}

int main(){
    cin >> n >> k;
    for(int i = 1; i <= n; i++) cin >> x[i];
    dfs(1, 0, 0);
    cout << ans << endl;
    return 0;
}
✅ 实测结果

官方样例 4 3 / 3 7 12 19 → 1 完全一致

压力测试(真实输出,用文件重定向计时避开终端开销):

  • 最坏规模 n=20, k=10、10 个大数(都是奇素数):答案 0(10 个奇数相加必为偶数且大于 2,确实不可能是素数 —— 结果合理), 耗时 32 ~ 37 ms(三次运行)。
  • 随机数据 n=20, k=10、20 个 1~5×10⁶ 的随机数:答案 11027,耗时 195 ~ 234 ms(三次运行)。
    对照 C(20,10) = 184756 种选法,11027 个素数和约占 6%,和 1/ln(5×10⁷) ≈ 6% 的素数密度吻合,结果可信。

易错点

  • isPrime 忘了处理 v < 2:0 和 1 都不是素数,漏判会把它们算成素数。虽然本题 xi ≥ 1、k ≥ 1, 和最小是 1,还是补上更保险。
  • 循环条件写成 d <= v/2 或 d <= v:能过但慢得离谱。要用 d * d <= v(试除到 √v)。
  • 索引从 1 开始但数组只开了 n:int x[25] 是安全的,但别写成 int x[20]。
  • 「选/不选」两个分支的顺序:本题不影响答案(只是数的顺序不同),但如果题目要求按字典序输出方案,就必须先「选」后「不选」。
  • 剪枝写反了:是「剩下的少于需要的」(n-idx+1 < k-chosen)才返回,写成 > 会把正常分支也砍掉。
P2404 自然数的拆分问题 普及- · 搜索 + 有序枚举

题意:把一个自然数 n(n > 1)拆成若干个小于 n 的自然数之和。 每个拆分里的数字要从小到大排列,输出所有拆分,按字典序。

比如 n=7,答案以 1+1+1+1+1+1+1 开头,以 3+4 结尾,共 14 行。 注意 7 自己不算(题目要求加数都小于 n,所以至少要拆成两个数)。

⚠️ 数据范围

2 ≤ n ≤ 8。范围给得很小是有意的:n 的分拆数是随 n 指数级增长的, n=8 时只有 21 种,而 n=20 就有 627 种、n=50 有 204226 种。

所以这题随便搜都能过,重点在于「怎么写才能不重复、且天然字典序」。

思路

还是第 4 节的「有序枚举」框架,只是把「选下标」换成「填加数」:

  • 状态:pos(正在填第几个加数)、remain(还差多少凑够 n)、start(这一位最小能填几)
  • 选择:这一位填 start ~ remain 里的任意一个数
  • 边界:remain == 0 → 凑齐了,输出
  • 去重 + 有序:下一位传 i(不是 i+1),保证「后面的数不小于前面的数」
🎯 为什么传 i 而不是 i+1

题目要求「后面的数不小于前面的数」—— 是「不小于」,所以允许相等(1+1+2 里的两个 1)。 因此下界传 i 而不是 i+1。

如果传了 i+1,就变成了「严格递增」,1+1+1+… 这种方案会全被砍掉,n=7 只剩下 1+2+4、1+6、2+5、3+4 这 4 行,答案全错。

💡 怎么保证「字典序最小优先」

因为每一位都是从 start 开始从小到大试的,而「先到的先输出」, 所以生成的顺序天然就是字典序。什么都不用额外做。

可以自己验证:n=7 的第一行是 1+1+1+1+1+1+1(能多小就多小),最后一行是 3+4 —— 完全符合样例。

样例输入
7
样例输出
1+1+1+1+1+1+1
1+1+1+1+1+2
1+1+1+1+3
1+1+1+2+2
1+1+1+4
1+1+2+3
1+1+5
1+2+2+2
1+2+4
1+3+3
1+6
2+2+3
2+5
3+4

完整代码

#include <iostream>
using namespace std;

int n;
int a[15];      // 存当前这一组加数

// pos:现在要决定第 pos 个加数
// remain:还差多少才能凑够 n
// start:这一位最小能填几(★ 强制不减,避免出现重复方案)
void dfs(int pos, int remain, int start){
    if(remain == 0){                 // 正好凑齐
        if(pos < 2) return;          // ★ 至少要有两个加数(不能只有 n 自己)
        for(int i = 0; i < pos; i++){
            if(i) cout << "+";        // 第一个数前面不加加号
            cout << a[i];
        }
        cout << "\n";
        return;
    }
    for(int i = start; i <= remain; i++){   // 试这一位填 i
        a[pos] = i;
        dfs(pos + 1, remain - i, i);        // ★ 下一位至少从 i 开始(可以相等)
    }
}

int main(){
    cin >> n;
    dfs(0, n, 1);
    return 0;
}
✅ 实测结果

官方样例 7 → 输出 14 行,从 1+1+1+1+1+1+1 到 3+4, 与样例输出逐行完全一致。

补充测试(真实输出):

  • 2 → 1+1(1 行) ✅ 最小值正确,没有输出单独的 2
  • 8 → 21 行,末行 4+4 ✅ 末行正确(最大的首项是 ⌊8/2⌋ = 4, 且 5+3 不合法因为要求递增,4+4 才是字典序最后一组)

易错点

  • 传 i+1 而不是 i:重复加数被禁掉,答案少一大半(n=7 只剩 4 行)。
  • 没排除「n 自己」:不加 if(pos < 2) return; 的话,第一行会多出 7,答案多 1 行。
  • 循环上界写成 i <= n:会产生无效分支(比如还剩 2 却去试 i=5,导致 remain 变负,虽然不会输出但白跑)。写成 i <= remain 更干净。
  • 输出的加号位置错:第一个数前面不能有 +。用 if(i) cout << "+"; 是标准写法。
  • 数组开小了:n=8 时最长的拆分是 1+1+1+1+1+1+1+1,共 8 个加数,a[15] 足够。
  • 用排序去重:有些同学先搜出所有拆分再排序去重,那是「先污染后治理」。 正确做法是从搜索时就保证不产生重复,这样既快又不会漏。

✗今日常见错误汇总

#错误写法后果正确写法
1回溯时忘了撤销 vis[i]=false第一个分支走完后所有数都被「占用」,只剩 1 行输出标记与撤销成对写,中间夹 dfs
2组合题里 dfs(step+1, i)出现 1 1 1 这种非法组合,方案数暴涨dfs(step+1, i+1)
3组合题里 start 一直传 1变成全排列,每种组合重复 r! 倍下一层传 i+1
4拆分题里传 i+1丢了所有含重复加数的方案(n=7 只剩 4 行)允许相等就传 i
5边界条件写错(step==n vs step>n)少填一位 / 多跑一层,输出少一行或多越界按「第几位开始、第几位结束」想清楚
6忘记 #include <iomanip>setw 编译错误用了 setw 就加头文件
7输出宽度不对(3 写成 5,或 5 写成 3)全部 PE(格式错误),一分不得严格照题目说的场宽
8数组开得刚好等于 n下标到 n 越界 → 玄学 WA开 n + 5 以上
9大规模输出用 endlP1157 / P1706 输出 18 万~36 万行,明显变慢用 "\n"
10没估算规模就开写n=25 还硬搜子集 → 必 TLE先算方案数,超 10⁷ 就剪枝或换算法

✓自测:你真的会了吗

第 1 题:组合和排列,搜索时防止重复的手段有什么本质区别?

答:

组合用 start 递增(下一位从 i+1 开始)。 它约束的是「位置之间的关系」—— 强制序列严格递增,于是每种组合只有「递增的那一种写法」能被生成。

排列用 vis[] 标记(用过的数跳过)。 因为排列允许任意顺序,没法用「递增」来约束,只能记录「这个元素被用过了没有」, 保证同一个元素在一个序列里不重复出现。

一句话:组合管「顺序」,排列管「元素」。

第 2 题:P1706 里如果忘了写 vis[i] = false;,n=3 会输出几行?

答:1 行,输出 1 2 3。

过程:最外层 i=1,标记 vis[1],进第二层;第二层 i 从 1 开始,1 被占,所以选 2,标记 vis[2]; 第三层选 3,标记 vis[3],输出 1 2 3。

然后逐层返回,但没有一层把标记清掉。回到最外层继续 i=2 时, vis[2] 还是 true,skip;i=3 时 vis[3] 还是 true,skip。循环结束,程序退出。

所以只输出 1 行,另外 5 行全丢了。这就是「回溯」这一步不能省的原因。

第 3 题:P1157 输入 5 3,一共会输出多少行?怎么算出来的?

答:10 行。

就是组合数 C(5,3) = 5!/(3!·2!) = 120/(6×2) = 10。

另一条思路:从 1 开头有 C(4,2)=6 种,从 2 开头有 C(3,2)=3 种, 从 3 开头有 C(2,2)=1 种,6+3+1 = 10。这正好对应搜索树里最外层 i=1,2,3 三个分支的叶子数。

第 4 题:为什么 P1036 的 n 只能到 20,而 P1706 的 n 只能到 9?

答:因为两者的方案数增长速度完全不同,而 OJ 一秒大约只能跑 10⁷~10⁸ 次操作。

P1036 是选/不选型,方案数最多 C(20,10) = 184756(不到 20 万),加上剪枝实际更少,所以 n 敢开到 20。

P1706 是全排列型,方案数是 n!:9! = 362880(还行), 但 10! = 3628800、11! = 39916800 —— 而且每一行还要输出 n 个数字, 总输出量早就把时限撑爆了。所以 n 只能到 9。

结论:看到数据范围,先反推它暗示的搜索规模。

第 5 题(手写代码):从 n 个数里选 k 个,输出所有组合的下标(不输出具体值)

这是「模板默写」题,请合上文章自己写一遍再对照:

int n, k;
int a[25];

void dfs(int step, int start){
    if(step == k){                        // 选够了
        for(int i = 0; i < k; i++){
            if(i) cout << " ";
            cout << a[i];
        }
        cout << "\n";
        return;
    }
    for(int i = start; i <= n; i++){      // ★ 从 start 开始
        a[step] = i;
        dfs(step + 1, i + 1);             // ★ 下一位从 i+1 开始
    }
}

int main(){
    cin >> n >> k;
    dfs(0, 1);
}

★ 检验标准:输入 5 3 必须输出 10 行,且第一行是 1 2 3、最后一行是 3 4 5。 如果输出了 60 行,说明 start 参数传错了。

▤今日速查卡(截图存手机)

【DFS 万能骨架】
  void dfs(状态参数){
      if(边界){ 输出/记录; return; }
      for(枚举这一层的所有选择){
          if(不合法) continue;      // 剪枝
          做出选择;                 // 修改状态
          dfs(下一层);
          撤销选择;                 // ★ 回溯
      }
  }

【写搜索前先回答三个问题】
  状态:我现在在哪一步?      → 一个 step / pos 参数
  选择:下一步能做什么?      → 一层 for 循环
  边界:什么时候算完成/死了? → 开头的 if...return

【框架 A:选或不选】
  void dfs(int idx, ...){
      if(idx > n){ 处理答案; return; }
      dfs(idx + 1, 选 idx 的状态);     // 选
      dfs(idx + 1, 不选的状态);        // 不选
  }
  // 复杂度 2^n,n ≤ 20 可裸搜

【框架 B:组合(★下标递增,防重复)】
  void dfs(int step, int start){
      if(step == r){ 输出; return; }
      for(int i = start; i <= n; i++){
          a[step] = i;
          dfs(step + 1, i + 1);        // ★ 关键:i+1
      }
  }
  // 复杂度 C(n,r),天然字典序
  // 允许同一值重复用 → 改成传 i

【框架 C:全排列(★vis 标记 + 回溯)】
  void dfs(int step){
      if(step > n){ 输出; return; }
      for(int i = 1; i <= n; i++){
          if(vis[i]) continue;
          vis[i] = true;               // 进入时标记
          a[step] = i;
          dfs(step + 1);
          vis[i] = false;              // ★ 退出时撤销
      }
  }
  // 复杂度 n!,n ≤ 9~10

【传 i 还是 i+1?】
  组合:不许重复用同一个值  →  dfs(step+1, i+1)
  拆分:允许重复用同一个值  →  dfs(pos+1, remain-i, i)

【输出格式】
  #include <iomanip>
  cout << setw(3) << x;    // 3 个场宽,只对下一次输出生效
  cout << "\n";            // 大量输出别用 endl

【规模红线】
  2^n   n≤20 可以裸搜
  C(n,k)  n≤20 可以裸搜
  n!    n≤9~10 可以裸搜
  超过 10^7 个叶子 → 必须剪枝或换算法

→明天预告 · Day 19

DFS 回溯进阶:网格与冲突检测

今天你学会的是「枚举所有方案」。明天要面对更麻烦的一类题目:方案之间会互相影响。

比如「八皇后」(两个皇后不能在同一行、同一列、同一对角线上),你一放下去就得检查有没有和前面的冲突; 再比如「迷宫计数」「找石油」这类网格题,要到格子里去走,还要用 vis 标记走过的格子防止绕圈。

明天的新东西:冲突检测的写法、网格 DFS 的四个方向、连通块标记。 今天学的「进入时标记、退出时撤销」会原封不动地用上 —— 所以今天的回溯一定要练熟。

小提醒:今天的 4 道题建议都动手敲一遍,尤其是 P1157 和 P1706, 明天写八皇后的时候你会发现它们的影子。