题单 · 习题解析

洛谷 P1706 全排列问题

算法第 3 章就写完了,坑在别处:漏撤销会怎样、判重的两种写法、以及那个 5 场宽

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

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

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

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

原题在洛谷上(页头有链接)。别人的网站不归我们管,打不开、改版、题号调整都可能发生。 所以每个解析页都把题面转录一份存在本地,跟着仓库一起进版本库。

转录自洛谷 P1706,日期见页头。两边不一致时信原站。

题目描述

按照字典序输出自然数 1 到 n 所有不重复的排列,即 n 的全排列, 要求所产生的任一数字序列中不允许出现重复的数字。

输入格式:一个整数 n。

输出格式:由 1 到 n 组成的所有不重复的数字序列,每行一个序列。 每个数字保留 5 个场宽。

数据范围1 ≤ n ≤ 9

1先看清楚:这是第 3 章的模板题,坑在别处

算法部分第 3 章已经写完了 —— perm.cpp 原样交上去就行。所以这一页要讲的是另外两件事:

① 那个「撤销」漏了会怎样        —— 第 3 章自测清单里点名的头号错误
② 每个数字保留 5 个场宽         —— 和算法毫无关系,但错了就是 WA

输入

3

输出

    1    2    3
    1    3    2
    2    1    3
    2    3    1
    3    1    2
    3    2    1

n = 3。注意每个数字前面都有 4 个空格 —— 那是 %5d 的效果,不是空格分隔。 上面那段输出是仓库里的 p1706.cpp 真跑出来的。

2第 ① 版:忘了把 used 改回去

第 3 章的自测清单里有这么一条:

perm.cpp 里的 used[v] = false 删掉跑一次,说明白为什么会那样。

这一页把它真写出来,好让你看见那个「那样」到底长什么样。

