一、DFS 与 BFS 本质对比
| 维度 | DFS(深度优先搜索) | BFS(广度优先搜索) |
|---|---|---|
| 核心思想 | 一条路走到黑,不撞南墙不回头 | 一层一层往外扩,像水波纹扩散 |
| 数据结构 | 栈(递归调用栈 或 手动 stack) |
队列(queue) |
| 访问顺序 | 先访问深层节点,后回溯访问兄弟节点 | 先访问同层节点,再访问下一层节点 |
| 空间开销 | 通常更小(栈深 = 当前路径长度) | 通常更大(队列要存整层的节点) |
| 典型适用场景 | 找路径(所有路径/任意一条路径)、连通性判断、拓扑排序 | 最短路径(无权图)、层序遍历、最少步数 |
二、核心区别图解
图的遍历顺序示例(从节点1出发):
1
/ \
2 3
/ \ \
4 5 6
DFS 访问顺序: 1 → 2 → 4 → 5 → 3 → 6 (一条道走到底,再回头)
BFS 访问顺序: 1 → 2 → 3 → 4 → 5 → 6 (一层一层铺开)
三、代码模板对比
DFS 模板(递归写法)
cpp
void dfs(图/节点, visited) {
// 1. 终止条件(找到目标 / 越界)
// 2. 处理当前节点(记录路径、标记 visited 等)
visited[x] = true;
// 3. 遍历所有邻接节点
for (邻居 : 当前节点的所有邻居) {
if (!visited[邻居]) {
dfs(邻居); // 递归深入
}
}
// 4. 回溯(撤销状态)
visited[x] = false;
}
BFS 模板(队列写法)
cpp
void bfs(起点) {
queue<int> q;
q.push(起点);
visited[起点] = true;
while (!q.empty()) {
int cur = q.front(); q.pop(); // 取出队头
// 处理当前节点
for (邻居 : cur 的所有邻居) {
if (!visited[邻居]) {
visited[邻居] = true; // 入队时就标记,防止重复入队!
q.push(邻居); // 邻居入队
}
}
}
}
四、记忆口诀
DFS 是"一根线"------ 用栈,走到底,求路径。
BFS 是"一张网"------ 用队列,逐层扩,求最短。
五、什么时候选 DFS,什么时候选 BFS?
| 问题类型 | 推荐算法 | 原因 |
|---|---|---|
| 求所有从A到B的路径 | DFS | DFS 天然带回溯,能穷举出所有路径 |
| 求无权图中A到B的最短路径 | BFS | BFS 逐层扩展,第一次到达终点就是最短路径 |
| 判断两个节点是否连通 | 都可以 | DFS 更省代码,BFS 也没问题 |
| 求最少步数/最少操作次数 | BFS | 本质就是最短路问题 |
| 拓扑排序 | DFS(或 BFS/Kahn) | DFS 的后序遍历天然适合拓扑排序 |
| 岛屿数量 / 连通块数量 | 都可以 | 遍历 + visited,哪个顺手用哪个 |
98. 可达路径
题目描述
给定一个有 n 个节点的有向无环图,节点编号从 1 到 n。请编写一个函数,找出并返回所有从节点 1 到节点 n 的路径。每条路径应以节点编号的列表形式表示。
输入描述
第一行包含两个整数 N,M,表示图中拥有 N 个节点,M 条边
后续 M 行,每行包含两个整数 s 和 t,表示图中的 s 节点与 t 节点中有一条路径
输出描述
输出所有的可达路径,路径中所有节点之间空格隔开,每条路径独占一行,存在多条路径,路径输出的顺序可任意。如果不存在任何一条路径,则输出 -1。
注意输出的序列中,最后一个节点后面没有空格! 例如正确的答案是 `1 3 5`,而不是 `1 3 5 `, 5后面没有空格!
输入示例
5 5 1 3 3 5 1 2 2 4 4 5输出示例
1 3 5 1 2 4 5
cpp
#include <iostream>
#include <vector>
using namespace std;
vector<int> path; // 全局变量:记录当前从起点走过的路径
vector<vector<int>> ans; // 全局变量:记录所有从起点到终点的完整路径集合
// 深度优先搜索函数
// graph: 图的邻接表
// visited: 节点访问状态数组,防止图中有环导致死循环
// x: 当前正在处理的节点编号
// n: 目标终点编号
void dfs(vector<vector<int>>& graph, vector<bool>& visited, int x, int n) {
// 1. 终止条件:如果当前节点就是终点,说明找到了一条有效路径
if (x == n) {
ans.push_back(path); // 将当前路径存入结果集合中
return; // 结束当前分支的递归,开始回溯
}
// 2. 防环处理:如果当前节点在本次路径中已经被访问过,说明遇到了环,直接放弃这条分支
if (visited[x]) return;
// 3. 做选择(进层):标记当前节点为已访问
visited[x] = true;
// 4. 遍历当前节点 x 的所有邻接节点(出边)
for (int i : graph[x]) {
path.push_back(i); // 将邻接节点加入当前路径
dfs(graph, visited, i, n); // 递归深入,以该邻接节点为起点继续往下找
path.pop_back(); // 撤销选择(回溯):从该邻接节点退回来,尝试其他分支
}
// 5. 撤销状态(回溯):将当前节点恢复为未访问状态
// 【极其重要】因为从其他路径再次到达该节点时,它是合法的,不能被永久锁死
visited[x] = false;
}
int main() {
// 1. 读取节点数 n 和 边数 m
int n, m;
cin >> n >> m;
// 2. 初始化邻接表,因为有 n 个节点(编号 1~n),所以开 n+1 的大小,下标 0 不用
vector<vector<int>> graph(n + 1);
vector<bool> visited(n + 1, false); // 初始化所有节点均为未访问
// 3. 读入 m 条边,构建有向图
for (int i = 0; i < m; i++) {
int s, t;
cin >> s >> t;
graph[s].push_back(t); // 节点 s 指向节点 t
}
// 4. 准备起点:题目要求从节点 1 出发,先将其压入路径中
path.push_back(1);
// 5. 启动深度优先搜索
dfs(graph, visited, 1, n);
// 6. 格式化输出所有找到的路径
for (auto i : ans) {
// 输出路径中除最后一个节点外的所有节点,后面加空格
for (int j = 0; j < i.size() - 1; j++) {
cout << i[j] << " ";
}
// 输出最后一个节点,后面直接换行,不加多余空格
cout << i.back() << endl;
}
return 0;
}
总结
1. 核心思想:回溯三步曲
- 做选择(进层前):
visited[x] = true;(锁定节点,防止在下面递归时绕圈子)和path.push_back(i);(把路走上去)。 - 递归(探索深处):
dfs(..., i, n);(看看这条路走下去能不能到终点)。 - 撤销选择(退层后):
path.pop_back();和visited[x] = false;(这条路走完了或者走不通,退回来,把脚印抹去,让别的路可以走过来)。
2. 为什么一定要有 visited[x] = false;?
这是图和树最大的区别。树是没有环的,所以树的回溯通常不需要恢复 visited 状态;但图是有环的。
假设有路径 A -> B -> C,你走通了。如果没有 visited[x] = false;,节点 B 就被永久标记为走过了。接下来当你想走 A -> D -> B 时,一查发现 B 走过了,直接跳过,这就导致漏掉了合法路径。所以必须还原状态。
3. 为什么要有 if(visited[x]) return;?
这是一种防御性编程。虽然在 main 函数第一次调用 dfs 时,1 肯定没被访问过;而且你在 for 循环里也可以加 if(!visited[i]) 来拦截。
但把 if(visited[x]) return; 放在 dfs 函数最前面,相当于给函数加了一把安全锁,无论从哪里调用这个函数,只要传进来一个已经走过的节点,它都能立刻安全刹车,避免逻辑漏洞。