【算法修炼】图论算法二(拓扑排序、二分图、并查集)

学习自https://labuladong.gitee.io/algo/2/19/36/

一、DFS实现拓扑排序

紧接上文:为什么需要对后序遍历的结果进行反转,才能得到拓扑排序的结果?
在这里插入图片描述
把上面的二叉树看成有向图,由根结点指向左右孩子结点,那么,根结点只会在最后进行后续遍历时才会遍历到,例如:1结点的左孩子,后续遍历顺序应该是:5 6 7 4 2,2是根节点所以最后访问,根据拓扑排序的定义,那么应该2输出在第一个位置,所以才需要逆序。

注意,网上有代码是不需要反转的,这是因为对 “边的定义” 不同,这里我们定义的边1->2,是指1被2依赖,修了1才能修2,如果改成2 -> 1,说明2依赖于1,取决于1,1修了,2才能修。

为了做题方便理解,才选用的第一种定义方式,只需最后reverse一下即可,不需要太多时间花费。

下面就可以开始书写代码,先建图、判断有无环、reverse后续遍历结果。

class Solution {
    // 避免重复访问
    boolean[] vis;
    // 记录当前访问路径
    boolean[] onPath;
    // 是否有环
    boolean hasCycle = false;
    // 记录后续遍历结果
    List<Integer> order = new ArrayList<>();
    public int[] findOrder(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);
        }
        // 有环就不可能有结果
        if (hasCycle) {
            return new int[]{};
        }
        // 逆序后序遍历的结果即为答案
        Collections.reverse(order);
        int[] res = new int[numCourses];
        for (int i = 0; i < numCourses; i++) {
            res[i] = order.get(i);
        }
        return res;
    }
    // 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);
        }
        // 记录后序遍历位置
        order.add(s);
        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;
    }
}

代码虽然看起来多,但是逻辑应该是很清楚的,只要图中无环,那么我们就调用 traverse 函数对图进行 DFS 遍历,记录后序遍历结果,最后把后序遍历结果反转,作为最终的答案。如果遇到其它DAG,也可以使用一样的方法,最后反转后序遍历结果即可。

二、BFS实现拓扑排序

BFS实现拓扑排序,更像是一种模拟人工实现拓扑排序的方法,它通过入度,实现环的检测和拓扑排序的生成,每次把入度为0的结点先全部入队,是一个个可以作为拓扑排序起点的结点,将这些结点依次弹出队列(弹出结点的顺序就是拓扑排序的结果),并把与当前结点相连的结点的入度–,在入度–的过程中,如果又遇到入度减到0的情况,又可以加到队列中。

class Solution {
    public int[] findOrder(int numCourses, int[][] prerequisites) {
        // 建图
        List<Integer>[] graph = new LinkedList[numCourses];
        for (int i = 0; i < numCourses; i++) graph[i] = new LinkedList<>();
        // 统计每个节点的入度
        int[] indegree = new int[numCourses];
        for (int[] cur : prerequisites) {
            graph[cur[1]].add(cur[0]);
            indegree[cur[0]]++;
        }
        Queue<Integer> queue = new LinkedList<>();
        for (int i = 0; i < numCourses; i++) {
            if (indegree[i] == 0) queue.offer(i);
        }
        // 记录拓扑排序的结果,并顺便判断是否有环
        int[] ans = new int[numCourses];
        int visited = 0;
        while (!queue.isEmpty()) {
            int cur = queue.poll();
            // 出队顺序即拓扑排序结果
            ans[visited++] = cur;
            for (int next : graph[cur]) {
                indegree[next]--;
                if (indegree[next] == 0) {
                    queue.offer(next);
                }
            }
        }
        // 有环不可能完成
        if (visited != numCourses) return new int[] {};
        return ans;
    }
}

在这里插入图片描述
按道理, 图的遍历都需要 visited 数组防止走回头路,这里的 BFS 算法其实是通过 indegree 数组实现的 visited 数组的作用,只有入度为 0 的节点才能入队,从而保证不会出现死循环。

个人更建议重点掌握BFS方法,理解起来很容易,就是每次把入度=0的节点入队, 出队的顺序就是拓扑排序的结果。

三、二分图判定

在这里插入图片描述

二分图判定思路

判定二分图的算法很简单,就是用代码解决「双色问题」。

说白了就是遍历一遍图,一边遍历一边染色,已经被访问过的点则说明颜色已被确定;未被访问过的点则说明颜色还没确定。看看能不能用两种颜色给所有节点染色,且相邻节点的颜色都不相同。

