0题目原文(本地存了一份)
原题在洛谷上(页头有链接)。但别人的网站不归我们管 —— 打不开、改版、题号调整都可能发生, 到那时候连题目都没了,这一页就成了半篇。所以每个解析页都把题面转录一份存在本地, 跟着仓库一起进版本库。
下面这段转录自洛谷 P1157,日期见页头的「题面本地存档」。 以原站为准;两边不一致时,信原站。
题目描述
从 n 个自然数 1, 2, ..., n 中抽出 r 个元素(不分顺序,且 r ≤ n),要求输出所有组合。
例如 n = 5、r = 3 时,所有组合为:123,124,125,134,135,145,234,235,245,345。
输入格式
一行两个自然数 n、r。
输出格式
所有的组合,每一个组合占一行,且其中的元素按由小到大的顺序排列; 每个元素占三个字符的位置;所有的组合也按字典顺序排列。
数据范围
1 < r ≤ n < 21
1先看清楚它到底要什么
题面里有三件事,少看一件就会挂:
- 不分顺序 ——
1 2 3和2 1 3是同一个组合,只能出现一次。这是全题的题眼。 - 每个元素占三个字符 —— 不是空格分隔,是
%3d。这一条和算法毫无关系,但错了就是 WA。 - 按字典顺序 —— 组合之间要排好序,不能想到哪个输出哪个。
输入
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 = 5、r = 3)就是它的样例。上面那段输出是仓库里的
p1157.cpp 真跑出来的,不是手敲的 —— 注意每个数前面都有两个空格(%3d)。
2第 ① 版:拿第 3 章的全排列改一改
刚学完第 3 章,手边就有一份全排列(perm.cpp)。它的出口是「第 n 位放完了」:
if (step == n) { 输出; return; }
要「从 n 个里选 r 个」,最自然的动作是把出口从 n 改成 r:
if (step == r) { 输出; return; }
这一步几乎人人都会这么做,所以我们把它真写出来跑一遍。
// 洛谷 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;}点「运行 ▶」看结果
它输出了 60 行,而答案只有 10 行。翻到第 3 行和第 8 行看看:
1 2 3 <- 第 1 行
...
2 1 3 <- 后面还有它
这份代码本身一个 bug 都没有 —— 它老老实实地把「从 n 个数里有顺序地取 r 个」全列了出来,
也就是排列 A(n, r)。
而题目要的是组合 C(n, r):1 2 3 和 2 1 3 算一个。
两者的关系是一个干净的等式:
A(n, r) = C(n, r) × r!也就是说,每一个组合都被它数了 r! 遍(那 r 个数的 r! 种排法各数一次)。
n = 5、r = 3 时 3! = 6,正好 10 × 6 = 60。
★ 记住这个感觉:当你的输出正好是答案的某个整数倍时,八成是「同一个东西被数了很多遍」。
3第 ② 版:排个序,重复的扔掉
看清病症之后,最自然的补救是:既然顺序不算数,那我就把每组排好序,重复的丢掉。
vector<int> comb(pick, pick + r);
sort(comb.begin(), comb.end()); // 顺序不算数了
seen.insert(comb); // set 自动去重,而且自动按字典序
// 洛谷 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;}点「运行 ▶」看结果
答案对了。而且用 set<vector<int>> 连「按字典序」都一并解决了。
交上去会怎样?
它治的是症状,不是病根:那 A(n, r) 条排列照样一条不落地生成了,
只是最后在 set 里被合并掉。
题面写着 n < 21,所以最坏情况是 n = 20、r = 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 只有「在里面 / 不在里面」
—— 根本没有顺序这回事
一个数只有「在」和「不在」两种身份,顺序这个概念压根不存在 ⇒ 天生不会重复。
// 洛谷 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;}点「运行 ▶」看结果
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; // ★ 剩下的全要也不够,掉头
// 洛谷 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;}点「运行 ▶」看结果
第 ③ 版和第 ④ 版的输出必须逐字节相同,这一条已经写成断言钉住了。
两句剪枝在做的是同一件事:把「不可能」提前认出来,而不是走到底再发现。
n = 20、r = 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 组合的输出 —— 第 ⑤ 版:回到排列那棵树,**只改 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;}点「运行 ▶」看结果
第 ③ 版照样 AC,第 ④ 版更快一点。这一版赢在说得清: 树上每一个节点都对应一个真答案的前缀,没有一步是白走的。
⇒ 「最漂亮的写法」和「能过的写法」是两个话题。考场上先要后者,复盘时再追前者。
7五个版本并排:换一把可复现的尺子
秒表在这儿量不出什么(除了第 ② 版直接跑不完,其余几版都在毫秒级,量到的大半是起进程的开销)。
所以数次数:dfs() 被调用了多少次。次数换台机器也不会变。
// 洛谷 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;}点「运行 ▶」看结果
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 章第 12 步那棵)——「每层决定这一位放谁」。
- 第 ③ ④ 版走的是子集树(第 3 章第 6 步那棵)——「每层决定这个数要不要」。
- 第 ⑤ 版是排列树改了一个起点之后的样子,而它长得和组合的定义一模一样。
★ 一道题能被两棵树都做出来,说明「选哪棵树」本身就是一个决定,而且是最要紧的那个决定。 下次卡住的时候,先别急着调代码,先问一句:我是不是选错了树?
⇒ 接着往下走的话,第 4 章 N 皇后会把「进入 → 递归 → 撤销」和剪枝练成肌肉记忆 —— 这一页第 ④ 版那两句,就是它的预告。