图的存储:邻接表与链式前向星
代码模板图的两种存图方式:日常首选的邻接表,和需要边编号时的链式前向星,附复杂度对比与选择指南。
一句话算法
存图先用“列表套列表”的邻接表;只有要给边发一个稳定编号时,才用链式前向星把边串成链。
问题模型
图论题通常需要频繁遍历某个点的所有出边。我们需要一种存图方式,支持:
- 加一条有向边或无向边;
- 枚举从点
出发的所有边; - (部分算法额外要求)通过边编号找到反向边、给某条边打标记。
设点数为
邻接矩阵要开
| 算法还需要什么 | 选择 |
|---|---|
| 只加边、枚举出边(DFS/BFS/最短路/拓扑) | 邻接表 |
| 边的稳定编号(网络流 |
链式前向星 |
| 邻接矩阵 | |
| 把边按权值排序整体处理(Kruskal) | 直接存边(边表) |
核心直觉
邻接表:一个点一个列表
append,遍历就用 for。没有指针、没有链表,列表本身负责扩容。
顶点编号是
1
g = [[] for _ in range(n)]
顶点编号稀疏或不连续时,把外层换成 defaultdict(list) 即可,遍历写法不变。
链式前向星:出边串成链
把每个点的出边看成一条链:
head[u] -> edge i -> edge next[i] -> edge next[next[i]] -> 空
新增边
edge[cnt].next = head[u]
head[u] = cnt
cnt++
这样加边是 next 走即可。每条边在边表里有固定下标,这个下标就是边的编号。
算法步骤
邻接表
- 建
个空列表(或一个 defaultdict(list))。 - 加有向边
就 g[u].append((v, w));无向边在两个方向各 append 一次。 - 遍历
的出边就是 for v, w in g[u]。
链式前向星
- 初始化所有
为空,表示没有出边。 - 新增边时,把边的信息写入边表末尾,新边的编号就是它在边表里的下标。
- 令新边的
next指向原来的。 - 更新
为新边的编号。 - 遍历点
时,从 开始不断跳 next。
算法证明
邻接表没有需要证明的不变量:列表里第 append 与 for 的语义由语言本身保证。
链式前向星需要证明“遍历链表恰好枚举
核心不变量:next 串起之前所有从
- 初始化时
为空,每个点的出边链为空。 - 加入新边时,新边的
next指向原链表头,所以旧边没有丢失。 - 再令
指向新边,所以新边成为链表头。 - 其他点的
不变,因此只改变了点 的出边链。
所以每条加入的边都会且只会被挂到它起点的链表中,遍历链表即可枚举所有出边。
复杂度分析
设点数为
| 操作 | 邻接表 | 链式前向星 | 邻接矩阵 |
|---|---|---|---|
| 加一条边 | |||
| 枚举 |
|||
| 查询 |
|||
| 遍历整张图 | |||
| 空间 |
- 建图时间复杂度:
(邻接表与链式前向星相同)。 - 遍历整张图的时间复杂度:
。 - 空间复杂度:
。
邻接表与链式前向星的渐进复杂度完全一样。差别在常数与能力:邻接表写法短、遍历快;链式前向星换来的是“每条边有编号”。C++ 中两者常数接近,怎么写都行;Python 中列表套列表通常更快也更短,所以 Python 只在必须用边编号时才写链式前向星。
代码实现
邻接表
两个模板都按 add_edge 加有向边,无向边再推一次,遍历直接 for;Python 版多列出编号稀疏形态、查边与末尾的 assert 自检。
1
2
3
4
5
6
7
8
9
10
11
const int maxn = 1e6 + 5;
vector<pair<int, int>> g[maxn]; // 带权:g[u] 里存 (v, w);无权就把 pair<int, int> 换成 int
void add_edge(int u, int v, int w) {
g[u].push_back({v, w});
// 加无向边,再来一次
// g[v].push_back({u, w});
}
// 遍历 u 的出边:顺序 = 加边顺序
// for (auto [v, w] : g[u]) { ... }
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
# 邻接表存图(Python):日常首选。g[u] 是 u 的出边列表,加边就是 append,遍历就是 for,没有指针和链表。
# 顶点 0..n-1 用形态一(列表套列表);顶点编号稀疏/不连续用形态二(defaultdict),遍历写法完全相同。
# 什么时候改用链式前向星(linklist.py):需要给边一个稳定编号时,例如网络流的 i ^ 1 反向边、给边打标记。
# 无向边 = 两条有向边,各 append 一次;遍历顺序 = 加边顺序(链式前向星是头插法,顺序相反)。
from collections import defaultdict
# ===== 形态一:顶点 0..n-1(最常用)=====
n = 4 # 顶点数,编号 0..n-1;按题面赋值
g = [[] for _ in range(n)] # 列表套列表:每个 g[u] 是 u 的出边列表
# 加有向边 u -> v:带权存 (v, w),无权直接存 v
g[0].append((1, 5))
g[0].append((2, 7))
g[1].append((3, 9))
# 加无向边 u - v:两个方向各存一次
g[0].append((1, 5))
g[1].append((0, 5))
# 遍历 u 的出边:顺序 = 加边顺序
for v, w in g[0]:
pass
# 查询 u -> v 是否存在:扫 g[u],O(出度);要 O(1) 查边就换邻接矩阵
has_edge = any(v == 2 for v, _ in g[0])
# ===== 形态二:顶点编号稀疏/不连续 =====
h = defaultdict(list) # 读不存在的点自动得到 [],不会 KeyError;空点不占额外判断
h[10].append((99, 1)) # 顶点编号可以是任意大整数,甚至字符串
for v, w in h[10]:
pass
# 自检:加边顺序决定遍历顺序
assert [v for v, _ in g[0]] == [1, 2, 1] # (1,5)、(2,7)、无向边 (1,5)
assert g[3] == [] # 孤立点就是一个空列表
assert any(v == 2 for v, _ in g[0]) is has_edge
链式前向星
需要边编号时使用。C++ 与 Python 是同一设计的两种写法:Python 版按 Python 习惯命名(add2 写作 add_undirected,for_each 改为迭代 out_edges,链尾哨兵 None),头插法与链结构完全一致。
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
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
# 链式前向星:head[u] 指向 u 最近加入的一条出边,边的 nxt 串起更早的边,None 表示链尾。
# 只在需要「边有稳定编号」时用它(网络流的 i ^ 1 反向边、给边打标记);日常存图用邻接表 graph_adj.py。
# edges 是动态边表,等价于 C++ 的 e[maxe] + edge_cnt,边下标就是边编号;权值 w 是 Python int,不会溢出。
from collections.abc import Iterator
from dataclasses import dataclass
@dataclass(slots=True)
class Edge:
"""一条边:起点 u、终点 v、权值 w、同起点的下一条边下标 nxt(None 表示链尾)。"""
u: int
v: int
w: int = 0
nxt: int | None = None
class LinkList:
"""reset() 后所有点都没有出边;add/add_undirected 插到对应点链表头部。"""
def __init__(self) -> None:
self.reset()
def reset(self) -> None:
"""清空整张图(对应 C++ 的 edge_cnt=0 + memset(head,-1),多测记得调用)。"""
self.edges: list[Edge] = []
# 普通 dict:读用 get,不会像 defaultdict 那样一读就插入键,孤立点不占内存
self.head: dict[int, int] = {}
def add(self, u: int, v: int, w: int = 0) -> None:
"""加有向边 u->v,O(1) 插到 u 的链表头,返回前边数加一。"""
self.edges.append(Edge(u, v, w, self.head.get(u)))
self.head[u] = len(self.edges) - 1
def add_undirected(self, u: int, v: int, w: int = 0) -> None:
"""加无向边 u<->v,等价于两条方向相反的有向边(对应 C++ 的 add2)。"""
self.add(u, v, w)
self.add(v, u, w)
def out_edges(self, u: int) -> Iterator[Edge]:
"""for edge in g.out_edges(u) 遍历 u 的出边,最近加入的边优先(头插法)。"""
i = self.head.get(u)
while i is not None:
edge = self.edges[i]
yield edge
i = edge.nxt
def adj(self, u: int) -> Iterator[int]:
"""for v in g.adj(u) 只遍历 u 的出边终点。"""
for edge in self.out_edges(u):
yield edge.v
e = LinkList()
日常写题不需要 reset 和迭代器,只要“加边 + 遍历”,把模板压到十几行更好抄。下面两种精简写法与完整版的头插法和哨兵语义完全一致,只是封装程度不同:数组版不封装,struct 版保留 e.add(u, v) 的调用形式。三个版本的遍历顺序都是加边的倒序。
链式前向星(数组精简版)
边表用平行数组(Python 用一个 (to, nxt, w) 的 tuple 列表),链尾用 add_edge(有向边)与 add_edge2(无向边,两个方向各插一条);Python 的 head 用 defaultdict(int) 顶 memset(head, 0)。
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
/**
* linklist 的简单实现
*/
const int maxn = 1e6 + 5;
// 边结构
struct Edge {
int to, next, w;
} e[maxn << 1];
int head[maxn], cnt;
void add_edge(int u, int v, int w = 1) {
e[++cnt] = {v, head[u], w};
head[u] = cnt;
}
void add_edge2(int u, int v, int w = 1) {
add_edge(u, v, w);
add_edge(v, u, w);
}
// i = 0 表示无边
for (int i = head[u]; i; i = e[i].next) {
int v = e[i].to;
// ...
}
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
# 链式前向星(raw 数组版):head[u] 指向 u 最近加入的一条出边,0 表示没有出边。
# e 是边表,下标就是边编号;e[i] = (to, nxt, w) 对应 C++ 的 e[i].to / e[i].next / e[i].w。
# 0 号是哨兵,add_edge 加有向边、add_edge2 加无向边,边从 1 号开始编号。
# 遍历 u 的出边:i = head[u],while i 时取 e[i] 的 to / w,再 i = nxt,走完 0 结束。
from collections import defaultdict
# (to, nxt, w):终点、同起点的下一条边编号、权值
type Edge = tuple[int, int, int]
# 等价于 memset(head, 0):读没出现过的点得到 0
head: defaultdict[int, int] = defaultdict(int)
# 0 号哨兵,让 head[u] == 0 能安全表示「无出边」
e: list[Edge] = [(0, 0, 0)]
def add_edge(u: int, v: int, weight: int = 1) -> None:
"""加有向边 u -> v:新边头插到 u 的链上,u == v 时也不会出错。"""
e.append((v, head[u], weight))
head[u] = len(e) - 1
def add_edge2(u: int, v: int, weight: int = 1) -> None:
"""加无向边 u - v:两个方向各插一条(对应 C++ 的 add2)。"""
add_edge(u, v, weight)
add_edge(v, u, weight)
链式前向星(struct 精简版)
把边表和 struct linkList 加全局实例 e,Python 是 LinkList 类加全局实例 e。边里多存一个起点 defaultdict(-1) 顶 memset(h, -1)),所以不需要 e.h[u] 开始,顺着 next 跳到
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
struct linkList {
struct edge {
int u, v, w, next;
};
edge e[maxe];
int h[maxn], edge_cnt;
linkList() {
edge_cnt = 0;
memset(h, -1, sizeof(h));
}
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);
}
} e;
// 如何遍历u为起点的所有边
for (int i = e.h[u]; i != -1; i = e.e[i].next) {
int v = e.e[i].v;
int w = e.e[i].w;
// do something
}
for u_, v, w, nxt in e.out_edges(u) 枚举点
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
# 链式前向星(struct 精简版):h[u] 指向 u 的第一条出边,next 串起后续边,-1 表示链尾。
# h 用 defaultdict(-1) 模拟 memset(h,-1) 的定长数组;e 是动态边表,替代 e[maxe]。
# 接口:add / add2 加边,out_edges(u) 遍历点 u 的出边,-1 表示链尾。
# e[i] = (u, v, w, nxt) 对应 C++ 的 e[i].u / e[i].v / e[i].w / e[i].next。
from collections import defaultdict
from collections.abc import Iterator
# (u, v, w, nxt):起点、终点、权值、同起点的下一条边编号
type Edge = tuple[int, int, int, int]
class LinkList:
"""构造后图为空;add 把新边插到 u 的链表头,-1 表示链尾。"""
def __init__(self) -> None:
self.e: list[Edge] = []
self.h: defaultdict[int, int] = defaultdict(lambda: -1)
def add(self, u: int, v: int, w: int = 0) -> None:
self.e.append((u, v, w, self.h[u]))
self.h[u] = len(self.e) - 1
def add2(self, u: int, v: int, w: int = 0) -> None:
self.add(u, v, w)
self.add(v, u, w)
def out_edges(self, u: int) -> Iterator[Edge]:
"""遍历点 u 的出边:for u_, v, w, nxt in e.out_edges(u),头插法,后加的边先出。"""
i = self.h[u]
while i != -1:
edge = self.e[i]
yield edge
i = edge[3] # nxt
e = LinkList()
测试用例
输入边:
1 2 5
1 3 7
2 4 9
邻接表:C++ 用 add_edge(1, 2, 5)、add_edge(1, 3, 7)、add_edge(2, 4, 9),Python 用 g[1].append((2, 5))、g[1].append((3, 7))、g[2].append((4, 9));建图后遍历点
链式前向星:如果用 add(1,2,5)、add(1,3,7)、add(2,4,9) 建图,遍历点
顺序差异
邻接表按加边顺序返回出边,链式前向星按加边的倒序返回。只依赖“枚举全部出边”的算法不受影响;依赖输入顺序逐条处理邻边的算法要注意这个差别。
应用分类详解
存图的本质是“让算法高效枚举某点的出边”。选哪种存储,看算法除了枚举出边还需要什么。
一、只需要枚举出边(日常首选邻接表)
典型模式: DFS、BFS、拓扑排序、最短路、生成树、二分图匹配。
识别信号: 题面给一张边表,算法只需要“访问
核心建模: 每个点挂一个列表,加边 append,遍历 for。
| 应用场景 | 经典题目 | 核心思路 |
|---|---|---|
| 图遍历 | 本书图遍历章节 | 枚举出边继续 DFS/BFS |
| 拓扑排序 | 本书拓扑排序章节 | 删除点时枚举出边更新入度 |
| 单源最短路 | luogu-P3371 | 每次取出 |
二、需要边的稳定编号(链式前向星)
典型模式: 网络流、割边、欧拉路径、重边标记。
识别信号: 出现网络流、割边、欧拉路径等算法;需要随时找到“这条边的反向边”或给某条边打标记。
核心建模: 每条边有稳定编号,成对加边时可以用
| 应用场景 | 经典题目 | 核心思路 |
|---|---|---|
| 最大流 | luogu-P3376 | 正反边成对存储 |
| 割边 | 本书割边章节 | 用进入边编号避免重边误判 |
三、需要 查边(邻接矩阵)
典型模式: 传递闭包、Floyd、点数不大的稠密图。
识别信号:
核心建模: 二维表
| 应用场景 | 经典题目 | 核心思路 |
|---|---|---|
| 传递闭包 | 本书 Floyd 章节 | 布尔矩阵自乘 |
| 判两点直连 | 小规模稠密图 |
经典例题
1. luogu-P3371
单源最短路模板题。稀疏图用邻接表存边即可;算法只需要枚举出边,不必用链式前向星。
2. luogu-P3386
二分图匹配。每次 DFS 从左部点枚举所有可连的右部点,邻接表存图。
3. luogu-P3376
最大流模板。需要正向边和反向边成对存储、用编号互相定位,链式前向星很适合这种写法。