题单 · 习题解析

洛谷 P1157 组合的输出

从排列到组合,五个版本一步一步走:第一版错在哪、第二版为什么跑不完、第三版就已经能过了

原题:洛谷 P1157出自 第 3 章 递归 = 决策树:子集、组合、全排列 的题单题面本地存档:2026-08-25
⚠ 先自己写一遍,再往下看

这一页不是标准答案,是一条阶梯:把大多数人真实会写出的第一版摆出来, 看它错在哪、慢在哪,再一步一步改。所以它对「已经自己动手撞过一次墙」的人最有用。
还没写过的话,先去写 —— 写出来的哪怕是错的,也比直接看这一页收获大。

0题目原文(本地存了一份)

为什么要在这儿抄一份题面

原题在洛谷上(页头有链接)。但别人的网站不归我们管 —— 打不开、改版、题号调整都可能发生, 到那时候连题目都没了,这一页就成了半篇。所以每个解析页都把题面转录一份存在本地, 跟着仓库一起进版本库。

下面这段转录自洛谷 P1157,日期见页头的「题面本地存档」。 以原站为准;两边不一致时,信原站

题目描述

从 n 个自然数 1, 2, ..., n 中抽出 r 个元素(不分顺序,且 r ≤ n),要求输出所有组合。

例如 n = 5r = 3 时,所有组合为:123,124,125,134,135,145,234,235,245,345。

输入格式

一行两个自然数 n、r。

输出格式

所有的组合,每一个组合占一行,且其中的元素按由小到大的顺序排列; 每个元素占三个字符的位置;所有的组合也按字典顺序排列。

数据范围

1 < r ≤ n < 21

1先看清楚它到底要什么

题面里有三件事,少看一件就会挂:

  1. 不分顺序 —— 1 2 32 1 3同一个组合,只能出现一次。这是全题的题眼。
  2. 每个元素占三个字符 —— 不是空格分隔,是 %3d。这一条和算法毫无关系,但错了就是 WA。
  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

题面里举的那个例子(n = 5r = 3)就是它的样例。上面那段输出是仓库里的 p1157.cpp 真跑出来的,不是手敲的 —— 注意每个数前面都有两个空格(%3d)。

2第 ① 版:拿第 3 章的全排列改一改

刚学完第 3 章,手边就有一份全排列(perm.cpp)。它的出口是「第 n 位放完了」:

if (step == n) { 输出; return; }

要「从 n 个里选 r 个」,最自然的动作是把出口从 n 改成 r

if (step == r) { 输出; return; }

这一步几乎人人都会这么做,所以我们把它真写出来跑一遍。

