资讯详情

邻接表链式存储:从原理到代码,彻底掌握图的存储方式

📅 2026/10/30 3:21:43 | 华诺云谱 👁 阅读
邻接表链式存储:从原理到代码,彻底掌握图的存储方式
图这种数据结构说它是所有高级算法和系统设计的“地基”一点都不夸张。我见过太多人刷了几百道题一遇到需要建模实体关系的场景还是发懵根子往往就出在图的存储方式没吃透。邻接表链式存储这个标题乍看只是教材里一个不起眼的小节实际上它是从“能存下图”到“能高效跑图算法”之间最关键的一道分水岭。这篇文章我想把它彻底掰开揉碎它到底解决什么问题、链式存储的指针到底怎么串、代码怎么写才不踩坑、什么场景该用它而不是邻接矩阵。不管你是刚学数据结构的学生还是工作几年后回头补基础的开发者只要你想把图真正用起来这篇内容都值得你从头看到尾。我会尽量用大白话加可复现的代码把那些教材里一笔带过的细节补全。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::vectorVertexNode边节点用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(w3) - 1(w2) 顶点 B(1): - 3(w4) - 2(w1) - 0(w2) 顶点 C(2): - 3(w5) - 1(w1) - 0(w3) 顶点 D(3): - 2(w5) - 1(w4)注意每个顶点的邻居顺序是插入顺序的逆序这正是头插法的特征。如果你看到某个顶点后面是空的或者指针指向了奇怪的下标基本就是建边时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; }有向图就复杂了。链表长度只是出度入度需要遍历所有顶点的链表统计有多少条边指向 uint 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(VE) 空间比邻接矩阵的 O(V²) 友好得多。反过来如果你的图是稠密图边数接近 V²或者你需要频繁判断两点是否直接相连O(1) 查询那邻接矩阵反而更合适。选型没有绝对优劣只有匹配与否。5.2 邻接矩阵、邻接表、链式前向星横向对比把三种常见存储放在一起对比判断标准一目了然维度邻接矩阵链式邻接表链式前向星空间复杂度O(V²)O(VE)O(VE)判断两点相邻O(1)O(度)O(度)插入一条边O(1)O(1)O(1)遍历某点邻居O(V)O(度)O(度)缓存友好度高低高实现复杂度低中中适用图类型稠密图稀疏图大规模稀疏图这张表我建议你记在心里。实际做技术选型时先问自己三个问题图稀疏还是稠密查询模式是“判断相邻”多还是“遍历邻居”多规模大到需要考虑缓存吗三个问题答完选型基本就定了。5.3 从链式邻接表延伸出去的学习路径把链式邻接表吃透之后你会发现它是一把钥匙能打开很多后续内容。往算法方向走BFS、DFS、拓扑排序、Dijkstra、Prim、Kruskal 全都建立在图存储之上存储结构选对了这些算法的实现会顺畅很多。往工程方向走链式前向星、CSR 格式、图数据库的底层索引本质上都是邻接表思想的变体和优化。我个人的学习体会是手写一遍链式邻接表的增删改查和释放比看十篇讲解都管用。指针这东西看别人写觉得简单自己写才知道哪里会漏。等你能够不看参考、从零写出一个带权无向图的链式邻接表并正确释放内存图论这块的地基就算真正打牢了。后面再学什么算法都只是在这个地基上盖楼而已。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