学习自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 = buildGraph(numCourses, prerequisites);
// 计算入度
int[] indegree = new int[numCourses];
for (int[] edge : prerequisites) {
int from = edge[1], to = edge[0];
//[1,0],由0指向1,需要先修0,再修1,所以1的入度++
indegree[to]++;
}
// 根据入度初始化队列中的结点
Queue<Integer> q = new LinkedList<>();
for (int i = 0; i < numCourses; i++) {
if (indegree[i] == 0) {
// 入度=0,就入队
q.offer(i);
}
}
// 记录拓扑排序的结果
int[] res = new int[numCourses];
// 索引
int count = 0;
// 执行BFS算法
while (!q.isEmpty()) {
int cur = q.poll();
// 弹出结点的顺序即为拓扑排序的结果
res[count++] = cur;
for (int next : graph[cur]) {
// 与当前结点相连的结点的入度--;
indegree[next]--;
if (indegree[next] == 0) {
q.offer(next);
}
}
}
if (count != numCourses) {
// 存在环,不存在拓扑排序
return new int[] {};
}
return res;
}
// 建图
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;
}
}
按道理, 图的遍历都需要 visited 数组防止走回头路,这里的 BFS 算法其实是通过 indegree 数组实现的 visited 数组的作用,只有入度为 0 的节点才能入队,从而保证不会出现死循环。
个人更建议重点掌握dfs方法,更加实用,适用面更广。
三、二分图判定
二分图判定思路
判定二分图的算法很简单,就是用代码解决「双色问题」。
说白了就是遍历一遍图,一边遍历一边染色,看看能不能用两种颜色给所有节点染色,且相邻节点的颜色都不相同。
既然说到遍历图,也不涉及最短路径之类的,当然是 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[] vis;
boolean[] colored;
boolean flag = true;
public boolean isBipartite(int[][] graph) {
vis = new boolean[graph.length];
colored = new boolean[graph.length];
// 先把遍历图的代码写出来
// 图不一定是连通的,可能存在多个子图,所以要对每个节点进行遍历
// 如果发现任何一个子图不是二分图,整个图都不是二分图
for (int i = 0; i < graph.length; i++) {
if (!vis[i]) {
traverse(graph, i);
}
}
return flag;
}
void traverse(int[][] 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;
}
} else {
// 没有访问过,就涂与当前s结点相反颜色
colored[t] = !colored[s];
traverse(graph, t);
}
}
}
}
用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 最小生成树算法
如果一幅图没有环,完全可以拉伸成一棵树的模样。说的专业一点,树就是「无环连通图」。
那么什么是图的「生成树」呢,其实按字面意思也好理解,就是在图中找一棵包含图中的所有节点的树。专业点说,生成树是含有图中所有顶点的「无环连通子图」。
容易想到,一幅图可以有很多不同的生成树,比如下面这幅图,红色的边就组成了两棵不同的生成树
对于加权图,每条边都有权重,所以每棵生成树都有一个权重和。比如上图,右侧生成树的权重和显然比左侧生成树的权重和要小。
那么最小生成树很好理解了,所有可能的生成树中,权重和最小的那棵生成树就叫「最小生成树」。
这里需要使用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;
}
}