一、问题引入
给定一个整数n,将数字1~n排成一排,按字典序输出所有排列方案。例如,当n=3时,所有排列为:
1 2 3
1 3 2
2 1 3
2 3 1
3 1 2
3 2 1
二、算法思想
深度优先搜索(DFS)是一种用于遍历或搜索树或图的算法。它会沿着一条路径尽可能深地搜索,直到到达叶子节点或无法继续前进,然后回溯到上一个分叉点,尝试另一条路径。
在全排列问题中,我们可以将每个位置看作树的一层,每个可用的数字看作一个分支。通过DFS遍历这棵树的所有路径,就能得到所有排列。
三、代码实现
cpp
#include <iostream>
using namespace std;
const int N = 10; // 最多处理n=9
int ans[N]; // 存储当前排列
bool mark[N]; // 标记数字是否被使用
int n; // 排列的长度
// DFS函数,u表示当前需要填写的位(从0开始)
void dfs(int u) {
// "回头"的条件:当u等于n时,说明已经填满了所有位置
if (u == n) {
// 输出当前排列
for (int i = 0; i < n; i++) {
cout << ans[i] << " ";
}
cout << endl;
return; // 返回上一层
}
// 枚举当前位置可以填的数字
for (int i = 1; i <= n; i++) {
// 如果数字i没有被使用过
if (mark[i] == false) {
mark[i] = true; // 标记数字i已被使用
ans[u] = i; // 将数字i填入当前位置
dfs(u + 1); // 递归填写下一个位置
// 回溯:恢复现场
mark[i] = false; // 取消标记,让数字i可以被重新使用
ans[u] = 0; // 可不写,因为会被覆盖
}
}
}
int main() {
n = 3; // 以3为例
dfs(0); // 从第0位开始填写
return 0;
}
四、执行过程详解(以n=3为例)
让我们一步步跟踪代码的执行过程,特别关注每一步的状态变化。
第一步:初始调用
主程序调用dfs(0),此时u=0,表示要从第0个位置开始填数字。
当前状态:
-
调用栈:只有
main函数 -
标记数组:
mark[1]=false,mark[2]=false,mark[3]=false -
答案数组:
ans[0]=0,ans[1]=0,ans[2]=0
第二步:探索第一条路径
步骤1 :进入dfs(0)(第一个栈帧):
-
不满足
u==3,进入for循环 -
i=1,mark[1]=false,进入if语句 -
执行:
mark[1]=true,ans[0]=1 -
调用
dfs(1),此时dfs(0)的循环暂停在i=1
调用栈变化:
[main] → [dfs(0)]
步骤2 :进入dfs(1)(第二个栈帧):
-
不满足
u==3,进入for循环 -
i=1,mark[1]=true,跳过 -
i=2,mark[2]=false,进入if语句 -
执行:
mark[2]=true,ans[1]=2 -
调用
dfs(2),此时dfs(1)的循环暂停在i=2
调用栈变化:
[main] → [dfs(0)] → [dfs(1)]
步骤3 :进入dfs(2)(第三个栈帧):
-
不满足
u==3,进入for循环 -
i=1,mark[1]=true,跳过 -
i=2,mark[2]=true,跳过 -
i=3,mark[3]=false,进入if语句 -
执行:
mark[3]=true,ans[2]=3 -
调用
dfs(3),此时dfs(2)的循环暂停在i=3
调用栈变化:
[main] → [dfs(0)] → [dfs(1)] → [dfs(2)]
步骤4 :进入dfs(3)(第四个栈帧):
-
满足
u==3,进入if语句 -
执行for循环:
for(int i=0; i<n; i++) cout << ans[i] << " "; -
此时
ans数组中存储的是完整的排列[1, 2, 3] -
输出:
1 2 3 -
执行
return,返回到dfs(2)的调用点
调用栈变化:
[main] → [dfs(0)] → [dfs(1)] → [dfs(2)] → [dfs(3)]
输出后,弹出dfs(3),回到dfs(2)
第三步:第一次回溯,探索第二条路径
步骤5 :回到dfs(2):
-
从
dfs(3)返回,继续执行i=3这次循环的剩余代码 -
执行回溯:
mark[3]=false,ans[2]=0← 这里将ans[2]恢复为0 -
执行
i++,i变为4 -
检查循环条件:
4<=3为假,循环结束 -
dfs(2)函数结束,返回到dfs(1)
调用栈变化:
[main] → [dfs(0)] → [dfs(1)] ← 弹出dfs(2),回到dfs(1)
步骤6 :回到dfs(1):
-
从
dfs(2)返回,继续执行i=2这次循环的剩余代码 -
执行回溯:
mark[2]=false,ans[1]=0 -
执行
i++,i变为3 -
检查循环条件:
3<=3为真,继续循环 -
此时
i=3,mark[3]=false,进入if语句 -
执行:
mark[3]=true,ans[1]=3 -
调用
dfs(2)(这是新的dfs(2)调用,新的栈帧)
调用栈变化:
[main] → [dfs(0)] → [dfs(1)] → [新的dfs(2)]
步骤7 :在新的dfs(2)中:
-
不满足
u==3,进入for循环 -
i=1,mark[1]=true,跳过 -
i=2,mark[2]=false,进入if语句 -
执行:
mark[2]=true,ans[2]=2 -
调用
dfs(3)
调用栈变化:
[main] → [dfs(0)] → [dfs(1)] → [新的dfs(2)] → [新的dfs(3)]
步骤8 :在新的dfs(3)中:
-
满足
u==3,进入if语句 -
执行for循环:
for(int i=0; i<n; i++) cout << ans[i] << " "; -
此时
ans数组中存储的是新的完整排列[1, 3, 2] -
输出:
1 3 2 -
执行
return,返回到新的dfs(2)
调用栈变化:
[main] → [dfs(0)] → [dfs(1)] → [新的dfs(2)] ← 弹出新的dfs(3),回到新的dfs(2)
步骤9 :回到新的dfs(2):
-
从
dfs(3)返回,继续执行i=2这次循环的剩余代码 -
执行回溯:
mark[2]=false,ans[2]=0 -
执行
i++,i变为3,mark[3]=true,跳过 -
执行
i++,i变为4,循环结束 -
dfs(2)结束,返回dfs(1)
调用栈变化:
[main] → [dfs(0)] → [dfs(1)] ← 弹出新的dfs(2),回到dfs(1)
步骤10 :回到dfs(1):
-
从
dfs(2)返回,继续执行i=3这次循环的剩余代码 -
执行回溯:
mark[3]=false,ans[1]=0 -
执行
i++,i变为4,循环结束 -
dfs(1)函数结束,返回到dfs(0)
调用栈变化:
[main] → [dfs(0)] ← 弹出dfs(1),回到dfs(0)
第四步:第二次回溯,探索更多路径
步骤11 :回到dfs(0):
-
从
dfs(1)返回,继续执行i=1这次循环的剩余代码 -
执行回溯:
mark[1]=false,ans[0]=0 -
执行
i++,i变为2 -
检查循环条件:
2<=3为真,继续循环 -
此时
i=2,mark[2]=false,进入if语句 -
执行:
mark[2]=true,ans[0]=2 -
调用
dfs(1)(新的dfs(1)栈帧)
调用栈变化:
[main] → [dfs(0)] → [新的dfs(1)]
后续过程会以类似的方式继续,生成并输出剩余的排列:
-
第三次输出:
[2, 1, 3] -
第四次输出:
[2, 3, 1] -
第五次输出:
[3, 1, 2] -
第六次输出:
[3, 2, 1]
五、完整递归树遍历过程
为了更清晰地理解整个过程,以下是完整的递归树遍历顺序:
cpp
1. dfs(0) i=1 → dfs(1) i=2 → dfs(2) i=3 → 输出 [1,2,3]
2. dfs(0) i=1 → dfs(1) i=3 → dfs(2) i=2 → 输出 [1,3,2]
3. dfs(0) i=2 → dfs(1) i=1 → dfs(2) i=3 → 输出 [2,1,3]
4. dfs(0) i=2 → dfs(1) i=3 → dfs(2) i=1 → 输出 [2,3,1]
5. dfs(0) i=3 → dfs(1) i=1 → dfs(2) i=2 → 输出 [3,1,2]
6. dfs(0) i=3 → dfs(1) i=2 → dfs(2) i=1 → 输出 [3,2,1]
每次输出后,程序都会回溯到上一个决策点,继续尝试其他可能的选择。

