深度优先搜索(DFS)理论基础
图的实质和存储方式
图实际上就是一棵多叉树,可以用以下的数据结构进行表示:
class Vertex {
int id;
vector<Vertex*> neighbors;
};
多叉树的:
/* 基本的 N 叉树节点 */
class TreeNode {
public:
int val;
vector<TreeNode*> children;
};
但实际上很少用这个实现图,一般用邻接表或邻接矩阵
邻接表很直观,我把每个节点 x
的邻居都存到一个列表里,然后把 x
和这个列表关联起来,这样就可以通过一个节点 x
找到它的所有相邻节点。
邻接矩阵则是一个二维布尔数组,我们权且称为 matrix
,如果节点 x
和 y
是相连的,那么就把 matrix[x][y]
设为 true
(上图中绿色的方格代表 true
)。如果想找节点 x
的邻居,去扫一圈 matrix[x][..]
就行了。
如果用代码的形式来表现,邻接表和邻接矩阵大概长这样:
// 邻接表
// graph[x] 存储 x 的所有邻居节点
vector<int> graph[];
// 邻接矩阵
// matrix[x][y] 记录 x 是否有一条指向 y 的边
bool matrix[][];
对于邻接表,好处是占用的空间少。
你看邻接矩阵里面空着那么多位置,肯定需要更多的存储空间。
但是,邻接表无法快速判断两个节点是否相邻。
比如说我想判断节点 1
是否和节点 3
相邻,我要去邻接表里 1
对应的邻居列表里查找 3
是否存在。但对于邻接矩阵就简单了,只要看看 matrix[1][3]
就知道了,效率高。
图的遍历
图的遍历就是多叉树的遍历,经历回溯的过程,首先看下多叉树的遍历框架:
/* 多叉树遍历框架 */
void traverse(TreeNode* root) {
if (root == nullptr) return;
// 前序位置
for (TreeNode* child : root->children) {
traverse(child);
}
// 后序位置
}
图和多叉树最大的区别是,图是可能包含环的,你从图的某一个节点开始遍历,有可能走了一圈又回到这个节点,而树不会出现这种情况,从某个节点出发必然走到叶子节点,绝不可能回到它自身。
所以,如果图包含环,遍历框架就要一个 visited
数组进行辅助:
// 记录被遍历过的节点
vector<bool> visited;
// 记录从起点到当前节点的路径
vector<bool> onPath;
/* 图遍历框架 */
void traverse(Graph graph, int s) {
if (visited[s]) return;
// 经过节点 s,标记为已遍历
visited[s] = true;
// 做选择:标记节点 s 在路径上
onPath[s] = true;
for (int neighbor : graph.neighbors(s)) {
traverse(graph, neighbor);
}
// 撤销选择:节点 s 离开路径
onPath[s] = false;
}
visited和onPath的区别:类比贪吃蛇游戏,visited
记录蛇经过过的格子,而 onPath
仅仅记录蛇身。在图的遍历过程中,onPath
用于判断是否成环,类比当贪吃蛇自己咬到自己(成环)的场景。
看到这个算法其实很像回溯算法的过程,不同的是回溯算法关注的不是节点,而是树枝。他们的区别可以反映在代码上:
// DFS 算法,关注点在节点
void traverse(TreeNode* root) {
if (root == nullptr) return;
printf("进入节点 %s", root);
for (TreeNode* child : root->children) {
traverse(child);
}
printf("离开节点 %s", root);
}
// 回溯算法,关注点在树枝
void backtrack(TreeNode *root) {
if (root == nullptr) return;
for (TreeNode* child : root->children) {
// 做选择
printf("从 %s 到 %s", root, child);
backtrack(child);
// 撤销选择
printf("从 %s 到 %s", child, root);
}
}
执行上面第一个的代码,会发现起始点被漏掉了。
因此对于图的遍历应该使用DFS算法,即把 onPath
的操作放到 for 循环外面,否则会漏掉记录起始点的遍历。
即把 onPath
的操作放到 for 循环外面,否则会漏掉记录起始点的遍历。
visited
数组,其目的很明显了,由于图可能含有环,visited
数组就是防止递归重复遍历同一个节点进入死循环的。
当然,如果题目告诉你图中不含环,可以把 visited
数组都省掉,基本就是多叉树的遍历。
深搜三部曲
与回溯三部曲一样,DFS也有一个框架;
void dfs(参数) {
if (终止条件) {
存放结果;
return;
}
for (选择:本节点所连接的其他节点) {
处理节点;
dfs(图,选择的节点); // 递归
回溯,撤销处理结果
}
}
- 确认递归函数,参数
一般情况,深搜需要 二维数组数组结构保存所有路径,需要一维数组保存单一路径,这种保存结果的数组,我们可以定义一个全局遍历,避免让我们的函数参数过多。
- 确认终止条件
终止添加不仅是结束本层递归,同时也是我们收获结果的时候。
另外,其实很多dfs写法,没有写终止条件,其实终止条件写在了, 下面dfs递归的逻辑里了,也就是不符合条件,直接不会向下递归
- 处理目前搜索节点出发的路径
一般这里就是一个for循环的操作,去遍历 目前搜索节点 所能到的所有节点。
797. 所有可能的路径
leetcode链接:力扣题目链接
给你一个有 n 个节点的 有向无环图(DAG),
请你找出所有从节点 0 到节点 n-1 的路径并输出(不要求按特定顺序)
graph[i] 是一个从节点 i 可以访问的所有节点的列表(
即从节点 i 到节点 graph[i][j]存在一条有向边)。
示例1:
输入:graph = [[1,2],[3],[3],[]]
输出:[[0,1,3],[0,2,3]]
解释:有两条路径 0 -> 1 -> 3 和 0 -> 2 -> 3
示例II:
输入:graph = [[4,3,1],[3,2,4],[3],[4],[]]
输出:[[0,4],[0,3,4],[0,1,3,4],[0,1,2,3,4],[0,1,4]]
这里注意到题目要求的是有向无环图,因此可以不用visited数组记录是否重复了。
这里的输入其实就是邻接表,
graph = [[1,2],[3],[3],[]]表示0节点和1,2相连,1节点和3相连等等。
深搜三部曲
- 确定递归函数和参数
dfs函数一定要存一个图,用来遍历的,还要存一个目前我们遍历的节点,定义为x
至于 单一路径,和路径集合可以放在全局变量,那么代码是这样的:
vector<vector<int>> result; // 收集符合条件的路径
vector<int> path; // 0节点到终点的路径
// x:目前遍历的节点
// graph:存当前的图
void dfs (vector<vector<int>>& graph, int x)
- 确定终止条件
当目前遍历的节点 为 最后一个节点的时候,就找到了一条,从 出发点到终止点的路径。
当前遍历的节点,我们定义为x,最后一点节点,就是 graph.size() - 1(因为题目描述是找出所有从节点 0 到节点 n-1 的路径并输出)。
所以 但 x 等于 graph.size() - 1 的时候就找到一条有效路径。 代码如下:
// 要求从节点 0 到节点 n-1 的路径并输出,所以是 graph.size() - 1
if (x == graph.size() - 1) { // 找到符合条件的一条路径
result.push_back(path); // 收集有效路径
return;
}
- 处理目前搜索节点出发的路径
遍历与x相连的所有节点,查找其邻接表:
for (int i = 0; i < graph[x].size(); i++) { // 遍历节点n链接的所有节点
进入循环后,加入节点,dfs,回溯:
for (int i = 0; i < graph[x].size(); i++) { // 遍历节点n链接的所有节点
path.push_back(graph[x][i]); // 遍历到的节点加入到路径中来
dfs(graph, graph[x][i]); // 进入下一层递归
path.pop_back(); // 回溯,撤销本节点
}
最终代码:
class Solution {
private:
vector<int> path;
vector<vector<int>> res;
void dfs(vector<vector<int>>& graph, int x){
if(x == graph.size() - 1){
res.push_back(path);
return;
}
for(int i = 0; i < graph[x].size(); i++){
path.push_back(graph[x][i]);
dfs(graph, graph[x][i]);
path.pop_back();
}
}
public:
vector<vector<int>> allPathsSourceTarget(vector<vector<int>>& graph) {
path.push_back(0);
dfs(graph,0);
return res;
}
};
总结
- DFS与回溯算法相似, 也有深搜三部曲;
- 对于有环图,使用visited数组标注是否遍历过;
- 注意图的存储方式(邻接表常用)和遍历方式