Lec9 Graph Algorithms(3)

图论的世界,探索!!!😎解决了图的遍历之后,接下来该你了:最短路径问题!🤔

前情提要:路径成本与路径长度

  1. 路径成本 (Path Cost)
    • 定义:路径成本是路径上所有边的成本之和。对于每条边,可能有不同的权重或成本,路径成本是这些边的成本总和。
    • 应用:路径成本通常用于加权图中,特别是在寻找最短路径或最低成本路径时。
  2. 路径长度 (Path Length)
    • 定义:路径长度是路径中边的数量,即从起始节点到目标节点经过的边的数量。
    • 应用:路径长度是一个无权重的度量,通常用于不考虑边的权重或成本的情境中,或者当我们只关心路径的边数而非成本时。

看图会更加直观的理解这两个概念:


image-20241120160949656

最短路径问题概述

问题定义:

  • 给定一个图 $ G = (V, E) $ 和一个“源”顶点 $ s V $,找到从源顶点 $ s $ 到每个顶点 $ V $ 的最小成本路径。
  • 单源最短路径问题:即给定一个源点,求出源点到所有其他顶点的最短路径。

问题的多种变体:

  • 无权图 vs. 加权图:在无权图中,所有的边具有相同的权重,而在加权图中,每条边都有不同的权重。
  • 有环图 vs. 无环图:有些最短路径算法要求图是无环的,而有些算法则适用于有环图。
  • 只有正权重 vs. 有正有负权重:有些算法仅适用于图中所有边的权重都是正数,而有些算法则可以处理边权为负的图。

为什么研究最短路径问题?

  1. 旅行预算:如果你想知道从广州到城市X的最便宜航班安排,最短路径算法可以帮助找出费用最少的路径。
  2. 优化互联网数据包路由
    • 顶点表示路由器,边表示具有不同延迟的网络连接。最短路径算法可以帮助确定延迟最小的路由路径。
  3. 运输问题:如果你需要找到最优的路线来减少交通延误,最短路径算法可以帮助你选择最少延迟的高速公路和道路。
  4. 其他应用:最短路径问题广泛应用于各种优化问题中,如物流配送、导航系统、社交网络分析等。

无权最短路径问题

问题定义:

  • 问题: 给定一个源顶点 $ s $ 和一个无权有向/无向图 $ G = (V, E) $,找出从源顶点 $ s $ 到图中所有顶点的最短路径。
  • 无权图: 图中的所有边没有权重,每条边的“成本”视为相等。最短路径是指通过最少的边数到达目标顶点的路径。

基本思想:

  • 从源顶点 $ s $ 开始,逐步找到可以通过 0、1、2、3… $ N-1 $ 条边到达的顶点(即使是有环图也适用)。
  • 使用广度优先搜索(BFS)算法来解决这个问题,因为BFS能有效地找到最短路径。
  • 初始状态:
    • 设置源顶点 $ s $ 的距离为 0,即 $ [s] := 0 $。
    • 将源顶点 $ s $ 入队,标记它。

算法步骤:

  1. 初始化:
    • 设置源顶点 $ s $ 的距离为 0。
    • 将源顶点 $ s $ 入队 $ Q $。
    • 标记源顶点 $ s $。
  2. 遍历过程:
    • 当队列 $ Q $ 不为空时:
      • 从队列中出队一个顶点 $ X $。
      • 遍历 $ X $ 的所有相邻顶点 $ Y $:
        • 如果 $ Y $ 尚未被标记:
          • 更新 $ Y $ 的距离 $ [Y] = [X] + 1 $,即 $ Y $ 通过 $ X $ 到达。
          • 如果需要记录路径,可以设置 $ [Y] := X $。
          • 将 $ Y $ 入队,标记 $ Y $。
  3. 标记:
    • 一旦一个顶点被标记,它就不会再被入队一次,这避免了重复的计算。

算法伪代码:

1
2
3
4
5
6
7
8
9
10
11
12
Distance[s] := 0
Enqueue(Q, s)
Mark(s)

