news 2026/8/6 6:04:36

C++图论算法精讲:从邻接表实现到最短路径与最小生成树

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C++图论算法精讲:从邻接表实现到最短路径与最小生成树

1. 从“图”说起:为什么我们需要一种新的数据结构?

如果你写过链表、树或者堆,可能会觉得数据结构的世界已经足够丰富了。链表处理线性关系,树处理层次关系,堆处理优先级。但当我们面对更复杂的关系时,比如社交网络中的好友关系、城市之间的交通路线、网页之间的超链接,这些结构就显得力不从心了。这些关系不再是简单的“上一个/下一个”或者“父节点/子节点”,而是呈现出一种多对多、网状交织的形态。这就是“图”登场的时刻。

图论,作为数学的一个古老分支,研究的就是这种由“顶点”和连接顶点的“边”所构成的抽象结构。在计算机科学中,图不再仅仅是理论模型,而是解决无数实际工程问题的核心工具。从你手机里的地图App规划最短路径,到电商平台给你推荐“购买此商品的人也买了...”,再到编译器分析代码的依赖关系,背后都有图论算法的身影。这一章,我们将深入图的世界,不仅理解其概念,更要掌握用C++这把利器去实现和操作它的方法。无论你是正在备战算法竞赛,还是希望夯实基础以应对未来的系统设计面试,这一章的内容都将是你工具箱里至关重要的一部分。

2. 图的基石:顶点、边与两种核心存储方式

理解图,首先要理解它的两个基本元素:顶点和边。顶点代表实体,比如一个人、一个城市、一个任务。边代表关系,比如“认识”、“有道路连接”、“依赖于”。边可以是有方向的,比如A关注了B(A->B);也可以是无方向的,比如A和B是微信好友(A-B)。边还可以有权重,代表关系的强度或成本,比如道路的长度、通信的带宽。

在C++中,我们如何将这种抽象的结构具象化地存储起来呢?主要有两种主流方法:邻接矩阵和邻接表。选择哪一种,取决于你面对的是什么类型的图。

2.1 邻接矩阵:直观的“城市间直达航班表”

想象一个N个城市的交通网。我们可以用一个N行N列的二维数组matrix来表示它。matrix[i][j] = 1表示从城市i到城市j有直达航班(对于无向图,matrix[j][i]也应为1);matrix[i][j] = 0则表示没有。如果边有权重,这里就可以存储权重值,用一个大数(如INT_MAX)表示不连通。

#include <vector> using namespace std; // 使用邻接矩阵表示一个最多有100个顶点的有向图 const int MAX_V = 100; int graph[MAX_V][MAX_V]; int n; // 实际顶点数 void initGraph() { for (int i = 0; i < MAX_V; ++i) { for (int j = 0; j < MAX_V; ++j) { // 初始化:自己到自己的距离为0,其他为无穷大(表示不连通) graph[i][j] = (i == j) ? 0 : INT_MAX; } } } void addEdge(int from, int to, int weight) { graph[from][to] = weight; // 添加一条有向边 // 如果是无向图,需要加上:graph[to][from] = weight; }

邻接矩阵的优缺点非常鲜明:

  • 优点:
    1. 查询极快:判断任意两个顶点uv之间是否有边,直接访问graph[u][v],时间复杂度是O(1)。
    2. 实现简单:对于稠密图(边数接近顶点数的平方),这种表示法非常紧凑和高效。
  • 缺点:
    1. 空间消耗大:需要O(V²)的空间(V是顶点数)。对于顶点数上万甚至百万的社交网络,这个矩阵将大得无法存储。
    2. 遍历邻居慢:要找出顶点v的所有邻居,你需要遍历一整行(或列),即使它只有一两个邻居,也需要O(V)的时间。

注意:邻接矩阵是典型的“以空间换时间”的策略。在顶点数较少(例如几百个)且需要频繁进行“两点间是否有边”查询的场景下,它是好选择。但对于顶点多、边相对稀疏的图(如大多数社交网络),它的空间浪费是致命的。

2.2 邻接表:高效的“个人通讯录”

这更符合我们的直觉。我们为每个顶点维护一个列表,记录它所有直接相连的邻居。在C++中,通常用vector的数组(vector<int> adj[MAX_V])或者更现代地,用vector<vector<pair<int, int>>>来同时存储邻居顶点和边权。

