深度优先搜索(DFS)与回溯算法详解:以全排列问题为例

一、问题引入

给定一个整数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=1mark[1]=false,进入if语句

  • 执行:mark[1]=trueans[0]=1

  • 调用dfs(1),此时dfs(0)循环暂停在i=1

调用栈变化

复制代码
[main] → [dfs(0)]

步骤2 :进入dfs(1)(第二个栈帧):

  • 不满足u==3,进入for循环

  • i=1mark[1]=true,跳过

  • i=2mark[2]=false,进入if语句

  • 执行:mark[2]=trueans[1]=2

  • 调用dfs(2),此时dfs(1)循环暂停在i=2

调用栈变化

复制代码
[main] → [dfs(0)] → [dfs(1)]

步骤3 :进入dfs(2)(第三个栈帧):

  • 不满足u==3,进入for循环

  • i=1mark[1]=true,跳过

  • i=2mark[2]=true,跳过

  • i=3mark[3]=false,进入if语句

  • 执行:mark[3]=trueans[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]=falseans[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]=falseans[1]=0

  • 执行i++i变为3

  • 检查循环条件:3<=3为真,继续循环

  • 此时i=3mark[3]=false,进入if语句

  • 执行:mark[3]=trueans[1]=3

  • 调用dfs(2)(这是新的 dfs(2)调用,新的栈帧)

调用栈变化

复制代码
[main] → [dfs(0)] → [dfs(1)] → [新的dfs(2)]

步骤7 :在新的dfs(2)中:

  • 不满足u==3,进入for循环

  • i=1mark[1]=true,跳过

  • i=2mark[2]=false,进入if语句

  • 执行:mark[2]=trueans[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]=falseans[2]=0

  • 执行i++i变为3mark[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]=falseans[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]=falseans[0]=0

  • 执行i++i变为2

  • 检查循环条件:2<=3为真,继续循环

  • 此时i=2mark[2]=false,进入if语句

  • 执行:mark[2]=trueans[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]=falseans[u]=0,这两行代码是回溯的关键。它们将状态恢复到做出选择之前,使得同一个数字可以在其他排列中使用。

4. 输出机制

输出发生在递归的最深层,当u==n时。此时ans数组中存储了一个完整的排列,通过for循环将其输出。每个完整的排列都会触发一次输出。

七、算法要点总结

  1. 递归深度:最多递归n层,当u==n时输出结果

  2. 输出时机:在递归最深层,当所有位置填满时输出

  3. 输出次数:等于完整路径数,即n!次

  4. 回溯时机:在递归调用返回后,执行mark[i]=false释放数字

  5. 恢复现场:回溯时将ans[u]恢复为0,为下次使用做准备

  6. 循环作用:枚举当前位置所有可能的选择

  7. 字典序输出:由于循环从1到n,自然按字典序生成排列

  8. 避免重复:通过mark数组确保每个数字只使用一次

八、常见疑问解答

1. 为什么循环变量i不会在回溯时重置?

每个递归调用都有自己独立的栈帧,保存局部变量(如循环变量i)。递归返回时,恢复到调用前的栈帧,i保持递归调用前的值,然后执行i++继续循环。

2. 为什么需要回溯?

如果不回溯,已使用的数字会一直保持标记状态,无法在其他路径中使用,导致只能生成一条路径。

3. 如何理解递归的"深度优先"?

算法会先沿着一条路径走到底(填满所有位置),输出结果后再返回尝试其他路径,而不是先尝试所有第一位的选择。

九、扩展思考

  1. 如果要生成组合而不是排列,代码如何修改?

  2. 如果数字可以重复使用,代码如何修改?

  3. 如果n较大,如何优化?

通过这个例子,我们可以看到DFS和回溯算法的强大之处:用简洁的代码系统地探索所有可能性。理解这个例子是学习更复杂回溯问题的基础。

相关推荐
Omics Pro2 小时前
马普所:生命蛋白质宇宙聚类
数据库·人工智能·算法·机器学习·数据挖掘·aigc·聚类
汀、人工智能2 小时前
[特殊字符] 第106课:旋转图像
数据结构·算法·矩阵·数据库架构·数组·旋转图像
ulias2122 小时前
leetcode热题 - 2
算法·leetcode·职场和发展
Ivanqhz2 小时前
SMT(Satisfiability Modulo Theories,基于模理论的可满足性)
人工智能·算法·机器学习
游乐码2 小时前
C#Dicitionary
算法·c#
华清远见IT开放实验室2 小时前
AI 算法核心知识清单(深度实战版1)
人工智能·python·深度学习·学习·算法·机器学习·ai
牧瀬クリスだ2 小时前
七大排序一次满足
数据结构·算法·排序算法
liu****2 小时前
第15届省赛蓝桥杯大赛C/C++大学B组
开发语言·数据结构·c++·算法·蓝桥杯·acm
无缘之缘2 小时前
蓝桥杯手把手教你备战(C/C++ B组)(最全面!最贴心!适合小白!)
c语言·c++·算法·蓝桥杯