while queue is not empty do
X := Dequeue(Q)
for each vertex Y adjacent to X do
if Y is unmarked then
Distance[Y] := Distance[X] + 1
Previous[Y] := X // 如果需要记录路径
Enqueue(Q, Y)
Mark(Y)
  • BFS 是解决无权图最短路径问题的有效算法,因为它通过层次遍历逐渐扩展到更远的顶点,保证了找到最短路径。
  • 算法的时间复杂度是 $ O(|V| + |E|) $,适用于无权图,并且能够处理图中的环。

例子(往往可以帮助做题)

注意队列里面的元素,看着元素的先后顺序就不会乱了。

image-20241120161802067
image-20241120161810536
image-20241120161934589
image-20241120162039955
image-20241120162058509
image-20241120162110538

加权最短路径

问题定义:

  • 问题: 当图中的边有权重时,如何找到从源顶点 $ s $ 到所有其他顶点的最短路径?
  • 区别: 如果边有权重,广度优先搜索(BFS)将无法再找到最短路径。因为最小代价路径可能包含比最短路径更多的边数。

Dijkstra 算法

Dijkstra 算法是解决带权图中单源最短路径问题的经典算法,但它假设所有的边权重都是非负的。

  • 适用情况: Dijkstra 算法适用于带权图,且图中没有负权边。
  • 算法性质: Dijkstra 算法是一种贪心算法,它通过不断选择当前最小代价的节点来扩展最短路径。

Dijkstra 算法的基本思想:

  1. 选择最小代价的未标记节点:
    • 每次选择一个代价最小的未标记节点。
  2. 标记节点并更新其邻居的代价:
    • 对于当前节点的每个相邻节点,检查通过当前节点的路径是否能降低该邻居的代价。如果是,则更新该邻居的代价。
  3. 重复上述过程,直到所有节点都被标记。

Dijkstra 算法的步骤:

  1. 初始化:
    • 将源节点 $ s $ 的代价初始化为 0,其他节点的代价初始化为无穷大(表示不可达)。
    • 初始化一个空集 $ S $,用来存储已经标记的节点,即已找到最短路径的节点。
  2. 执行过程:
    • 当 $ S $ 中的节点数量不等于图中的所有节点时:
      • 从未标记的节点中选择代价最小的节点 $ A $,并将 $ A $ 加入集合 $ S $。
      • 对于节点 $ A $ 的每个相邻节点 $ B $,如果通过 $ A $ 到达 $ B $ 的代价比当前已知的代价小,更新 $ B $ 的代价: $ (B) = (A) + (A, B) $
        • 同时记录路径,设置 $ (B) = A $,以便我们能回溯路径。
  3. 停止条件:
    • 当所有节点都被标记(即所有节点都有了最短路径)时,算法结束。

伪代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
初始化:
cost(s) = 0
对于所有其他节点 v, cost(v) = ∞
设置集合 S = ∅(S为已处理节点集合)

while S 不包含所有节点 do
选择一个最小代价的未标记节点 A
将 A 加入 S

对于每个邻接节点 B:
if cost(A) + cost(A, B) < cost(B) then
cost(B) = cost(A) + cost(A, B)
previous(B) = A // 记录路径

例子

图论的题例子是必须的。

image-20241120162524314
image-20241120162556102
image-20241120162632190
image-20241120162729796
image-20241120162804539
image-20241120162829101
image-20241120162859797
image-20241120162908299

Dijkstra 算法实现

Dijkstra 算法伪代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
void Dijkstra(Graph* G, int* D, int s) {
int i, v, w;

// 初始化所有节点的最短路径为无穷大
for (i = 0; i < G->n(); i++) {
D[i] = INFINITY;
}

D[s] = 0; // 源节点的最短路径为 0

for (i = 0; i < G->n(); i++) {
// 找到未访问的最小距离节点
v = minVertex(G, D);

if (D[v] == INFINITY) return; // 如果最小距离是无穷大,说明剩余节点不可达

G->setMark(v, VISITED); // 标记节点为已访问

// 更新邻居节点的距离
for (w = G->first(v); w < G->n(); w = G->next(v, w)) {
if (G->getMark(w) == UNVISITED) {
if (D[w] > (D[v] + G->weight(v, w))) {
D[w] = D[v] + G->weight(v, w); // 更新最短路径
}
}
}
}
}

