图这种数据结构,说它是所有高级算法和系统设计的“地基”一点都不夸张。我见过太多人刷了几百道题,一遇到需要建模实体关系的场景还是发懵,根子往往就出在图的存储方式没吃透。邻接表(链式存储)这个标题,乍看只是教材里一个不起眼的小节,实际上它是从“能存下图”到“能高效跑图算法”之间最关键的一道分水岭。这篇文章我想把它彻底掰开揉碎:它到底解决什么问题、链式存储的指针到底怎么串、代码怎么写才不踩坑、什么场景该用它而不是邻接矩阵。不管你是刚学数据结构的学生,还是工作几年后回头补基础的开发者,只要你想把图真正用起来,这篇内容都值得你从头看到尾。我会尽量用大白话加可复现的代码,把那些教材里一笔带过的细节补全。
1. 为什么图的存储方式决定了你后面所有的操作
很多人学图的时候,注意力全在 BFS、DFS、最短路这些算法上,觉得存储结构只是个“容器”,随便选一个就行。这个想法在实际项目里会吃大亏。图的存储方式直接决定了三件事:空间开销、边查询的效率、以及遍历时的缓存友好度。你选错了存储,算法写得再漂亮,跑起来也是又慢又占内存。
1.1 邻接矩阵的“富贵病”与邻接表的出场理由
先说说邻接矩阵,它是用二维数组matrix[i][j]表示顶点 i 到 j 有没有边、权重多少。优点是直观、判断两点是否相邻是 O(1)。但它有个致命问题:空间复杂度是 O(V²),V 是顶点数。假设你要处理一个一万个顶点的社交关系图,邻接矩阵就是 1 亿个格子,哪怕每个格子只占一个字节,也是 100MB 起步,而实际存在的边可能只有几万条。这就是典型的“稀疏图用稠密结构存”,大量空间浪费在表示“没有边”上。
邻接表就是针对这个痛点设计的。它的核心思想是:只为实际存在的边分配空间。每个顶点挂一条链表,链表里只存这个顶点真正连出去的邻居。空间复杂度降到 O(V + E),E 是边数。对于稀疏图(E 远小于 V²),这个差距是数量级的。我做过一个粗略的对比测试,一万个顶点、五万条边的图,邻接矩阵占约 100MB,邻接表(链式)只占几 MB,差距非常直观。
注意:邻接表不是“一种”结构,而是一类结构。数组套链表、数组套动态数组(vector)、链式前向星,都算邻接表家族。标题里的“链式存储”特指用指针把边节点串起来的那种,也就是最经典、最能体现链表思想的实现。
1.2 链式存储到底“链”的是什么
这里必须把概念讲透,否则代码写出来自己都看不懂。邻接表的链式存储由两部分组成:顶点数组和边节点链表。顶点数组的每个元素代表一个顶点,里面存两样东西——顶点信息(比如编号、名字)和一个指向“第一条边”的指针。这个指针指向的边节点,记录的是“这个顶点连到了谁”,同时它自己还有一个next指针,指向“下一条边”。
打个生活化的比方:顶点数组就像一栋楼的住户名单,每个住户门口挂着一个信箱(头指针)。信箱里第一封信写着“我认识 3 号楼的老王”,信背面还夹着一张纸条指向下一封信“我还认识 5 号楼的老李”。你要找某个住户认识的所有人,就顺着信箱里的信一封封往下翻。这个“信+纸条”的结构,就是边节点加 next 指针。
这种设计的好处是插入边非常快:新认识一个人,直接把新信塞到信箱最前面,O(1) 搞定,不用挪动任何已有数据。这也是链式存储相比数组存储的一大优势——头插法插入边无需扩容、无需搬移。
1.3 有向图、无向图、带权图在链式结构下的差异
同一个链式邻接表,处理不同类型的图时细节不一样,这点教材经常含糊带过,我强调一下:
- 无向图:一条边 (u, v) 要在 u 的链表里加一个 v 节点,同时在 v 的链表里加一个 u 节点。也就是一条边存两次,边节点总数是 2E。
- 有向图:边 u→v 只在 u 的链表里加 v 节点,边节点总数是 E。如果算法需要反向遍历(比如求入度、反向搜索),通常还要额外建一个逆邻接表。
- 带权图:边节点里要多存一个
weight字段。这个字段在无权图里可以省略,但为了代码统一,很多人干脆都留着,代价是每个节点多几个字节,一般可以接受。
这个差异看起来是小事,但它直接影响你后面统计边数、计算度的逻辑。比如无向图里,顶点 u 的链表长度就是它的度;有向图里,链表长度是出度,入度得靠遍历全表或者额外维护。
2. 链式邻接表的核心结构与代码落地
概念讲完,进入真正能跑的部分。我用 C 语言来演示,因为链式存储的指针操作在 C 里最“裸”,最能看清本质。你用 Java、Python 也能实现,但那些语言把指针包装成了引用,反而容易让人忽略底层到底在干什么。
2.1 两个核心结构体的设计取舍
链式邻接表需要两个结构体:边节点和顶点节点。先看边节点:
typedef struct EdgeNode { int adjvex; // 邻接点在顶点数组中的下标 int weight; // 权值,无权图可忽略 struct EdgeNode *next; // 指向下一条边的指针 } EdgeNode;再看顶点节点和整个图的结构:
#define MAXVEX 100 // 顶点数上限 typedef struct VertexNode { char data; // 顶点信息,这里用字符示意 EdgeNode *firstedge; // 指向第一条边 } VertexNode; typedef struct { VertexNode adjList[MAXVEX]; // 顶点数组 int numVertexes, numEdges; // 当前顶点数和边数 } GraphAdjList;这里有几个设计决策值得说清楚。第一,adjvex存的是下标而不是顶点值,因为下标访问数组是 O(1),而且比较两个顶点是否相同只需比较整数,比比较字符串快得多。第二,顶点数组用固定大小MAXVEX,这是教学实现的简化;工程里更常见的是动态分配或者用vector,避免上限写死。第三,firstedge初始必须置为NULL,这是链式结构最容易漏的一步,漏了就是野指针,后面遍历直接崩。
提示:如果你用 C++,顶点数组可以直接用
std::vector<VertexNode>,边节点用new分配;如果用 Python,边节点可以用类或者干脆用列表模拟,但那样就失去“链式”的意义了,理解原理时还是建议手写一遍指针版本。
2.2 头插法建边:为什么插入是 O(1)
建图的核心操作是“给顶点 u 加一条到 v 的边”。链式存储用头插法,代码只有三行:
void addEdge(GraphAdjList *G, int u, int v, int w) { EdgeNode *e = (EdgeNode *)malloc(sizeof(EdgeNode)); e->adjvex = v; e->weight = w; e->next = G->adjList[u].firstedge; // 新节点指向原头 G->adjList[u].firstedge = e; // 头指针指向新节点 }为什么是 O(1)?因为不管链表多长,我们只动头指针和新节点的 next,不涉及任何遍历。这跟数组存储“插入要挪后面所有元素”形成鲜明对比。头插法还有个副作用:链表里边的顺序和插入顺序相反。这在大多数算法里无所谓,但如果你需要按特定顺序遍历邻居(比如按顶点编号从小到大),头插法就不合适了,得改成尾插或者建完再排序。我个人的习惯是,除非有明确顺序要求,一律用头插,简单快速。
无向图建边时,调用两次即可:
addEdge(G, u, v, w); addEdge(G, v, u, w);这里有个坑:如果是有自环(u 到 u 的边),无向图里调两次会存两个自环节点,逻辑上应该只存一个。实际项目里自环少见,但写通用库时得判断一下u != v。
2.3 内存释放:链式结构绕不开的收尾工作
链式存储用malloc分配节点,用完必须free,否则内存泄漏。释放逻辑是遍历每个顶点的链表,逐个释放:
void destroyGraph(GraphAdjList *G) { for (int i = 0; i < G->numVertexes; i++) { EdgeNode *p = G->adjList[i].firstedge; while (p != NULL) { EdgeNode *tmp = p; p = p->next; free(tmp); } G->adjList[i].firstedge = NULL; } }这段代码的关键是先保存 next 再 free 当前节点,顺序反了就是释放后使用(use-after-free),程序行为不可预测。这个细节我在早期项目里栽过,调试了半天才发现是释放顺序写反了。用带垃圾回收的语言(Java、Python、Go)可以省掉这一步,但理解手动释放的过程对建立内存直觉很有帮助。
3. 基于链式邻接表的遍历与度计算实操
结构建好了,得验证它能不能正确工作。最直接的验证方式就是遍历和算度。这部分我会给出完整可运行的代码片段和预期输出,你可以直接抄去跑。
3.1 打印邻接表:一眼看穿指针串得对不对
调试链式结构,第一件事就是把它打印出来看。下面这个函数遍历每个顶点,输出它所有的邻居:
void printGraph(GraphAdjList *G) { for (int i = 0; i < G->numVertexes; i++) { printf("顶点 %c(%d): ", G->adjList[i].data, i); EdgeNode *p = G->adjList[i].firstedge; while (p != NULL) { printf("-> %d(w=%d) ", p->adjvex, p->weight); p = p->next; } printf("\n"); } }假设我们建一个 4 个顶点、5 条边的无向带权图,边为 (0,1,2)、(0,2,3)、(1,2,1)、(1,3,4)、(2,3,5),跑出来的输出大概长这样:
顶点 A(0): -> 2(w=3) -> 1(w=2) 顶点 B(1): -> 3(w=4) -> 2(w=1) -> 0(w=2) 顶点 C(2): -> 3(w=5) -> 1(w=1) -> 0(w=3) 顶点 D(3): -> 2(w=5) -> 1(w=4)注意每个顶点的邻居顺序是插入顺序的逆序,这正是头插法的特征。如果你看到某个顶点后面是空的,或者指针指向了奇怪的下标,基本就是建边时firstedge没初始化或者next串错了。这个打印函数是我每次写完建图代码必跑的“冒烟测试”,比任何断点都直观。
3.2 计算顶点的度:无向图与有向图的区别
无向图里,顶点 u 的度就是它链表长度,数一下就行:
int degree(GraphAdjList *G, int u) { int cnt = 0; EdgeNode *p = G->adjList[u].firstedge; while (p != NULL) { cnt++; p = p->next; } return cnt; }有向图就复杂了。链表长度只是出度,入度需要遍历所有顶点的链表,统计有多少条边指向 u:
int inDegree(GraphAdjList *G, int u) { int cnt = 0; for (int i = 0; i < G->numVertexes; i++) { EdgeNode *p = G->adjList[i].firstedge; while (p != NULL) { if (p->adjvex == u) cnt++; p = p->next; } } return cnt; }这个操作是 O(V + E),每次求入度都这么算显然不划算。所以工程里如果频繁需要入度,通常会额外维护一个入度数组,建边时顺手indegree[v]++,把查询降到 O(1)。这就是“用空间换时间”的典型应用,也是链式邻接表的一个常见优化点。拓扑排序(Kahn 算法)就依赖这个入度数组,否则效率会很难看。
3.3 完整建图与验证流程
把上面的片段串起来,一个最小可运行流程是这样的:
int main() { GraphAdjList G; G.numVertexes = 4; G.numEdges = 0; char names[] = {'A', 'B', 'C', 'D'}; for (int i = 0; i < 4; i++) { G.adjList[i].data = names[i]; G.adjList[i].firstedge = NULL; // 关键:必须初始化 } int edges[5][3] = {{0,1,2},{0,2,3},{1,2,1},{1,3,4},{2,3,5}}; for (int i = 0; i < 5; i++) { addEdge(&G, edges[i][0], edges[i][1], edges[i][2]); addEdge(&G, edges[i][1], edges[i][0], edges[i][2]); G.numEdges++; } printGraph(&G); printf("顶点 1 的度: %d\n", degree(&G, 1)); destroyGraph(&G); return 0; }跑通这段,你就拥有了一个功能完整的链式邻接表。我建议你亲手敲一遍,尤其是firstedge = NULL那行,别复制粘贴,自己写一遍印象深得多。实测下来,新手 80% 的段错误都出在忘了初始化头指针。
4. 链式邻接表的高频踩坑与排查实录
理论再清楚,实操照样会翻车。这一节我把这些年遇到的和见过的典型问题整理出来,做成速查表,你遇到问题时可以直接对照。
4.1 段错误、野指针与内存泄漏的排查思路
链式结构最让人头疼的就是指针问题。下面这张表覆盖了最常见的几类故障:
| 现象 | 最可能原因 | 排查方法 | 修复方式 |
|---|---|---|---|
| 程序一运行就段错误 | firstedge未初始化为 NULL | 在建图前打印每个头指针 | 初始化循环里统一置 NULL |
| 遍历时崩溃在某个 next | 边节点next未正确赋值 | 检查 addEdge 里 next 赋值顺序 | 先e->next = 原头再更新头 |
| 内存持续增长 | 未调用 destroyGraph | 用内存检测工具观察 | 程序结束前遍历释放 |
| 释放时崩溃 | 先 free 再取 next | 检查释放循环顺序 | 先存 next 再 free 当前 |
| 邻居数量不对 | 无向图只加了一次边 | 统计边节点总数是否为 2E | 无向图调用两次 addEdge |
这里重点说“先存 next 再 free”这个坑。很多人写释放循环时顺手写成free(p); p = p->next;,看起来顺理成章,实际上free(p)之后p->next已经是非法访问了,运气好读到垃圾值,运气差直接崩。正确写法一定是tmp = p; p = p->next; free(tmp);。这个顺序问题我在代码 review 里见过不下十次,属于链式结构的“经典陷阱”。
4.2 头插导致顺序颠倒引发的算法 bug
头插法让邻居顺序反转,大多数时候无害,但在某些算法里会出问题。比如你实现 DFS 时如果期望按顶点编号从小到大访问,头插法给你的却是从大到小,输出结果和预期对不上,你会以为是 DFS 写错了,其实是建图顺序的锅。解决办法有两个:一是建图时改用尾插(需要维护尾指针,稍麻烦);二是建完图后对每个链表做一次排序(O(E log E) 级别)。我的建议是,如果算法对邻居访问顺序敏感,就在建图阶段用尾插,别等到算法层再补救,那样逻辑会更乱。
尾插的实现需要给每个顶点额外维护一个尾指针,或者每次插入时遍历到链表末尾(O(度))。前者空间换时间,后者时间换空间,按需选择。对于顶点度数普遍不大的图,直接遍历到尾插也够用。
4.3 大规模图下的性能观察与优化方向
链式邻接表虽然空间省,但它的缓存局部性差。边节点是分散在堆上的,遍历时指针跳来跳去,CPU 缓存命中率低。当图规模很大(比如百万级边)时,这个劣势会显现出来。我做过对比,同样规模的稀疏图,链式邻接表遍历比“数组套连续数组”(CSR 格式)慢不少,差距主要来自缓存未命中。
如果你的场景是超大规模图且以遍历为主,可以考虑链式前向星:用一个大数组存所有边,每个边节点记录to、weight、next(这里的 next 是数组下标而非指针),头指针也用数组。这样所有数据连续存放,缓存友好,而且省去了大量 malloc 开销。链式前向星本质上是链式邻接表的“数组化”版本,思想完全一致,只是把指针换成了下标。竞赛圈里几乎清一色用它,就是因为这个性能优势。
提示:链式前向星的头指针数组通常初始化为 -1,用 -1 表示“没有下一条边”,遍历时判断
head[u] != -1。这个 -1 哨兵值是个约定,别用 0,因为 0 是合法的边下标。
5. 什么场景该用链式邻接表:选型判断与横向对比
学到最后,最重要的能力是“知道什么时候用它”。链式邻接表不是万能的,它有明确的适用边界。这一节我把选型逻辑讲清楚,帮你建立判断标准。
5.1 稀疏图、频繁插边、内存敏感三大典型场景
链式邻接表最适合以下三类场景:
- 稀疏图:边数远小于顶点数平方。社交网络、网页链接图、道路网络都是典型稀疏图,用邻接表空间优势巨大。
- 频繁动态插边:图结构需要不断加边(比如增量构建关系网络),链式头插 O(1),不用像数组那样扩容搬移。
- 内存敏感环境:嵌入式、移动端等内存受限场景,邻接表的 O(V+E) 空间比邻接矩阵的 O(V²) 友好得多。
反过来,如果你的图是稠密图(边数接近 V²),或者你需要频繁判断两点是否直接相连(O(1) 查询),那邻接矩阵反而更合适。选型没有绝对优劣,只有匹配与否。
5.2 邻接矩阵、邻接表、链式前向星横向对比
把三种常见存储放在一起对比,判断标准一目了然:
| 维度 | 邻接矩阵 | 链式邻接表 | 链式前向星 |
|---|---|---|---|
| 空间复杂度 | O(V²) | O(V+E) | O(V+E) |
| 判断两点相邻 | O(1) | O(度) | O(度) |
| 插入一条边 | O(1) | O(1) | O(1) |
| 遍历某点邻居 | O(V) | O(度) | O(度) |
| 缓存友好度 | 高 | 低 | 高 |
| 实现复杂度 | 低 | 中 | 中 |
| 适用图类型 | 稠密图 | 稀疏图 | 大规模稀疏图 |
这张表我建议你记在心里。实际做技术选型时,先问自己三个问题:图稀疏还是稠密?查询模式是“判断相邻”多还是“遍历邻居”多?规模大到需要考虑缓存吗?三个问题答完,选型基本就定了。
5.3 从链式邻接表延伸出去的学习路径
把链式邻接表吃透之后,你会发现它是一把钥匙,能打开很多后续内容。往算法方向走,BFS、DFS、拓扑排序、Dijkstra、Prim、Kruskal 全都建立在图存储之上,存储结构选对了,这些算法的实现会顺畅很多。往工程方向走,链式前向星、CSR 格式、图数据库的底层索引,本质上都是邻接表思想的变体和优化。
我个人的学习体会是,手写一遍链式邻接表的增删改查和释放,比看十篇讲解都管用。指针这东西,看别人写觉得简单,自己写才知道哪里会漏。等你能够不看参考、从零写出一个带权无向图的链式邻接表并正确释放内存,图论这块的地基就算真正打牢了。后面再学什么算法,都只是在这个地基上盖楼而已。