既然说到遍历图,也不涉及最短路径之类的,当然是 DFS 算法和 BFS 皆可了,DFS 算法相对更常用些,所以我们先来看看如何用 DFS 算法判定双色图。

再来回顾一下图的遍历代码:

void traverse(int[][] graph, int s) {
	// 用visited标记访问过的结点,防止有环重复访问
	if (visited[s]) {
		return;
	}
	visited[s] = true;
	// 前序遍历代码
	for (int t : graph[s]) {
		traverse(graph, t);
	}
	// 后序遍历代码
}

回顾一下二分图怎么判断,其实就是让 traverse 函数一边遍历节点,一边给节点染色,尝试让每对相邻节点的颜色都不一样。

所以,判定二分图的代码逻辑可以这样写:

/* 图遍历框架 */
void traverse(Graph graph, boolean[] visited, int v) {
    visited[v] = true;
    // 遍历节点 v 的所有相邻节点 neighbor
    for (int neighbor : graph.neighbors(v)) {
        if (!visited[neighbor]) {
            // 相邻节点 neighbor 没有被访问过
            // 那么应该给节点 neighbor 涂上和节点 v 不同的颜色
            traverse(graph, visited, neighbor);
        } else {
            // 相邻节点 neighbor 已经被访问过
            // 那么应该比较节点 neighbor 和节点 v 的颜色
            // 若相同,则此图不是二分图,直接return节约时间
        }
    }
}

染色,用一个color来记录每个结点的颜色即可。

在这里插入图片描述

判断二分图(中等)

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

class Solution {
    boolean flag = true;
    boolean[] vis;
    boolean[] colors;
    public boolean isBipartite(int[][] graph) {
        int n = graph.length;
        // 记录每个节点是否被访问
        vis = new boolean[n];
        // 记录每个节点的颜色
        colors = new boolean[n];
        // 注意:并没有说是连通图,所以需要遍历每一个节点
        for (int i = 0; i < n; i++) {
            if (!vis[i]) {
                vis[i] = true;
                dfs(graph, i);
            }
        }
        return flag;
    }
    void dfs(int[][] graph, int cur) {
        if (flag == false) return;
        for (int next : graph[cur]) {
            if (vis[next] == false) {
                // 还未访问,则涂与cur相反的颜色
                colors[next] = !colors[cur];
                vis[next] = true;
                dfs(graph, next);
            } else {
                // 访问过就看涂的颜色是否相反
                if (colors[cur] == colors[next]) {
                    flag = false;
                }
            }
        }
    }
}

用BFS也可以解,就是BFS遍历图的过程

// 记录图是否符合二分图性质
boolean ok = true;
// 记录图中节点的颜色,false 和 true 代表两种不同颜色
boolean[] color;
// 记录图中节点是否被访问过
boolean[] visited;

public boolean isBipartite(int[][] graph) {
    int n = graph.length;
    color =  new boolean[n];
    visited =  new boolean[n];
    
    for (int v = 0; v < n; v++) {
        if (!visited[v]) {
            // 改为使用 BFS 函数
            bfs(graph, v);
        }
    }
    
    return ok;
}

// 从 start 节点开始进行 BFS 遍历
private void bfs(int[][] graph, int start) {
    Queue<Integer> q = new LinkedList<>();
    visited[start] = true;
    q.offer(start);
    
    while (!q.isEmpty() && ok) {
        int v = q.poll();
        // 从节点 v 向所有相邻节点扩散
        for (int w : graph[v]) {
            if (!visited[w]) {
                // 相邻节点 w 没有被访问过
                // 那么应该给节点 w 涂上和节点 v 不同的颜色
                color[w] = !color[v];
                // 标记 w 节点,并放入队列
                visited[w] = true;
                q.offer(w);
            } else {
                // 相邻节点 w 已经被访问过
                // 根据 v 和 w 的颜色判断是否是二分图
                if (color[w] == color[v]) {
                    // 若相同,则此图不是二分图
                    ok = false;
                }
            }
        }
    }
}
可能的二分法(中等)


在这里插入图片描述

ai与bi不能归为一组,因为它们dislike,如果可以把所有人分为两组,就说明可以二分。我们可以把每一个dislike的组用无向图表示,只有两个组,就相当于两个颜色,不就相当于判断当前图结构,是否为二分图,如果是,那就说明可以分成两组。

注意,无向图建图,要记录两条边!

