图的存储:邻接表与链式前向星

代码模板

图的两种存图方式:日常首选的邻接表,和需要边编号时的链式前向星,附复杂度对比与选择指南。

一句话算法

存图先用“列表套列表”的邻接表;只有要给边发一个稳定编号时,才用链式前向星把边串成链。

问题模型

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

  • 加一条有向边或无向边;
  • 枚举从点 uu 出发的所有边;
  • (部分算法额外要求)通过边编号找到反向边、给某条边打标记。

设点数为 nn,边数为 mm,d+(u)d^+(u) 表示点 uu 的出度。目标是加边 O(1)O(1)、枚举 uu 的出边 O(d+(u))O(d^+(u))、空间 O(n+m)O(n+m)。

邻接矩阵要开 n2n^2 的表,稀疏图浪费严重;直接存边(开一个边表逐条扫)枚举出边要 O(m)O(m)。所以大多数图用邻接表:每个点挂一个列表,正好满足前两条要求。只有当算法还要第三条——边有稳定编号——时,才升级到链式前向星。

算法还需要什么 选择
只加边、枚举出边(DFS/BFS/最短路/拓扑) 邻接表
边的稳定编号(网络流 i⊕1i \oplus 1 反向边、给边打标记) 链式前向星
O(1)O(1) 查询 u→vu \to v 是否存在,且点数不大 邻接矩阵
把边按权值排序整体处理(Kruskal) 直接存边(边表)

核心直觉

邻接表:一个点一个列表

g[u]g[u] 就是点 uu 的出边列表:加边就往列表里 append,遍历就用 for。没有指针、没有链表,列表本身负责扩容。

顶点编号是 0…n−10 \ldots n-1 时用列表套列表:

python
        
1
g = [[] for _ in range(n)]

顶点编号稀疏或不连续时,把外层换成 defaultdict(list) 即可,遍历写法不变。

链式前向星:出边串成链

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

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

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

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

这样加边是 O(1)O(1),遍历 uu 的所有出边时沿着 next 走即可。每条边在边表里有固定下标,这个下标就是边的编号。

算法步骤

邻接表

  1. 建 nn 个空列表(或一个 defaultdict(list))。
  2. 加有向边 (u,v,w)(u,v,w) 就 g[u].append((v, w));无向边在两个方向各 append 一次。
  3. 遍历 uu 的出边就是 for v, w in g[u]。

链式前向星

  1. 初始化所有 head[u]head[u] 为空,表示没有出边。
  2. 新增边时,把边的信息写入边表末尾,新边的编号就是它在边表里的下标。
  3. 令新边的 next 指向原来的 head[u]head[u]。
  4. 更新 head[u]head[u] 为新边的编号。
  5. 遍历点 uu 时,从 head[u]head[u] 开始不断跳 next。

算法证明

邻接表没有需要证明的不变量:列表里第 ii 个元素就是第 ii 条加入的边,append 与 for 的语义由语言本身保证。

链式前向星需要证明“遍历链表恰好枚举 uu 的所有出边”。

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

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

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

复杂度分析

设点数为 nn,边数为 mm,d+(u)d^+(u) 为点 uu 的出度。

操作 邻接表 链式前向星 邻接矩阵
加一条边 O(1)O(1) O(1)O(1) O(1)O(1)
枚举 uu 的出边 O(d+(u))O(d^+(u)) O(d+(u))O(d^+(u)) O(n)O(n)
查询 u→vu \to v 是否存在 O(d+(u))O(d^+(u)) O(d+(u))O(d^+(u)) O(1)O(1)
遍历整张图 O(n+m)O(n+m) O(n+m)O(n+m) O(n2)O(n^2)
空间 O(n+m)O(n+m) O(n+m)O(n+m) O(n2)O(n^2)
  • 建图时间复杂度:O(m)O(m)(邻接表与链式前向星相同)。
  • 遍历整张图的时间复杂度:O(n+m)O(n+m)。
  • 空间复杂度:O(n+m)O(n+m)。