#include <vector> using namespace std; const int MAX_V = 100; // 方法1:仅存储邻居顶点编号,适用于无权图 vector<int> adj_list[MAX_V]; // 方法2(推荐):存储 (邻居顶点编号, 边权值) 对,适用于带权图 vector<vector<pair<int, int>>> weighted_adj_list(MAX_V); void addEdge(int from, int to, int weight) { // 对于无权图 adj_list[from].push_back(to); // 对于无向图,还需要:adj_list[to].push_back(from); // 对于带权图 weighted_adj_list[from].push_back({to, weight}); // 对于无向图,还需要:weighted_adj_list[to].push_back({from, weight}); } // 遍历顶点v的所有出边 void traverseNeighbors(int v) { cout << "Neighbors of vertex " << v << ": "; for (const auto& neighbor : weighted_adj_list[v]) { cout << "-> " << neighbor.first << "(weight: " << neighbor.second << ") "; } cout << endl; }

邻接表的优缺点:

  • 优点:
    1. 空间高效:只存储实际存在的边,空间复杂度为O(V + E),对于稀疏图节省了大量内存。
    2. 遍历邻居快:遍历某个顶点的所有邻居,时间复杂度与该顶点的度数(邻居数)成正比,通常远小于O(V)。
  • 缺点:
    1. 查询边慢:判断uv是否有边,需要遍历u的邻居列表,最坏情况O(degree(u))。虽然可以用unordered_set存储邻居来将查询优化到平均O(1),但这会牺牲一些遍历效率和空间。
    2. 实现稍复杂:相比矩阵,代码结构稍微复杂一点。

实操心得:在99%的算法竞赛和面试场景中,邻接表是默认且首选的实现方式。因为它能高效处理大规模稀疏图,而这是最常见的情况。只有在明确知道图非常稠密,或者题目强制要求使用矩阵时,才考虑邻接矩阵。我个人的代码模板库中,vector<vector<pair<int, int>>> graph是绝对的主力。

3. 关键概念辨析:入边、出边与度的计算

当我们处理有向图时,边的方向赋予了顶点两种不同的“度”的概念,这是理解很多算法(如拓扑排序、欧拉路径)的基础。

  • 出边:从当前顶点指向其他顶点的边。顶点v出度,就是v的出边数量。在邻接表中,graph[v].size()直接就是v的出度。
  • 入边:从其他顶点指向当前顶点的边。顶点v入度,就是v的入边数量。计算入度需要遍历整个图。
// 计算有向图中所有顶点的入度 vector<int> calculateInDegree(int n, const vector<vector<pair<int, int>>>& graph) { vector<int> in_degree(n, 0); for (int u = 0; u < n; ++u) { for (const auto& [v, w] : graph[u]) { // C++17结构化绑定 in_degree[v]++; // 对于每条 u->v 的边,v的入度加1 } } return in_degree; } // 计算有向图中顶点v的出度(非常简单) int outDegree(int v, const vector<vector<pair<int, int>>>& graph) { return graph[v].size(); }

为什么区分入度和出度很重要?拓扑排序的经典Kahn算法就从入度为0的顶点开始。在网络流中,源的出度与汇的入度是分析的基础。判断一个有向图是否存在欧拉回路,条件就是每个顶点的入度等于出度。理解并熟练计算这两个概念,是进行有向图算法分析的第一步

常见问题:无向图的度对于无向图,每条边(u, v)在邻接表中会被存储两次(u的列表里有vv的列表里有u)。因此,顶点v的度就是graph[v].size()。同时,无向图中顶点的度也等于其入度或出度(因为无向边可以看作两条方向相反的有向边)。

4. 图的遍历:深度与广度优先搜索

遍历是图算法中最基础的操作,如同数组的循环。两种最经典的遍历策略是深度优先搜索和广度优先搜索,它们奠定了众多高级算法的思想基础。

4.1 深度优先搜索:一条路走到黑,再回头

DFS的策略是尽可能深地探索图的分支。它从某个顶点开始,沿着一条边不断深入,直到没有未访问的邻居,然后回溯到上一个顶点,探索另一条路径。这个过程天然适合用递归实现,或者显式地使用栈。

