建议先阅读: G 栈 Stack, H 队列 Queue


原理

图是什么

社交网络中每个人是顶点,好友关系是边——你和朋友的”距离”是一度人脉,朋友的朋友是二度人脉。地图上每个路口是顶点,道路是边——GPS 导航就是在加权图上求最短路径。图是最灵活的数据结构:树是”无环连通图”,链表是”每个节点恰好一条出边的有向图”,哈希表的冲突链是”每个桶一条出边的图”——几乎所有数据结构都是图的特例。

与其他数据结构的对比

数组/链表
结构线性层次任意
节点关系前驱/后继父/子任意多邻居
典型操作按下标访问遍历/搜索路径查找/连通性/排序
复杂度


图在哪里

  • 地图导航:加权图 + Dijkstra/A* 寻路——Google Maps 每次导航本质是图搜索
  • 社交网络:好友推荐(二度好友 = BFS 两层)、影响力分析(连通分量大小)
  • 包依赖管理:npm/pip 的依赖关系是有向图,安装顺序 = 拓扑排序——环检测发现循环依赖
  • 编译器:函数调用图 + 拓扑排序确定编译顺序;控制流图 + DFS 检测死循环
  • 网络流:物流/供水/电路的最大流量 = 最大流问题(Dinic 算法)

图的分类