辅助函数:minVertex

minVertex 函数用于从未访问的节点中选择距离源节点最小的节点。

方法 1: 扫描所有节点找到最小距离的节点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
int minVertex(Graph* G, int* D) {
int i, v = -1;

// 初始化 v 为某个未访问的节点
for (i = 0; i < G->n(); i++) {
if (G->getMark(i) == UNVISITED) {
v = i;
break;
}
}

// 找到最小的 D 值
for (i++; i < G->n(); i++) {
if ((G->getMark(i) == UNVISITED) && (D[i] < D[v])) {
v = i;
}
}
return v;
}

复杂度分析:

  1. 方法 1:
    • minVertex 执行 $ |V| $ 次,每次扫描所有 $ |V| $ 个节点,时间复杂度为 $ (|V|^2) $。
    • 每次处理边的数量为 $ (|E|) $,每次访问一条边时可能会更新数组 $ D $ 的值,时间复杂度为 $ (|E|) $。
    • 总体时间复杂度:$ (|V|^2 + |E|) = (|V|^2) $。
  2. 方法 2:
    • 使用优先队列(最小堆)存储未处理的节点,按距离值排序。每次从堆中找出最小距离的节点,时间复杂度为 $ (|V|) $。
    • 每次更新节点的距离时,堆会重新排序,即通过删除并重新插入来更新距离值,时间复杂度为 $ (|V|) $。
    • 总体时间复杂度:$ (|E| |V|) $(处理所有边的总次数)加 $ (|V| |V|) $(每个节点的更新)。

Dijkstra 算法实现:使用优先队列

类定义:DijkElem

DijkElem 是一个用于存储节点信息的类,其中包括节点的编号(vertex)和当前节点到源节点的距离(distance)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class DijkElem {
public:
int vertex, distance;

DijkElem() {
vertex = -1;
distance = -1;
}

DijkElem(int v, int d) {
vertex = v;
distance = d;
}
};

Dijkstra 算法实现:

Dijkstra 算法使用优先队列(最小堆)来选择距离最小的未访问节点,并根据当前最短路径更新邻接节点的距离。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
void Dijkstra(Graph* G, int* D, int s) {
int i, v, w; // v 为当前节点
DijkElem temp;
DijkElem E[G->e()]; // 堆数组

// 初始化所有节点的最短距离为无穷大
for (int i = 0; i < G->n(); i++) {
D[i] = INFINITY;
}
D[s] = 0;

// 初始化堆数组
temp.distance = 0;
temp.vertex = s;
E[0] = temp;
heap<DijkElem, DDComp> H(E, 1, G->e()); // 创建堆

// 获取未访问的最小距离节点
for (i = 0; i < G->n(); i++) {
do {
if (H.size() == 0) return; // 没有节点可删除
temp = H.removefirst(); // 删除最小节点
v = temp.vertex;
} while (G->getMark(v) == VISITED);

G->setMark(v, VISITED); // 标记该节点为已访问

if (D[v] == INFINITY) return; // 如果节点不可达

// 更新邻接节点的距离
for (w = G->first(v); w < G->n(); w = G->next(v, w)) {
if (G->getMark(w) == UNVISITED) {
if (D[w] > (D[v] + G->weight(v, w))) {
D[w] = D[v] + G->weight(v, w); // 更新最短距离
temp.distance = D[w];
temp.vertex = w;
// 将更新后的节点插入堆
H.insert(temp);
}
}
}
}
}

Dijkstra 算法的贪心性质:

Dijkstra 算法是一个贪心算法,它的基本思想是每次选择当前最优解(最小的距离节点),并忽略未来的可能性。具体来说,Dijkstra 算法会选择当前最小的未访问节点,但这并不意味着这种选择能保证得到全局最优解。

  • 贪心策略: 在每一步中,选择当前距离最小的节点。
  • 局部最优: 当前的最短路径看起来是最优的,但如果考虑后续路径,可能会有更短的路径。
  • 不一定全局最优: 贪心选择并不总是最优的,尤其是当存在通过其他节点的更短路径时。

