以 leetcode 第 743 题网络延迟时间为例子
有 n 个网络节点,标记为 1 到 n。
给你一个列表 times,表示信号经过 有向 边的传递时间。 times[i] = (ui, vi, wi),其中 ui 是源节点,vi 是目标节点, wi 是一个信号从源节点传递到目标节点的时间。
现在,从某个节点 K 发出一个信号。需要多久才能使所有节点都收到信号?如果不能使所有节点收到信号,返回 -1 。
Input: times = [[2,1,1],[2,3,1],[3,4,1]], n = 4, k = 2
Output: 2
使用 Dijkstra’s 算法来解决网络延迟问题有以下几个原因:
适用的图类型
Dijkstra’s 算法适用于加权有向图,且所有边的权重为非负数。网络延迟时间问题的图正是这种类型:每条边的权重(时间)都是非负的。
目标
该问题的目标是找到从一个起点节点 k 到所有其他节点的最短路径,并确定信号到达所有节点所需的最长时间。Dijkstra’s 算法正是用来解决单源最短路径问题的。
算法效率
Dijkstra’s 算法结合优先队列(如最小堆)可以在 O((E + V) \log V) 的时间复杂度内完成,其中 E 是边数, V 是节点数。这个效率在大多数现实问题中都非常可行。
实现简单
Dijkstra’s 算法相对简单且直接,可以通过使用优先队列(如最小堆)实现,代码易于理解和维护。
步骤
1. 初始化:从起点开始,初始化所有节点到起点的距离为无穷大,起点到自己的距离为 0。
2. 优先队列:使用一个最小堆来按当前已知最短路径的顺序处理节点。
3. 更新距离:从堆中取出距离最小的节点,更新其邻接节点的最短路径。
4. 重复:重复上述步骤,直到所有节点都被处理完。
我使用的是 邻接表 来表示图。 这是因为邻接表在处理稀疏图(即边数远少于顶点数平方的图)时更加高效。
邻接表 vs. 邻接矩阵
邻接表:
• 邻接表使用一个列表(或字典)来存储每个节点的邻接节点及其权重。
• 更节省空间,对于稀疏图非常高效。
• 插入和删除边操作通常更快。
邻接矩阵:
• 邻接矩阵使用一个二维数组来存储节点之间的连接关系,矩阵中的元素表示节点之间的边的权重。
• 对于密集图(边数接近顶点数平方)效率更高。
• 但是对于稀疏图会浪费大量空间。
为什么选择邻接表
在这个问题中,我们可能面对一个稀疏图,即边数远小于节点数的平方。邻接表在这种情况下更加高效且节省空间。此外,邻接表在遍历节点的邻接节点时也很方便,可以直接获取所有与当前节点相连的节点及其权重。
邻接表的实现
在下面的代码中,我使用一个 Map 来实现邻接表,Map 的键是节点,值是一个数组,数组中的元素是元组,表示与该节点相邻的节点及其权重。
// 使用Map构建图的邻接表表示
const graph = new Map();
for (let [u, v, w] of times) {
if (!graph.has(u)) graph.set(u, []);
graph.get(u).push([v, w]);
}
使用 MinHeap(最小堆)在 Dijkstra 算法中具有重要意义,因为它使得每次总是优先处理当前距离最小的节点,从而确保以最优的方式更新其他节点的距离。这极大地提升了算法的效率。具体原因如下:
- 优先处理最近节点:
• Dijkstra 算法的核心思想是每次扩展最短路径,也就是总是从当前未处理的节点中选择距离起点最近的节点进行处理。
• MinHeap 使得我们可以高效地获取当前距离最小的节点,并将其从堆中移除,从而保证每次处理的都是最优节点。
- 降低时间复杂度:
• 使用 MinHeap,可以将获取最小距离节点的操作从 O(n) 降低到 O(\log n),其中 n 是节点数。
• 结合 MinHeap,Dijkstra 算法的整体时间复杂度为 O((E + V) \log V),其中 E 是边数,V 是节点数。如果不使用堆,每次获取最小距离节点的操作需要遍历所有节点,时间复杂度为 O(V^2)。
- 动态更新节点的距离:
• 在处理某节点并更新其邻接节点的距离时,如果发现有更短的路径,可以将这些更新后的节点重新加入 MinHeap 或调整其位置,从而始终保证堆顶元素是当前距离最小的节点。
具体代码实现中的 MinHeap 类如下:
class MinHeap {
constructor() {
this.heap = [];
}
push(val) {
this.heap.push(val);
this.bubbleUp();
}
pop() {
if (this.size() === 1) return this.heap.pop();
const top = this.heap[0];
this.heap[0] = this.heap.pop();
this.bubbleDown();
return top;
}
size() {
return this.heap.length;
}
bubbleUp() {
let index = this.heap.length - 1;
while (index > 0) {
let element = this.heap[index];
let parentIndex = Math.floor((index - 1) / 2);
let parent = this.heap[parentIndex];
if (parent[0] <= element[0]) break;
this.heap[index] = parent;
this.heap[parentIndex] = element;
index = parentIndex;
}
}
bubbleDown() {
let index = 0;
const length = this.heap.length;
const element = this.heap[0];
while (true) {
let leftChildIndex = 2 * index + 1;
let rightChildIndex = 2 * index + 2;
let leftChild, rightChild;
let swap = null;
if (leftChildIndex < length) {
leftChild = this.heap[leftChildIndex];
if (leftChild[0] < element[0]) {
swap = leftChildIndex;
}
}
if (rightChildIndex < length) {
rightChild = this.heap[rightChildIndex];
if (
(swap === null && rightChild[0] < element[0]) ||
(swap !== null && rightChild[0] < leftChild[0])
) {
swap = rightChildIndex;
}
}
if (swap === null) break;
this.heap[index] = this.heap[swap];
this.heap[swap] = element;
index = swap;
}
}
}
bubbleUp 和 bubbleDown 是维护堆(heap)性质的两个关键操作,分别用于在插入新元素和删除堆顶元素后调整堆的结构。通过这两个操作,最小堆(或最大堆)的性质得以保持,即堆中每个节点的值总是小于或等于其子节点的值(对于最小堆),或者大于或等于其子节点的值(对于最大堆)。
bubbleUp
bubbleUp 操作用于在插入新元素后调整堆的结构。其作用是将新插入的元素向上移动,以保持堆的性质。
步骤:
-
新元素被添加到堆的末尾。
-
将新元素与其父节点比较,如果新元素小于其父节点(对于最小堆),则交换它们的位置。
-
重复步骤 2,直到新元素不再小于其父节点或达到堆的顶部。
bubbleDown
bubbleDown 操作用于在删除堆顶元素(通常是最小元素)后调整堆的结构。其作用是将堆顶元素(被放置在堆的末尾)向下移动,以保持堆的性质。
步骤:
-
堆顶元素被移除,堆的最后一个元素被移到堆顶。
-
将新的堆顶元素与其子节点比较,如果它大于任何一个子节点(对于最小堆),则与较小的子节点交换位置。
-
重复步骤 2,直到新的堆顶元素不再大于其子节点或达到堆的底部。