维度类型说明
方向有向图 / 无向图边是否有方向
权重加权图 / 无权图边是否有数值权重
连通性连通 / 非连通任意两点是否可达
密度稠密图()/ 稀疏图(边的数量级


两种存储方式

邻接矩阵邻接表
空间
判边 是否存在 — 直接访问 matrix[u][v] — 遍历邻接表
遍历所有邻居 — 扫描整行 — 仅遍历存在的边
适用稠密图(),Floyd-Warshall稀疏图(),DFS/BFS/Dijkstra

邻接表的实现细节:实际工程中,邻接表用 vector<vector<int>> 或 CSR 格式(类比稀疏矩阵一章)而非链表——因为 vector 的缓存友好性更好,且边的集合在构建后很少改变。

BFS 与 DFS

BFS 和 DFS 是图遍历的两条基本路径:

BFSDFS
数据结构队列栈(递归/显式)
遍历特性层序:先处理最近发现的节点深度优先:一路到底再回溯
最短路无权图的单源最短路径——首次发现即是最短不保证最短路
适用问题最短路径、二分图判定、最小生成树(Prim)拓扑排序、强连通分量、回溯搜索
复杂度

Dijkstra 算法

Dijkstra 算法求解非负权图上的单源最短路径。BFS 是 Dijkstra 在边权全为 1 时的特例——因为队列的 FIFO 性质天然维护了”距离递增”。

算法正确性基于贪心选择性质:

每次从优先队列中取出 最小的顶点 ,此时 已为最终最短距离——因为任何尚未处理的顶点 都有 ,而所有边权非负,不可能通过 再找到更短的到 的路径。

复杂度分析

  • 朴素实现(每次遍历所有顶点找最小值):
  • 二叉堆: —— 优先队列加速
  • 斐波那契堆:(理论最优,常数因子大)

BFS → Dijkstra → A*

这三者构成一个连续谱:

BFS: 队列 (FIFO) 边权全为 1 ← 处理时间最早 = 距离最近
Dijkstra: 优先队列(最小堆) 边权 ≥ 0 ← 当前距离最小 = 最终最短距离
A*: 优先队列 + 启发式 边权 ≥ 0 ← d(v) + h(v) 最小(启发式估计到目标的剩余距离)

A* 将贪心选择从”已走距离最短”改为”已走距离 + 预计剩余距离最短”。若启发式函数 是可容许的(admissible,即 真实剩余距离),A* 保证最优解。从这层意义上看,Dijkstra 就是 A* 在 时的退化。


实现

加权邻接表

无权图的 BFS 只能求最短路径长度(边数),而 Dijkstra 需要边权重。我们用加权邻接表:

#include <stdlib.h>
#include <limits.h>
 
// 加权邻接表边节点
typedef struct WeightedAdjNode {
 int vertex;
 int weight;
 struct WeightedAdjNode* next;
} WeightedAdjNode;
 
typedef struct {
 int V;
 WeightedAdjNode** adj;
} WeightedGraph;
 
WeightedAdjNode* create_wadj_node(int v, int w) {
 WeightedAdjNode* node = malloc(sizeof(WeightedAdjNode));
 node->vertex = v;
 node->weight = w;
 node->next = NULL;
 return node;
}
 
void wgraph_init(WeightedGraph* g, int V) {
 g->V = V;
 g->adj = calloc(V, sizeof(WeightedAdjNode*));
}
 
void wgraph_destroy(WeightedGraph* g) {
 for (int i = 0; i < g->V; i++) {
 WeightedAdjNode* cur = g->adj[i];
 while (cur) {
 WeightedAdjNode* tmp = cur;
 cur = cur->next;
 free(tmp);
 }
 }
 free(g->adj);
}
 
// directed=1 有向,directed=0 无向
void wgraph_add_edge(WeightedGraph* g, int u, int v, int w, int directed) {
 WeightedAdjNode* node = create_wadj_node(v, w);
 node->next = g->adj[u];
 g->adj[u] = node;
 if (!directed)
 wgraph_add_edge(g, v, u, w, 1);
}

BFS / DFS 遍历(无权图)

typedef struct AdjNode { int vertex; struct AdjNode* next; } AdjNode;
 
void graph_bfs(AdjNode** adj, int V, int start) {
 int* visited = calloc(V, sizeof(int));
 int* queue = malloc(V * sizeof(int));
 int head = 0, tail = 0;
 visited[start] = 1;
 queue[tail++] = start;
 while (head < tail) {
 int u = queue[head++];
 for (AdjNode* cur = adj[u]; cur; cur = cur->next)
 if (!visited[cur->vertex]) {
 visited[cur->vertex] = 1;
 queue[tail++] = cur->vertex;
 }
 }
 free(visited); free(queue);
}
 
void graph_dfs(AdjNode** adj, int V, int start) {
 int* visited = calloc(V, sizeof(int));
 int stack[V], top = 0;
 stack[top++] = start;
 while (top > 0) {
 int u = stack[--top];
 if (visited[u]) continue;
 visited[u] = 1;
 for (AdjNode* cur = adj[u]; cur; cur = cur->next)
 if (!visited[cur->vertex])
 stack[top++] = cur->vertex;
 }
 free(visited);
}

Dijkstra 最短路径

Dijkstra 的核心思想是贪心:每次从未确定的顶点中选出距离起点最近的顶点,用它去松弛其邻居。重复 V 次,每次选最近顶点需要 O(V),总 O(V^2)。用最小堆优化后选顶点降为 O(log V),总 O((V+E)log V)。

flowchart TD
 A["dist[start]=0, 其余 dist=INF"] --> B{"所有顶点已确定?"}
 B -->|否| C["从未确定顶点中选 dist 最小的 u"]
 C --> D["标记 u 为已确定"]
 D --> E["遍历 u 的每个邻居 v"]
 E --> F{"dist[u] + w(u,v) < dist[v]?"}
 F -->|是| G["更新 dist[v]"]
 G --> H["将 (v, dist[v]) 入堆"]
 H --> E
 E --> B
 B -->|是| I["结束,dist 数组即为最短路径"]

C 实现:用数组模拟最小堆作为优先队列。

// ---------- 最小堆优先队列 ----------
typedef struct { int dist; int vertex; } PQNode;
 
typedef struct { PQNode* data; int size; int cap; } MinPQ;
 
void pq_init(MinPQ* pq, int cap) {
 pq->data = malloc(cap * sizeof(PQNode));
 pq->size = 0; pq->cap = cap;
}
void pq_destroy(MinPQ* pq) { free(pq->data); }
 
static void pq_swap(PQNode* a, PQNode* b) {
 PQNode t = *a; *a = *b; *b = t;
}
 
void pq_push(MinPQ* pq, int dist, int v) {
 int i = pq->size++;
 pq->data[i] = (PQNode){dist, v};
 while (i > 0) {
 int p = (i - 1) / 2;
 if (pq->data[p].dist <= pq->data[i].dist) break;
 pq_swap(&pq->data[p], &pq->data[i]);
 i = p;
 }
}
 
int pq_pop(MinPQ* pq, int* out_dist, int* out_v) {
 if (pq->size == 0) return -1;
 *out_dist = pq->data[0].dist;
 *out_v = pq->data[0].vertex;
 pq->data[0] = pq->data[--pq->size];
 int i = 0;
 while (1) {
 int smallest = i;
 int left = 2 * i + 1, right = 2 * i + 2;
 if (left < pq->size && pq->data[left].dist < pq->data[smallest].dist)
 smallest = left;
 if (right < pq->size && pq->data[right].dist < pq->data[smallest].dist)
 smallest = right;
 if (smallest == i) break;
 pq_swap(&pq->data[i], &pq->data[smallest]);
 i = smallest;
 }
 return 0;
}
 
int pq_empty(MinPQ* pq) { return pq->size == 0; }
// ---------- 堆结束 ----------
 
void dijkstra(WeightedGraph* g, int start, int* dist) {
 for (int i = 0; i < g->V; i++) dist[i] = INT_MAX / 2;
 dist[start] = 0;
 
 MinPQ pq;
 pq_init(&pq, g->V * 2);
 pq_push(&pq, 0, start);
 
 while (!pq_empty(&pq)) {
 int d, u;
 pq_pop(&pq, &d, &u);
 if (d != dist[u]) continue; // 过期条目跳过
 
 for (WeightedAdjNode* cur = g->adj[u]; cur; cur = cur->next) {
 int v = cur->vertex, w = cur->weight;
 if (dist[u] + w < dist[v]) {
 dist[v] = dist[u] + w;
 pq_push(&pq, dist[v], v);
 }
 }
 }
 pq_destroy(&pq);
}

Kruskal 最小生成树

Kruskal 的核心思想:将所有边按权重排序,从小到大依次加入,若加入后不形成环则保留,直到有 V-1 条边。

flowchart LR
 A["所有边按 w 排序"] --> B["依次取最小边 (u,v,w)"]
 B --> C{"u 和 v 已连通?"}
 C -->|否| D["加入此边,合并 u 和 v"]
 C -->|是| E["跳过"]
 D --> F{"已有 V-1 条边?"}
 E --> B
 F -->|否| B
 F -->|是| G["生成树完成"]

C 实现需要并查集,这里直接内联一个简易版:

#include <stdlib.h>
 
typedef struct { int u, v, w; } KEdge;
 
int kedge_cmp(const void* a, const void* b) {
 return ((KEdge*)a)->w - ((KEdge*)b)->w;
}
 
static int kruskal_find(int* parent, int x) {
 return parent[x] == x ? x : (parent[x] = kruskal_find(parent, parent[x]));
}
 
int kruskal(int V, KEdge* edges, int E) {
 int* parent = malloc(V * sizeof(int));
 int* rank = calloc(V, sizeof(int));
 for (int i = 0; i < V; i++) parent[i] = i;
 
 qsort(edges, E, sizeof(KEdge), kedge_cmp);
 
 int total_weight = 0, cnt = 0;
 for (int i = 0; i < E && cnt < V - 1; i++) {
 int pu = kruskal_find(parent, edges[i].u);
 int pv = kruskal_find(parent, edges[i].v);
 if (pu != pv) {
 if (rank[pu] < rank[pv]) { int t = pu; pu = pv; pv = t; }
 parent[pv] = pu;
 if (rank[pu] == rank[pv]) rank[pu]++;
 total_weight += edges[i].w;
 cnt++;
 }
 }
 free(parent); free(rank);
 return total_weight;
}

常用算法复杂度

算法用途时间复杂度条件
BFS无权最短路径、层序遍历O(V+E)任意图
DFS遍历、环检测、连通分量O(V+E)任意图
Dijkstra单源最短路径O((V+E)logV)非负权
Bellman-Ford单源最短路径(可负权)O(VE)可检负环
Floyd-Warshall全源最短路径O(V^3)稠密图
Kruskal最小生成树O(E log E)任意图
Prim最小生成树O((V+E)logV)任意图

Dijkstra 手算轨迹

给定加权有向图(5 个顶点 0-4),源点 = 0:

graph LR
    0 -->|10| 1
    0 -->|3| 2
    1 -->|1| 3
    1 -->|2| 4
    2 -->|2| 3
    3 -->|4| 4

边:(0→1,10), (0→2,3), (1→3,1), (1→4,2), (2→3,2), (3→4,4)

选取 udist[] 变化邻居松弛
初始[0,∞,∞,∞,∞]
10 (dist=0)[0,10,3,∞,∞]0→1: 0+10=10, 0→2: 0+3=3
22 (dist=3)[0,10,3,5,∞]2→3: 3+2=5
33 (dist=5)[0,10,3,5,9]3→4: 5+4=9
44 (dist=9)[0,10,3,5,9]4 无出边
51 (dist=10)[0,10,3,5,9]1→3: 10+1=11>5 不更新, 1→4: 10+2=12>9 不更新

最终 dist = [0, 10, 3, 5, 9]。从 0 到各点的最短距离:0→0=0, 0→1=10, 0→2=3, 0→3=5, 0→4=9。

核心推演:Dijkstra

4 个顶点,源点 = 0,边:(0→1,5), (0→2,1), (1→3,2), (2→1,3), (2→3,7)。求 dist[]。

答案:

udist[]松弛
初始[0,∞,∞,∞]
10[0,5,1,∞]0→1:5, 0→2:1
22 (dist=1)[0,4,1,∞]2→1: 1+3=4<5 更新
31 (dist=4)[0,4,1,6]1→3: 4+2=6
43 (dist=6)[0,4,1,6]

最终 dist = [0, 4, 1, 6]

Kruskal 手算轨迹

6 个顶点(0-5),边按权重排序后依次加入:

wFind(u), Find(v)动作已选边数
1(1,4)11≠4选入1
2(3,4)23≠4选入2
3(0,3)30≠3选入3
4(2,5)42≠5选入4
5(0,4)50 和 4 已连通(0→3→4)跳过(成环)4
6(1,3)61 和 3 已连通(1→4→3)跳过(成环)4
7(2,3)72≠3选入5

5 条边(V-1=5),MST 总权重 = 1+2+3+4+7 = 17。

核心推演:Kruskal

5 个顶点(0-4),边:(0,1,2), (0,2,5), (1,2,1), (1,3,6), (2,3,3), (3,4,4)。画出 MST。

答案:

按权重排序:(1,2,1), (0,1,2), (2,3,3), (3,4,4), (0,2,5), (1,3,6)

w动作
1(1,2)1选入
2(0,1)2选入
3(2,3)3选入
4(3,4)4选入
5(0,2)5跳过(0-1-2 已连通)
6(1,3)6跳过(1-2-3 已连通)

MST 边:(1,2,1), (0,1,2), (2,3,3), (3,4,4),总权重 = 10


应用场景

  • 地图导航: 加权图 + Dijkstra/A* 寻路
  • 社交网络: 好友推荐(二度好友)、影响力分析(连通分量大小)
  • 包依赖管理: 有向无环图(DAG)+ 拓扑排序确定安装顺序

练习

题号题目说明
743网络延迟时间Dijkstra
1584连接所有点的最小费用最小生成树
200岛屿数量BFS/DFS 遍历
133克隆图BFS/DFS + 哈希
207课程表拓扑排序

力扣 (LeetCode) 有对应题型,竞赛方向推荐力扣/Codeforces。

动手实验

编号题目说明
E1邻接矩阵 vs 邻接表空间对比随机生成稀疏图(E = 2V)和稠密图(E = V^2/4),分别用邻接矩阵和邻接表存储,比较内存占用和遍历耗时
E2Dijkstra 优先级队列必要性验证分别用”优先队列”和”每次 O(V) 扫描”实现 Dijkstra,对顶点数 V=10000, E=50000 的图运行,对比耗时,验证堆优化从 O(V^2) 到 O(E log V) 的差距
E3DFS/BFS 遍历树对比对同一个图分别用 DFS 和 BFS 生成遍历树,打印两种遍历树的结构(边集),观察前驱子图与最短路径树的结构差异