p1706NoUndo.cpp第 ① 版(错的)
输入 3。它只打出一行。想清楚为什么是「一行」,而不是「少几行」。
// 洛谷 P1706 全排列问题 —— 第 ① 版:忘了把 used 改回去(**这一版是错的**)
//
// 输入:n(1 <= n <= 9)
// 输出:本该是 1..n 的全部排列,每个数占 5 个场宽
//
// 这份为什么存在:第 3 章的自测清单里点名了这个错 ——
// 「把 perm.cpp 里的 used[v] = false 删掉跑一次,说明白为什么会那样」。
// 这里把它真写出来,好让你看见那个「那样」到底长什么样。
//
// ⚠ 现象非常极端:**它只输出一行**(n >= 2 时)。
// 因为第一条路走到底之后,1..n 全被标成「用过了」,而**没有任何一个被还回来** ——
// 于是回到上一层想换个数时,一个能用的都找不到,整棵树当场枯死。
//
// ★ 这个错的可怕之处不在这道题(一眼就看出来),在别的题:
// 多数题目只输出一个**计数**或**最优值**,漏了撤销只会让答案**悄悄变小**,
// 不报错、不崩溃、样例还可能碰巧对。第 4 章 N 皇后那一章专门讲这件事。
#include <bits/stdc++.h>
using namespace std;
int n;
int pick[12];
bool used[12];
void dfs(int step) {
if (step == n) {
for (int i = 0; i < n; i++) printf("%5d", 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)) return 0;
dfs(0);
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
⚠ 现象极端:它只输出一行

第一条路走到底之后,1..n 全被标成「用过了」,而没有任何一个被还回来。 于是回到上一层想换个数时,一个能用的都找不到 —— 整棵树当场枯死

dfs(0)  试 v=1  -> used[1]=true
  dfs(1)  试 v=2 -> used[2]=true
    dfs(2)  试 v=3 -> used[3]=true
      dfs(3)  打印 1 2 3
    回到 dfs(2):v=3 用完没还,1、2 也没还 -> 循环里一个都进不去 -> 返回
  回到 dfs(1):同样一个都进不去 -> 返回
回到 dfs(0):同样 -> 结束

★ 但这个错真正的可怕之处不在这道题(一眼就看出来),在别的题:

多数题目只输出一个计数最优值,漏了撤销只会让答案悄悄变小 —— 不报错、不崩溃、样例还可能碰巧对。 第 4 章 N 皇后那一章专门讲这件事(n=8 会从 92 变成 4)。

3第 ② 版:不用 used[],每次回头翻一遍(也能过)

想不起来「标记数组」这个套路的时候,人的自然反应是: 「我要放的这个数,前面用过了吗?回头翻一遍不就知道了。」

bool taken(int step, int v) {
    for (int i = 0; i < step; i++) if (pick[i] == v) return true;
    return false;
}
p1706Find.cpp第 ② 版(也能过)
和第 ③ 版逐字节相同。n = 9 也照样一瞬间。
// 洛谷 P1706 全排列问题 —— 第 ② 版:不用 used[],每次回头找一遍(对,也能过)
//
// 输入:n
// 输出:1..n 的全部排列,字典序,每个数占 5 个场宽
//
// 这份为什么存在:想不起来「标记数组」这个套路的时候,人的自然反应是 ——
// **「我要放的这个数,前面用过了吗?回头翻一遍不就知道了。」**
//
// for (int i = 0; i < step; i++) if (pick[i] == v) 用过了;
//
// ⇒ 这是对的,而且这道题 n <= 9,**它照样满分**。
//
// ★ 它和第 ③ 版的差别只有一处:判一次「用过没有」要花多少功夫。
// 这一版 O(n)(回头翻) 第 ③ 版 O(1)(查一个数组)
// ⚠ 而这正是第 4 章 N 皇后那一章展开讲的东西 ——「打勾册子」这个套路。
// 那一章有一整节在讲:**冲突检查从「问所有人」变成「问那样东西」。**
//
// ⇒ 所以别觉得这一版「low」:想不起来标记数组时先写它,能过就是能过。
#include <bits/stdc++.h>
using namespace std;
int n;
int pick[12];
/** v 在 pick[0..step-1] 里出现过吗 */
bool taken(int step, int v) {
for (int i = 0; i < step; i++) if (pick[i] == v) return true;
return false;
}
void dfs(int step) {
if (step == n) {
for (int i = 0; i < n; i++) printf("%5d", pick[i]);
printf("\n");
return;
}
for (int v = 1; v <= n; v++) {
if (taken(step, v)) continue;
pick[step] = v;
dfs(step + 1);
// 不用撤销:taken() 只看 pick[0..step-1],写在 pick[step] 上的痕迹没人读得到
}
}
int main() {
if (!(cin >> n)) return 0;
dfs(0);
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★ 别觉得这一版「low」—— 它照样满分

它和第 ③ 版的差别只有一处:判一次「用过没有」要花多少功夫

这一版   O(n)   回头把已经选的翻一遍
第 ③ 版  O(1)   查一个标记数组

n ≤ 9,这点差别一分钱都不值。想不起来标记数组时先写它,能过就是能过。

⇒ 而这两种写法的对比,正是第 4 章 N 皇后那一章展开讲的东西: 「打勾册子」这个套路 —— 把「问所有人」换成「问那样东西」。 那一章有一整节在讲它,因为到了 N 皇后,这个差别就开始决定生死了。

★ 顺带注意:这一版不需要撤销。因为 taken() 只看 pick[0..step-1], 写在 pick[step] 上的痕迹没有人读得到。 要不要撤销,看的永远是「别人会不会读到你留下的痕迹」。

4第 ③ 版:标记数组 + 三段式(正解)

for (int v = 1; v <= n; v++) {
    if (used[v]) continue;
    used[v] = true;  pick[step] = v;      // 进入
    dfs(step + 1);                        // 递归
    used[v] = false;                      // 撤销  <- 第 ① 版少的就是这行
}
p1706.cpp第 ③ 版(正解)
输入 4 看 24 行。这就是第 3 章的 perm.cpp,一个字都没改。
// 洛谷 P1706 全排列问题 —— 第 ③ 版:标记数组 + 进入/递归/撤销(正解)
//
// 输入:n(1 <= n <= 9)
// 输出:1..n 的全部排列,按字典序,**每个数占 5 个场宽**
//
// 这就是第 3 章 perm.cpp 那份代码,一个字都没变 —— 这道题是它的模板题。
// 三段式请练成肌肉记忆:
//
// used[v] = true; pick[step] = v; // 进入
// dfs(step + 1); // 递归
// used[v] = false; // 撤销 <- 忘了这行见第 ① 版
//
// 为什么输出正好是字典序:内层循环 v 从 1 到 n 从小到大试,
// 于是每一层都先走小的那一支 ⇒ 出来就是字典序。**一行排序都没写。**
//
// ⚠ 和算法完全无关、但真会挂人的一条:题面写的是「每个数字保留 5 个场宽」,
// 所以是 printf("%5d", v),不是空格分隔。见解析页第 ⑤ 步。
#include <bits/stdc++.h>
using namespace std;
int n;
int pick[12];
bool used[12];
void dfs(int step) {
if (step == n) {
for (int i = 0; i < n; i++) printf("%5d", 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)) return 0;
dfs(0);
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

进入 → 递归 → 撤销,三行必须一起写。 写完 dfs(step+1) 的那一秒就把撤销补上,再回头想别的 —— 靠结构,别靠记性。

✓ 为什么输出正好是字典序,而且一行排序都没写

内层循环 v 从 1 到 n 从小往大试,于是每一层都先走小的那一支。

⇒ 以 1 开头的全部排在前面;在它们内部,第二位小的又排在前面…… 层层如此,出来就是字典序。

你把哪个分支写在前面,就决定了答案按什么顺序出来。 这条以后写 DFS 会一直用到(P1157 第 ④ 步是同一件事的另一张脸)。

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

⚠ 每个数字保留 5 个场宽

题面白纸黑字写着「每个数字保留 5 个场宽」。所以是:

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

不是:

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

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

★ 这类「算法全对、格式挂掉」的失分,在初学阶段占比高得吓人,而且对拍抓不到 —— 你自己写的两份程序格式一样,比不出问题。

⇒ 唯一的办法是回头把输出格式那一句再读一遍,然后对着样例逐字节比。 上面样例框里那段是真跑出来的,可以直接拿去比。

6第 ④ 版:标准库里本来就有(知道就行)

p1706Stl.cpp第 ④ 版(STL)
和第 ③ 版逐字节相同。整道题变成一个 do-while。
// 洛谷 P1706 全排列问题 —— 第 ④ 版:标准库里本来就有(知道就行)
//
// 输入:n
// 输出:和第 ③ 版逐字节相同
//
// 这份为什么存在:C++ 标准库里有 next_permutation ——
// 它把当前排列变成**字典序里的下一个**,没有下一个就返回 false。
// 于是整道题变成一个 do-while。
//
// ★ 但请注意它的位置:**它是给你省时间的,不是给你替代理解的。**
// · 考场上要「把所有排列列出来」,用它当然可以,写得快、不容易错;
// · 可一旦题目变成「排列 + 剪枝」(第 4 章 N 皇后)、
// 「组合而不是排列」(P1157)、「边生成边判断」——
// next_permutation 一概帮不上忙,因为**它不让你站在决策过程的中间**。
//
// ⇒ 所以顺序是:**先把第 ③ 版练到闭着眼睛能写,再把这一版当成一个顺手的工具。**
// 反过来的话,你会在第一道需要剪枝的题上原地卡住。
//
// ⚠ 用它之前数组必须**已经是字典序最小**(这里是 1,2,...,n),否则会漏掉前面那些。
#include <bits/stdc++.h>
using namespace std;
int main() {
int n;
if (!(cin >> n)) return 0;
vector<int> a(n);
for (int i = 0; i < n; i++) a[i] = i + 1; // 必须从字典序最小的那个开始
do {
for (int v : a) printf("%5d", v);
printf("\n");
} while (next_permutation(a.begin(), a.end()));
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
⚠ 它是给你省时间的,不是给你替代理解的

next_permutation 把当前排列变成字典序里的下一个,没有下一个就返回 false。

  • 考场上要「把所有排列列出来」,用它当然可以 —— 写得快、不容易错。
  • 可一旦题目变成排列 + 剪枝(第 4 章 N 皇后)、组合而不是排列P1157)、 或者边生成边判断,它一概帮不上忙 —— 因为它不让你站在决策过程的中间(第 3 章第 5 步埋的那个伏笔)。

⇒ 顺序是:先把第 ③ 版练到闭着眼睛能写,再把这一版当成一个顺手的工具。 反过来的话,你会在第一道需要剪枝的题上原地卡住。

⚠ 用它之前数组必须已经是字典序最小(这里是 1,2,...,n),否则会漏掉前面那些。

7回头看:这道题在教什么

✓ 四件带得走的东西
  1. 漏撤销在这道题上一眼可见,在别的题上只会让答案悄悄变小 —— 后者才是真正危险的。
  2. 判重可以「回头翻」,也可以「查表」;这道题两种都过, 而它们的差别到第 4 章会变成生死线。
  3. 哪个分支写在前面,决定了输出的顺序 —— 字典序不用排序就有了。
  4. 输出格式是题目的一部分,而且是对拍抓不到的那一部分。