深入理解链式前向星:数组模拟邻接表的原理、实现与应用
发布时间:2026/10/3 0:25:21 作者:尧图编辑部 阅读量:1,286

链式前向星这个名词在很多初学者眼里属于那种“听说过但一直没搞懂”的存在尤其是刷LeetCode、备战算法竞赛、考研数据结构复习的时候总是绕不开它。我第一次接触这个结构是在做图论题被vector邻接表反复超时之后才真正下决心把它啃下来。这一篇我尽量把链式前向星的原理、实现、应用和坑一次讲清楚让它成为你顺手就能用的工具而不是只停留在收藏夹里的名词。这个内容适合谁正在准备算法竞赛的选手、考研或保研面试需要扎实数据结构基础的同学以及工作中需要用C/Java等语言实现高性能图算法的人。链式前向星解决的核心问题其实很简单在稀疏图中如何用内存更紧凑、常数更小的方式存储图的邻接关系同时保证遍历邻接边的高效。它本质上是一种用数组模拟链表实现邻接表的方法但比vector套vector的实现更可控比邻接矩阵内存占用小得多在网络流、最短路、树形DP等场景里都有非常广泛的应用。1. 链式前向星的本质用数组模拟“带索引的邻接链表”1.1 从图的存储需求说起先想想我们存储一张有向图或无向图时到底需要什么对每个顶点u要知道它和哪些顶点相连以及每条边的权重如果有的话。最简单的邻接矩阵用一个n乘n的二维数组存查询两个点是否有边是O(1)但空间是O(n^2)。当n达到10^5边数只有2*10^5时邻接矩阵就是灾难光初始化一个10^5乘10^5的二维数组就够你内存爆掉。于是大家自然转向邻接表对每个顶点u维护一个列表里面存从u出发的所有边。这个列表在C里最容易想到的实现就是vector adj[N]需要存权重时再用vectorpairint,int或者定义结构体。vector实现简单、思维负担小所以很多人入门图论时都用它。可问题在于vector不是为高频插入和遍历而生的每次push_back可能触发内存重新分配涉及拷贝和搬移遍历时虽然是连续的但多个vector分布在不同内存块里整体缓存命中率不一定好。链式前向星做的事情就是把“每个顶点的邻接边列表”用一条逻辑上的链表串起来而且整条链表的所有结点都放在几个全局数组里。它不依赖动态分配不依赖vector的实现细节而是自己管理“下一个结点是谁”。理解它之后你会发现它其实就是手写了一个非常轻量的邻接表。1.2 链式前向星的三个核心数组链式前向星通常维护三个数组或再加一个权重数组head[u]表示从顶点u出发的第一条边在边数组中的下标也可以理解为链表头指针。to[i]第i条边指向的终点顶点。nxt[i]第i条边的下一条边在下标数组中的位置即链表的后继指针。w[i]第i条边的权值若需要。这里数组的下标i是边的编号每条边的编号在添加时被确定。添加一条边时采用头插法新的边结点插入到顶点u的链表头部。也就是说void addEdge(int u, int v, int w) { to[cnt] v; // 边的终点 weight[cnt] w; // 边的权值 nxt[cnt] head[u]; // 新边的下一条边指向原来u的第一条边 head[u] cnt; // 更新u的链表头为新边 }看到这个操作是不是觉得特别像单链表的头插法对它就是单链表。区别在于普通单链表的结点是动态new出来的要用指针连接链式前向星用数组下标指向下一个结点所以它的“指针”是一个int。这也是为什么它常被称为“静态邻接表”。用一个生活化的类比来理解假设每个顶点是一个抽屉head[u]是抽屉里放的那张索引卡片卡片上写着“第一条边的编号”。每条边的信息终点、权值、下一条边的编号登记在账本的某一行。头插法相当于每次来了新信息先在账本上记一行然后更新抽屉里的卡片让卡片指向最新一行同时在新一行里写上“上一行是哪一行”。这样从任一条边出发都能沿着“下一条边”的指引把整个抽屉里的边全部翻出来。1.3 为什么它能提升性能连续性与可控性链式前向星的核心优势之一是所有边都存放在连续的数组里。用vector存邻接表时虽然单个顶点的边是连续存储的但不同顶点的边分散在不同vector中而链式前前星把所有边统一放在几个数组里更像是一块连续的内存被不同链表分块占用。对CPU缓存来说连续数组的遍历比其他零散分配的对象友好很多尤其当你的算法需要反复遍历某个顶点的所有邻边时这种友好会被放大。另一层优势是“可控”。vector的扩容时机由标准库决定在竞赛这种对时间极其敏感的场景里你不知道某次push_back会触发多大代价的重新分配。链式前向星则不同只要你提前算好最大边数开一个定长数组就完全避免动态分配所有内存一次到位。再加上它的“指针”只是int下标比64位指针小一半同样存10万条边存储开销更低。这些优势叠加起来在图规模比较大、算法常数要求高的题里可能就是压线通过和超时的区别。2. 核心实现与每一步的原理拆解2.1 一张图看懂添加和遍历先看一个具体例子我们手动模拟一下向图中添加几条边的过程这样可以建立非常直观的印象。假设有一张有向图顶点1到22到31到3的边按这个顺序添加。初始时head[1]-1head[2]-1head[3]-1cnt0这里我用-1表示空链表。添加边1-2cnt变成1to[1]2nxt[1]head[1]-1head[1]1。这时的链表状态head[1]指向边1边1的next为-1。添加边2-3cnt变成2to[2]3nxt[2]head[2]-1head[2]2。添加边1-3cnt变成3to[3]3nxt[3]head[1]1head[1]3。现在如果遍历顶点1的所有出边i head[1] 3访问to[3]3这条路i nxt[3] 1访问to[1]2这条路i nxt[1] -1遍历结束。看到没有遍历顺序是倒过来的先添加的边1-2反而后被访问到。这是头插法的天然特性大多数情况下顺序不影响正确性但如果你依赖边的访问顺序要记得这一点。这也是很多新手写代码时一时反应不过来的地方。遍历代码很简单for (int i head[u]; i ! -1; i nxt[i]) { int v to[i]; // 当前边指向的顶点 int w weight[i]; // 当前边的权重 // 在这里处理业务逻辑 }这段代码非常“机械”可以说是链式前向星的固定写法背下来就行。关键是要理解为什么i nxt[i]能走到下一条边以及为什么循环停止条件是i -1。2.2 头插法与数组下标管理细则添加边的函数里有一个极其关键的细节cnt到底从0开始还是从1开始以及用cnt还是cnt这直接影响后面很多问题的处理。我习惯用以下的写法const int N 100005; const int M 2 * 200005; // 无向图注意开两倍边数 int head[N], to[M], nxt[M], weight[M]; int cnt 0; inline void add(int u, int v, int w) { to[cnt] v; weight[cnt] w; nxt[cnt] head[u]; head[u] cnt; }这里cnt从0开始每次先再使用所以边的编号从1开始。这样有一个隐藏好处如果某道题里你在某个数组位置存了一个哨兵值0号位置不会被实际边占用便于记忆和检查。当然也可以让cnt从0开始使用0号位置这样编号从0开始不过程序员之间的习惯差异很大重要的是你选一种并坚持一致。初始化时head数组全部置为-1memset(head, -1, sizeof(head));有人会问为什么不用0表示空链表用0也行但需要把cnt初始化为0且add时用cnt而不是cnt并且还要额外设置0号位置的值。如果你用-1表示空代码里所有遍历的结束条件都写成i ! -1语义非常明确。我建议新手一开始就用-1减少边界条件混乱。2.3 有向图、无向图和反向边问题链式前向星处理有向图就是直接add(u, v, w)就完事。无向图则要添加两次add(u, v, w)和add(v, u, w)分别表示两条方向相反的边。这里有一个很多竞赛选手爱用的经典技巧如果添加无向边时先add(u,v,w)再add(v,u,w)那么这两条边的编号分别为cnt1和cnt2并且满足关系1号边和2号边互为反向边3号边和4号边互为反向边……更一般地说编号i和i^1是互为反向边。这个性质在处理网络流算法时需要快速找反向边极其有用。void addUndirected(int u, int v, int w) { add(u, v, w); add(v, u, w); } // 若需要访问某条边i的反向边直接访问 i ^ 1 即可这个“异或1”的技巧是链式前向星在网络流题里的杀手锏之一。用vector邻接表时你得在结构体里存rev字段或者费劲维护反向边的位置而链式前向星天然具备这个性质。所以如果你准备学Dinic、SAP这类算法链式前向星几乎是必学的前置内容。3. 链式前向星与主流存图方式的横向对比3.1 三种存图方式的核心参数对比为了更直观地看出区别我平时做题时会用这个表来做决策存储方式空间复杂度判断两点是否相邻遍历某顶点所有邻边适用场景邻接矩阵O(n^2)O(1)O(n)n很小通常1000以内需要考虑稠密图、需要频繁判断两点是否相邻vector邻接表O(nm)需要遍历列表最坏O(deg)O(deg)常数较大常规图论题、实现简单对性能不极致要求链式前向星O(nm)常数小需要遍历列表同左O(deg)常数小数组连续大规模稀疏图、网络流、对时间和内存限制严格的竞赛题从工程上的直观体感来说n10^5、m2*10^5级别的时候链式前向星遍历邻接边的总时间大概是vector邻接表的60%-80%。这个数字取决于编译器和系统环境但趋势是一致的。如果你的题目里需要跑很多轮遍历比如反复做多源BFS、Dijkstra堆优化里的多次松弛差距会被拉得更大。3.2 为什么说链式前向星更适合竞赛和高性能场景很多人觉得既然有了vector为什么还要背链式前向星问题在于vector本质是动态数组每次push_back遇到容量不够时会重新分配内存把旧内容搬到新内存里。这个过程虽然是均摊O(1)但单次操作可能突然变慢而且分配器本身也有开销。在竞赛评测机那种极端场景下有的题目就卡在这种常数上。而链式前向星是“手工静态分配”。你知道最多有多少条边就把数组开够。添加边只是几次数组赋值连内存分配都没有。同时因为所有边都在同一个数组里遍历的缓存局部性更好尤其当你只需要访问边的终点、权值、下一条边这三个字段时CPU可以把整块数组搬到高速缓存里效率远高于分散在堆上的vector结点。这有点类似数据库里“聚集索引”和“分散索引”的区别数据如果能物理连续地放在一起范围扫描就会快很多。链式前向星把边的结构天然聚集在同一片连续内存里所以它跑扫描式的图算法遍历每个点的邻边确实有物理层面的优势。3.3 什么情况下不要执念链式前向星链式前向星并非万能。如果你的图规模不大比如n在1000以内或者你用的是Python、Java这类语言链式前向星的优势会被语言本身的数组对象开销稀释。Python里你写list套list反而更直观而且Python的列表对象和循环开销本身就是瓶颈手写数组模拟并不能从根本上改变复杂度。再比如需要频繁删边的场景链式前向星做“物理删除”很麻烦。虽然你可以加一个vis标记打懒标记但如果你真的需要不断地删除某条边、恢复某条边动态链表或者平衡树结构会更合适。链式前向星适合“建图之后反复读边几乎不做结构修改”的场景这恰好覆盖了绝大多数图论算法题。4. 链式前向星在经典算法中的应用4.1 堆优化Dijkstra中的链式前向星最短路算法里链式前向星是堆优化Dijkstra的固定搭档。原因在于Dijkstra的核心操作是从优先队列里取出距离最小的点然后遍历这个点的所有出边尝试松弛。这个过程对邻接表的“遍历”需求极其频繁每一条出边都要被访问一次。用链式前向星写出来非常自然void dijkstra(int s) { memset(dist, 0x3f, sizeof(dist)); dist[s] 0; priority_queuepairint,int, vectorpairint,int, greaterpairint,int pq; pq.push({0, s}); while (!pq.empty()) { auto [d, u] pq.top(); pq.pop(); if (d ! dist[u]) continue; for (int i head[u]; i ! -1; i nxt[i]) { int v to[i]; int w weight[i]; if (dist[v] dist[u] w) { dist[v] dist[u] w; pq.push({dist[v], v}); } } } }这段代码里遍历部分就是链式前向星的标准循环没有显式的“遍历索引”也没有复杂的迭代器就三个数组三个变量清爽干净。先把dist初始化成无穷大用0x3f可以避免加法溢出每次取出当前最小点如果堆里的距离不是最新的就跳过这就是“惰性删除”策略。4.2 DFS、BFS与树DP的配套写法DFS和BFS同样可以直接套链式前向星。DFS的递归写法void dfs(int u, int fa) { for (int i head[u]; i ! -1; i nxt[i]) { int v to[i]; if (v fa) continue; // 处理从u到v的边 dfs(v, u); } }树形DP时这个dfs里经常要结合子树信息做状态转移。比如求树的重心、树的直径、子树大小之类的问题链式前向星遍历子节点非常合适。因为每个节点出边的顺序并不重要而链式存储使得从父节点到子节点的跳跃只依赖一次数组下标访问。BFS的写法也很直接用一个队列存顶点每次弹出一个u就遍历head[u]开头的链表将所有未访问的v入队。整个过程中不需要像邻接矩阵那样对无关顶点也扫描一遍所以复杂度是O(nm)。4.3 网络流算法中的反向边利器网络流算法Edmonds-Karp、Dinic、ISAP都需要维护残量网络核心操作之一就是不断找到一条增广路后对路径上的边的容量做减法并对反向边容量做加法。这时前面提到的i^1技巧派上了用场你只要把边从1开始编号保证每加入一条正向边后立刻加入它的反向边那么正向边和反向边的编号就是相邻的异或1就能互相找到。我自己写Dinic时一条addEdge函数inline void addEdge(int u, int v, int c) { to[cnt] v; cap[cnt] c; nxt[cnt] head[u]; head[u] cnt; to[cnt] u; cap[cnt] 0; nxt[cnt] head[v]; head[v] cnt; }正向边容量c反向边容量0。更新时int cur i ^ 1; cap[i] - flow; cap[cur] flow;这样写比vector版本清爽很多因为vector需要你手动记录反向边在哪个位置甚至需要在结构体里维护一个int rev。链式前向星天生把反向边绑定在编号异或关系上不用额外存储自然成为图论竞赛选手网络流题的首选。5. 常见问题与排查手把手解决链式前向星的坑5.1 数组越界无向图边的数量要乘2这是我见过最多新手踩的坑。题目说m条无向边你开数组时如果只开m大小那add两次的时候第m1次添加就会越界。正确做法是至少开2m为了保险我通常开2m5。有人可能会问为什么不是m5因为无向边会拆成两条有向边存储所以添加次数是2m。如果你开了m5的大小可能在本地测试小数据时一切正常一到大数据就直接RE运行时错误或者本地跑得慢提交上去返回段错误。排查方法很简单先检查你的全局数组大小是不是除以2了。5.2 head数组初始化为-1 vs 0和判重冲突链式前向星的判断条件是i ! -1所以head初始化为-1非常关键。如果忘了初始化head里的值可能是0或者垃圾值导致遍历时从错误的边开始甚至访问到未定义的数组位置。多组测试数据时记得在每组数据开始前把head重新memset为-1。另外如果你使用0号边作为哨兵那么head初始化为0并且add时用cnt先用0号位置再自增那么遍历条件是i ! 0。这种写法也可以但新手容易在调试时把0和-1搞混。我自己的建议是统一用-1别混用。5.3 遍历顺序相反导致结果不对链式前向星是头插法所以边的遍历顺序和添加顺序相反。如果你在算法里依赖“先访问到某条边”的顺序就很容易出bug。比如有些DP题需要按输入的边顺序处理有些题需要你按特定顺序输出路径这时别慌可以通过调整添加顺序或者翻转逻辑来修正。有个小技巧如果你希望遍历顺序和输入顺序一致可以在add的时候插入到链尾而不是链头也就是维护一个tail数组记录每个点当前的最后一条边然后新边接到最后一条边的nxt上。不过大多数竞赛题不要求顺序所以头插法足够了知道这点能避免很多无谓的烦恼。5.4 忘记处理重边和自环链式前向星本身不会自动去重也不会拒绝自环。如果你的算法要求处理重边比如最短路、最小生成树要自己在边权取min或者在读取时判断一下。自环u到u的边在链式前向星里会被正常存储和遍历这通常是正确的但在某些特殊DP或图论性质题中自环可能导致状态转移死循环需要在转移时判断v ! u或者用vis数组标记。我踩过的一个真实坑是写Bellman-Ford时因为自环边不断更新dist程序陷入了死循环。后来排查了很久才发现是自环造成的。所以如果你用链式前向星跑这类基于边数循环的算法务必考虑自环的影响。5.5 多组测试数据的清零问题多组测试时很多人只memset了head却忘了其他数组其实不需要完全清零——只要不越界读取头插法添加边时to、nxt、weight都会被覆盖所以不清理没问题。但head必须清否则上一组数据遗留的边头会让新的遍历跑到无效的旧边。有一种更节省时间的操作用一个时间戳数组或者记录每组数据的边起点位置从上次的cnt开始继续用不清空head也能避免越界。不过这种骚操作只适合对性能极致敏感的题目大多数时候老老实实memset就够了。5.6 用链表头插法写错nxt指向写add时最容易写错的是nxt[cnt] head[u]和head[u] cnt的顺序。正确做法是“先托孤再夺位”先把新边的next指向原来的头再把头指向新边。如果写反了会出现环或者丢边。这一点在每个见到链式前向星的人身上几乎都发生过所以单独提出来。另外用递归DFS时注意栈溢出问题。链式前向星存图后递归深度可能达到n而某些评测环境栈空间很小比如Windows下的某些编译器只有1MB这时可以把递归改成显式栈模拟。图论的DFS递归写法在n10^5级别一般还能承受但n更大或者需要多次DFS时就要小心了。6. 实操建议什么时候首选链式前向星以及模板分享6.1 决策思路总结根据我个人的实战经验在下面几种情况我基本无脑选链式前向星顶点数n在10^4以上边数m在10^5以上需要高频遍历一个点的所有邻边且这种遍历会执行很多轮要写网络流等需要反向边的算法对内存有限制的题目数组比vector更可控在C/C竞赛环境中为了稳定性想避开vector扩容的波动。如果只是做LeetCode上的简单图题n经常只有几百vector就够了真没必要上链式前向星。但如果你打算长期搞算法竞赛练熟链式前向星几乎是一道必过的门槛因为它能帮你理解“数组模拟链表”这一大类的核心思想后面遇到链式哈希表、静态Treap、并查集的可撤销版本时思路都会自然得多。6.2 一个可以直接抄的通用模板我把常用写法整理成一个模板平时做题直接在这个基础上改就行#include bits/stdc.h using namespace std; const int N 100005; const int M 200005; int head[N], to[M], nxt[M], weight[M]; int cnt 0; inline void add(int u, int v, int w) { to[cnt] v; weight[cnt] w; nxt[cnt] head[u]; head[u] cnt; } void dfs(int u, int fa) { for (int i head[u]; i; i nxt[i]) { // 这里如果head用-1初始化条件就是 i ! -1 int v to[i]; if (v fa) continue; dfs(v, u); // 可以在这里做树上DP或统计 } } int main() { memset(head, -1, sizeof(head)); // 读边 int n, m; cin n m; for (int i 0; i m; i) { int u, v, w; cin u v w; add(u, v, w); add(v, u, w); // 无向图 } dfs(1, 0); return 0; }注意我注释里提到的一个细节这段代码里如果head初始化为-1遍历条件必须是i ! -1如果在某个版本里我用0当空标记那head初始化成0遍历条件就是i ! 0。这两种写法我都见过但一个程序里只能混用一种。建议用-1因为0号位置在cnt从1开始时本来就不使用不会引发歧义。6.3 和现代C语法结合的小改进如果你用C17或C20可以给链式前向星包装成一个结构体简化多组数据时的初始化struct Graph { int cnt 0; vectorint head, to, nxt, w; Graph(int n, int m) : head(n 1, -1), to(m 5), nxt(m 5), w(m 5) {} void add(int u, int v, int weight 0) { to[cnt] v; w[cnt] weight; nxt[cnt] head[u]; head[u] cnt; } };这样每次构造一个Graph对象就自动分配好内存并初始化head为-1不用每次手动memset。实测下来代码可读性更好也不容易初始化漏掉。缺点是多一层对象封装可能有一点点性能损耗但多数情况下影响微乎其微。如果在极限性能要求的题目里还是建议写全局数组毕竟竞赛环境下全局数组零初始化就是最稳定的选择。在实际写题过程中我还发现链式前向星对调试很友好因为你把边集中放在数组里打印某个点邻边时可以很方便地输出边的编号、to和next调试信息非常直观。而vector邻接表虽然也能打印但没法同时看到“下一条边”这个维度。所以从调试角度讲链式前向星其实并没有想象中那么“反人类”反而帮你把链表结构可视化出来。刚开始接触链式前向星时我建议你花半小时做一件事自己用纸笔模拟一个5个点6条边的图按add的步骤写下每次更新后的head数组、to数组、nxt数组然后手动走一遍遍历过程。这个练习能让你彻底摆脱“背代码”的状态真正理解它为什么能串起一条边链表。有了这种手感之后不管面试还是比赛里遇到什么样包装的图存储题你都能一眼看穿底层的结构关系。另外如果你用C写题可以留意一下开启编译优化后的差距。链式前向星配合-O2优化编译循环里的数组访问通常会被优化得很好vector版本在开启优化后差距会缩小一些但极端情况下链式前向星仍然更稳。所以在本地评测和线上评测环境不一致时选链式前向星能减少一些“本地过了线上超时”的玄学问题。最后再分享一个我自己判断是否使用链式前向星的经验先看数据范围如果n和m接近或者m远大于n那就偏向用邻接矩阵但这通常只在n比较小时成立如果m和n同量级或者m只是n的几倍那就是典型的稀疏图链式前向星或vector都行如果题目明确要求处理反向边、残量网络这种结构链式前向星要果断优先。数据结构这东西没有绝对的最好只有场景下的最适合能把链式前向星和vector邻接表都熟练在手才是真正的图论基本功。