# 图遍历算法
图遍历算法,本质上就是一个更泛的树遍历算法。其遍历本质其实还是递归与回溯。
图的数据结构表示形式多以邻接表
和邻接矩阵
为主,这样能比较清晰的表示图中节点的连接关系。
邻接表
的表示形式多以二维数组或集合(Set)数组为主,主要是表现对于每一个节点,其相邻的节点有哪些。数组形式也能表明对于每一个元素,其所对应的出度是什么。
邻接矩阵
的表现形式为二维数组。横纵轴分别为每个节点,单元格中的值为从节点 A 至节点 B 的权重。
# 有向无环图 (DAG) 的遍历
无环图的特点在于不需要判断当前节点是否被访问过,可以节省一个 visited 的数组空间。
这种题型的遍历方式与多叉树的遍历十分相似,都是递归其所有子节点。
这类题型基本上都是需要达到目标点时的路径,因此需要维护一个 path 数组来记录每一次经过的节点。
与回溯法类似,每消费完当前节点后,一定要记得将当前节点从选择路径中去除,来撤销当前选择,便于下一个递归函数继承 path。
这类题型典型代表是 [797] 所有可能的路径
。
// 结果数组
const res: number[][] = [];
// 记录已遍历过的路径
const path: number[] = [];
traverse(root);
return res;
function traverse(curr: Node) {
// 添加当前节点到路径中
path.push(curr);
// 遍历到达终点
if (curr 到达目标点) {
// 拷贝一份当前路径,将其添加到结果数组中
res.push([...path]);
// 从路径中移除当前节点,相当于回溯法中的撤销选择
path.pop();
return;
}
// 递归所有相邻节点
for (const adjacent of graph[curr]) {
traverse(adjacent);
}
// 从路径中移除当前节点,相当于回溯法中的撤销选择
path.pop();
}
题型参考:
[797] 所有可能的路径
# 图的成环检测
那么,如果不确定图是否是一个 DAG,那么该如何进行检测呢?
我们只需要再多维护一个 visited 数组,记录下每个已遍历过的节点。
const visited: boolean[] = [];
const onPath: boolean[] = [];
// 此处是图的邻接表
const graph: Set<number>[];
let hasCycle = false;
// 从每个节点为起点,开始进行遍历,保证每一个节点都被遍历到
for (let i = 0; i < numCourses; i++) {
traverse(i);
}
return !hasCycle;
function traverse(index: number) {
// 当前路径中的节点被再一次访问到了,说明出现了环
if (onPath[index]) {
hasCycle = true;
// 已经发现环,则直接退出
return;
}
// 若已经遍历过该节点,则剪枝
if (visited[index]) {
return;
}
// 做选择
visited[index] = true;
onPath[index] = true;
// 递归遍历子节点
for (const childIndex of graph[index]) {
traverse(childIndex);
}
// 撤销选择
onPath[index] = false;
}
题型参考:
[207] 课程表
# 拓扑排序
拓扑排序。
注意,有环图是无法进行拓扑排序的。因此若不确定图无环,在进行拓扑排序前,需要先进行一次环检测算法。
拓扑排序其实就是图的后序遍历结果。因此与图的遍历方式密切相关。
const res = [];
traverse(root);
return res;
function traverse(curr: Node) {
// 添加当前节点到路径中
path.push(curr);
// 遍历到达终点
if (curr 到达目标点) {
// 拷贝一份当前路径,将其添加到结果数组中
res.push([...path]);
// 从路径中移除当前节点,相当于回溯法中的撤销选择
path.pop();
return;
}
// 递归所有相邻节点
for (const adjacent of graph[curr]) {
traverse(adjacent);
}
// 后序遍历位置,记录当前值并塞入拓扑排序序列
res.push(curr.val)
// 从路径中移除当前节点,相当于回溯法中的撤销选择
path.pop();
}
题型参考:
[210] 课程表 ii
# 有权图的最短路径
有权图又是一种新的图类型。如果图的路径是有加权的,即相邻节点之间的距离并不一定为 1,那么通过普通 BFS 的方式第一次遍历到的某节点并不一定就是最短的节点。因此,如果要求出这种情况下的最短路径,我们需要对普通图的 BFS 算法做出些改进。
首先需要改进的点是图的数据结构。由于路径的权重也是一个变量,因此我们对于一个邻接节点的表示方式应该为:
{
to: number,
weight: number
}
同理,我们每一次推队列的节点也应该是类似数据结构,为当前的节点 id,以及该节点通过该条路径算出的与起点的距离。
{
curNodeId: number,
cur2StartDistance: number
}
于此同时,我们可以建立一个最短路径数组 minDists,表示从 start 位置到各个点的最短距离。类似于动态规划的 dp 数组,能够帮我们存储每一个节点距离起点的最小距离,同时提前进行剪枝判断。
在遍历出队节点的每个子节点时,我们会将会比较当前这条加权路径与起点的距离,与在 minDists 中已知的子节点最短距离相比,只有当发现更短加权路径时,才进行最短路径更新,并将子节点以及新的最短距离塞入队列便于进行下一次遍历。否则说明该路径不是最优的,剪枝丢弃。
经过这样一番改造,实际上我们就实现了一个迪杰斯特拉算法的基础结构。它能获取到由某一起点到图中每个节点的最短加权距离。
const graph: { to: number; weight: number }[][] = Array(n + 1)
.fill(0)
.map(x => Array());
const distances = dijkstra(start);
function dijkstra(start: number): number[] {
const minDists: number[] = Array(n + 1).fill(Number.MAX_VALUE);
// 去除无意义的节点距离
minDists[0] = -1;
// 起点距离自己的最短距离是 0
minDists[start] = 0;
const queue = [{ curId: start, curToStartDist: 0 }];
while (queue.length > 0) {
const { curId, curToStartDist } = queue.shift();
// 与当前路径到该节点相比,已经有更短的路径了,剪枝
if (curToStartDist > minDists[curId]) {
continue;
}
// 遍历所有邻接节点,以期望获取更小距离
for (const { to, weight } of graph[curId]) {
// 判断从当前节点到相邻节点的这条加权路径是否更短
const newDistance = minDists[curId] + weight;
if (newDistance < minDists[to]) {
// 如果更短,更新相邻节点的最短路径
// 由于该路径是可行的,将该路径信息加入到队列中等待下次递归
minDists[to] = newDistance;
queue.push({
curId: to,
curToStartDist: newDistance
});
}
}
}
return minDists;
}
题型参考:
[743] 网络延迟时间
← 动态规划 - 子序列问题 单调栈算法 →