class Solution {
    // 最终答案
    boolean flag = true;
    // 记录过去访问过的结点
    boolean[] vis;
    // 记录每隔二结点的颜色
    boolean[] colored;
    public boolean possibleBipartition(int n, int[][] dislikes) {
        List<Integer>[] graph = buildGraph(n, dislikes);
        vis = new boolean[n + 1];
        colored = new boolean[n + 1];
        // 可能存在子图,并非全部连通,所以需要遍历每个结点
        for (int i = 1; i <= n; i++) {
            if (!vis[i]) {
                traverse(graph, i);
            }
        }
        return flag;
    }
    void traverse(List<Integer>[] graph, int s) {
        // 已经不可能是二分图,直接返回
        if (flag == false) {
            return;
        }
        // 访问过的结点,别忘了标记
        vis[s] = true;
        for (int t : graph[s]) {
            if (vis[t]) {
                // 访问过了就看这个节点和当前结点s,颜色是否相同
                if (colored[t] == colored[s]) {
                    flag = false;
                    // 已经不可能是二分图了,直接return
                    return;
                }
            } else {
                // 没有访问过就把颜色标记为相反颜色
                colored[t] = !colored[s];
                // 继续往下遍历
                traverse(graph, t);
            }
        }
    }
    List<Integer>[] buildGraph(int n, int[][] dislikes) {
        List<Integer>[] graph = new LinkedList[n + 1];
        for (int i = 1; i <= n; i++) {
            graph[i] = new LinkedList<>();
        }
        for (int[] t : dislikes) {
            int from = t[0];
            int to = t[1];
            // 极易错!
            // 注意无向图要记录两条边!!!!
            graph[from].add(to);
            graph[to].add(from);
        }
        return graph;
    }
}

四、Kruskal 最小生成树算法

如果一幅图没有环,完全可以拉伸成一棵树的模样。说的专业一点,树就是「无环连通图」。

那么什么是图的「生成树」呢,其实按字面意思也好理解,就是在图中找一棵包含图中的所有节点的树。专业点说,生成树是含有图中所有顶点的「无环连通子图」。

容易想到,一幅图可以有很多不同的生成树,比如下面这幅图,红色的边就组成了两棵不同的生成树
在这里插入图片描述
对于加权图,每条边都有权重,所以每棵生成树都有一个权重和。比如上图,右侧生成树的权重和显然比左侧生成树的权重和要小。

那么最小生成树很好理解了,所有可能的生成树中,权重和最小的那棵生成树就叫「最小生成树」。

PS:一般来说,我们都是在无向加权图中计算最小生成树的,所以使用最小生成树算法的现实场景中,图的边权重一般代表成本、距离这样的标量。

这里需要使用Union-Find 并查集算法,来保证图中生成的是树(不包环)。

并查集算法是如何做到的?先来看看这道题
给你输入编号从 0 到 n - 1 的 n 个结点,和一个无向边列表 edges(每条边用节点二元组表示),请你判断输入的这些边组成的结构是否是一棵树。
在这里插入图片描述
这些边构成的是一颗树,应该返回true:
在这里插入图片描述
在这里插入图片描述
对于这道题,我们可以思考一下,什么情况下加入一条边会使得树变成图(出现环)?

显然,像下面这样添加边会出现环:

在这里插入图片描述
而下面这样添加就不会出现环:
在这里插入图片描述
总结一下规律就是:

对于添加的这条边,如果该边的两个节点本来就在同一连通分量里,那么添加这条边会产生环;反之,如果该边的两个节点不在同一连通分量里,则添加这条边不会产生环。

而判断两个节点是否连通(是否在同一个连通分量中)就是 Union-Find 算法的拿手绝活。

并查集算法

下面就来重点讲解并查集算法的使用和实现。

并查集主要用于解决图论中的动态连通性问题

简单说,动态连通性其实可以抽象成给一幅图连线。比如下面这幅图,总共有 10 个节点,他们互不相连,分别用 0~9 标记:
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

class UF {
    // 记录连通分量
    private int count;
    // 节点 x 的节点是 parent[x]
    private int[] parent;

    /* 构造函数,n 为图的节点总数 */
    public UF(int n) {
        // 一开始互不连通
        this.count = n;
        // 父节点指针初始指向自己(带环)
        parent = new int[n];
        for (int i = 0; i < n; i++)
            parent[i] = i;
    }

    /* 其他函数 */
}