递归版DFS模板:

vector<bool> visited; // 访问标记数组 void dfs(int v, const vector<vector<int>>& graph) { visited[v] = true; // 在这里处理顶点v,例如打印、记录等 // cout << v << " "; for (int neighbor : graph[v]) { if (!visited[neighbor]) { dfs(neighbor, graph); // 递归深入 } } // 回溯发生在这里(函数返回时) } void dfsTraversal(int start, int n, const vector<vector<int>>& graph) { visited.assign(n, false); dfs(start, graph); // 如果是非连通图,可能需要循环检查所有顶点,对未访问的调用dfs }

迭代版DFS(使用栈):

void dfsIterative(int start, const vector<vector<int>>& graph) { int n = graph.size(); vector<bool> visited(n, false); stack<int> s; s.push(start); while (!s.empty()) { int v = s.top(); s.pop(); if (visited[v]) continue; visited[v] = true; // 处理顶点v // 注意:为了与递归顺序一致(在邻接表顺序下),可能需要将邻居逆序入栈 for (int neighbor : graph[v]) { if (!visited[neighbor]) { s.push(neighbor); } } } }

DFS的核心应用场景:

  • 连通分量检测:一次DFS能遍历一个连通子图的所有顶点。
  • 拓扑排序(在有向无环图中)。
  • 寻找图中的环
  • 解决回溯问题(如迷宫、八皇后),图本身就是状态空间的模型。

4.2 广度优先搜索:层层推进,由近及远

BFS的策略是按距离起始点的层次来遍历。它先访问所有距离为1的邻居,然后是距离为2的邻居,依此类推。这保证了找到的路径(在无权图中)是最短路径。BFS必须使用队列来实现。

BFS模板:

void bfs(int start, const vector<vector<int>>& graph) { int n = graph.size(); vector<bool> visited(n, false); queue<int> q; visited[start] = true; q.push(start); while (!q.empty()) { int v = q.front(); q.pop(); // 处理顶点v for (int neighbor : graph[v]) { if (!visited[neighbor]) { visited[neighbor] = true; // **关键**:在入队时标记已访问,避免重复入队 q.push(neighbor); } } } }

BFS的核心应用场景:

  • 无权图的最短路径:BFS首次访问到某个顶点时,经过的路径一定是最短路径。
  • 层次遍历:例如,在社交网络中寻找“二度好友”、“三度好友”。
  • 迷宫最短路径求解
  • 广播消息:模拟信息在网络中的传播过程。

避坑技巧:在BFS中,必须在顶点入队时立即标记为已访问,而不是在出队时。想象一下,顶点A和B都是C的邻居,它们会先后将C加入队列。如果在出队时才标记,C就会被重复加入队列两次,导致效率降低,在复杂图中可能引发严重问题。这是新手最容易犯的错误之一。

5. 最短路径算法:从单源到全源

寻找图中两点间的最短路径是图论最经典的问题之一。根据图的特性(有无负权边)和需求(单源还是全源),有不同的算法选择。

算法核心思想时间复杂度适用图类型主要用途
Dijkstra贪心,每次从未确定顶点中选取距离源点最近的O((V+E)logV) (优先队列)非负权有向/无向图单源最短路径
Bellman-Ford动态规划,松弛所有边 V-1 轮O(VE)任意权有向图(可检测负权环)单源,含负权边
SPFABF的队列优化,只松弛被更新的顶点关联边平均O(kE),最坏O(VE)任意权有向图(可检测负权环)单源,稀疏图负权
Floyd-Warshall动态规划,以每个顶点作为中转点更新距离O(V³)任意权有向/无向图(可处理负权,不能有负环)全源最短路径

5.1 Dijkstra算法:非负权图的王者

Dijkstra算法是解决单源、非负权图最短路径问题的标准算法。它的核心是维护一个“已确定最短距离”的集合,并不断从“未确定”集合中挑选出当前距离源点最近的顶点加入“已确定”集合,并松弛其出边。

使用优先队列(小顶堆)优化的Dijkstra实现:

#include <vector> #include <queue> #include <climits> using namespace std; vector<int> dijkstra(int start, int n, const vector<vector<pair<int, int>>>& graph) { vector<int> dist(n, INT_MAX); dist[start] = 0; // 优先队列存储 (当前到该点的距离, 顶点编号) priority_queue<pair<int, int>, vector<pair<int, int>>, greater<pair<int, int>>> pq; pq.push({0, start}); while (!pq.empty()) { auto [current_dist, u] = pq.top(); // C++17 pq.pop(); // 重要:如果当前取出的距离大于记录的距离,说明是旧的无用数据,直接跳过 if (current_dist > dist[u]) { continue; } for (const auto& [v, weight] : graph[u]) { int new_dist = dist[u] + weight; if (new_dist < dist[v]) { dist[v] = new_dist; pq.push({new_dist, v}); // 可能产生重复数据,但由上面的continue处理 } } } return dist; // dist[i] 即为从start到i的最短距离,若为INT_MAX则不可达 }

为什么Dijkstra不能处理负权边?因为Dijkstra基于贪心策略,假设“当前最短路径就是最终最短路径”。一旦有负权边,这个假设就不成立了。因为可能通过一个当前距离更远的点,加上一条负权边,得到一条更短的路径。贪心策略无法回溯。

5.2 Bellman-Ford与SPFA:负权图的解决方案

当图中存在负权边时,就需要Bellman-Ford算法。它的思想很简单:对所有的边进行V-1轮松弛操作。因为最短路径最多包含V-1条边,所以V-1轮后所有最短路径必然被找到。如果在第V轮还能松弛,说明图中存在从源点可达的负权环

Bellman-Ford标准实现:

struct Edge { int u, v, w; // 起点,终点,权值 }; bool bellmanFord(int start, int n, const vector<Edge>& edges, vector<int>& dist) { dist.assign(n, INT_MAX); dist[start] = 0; // 松弛 n-1 轮 for (int i = 0; i < n - 1; ++i) { bool relaxed = false; for (const auto& e : edges) { if (dist[e.u] != INT_MAX && dist[e.u] + e.w < dist[e.v]) { dist[e.v] = dist[e.u] + e.w; relaxed = true; } } if (!relaxed) break; // 如果一轮没有松弛,提前结束 } // 检查第n轮是否还能松弛,判断负环 for (const auto& e : edges) { if (dist[e.u] != INT_MAX && dist[e.u] + e.w < dist[e.v]) { return false; // 存在从源点可达的负权环 } } return true; }

SPFA:Bellman-Ford的队列优化SPFA并不是一个“新算法”,而是对Bellman-Ford的优化。它维护一个队列,只对上一轮距离被更新过的顶点所关联的边进行松弛。在随机图上效率很高,但最坏情况会退化成O(VE)。

bool spfa(int start, int n, const vector<vector<pair<int, int>>>& graph, vector<int>& dist) { dist.assign(n, INT_MAX); vector<int> cnt(n, 0); // 记录入队次数,用于检测负环 vector<bool> inQueue(n, false); queue<int> q; dist[start] = 0; q.push(start); inQueue[start] = true; cnt[start]++; while (!q.empty()) { int u = q.front(); q.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; if (!inQueue[v]) { q.push(v); inQueue[v] = true; cnt[v]++; if (cnt[v] >= n) { // 一个顶点入队超过n次,说明有负环 return false; } } } } } return true; }

实操心得:在算法竞赛中,如果题目明确没有负权边,无脑用Dijkstra。如果可能有负权边,且图是稀疏的,可以尝试SPFA,但要注意设置合理的入队次数限制以防被极端数据卡超时。如果题目要求检测负环,或者图比较稠密,老老实实用标准的Bellman-Ford更稳妥。

6. 最小生成树:连接所有点的最低成本

想象你要在几个村庄之间铺设电线,让所有村庄都通电,且总电线长度最短。这就是最小生成树问题。MST要求在一个连通无向带权图中,找到一个边的子集,使得这些边连接所有顶点,且没有环,并且总权重最小。两个最著名的算法是Prim和Kruskal。

6.1 Prim算法:从一点开始,逐步生长

Prim算法非常像Dijkstra。它从任意一个顶点开始,每次将连接“已选顶点集合”和“未选顶点集合”的权值最小的边及其连接的顶点加入MST。

使用优先队列的Prim算法实现:

int prim(int n, const vector<vector<pair<int, int>>>& graph) { vector<bool> inMST(n, false); priority_queue<pair<int, int>, vector<pair<int, int>>, greater<pair<int, int>>> pq; // 从顶点0开始 pq.push({0, 0}); // (边权, 顶点) int mst_weight = 0; int edges_used = 0; while (!pq.empty() && edges_used < n) { auto [weight, u] = pq.top(); pq.pop(); if (inMST[u]) continue; // 已经在MST中,跳过 inMST[u] = true; mst_weight += weight; edges_used++; for (const auto& [v, w] : graph[u]) { if (!inMST[v]) { pq.push({w, v}); // 将与u相连的、不在MST中的顶点加入队列 } } } // 如果 edges_used != n,说明图不连通,无法生成MST return (edges_used == n) ? mst_weight : -1; }

6.2 Kruskal算法:按权值排序,避免成环

Kruskal算法的思路更直接:将所有边按权值从小到大排序,然后依次考虑每条边。如果加入这条边不会在已选的边集中形成环,就加入它,直到选中了n-1条边。判断是否成环,需要用到并查集这个高效的数据结构。

Kruskal算法实现(需并查集支持):

struct DSU { vector<int> parent, rank; DSU(int n) : parent(n), rank(n, 1) { for (int i = 0; i < n; ++i) parent[i] = i; } int find(int x) { return parent[x] == x ? x : parent[x] = find(parent[x]); // 路径压缩 } bool unite(int x, int y) { x = find(x); y = find(y); if (x == y) return false; if (rank[x] < rank[y]) swap(x, y); // 按秩合并 parent[y] = x; if (rank[x] == rank[y]) rank[x]++; return true; } }; int kruskal(int n, vector<tuple<int, int, int>>& edges) { // (weight, u, v) sort(edges.begin(), edges.end()); // 按权值排序 DSU dsu(n); int mst_weight = 0; int edges_used = 0; for (const auto& [w, u, v] : edges) { if (dsu.unite(u, v)) { // 如果u和v不在一个集合,加入这条边不会成环 mst_weight += w; edges_used++; if (edges_used == n - 1) break; } } return (edges_used == n - 1) ? mst_weight : -1; }

Prim vs Kruskal 如何选择?

  • Prim算法更适合稠密图。它的时间复杂度与使用邻接矩阵还是邻接表有关,用优先队列优化后是O(ElogV)。在边非常多的时候,其性能相对稳定。
  • Kruskal算法更适合稀疏图。它的时间复杂度主要花在排序上,为O(ElogE)。在边比较少的时候,排序开销小,且实现非常简洁,尤其是借助并查集。

7. 拓扑排序:为有向无环图的任务排个序

当你有一系列有依赖关系的任务(比如编译源码、课程选修),你需要找到一个线性序列,使得对于任何有向边(u->v),u都排在v的前面。这就是拓扑排序,它只适用于有向无环图

7.1 Kahn算法:基于入度的广度优先策略

这是最直观的算法。不断寻找图中入度为0的顶点,将其输出,并从图中“移除”(将其所有出边指向的顶点入度减1)。重复此过程。

vector<int> topologicalSortKahn(int n, const vector<vector<int>>& graph) { vector<int> in_degree(n, 0); for (int u = 0; u < n; ++u) { for (int v : graph[u]) { in_degree[v]++; } } queue<int> q; for (int i = 0; i < n; ++i) { if (in_degree[i] == 0) { q.push(i); } } vector<int> topo_order; while (!q.empty()) { int u = q.front(); q.pop(); topo_order.push_back(u); for (int v : graph[u]) { if (--in_degree[v] == 0) { q.push(v); } } } // 如果排序后的顶点数小于n,说明图中有环 if (topo_order.size() != n) { return {}; // 返回空数组表示无法拓扑排序(存在环) } return topo_order; }

7.2 基于DFS的拓扑排序

另一种方法是在DFS回溯的过程中,将顶点加入序列。最终将序列反转即可。这种方法更容易在递归中集成其他逻辑。

bool dfsTopo(int u, vector<int>& visited, const vector<vector<int>>& graph, vector<int>& order) { visited[u] = 1; // 1表示正在访问中 for (int v : graph[u]) { if (visited[v] == 1) return false; // 存在环 if (visited[v] == 0) { if (!dfsTopo(v, visited, graph, order)) return false; } } visited[u] = 2; // 2表示已访问完成 order.push_back(u); return true; } vector<int> topologicalSortDFS(int n, const vector<vector<int>>& graph) { vector<int> visited(n, 0); // 0未访问,1访问中,2已结束 vector<int> order; for (int i = 0; i < n; ++i) { if (visited[i] == 0) { if (!dfsTopo(i, visited, graph, order)) { return {}; // 检测到环 } } } reverse(order.begin(), order.end()); // 反转得到拓扑序 return order; }

拓扑排序的应用远不止任务调度:

  • 编译顺序:确定源文件编译的先后顺序。
  • 课程安排:安排有先修课要求的课程。
  • 依赖解析:软件包管理器确定安装顺序。
  • 死锁检测:如果图中有环,则说明存在循环依赖,可能引发死锁。

8. 常见问题与排查技巧实录

在实际编码和解题中,总会遇到一些“坑”。这里记录了几个最常见的问题和我的解决思路。

8.1 图不连通导致遍历不完全

无论是DFS还是BFS,如果只从一个起点开始,对于非连通图,只能访问到该连通分量里的顶点。标准做法是,初始化访问数组后,用一个循环遍历所有顶点,对每个未访问的顶点调用遍历函数。

void traverseWholeGraph(int n, const vector<vector<int>>& graph) { vector<bool> visited(n, false); int componentCount = 0; // 连通分量计数器 for (int i = 0; i < n; ++i) { if (!visited[i]) { // bfs(i, graph, visited); 或 dfs(i, graph, visited); componentCount++; } } cout << "Number of connected components: " << componentCount << endl; }

8.2 递归深度过大导致栈溢出

DFS的递归实现简洁,但当图深度很大(例如一条长链)时,可能导致递归调用栈溢出。解决方案

  1. 改用迭代版DFS(显式栈)
  2. 调整编译器的栈空间大小(竞赛中通常不可行)。
  3. 对于明确是深度搜索的问题,考虑是否能用BFS解决。

8.3 邻接表遍历时修改容器

这是一个非常隐蔽的错误。在遍历vector<int> adj[v]时,如果调用的函数(比如递归的DFS)可能会向adj[v]中添加新的边(例如在遍历过程中动态建图),就会导致迭代器失效,引发未定义行为。

// 危险代码示例 void dfs_bad(int v, vector<vector<int>>& graph) { visited[v] = true; for (int to : graph[v]) { // 遍历过程中,如果dfs递归调用修改了graph[v],这里会出错 if (!visited[to]) { // 假设这里某种条件下会调用 addEdge(graph, v, some_new_node); dfs_bad(to, graph); } } }

安全做法:如果需要遍历的同时修改,可以先复制一份邻居列表,或者使用索引遍历。

void dfs_safe(int v, vector<vector<int>>& graph) { visited[v] = true; // 复制当前邻居列表 vector<int> neighbors = graph[v]; for (int to : neighbors) { if (!visited[to]) { // 现在可以安全地修改graph[v]了 dfs_safe(to, graph); } } }

8.4 多测试用例未重置数据

在在线判题系统中,通常有多个测试用例。如果你使用全局或静态的graphvisiteddist等数组,必须在每个测试用例开始前将其彻底重置。忘记清空是常见的WA(错误答案)原因。

void solve() { int n, m; while (cin >> n >> m) { // 1. 重置图结构 vector<vector<pair<int, int>>> graph(n); // 2. 读入数据,建图... // 3. 重置辅助数组 vector<int> dist(n, INF); vector<bool> visited(n, false); // 4. 执行算法... } }

8.5 负权环的误判与处理

在使用SPFA或Bellman-Ford判断负环时,需要注意:

  • 从特定源点出发:标准Bellman-Ford和上面的SPFA只能检测从源点s出发可达的负权环。如果图不连通,且负环存在于另一个连通分量中,这些算法会报告“无负环”,但这不意味着整个图没有负环。
  • 全图检测:为了检测整个图中的任何负环,一个常用的技巧是初始化一个超级源点。即创建一个新顶点,将其到所有原顶点的距离设为0,然后从这个超级源点跑SPFA/Bellman-Ford。或者更简单粗暴地,在SPFA开始时将所有顶点入队并标记。
// SPFA检测全图负环(通用做法) bool hasNegativeCycle(int n, const vector<vector<pair<int, int>>>& graph) { vector<int> dist(n, 0); // 初始距离设为0 vector<int> cnt(n, 0); vector<bool> inQueue(n, true); // 所有顶点一开始都在队列中 queue<int> q; for (int i = 0; i < n; ++i) q.push(i); while (!q.empty()) { int u = q.front(); q.pop(); inQueue[u] = false; for (const auto& [v, w] : graph[u]) { if (dist[u] + w < dist[v]) { dist[v] = dist[u] + w; if (!inQueue[v]) { q.push(v); inQueue[v] = true; if (++cnt[v] >= n) { return true; // 发现负环 } } } } } return false; }

图论这一章的内容就像一座宝库,从基础的存储遍历,到经典的最短路径、最小生成树,再到拓扑排序,每一部分都对应着大量经典的现实问题。理解概念是第一步,更重要的是动手实现,并在大量的练习中体会不同算法之间的微妙差别和适用场景。我建议从邻接表的实现、DFS/BFS遍历模板开始,牢牢掌握,然后逐个攻破Dijkstra、并查集+Kruskal、拓扑排序这些高频考点。当你遇到一个复杂的问题,能下意识地想到“这可以建模成图,用那个算法来解决”时,这一章才算真正学到位了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/8/6 6:04:22

从OpenClaw到Hermes:AI智能体平台生产级迁移实战与部署指南

1. 项目概述&#xff1a;一次深思熟虑的AI智能体平台迁移最近&#xff0c;我把手头一个核心的AI智能体项目&#xff0c;从原先使用的OpenClaw平台&#xff0c;完整地迁移到了Hermes上。这个决定不是一时兴起&#xff0c;而是在经历了几个月的实际开发、部署和运维后&#xff0c…

作者头像 李华
网站建设 2026/8/6 6:01:42

GD32F103实现SD卡USB大容量存储设备(MSC)与FATFS文件系统完整指南

1. 项目缘起&#xff1a;为什么是GD32F103SD卡USB文件系统&#xff1f;几年前&#xff0c;我在一个工业数据采集器的项目上遇到了一个经典难题&#xff1a;设备需要在野外长时间运行&#xff0c;采集到的数据量不小&#xff0c;需要可靠地存储下来&#xff0c;并且能方便地让现…

作者头像 李华
网站建设 2026/8/6 6:00:17

CAD快速标注全攻略:从QDIM命令到高效工作流,提升300%绘图效率

在CAD绘图中&#xff0c;你是否也经历过这样的场景&#xff1a;一张复杂的机械装配图&#xff0c;几十个尺寸需要标注&#xff0c;你耐着性子一个个点击“线性标注”&#xff0c;重复着“指定第一条尺寸界线原点 -> 指定第二条 -> 指定尺寸线位置”的机械操作。半小时过去…

作者头像 李华
网站建设 2026/8/6 5:57:43

RAG技术解析:如何构建企业级知识库问答系统

1. 从“人工智障”到“智能伙伴”的进化之路如果你最近尝试过用大语言模型&#xff08;LLM&#xff09;来回答关于你公司内部文档、产品手册或者历史项目资料的问题&#xff0c;大概率会经历一个从满怀期待到哭笑不得的过程。你问它&#xff1a;“我们去年Q3发布的XX产品&#…

作者头像 李华
网站建设 2026/8/6 5:55:05

部署 MHA 高可用

目录 一、MySQL MHA 1.1 什么是MHA 1.2 MHA的组成 1.3 MHA的特点 1.4 MHA的工作原理 二、搭建MySQL MHA 2.1 环境 2.2 准备工作 2.3 安装MHA 2.4 在所有服务器上配置无密码认证 2.5 在manager节点上配置MHA 2.6 第一次配置需要在Master节点上手动开启虚拟IP 2.7 在…

作者头像 李华