%% # 纲要 > 主干纲要、Hint/线索/路标 # Q&A #### 已明确 #### 待明确 > 当下仍存有的疑惑 **❓<font color="#c0504d"> 有什么问题?</font>** # Buffer ## 闪念 > sudden idea ## 候选资料 > Read it later # 图的表示实现细节说明 ❓ <font color="#c00000">如何建图?</font> - 给定**边集或邻接矩阵**,根据边建图(**邻接表保存图**) - 无向图 => **建两条有向边**; ❓ <font color="#c00000"> DFS/BFS 搜索,什么时候需要 visited 数组?什么时候不需要?</font> 1. 在图上时: 1. **一般图**:需要; 2. **DAG (有向无环图)**: 不需要; - 从任意节点出发进行 DFS/BFS 搜索,"**同一路径**" 上一定不会回到 "**当前已遍历过**" 的节点/边,因此当前路径上的节点/边都不会被重复访问。 2. 在树上: - **树上 DFS/BFS 搜索不需要**,只需用一个变量**记录前一个父节点 parent**即可,跳过父节点,就**不会重复访问同一个节点或边**。 - 因为树是无环无向图,只要**保证不回头**,则**任意两节点间的边只会经过一次**。 - ![[Excalidraw-Solution/树.excalidraw.svg|525]] 3. 在网格图上: - **需要**,但可以采用**其他方式**实现,例如在原网格值 `grid[i][j]` 上直接进行标记。 ❓ <font color="#c00000">如何表示 "已访问过的位置" ?</font> - 对于一般图: - **标记访问过的顶点**——用 `vector<int> visited`; - **标记访问过的边**——用 `set<pair<int,int>>`; - 不能使用 `unordered_set<pair<int,int>>`,因为**没有为 `pair` 类型定义哈希函数**。 - 对于网格图: 1. 开一个**与原矩阵相同尺寸的 `vector<vector<bool>> visited(m, vector<bool>(n))` 矩阵**进行标识。 2. 使用**有序集合** **`set<pair<int, int>>` 存储坐标点**进行标识 - 不能使用 `unordered_set<pair<int,int>>`,因为**没有为 `pair` 类型定义哈希函数**。 3. 矩阵元素值非负时,**==取负==进行标识**,**需要用元素值时,取绝对值**即可: - `grid[i][j] < 0` 表示已访问过; - `abs(grid[i][j])` 表示原始值; 4. 矩阵元素值范围为 `[min, max]` 时,令**每个值 `gird[i][j] += (max-min+1)`**: - `grid[i][j] > max` 表示已访问过; - `(grid[i][j] > max) ? grid[i][j] - (max-min+1) : grid[i][j]` 表示原始值。 > [!NOTE] ❓ <font color="#c00000">如何标记 "已访问过的位置" ?</font> > > - 方法一:使用一个**额外的 `m*n` 的`visited` 数组来标记已访问**。 > - 方法二:直接**修改原矩阵中的值标记 "已访问**",最后**再额外扫描一次恢复原矩阵的值**,则空间开销降为 $O(1)$; ## 网格图方向的表示 上下左右四个方向: ```cpp constepxr int dirs[4][2] {{-1, 0}, {1, 0}, {0, -1}, {0, 1}}; for (const auto& [dx, dy] : dirs) { int nx = x + dx, ny = y + dy; } ``` 相邻一圈 8 个方向: ```cpp // 方式一: constexpr int dir_x[8] {0, 0, 1, 1, 1, -1, -1, -1}; constexpr int dir_y[8] {-1, 1, -1, 0, 1, -1, 0, 1}; for (int i = 0; i < 8; ++i) { int nx = x + dir_x[i], ny = y + dir_y[i]; ... } // 方式二: constexpr int dir_x[3] {0, -1, 1}; constexpr int dir_y[3] {0, -1, 1}; for (int dx : dir_x) { for (int dy : dir_y) { // 会有一个偏移量(0,0). int nx = x + dx, ny = y + dy; if (dx == 0 && dy == 0) continue; ... } } ``` %% # 图相关算法总览 - 图的遍历算法 - ![[00-数据结构与算法笔记/数据结构/图相关算法#^ub7lm8]] - ![[00-数据结构与算法笔记/数据结构/图相关算法#^1j9yqm]] - Tarjan 算法: - 强连通分量问题(查找有向图中的强连通分量,或强连通分量分解) - 双连通分量问题(求出无向图的"割点"与"桥",双连通分量分解, 求出**无向图中的所有双连通分量**) - 最近公共祖先问题(LCA) ![[00-数据结构与算法笔记/数据结构/图相关算法#^ut8xuj]] - 最小生成树问题 - Kruskal 算法 - Prim 算法 - 拓扑排序 <br><br> # 图搜索 ![image-20231127220809933|694](_attachment/00-数据结构与算法笔记/数据结构/图相关算法.assets/IMG-图相关算法-262BC59596E84CD32F169B1BC9173A5D.png) > [!NOTE] 标注 🏳️‍🌈 的代表 DFS/BFS 两种方法均可 - DFS 应用 ^ub7lm8 - **回溯**:暴力搜索,枚举所有可能情况 - 搜索/枚举**两顶点间所有路径** - **有向图的环路检测**(三色标识法) - **搜索连通分量** 🏳️‍🌈 - 找出图中的所有连通分量 (统计数量) - 搜索**一个极大连通分量**,包括 - 得到连通分量的**支撑树**(为顶点染色标识) - 获取连通分量中的**顶点集**、**顶点总数**; - 获取连通分量中的**边集**、**边总数** - **判断两顶点间连通性**(判断是否可达) - **求两顶点间路径数量**(DFS 分治 & 记忆化优化) - BFS 应用 ^1j9yqm - **顶点 u 出发==可行走的最大步数==** (顶点 u 的最大邻居阶数) 🏳️‍🌈 - **顶点 u 出发行走==指定步数==可抵达的位置** (顶点 u 指定阶数的邻居)🏳️‍🌈 - **无权图的单源最短路径** - 顶点 u 到**单个顶点 v** 的最短距离 - 顶点 u 到**最近的某一类顶点 vs** 的最短距离(多源 BFS,**以 vs 类顶点为起点**,反过来求) - "**某一类顶点 us**" 到 **==离各自最近==的 "另一类顶点 vs"** 的最短距离(多源 BFS,同上) - **拓扑排序** > [!NOTE] > > 无向图中进行 DFS/BFS 搜索时,通常 **==不能重复访问节点==**,仅在需要 "**==松弛操作==**" 时(从不同路径**到达节点时方案更优**,或者**到达节点时状态不同**)**才允许重复访问**。 > > - [[99-题解/leetcode/lc-505 迷宫 II|lc-505 迷宫 II]] > - [[99-题解/leetcode/lc-1129 颜色交替的最短路径|lc-1129 颜色交替的最短路径]] > - [[99-题解/leetcode/lc-1293 网格中的最短路径|lc-1293 网格中的最短路径]] > ## 连通分量 > [!NOTE] 有向图中没有 "**连通分量**" 的概念。 > > 找 "**==极大有根树==**" 时需要**从 "==入度为 0==" 的顶点**进行 DFS/BFS,并且**允许访问重复节点**(一个子图可能**被多个有根树共享**)。 ## 连通图的生成树 > **生成树**也即**支撑树**,即覆盖连通图中 "**所有顶点**" 的树。 - 对于 "**连通图**",从**任一顶点**出发进行 DFS/BFS(不访问重复顶点),可得到图中的 "**==支撑树==**"。 - 对于 "**非连通图**",分别以所有顶点为起点进行 DFS/BFS(不访问重复顶点),可得到 "**==支撑森林==**" - 即为 "图中**每个独立连通分量**分别得到各自的支撑树"。 ![[Excalidraw-Solution/图相关算法_2.excalidraw|775]] ## 环路检测 > 判断**是否存在环**,求出**环路上的边 or 顶点**。 - **无向图环路检测**:三种方法 - (1)**==DFS & 跳过父节点==** - DFS 过程中,若每个节点的邻居在 **==排除父节点==** 后**还存在已访问过的顶点**,则说明有环 - (2)**==并查集==** - 遍历 **边集**,对于一条边 `u-v`,若两端顶点**此前已连通,说明有环**;若尚未连通,则连通两顶点,标记边加入图中 - (3)**==拓扑排序==** - 每次移除所有**度减为 1** 的顶点,若无环,则 n 个顶点将全被移除;若有环,则**环上顶点度大于 1** - **有向图环路检测**:两种方式 - (1)**==三色标记法 & DFS==**; - 0/1/2,分别表示 **"未访问过"**,**"在当前 DFS 路径上 or 已访问过从该顶点出发有环"**,**"已访问过但未在当前 DFS 路径上,且从该顶点出发无环"**; - (2)**==拓扑排序==** - 每次移除所有**入度减为 0** 的顶点,若无环,则 n 个顶点将全被移除;若有环,则**环上顶点入度大于 0**; 例题参见[[01-题单分类/图题单#环路检测|图题单]] <br><br> # 最短路径问题 > 参见[^1] > [!NOTE] 最短路径 > > - **单源**最短路径:求解图中**某个起始顶点**到**其它所有顶点**之间的最短路径 > - **多源**最短路径:求解出图中**每个顶点到其它所有顶点**之间的最短路径。 > - 最短路径问题 ^ut8xuj - **无权图**中的**单源**最短路径问题(边没有权值,或者所有边权值相等) - BFS 逐层遍历求解 - **带权图**中的**单源**最短路径问题 - **边的权值非负**:Dijkstra 算法 - **边的权值可为负**: - Bellman-ford 算法 - SPFA 算法 (Bellman-Ford 算法的改进版本) - **带权图**中的**多源**最短路径问题 - Floyd 算法 | 算法名称 | 适用问题 | 时间复杂度 | 空间复杂度 | 处理负权边 | 处理负权回路 | | ------------ | ------- | ----------------------- | ------ | ----- | ------ | | BFS | 无权图; 单源 | $O(V+E)$ | $O(V)$ | | | | Dijkstra | 带权图; 单源 | $O(E+V^2)=O(V^2)$ | $O(E)$ | ❌ | ❌ | | | | $O(E*logE)$ 小顶堆优化 | $O(E)$ | ❌ | ❌ | | Bellman-Ford | 带权图; 单源 | $O(V*E)$ | $O(V)$ | ✔ | ✔ | | SPFA | 带权图; 单源 | 趋近 $O(E)$,最坏 $O (V*E)$ | $O(V)$ | ✔ | ✔ | | Floyd | 带权图; 多源 | $O(V^3)$ | | ✔ | ❌ | > [!NOTE] 对无向图、有向图均可求解最短路径。 > [!info] 负权回路:环上 "边权总和"为负,故可无限循环 <br><br> ## BFS 求无权图的单源最短路径 以给定顶点为起点, **==BFS 逐层遍历==**,**可求得起点到各顶点的单源最短路径**。 BFS 时记录**层数**,每一层即代表一个步长,**首次访问**到一个顶点时: 1. BFS 的步数即为 **起点到该顶点的最短路径**。该顶点的 "前驱顶点" 2. 该顶点的 "**前驱顶点**",即为该最短路径上抵达该顶点的 "**前驱顶点**"。 ###### 一般图示例 ```cpp /* 利用BFS求解无权图中的单源最短路径*/ class BFS_ShortestPath { private: vector<vector<int>> graph; vector<int> dist; vector<vector<int>> prevs; public: void build_graph(int n, vector<vector<int>>& edges) { graph = vector<vector<int>>(n); dist = vector<int>(n, INT_MAX); // dist[i]记录起点到顶点i的最短路径 prevs = vector<vector<int>>(n); // prevs[i]记录顶点i的最短路径上的前驱顶点. } // 获取起点到指定终点`dest`的最短路径上的边.(最短路径可能不唯一, 故输出所有最短路径上存在的边.) // `prevs`是给定起点到各顶点的最短路径上的前驱顶点. vector<pair<int,int>> get_edges_in_shortest_path(int dest) { int n = graph.size(); vector<pair<int, int>> edges; vector<bool> visited(n, false); queue<int> que {{dest}}; visited[dest] = true; while (!que.empty()) { // 从dest起, 反向遍历prevs给出的有向图. int u = que.front(); que.pop(); for (const int v : prevs[u]) { if (visited[v]) continue; visited[v] = true; edges.emplace_back(v, u); que.emplace(v); } } return edges; } // 求起点start到其余各顶点的最短距离. vector<int> ShortestPathByBFS(vector<vector<int>>& graph, int start) { // graph是邻接表. int n = graph.size(); queue<int> que{{start}}; dist[start] = 0; int step = -1; while (!que.empty()) { ++step; int size = que.size(); while (size--) { int u = que.front(); que.pop(); for (const int v : graph[u]) { if (dist[v] != INT_MAX) continue; // 已访问过, 已求得最短路径 dist[v] = step + 1; // 下一步将访问v, 得到起点到v的最短距离. que.emplace(v); prevs[v].emplace_back(u); // 记录到v的最短路径上的前驱顶点 } } } return dist; } }; ``` ###### 网格图示例 示例参见 [[99-题解/leetcode/lc-1091 二进制矩阵中的最短路径|lc-1091 二进制矩阵中的最短路径]],求**起点到终点**的 **最短路径长度** ```cpp class Solution { public: static constexpr int dirs[8][2] { {-1, 0}, {1, 0}, {0, -1}, {0, 1}, {-1, -1}, {-1, 1}, {1, -1}, {1, 1} }; int shortestPathBinaryMatrix(vector<vector<int>>& grid) { int n = grid.size(); if (grid[0][0] == 1) return -1; // 特判: 起点不可达. if (n == 1 && grid[0][0] == 0) return 1; // 特判: 起点即终点的情况 vector<vector<bool>> visited(n, vector<bool>(n, false)); queue<pair<int, int>> que {{ {0, 0} }}; visited[0][0] = true; int path_len = 0; // 路径长度: 途径的单元格数量. while (!que.empty()) { ++path_len; int size = que.size(); while (size--) { auto [x, y] = que.front(); que.pop(); if (x == n - 1 && y == n - 1) { // 涵盖起点即终点的情况. return path_len; } for (const auto& [dx, dy] : dirs) { int nx = x + dx, ny = y + dy; if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue; if (grid[nx][ny] == 1) continue; if (visited[nx][ny]) continue; visited[nx][ny] = true; que.emplace(nx, ny); } } } return -1; } }; ``` <br><br> ## Dijsktra 算法 > 算法功能:**带权图的单源最短路径** > [!caution] Dijsktra 不能用于存在 "**==负权边==**" 的图。 > > Dijsktra 每次 "**==贪心==**" 地选择当前**距离源点最短的顶点**, > 如果存在**负权边**,贪心策略可能会 **==提前确定一个错误的最短路径==**。 > > 例如**在确定到某顶点的最短距离**后,若**后续路径若有负权边**,则将能够缩短该顶点到起点的最短距离。 > 然而此时**算法已经结束了对该顶点的计算**,**无法再利用负权边对已处理过的顶点进行更新**,从而导致错误。 > > > ![[Excalidraw-Solution/图相关算法.excalidraw|461]] > > ### 算法步骤 1. 开一个数组,`dist[v]` 记录**起点 s 到图中其余顶点`v`** 的**带权最短路径**,初始化 `dist[s] = 0`,其余 `dist[v] = INF`; 2. 从起点 s 出发,初始时得到**起点 s 到其直接邻居 v 的距离**。 3. 此后每一步,重复两个步骤,直至为 "**所有可达顶点**" 求得最短路径。 1. 从 "**所有剩余待确认顶点**" 中找出 "**==距离起点 s 最近的顶点`u`==**",则 **u 到起点 s 的最短距离**即为 "**==当前求得的距离== `dist[u]`**"; 2. **==松弛 u 的所有邻接点==**:以 `u` 作为中转点,求得 "**起点s -> ==途经u== -> u 的直接邻居顶点 v**" 的最小距离,**若该距离小于当前 `dist[v]`,则更新 `dist[v]`**。 ### 算法实现说明 两种实现版本: - (1)**朴素实现**: - 需要一个 `visited[v]` 数组标记 "**是否已为顶点 u 求得最短距离**"; - 每一步需要 **==遍历所有顶点==**,从中找出 "**当前剩余顶点中,==距离起点 s 最近的顶点 u==**"; - 复杂度分析: - 时间复杂度:$O(|V|^2)$;**每一步需要遍历所有顶点**,从而找出剩余顶点中距离起点最近的顶点。 - 空间复杂度:$O(|V|)$ - `dist[]` 数组记录各顶点距离起点的距离 - `visited[]` 数组记录 "是否已求得该顶点到起点的最短距离" - (2)**小顶堆优化实现** - 使用一个 "**==小顶堆==**" 维护 "**当前剩余顶点到起点 s 的距离**":`{顶点v到起点s的距离, 顶点v}`,每次可直接取**堆顶元素**。 - 若 "**堆顶元素**" 记录的距离大于 `dist[v]`,表明**堆顶元素过期**,持续弹出,直至获取**有效堆顶元素**。 - 复杂度分析: - 时间复杂度:$O(|E|\log |E|)$ 或 $O(|E|\log|V|)$ ,取决于 "优先队列" 的具体实现。 - 使用 C++ 中的 `priority_queue` 时,由于图上每条边都可能被 "**==松弛一次==**",故**每个顶点可能出入堆多次**,但**出入堆次数**以及**堆中元素总数**均不超过边数 $|E|$,故时间复杂度为 $O(|E|\log |E|)$ 。 - 空间复杂度:$O(|N|+|E|)$ - `dist[]` 数组记录距离; - `min_pq` 小顶堆,元素数量最多为 $|E|$ > [!NOTE] 关于 Dijsktra 算法优化的时间复杂度说明 > > ![[_attachment/00-数据结构与算法笔记/数据结构/图相关算法.assets/IMG-图相关算法-64DC6B5A58290AF63348383B731E927C.png|738]] > > 在 C++中: > > - 若使用 `priority_queue`,其实现是 "**二叉堆**",不直接支持 `decrease-key` (减小一个元素值)操作。 > 因此只能通过 "**==惰性删除==**" 来间接模拟 `decrease-key`,这一情况下堆中可能包含重复元素,最多 $O(|E|)$ 个,故时间复杂度为 $O(|E|\log |E|)$ > >> > > - 若使用 `set`,其实现是 "**红黑树**",获取最小元素的开销是 $O(1)$ (`set.begin()`),每次松弛时,**可先删除旧值**,**再插入新距离值**,保证堆中元素总数不超过 $|V||$,故时间开销为 $O(|E|\log|V|)$。 ### 小顶堆实现 下面给出 Dijsktra 算法获取 "**无向带权图**" 中 "**==指定起点的单源最短路径距离==**" 以及 "**==到指定终点的最短路径上所有边==**" 的实现。 > [!NOTE] **获取最短路径上所有边** > > 1. 首先,需应用 Dijsktra 算法获取指定起点**到各顶点的最短距离数组 `dist[v]`**; > 2. 从 "**==指定终点==**" 起始,依据 `dist[v] + w == dist[u]` 这一关系进行**反向 DFS 或 BFS**,为每个 "**==更远顶点==**" 找到**关联的 "==更近顶点=="**,即为其 "**==前驱顶点==**"。 > > > > [!NOTE] 另一种实现方式: 直接使用 `vector<vector<int>> prevs(n)` 数组在 Dijsktra 过程中**记录每个顶点到起点的==最短路径上的前驱顶点==**。 > > > > - 相当于用**最短路径边**构建了一个 **==各顶点->起点==**的 "**==反向有向图==**"。 > > - 最后,从**指定终点**起,**反向应用 DFS 或 BFS 遍历,即可得到给定起点->终点间最短路径上所有边**。 > > > > 例题: [[99-题解/leetcode/lc-3123 最短路径中的边|lc-3123 最短路径中的边]] ```cpp class Dijsktra { private: using PII = pair<int,int>; vector<vector<PII>> graph; public: void build_graph(int n, vector<vector<int>>& edges) { graph.clear(); graph.resize(n); for (const auto& e : edges) { graph[e[0]].emplace_back(e[1], e[2]); graph[e[1]].emplace_back(e[0], e[2]); } } // 获取指定起点的单源最短距离. vector<int> get_shortest_dist(int start) { int n = graph.size(); vector<int> dist(n, INT_MAX); priority_queue<PII, vector<PII>, greater<>> min_pq; // `{起点到顶点的距离, 顶点}` min_pq.emplace(0, start); dist[start] = 0; while (!min_pq.empty()) { auto [d, u] = min_pq.top(); min_pq.pop(); if (d > dist[u]) continue; for (const auto& [v, w] : graph[u]) { int new_d = d + w; if (new_d < dist[v]) { dist[v] = new_d; min_pq.emplace(new_d, v); } } } return dist; } // 获取所有最短路径上的边.(最短路径可能不唯一, 打印出所有最短路径构成的连通分量上的各条边) vector<pair<int,int>> get_edges_in_shortest_path(vector<int>& dist, int dest){ int n = dist.size(); // n个顶点 vector<bool> visited(n, false); vector<pair<int,int>> edges; auto dfs = [&](auto& self, int u) -> void { visited[u] = true; for (const auto [v, w] : graph[u]) { if (dist[v] + w == dist[u]) { edges.emplace_back(v, u); // 不会重复. 保证只会从"更远顶点"指向"更近顶点". if (!visited[v]) { self(self, v); } } } }; dfs(dfs, dest); return edges; } }; ``` <br><br> ## Bellman-Ford 算法 > 算法功能:**带权图的单源最短路径**,可确**处理负权边** & **检测负权环**(即图中存在**无法终止的负权循环**) ### 算法步骤 1. 数组 **`dist[v]`记录起点 s 到各顶点 v 的最短距离**,初始化为 `dist[s] = 0`,其余为 INT_MAX。 2. **松弛操作**: - 进行 **`N-1` 轮迭代**,`N` 为图中顶点数量。 - 每轮迭代,**==遍历图上所有边==**,对于边 `(u,v,w)` : - 若 `dist[v] + w < dist[u]` 成立,则更新 `dist[u]`; - 对于 "**无向图**",同时**检查 `dist[u] + w < dist[u]` 是否成立,若成立则更新 `dist[v]`**。 - 若一整轮中,**没有任何 `dist[v]` 被松弛更新**,代表 "**==已求得到各顶点的最短路径距离==**",则跳出循环。 3. **负权回路检测**: - 完成 `N-1` 轮迭代后,再进行一次检查,若还有边可以 "**==被松弛==**",说明图中**存在负权回路**。 > [!NOTE] 算法正确性理解 > 对于存在 **N 个顶点的连通图**,图上**任意两点间路径最多经过 ==N-1 条边==**。 > > - 在第 1 轮松弛操作中,得到所有**离起点==经过 1 条边==的路径** => `dist` 中求得 "**经过 ==1 条边==到各顶点**" 的最短路径的距离; > - 在第 2 轮松弛操作中,得到所有**离起点==经过 2 条边==的路径** => `dist` 中求得 "**经过 ==最多 2 条边==到各顶点**" 的最短路径的距离; > - ...... > - 在第 N-1 轮松弛操作中,得到所有**离起点==经过 2 条边==的路径** => `dist` 中求得 "**经过 ==最多N-1 条边==到各顶点**" 的最短路径距离; > > ![[Excalidraw-Solution/图相关算法_3.excalidraw|1000]] > > [!NOTE] 证明: "若一整轮中没有任何 `dist[v]` 被更新则可提前结束" > > 假设**第 k 轮没有任何 `dist[v]` 被更新**,说明**对所有边 `(u, v, w)` 均有 `dist[u] + w >= dist[v]`**。 > > 由于:(1)**==第 k + 1 轮遍历的"边"== 与第 k 轮==完全相同==**;(2)**第 k 轮中,与 v 相邻的所有顶点 `u` 的 ==`dist[u]` 均未被更新==**。 > > 因此**第 k+1 轮中 `dist[v]` 也不会被更新**。 ### 实现 - 时间复杂度:$O(|V|*|E|)$; - 空间复杂度:$O(|V|)$ ```cpp void BellMan_Ford(int n, vector<vector<int>>& edges, int start) { vector<int> dist(n, INT_MAX); dist[start] = 0; // 遍历所有边, 进行松弛操作, 最多迭代n-1轮. for (int i = 0; i < n - 1; ++i) { bool update = false; // 标记此轮中是否有dist[]被更新. for (const auto& e : edges) { int u = e[0], v = e[1], w = e[2]; if (dist[u] != INT_MAX && dist[u] + w < dist[v]) { dist[v] = dist[u] + w; update = true; } // 对于无向图, 需要同时考虑`v->u`. if (dist[v] != INT_MAX && dist[v] + w < dist[u]) { dist[u] = dist[v] + w; update = true; } } if (!update) break; // 此轮中没有任何dist被更新, 说明已求得起点到各顶点的最短路径距离. } // 第n轮检查是否存在 "负权回路" for (const auto& e : edges) { int u = e[0], v = e[1], w = e[2]; if (dist[u] != INT_MAX && dist[u] + w < dist[v] || dist[v] != INT_MAX && dist[v] + w < dist[u]) { cout << "图中存在负权回路!" << endl; return; } } cout << "得到有效的单源最短距离dist" << endl; } ``` <br><br> ## SPFA 算法 > SPFA(Shortest Path Faster Algorithm),是 **基于 Bellman-Ford 算法的优化算法**。 思路: 用 **队列** 来维护 **==被更新/松弛过==的节点**,基于**顶点 u 的 `dist[u]`** 去**尝试松弛其邻居`v`**,仅当 v 的 `dist[v]` 被更新时,才将`v` 加入队列。 > [!NOTE] SPFA 算法也即 "**==BFS + 松弛操作==**"。当顶点 v 的 `dist[v]` 被更新时,重新队列进行搜索。 > > 若按 "**层**" 记录队列操作,则 **"每一层"** 即等价于 Bellman-Ford 算法中的 "**==一轮迭代==**": > > - 第 1 层时,队列中存储的都是**从起点 1 步可达的邻居**; > - 第 2 层时,队列中存储的都是**从起点 2 步以内可达的邻居**; > - 第 k 层时,队列中存储的都是**从起点 k 步以内可达的邻居**。 > > 同 Bellman-Ford 算法,$N$ 个顶点的图上,**任意两顶点间最短路径==最多包含 $N-1$ 条边==**。 > 因此在没有 "**==负权回路==**" 的情况下,**每个顶点最多出入队列 $N-1$ 次必然可求得最短路径**。 > 由于每次顶点入队都可能导致**其所有邻边被松弛**,因此**所有边**共计最多被松弛 $N*E$ 次,故时间复杂度**最坏上限为 $O(N*E)$**。 ### 算法步骤 1. 维护三个数组: - `dist[v]` 记录起点到各顶点 v 的最短距离,初始化起点 `dist[s] = 0`,其余为 INT_MAX; - `inQueue[v]` 记录**顶点 ==v 是否已在队列中==**。当 `dist[v]` 被更新时,若**队列中已存在 `v`**,则**不必重复入队**。=> 队列弹出 v 时,将基于最新的 `dist[v]` 进行松弛。 - `count[v]` 记录**顶点 v的 `dist[v]` 被==松弛过的次==数**,当松弛次数**超过 v-1 次**说明存在 **==负权回路==**。 2. 初始时,将**起点入队**。 3. 每次从队列中**取出一个顶点 u**,根据 `u->v`,若 `dist[u]+w < dist[v]` 则更新`dist[v]`。**若 `dist[v]` 被更新,则将 `v` 加入队列进一步搜索**。 4. 重复步骤 3,直至**队列为空**,或者**某顶点松弛次数超过 v-1 次**。 ##### 复杂度分析 | | 最优 | 最坏 | 说明 | | ----- | ------ | -------- | -------------------------------------------------------------------- | | 时间复杂度 | $O(E)$ | $O(V*E)$ | 最坏情况下,**每个顶点可能被入队 V-1 次**,导致所有边共计最多被松弛 $V*E$ 次松弛操作,退化为 Bellman-Ford。 | | 空间复杂度 | $O(V)$ | $O(V)$ | 由于有 `inQueue` 标记,队列中无重复顶点,故不超过 $V$ | ### 实现 ```cpp void SPFA(int n, vector<vector<int>>& edges, int start) { vector<vector<pair<int, int>>> graph(n); for (const auto& e : edges) { graph[e[0]].emplace_back(e[1], e[2]); graph[e[1]].emplace_back(e[0], e[2]); } vector<int> dist(n, INT_MAX); vector<bool> inQueue(n, false); vector<int> count(n, 0); queue<int> que {{start}}; dist[start] = 0; while (!que.empty()) { int u = que.front(); que.pop(); inQueue[u] = false; for (const auto& [v, w] : graph[u]) { if (dist[u] != INT_MAX && dist[u] + w < dist[v]) { dist[v] = dist[u] + w; ++count[v]; if (count[v] > n - 1) { cout << "存在负权回路!" << endl; return; } if (!inQueue[v]) { // 若v已在队列中, 只需更新dist[v]即可, 无需重复入队. que.emplace(v); inQueue[v] = true; } } } } cout << "成功得到从起点到各顶点的最短距离dist" << endl; } ``` %% ## Floyd 算法 > 算法功能:**带权图的多源最短路径** Floyd 算法的核心是 "**动态规划**"。 #TODO 二维矩阵 `dist[i][j][k]` 记录表示从**顶点 i 到顶点 j** ,**以 ==前 k 个顶点 `[0,k-1]` 中某顶点为中转点==时的最短距离** ```cpp dist[i][j][k] = dist[i][j][k-1], dist[i][n] ``` ### Floyd 算法复杂度分析 - 时间复杂度:$O(|N|^3)$,**只取决于顶点 $N$ 的数量**。 - 空间复杂度:$O(|N|^3)$,三维 dp 数组的开销; > [!NOTE] 如果是**稀疏图**,**顶点多而边少**,可考虑**依次==以各个顶点为起点多次应用 dijsktra==**,时间复杂度为 $|N|*|E|\log |E|$。 %% <br><br><br> # 最小生成树 给定**无向加权连通图**,找到**一棵包含图上所有顶点、且==树上所有边的权重之和最小==的生成树**。 > [!NOTE] 当图上**存在权重相同的边**时,最小生成树可能不唯一。 > [!NOTE] 图中边的权值可以为 0 或者为负, 不影响求解; #### 最小生成树的求解算法 有两种算法:**Kruskal 算法**、**Prim 算法** | | 时间复杂度 | 空间复杂度 | | ---------- | ---------------------------------------------- | -------------------- | | Prim 算法 | $O(E\log E)$ (延迟删除堆中无效边) | $O(E)$ (堆中最多 $E$ 条边) | | Kruskal 算法 | $O(E\log E+E\cdot\alpha(V))$(需要对所有边排序 & 并查集开销) | $O(V)$ (并查集所需空间) | > [!danger] Kruskal 算法、Prim 算法不能处理 "**==有向图==**" > > - 对于 Prim 算法:**边的有向性会导致无法遍历整个图**,例如图 `1->2->3`,**如果以 `2` 为起点,将无法得到边 `1->2`**。 > - 对于 Kruskal 算法:有向图没有 "**连通分量**" 的概念,`A->B` 不等于 `B->A`,故**并查集的 "连通/合并" 判断是否成环的逻辑不适用**。 > - ![[Excalidraw-Solution/图相关算法_1.excalidraw|500]] > > [!note] 当图 "不连通" 时的处理结果(即图中存在多个孤立的连通分量) > > - 对于 Prim 算法:其是从某一顶点起始进行扩展,故将得到 **起始顶点所在连通分量的最小生成树**。 > - 对于 Kruskal 算法:将为 **图上所有连通分量** 分别得到 "**==各自的最小生成树==**"。 <br><br> ## Prim 算法 核心思想:每一步**找出 "==连接到当前生成树中顶点==" 的最小权重边**,逐步生成 "**生成树**"。 过程: 1. 从**任一顶点**开始,初始时**生成树中只有该顶点**。 2. 每一步中,找出连接 "**==生成树中顶点==**" 与 "**==树外顶点==**" 的 **==最小权重的边==**,将该**边**以及**边所连的树外顶点**,**加入生成树中**。 3. 重复第二步,直至: - **所有顶点都已加入到生成树**。 => 得到有效的 MST - 或者没有与生成树相连的**横切边**。 => 图不连通,图上存在多个孤立的连通分量 > [!info] 横切边:连接 "**生成树上顶点**" 与 "**树外顶点**" 的边[^3] ### 实现 求给定无环连通图中**最小生成树的权重**: ```cpp // Prime算法 int MST_Weight_By_Prime(int n, vector<vector<int>>& edges) { using PII = pair<int, int>; vector<vector<PII>> graph(n + 1); for (const auto& e : edges) { graph[e[0]].emplace_back(e[1], e[2]); graph[e[1]].emplace_back(e[0], e[2]); } unordered_set<int> MST; // 记录已添加到生成树中的顶点. priority_queue<PII, vector<PII>, greater<>> min_pq; // 小顶堆中存储连接到当前生成树的横切边: `{边权, 边另一端连接的树外顶点}`. vector<int> min_weight(n + 1, INT_MAX); // 记录与树外顶点关联的、已出现的最小权重边`(parent[i], i, w)` // 注: min_weight[i]记录的是算法过程中, 将顶点i作为"树外顶点"连接的边中, 权值最小的. 用于优化算法效率, 过滤非最小权值的边. min_pq.push({0, 1}); min_weight[0] = 0; int weight = 0; // 每次取出连接"树中顶点"与"树外顶点", 且权重最小的一条边, 加入树中. while (MST.size() < n && !min_pq.empty()) { auto [w, u] = min_pq.top(); min_pq.pop(); if (MST.count(u)) continue; MST.insert(u); weight += w; for (const auto& [v, ew] : graph[u]) { if (MST.count(v) || ew > min_weight[v]) continue; // 顶点v已在树中, 或者该边不是连接树外顶点v的最小权重边. min_weight[v] = ew; // 只将与关联树外顶点v的关联, 且权值最小的边入堆, 作为候选. min_pq.emplace(ew, v); } } return MST.size() == n ? weight : -1; } ``` 求给定无环连通图中**最小生成树的边集**: ```cpp // Prim算法 int MST_Egdes_By_Prim(int n, vector<vector<int>>& edges) { using PII = pair<int, int>; vector<vector<PII>> graph(n + 1); for (const auto& e : edges) { graph[e[0]].emplace_back(e[1], e[2]); graph[e[1]].emplace_back(e[0], e[2]); } unordered_set<int> MST; // 记录已添加到最小生成树中的顶点. vector<vector<int>> MST_edges; // 记录最小生成树中的边. `{u, v, w}` using T = tuple<int, int, int>; priority_queue<T, vector<T>, greater<>> min_pq; // 小顶堆中存储连接到当前生成树的横切边: `{边权, 树外顶点, 生成树内的顶点}`, vector<int> min_weight(n + 1, INT_MAX); // 记录与树外顶点关联的最小权重边. min_pq.push({0, 1, 1}); // 初始顶点1 即树外顶点. int weight = 0; // 每次取出连接"树中顶点"与"树外顶点", 且权重最小的一条边, 加入树中. while (MST.size() < n && !min_pq.empty()) { auto [w, u, s] = min_pq.top(); min_pq.pop(); // s是树中顶点, u应是树外顶点 if (MST.count(u)) continue; // 失效边 if (u != s) { MST_edges.push_back({s, u, w}); // 加入MST边集中. } MST.insert(u); weight += w; // 只更新min_weight,如果找到更小的权重边 for (const auto& [v, ew] : graph[u]) { if (MST.count(v) || ew >= min_weight[v]) continue; // 顶点已在树中, 或者该边不是连接树外顶点v的最小权重边. min_weight[v] = ew; min_pq.emplace(ew, v, u); } } return MST_edges; } ``` <br><br> ## Kruskal 算法 核心思想:**对边集==按权重升序排序==**,每次取出一条 **==权重最小==**、且不**会与生成树中已有边构成 "环"** 的边,加入生成树中。 算法过程: 1. **对边集==按权重升序排序==**; 2. 初始化一个 "**并查集**" 结构,用于**判断两顶点是否连通**。 3. **逐条处理按权重升序排列的边**: - 若边的 **==两顶点尚未连通==**,则**将边加入生成树中**,标记两顶点已连通。 - 否则,意味着加入后将形成环,跳过。 4. 重复步骤三,直至: - **生成树中已有 n-1 条边** => 得到**有效的最小生成树**,n 是顶点数 - 或者,边集已被遍历完 => 说明**图不连通,存在多个独立的连通分量** > [!NOTE] 利用 "并查集" 高效判断 "**两个顶点是否已连通**"。 > > ![[Excalidraw-Solution/图相关算法_0.excalidraw|750]] ### 实现 求给定无环连通图中**最小生成树的权重 & 边集** ```cpp class MinimumSpanningTree { public: // 并查集 class UnionFind { private: vector<size_t> parent; vector<size_t> rank; public: UnionFind(size_t n) : parent(n), rank(n, 1) { iota(parent.begin(), parent.end(), 0); }; size_t find(size_t x) { if (parent[x] != x) { parent[x] = find(parent[x]); // 路径压缩 } return parent[x]; } void unite(size_t x, size_t y) { x = find(x), y = find(y); if (x == y) return; if (rank[x] < rank[y]) swap(x, y); parent[y] = x; ++rank[y]; } bool connected(size_t x, size_t y) { return find(x) == find(y); } }; // Kruskal会为图中每个"独立"的连通分量, 分别求得"最小生成树". void get_MST(int n, vector<vector<int>>& edges) { // 1.对边集按权重升序排列 sort(edges.begin(), edges.end(), [](const auto& vec1, const auto& vec2) -> bool { return vec1[2] < vec2[2]; }); // 2.初始化并查集, 用于判断两顶点是否连通. UnionFind uf(n); vector<vector<int>> MST_edges; // 记录最小生成树中的边. int weight = 0; int components = n; // 记录连通分量数量. 初始时每个顶点独立 // 3. 每次取出权重最小, 且加入后不会令生成树成环的边加入生成树中. for (const auto& e : edges) { int u = e[0], v = e[1], w = e[2]; if (!uf.connected(u, v)) { // 顶点u,v尚未连通, 可加入边u-v. uf.unite(u, v); weight += w; MST_edges.emplace_back(e); --components; if (MST_edges.size() == n -1) break; // 边数足够, 统计完成 // 或者if (components == 1) break; } } if (MST_edges.size() == n - 1) { // 边数为n-1, 得到有效的最小生成树. cout << "最小生成树权重: " << weight << endl; } else { cout << "图中存在多个孤立的连通分量, 共 " << components << " 个" << endl; } } }; ``` <br><br> # 拓扑排序 > 下图参见 [^2] ![[_attachment/00-数据结构与算法笔记/数据结构/图相关算法.assets/IMG-图相关算法-F9F4D205FBF4B478722B94C7AB354498.png|656]] > [!info] **拓扑排序的结果不唯一**,**只要排序结果保证==对于任意一条有向边 `u->v`==,==顶点 u 都排在顶点 v 之前==即是正确的**。 > [!NOTE] 拓扑排序 > > 步骤如下: > > 1. 将所有 "**入度为 0 的顶点**" 加入队列; > 2. 每**出队一个顶点**,将该顶点所**指向的邻居节点 "==入度减 1=="**,**若邻居节点==入度减为了 0==,则将邻居节点入队**。 > 3. 重复上述过程,直至队列为空。 > > 若最后 **==所有顶点的入度均为 0==**,则**存在有效的拓扑排序**,**"==各顶点入度变为 0 的顺序==" 即为拓扑排序顺序**。 > 否则意味着 **==有向图中有环==**。 ^a1kq8q > [!info] 拓扑排序的开销 > > - 时间复杂度:$O(|V|+|E|)$。从图中入度为 0 的顶点起,图中**每个顶点都会入队/出队一次**,然后 **==列举各顶点的每条边==**,故**每条边也被访问一次**。 > - 空间复杂度:$O(|V|)$,队列本身最多存 $|V|$ 个顶点,但构图(如邻接表) 需要 $O(|V|+|E|)$。 算法实现示例: ```cpp // 拓扑排序(有向图), 返回拓扑排序的结果. vector<int> findOrder(int numCourses, vector<vector<int>>& prerequisites) { int& n = numCourses; vector<vector<int>> graph(n); vector<int> degree(n); for (const auto& e : prerequisites) { graph[e[1]].push_back(e[0]); ++degree[e[0]]; } vector<int> ans; queue<int> que; for (int i = 0; i < n; ++i) { if (degree[i] == 0) { que.push(i); ans.push_back(i); } } while (!que.empty()) { int u = que.front(); que.pop(); for (int v : graph[u]) { --degree[v]; if (!degree[v]) { que.push(v); ans.push_back(v); } } } if (ans.size() != n) return {}; return ans; } ``` <br><br><br> # 参考资料 # Footnotes [^1]: [最短路径四种算法——总结篇](https://blog.csdn.net/qq_44691917/article/details/104616471) [^2]: [leetcode.cn/circle/discuss/01LUak/](https://leetcode.cn/circle/discuss/01LUak/) [^3]: 《算法(第四版)》P391