邻接表与链式前向星的渐进复杂度完全一样。差别在常数与能力:邻接表写法短、遍历快;链式前向星换来的是“每条边有编号”。C++ 中两者常数接近,怎么写都行;Python 中列表套列表通常更快也更短,所以 Python 只在必须用边编号时才写链式前向星。

代码实现

邻接表

两个模板都按 0…n−10 \ldots n-1 编号写。C++ 版给出最短的数组版:add_edge 加有向边,无向边再推一次,遍历直接 for;Python 版多列出编号稀疏形态、查边与末尾的 assert 自检。

C++
cpp
        
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]) { ... }
Python
python
        
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,链尾哨兵 −1-1 写作 None),头插法与链结构完全一致。

C++
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;
Python
python
        
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 列表),链尾用 00 做哨兵,所以 00 号边留空、边从 11 号开始编号。两语言都拆成 add_edge(有向边)与 add_edge2(无向边,两个方向各插一条);Python 的 head 用 defaultdict(int) 顶 memset(head, 0)。

C++
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
/** * 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; // ... }
Python
python
        
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 精简版)

把边表和 headhead 包成结构体:C++ 是 struct linkList 加全局实例 e,Python 是 LinkList 类加全局实例 e。边里多存一个起点 uu,链尾用 −1-1(Python 用 defaultdict(-1) 顶 memset(h, -1)),所以不需要 00 号哨兵。遍历从 e.h[u] 开始,顺着 next 跳到 −1-1 为止。

C++
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
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 }
Python

for u_, v, w, nxt in e.out_edges(u) 枚举点 uu 的每条出边。

python
        
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));建图后遍历点 11 的出边都得到 1→21 \to 2 和 1→31 \to 3,顺序与加边顺序相同。

链式前向星:如果用 add(1,2,5)、add(1,3,7)、add(2,4,9) 建图,遍历点 11 的出边得到 1→31 \to 3 和 1→21 \to 2。由于使用头插法,遍历顺序与加边顺序相反。

顺序差异

邻接表按加边顺序返回出边,链式前向星按加边的倒序返回。只依赖“枚举全部出边”的算法不受影响;依赖输入顺序逐条处理邻边的算法要注意这个差别。

应用分类详解

存图的本质是“让算法高效枚举某点的出边”。选哪种存储,看算法除了枚举出边还需要什么。

一、只需要枚举出边(日常首选邻接表)

典型模式: DFS、BFS、拓扑排序、最短路、生成树、二分图匹配。

识别信号: 题面给一张边表,算法只需要“访问 uu 的每个邻居”。

核心建模: 每个点挂一个列表,加边 append,遍历 for。

应用场景 经典题目 核心思路
图遍历 本书图遍历章节 枚举出边继续 DFS/BFS
拓扑排序 本书拓扑排序章节 删除点时枚举出边更新入度
单源最短路 luogu-P3371 每次取出 uu 后枚举出边松弛

二、需要边的稳定编号(链式前向星)

典型模式: 网络流、割边、欧拉路径、重边标记。

识别信号: 出现网络流、割边、欧拉路径等算法;需要随时找到“这条边的反向边”或给某条边打标记。

核心建模: 每条边有稳定编号,成对加边时可以用 i⊕1i \oplus 1 找配对反向边。

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

三、需要 O(1)O(1) 查边(邻接矩阵)

典型模式: 传递闭包、Floyd、点数不大的稠密图。

识别信号: n2n^2 的空间可以接受,且频繁查询“uu 到 vv 有没有边”。

核心建模: 二维表 adj[u][v]adj[u][v] 直接存边的有无或权值。

应用场景 经典题目 核心思路
传递闭包 本书 Floyd 章节 布尔矩阵自乘
判两点直连 小规模稠密图 O(1)O(1) 查询

经典例题

1. luogu-P3371

单源最短路模板题。稀疏图用邻接表存边即可;算法只需要枚举出边,不必用链式前向星。

2. luogu-P3386

二分图匹配。每次 DFS 从左部点枚举所有可连的右部点,邻接表存图。

3. luogu-P3376

最大流模板。需要正向边和反向边成对存储、用编号互相定位,链式前向星很适合这种写法。