如果某两个节点被连通,则让其中的(任意)一个节点的根节点接到另一个节点的根节点上:
在这里插入图片描述

public void union(int p, int q) {
    int rootP = find(p);
    int rootQ = find(q);
    if (rootP == rootQ)
        return;
    // 将两棵树合并为一棵
    parent[rootP] = rootQ;
    // parent[rootQ] = rootP 也一样
    count--; // 两个分量合二为一
}

/* 返回某个节点 x 的根节点 */
private int find(int x) {
    // 根节点的 parent[x] == x
    while (parent[x] != x)
        x = parent[x];
    return x;
}

/* 返回当前的连通分量个数 */
public int count() { 
    return count;
}

这样,如果节点 p 和 q 连通的话,它们一定拥有相同的根节点:
上面这句话特别关键,它们一定拥有相同的根节点!
在这里插入图片描述

// 判断p q节点是否连通
public boolean connected(int p, int q) {
    int rootP = find(p);
    int rootQ = find(q);
    return rootP == rootQ;
}

至此,Union-Find 算法就基本完成了。是不是很神奇?竟然可以这样使用数组来模拟出一个森林,如此巧妙的解决这个比较复杂的问题!

那么这个算法的复杂度是多少呢?我们发现,主要 API connected 和 union 中的复杂度都是 find 函数造成的,所以说它们的复杂度和 find 一样。

find 主要功能就是从某个节点向上遍历到树根,其时间复杂度就是树的高度。我们可能习惯性地认为树的高度就是 logN,但这并不一定。logN 的高度只存在于平衡二叉树,对于一般的树可能出现极端不平衡的情况,使得「树」几乎退化成「链表」,树的高度最坏情况下可能变成 N。

所以说上面这种解法,find , union , connected 的时间复杂度都是 O(N)。这个复杂度很不理想的,你想图论解决的都是诸如社交网络这样数据规模巨大的问题,对于 union 和 connected 的调用非常频繁,每次调用需要线性时间完全不可忍受。

平衡性优化

我们要知道哪种情况下可能出现不平衡现象,关键在于 union 过程:

public void union(int p, int q) {
	// 如果头结点一样,那就不用合并了
    int rootP = find(p);
    int rootQ = find(q);
    if (rootP == rootQ)
        return;
    // 将两棵树合并为一棵
    parent[rootP] = rootQ;
    // parent[rootQ] = rootP 也可以
    // 我们该选哪一种方案呢?

	// 连通分量--
    count--;

我们一开始就是简单粗暴的把 p 所在的树接到 q 所在的树的根节点下面,那么这里就可能出现 「头重脚轻」的不平衡状况,比如下面这种局面:

在这里插入图片描述
长此以往,树可能生长得很不平衡。我们其实是希望,小一些的树接到大一些的树下面,这样就能避免头重脚轻,更平衡一些。解决方法是额外使用一个 size 数组,记录每棵树包含的节点数,我们不妨称为「重量」:

class UF {
    private int count;
    private int[] parent;
    // 新增一个数组记录树的“重量”
    private int[] size;

    public UF(int n) {
        this.count = n;
        parent = new int[n];
        // 最初每棵树只有一个节点
        // 重量应该初始化 1
        size = new int[n];
        for (int i = 0; i < n; i++) {
            parent[i] = i;
            size[i] = 1;
        }
    }
    /* 其他函数 */
}

比如说 size[3] = 5 表示,以节点 3 为根的那棵树,总共有 5 个节点。这样我们可以修改一下 union 方法:关键就在于有了树的结点判断,使得树的生成更加平衡。

public void union(int p, int q) {
    int rootP = find(p);
    int rootQ = find(q);
    if (rootP == rootQ)
        return;
    
    // 小树接到大树下面,较平衡
    if (size[rootP] > size[rootQ]) {
        parent[rootQ] = rootP;
        size[rootP] += size[rootQ];
    } else {
        parent[rootP] = rootQ;
        size[rootQ] += size[rootP];
    }
    count--;
}

这样,通过比较树的重量,就可以保证树的生长相对平衡,树的高度大致在 logN 这个数量级,极大提升执行效率。

此时,find , union , connected 的时间复杂度都下降为 O(logN),即便数据规模上亿,所需时间也非常少。

路径压缩

这步优化特别简单,所以非常巧妙。我们能不能进一步压缩每棵树的高度,使树高始终保持为常数?

这样 find 就能以 O(1) 的时间找到某一节点的根节点,相应的,connected 和 union 复杂度都下降为 O(1)。

要做到这一点,非常简单,只需要在 find 中加一行代码:

private int find(int x) {
    while (parent[x] != x) {
        // 进行路径压缩
        parent[x] = parent[parent[x]];
        x = parent[x];
    }
    return x;
}

注意,while循环的停止条件是parent[x] != x,这是因为根节点是带环的,一旦出现parent[x] == x,说明已经到根节点了。
在这里插入图片描述

可见,调用 find 函数每次向树根遍历的同时,顺手将树高缩短了,最终所有树高都不会超过 3(union 的时候树高可能达到 3)。

进行路径压缩的目的:更快的找到根结点,反正最终目的都是找根节点,那我直接把当前结点的parent结点指向根节点,不就直接节省时间了。

有了路径压缩以后,union函数就不太需要判断小树大树了,对时间的效率影响很小,当然判断了更好。

整体并查集的代码:

class UF {
    // 连通分量个数
    int count;
    // 存储一棵树的根节点
    int[] parent;
    // 记录树的“重量”(节点数)
    int[] size;

    // n 为图中节点的个数
    public UF(int n) {
        // 刚开始还没有连在一起,连通分量=n
        this.count = n;
        parent = new int[n];
        size = new int[n];
        for (int i = 0; i < n; i++) {
            // 节点指向自己
            parent[i] = i;
            // 当前这棵树只有一个节点(根节点)
            size[i] = 1;
        }
    }

    // 返回节点 x 的连通分量的根节点
    public int find(int x) {
        while (parent[x] != x) {
            // 路径压缩
            parent[x] = parent[parent[x]];
            x = parent[x];
        }
        return x;
    }

    // 将节点 p 和 q 连通
    public void union(int p, int q) {
        int rootP = find(p);
        int rootQ = find(q);
        // 已经连通了:根节点一样
        if (rootP == rootQ) {
            return;
        }

        // 小树接到大树下面,更平衡
        // 大树的根节点作为整体的根节点
        if (size[rootP] > size[rootQ]) {
            parent[rootQ] = rootP;
            size[rootP] += size[rootQ];
        } else {
            parent[rootP] = rootQ;
            size[rootQ] += size[rootP];
        }

        // 两个连通分量合并成一个连通分量
        count--;
    }

    // 判断节点 p 和 q 是否连通
    // 本质是找根节点是否一样
    public boolean connected(int p, int q) {
        int rootP = find(p);
        int rootQ = find(q);
        return rootP == rootQ;
    }

    // 返回图中的连通分量个数
    public int count() {
        return count;
    }
}

图论算法三

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
DFS算法实现拓扑排序的代码如下所示: ```java package com.sh.zfc.graph.bfs; import org.junit.Test; import java.util.Arrays; import java.util.Stack; import static org.junit.Assert.*; public class TopSortTest { @Test public void dfs() { Digraph.GraphVertex\[\] vertices = new Digraph.GraphVertex\[4\]; vertices\[0\] = new Digraph.GraphVertex("A" , Arrays.asList(1,3)); vertices\[1\] = new Digraph.GraphVertex("B" , Arrays.asList(2,3)); vertices\[2\] = new Digraph.GraphVertex("C" ); vertices\[3\] = new Digraph.GraphVertex("D" ); Digraph<String> di = new Digraph<>(vertices); TopSort topsort = new TopSort(di); topsort.topSortByDFS(di); Stack<Integer> result = topsort.getReversePost(); Stack<Integer> expect = new Stack<>(); expect.push(2); expect.push(3); expect.push(1); expect.push(0); assertEquals(expect,result); } } ``` 这段代码使用了DFS算法来实现拓扑排序。首先创建了一个有向图,然后通过DFS算法进行拓扑排序。最后,将排序结果与预期结果进行比较,以验证算法的正确性。 #### 引用[.reference_title] - *1* [浅谈拓扑排序(基于dfs算法)](https://blog.csdn.net/langzitan123/article/details/79687736)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v91^control_2,239^v3^insert_chatgpt"}} ] [.reference_item] - *2* [算法之拓朴排序DFS实现](https://blog.csdn.net/tony820418/article/details/84588614)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v91^control_2,239^v3^insert_chatgpt"}} ] [.reference_item] - *3* [拓扑排序(topological sort)DFS](https://blog.csdn.net/Tczxw/article/details/47334785)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v91^control_2,239^v3^insert_chatgpt"}} ] [.reference_item] [ .reference_list ]
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

@u@

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值