本文已参与「新人创作礼」活动,一起开启掘金创作之路。
学习自:labuladong.gitee.io/algo/2/19/3…
终于开始了数据结构算法的学习....(😰数据结构)
一、图的基本结构
二、图的存储方式
图结构的两种存储方式:
无向图的邻接矩阵一定是对称的。
三、度
四、图的遍历
图和多叉树最大的区别是,图是可能包含环的,你从图的某一个节点开始遍历,有可能走了一圈又回到这个节点。
所以,如果图包含环,遍历框架就要一个 visited 数组进行辅助:
// 记录被遍历过的节点
boolean[] visited;
// 记录从起点到当前节点的路径
boolean[] 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;
}
上面的图遍历方式属于DFS,深度优先搜索。一定要把这里图的遍历与回溯算法区分开,不可混为一谈。
使用visited数组是为了避免有环图中的循环访问。
上述 GIF 描述了递归遍历二叉树的过程,在 visited 中被标记为 true 的节点用灰色表示,在 onPath 中被标记为 true 的节点用绿色表示。
onPath的撤销操作,类似于回溯算法中的回溯,但是撤销的位置有所不同。onPath在for循环外面,回溯是在for循环里面。
所有可能的路径(中等)
本题主要是考察对图的遍历,图结构是按照邻接表存储的,
本题不存在环,所以不需要使用visited数组。
class Solution {
List<List<Integer>> res = new LinkedList<>();
public List<List<Integer>> allPathsSourceTarget(int[][] graph) {
// 维护递归过程中经过的路径
LinkedList<Integer> path = new LinkedList<>();
// s表示遍历到第几个结点
// graph[0]就是第0个结点相连的结点
traverse(graph, 0, path);
return res;
}
void traverse(int[][] graph, int s, LinkedList<Integer> path) {
// 添加结点 s 到路径path
path.addLast(s);
// 所有结点数目
int n = graph.length;
if (s == n - 1){
// 到达重点,注意一定要new一个新的list传给res
// 否则path的变化,会导致res的变化
res.add(new LinkedList<>(path));
path.removeLast();
return;
}
// 递归遍历当前结点的相邻结点
for (int v : graph[s]) {
traverse(graph, v, path);
}
// 从路径中移除节点s
path.removeLast();
// 由于不存在环,所以不用使用visted数组
}
}
一定要注意,递归遍历是遍历当前结点的相邻结点,移除结点是在for循环外部移除。
五、图的环检测
有向图的环检测
课程表(中等)
prerequisites = [[1,0]],指的是学习1课程之前必须先学习课程0。 就相当于是一个图中,0指向1,代表必须修了0才能修1。
那么,是否能完成所有课程的学习,取决于这个图是否存在环? 如果有环,那肯定就不能全部修完,存在重复依赖了。所以问题,就转换为检测图中的环。
首先需要存储图结构,图结构最好选用邻接表进行存储,可以使用下面的结构存储:
List<Integer>[] graph;
graph是一个列表,里面存储着图中的一个个结点,graph[0]代表0结点相连的结点情况。
建图函数可以仿照下面的方式书写:用List更加方便存储和删除,数组一直需要记录下标
List<Integer>[] builddGraph(int numCourses, int[][] prerequisites) {
// 图中有 numCourses 个结点
List<Integer>[] graph = new LinkedList[numCourses];
for (int i = 0; i < numCourses; i++) {
graph[i] = new LinkedList<>();
}
for (int[] edge : prerequisites) {
// [0,1],0 <- 1,修 0之前必须先修 1
int from = edge[1], to = edge[0];
// 添加一条边从 from 指向 to
graph[from].add(to);
}
return graph;
}
图建好了,下面就是遍历。
// 防止遍历同一个节点
boolean[] visited;
// 从节点 s 开始 DFS 遍历,将遍历过的节点标记为true
void traverse(List<Integer>[] graph, int s) {
if (visited[s]) {
return;
}
/* 前序遍历代码位置 */
// 将当前节点标记为已遍历
visited[s] = true;
// DFS遍历与当前结点相连的结点
for (int t : graph[s]) {
traverse(graph, t);
}
/* 后续遍历代码位置 */
}
上面只是一个结点的遍历过程,由于本题中并不是所有结点都相连,所以必须得对每个结点都进行遍历。
// 防止重复遍历同一个节点
boolean[] visited;
boolean canFinish(int numCourses, int[][] prerequisites) {
List<Integer>[] graph = buildGraph(numCourses, prerequisites);
visited = new boolean[numCourses];
for (int i = 0; i < numCourses; i++) {
// 对每个结点都要进行dfs遍历
traverse(graph, i);
}
}
void traverse(List<Integer>[] graph, int s) {
// 代码见上文
}
现在可以思考如何判断这幅图中是否存在环。可以把 traverse 看做在图中节点上游走的指针,只需要再添加一个布尔数组 onPath 记录当前 traverse 经过的路径。
// 当前traverse经过的路径(为true的结点)
// 如果当前traverse遍历到的结点已经在onPath中出现过了,那就是成环了(onPath = true)
boolean[] onPath;
// 避免重复访问
boolean[] visited;
boolean hasCycle = false;
// DFS遍历
void traveeerse(List<Integer>[] graph, int s) {
if (onPath[s]) {
// 发现环
hasCycle = true;
}
// 有环,或者当前结点被访问,那就return
if (visited[s] || hasCycle) {
return;
}
// 结点s标记为已遍历
visited[s] = true;
// 当前结点加入路径中
onPath[s] = true;
// 开始遍历结点 s 相连的结点
for (int t : graph[s]) {
traveeerse(graph, t);
}
// 结点 s 遍历完成,回溯到上一个结点
onPath[s] = false;
}
类比贪吃蛇游戏,visited 记录蛇经过过的格子,而 onPath 仅仅记录蛇身。onPath 用于判断是否成环,类比当贪吃蛇自己咬到自己(成环)的场景。
综上,可以写出完整本题代码:
class Solution {
// 避免重复访问
boolean[] vis;
// 记录当前访问路径
boolean[] onPath;
// 是否有环
boolean hasCycle = false;
public boolean canFinish(int numCourses, int[][] prerequisites) {
// 先建图
List<Integer>[] graph = buildGraph(numCourses, prerequisites);
// 遍历
vis = new boolean[numCourses];
onPath = new boolean[numCourses];
for (int i = 0; i < numCourses; i++) {
// 因为题目中图的各结点可能不相连(各成一坨)
// 所以需要遍历每个结点
traverse(graph, i);
}
// 没有环就可以修完
return !hasCycle;
}
// DFS遍历
void traverse(List<Integer>[] graph, int s) {
if (onPath[s]) {
// 当前遍历到的结点是当前遍历路径上的结点
hasCycle = true;
}
if (vis[s] || hasCycle) {
return;
}
vis[s] = true;
onPath[s] = true;
for (int t : graph[s]) {
traverse(graph, t);
}
// 当前s结点dfs遍历到头了,回溯到上一个节点
onPath[s] = false;
}
List<Integer>[] buildGraph(int numCourses, int[][] prerequisites) {
// numCourses个结点
List<Integer>[] graph = new LinkedList[numCourses];
for (int i = 0; i < numCourses; i++) {
graph[i] = new LinkedList<>();
}
for (int[] t : prerequisites) {
// [1,0],0 - > 1,修完0,才能修1
int from = t[1];
int to = t[0];
// 生成边
graph[from].add(to);
}
return graph;
}
}
分模块,把每个部分搞清楚了,整个代码过程是不难的。
这道题就解决了,核心就是判断一幅有向图中是否存在环。
不过如果出题人继续恶心你,让你不仅要判断是否存在环,还要返回这个环具体有哪些节点,怎么办?
你可能说,onPath 里面为 true 的索引,不就是组成环的节点编号吗?
不是的,假设下图中绿色的节点是递归的路径,它们在 onPath 中的值都是 true,但显然成环的节点只是其中的一部分:
无向图的环检测
用上面的方法依然可以,一般无向图在建图时需要记录两条边,因为从1到2的同时,存在从2到1。在进行无向图的环检测时,只进行一条边的统计,比如1到2,只记录从1到2,而不记录从2到1,这样就可以实现环检测。
六、拓扑排序
课程表Ⅱ(中等)
这一题是上一题的升级版,上一题只需要判断环,本题不仅要判断环,还要使用拓扑排序,确定出能够修完所有课的顺序。
有向无环图(DAG)才有拓扑排序,非DAG图没有拓扑排序一说。
先看看如何生成拓扑排序:
1、从 DAG 图中选择一个 没有前驱(即入度为0)的顶点并输出。
2、从图中删除该顶点和所有以它为起点的有向边。
3、重复 1 和 2 直到当前的 DAG 图为空或当前图中不存在无前驱的顶点为止。后一种情况说明有向图中必然存在环。
于是,得到拓扑排序后的结果是 { 1, 2, 4, 3, 5 }。通常,一个有向无环图可以有一个或多个拓扑排序序列。
其实也不难看出来,如果把课程抽象成节点,课程之间的依赖关系抽象成有向边,那么这幅图的拓扑排序结果就是上课顺序。
其实特别简单,将后序遍历的结果进行反转,就是拓扑排序的结果。
后续内容见:图论算法二