图的存储:链式前向星

图的存储方式:链式前向星(邻接表)的原理与实现。

一句话算法

链式前向星用数组模拟邻接表:head[u] 指向第一条边,每条边用 next 串起同一起点的下一条边。

问题模型

图论题通常需要频繁遍历某个点的所有出边。我们需要一种存图方式,支持:

  • 加一条有向边或无向边;
  • 枚举从点 u 出发的所有边;
  • 在网络流、Tarjan 等算法中通过边编号访问反向边。

邻接矩阵空间是 O(n2)O(n^2),稀疏图会浪费很多空间。链式前向星只按边数开数组,空间是 O(n+m)O(n+m)

核心直觉

把每个点的出边看成一条链:

head[u] -> edge i -> edge next[i] -> edge next[next[i]] -> -1

新增边 (u,v,w) 时,把它插到 u 的链表头:

edge[cnt].next = head[u]
head[u] = cnt
cnt++

这样加边是 O(1)O(1),遍历 u 的所有出边时沿着 next 走即可。

算法步骤

  1. 初始化所有 head[u] = -1,表示没有出边。
  2. 新增边时,把边的信息写入 edge[cnt]
  3. 令新边的 next 指向原来的 head[u]
  4. 更新 head[u] = cnt
  5. 遍历点 u 时,从 head[u] 开始不断跳 next

算法证明

核心不变量head[u] 始终指向点 u 最近加入的一条出边,next 串起之前所有从 u 出发的边。

  1. 初始化时,head[u] = -1,每个点的出边链为空。
  2. 加入新边时,新边的 next 指向原链表头,所以旧边没有丢失。
  3. 再令 head[u] 指向新边,所以新边成为链表头。
  4. 其他点的 head 不变,因此只改变了点 u 的出边链。

所以每条加入的边都会被且只会被挂到它起点的链表中,遍历链表即可枚举所有出边。

复杂度分析

设点数为 nn,边数为 mm

  • 建图时间复杂度:O(m)O(m)
  • 遍历整张图的时间复杂度:O(n+m)O(n+m)
  • 空间复杂度:O(n+m)O(n+m)

代码实现

cpp
        
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
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
// const int maxn = 1e6+5; // const int maxe = 1e6+5; struct linkList { typedef struct {int u,v,w,next;} edge; edge e[maxe]; int h[maxn],edge_cnt=0; linkList(){ reset(); } void reset() { edge_cnt=0; memset(h,-1,sizeof(h)); } //遍历点u 周围点 template<typename U> void for_each(int u,U func){ for(int i = h[u] ; i !=-1;i = e[i].next) func(e[i].u,e[i].v,e[i].w); //u v w } void add(int u,int v,int w=0){ e[edge_cnt] = {u,v,w,h[u]}; h[u] = edge_cnt++; } void add2(int u,int v,int w=0){ add(u,v,w); add(v,u,w); } //下标访问 edge& operator[](int i){ return e[i]; } // 参考 语法糖 // https://en.cppreference.com/w/cpp/language/range-for.html #ifdef __cpp_range_based_for // C++ 模板 和 策略模式(Policy) 来消除重复代码。 // 我们可以定义一个通用的迭代器模板,通过传入不同的“提取器(Getter)”来决定 operator* 返回什么。 // === 1. 定义数据提取策略 (核心区别) === // 策略A: 获取整条边 (对应原本的 Iterator) struct UseEdge { using ReturnType = edge&; // 定义返回类型 static ReturnType get(linkList* p, int i) { return p->e[i]; } }; // 策略B: 只获取邻接点v (对应原本的 AdjIterator) struct UseAdj { using ReturnType = int; // 定义返回类型 static ReturnType get(linkList* p, int i) { return p->e[i].v; } }; // === 2. 通用迭代器模板 (复用逻辑) === template<typename Getter> struct BaseIterator { int i; // 边的编号 linkList* p; // linkList指针 BaseIterator(linkList* p, int i) : p(p), i(i) {} // 通用的遍历逻辑 BaseIterator& operator++() { i = p->e[i].next; return *this; } bool operator!=(const BaseIterator& oth) { return i != oth.i; } // 差异化逻辑:委托给 Getter 处理 typename Getter::ReturnType operator*() { return Getter::get(p, i); } }; // 定义具体的迭代器别名 using Iterator = BaseIterator<UseEdge>; using AdjIterator = BaseIterator<UseAdj>; // === 3. 通用范围类模板 === template<typename IterT> struct BaseRange { int start; linkList* p; BaseRange(linkList* p, int start) : p(p), start(start) {} IterT begin() { return IterT(p, p->h[start]); } IterT end() { return IterT(p, -1); } }; // === 4. 接口语法糖 === // usage: for(auto& e : list(u)) BaseRange<Iterator> operator()(int u) { return BaseRange<Iterator>(this, u); } // usage: for(int v : list.adj(u)) BaseRange<AdjIterator> adj(int u) { return BaseRange<AdjIterator>(this, u); } #endif } e;

测试用例

输入边:

1 2 5
1 3 7
2 4 9

如果用 add(1,2,5)add(1,3,7)add(2,4,9) 建图,那么遍历点 1 的出边可以得到 1 -> 31 -> 2。由于使用头插法,遍历顺序通常与加边顺序相反。

应用分类详解

链式前向星的本质是“高效枚举某点出边”。只要图稀疏并且需要反复遍历邻接边,就适合使用它。

一、普通图遍历

典型模式: DFS、BFS、拓扑排序、最短路。

识别信号: 需要从一个点访问所有相邻点。

核心建模:head[u] 定位起点,用 next 枚举出边。

应用场景 经典题目 核心思路
图遍历 本书图遍历章节 枚举出边继续 DFS/BFS
拓扑排序 本书拓扑排序章节 删除点时枚举出边更新入度

二、需要边编号的算法

典型模式: 需要找到反向边、标记某条边、处理重边。

识别信号: 出现网络流、割边、欧拉路径等算法。

核心建模: 每条边有稳定编号,可以用 i ^ 1 找配对反向边。

应用场景 经典题目 核心思路
最大流 luogu-P3376 正反边成对存储
割边 本书割边章节 用进入边编号避免重边误判

经典例题

1. luogu-P3371

单源最短路模板题。稀疏图使用邻接表或链式前向星存边。

2. luogu-P3386

二分图匹配。每次 DFS 从左部点枚举所有可连右部点。

3. luogu-P3376

最大流模板。需要正向边和反向边成对存储,链式前向星很适合这种写法。