图的遍历#
图的遍历是指从某个顶点出发,系统地访问图中的所有顶点,使得每个顶点恰好被访问一次。最基本的两种遍历方式是广度优先搜索 (BFS) 和 深度优先搜索 (DFS)。
BFS#
BFS 从源顶点出发,逐层向外扩展——先访问距离为 1 的所有邻居,再访问距离为 2 的所有邻居,依此类推。它本质上使用一个队列来维护待访问的顶点。
- 将源顶点入队,标记为已访问
- 当队列非空时,弹出队首顶点
- 遍历 的所有未被访问的邻居,依次入队并标记
- 重复 2-3 直到队列为空
flowchart TD
A((A)) --> B((B))
A --> C((C))
A --> D((D))
B --> E((E))
B --> F((F))
C --> G((G))
void bfs(int start, int n, const vector<vector<int>>& adj) {
vector<bool> vis(n, false);
queue<int> q;
q.push(start); vis[start] = true;
while (!q.empty()) {
int u = q.front(); q.pop();
for (int v : adj[u]) {
if (!vis[v]) {
vis[v] = true;
q.push(v);
}
}
}
}cppfrom collections import deque
def bfs(start, n, adj):
vis = [False] * n
q = deque([start])
vis[start] = True
while q:
u = q.popleft()
for v in adj[u]:
if not vis[v]:
vis[v] = True
q.append(v)pythonDFS#
DFS 从源顶点出发,沿着一条路径尽可能深入,直到无法继续再回溯。它本质上使用递归栈(或显式的栈)来实现。
- 标记当前顶点 为已访问
- 遍历 的所有未被访问的邻居,对每个邻居递归调用 DFS
- 所有邻居处理完毕后回溯
void dfs(int u, const vector<vector<int>>& adj, vector<bool>& vis) {
vis[u] = true;
for (int v : adj[u]) {
if (!vis[v]) dfs(v, adj, vis);
}
}cppdef dfs(u, adj, vis):
vis[u] = True
for v in adj[u]:
if not vis[v]:
dfs(v, adj, vis)pythonDFS 在遍历过程中会将边分为树边(首次到达顶点的边)和非树边(回边、前向边、交叉边)。这个分类在后续的环检测、强连通分量等算法中至关重要。
flowchart TD
A((A)) --> B((B))
B --> C((C))
C --> A
B --> D((D))
D --> A
DFS 判断环#
无向图:在 DFS 过程中,如果发现一条边连向一个已经访问过且不是当前顶点直接父亲的顶点,则该边是一条回边,说明图中存在环。
有向图:使用三色标记法——白色表示未访问,灰色表示正在递归栈中(尚未回溯),黑色表示已处理完毕。如果在 DFS 中遇到一条边指向灰色顶点,则说明存在回边,即存在环。
flowchart LR
A((A)) --> B((B))
B --> C((C))
C --> B
Tarjan 求强连通分量#
在有向图中,如果任意两个顶点都互相可达,则称该图是强连通的。有向图的强连通分量 (SCC) 是指极大的强连通子图——即不能再加入更多顶点而保持强连通性。
Tarjan 算法是求 SCC 最经典的方法,它基于 DFS 一次遍历即可完成,时间复杂度 。
核心概念#
算法维护两个关键数组:
dfn[u]:顶点 在 DFS 中的访问时间戳(第几个被访问的)low[u]:顶点 通过其子树中的回边能到达的最早的祖先的dfn值
初始时 low[u] = dfn[u]。在 DFS 回溯时更新:
- 如果 是 的树边子节点:
low[u] = min(low[u], low[v]) - 如果 已经在栈中(回边):
low[u] = min(low[u], dfn[v])
算法流程#
- 从某个未访问的顶点开始 DFS,维护一个全局时间戳
timer和一个栈 - 进入顶点 时,设
dfn[u] = low[u] = ++timer,并将 压入栈 - 对 的每个邻居 :
- 如果 未访问,递归 DFS(),回溯后
low[u] = min(low[u], low[v]) - 如果 已在栈中,
low[u] = min(low[u], dfn[v])
- 如果 未访问,递归 DFS(),回溯后
- 当
low[u] == dfn[u]时,说明 是一个 SCC 的根:从栈中不断弹出顶点直到弹出 ,这些顶点构成一个 SCC
void tarjan(int u) {
dfn[u] = low[u] = ++timer;
stk.push(u); in_stk[u] = true;
for (int v : adj[u]) {
if (!dfn[v]) {
tarjan(v);
low[u] = min(low[u], low[v]);
} else if (in_stk[v]) {
low[u] = min(low[u], dfn[v]);
}
}
if (low[u] == dfn[u]) {
// 弹出一个 SCC
int v;
do {
v = stk.top(); stk.pop();
in_stk[v] = false;
// v 属于当前 SCC
} while (u != v);
}
}cppflowchart TD
A((0)) --> B((1))
B --> C((2))
C --> A
B --> D((3))
D --> C
D --> E((4))
E --> D
正确性分析#
Tarjan 算法的正确性基于以下观察:
- 每个 SCC 在 DFS 树中形成一棵子树(可能不完整,但根一定在其中)
low[u]本质上在追踪 所在的 SCC 中dfn最小的顶点- 当
low[u] == dfn[u]时,说明 就是该 SCC 的根——因为从 出发无法通过任何回边到达更早的顶点 - 栈保证了 SCC 按照 DFS 完成顺序被识别,不会出现跨越 SCC 的遗漏
最小生成树(Minimum Spanning Tree, MST)#
给定一个连通无向赋权图 ,其最小生成树 (MST) 是包含所有顶点的一棵树(即 的子集, 条边),使得边的权值之和最小。
Kruskal#
Kruskal 算法的思想非常直观:贪心地从小到大考虑边,如果加入这条边不会形成环,就把它加入 MST。
- 将所有边按权值从小到大排序
- 依次考虑每条边 :
- 如果 和 当前不在同一个连通分量中,加入这条边并合并两个分量
- 否则跳过(会形成环)
- 当 MST 中已有 条边时停止
算法的核心数据结构是并查集 (Disjoint Set Union, DSU),用于高效地查询和合并连通分量。
struct Edge { int u, v, w; };
bool operator<(const Edge& a, const Edge& b) { return a.w < b.w; }
int find(int x, vector<int>& parent) {
return parent[x] == x ? x : parent[x] = find(parent[x], parent);
}
int kruskal(int n, vector<Edge>& edges) {
sort(edges.begin(), edges.end());
vector<int> parent(n);
iota(parent.begin(), parent.end(), 0);
int mst_weight = 0, edges_cnt = 0;
for (auto& e : edges) {
int ru = find(e.u, parent), rv = find(e.v, parent);
if (ru != rv) {
parent[ru] = rv;
mst_weight += e.w;
if (++edges_cnt == n - 1) break;
}
}
return mst_weight;
}cpp时间复杂度:(排序占主导,并查集操作近似 )
空间复杂度:
Prim#
Prim 算法从任意一个顶点出发,逐步扩展 MST。它维护一个「已在 MST 中的顶点集合」,每一步选择连接 和 的权值最小的边,将其另一端点加入 。
- 任选起点 ,初始化所有顶点到 的距离为 ( 为 )
- 用优先队列维护每个顶点到 的最小边权
- 每次取出距离最小的顶点 ,将其加入
- 对 的每个邻居 ,如果 的权值比 当前的距离更小,则更新并加入优先队列
- 重复 3-4 直到所有顶点都加入
int prim(int n, const vector<vector<pair<int,int>>>& adj) {
priority_queue<pair<int,int>, vector<pair<int,int>>, greater<>> pq;
vector<int> min_cost(n, INT_MAX);
vector<bool> in_mst(n, false);
int mst_weight = 0, cnt = 0;
min_cost[0] = 0;
pq.push({0, 0}); // {cost, vertex}
while (!pq.empty() && cnt < n) {
auto [cost, u] = pq.top(); pq.pop();
if (in_mst[u]) continue;
in_mst[u] = true;
mst_weight += cost; cnt++;
for (auto& [v, w] : adj[u]) {
if (!in_mst[v] && w < min_cost[v]) {
min_cost[v] = w;
pq.push({w, v});
}
}
}
return mst_weight;
}cpp时间复杂度:(优先队列)
空间复杂度:
正确性证明(贪心选择性)#
MST 算法的贪心策略之所以能得到最优解,基于以下关键定理:
Cut Property(割性质):将顶点集 任意划分为 和 ,设 是跨越这个割的所有边中权值最小的那条(其中 ),则 一定在 MST 中。
证明(反证法):假设存在一棵 MST 不包含 。将 加入 会形成唯一的一个环,这个环中必然存在另一条跨越割的边 。因为 是最小跨越边,有 。从 中移除 加入 得到一棵新生成树 ,其总权值不超过 ,因此 也是 MST——且包含 。
- Kruskal:每条被选中的边都是其对应时刻某个割的最小边
- Prim:每次选择的边都是割 的最小边
最短路#
Dijkstra#
Dijkstra 算法求解非负权图的单源最短路问题。它的思想和 Prim 非常相似:维护一个「已确定最短路的顶点集合」,每一步从剩余的顶点中选出离源点最近的顶点加入 ,然后用它更新邻居的距离。
- 初始化源点距离 ,其余顶点
- 用优先队列按距离从小到大维护所有顶点
- 取出距离最小的顶点 (这就是贪心的关键: 的最短路已经确定)
- 对 的每个邻居 ,执行松弛操作:如果 ,则更新
- 重复 3-4 直到队列为空
vector<int> dijkstra(int n, const vector<vector<pair<int,int>>>& adj, int s) {
vector<int> dist(n, INT_MAX);
priority_queue<pair<int,int>, vector<pair<int,int>>, greater<>> pq;
dist[s] = 0;
pq.push({0, s});
while (!pq.empty()) {
auto [d, u] = pq.top(); pq.pop();
if (d > dist[u]) continue; // 过时条目
for (auto& [v, w] : adj[u]) {
if (dist[u] + w < dist[v]) {
dist[v] = dist[u] + w;
pq.push({dist[v], v});
}
}
}
return dist;
}cpp时间复杂度:(优先队列实现)
空间复杂度:
Bellman-Ford#
Bellman-Ford 算法可以处理含负权边的最短路问题,并且能够检测负权环。
算法的核心操作是松弛 (Relaxation):对边 权值为 ,如果 ,则更新 。
- 初始化 ,其余
- 对所有边执行松弛操作,重复 轮
- 再执行一轮松弛:如果还有距离可以被更新,说明存在负权环
bool bellman_ford(int n, int s, const vector<tuple<int,int,int>>& edges, vector<int>& dist) {
fill(dist.begin(), dist.end(), INT_MAX);
dist[s] = 0;
for (int i = 0; i < n - 1; i++) {
bool changed = false;
for (auto& [u, v, w] : edges) {
if (dist[u] != INT_MAX && dist[u] + w < dist[v]) {
dist[v] = dist[u] + w;
changed = true;
}
}
if (!changed) break; // 提前终止
}
// 检查负环
for (auto& [u, v, w] : edges) {
if (dist[u] != INT_MAX && dist[u] + w < dist[v])
return false; // 存在负环
}
return true;
}cpp时间复杂度:
空间复杂度:
正确性:在没有负环的情况下,任意两点间的最短路径最多经过 条边(否则有环)。因此 轮松弛足以让所有最短路收敛。如果第 轮还能松弛,说明存在可以无限缩短的路径——即负权环。
SPFA (Shortest Path Faster Algorithm)#
SPFA 是 Bellman-Ford 的一种队列优化版本。它的核心思想是:只有那些在上一轮中被更新了距离的顶点,才有可能在下一轮中更新它们的邻居。
- 初始化 ,其余 ,将 入队
- 当队列非空时,弹出队首
- 对 的每个邻居 ,执行松弛;如果 被更新且 不在队列中,则入队
- 如果某个顶点入队次数超过 次,说明存在负权环
Floyd#
Floyd-Warshall 算法求解所有点对的最短路问题(APSP)。它基于动态规划,代码极其简洁。
定义 为「只允许经过前 个顶点作为中间节点时, 到 的最短距离」。状态转移方程:
可以优化掉 这一维,直接在原数组上更新:
def floyd_warshall(n, graph):
dist = [row[:] for row in graph]
for k in range(n):
for i in range(n):
for j in range(n):
dist[i][j] = min(dist[i][j], dist[i][k] + dist[k][j])
# 检查负环:如果 dist[i][i] < 0 则存在负环
return distpython时间复杂度:
空间复杂度:
Johnson#
当图含有负权边但没有负环时,如果需要对每一对顶点求最短路,Dijkstra 跑 次会失败,Floyd 又太慢()。Johnson 算法巧妙地结合了 Bellman-Ford 和 Dijkstra:
- 新建一个虚拟源点 ,从 向每个顶点连一条权值为 的边
- 从 运行 Bellman-Ford,得到每个顶点 的最短距离
- 重新赋权:将每条边 的权值改为 。可以证明
- 在新的非负权图上对每个顶点运行一次 Dijkstra
- 将 Dijkstra 得到的距离还原:
时间复杂度:(优先队列实现 Dijkstra)
拓扑排序#
拓扑排序是针对有向无环图(DAG)的一种排序:将顶点排成一个线性序列,使得对于任意有向边 , 在序列中都出现在 之前。
Kahn 算法(基于入度)#
- 统计每个顶点的入度
- 将所有入度为 的顶点入队
- 每次弹出队首 ,加入拓扑序列
- 将 的所有邻居的入度减 ;如果某个邻居入度变为 ,入队
- 重复 3-4 直到队列为空
- 如果拓扑序列中的顶点数小于 ,说明图中存在环(无法拓扑排序)
vector<int> topological_sort(int n, const vector<vector<int>>& adj) {
vector<int> indeg(n, 0);
for (int u = 0; u < n; u++)
for (int v : adj[u]) indeg[v]++;
queue<int> q;
for (int i = 0; i < n; i++)
if (indeg[i] == 0) q.push(i);
vector<int> order;
while (!q.empty()) {
int u = q.front(); q.pop();
order.push_back(u);
for (int v : adj[u]) {
if (--indeg[v] == 0) q.push(v);
}
}
return order.size() == n ? order : vector<int>(); // 空表示有环
}cppDFS 方法#
另一种方法是利用 DFS 的后序遍历(post-order):对每个未访问的顶点执行 DFS,在回溯时将顶点压入栈(或列表头部)。最终栈的逆序就是拓扑排序。
时间复杂度:两种方法都是 。
网络流#
最大流#
给定一个流网络——有向图 ,源点 ,汇点 ,每条边有一个非负容量 。一个流 是赋予每条边的值,满足:
- 容量限制:
- 流量守恒:除 和 外,每个顶点的流入总量等于流出总量
最大流问题:求从 到 的最大可能的总流量。
Ford-Fulkerson 方法#
Ford-Fulkerson 是一个框架而非具体算法:
- 从零流开始
- 在残量网络中找到一条从 到 的增广路径
- 沿这条路径增加尽可能多的流量(等于路径上残量的最小值)
- 重复 2-3 直到找不到增广路径为止
// Edmonds-Karp:用 BFS 找最短增广路
int edmonds_karp(int n, vector<vector<int>>& cap, int s, int t) {
int max_flow = 0;
while (true) {
// BFS 找增广路
vector<int> parent(n, -1);
queue<int> q;
q.push(s); parent[s] = s;
while (!q.empty()) {
int u = q.front(); q.pop();
for (int v = 0; v < n; v++) {
if (parent[v] == -1 && cap[u][v] > 0) {
parent[v] = u; q.push(v);
}
}
}
if (parent[t] == -1) break; // 没有增广路
int flow = INT_MAX;
for (int v = t; v != s; v = parent[v])
flow = min(flow, cap[parent[v]][v]);
for (int v = t; v != s; v = parent[v]) {
cap[parent[v]][v] -= flow;
cap[v][parent[v]] += flow;
}
max_flow += flow;
}
return max_flow;
}cppDinic 算法#
Dinic 是对 Ford-Fulkerson 的重要改进。它引入了分层图的概念:
- 用 BFS 构建分层图:计算每个顶点到 的最短距离(按边数),只保留从第 层到第 层的边
- 在分层图上用 DFS 找多条增广路(称为阻塞流)
- 重复 1-2 直到 不可达
Edmonds-Karp 时间复杂度:
Dinic 时间复杂度:
最小割#
割的定义:将顶点集 划分为 和 ,其中 ,。割的容量是所有从 到 的边的容量之和。
最小割问题:找到容量最小的 - 割。
最大流最小割定理:在任何流网络中,最大流的值等于最小割的容量。
证明思路:
- 任意可行流的值 任意割的容量(显然,因为流必须穿过割)
- 当 Ford-Fulkerson 终止时,残量网络中没有 的路径。令 为残量网络中从 可达的顶点集合,。
- 此时从 到 的所有原边都已满流(残量为 ),从 到 的所有边都为零流。
- 因此当前流的值恰好等于这个割的容量。由上面的不等式,这既是最大流也是最小割。
图着色问题#
图着色:为无向图 的每个顶点分配一种颜色,使得相邻顶点颜色不同。色数 是最少需要的颜色数。
图着色问题是经典的 NP-complete 问题。求解方法包括:
- 贪心着色:按某种顺序依次为顶点着色,每个顶点选当前可用的最小颜色编号。最坏情况下可能需要 种颜色( 为最大度)
- Welsh-Powell 算法:按度数从大到小排序后再贪心着色,通常效果优于简单贪心
- 回溯搜索:精确求解色数,但指数时间复杂度
Euler 路径/回路问题#
Euler 回路:经过每条边恰好一次且回到起点的闭合迹。
Euler 路径:经过每条边恰好一次但不一定回到起点的迹。
判定条件#
| Euler 回路 | Euler 路径 | |
|---|---|---|
| 无向连通图 | 所有顶点度为偶数 | 恰好 0 或 2 个顶点度为奇数 |
| 有向强连通图 | 每个顶点入度 = 出度 | 至多一个顶点出度 = 入度+1(起点),至多一个顶点入度 = 出度+1(终点),其余入度 = 出度 |
Hierholzer 算法#
如果存在 Euler 回路,可以用 Hierholzer 算法在 时间内找到:
- 从起点出发,沿未走过的边不断前进,直到回到起点(形成一个环)
- 如果环上还有未访问的边,从该点出发再找一个环,拼接上去
- 重复直到所有边都被访问
vector<int> eulerian_circuit(int n, vector<vector<int>>& adj) {
vector<int> circuit, stk = {0};
while (!stk.empty()) {
int u = stk.back();
if (!adj[u].empty()) {
int v = adj[u].back();
adj[u].pop_back();
stk.push(v);
} else {
circuit.push_back(u);
stk.pop_back();
}
}
reverse(circuit.begin(), circuit.end());
return circuit;
}cppHamilton 路径/回路问题#
Hamilton 回路:经过每个顶点恰好一次且回到起点的回路。
Hamilton 路径:经过每个顶点恰好一次的路径。
与 Euler 路径不同(判边),Hamilton 路径判顶点,这导致了本质上的困难:
- 判定 Hamilton 回路是否存在是 NP-complete 的
- 旅行商问题 (TSP) 是 Hamilton 回路的加权优化版本,同样是 NP-hard
- 目前最好的精确算法仍然是指数时间复杂度
可平面图#
可平面图:可以画在平面上,使得任意两条边只在端点处相交(即没有交叉边)。
欧拉公式#
对于连通的简单平面图,设顶点数为 ,边数为 ,面数为 ,则:
由此可以推出一些必要条件:
- 若 ,则
- 若图中没有三角形(最短环长 ),则
- 平均度 ,即必然存在度数不超过 的顶点
Kuratowski 定理#
一个图是可平面的 充要条件:它不包含 (5 个顶点的完全图)或 (两部各 3 个顶点的完全二分图)的细分。
细分:在边上插入度为 的新顶点,把一条边替换为一条路径。反过来说,如果反复「平滑」度为 的顶点后能得到 或 ,则原图不可平面。
flowchart LR
A((1)) --- B((2))
B --- C((3))
A --- D((4))
B --- D
C --- D
A ---- C是可平面的
flowchart LR
A((1)) --- B((2))
B --- C((4))
A --- D((3))
D --- B
D --- C
A ---- C
A --- E((5))
B --- E
C --- E
D --- E是不可平面的
中心度(Centrality)#
中心度用于量化图中顶点的「重要性」,不同的中心度从不同角度定义重要性:
度中心度 (Degree Centrality)#
度最高的节点最「中心」。例如社交网络中的「社交达人」、疫情中的超级传播者。
接近中心度 (Closeness Centrality)#
到其他节点平均距离最短的节点最「中心」。适合选址问题,如医院、消防站的位置选择。
介数中心度 (Betweenness Centrality)#
其中 是 到 的最短路径总数, 是经过 的最短路径数。介数高的节点是网络中的「守门人」或瓶颈,例如交通枢纽。
特征向量中心度 (Eigenvector Centrality)#
一个节点的重要性不仅取决于邻居的数量,还取决于邻居的重要性。这是 Google PageRank 的基础:
- PageRank:在特征向量中心度的基础上增加了随机跳转(阻尼因子 ),解决了悬挂节点(没有出链的页面)和循环吸收的问题
- 公式: