一条路走到黑,走不通就退回来换一条路 —— 今天学会「把答案一个一个试出来」
| 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 的全部语法基础。今天只是换个用法。
你站在一个迷宫入口,目标是从出口出去。你手上没有地图,怎么办?
最朴素也最有效的办法是:
一条路走到黑,走不通就退回来换路。
「退回来换路」这个动作有个正式名字,叫 回溯(backtracking)。
| 普通递归(比如昨天的斐波那契) | DFS 搜索 | |
|---|---|---|
| 目的 | 算出一个数值结果 | 枚举出一大堆具体方案 |
| 分支数 | 固定(比如 2 个) | 每层不一样,靠 for 循环枚举 |
有没有 for | 通常没有 | ✅ 必须有,这是最大区别 |
| 返回值 | 返回一个值 | 通常是 void,边走边输出/边统计 |
| 要不要撤销 | 不需要 | ⚠️ 需要(这就是回溯) |
题干里出现这些词,基本就是 DFS:
「输出所有……」「求出所有方案」「有多少种不同的选法」「按字典序输出全部……」
void dfs(参数){
if(到达边界 / 目标){ // ① 边界:该停了
记录 / 输出答案;
return;
}
for(枚举这一层所有可能的选择){ // ② 选择:下一步能做什么
if(这个选择不合法) continue; // 剪枝
做出选择; // 修改状态
dfs(更新后的参数); // ③ 递归:进入下一层
撤销选择; // ★ 回溯:恢复状态
}
}
就这十几行,今天 4 道题全是它的变体。请你现在就把这个骨架抄到笔记本上。
新手写 DFS 最容易卡在「不知道参数该传什么」。其实只要按顺序回答下面三个问题,参数自然就出来了。
| 要素 | 要回答的问题 | 通常变成什么 |
|---|---|---|
| 状态 | 「我现在走到哪一步了?」 | 一个 step / pos 参数 |
| 选择 | 「下一步我能做什么?」 | 一层 for 循环 |
| 边界 | 「什么时候算完成?什么时候死了?」 | 开头的 if ... return; |
| 要素 | 在这道题里是什么 |
|---|---|
| 状态 | 「已经填好了前 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 就不需要传参了。两种写法都可以,新手先用「需要什么就传什么」更清楚。
假设有 n 个物品,要从中挑出一些。站在第 idx 个物品面前,你只有两种选择:
idx + 1,代价/贡献加上它idx + 1,什么都不加所以整个搜索树是一棵二叉树:每一层分成两支,一共 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⁷ 就要考虑剪枝或者换算法。
这是今天最重要的一节,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 就像一条「只能往右走」的边界线,每次选择都把它往右推一格,这样永远不可能出现「回头选」的情况,
重复方案就被从源头上消灭了。
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 要求把 n 拆成若干个加数之和,加数要「从小到大排序」,且不能出现重复的拆分。
你会发现思路一模一样,只是把「选第几个数」换成了「这一位填多大的加数」:
| P1157 组合 | P2404 拆分 | |
|---|---|---|
| 要填多少位 | 固定 r 位 | 位数不定(凑够 n 为止) |
| 这一位填什么 | 一个下标 i | 一个加数的大小 i |
| 边界 | step == r | remain == 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(强制「严格递增」)。
| 组合 | 排列 | |
|---|---|---|
| 顺序重要吗 | 不重要(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 行。
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,且输出顺序天然是字典序。
| 搜索类型 | 方案数 | n=10 | n=15 | n=20 | n=25 |
|---|---|---|---|---|---|
| 子集(选/不选) | 2n | 1024 | 32768 | 1048576 | 33554432 |
| 组合 C(n, n/2) | C(n,n/2) | 252 | 6435 | 184756 | 5200300 |
| 全排列 | n! | 3628800 | 1.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 学。
剪枝的判断条件如果写得太激进,会把本来有解的方案也剪掉,结果答案偏小。
写完剪枝后,一定要用最小规模的数据手算对一遍,确认没剪错。
下面每道题的代码我都用 g++ -std=c++14 -O2 -Wall 实际编译运行过,输出是你看到的真实结果。
题意:从 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 → 输出这一行,returnsetw()
题目明确要求「每个数字需要 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"。a[25] 够用,但别只开 a[20]。题意:按字典序输出 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) 开始。a[15] 和 vis[15] 对应 n ≤ 9,注意 vis 下标到 n 为止,n=9 时刚好够;如果题目 n 到 10 就要开到 11 以上。#include <iomanip>。所有层共用同一个 vis 数组,这正是它能表达「哪些数已经被用了」的原因。
但反过来说,每层结束时必须把自己改的东西改回去,否则会污染兄弟分支 —— 这就是「回溯」存在的理由。
题意:给出 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)chosen == k → 判断 sum 是不是素数,是就 ans++,returnidx > n → 数用完了还没选够,returnn - idx + 1 < k - chosen → 剩下的数不够选,returnn=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)。int x[25] 是安全的,但别写成 int x[20]。n-idx+1 < k-chosen)才返回,写成 > 会把正常分支也砍掉。题意:把一个自然数 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 行) ✅ 最小值正确,没有输出单独的 28 → 21 行,末行 4+4 ✅ 末行正确(最大的首项是 ⌊8/2⌋ = 4,
且 5+3 不合法因为要求递增,4+4 才是字典序最后一组)i+1 而不是 i:重复加数被禁掉,答案少一大半(n=7 只剩 4 行)。if(pos < 2) return; 的话,第一行会多出 7,答案多 1 行。i <= n:会产生无效分支(比如还剩 2 却去试 i=5,导致 remain 变负,虽然不会输出但白跑)。写成 i <= remain 更干净。+。用 if(i) cout << "+"; 是标准写法。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 | 大规模输出用 endl | P1157 / P1706 输出 18 万~36 万行,明显变慢 | 用 "\n" |
| 10 | 没估算规模就开写 | n=25 还硬搜子集 → 必 TLE | 先算方案数,超 10⁷ 就剪枝或换算法 |
答:
组合用 start 递增(下一位从 i+1 开始)。
它约束的是「位置之间的关系」—— 强制序列严格递增,于是每种组合只有「递增的那一种写法」能被生成。
排列用 vis[] 标记(用过的数跳过)。
因为排列允许任意顺序,没法用「递增」来约束,只能记录「这个元素被用过了没有」,
保证同一个元素在一个序列里不重复出现。
一句话:组合管「顺序」,排列管「元素」。
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 行全丢了。这就是「回溯」这一步不能省的原因。
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 三个分支的叶子数。
答:因为两者的方案数增长速度完全不同,而 OJ 一秒大约只能跑 10⁷~10⁸ 次操作。
P1036 是选/不选型,方案数最多 C(20,10) = 184756(不到 20 万),加上剪枝实际更少,所以 n 敢开到 20。
P1706 是全排列型,方案数是 n!:9! = 362880(还行),
但 10! = 3628800、11! = 39916800 —— 而且每一行还要输出 n 个数字,
总输出量早就把时限撑爆了。所以 n 只能到 9。
结论:看到数据范围,先反推它暗示的搜索规模。
这是「模板默写」题,请合上文章自己写一遍再对照:
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 个叶子 → 必须剪枝或换算法
DFS 回溯进阶:网格与冲突检测
今天你学会的是「枚举所有方案」。明天要面对更麻烦的一类题目:方案之间会互相影响。
比如「八皇后」(两个皇后不能在同一行、同一列、同一对角线上),你一放下去就得检查有没有和前面的冲突;
再比如「迷宫计数」「找石油」这类网格题,要到格子里去走,还要用 vis 标记走过的格子防止绕圈。
明天的新东西:冲突检测的写法、网格 DFS 的四个方向、连通块标记。 今天学的「进入时标记、退出时撤销」会原封不动地用上 —— 所以今天的回溯一定要练熟。
小提醒:今天的 4 道题建议都动手敲一遍,尤其是 P1157 和 P1706, 明天写八皇后的时候你会发现它们的影子。