学了忘,忘了学,学了再忘,忘了再学。
图的算法分成两种,一种是网格图,另一种是有向图(无向图)。
深搜
- 对于有向图而言,意味着一条路径
- 对于网格图而言,意味着一个连通区域
广搜
- 二者的含义差不多。
对于有向图,题干的形式一般是{node, edge}这样的数据结构,不利于dfs和bfs,我们一般需要转换为邻接矩阵。
对于网格图,一般给出一个数组
有向图我们应该额外注意到的两个性质是 {入度和出度},有些题干利用好入度和出度可以更方便的理解题意。
除此以外,选择dfs/bfs,如何去重,如何回溯,如何构造临时的中间存储结构,就依靠刷题提升感觉了。
拓扑排序是有向图上的一个算法,主要用于判断是否存在环路。
拓扑序: 给定一个顺序序列,如果序列里的每个node的顺序,在edges里的每条边{x, y}中,不会有任何y比x先出现,则是一个合法的拓扑序。要注意,有些结点之间没有明显的边关系,对于这样的结点在序列里的顺序是随意的。
Kahn算法,统计入度 + bfs
流程
- 统计所有结点的入度,将入度为0的加入队列。入度为零意味着该结点"无拘无束",不依赖之前的任何一个结点。
- 依次弹出队列的结点,将该结点加入结果,并将该结点为入度的所有边的出度结点的入度-1(等价于删除以当前结点为入度的所有边),如果删除后某个结点的入度为0,则加入该结点。
- 退出时(队列为空),如果结果的长度刚好就是结点个数,则构成一个合法的拓扑序。
leetcode 207: 课程表
需要理解的是第29行,其实就是在证明"每个node至多被加入到queue里一次"
如果无环,那么等价于从一开始入度为0的点(多源)到达他们出发的所有路径,每个结点只会被加入一次。
如果有环,遍历过程中就会发现某个中间状态,任何node的入度都不为0,此时提前退出,部分结点一次都没被加入。
class Solution {
// 拓扑排序
// {node, edges} 构造成邻接矩阵,同时构造入度列表
public boolean canFinish(int numCourses, int[][] prerequisites) {
int[] din = new int[numCourses];
List<List<Integer>> graph = new ArrayList<>();
for (int i = 0; i < numCourses; i++) graph.add(new ArrayList<>());
for (int[] edge : prerequisites) {
int from = edge[0];
int to = edge[1];
din[to]++; // 入度数组
graph.get(from).add(to); // 邻接矩阵
}
// 拓扑序列
List<Integer> res = new ArrayList<>();
// bfs
Queue<Integer> q = new LinkedList<>();
for (int i = 0; i < numCourses; i++) {
if (din[i] == 0) {
q.add(i);
}
}
while (!q.isEmpty()) {
int cur = q.poll();
res.add(cur); // 证明: 每个node至多被加入一次
// 找到所有cur为起点的边
for (int to : graph.get(cur)) {
// 移除{cur, to} 这条边,如果移除后to的入度为0,则to加入
if (--din[to] == 0) q.add(to);
}
}
// 如果退出时, res.size() != numsCourse,则存在环路
// 也就是在某个过程,我们发现每个结点的入度都不为1,此时表示无论如何也没法满足彼此的依赖关系
// todo: 依赖管理工具是不是就这个原理
return res.size() == numCourses;
}
}
dfs版本:
- 所有结点 初始化为染色成0
- 首次到达结点时,如果此时染色为0,将结点染色为-1,如果此时发现染色为-1,说明存在环路,一路返回0
- 遍历结点的所有孩子(dfs),如果孩子返回0,一路返回0
- 返回结点时(所有的孩子都访问完了),将结点染色为1,并返回1
注意几点
- 每次dfs意味着 整条路径上的结点都会被染色。所以我们for (all node) { 去重 then dfs() } 实际上访问了所有有效路径(多源dfs)
- 只有所有路径都没有环,才能保证结果返回true。
- 对于单条路径(一个dfs过程),只要其中一个过程有环,则不需要继续递归,直接一路返回false
- 如果遍历完所有孩子,必须让自己变成1,1表示"遍历过但是无环",如果一个结点同时位于两个路径上,不及时更新的话,会误判。e.g. {1->2->4}, {3->2->5} 2会被遍历两次,但是合法的,关键在于1->2->3遍历后,2的颜色为1,而不是-1。
状态机
-
-1 表示 "同一条路径存在环"
-
0 表示结点从没被遍历过
-
1 表示 "一个结点位于不同路径"
-
如果进入递归前,发现结点颜色为 -1 表示非法,一路返回false
-
如果进入递归前,发现结点颜色为1, 则继续递归不返回,注意这个设计很精妙,如果一个node颜色为1,则说明从这个node出发的所有路径已经被证明无环了,那就没必要再继续下去了。
-
如果进入递归前,发现结点颜色为0,染成-1再继续递归
-
如果某个结点返回时,颜色从-1 染成 1
class Solution {
public boolean canFinish(int numCourses, int[][] prerequisites) {
// 同样的,邻接矩阵用于dfs, 单纯的{node, edges} 不可以用于dfs
List<List<Integer>> g = new ArrayList<>();
for (int i = 0; i < numCourses; i++) g.add(new ArrayList<>());
for (int[] edge : prerequisites) {
g.get(edge[0]).add(edge[1]);
}
// 一开始所有的node染色为0
// 我们要从所有node出发进行dfs, 因为有些结点可能没有关联
// 一次dfs = 遍历一条路径,一次dfs会把路径上的所有结点都染色成1,
// 所以尽管我们好像是从所有node出发复杂度O(len of node),实际上复杂度是O(len of path)
int[] color = new int[numCourses];
for (int i = 0; i < numCourses; i++) {
// 如果该位置是1,表示这个结点在之前的某次dfs路径被访问过了,直接退出
if (color[i] != 0) continue;
// 有任何一个路径不满足,结果都是不满足的
if(!dfs(g, i, color)) return false;
}
return true;
}
public boolean dfs(List<List<Integer>> g, int cur, int[] color) {
// 如果该位置已经是-1了,然后又进来一次,此时直接返回false
if (color[cur] == -1) return false;
// 将位置染色成-1
color[cur] = -1;
// 遍历所有孩子
for (int y : g.get(cur)) {
if (color[y] == 1) continue; // 跳过
if (!dfs(g, y, color)) return false; // 如果自己的一个孩子有环,那自己一路返回false,不需要继续递归了
}
// 如果遍历完所有孩子, 将自己染色为1,返回true
color[cur] = 1;
return true;
}
}
210: 课程表II
就是把207的具体的拓扑顺序求出来
class Solution {
// 和207 完全一样,就是求出具体的拓扑序
public int[] findOrder(int numCourses, int[][] prerequisites) {
List<List<Integer>> g = new ArrayList<>();
for (int i = 0; i < numCourses; i++) g.add(new ArrayList<>());
int[] din = new int[numCourses];
for (int[] edge : prerequisites) {
g.get(edge[0]).add(edge[1]);
din[edge[1]]++;
}
List<Integer> res = new ArrayList<>();
Queue<Integer> q = new LinkedList<>();
for (int i = 0; i < numCourses; i++) if (din[i] == 0) q.add(i);
while (!q.isEmpty()) {
int cur = q.poll();
res.add(cur);
for (int to : g.get(cur)) {
if (--din[to] == 0) q.add(to);
}
}
int[] ret = new int[res.size()];
if (ret.length != numCourses) return new int[0];
// 注意题意,[a, b]的含义是a依赖b
// 我们res其实是存储了 a->b->c->d 的顺序,因此最后的需要最先修
for (int i = numCourses - 1; i >= 0; i--) ret[i] = res.get(numCourses - i + - 1);
return ret;
}
}
递归做法不需要翻转数组。刚好递归的含义就是题干的意思
class Solution {
List<Integer> ret = new ArrayList<>();
public int[] findOrder(int numCourses, int[][] prerequisites) {
// 同样的,邻接矩阵用于dfs, 单纯的{node, edges} 不可以用于dfs
List<List<Integer>> g = new ArrayList<>();
for (int i = 0; i < numCourses; i++) g.add(new ArrayList<>());
for (int[] edge : prerequisites) {
g.get(edge[0]).add(edge[1]);
}
// 一开始所有的node染色为0
// 我们要从所有node出发进行dfs, 因为有些结点可能没有关联
// 一次dfs = 遍历一条路径,一次dfs会把路径上的所有结点都染色成1,
// 所以尽管我们好像是从所有node出发复杂度O(len of node),实际上复杂度是O(len of path)
int[] color = new int[numCourses];
for (int i = 0; i < numCourses; i++) {
// 如果该位置是1,表示这个结点在之前的某次dfs路径被访问过了,直接退出
if (color[i] != 0) continue;
// 有任何一个路径不满足,结果都是不满足的
if(!dfs(g, i, color)) return new int[0];
}
int[] res = new int[ret.size()];
if (ret.size() != numCourses) return new int[0];
for (int i = 0; i < numCourses; i++) res[i] = ret.get(i);
return res;
}
public boolean dfs(List<List<Integer>> g, int cur, int[] color) {
// 如果该位置已经是-1了,然后又进来一次,此时直接返回false
if (color[cur] == -1) return false;
// 将位置染色成-1
color[cur] = -1;
// 遍历所有孩子
for (int y : g.get(cur)) {
if (color[y] == 1) continue; // 不继续遍历这个孩子 但尝试访问其他孩子(找到没访问过的孩子)
if (!dfs(g, y, color)) return false; // 如果自己的一个孩子有环,那自己一路返回false,不需要继续递归了
}
// 如果遍历完所有孩子, 将自己染色为1,返回true
color[cur] = 1;
// 同时把自己加入结果集
// 注意: 这里的顺序就是题目的顺序, 首先加入的是路径里最深的结点,也就是依赖的首个结点
ret.add(cur);
return true;
}
}