六、核心机制详解
1. 递归深度控制
递归函数dfs(u)的参数u表示当前要填写的位置。当u从0增加到3时,递归深度也相应增加。当u==n时,表示已经填满了所有位置,此时输出结果。
2. 循环枚举选择
在每个递归层级,for循环for(int i=1; i<=n; i++)会尝试所有可能的数字。通过mark数组避免重复使用数字,实现了剪枝优化。
3. 回溯恢复现场
每次递归调用返回后,都会执行mark[i]=false和ans[u]=0,这两行代码是回溯的关键。它们将状态恢复到做出选择之前,使得同一个数字可以在其他排列中使用。
4. 输出机制
输出发生在递归的最深层,当u==n时。此时ans数组中存储了一个完整的排列,通过for循环将其输出。每个完整的排列都会触发一次输出。
七、算法要点总结
-
递归深度:最多递归n层,当
u==n时输出结果 -
输出时机:在递归最深层,当所有位置填满时输出
-
输出次数:等于完整路径数,即n!次
-
回溯时机:在递归调用返回后,执行
mark[i]=false释放数字 -
恢复现场:回溯时将
ans[u]恢复为0,为下次使用做准备 -
循环作用:枚举当前位置所有可能的选择
-
字典序输出:由于循环从1到n,自然按字典序生成排列
-
避免重复:通过
mark数组确保每个数字只使用一次
八、常见疑问解答
1. 为什么循环变量i不会在回溯时重置?
每个递归调用都有自己独立的栈帧,保存局部变量(如循环变量i)。递归返回时,恢复到调用前的栈帧,i保持递归调用前的值,然后执行i++继续循环。
2. 为什么需要回溯?
如果不回溯,已使用的数字会一直保持标记状态,无法在其他路径中使用,导致只能生成一条路径。
3. 如何理解递归的"深度优先"?
算法会先沿着一条路径走到底(填满所有位置),输出结果后再返回尝试其他路径,而不是先尝试所有第一位的选择。
九、扩展思考
-
如果要生成组合而不是排列,代码如何修改?
-
如果数字可以重复使用,代码如何修改?
-
如果n较大,如何优化?
通过这个例子,我们可以看到DFS和回溯算法的强大之处:用简洁的代码系统地探索所有可能性。理解这个例子是学习更复杂回溯问题的基础。