p1157Perm.cpp第 ① 版(错的)
输入 5 3。答案该是 10 行,数一数它输出了多少行。
// 洛谷 P1157 组合的输出 —— 第 ① 版:把第 3 章的全排列改一改(**这一版是错的**)
//
// 输入:n r(1 < r <= n < 21)
// 输出:本该是所有组合,实际是所有**排列**
//
// 这份为什么存在:它是几乎所有人的第一反应,所以它必须被写出来、被跑一遍、被看见错在哪。
// 「教科书直接给标准答案」的毛病就在这儿 —— 跳过这一步,学生不知道自己为什么会走错。
//
// 改动只有一处:第 3 章 perm.cpp 的出口是 `if (step == n)`,这里改成 `if (step == r)`。
// 看起来完全合理:本来选 n 个,现在只选 r 个。
//
// ⚠ 但它给出的是 A(n, r) 行,不是 C(n, r) 行。n = 5、r = 3 时它输出 60 行,而答案只有 10 行。
// 因为「1 2 3」和「2 1 3」在它眼里是两回事 —— 排列关心顺序,组合不关心。
// ⇒ 这一版错在**题意**上,不在代码上。代码写得一点毛病都没有。
#include <bits/stdc++.h>
using namespace std;
int n, r;
int pick[25]; // pick[0..step-1] = 已经选好的数
bool used[25];
void dfs(int step) {
if (step == r) { // ← 唯一改动:n 换成 r
for (int i = 0; i < r; i++) printf("%3d", pick[i]);
printf("\n");
return;
}
for (int v = 1; v <= n; v++) {
if (used[v]) continue;
used[v] = true; // 进入
pick[step] = v;
dfs(step + 1); // 递归
used[v] = false; // 撤销
}
}
int main() {
if (!(cin >> n >> r)) return 0;
if (r < 0 || r > n) return 0;
dfs(0);
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

它输出了 60 行,而答案只有 10 行。翻到第 3 行和第 8 行看看:

  1  2  3        <- 第 1 行
  ...
  2  1  3        <- 后面还有它
⚠ 错在哪:代码没毛病,题意看漏了

这份代码本身一个 bug 都没有 —— 它老老实实地把「从 n 个数里有顺序地取 r 个」全列了出来, 也就是排列 A(n, r)

而题目要的是组合 C(n, r)1 2 32 1 3 算一个。

两者的关系是一个干净的等式:

A(n, r) = C(n, r) × r!

也就是说,每一个组合都被它数了 r! 遍(那 r 个数的 r! 种排法各数一次)。 n = 5r = 33! = 6,正好 10 × 6 = 60

★ 记住这个感觉:当你的输出正好是答案的某个整数倍时,八成是「同一个东西被数了很多遍」。

3第 ② 版:排个序,重复的扔掉

看清病症之后,最自然的补救是:既然顺序不算数,那我就把每组排好序,重复的丢掉。

vector<int> comb(pick, pick + r);
sort(comb.begin(), comb.end());     // 顺序不算数了
seen.insert(comb);                  // set 自动去重,而且自动按字典序
p1157Set.cpp第 ② 版(对但慢)
输入 5 3,这回是 10 行,完全正确。再试试 12 6 —— 还是很快。然后想想 20 10 会怎样。
// 洛谷 P1157 组合的输出 —— 第 ② 版:排列 + 排序 + set 去重(**对,但跑不完**)
//
// 输入:n r
// 输出:所有组合,字典序(set<vector<int>> 天然就是字典序)
//
// 这份为什么存在:发现第 ① 版把「1 2 3」和「2 1 3」当成两个之后,
// 最自然的补救就是「排个序,重复的扔掉」。**这个想法是对的,答案也是对的。**
//
// ⚠ 但它治的是症状不是病根:那 A(n, r) 条排列**照样一条不落地生成了**,
// 只是最后被丢进 set 里合并掉。n = 20、r = 10 时 A(20,10) = 670 442 572 800 ——
// 六千七百亿次递归,而正确答案只有 C(20,10) = 184 756 行。
// ⇒ **多算的倍数正好是 r! = 10! = 3 628 800** —— 每个组合的 r! 种排列各被数了一遍。
// (A(n,r) / C(n,r) = r!,这不是巧合,就是组合数的定义。)
//
// ★ 这一版最值钱的一课:**「答案对」和「能过」是两件事。**
// 小数据(比如样例的 n=5 r=3)它跑得飞快,一交上去就是 TLE。
// ⇒ 提交前先拿题面给的上界估一估工作量,这一步只要三秒钟。
#include <bits/stdc++.h>
using namespace std;
int n, r;
int pick[25];
bool used[25];
set<vector<int>> seen; // 排好序的组合,set 自动去重、自动字典序
void dfs(int step) {
if (step == r) {
vector<int> comb(pick, pick + r);
sort(comb.begin(), comb.end()); // ← 补救:先排序,顺序就不算数了
seen.insert(comb); // ← 再去重
return;
}
for (int v = 1; v <= n; v++) {
if (used[v]) continue;
used[v] = true;
pick[step] = v;
dfs(step + 1);
used[v] = false;
}
}
int main() {
if (!(cin >> n >> r)) return 0;
if (r < 0 || r > n) return 0;
dfs(0);
for (const vector<int>& comb : seen) {
for (int v : comb) printf("%3d", v);
printf("\n");
}
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

答案对了。而且用 set<vector<int>> 连「按字典序」都一并解决了。

交上去会怎样?

⚠ 「答案对」和「能过」是两件事

它治的是症状,不是病根:那 A(n, r) 条排列照样一条不落地生成了, 只是最后在 set 里被合并掉。

题面写着 n < 21,所以最坏情况是 n = 20r = 10

它要生成的     A(20, 10) = 670 442 572 800   ≈ 6.7 × 10^11
真正的答案     C(20, 10) =         184 756
多做的倍数     10! =             3 628 800

六千七百亿次递归 —— 交上去必然 TLE,而且不是差一点,是差六个数量级。

★ 这一版真正值钱的一课不是代码,是这个动作: 交之前,拿题面给的上界估一估工作量。 这一步只要三秒钟,比任何调试都值。 小数据(比如样例)它跑得飞快,什么都看不出来 —— 这正是最坑人的地方。

4第 ③ 版:换一棵树 —— 而且这一版就已经能 AC 了

上一版的病根,一句话就能说清:

排列树本来就会把同一个组合数很多遍。 在它上面去重,是在给一棵长歪的树擦地板。

那就换一棵树。第 3 章讲子集时用的正是另一棵:每一层决定的不是「第几位放谁」, 而是「第 i 个数选不选」。

      排列树                          子集树
  每层:这一位放谁?               每层:这个数要不要?
  一个岔口 n 支                    一个岔口 2 支
  1 2 3 和 2 1 3 是两片叶子        1、2、3 只有「在里面 / 不在里面」
                                   —— 根本没有顺序这回事

一个数只有「在」和「不在」两种身份,顺序这个概念压根不存在 ⇒ 天生不会重复。

p1157Subset.cpp第 ③ 版(能 AC)
输入 5 3,10 行,和第 ② 版逐字节相同。试试 20 10 —— 18 万行,眨眼就完。
// 洛谷 P1157 组合的输出 —— 第 ③ 版:换成第 3 章那棵**子集树**(★ 这一版就能 AC)
//
// 输入:n r
// 输出:所有组合,字典序
//
// 这份为什么存在:第 ② 版的病根是「排列树本来就会把同一个组合数很多遍」。
// 换一棵树就没这个病了 —— 第 3 章讲子集时那棵树,每层决定的是
// **「第 i 个数选不选」**,而不是「第 step 位放谁」。
// 一个数只有「在里面 / 不在里面」两种身份,**根本没有顺序这回事** ⇒ 天生不重复。
//
// ★★ 请特别记住这一版:**它已经能 AC 了。**
// n < 21,整棵子集树 2^(n+1) - 1 <= 2 097 151 个节点,跑一遍连 0.1 秒都不到。
// 考场上写到这儿就该去做下一题了。
// 「不写到最优就不配交」是教科书给人的错觉 —— 能过的分和最优解的分一模一样。
//
// 为什么输出正好是字典序:每一层都先走「选」再走「不选」,
// 于是含 1 的组合全部排在不含 1 的前面,含 2 的排在不含 2 的前面……层层如此,正好是字典序。
//
// ⚠ 它仍然有一处浪费,第 ④ 版会补上:已经选够 r 个了它还继续往下走,
// 剩下的数一个都不够了它也照走不误。
#include <bits/stdc++.h>
using namespace std;
int n, r;
int pick[25]; // pick[0..cnt-1] = 已经选进来的数
// 轮到「第 i 个数选不选」,此刻已经选了 cnt 个
void dfs(int i, int cnt) {
if (i > n) { // n 个数都表过态了
if (cnt == r) {
for (int k = 0; k < r; k++) printf("%3d", pick[k]);
printf("\n");
}
return;
}
pick[cnt] = i; // 选它
dfs(i + 1, cnt + 1);
dfs(i + 1, cnt); // 不选它
// 不用撤销:pick[cnt] 只会被「比它靠后的层」重新写掉,
// 而读 pick 的地方只读 [0, cnt)。和第 4 章讲的判据是同一条。
}
int main() {
if (!(cin >> n >> r)) return 0;
if (r < 0 || r > n) return 0;
dfs(1, 0);
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★★ 请特别记住这一版:它已经能过了

n < 21,整棵子集树最多 2^(n+1) - 1 = 2 097 151 个节点,跑一遍连 0.1 秒都不到。

考场上写到这里就该去做下一题了。

「不写到最优就不配交」是教科书给人的错觉。评测机只看两件事:答案对不对、时间够不够。 能过的分和最优解的分一模一样

⇒ 所以做题的正确顺序是:先估一估「最笨但正确的做法要多久」, 够快就直接写它;不够快,才去想怎么改进。而不是一上来就找最优解。

✓ 顺带解决了「字典序」,而且不用排序

为什么它的输出正好是字典序?因为每一层都先走「选」,再走「不选」

于是所有含 1 的组合,全部排在不含 1 的前面;在含 1 的那一片里,含 2 的又排在不含 2 的前面…… 层层如此,出来就是字典序。一行排序代码都没写。

★ 这是「树的形状 = 输出的顺序」第一次派上用场。以后写 DFS 的时候多留意: 你把哪个分支写在前面,就决定了答案是按什么顺序出来的。

5第 ④ 版:加两句剪枝

第 ③ 版已经能过,但它有两处明摆着的白工:

  • 已经选够 r 个了,它还继续往下问「第 i 个数要不要」;
  • 剩下的数全都选上也凑不够 r 个了,它还在那条路上接着走。

这正是第 4 章「剪枝」的预演 —— 两句话:

if (cnt == r) { 输出; return; }                 // ★ 够了,后面不用问了
if (cnt + (n - i + 1) < r) return;              // ★ 剩下的全要也不够,掉头
p1157Prune.cpp第 ④ 版
输入 5 3,输出和第 ③ 版逐字节相同 —— 剪枝不改变答案,只改变工作量。
// 洛谷 P1157 组合的输出 —— 第 ④ 版:给子集树加两句剪枝
//
// 输入:n r
// 输出:所有组合,字典序(和第 ③ 版逐字节相同)
//
// 这份为什么存在:第 ③ 版已经能过了,但它把功夫花在了两种明摆着没戏的地方 ——
// 这正好是第 4 章「剪枝」的预演,而且两句话就能写完:
//
// ① 已经选够 r 个了 —— 后面的数一个都不用再问,直接输出走人;
// ② 剩下的数**全选**都还不够 r 个 —— 这条路上一个答案都长不出来,掉头。
//
// ★ 两句都在做同一件事:**把「不可能」提前认出来,而不是走到底再发现**。
// p1157Count.cpp 会把省下来的节点数量出来 —— n = 20、r = 10 时从 2 097 151 降到 705 431(省了三分之二)。
//
// ⚠ 剪枝**不改变答案**,只改变工作量。所以第 ③ 版和这一版的输出必须逐字节相同,
// 这一条已经写成断言(check-viz.mjs 里第 3 章那一段)。
#include <bits/stdc++.h>
using namespace std;
int n, r;
int pick[25];
void dfs(int i, int cnt) {
if (cnt == r) { // ★ 剪枝 ①:够了,后面不用问了
for (int k = 0; k < r; k++) printf("%3d", pick[k]);
printf("\n");
return;
}
if (cnt + (n - i + 1) < r) return; // ★ 剪枝 ②:剩下的全要也不够
if (i > n) return; // 兜底(有了 ② 其实走不到,留着更好读)
pick[cnt] = i;
dfs(i + 1, cnt + 1);
dfs(i + 1, cnt);
}
int main() {
if (!(cin >> n >> r)) return 0;
if (r < 0 || r > n) return 0;
dfs(1, 0);
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★ 剪枝改的是工作量,不是答案

第 ③ 版和第 ④ 版的输出必须逐字节相同,这一条已经写成断言钉住了。

两句剪枝在做的是同一件事:把「不可能」提前认出来,而不是走到底再发现n = 20r = 10 时节点数从 2 097 151 掉到 705 431 —— 省了三分之二。

6第 ⑤ 版:回到排列那棵树,只改 for 的起点

题单里那句「从排列改成组合,只需改一个地方 —— 想清楚是哪个」,说的就是这一版。

把第 ① 版(也就是第 3 章的 perm.cpp)和它并排放:

// 排列:从 1 开始挑,挑过的用 used[] 挡住
for (int v = 1; v <= n; v++) {
    if (used[v]) continue;
    used[v] = true;
    pick[step] = v;
    dfs(step + 1);
    used[v] = false;
}

// 组合:从「刚选的那个 + 1」开始挑
for (int v = start; v <= n; v++) {
    pick[step] = v;
    dfs(step + 1, v + 1);
}
★ 改的就是那个起点

下一层不再从 1 开始挑,而是从「我刚选的那个数 + 1」开始挑。

于是选出来的数天生递增。而一个组合,写成递增序列只有一种写法 —— 重复从根上就没了,不用去重,也不用排序。

★★ 而且改完之后你会发现:used[] 整个消失了,连带那句撤销也没了。

「不许重复用同一个数」这条规则,被「起点」这个表示法吃掉了。 (第 2 章那句「换个表示法就消掉一条规则」、第 4 章开头用排列表示法消掉「同行同列」—— 都是同一招。看见一次记不住,看见三次就是你的了。)

p1157.cpp第 ⑤ 版(最终)
输入 5 3。和第 ③ ④ 版逐字节相同。这一版最短,也最说得清。
// 洛谷 P1157 组合的输出 —— 第 ⑤ 版:回到排列那棵树,**只改 for 的起点**
//
// 输入:n r
// 输出:所有组合,字典序
//
// 这份为什么存在:题单里那句「从排列改成组合,只需改一个地方 —— 想清楚是哪个」说的就是它。
//
// 把第 ① 版(也就是第 3 章的 perm.cpp)和这一份并排看:
//
// 排列:for (int v = 1; v <= n; v++) { if (used[v]) continue; ... }
// 组合:for (int v = start; v <= n; v++) { ... }
// ~~~~~~~
//
// **改的就是那个起点**:下一层不从 1 开始挑,而是从「我刚选的这个数 + 1」开始挑。
// 于是选出来的数天生递增 —— 递增的序列每个组合只有一种写法,**重复从根上就没了**。
//
// ★★ 而且改完之后 `used[]` 整个消失了,连带那句撤销也没了:
// 「不许重复用同一个数」这条规则,被**起点**这个表示法吃掉了。
// (第 2 章那句「换个表示法就消掉一条规则」,第 4 章开头也用同一招消掉了「同行同列」。)
//
// ⚠ 别把「只改一个地方」理解成「只有这一版才对」:第 ③ 版照样 AC。
// 这一版赢在**说得清**:树上每个节点都对应一个真答案的前缀,没有一步是白走的。
#include <bits/stdc++.h>
using namespace std;
int n, r;
int pick[25];
// 轮到选第 step 个数(0 基),只许从 start 往后挑
void dfs(int step, int start) {
if (step == r) {
for (int k = 0; k < r; k++) printf("%3d", pick[k]);
printf("\n");
return;
}
for (int v = start; v <= n; v++) { // ★ 全部的改动就在这个 start 上
pick[step] = v;
dfs(step + 1, v + 1); // 下一个只能比 v 大
}
}
int main() {
if (!(cin >> n >> r)) return 0;
if (r < 0 || r > n) return 0;
dfs(0, 1);
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
⚠ 别把「只改一个地方」理解成「只有这一版才对」

第 ③ 版照样 AC,第 ④ 版更快一点。这一版赢在说得清: 树上每一个节点都对应一个真答案的前缀,没有一步是白走的

⇒ 「最漂亮的写法」和「能过的写法」是两个话题。考场上先要后者,复盘时再追前者。

7五个版本并排:换一把可复现的尺子

秒表在这儿量不出什么(除了第 ② 版直接跑不完,其余几版都在毫秒级,量到的大半是起进程的开销)。 所以数次数dfs() 被调用了多少次。次数换台机器也不会变。

p1157Count.cpp数次数
不用输入,直接跑。最后一行 20 / 10 就是题目的上界。
// 洛谷 P1157 的五个版本,各要走多少个节点 —— 换一把可复现的尺子
//
// 输入:无(直接跑,表是写死的四组 n / r,最后一组就是题目的上界)
// 输出:一张表,对每组 (n, r) 列出
// 组合数 C(n,r) 正确答案有多少行
// ① 排列 + 去重 p1157Perm / p1157Set 走的节点数
// ③ 子集树 p1157Subset
// ④ 加剪枝 p1157Prune
// ⑤ 改 for 的起点 p1157.cpp
//
// 「节点数」的定义全表统一:**dfs() 被调用了多少次**。
//
// ⚠ ① 那一列是**算出来的,不是跑出来的** —— n = 20 时它有七千多亿个节点,
// 跑不完。这本身就是这一列要说明的事:
// Σ P(n,k)(k = 0..r)是排列树前 r 层的节点数,公式一行就写完,而秒表永远等不到它。
// 另外三列都是真跑出来的。
//
// ★ 为什么用「次数」不用秒表:秒数换台机器就变,次数不变
// (第 21、36、38 章都是这么干的;第 4 章第 11 步刚用过同一招)。
#include <bits/stdc++.h>
using namespace std;
int n, r;
long long nodes = 0;
/* ③ 子集树:每层决定「第 i 个数选不选」 */
void subset(int i, int cnt) {
nodes++;
if (i > n) return;
subset(i + 1, cnt + 1);
subset(i + 1, cnt);
}
/* ④ 同一棵树,加两句剪枝 */
void prune(int i, int cnt) {
nodes++;
if (cnt == r) return;
if (cnt + (n - i + 1) < r) return;
if (i > n) return;
prune(i + 1, cnt + 1);
prune(i + 1, cnt);
}
/* ⑤ 排列树,只把 for 的起点从 1 换成 start */
void pickFrom(int step, int start) {
nodes++;
if (step == r) return;
for (int v = start; v <= n; v++) pickFrom(step + 1, v + 1);
}
/** C(n, r):n < 21,long long 装得下(C(20,10) = 184756) */
long long comb(int a, int b) {
long long c = 1;
for (int i = 1; i <= b; i++) c = c * (a - b + i) / i;
return c;
}
/** ① 排列树前 r 层的节点数 = Σ P(n,k),k = 0..r。跑不完,只能算 */
long long permNodes(int a, int b) {
long long total = 0, p = 1; // p = P(a, k)
for (int k = 0; k <= b; k++) {
total += p;
p *= (a - k); // P(a, k+1) = P(a, k) × (a - k)
}
return total;
}
long long countWith(void (*run)(int, int), int a, int b, int x, int y) {
n = a; r = b; nodes = 0;
run(x, y);
return nodes;
}
int main() {
// ⚠ 表头一律用 ASCII 的「1.」而不是「①」:圈号是「东亚宽度=歧义」,
// 在页面上按双宽渲染、在别的终端可能按单宽,列就歪了(同 check:text 第 ① 条那个坑)。
cout << " n r 组合数 1.排列+去重 3.子集树 4.加剪枝 5.改起点\n";
cout << "--- -- ------- ------------- ---------- --------- ---------\n";
const int rows[][2] = { {5, 3}, {10, 5}, {16, 8}, {20, 10} };
for (const auto& row : rows) {
int a = row[0], b = row[1];
cout << setw(3) << a
<< setw(5) << b
<< setw(10) << comb(a, b)
<< setw(16) << permNodes(a, b)
<< setw(13) << countWith(subset, a, b, 1, 0)
<< setw(12) << countWith(prune, a, b, 1, 0)
<< setw(12) << countWith(pickFrom, a, b, 0, 1) << "\n";
}
cout << "\n第 1 列是用公式算的(它跑不完);另外三列是真跑出来的。\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
  n    r    组合数     1.排列+去重     3.子集树    4.加剪枝    5.改起点
---   --   -------   -------------   ----------   ---------   ---------
  5    3        10              86           63          39          26
 10    5       252           36101         2047         923         638
 16    8     12870       582913217       131071       48619       39203
 20   10    184756    736891600001      2097151      705431      616666

四件事值得盯着看:

  • 第 1 列(排列 + 去重)在飞速失控n 从 16 到 20,它从 5.8 亿涨到 7368 亿, 而答案只从 1.2 万涨到 18 万。⇒ 这一列是算出来的,不是跑出来的 —— 它跑不完,秒表永远等不到它。
  • 第 3 列(子集树)只有 209 万,而且它只跟 n 有关(2^(n+1) - 1),和 r 没关系。 这一列就是「能过」的那条线。
  • 第 4、5 列贴着答案走:70 万、61 万,都在 18 万这个量级附近。
  • 第 4 列和第 5 列差得不多(705431 对 616666,1.14 倍)—— ⇒ 换了棵树 + 剪了枝,剩下的那点差距已经不重要了。 真正的胜负在前面那一步。
★ 两种「快」,差着一个数量级的意义
换一棵树     6.7 × 10^11  ->  2.1 × 10^6      跑不完  ->  能过
加剪枝 / 换起点   2.1 × 10^6  ->  6.2 × 10^5      能过    ->  更快一点

第一步救命,第二步锦上添花。 顺序不能反 —— 在一棵长歪的树上抠常数,是给不该走的路擦地板(第 4 章第 11 步刚说过同一件事)。

8⚠ 和算法无关,但一定会挂人的那一条

⚠ 输出格式:每个元素占三个字符

题面白纸黑字写着「每个元素占三个字符的位置」。所以是:

printf("%3d", v);          // ✓  输出「  1」「 12」

不是:

printf("%d ", v);          // ✗  输出「1 」—— 算法全对,评测机判 WA
cout << v << " ";          // ✗  同上

cout 的话对应写法是 cout << setw(3) << v;(要 #include <iomanip>)。

★ 这类「算法全对、格式挂掉」的失分,在初学阶段占比高得吓人,而且对拍抓不到 (你自己写的两份程序格式一样,比不出问题)。 ⇒ 唯一的办法是回头把输出格式那一句再读一遍,然后对着样例逐字节比。 样例框里那段是真跑出来的,可以直接拿去比。

9回头看:你其实把第 3 章的两棵树都用上了

✓ 这一道题串起了第 3 章的全部内容
  • 第 ① ② 版走的是排列树(第 3 章第 12 步那棵)——「每层决定这一位放谁」。
  • 第 ③ ④ 版走的是子集树(第 3 章第 6 步那棵)——「每层决定这个数要不要」。
  • 第 ⑤ 版是排列树改了一个起点之后的样子,而它长得和组合的定义一模一样。

★ 一道题能被两棵树都做出来,说明「选哪棵树」本身就是一个决定,而且是最要紧的那个决定。 下次卡住的时候,先别急着调代码,先问一句:我是不是选错了树?

⇒ 接着往下走的话,第 4 章 N 皇后会把「进入 → 递归 → 撤销」和剪枝练成肌肉记忆 —— 这一页第 ④ 版那两句,就是它的预告。