【算法修炼】图论算法一(图的表示、图的遍历、图中环的检测)

711 阅读6分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路。

学习自:labuladong.gitee.io/algo/2/19/3…

终于开始了数据结构算法的学习....(😰数据结构)

一、图的基本结构

![在这里插入图片描述](https://img-blog.csdnimg.cn/1a51429919214df0ab13fc67f29418bf.png

二、图的存储方式

图结构的两种存储方式: 在这里插入图片描述 在这里插入图片描述 在这里插入图片描述 无向图的邻接矩阵一定是对称的。

三、度

在这里插入图片描述 在这里插入图片描述

四、图的遍历

图和多叉树最大的区别是,图是可能包含环的,你从图的某一个节点开始遍历,有可能走了一圈又回到这个节点。

所以,如果图包含环,遍历框架就要一个 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 }。通常,一个有向无环图可以有一个或多个拓扑排序序列。

其实也不难看出来,如果把课程抽象成节点,课程之间的依赖关系抽象成有向边,那么这幅图的拓扑排序结果就是上课顺序。

其实特别简单,将后序遍历的结果进行反转,就是拓扑排序的结果。

后续内容见:图论算法二