图论基础以及遍历算法
图和多叉树的结构十分类似,不过一般通过邻接表和邻接矩阵来实现
图的遍历
图怎么遍历?还是那句话,参考多叉树,多叉树的 DFS 遍历框架如下:
/* 多叉树遍历框架 */
void traverse(TreeNode root) {
if (root == null) return;
// 前序位置
for (TreeNode child : root.children) {
traverse(child);
}
// 后序位置
}
图和多叉树最大的区别是,图是可能包含环的,你从图的某一个节点开始遍历,有可能走了一圈又回到这个节点,而树不会出现这种情况,从某个节点出发必然走到叶子节点,绝不可能回到它自身。
所以,如果图包含环,遍历框架就要一个 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;
}
类比贪吃蛇游戏,visited
记录蛇经过过的格子,而 onPath
仅仅记录蛇身。在图的遍历过程中,onPath
用于判断是否成环,类比当贪吃蛇自己咬到自己(成环)的场景。
visited看上去似乎有点重复,但这么做会减少计算量,如果碰到visited被标记为true,则说明前面已经对这个节点之后的样子做出判断了,无须再进行延伸,所以减少计算量
如果题目告诉你图中不含环,可以把 visited
数组都省掉,基本就是多叉树的遍历
回溯算法和 DFS 算法的区别所在:回溯算法关注的不是节点,而是树枝。不信你看前文画的回溯树,我们需要在「树枝」上做选择和撤销选择:
不过为什么回溯算法的前后序操作位置在for循环里面呢?
// DFS 算法,关注点在节点
void traverse(TreeNode root) {
if (root == null) return;
printf("进入节点 %s", root);
for (TreeNode child : root.children) {
traverse(child);
}
printf("离开节点 %s", root);
}
// 回溯算法,关注点在树枝
void backtrack(TreeNode root) {
if (root == null) return;
for (TreeNode child : root.children) {
// 做选择
printf("从 %s 到 %s", root, child);
backtrack(child);
// 撤销选择
printf("从 %s 到 %s", child, root);
}
}
如果我们将做选择放在循环里面的话,就会发现漏掉了根节点。所以对于这里「图」的遍历,我们应该用 DFS 算法即把 onPath
的操作放到 for 循环外面,否则会漏掉记录起始点的遍历
所有可能的路径
既然输入的图是无环的,我们就不需要 visited
数组辅助了,直接套用图的遍历框架
// 记录所有路径
List<List<Integer>> res = new LinkedList<>();
public List<List<Integer>> allPathsSourceTarget(int[][] graph) {
// 维护递归过程中经过的路径
LinkedList<Integer> path = new LinkedList<>();
traverse(graph, 0, path);
return res;
}
/* 图的遍历框架 */
void traverse(int[][] graph, int s, LinkedList<Integer> path) {
// 添加节点 s 到路径
path.addLast(s);
int n = graph.length;
if (s == n - 1) {
// 到达终点
res.add(new LinkedList<>(path));
// 可以在这直接 return,但要 removeLast 正确维护 path
// path.removeLast();
// return;
// 不 return 也可以,因为图中不包含环,不会出现无限递归
}
// 递归每个相邻节点
for (int v : graph[s]) {
traverse(graph, v, path);
}
// 从路径移出节点 s
path.removeLast();
}
这道题就这样解决了,注意 Java 的语言特性,因为 Java 函数参数传的是对象引用,所以向 res
中添加 path
时需要拷贝一个新的列表,否则最终 res
中的列表都是空的。
环检测以及拓扑排序算法
环检测算法(DFS 版本)
判断图中是否存在环
// 记录一次递归堆栈中的节点
boolean[] onPath;
// 记录遍历过的节点,防止走回头路
boolean[] visited;
// 记录图中是否有环
boolean hasCycle = false;
boolean canFinish(int numCourses, int[][] prerequisites) {
List<Integer>[] graph = buildGraph(numCourses, prerequisites);
visited = new boolean[numCourses];
onPath = new boolean[numCourses];
for (int i = 0; i < numCourses; i++) {
// 遍历图中的所有节点
// 注意图中并不是所有节点都相连
// 所以要用一个 for 循环将所有节点都作为起点调用一次 DFS 搜索算法
traverse(graph, i);
}
// 只要没有循环依赖可以完成所有课程
return !hasCycle;
}
void traverse(List<Integer>[] graph, int s) {
if (onPath[s]) {
// 出现环
hasCycle = true;
}
if (visited[s] || hasCycle) {
// 如果已经找到了环,也不用再遍历了
// 如果已经被visited,那么也不用再次遍历,直接返回,相当于动态规划中的memo数组
return;
}
// 前序代码位置
visited[s] = true;
onPath[s] = true;
for (int t : graph[s]) {
traverse(graph, t);
}
// 后序代码位置
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[] edge : prerequisites) {
int from = edge[1], to = edge[0];
// 添加一条从 from 指向 to 的有向边
// 边的方向是「被依赖」关系,即修完课程 from 才能修课程 to
graph[from].add(to);
}
return graph;
}
如果出题人继续恶心你,让你不仅要判断是否存在环,还要返回这个环具体有哪些节点,怎么办?
值得注意的是头结点也在环中的情况
拓扑排序算法(DFS 版本)
这道题就是上道题的进阶版,不是仅仅让你判断是否可以完成所有课程,而是进一步让你返回一个合理的上课顺序,保证开始修每个课程时,前置的课程都已经修完。
拓扑排序直观地说就是,让你把一幅图「拉平」,而且这个「拉平」的图里面,所有箭头方向都是一致的。很显然,如果一幅有向图中存在环,是无法进行拓扑排序的,因为肯定做不到所有箭头方向一致;反过来,如果一幅图是「有向无环图」,那么一定可以进行拓扑排序。
如果把课程抽象成节点,课程之间的依赖关系抽象成有向边,那么这幅图的拓扑排序结果就是上课顺序
class Solution {
// 存储有向图
List<List<Integer>> edges;
// 标记每个节点的状态:0=未搜索,1=搜索中,2=已完成
int[] visited;
// 用数组来模拟栈,下标 n-1 为栈底,0 为栈顶
int[] result;
// 判断有向图中是否有环
boolean valid = true;
// 栈下标
int index;
public int[] findOrder(int numCourses, int[][] prerequisites) {
edges = new ArrayList<List<Integer>>();
for (int i = 0; i < numCourses; ++i) {
edges.add(new ArrayList<Integer>());
}
visited = new int[numCourses];
result = new int[numCourses];
index = numCourses - 1;
for (int[] info : prerequisites) {
edges.get(info[1]).add(info[0]);
}
// 每次挑选一个「未搜索」的节点,开始进行深度优先搜索
for (int i = 0; i < numCourses && valid; ++i) {
if (visited[i] == 0) {
dfs(i);
}
}
if (!valid) {
return new int[0];
}
// 如果没有环,那么就有拓扑排序
return result;
}
public void dfs(int u) {
// 将节点标记为「搜索中」
visited[u] = 1;
// 搜索其相邻节点
// 只要发现有环,立刻停止搜索
for (int v: edges.get(u)) {
// 如果「未搜索」那么搜索相邻节点
if (visited[v] == 0) {
dfs(v);
if (!valid) {
return;
}
}
// 如果「搜索中」说明找到了环
else if (visited[v] == 1) {
valid = false;
return;
}
}
// 将节点标记为「已完成」
visited[u] = 2;
// 将节点入栈
result[index--] = u;
}
}
如何进行拓扑排序呢?
其实特别简单,将后序遍历的结果进行反转,就是拓扑排序的结果
为什么算法需要反转
你确实可以看到这样的解法,原因是他建图的时候对边的定义和我不同。图中箭头方向是「被依赖」关系,比如节点
1
指向2
,含义是节点1
被节点2
依赖,即做完1
才能去做2
,如果你反过来,把有向边定义为「依赖」关系,那么整幅图中边全部反转,就可以不对后序遍历结果反转。具体来说,就是把代码中
graph[from].add(to);
改成graph[to].add(from);
就可以不反转了。
在上一题环检测的代码基础上添加了记录后序遍历结果的逻辑,就是这道题的答案
那么为什么后序遍历的反转结果就是拓扑排序呢?
后序遍历的这一特点很重要,之所以拓扑排序的基础是后序遍历,是因为一个任务必须等到它依赖的所有任务都完成之后才能开始开始执行
环检测算法(BFS 版本)
BFS 算法的思路:
1、构建邻接表,和之前一样,边的方向表示「被依赖」关系。
2、构建一个 indegree
数组记录每个节点的入度,即 indegree[i]
记录节点 i
的入度。
3、对 BFS 队列进行初始化,将入度为 0 的节点首先装入队列。
4、开始执行 BFS 循环,不断弹出队列中的节点,减少相邻节点的入度,并将入度变为 0 的节点加入队列。
5、如果最终所有节点都被遍历过(count
等于节点数),则说明不存在环,反之则说明存在环。
拓扑排序算法(BFS 版本)
同样是210. 课程表 II - 力扣(LeetCode)的BFS版本
class Solution {
// 存储有向图
List<List<Integer>> edges;
// 存储每个节点的入度
int[] indeg;
// 存储答案
int[] result;
// 答案下标
int index;
public int[] findOrder(int numCourses, int[][] prerequisites) {
edges = new ArrayList<List<Integer>>();
for (int i = 0; i < numCourses; ++i) {
edges.add(new ArrayList<Integer>());
}
indeg = new int[numCourses];
result = new int[numCourses];
index = 0;
for (int[] info : prerequisites) {
edges.get(info[1]).add(info[0]);
++indeg[info[0]];
}
Queue<Integer> queue = new LinkedList<Integer>();
// 将所有入度为 0 的节点放入队列中
for (int i = 0; i < numCourses; ++i) {
if (indeg[i] == 0) {
queue.offer(i);
}
}
while (!queue.isEmpty()) {
// 从队首取出一个节点
int u = queue.poll();
// 放入答案中
result[index++] = u;
for (int v: edges.get(u)) {
--indeg[v];
// 如果相邻节点 v 的入度为 0,就可以选 v 对应的课程了
if (indeg[v] == 0) {
queue.offer(v);
}
}
}
if (index != numCourses) {
return new int[0];
}
return result;
}
}
二分图判定
二分图的顶点集可分割为两个互不相交的子集,图中每条边依附的两个顶点都分属于这两个子集,且两个子集内的顶点不相邻。
判断思路
遍历一遍图,一边遍历一边染色,看看能不能用两种颜色给所有节点染色,且相邻节点的颜色都不相同
785. 判断二分图 - 力扣(LeetCode)
public class Solution
{
private bool[] color;
private bool[] visited;
public bool IsBipartite(int[][] graph)
{
color = new bool[graph.Length];
visited = new bool[graph.Length];
for (int i = 0; i < graph.Length; i++)
{
if (!visited[i])
{
DFS(graph, i);
}
}
// 遍历整个图看是否相邻有相同颜色
for (int i = 0; i < graph.Length; i++)
{
foreach (int num in graph[i])
{
if(color[i] == color[num])
{
return false;
}
}
}
return true;
}
private void DFS(int[][] graph,int index)
{
visited[index] = true;
foreach(int num in graph[index])
{
if (!visited[num])
{
// 给相邻结点染上和自己不同的颜色
color[num] = !color[index];
DFS(graph, num);
}
}
}
}
886. 可能的二分法 - 力扣(LeetCode)
题目只是换了一种描述,我们需要自己构建图关系,然后参照上面的方法就可以了
public class Solution
{
private bool[] color;
private bool[] visited;
private bool res = true;
public bool PossibleBipartition(int n, int[][] dislikes)
{
color = new bool[n];
visited = new bool[n];
// 构建图关系
List<int>[] relationship = new List<int>[n];
for(int i = 0; i < n; i++)
{
relationship[i] = new List<int>();
}
foreach (int[] item in dislikes)
{
relationship[item[0] - 1].Add(item[1] - 1);
relationship[item[1] - 1].Add(item[0] - 1);
}
for (int i = 0; i < n; i++)
{
if (!visited[i])
{
DFS(relationship, i);
}
}
return res;
}
// 遍历图,如果相邻结点分组相同那么返回false
// 和785题的DFS代码基本一致
private void DFS(IList<int>[] graph, int index)
{
if (!res) return;
visited[index] = true;
foreach (int num in graph[index])
{
if (!visited[num])
{
color[num] = !color[index];
DFS(graph, num);
}else if(color[num] == color[index])
{
res = false;
return;
}
}
}
}
并查集(Union-Find)算法
常说的并查集(Disjoint Set)结构,主要是解决图论中「动态连通性」问题的
动态连通性其实可以抽象成给一幅图连线,我们可以实现如下API:
假设我们有十个互不相连的结点,那么他的连通分量就是10
如果现在调用 union(0, 1)
,那么 0 和 1 被连通,连通分量降为 9 个。
再调用 union(1, 2)
,这时 0,1,2 都被连通,调用 connected(0, 2)
也会返回 true,连通分量变为 8 个
class UF {
/* 将 p 和 q 连接 */
public void union(int p, int q);
/* 判断 p 和 q 是否连通 */
public boolean connected(int p, int q);
/* 返回图中有多少个连通分量 */
public int count();
}
连通性的数据结构
我们使用森林(若干棵树)来表示图的动态连通性,用数组来具体实现这个森林。
怎么用森林来表示连通性呢?我们设定树的每个节点有一个指针指向其父节点,如果是根节点的话,这个指针指向自己
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;
}
/* 其他函数 */
}
如果某两个节点被连通,则让其中的(任意)一个节点的根节点接到另一个节点的根节点上
这样,如果节点 p
和 q
连通的话,它们一定拥有相同的根节点
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 boolean connected(int p, int q) {
int rootP = find(p);
int rootQ = find(q);
return rootP == rootQ;
}
值得注意的是,连接两棵树的根节点的时候,有可能出现左右子树节点数量相差很大的极端情况。解决方法是额外使用一个 size
数组,记录每棵树包含的节点数,使其尽量平衡
路径压缩
其实我们并不在乎每棵树的结构长什么样,只在乎根节点。
因为无论树长啥样,树上的每个节点的根节点都是相同的,所以能不能进一步压缩每棵树的高度,使树高始终保持为常数
// 每次 while 循环都会把一对儿父子节点改到同一层
// 这样每次调用 find 函数向树根遍历的同时,顺手就将树高缩短了
private int find(int x) {
while (parent[x] != x) {
// 这行代码进行路径压缩
parent[x] = parent[parent[x]];
x = parent[x];
}
return x;
}
// 第二种路径压缩的 find 方法
// 让所有结点都在根节点的下一层
public int find(int x) {
if (parent[x] != x) {
parent[x] = find(parent[x]);
}
return parent[x];
}
如果使用路径压缩技巧,那么 size
数组的平衡优化就不是特别必要了
等式方程的可满足性
动态连通性其实就是一种等价关系,具有「自反性」「传递性」和「对称性」,其实 ==
关系也是一种等价关系,具有这些性质。所以这个问题用 Union-Find 算法就很自然
核心思想是,将 equations
中的算式根据 ==
和 !=
分成两部分,先处理 ==
算式,使得他们通过相等关系连通;然后处理 !=
算式,检查不等关系是否破坏了相等关系的连通性。
使用上面构造的UF类能轻松解答,char
类型可以通过减去‘a’
来转换为int
最小生成树
Kruskal
生成树是含有图中所有顶点的「无环连通子图」,加权图,每条边都有权重,所以每棵生成树都有一个权重和。
所有可能的生成树中,权重和最小的那棵生成树就叫「最小生成树」。
最小生成树,就是图中若干边的集合(我们后文称这个集合为mst
,最小生成树的英文缩写),保证这些边:
1、包含图中的所有节点。
2、形成的结构是树结构(即不存在环)。
3、权重和最小。
怎么分辨图和树
对于添加的这条边,如果该边的两个节点本来就在同一连通分量里,那么添加这条边会产生环;反之,如果该边的两个节点不在同一连通分量里,则添加这条边不会产生环。
如何保证得到的这棵生成树是权重和最小的?
将所有边按照权重从小到大排序**,从权重最小的边开始遍历,如果这条边和mst
中的其它边不会形成环,则这条边是最小生成树的一部分**,将它加入mst
集合;否则,这条边不是最小生成树的一部分,不要把它加入mst
集合。
Kruskal 算法,就是利用 Union-Find 并查集算法向最小生成树中添加边,配合排序的贪心思路,从而得到一棵权重之和最小的生成树。
1584. 连接所有点的最小费用 - 力扣(LeetCode)
// 构造边类型
class Edge
{
public int len, x, y;
public Edge(int len, int x, int y)
{
this.len = len;
this.x = x;
this.y = y;
}
}
public class Solution
{
int[] parents;
public int MinCostConnectPoints(int[][] points)
{
int n = points.Length;
// 初始化父节点指针
parents = new int[n];
for (int i = 0; i < n; i++)
{
parents[i] = i;
}
// 计算出所有点形成的边
List<Edge> edges = new List<Edge>();
for (int i = 0; i < n; i++)
{
for (int j = i + 1; j < n; j++)
{
edges.Add(new Edge(dist(points, i, j), i, j));
}
}
// 自定义委托类型从小到大排序
edges.Sort((edge1, edge2) =>
{
// 注意是返回CompareTo而不是直接返回较大的值
return edge1.len.CompareTo(edge2.len);
});
// 遍历有序边集合
int res = 0, index = 1;
foreach (Edge edge in edges)
{
// 如果添加最小边不会形成环,那么这条边一定是最小生成树的组成部分
if (!IsConnected(edge.x, edge.y))
{
Union(edge.x, edge.y);
res += edge.len;
// 如果已经找到了n-1条边,那么已经连通,可以直接跳过
++index;
if (index == n) break;
}
}
return res;
}
// 计算曼哈顿距离
public int dist(int[][] points, int x, int y)
{
return Math.Abs(points[x][0] - points[y][0]) + Math.Abs(points[x][1] - points[y][1]);
}
// 以下都是并查集构造相关的API
private void Union(int left, int right)
{
int leftRoot = Find(left);
int rightRoot = Find(right);
if (leftRoot != rightRoot)
{
parents[leftRoot] = rightRoot;
}
}
private int Find(int index)
{
while (parents[index] != index)
{
parents[index] = parents[parents[index]];
index = parents[index];
}
return index;
}
private bool IsConnected(int left, int right)
{
int leftRoot = Find(left);
int rightRoot = Find(right);
if (leftRoot == rightRoot)
{
return true;
}
else return false;
}
}
Prim
Prim 算法也使用贪心思想来让生成树的权重尽可能小,也就是「切分定理」
其次,Prim 算法使用 BFS 和 visited
布尔数组避免成环,来保证选出来的边最终形成的一定是一棵树。
切分定理
切分:将一幅图分为两个不重叠且非空的节点集合
切分定理:对于任意一种「切分」,其中权重最小的那条「横切边」一定是构成最小生成树的一条边。
有了切分定理,我们能得到生成最小生成树的方法;既然每一次「切分」一定可以找到最小生成树中的一条边,那就随便切,每次都把权重最小的「横切边」拿出来加入最小生成树,直到把构成最小生成树的所有边都切出来为止。
但是随便切不好准确定义,那就只切出一个结点,将新节点与之前的形成集合,这是最简单的切分,也最好实现
此外还需要用一个优先级队列存储动态计算权重最小的横切边,用布尔数组防止重复计算横切边
也可以创建一个 lowcost
数组来记录每个点到生成树的最短路径,每次加入点之后更新
public class Solution {
public int MinCostConnectPoints(int[][] points)
{
// 普利姆算法描述: 每加入点之前,更新一遍点到树的距离,选距离最小的点加入到树。与边数无关,适用于稠密图。
int pointNum = points.Length, ans = 0;
if (pointNum < 2) return 0;
// 记录每个点到生成树的最短距离
int[] lowcost = new int[pointNum];
// 将第一个点加入生成树中,除0外都为最大值
for (int i = 1; i < pointNum; i++) lowcost[i] = int.MaxValue;
int nearestPoint = 0, p, minD;
for (int i = 1; i < pointNum; i++)// 循环n-1次,加入每个点
{
minD = int.MaxValue;
p = 0;
// 获取未加入的点中离生成树最近的点,每次加入点之前都更新
for (int j = 1; j < pointNum; j++)
{
if (lowcost[j] == 0) continue;
// 如果是未加入的点,计算上次加入新的点之后是否变化,并保存最小值
lowcost[j] = Math.Min(lowcost[j], GetInstance(points,j, nearestPoint));
//记录离树最近的点
if (lowcost[j] < minD)
{
p = j;
minD = lowcost[j];
}
}
nearestPoint = p;
ans += lowcost[nearestPoint];
//一旦选中,累加,清0,标记为已经加入
lowcost[nearestPoint] = 0;
}
return ans;
}
private int GetInstance(int[][] points,int point1 ,int point2)
{
return Math.Abs(points[point1][0] - points[point2][0]) + Math.Abs(points[point1][1] - points[point2][1]);
}
}
总结
Kruskal 算法是在一开始的时候就把所有的边排序,然后从权重最小的边开始挑选属于最小生成树的边,组建最小生成树。
适用于稀疏图
Prim 算法是从一个起点的切分(一组横切边)开始执行类似 BFS 算法的逻辑,借助切分定理和优先级队列动态排序的特性,从这个起点「生长」出一棵最小生成树。
适用于稠密图
Dijkstra算法
输入一幅图和一个起点 start,计算 start 到其他节点的最短距离
在BFS算法中,我们需要while和for两层循环来分别控制一层一层往下和同一层之间的遍历,还需要用depth
变量来记录步数也就是当前for循环次数,还利用visited
数组来防止重复访问
但是Dijkstra算法需要去除掉for循环,因为在有权图中,步数也就没有意义,我们需要记录权重和。但与此同时我们仍需记录depth
变量,因此要新建一个类来存储=
框架
使用 Dijkstra 算法的前提,加权有向图,没有负权重边,求最短路径
因为 Dijkstra 计算最短路径的正确性依赖一个前提:路径中每增加一条边,路径的总权重就会增加
每次从「未确定节点」中取一个与起点距离最短的点,将它归类为「已确定节点」,并用它「更新」从起点到其他所有「未确定节点」的距离。直到所有点都被归类为「已确定节点」。
用节点 A「更新」节点 B 的意思是,用起点到节点 A 的最短路长度加上从节点 A 到节点 B 的边的长度,去比较起点到节点 B 的最短路长度,如果前者小于后者,就用前者更新后者
因为我们已经用了每一个「已确定节点」更新过了当前节点,无需再次更新(因为一个点不能多次到达)。而当前节点已经是所有「未确定节点」中与起点距离最短的点,不可能被其它「未确定节点」更新。所以当前节点可以被归类为「已确定节点」
权重和获取相邻结点的函数根据不同的题目选择
class Solustion
{
// 返回节点 from 到节点 to 之间的边的权重
int weight(int from, int to) { };
// 输入节点 s 返回 s 的相邻节点
List<int> adj(int s);
// 输入一幅图和一个起点 start,计算 start 到其他节点的最短距离
int[] dijkstra(int start, List<int>[] graph)
{
// 图中节点的个数
int V = graph.Length;
// 记录最短路径的权重,你可以理解为 dp table
// 定义:distTo[i] 的值就是节点 start 到达节点 i 的最短路径权重
int[] distTo = new int[V];
// 求最小值,所以 dp table 初始化为正无穷
Array.Fill(distTo, int.MaxValue);
// base case,start 到 start 的最短距离就是 0
distTo[start] = 0;
List<State> pq = new List<State>();
// 从起点 start 开始进行 BFS
pq.Add(new State(start, 0));
while (pq.Count != 0)
{
pq.Sort();
State curState = pq[0];
pq.RemoveAt(0);
int curNodeID = curState.id;
int curDistFromStart = curState.distFromStart;
if (curDistFromStart > distTo[curNodeID])
{
// 已经有一条更短的路径到达 curNode 节点了
continue;
}
// 将 curNode 的相邻节点装入队列
foreach (var nextNodeID in adj(curNodeID))
{
// 看看从 curNode 达到 nextNode 的距离是否会更短
int distToNextNode = distTo[curNodeID] + weight(curNodeID, nextNodeID);
if (distTo[nextNodeID] > distToNextNode)
{
// 更新 dp table
distTo[nextNodeID] = distToNextNode;
// 将这个节点以及距离放入队列
pq.Add(new State(nextNodeID, distToNextNode));
}
}
}
return distTo;
}
}
public class State : IComparer<State>
{
// 图节点的 id
public int id;
// 从 start 节点到当前节点的距离
public int distFromStart;
public State(int id, int distFromStart)
{
this.id = id;
this.distFromStart = distFromStart;
}
public int Compare(State x, State y)
{
return x.distFromStart.CompareTo(y.distFromStart);
}
}
例题
743. 网络延迟时间 - 力扣(LeetCode)
public int NetworkDelayTime(int[][] times, int n, int k) {
const int INF = int.MaxValue / 2;
// 表示两个点之间的距离
int[,] g = new int[n, n];
for (int i = 0; i < n; ++i) {
for (int j = 0; j < n; ++j) {
g[i, j] = INF;
}
}
foreach (int[] t in times) {
int x = t[0] - 1, y = t[1] - 1;
g[x, y] = t[2];
}
// 表示起点到各个点的最短距离
int[] dist = new int[n];
Array.Fill(dist, INF);
dist[k - 1] = 0;
bool[] used = new bool[n];
for (int i = 0; i < n; ++i) {
int x = -1;// 表示当前未确定点中离起点最近的点
for (int y = 0; y < n; ++y) {
if (!used[y] && (x == -1 || dist[y] < dist[x])) {
x = y;
}
}
used[x] = true;
for (int y = 0; y < n; ++y) {
// 新加入点之后,更新dist
dist[y] = Math.Min(dist[y], dist[x] + g[x, y]);
}
}
int ans = dist.Max();
return ans == INF ? -1 : ans;
}
1631. 最小体力消耗路径 - 力扣(LeetCode)
我们可以将二维数组的每一个元素都看作是结点,注意的是这道题的路径指的是经过权重的最大值
1514. 概率最大的路径 - 力扣(LeetCode)
前面讲过Dijkstra 计算最短路径的正确性依赖一个前提:路径中每增加一条边,路径的总权重就会增加。
反过来如果你想计算最长路径,路径中每增加一条边,路径的总权重就会减少,要是能够满足这个条件,也可以用 Dijkstra 算法
概率越乘越小,也符合这个规则