图的存储:链式前向星
图的存储方式:链式前向星(邻接表)的原理与实现。
一句话算法
链式前向星用数组模拟邻接表:head[u] 指向第一条边,每条边用 next 串起同一起点的下一条边。
问题模型
图论题通常需要频繁遍历某个点的所有出边。我们需要一种存图方式,支持:
- 加一条有向边或无向边;
- 枚举从点
u出发的所有边; - 在网络流、Tarjan 等算法中通过边编号访问反向边。
邻接矩阵空间是
核心直觉
把每个点的出边看成一条链:
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++
这样加边是 u 的所有出边时沿着 next 走即可。
算法步骤
- 初始化所有
head[u] = -1,表示没有出边。 - 新增边时,把边的信息写入
edge[cnt]。 - 令新边的
next指向原来的head[u]。 - 更新
head[u] = cnt。 - 遍历点
u时,从head[u]开始不断跳next。
算法证明
核心不变量:head[u] 始终指向点 u 最近加入的一条出边,next 串起之前所有从 u 出发的边。
- 初始化时,
head[u] = -1,每个点的出边链为空。 - 加入新边时,新边的
next指向原链表头,所以旧边没有丢失。 - 再令
head[u]指向新边,所以新边成为链表头。 - 其他点的
head不变,因此只改变了点u的出边链。
所以每条加入的边都会被且只会被挂到它起点的链表中,遍历链表即可枚举所有出边。
复杂度分析
设点数为
- 建图时间复杂度:
。 - 遍历整张图的时间复杂度:
。 - 空间复杂度:
。
代码实现
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 -> 3 和 1 -> 2。由于使用头插法,遍历顺序通常与加边顺序相反。
应用分类详解
链式前向星的本质是“高效枚举某点出边”。只要图稀疏并且需要反复遍历邻接边,就适合使用它。
一、普通图遍历
典型模式: DFS、BFS、拓扑排序、最短路。
识别信号: 需要从一个点访问所有相邻点。
核心建模: 用 head[u] 定位起点,用 next 枚举出边。
| 应用场景 | 经典题目 | 核心思路 |
|---|---|---|
| 图遍历 | 本书图遍历章节 | 枚举出边继续 DFS/BFS |
| 拓扑排序 | 本书拓扑排序章节 | 删除点时枚举出边更新入度 |
二、需要边编号的算法
典型模式: 需要找到反向边、标记某条边、处理重边。
识别信号: 出现网络流、割边、欧拉路径等算法。
核心建模: 每条边有稳定编号,可以用 i ^ 1 找配对反向边。
| 应用场景 | 经典题目 | 核心思路 |
|---|---|---|
| 最大流 | luogu-P3376 | 正反边成对存储 |
| 割边 | 本书割边章节 | 用进入边编号避免重边误判 |
经典例题
1. luogu-P3371
单源最短路模板题。稀疏图使用邻接表或链式前向星存边。
2. luogu-P3386
二分图匹配。每次 DFS 从左部点枚举所有可连右部点。
3. luogu-P3376
最大流模板。需要正向边和反向边成对存储,链式前向星很适合这种写法。