优缺点:

  • 优点: Dijkstra 算法能够有效地解决无负权图的单源最短路径问题,尤其在图的规模较大时,使用优先队列能够显著提高效率。
  • 缺点: 该算法的贪心策略仅考虑局部最优解,可能导致全局最优解不可达。如果图中包含负权边,则不能应用 Dijkstra 算法。

求有向图中任意两点间的最短路径长度

问题描述:

给定一个带权有向图 $ G = (V, E) $,我们要求所有节点对 $ u, v V $ 之间的最短路径长度。

解法一:使用单源最短路径算法多次运行

一种直观的做法是:对于每一对节点 $ u, v $,运行一次单源最短路径算法(如 Dijkstra 算法)。具体步骤如下:

  1. 对于图中的每个节点 $ u $,执行一次单源最短路径算法(例如 Dijkstra)来计算从节点 $ u $ 到所有其他节点的最短路径。
  2. 重复以上步骤,共运行 $ |V| $ 次(即对于每个节点都运行一次最短路径算法)。

这种方法的时间复杂度为 $ O(|V| (|E| + |V||V|)) $,其中:

  • $ |V| $ 是图中的节点数。
  • $ |E| $ 是图中的边数。
  • $ |V||V| $ 是使用优先队列时单次 Dijkstra 算法的时间复杂度。

适用场景:

  • 稀疏图: 在稀疏图中,图的边数 $ |E| $ 相对较小,Dijkstra 算法利用优先队列能够高效地求解最短路径。因此,运行 $ |V| $ 次 Dijkstra 算法能在实践中得到较好的性能。

解法二:使用全源最短路径算法

对于图中所有节点对的最短路径问题,另一种高效的算法是 Floyd-Warshall 算法,它是一个动态规划算法,能够在一个阶段内计算出所有节点对的最短路径。具体步骤如下:

  1. 初始化一个二维数组 $ dist[i][j] $,其中 $ dist[i][j] $ 表示从节点 $ i $ 到节点 $ j $ 的最短路径距离。
  2. 设置初始条件:对于每一条边 $ (i, j) E $, $ dist[i][j] = w(i, j) $,其中 $ w(i, j) $ 是边的权重;对于没有边的节点对,$ dist[i][j] $ 初始化为无穷大(除非 $ i = j $,此时 $ dist[i][i] = 0 $)。
  3. 通过动态规划的方式更新距离:对于每个节点 $ k $,尝试通过节点 $ k $ 来更新所有其他节点对的最短路径。 $ dist[i][j] = (dist[i][j], dist[i][k] + dist[k][j]) $
  4. 最终得到的 $ dist[i][j] $ 就是从节点 $ i $ 到节点 $ j $ 的最短路径长度。

代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Floyd-Warshall 算法:计算所有节点对的最短路径
void floydWarshall(vector<vector<int>>& dist, int V) {
// 通过每个节点作为中介节点更新路径
for (int k = 0; k < V; k++) {
for (int i = 0; i < V; i++) {
for (int j = 0; j < V; j++) {
if (dist[i][k] != INF && dist[k][j] != INF) {
// 更新 i 到 j 的最短路径
dist[i][j] = min(dist[i][j], dist[i][k] + dist[k][j]);
}
}
}
}
}

适用场景:

  • 密集图: 对于密集图,使用 Floyd-Warshall 算法通常在实践中更高效。虽然它的时间复杂度是 $ O(|V|^3) $,但在图的边数接近 $ |V|^2 $ 时,该算法的实现通常会比运行多次 Dijkstra 更快。

时间复杂度对比:

  • Dijkstra 多次运行:
    • 每次运行 Dijkstra 算法的时间复杂度为 $ O(|E| + |V||V|) $。
    • 总的时间复杂度为 $ O(|V| (|E| + |V||V|)) $。
  • Floyd-Warshall 算法:
    • 时间复杂度为 $ O(|V|^3) $。