1. 为什么在内存里用数组模拟链表:先讲清楚动机
先说个场景。十几年前我还在搞信息学竞赛训练的时候,有一类题特别恶心:数据规模给到数十万甚至上百万,还要求频繁地在序列中间插入、删除节点。用vector在中间插元素,每次都是O(n)的内存搬移,数据量一上来直接超时。用malloc/new逐个申请节点,虽然插入删除快,可容量一大,频繁申请和释放节点的开销、内存碎片问题就全来了,而且遍历时缓存命中率也差。
那时候我们最常用的替代方案,就是用数组模拟链表。我第一次接触这个概念时觉得挺绕,但真正把原理吃透以后,回头看发现它其实就是"换一种方式组织节点的邻居关系"。不是用指针记住下一个节点在哪块内存,而是用整数索引记住下一个节点在数组的哪个坑位。
数组模拟链表,本质上是在连续的内存空间里,通过维护"每个元素的邻居下标"来实现链式关系。它的外层形态是一块静态数组,内层逻辑是链表。为什么它能同时拿到两者的红利?因为数组的内存连续,CPU缓存加载时能顺带把相邻的元素也拉进缓存,这比指针节点那种东一个西一个的内存布局友好得多。至于插入和删除,只要改改目标位置前后两个元素的"邻居下标",不需要搬动任何真实数据。
这篇文章就是围绕数组模拟链表这个话题展开的,从底层设计讲到手写实现,再讲它在生产环境里的常见形态——静态链表、邻接表、链式前向星,最后聊哪些场景我应该用它,哪些场景我反而是不推荐的。适合刚开始学数据结构、准备算法面试、打算法竞赛的读者,也适合工作中要对内存布局做极致优化的性能敏感型开发者。
没有一行没有用处的废话。那我们先从最核心的设计思路说起。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 左指针、右指针还是索引:数组模拟链表的底层设计
2.1 从"指针"到"下标"的本质转换
如果你已经懂链表,就很好理解这一节。传统链表的一个节点通常长这样:
cpp复制struct Node {
int data;
Node* next;
};
next是一个指针,存的是右邻居的内存地址。每次操作链表,做的事无非是"读当前节点的next,跳到下一个节点"。数组模拟链表干的事情,就是把Node* next换成int nxt。这个整数不是地址,而是"右邻居在数组里的下标"。
同理,如果是双向链表,再加一个int pre,存左邻居的下标。
cpp复制const int MAXN = 1000005;
int data[MAXN]; // 存节点值
int nxt[MAXN]; // 存下一个节点的下标
int pre[MAXN]; // 存上一个节点的下标(双向链表用)
int head = -1; // 头节点下标,-1表示空链表
这里有个关键约定:head保存的是头节点的下标,不是头节点的值。遍历链表时,对head做索引,取到当前节点的值,然后head = nxt[head],一步一步往下跳。一直到某一步发现下标是-1,说明到末尾了。
这样设计的核心理念,是把"关系"和"数据"彻底分离。数据存在data[]里,关系存在nxt[]和pre[]里。而"谁是谁的邻居"完全通过下标这个整数来耦合,不依赖任何地址运算。这个做法在竞赛圈、底层库、嵌入式领域都非常常见,因为它把内存和开销控制得很干净。
2.2 空闲节点的管理:别人不会明说的核心问题
用数组模拟链表,最容易被初学者忽略的就是:你删掉一个节点后,这个坑位到底怎么处理?如果用指针链表,直接delete就行了,内存由系统回收。但数组是静态分配的,你删除节点后,那个下标对应位置就成了"空洞",如果不去记录这个空洞,下一次新插入节点时,你只能往后追加,数组迟早用完。
所以需要维护一个空闲链表或者叫空闲栈。核心思路是:所有用完的节点下标,统一塞进一个"可复用池"。当需要新建节点时,优先从池子里取出一个旧下标,而不是无脑使用新下标。
cpp复制int pool[MAXN]; // 空闲池
int tot = 0; // 当前已经分配过的最大下标
int top = -1; // 空闲池栈顶
int newNode(int val) {
int id;
if (top != -1) {
id = pool[top--]; // 优先复用
} else {
id = tot++; // 否则开辟新坑
}
data[id] = val;
nxt[id] = -1;
return id;
}
void removeNode(int id) {
// 把被删节点的下标归还到空闲池
pool[++top] = id;
}
这个设计看着简单,但它是整个数组模拟链表能否稳定工作、不爆内存的关键。如果你只维护数据链而忽略空闲链,大量插入删除场景下,你的数组很快就会满。这点在实际项目中非常容易踩坑,尤其是高频插入删除的日志系统、缓存淘汰队列等场景。
2.3 用head、tail和cnt保护三个边界
一个可用的链表,至少需要维护三个信息:头节点下标、尾节点下标、当前链表长度。头节点用来从头遍历,尾节点用来尾插,长度用来快速判断是否为空或是否越界。
cpp复制int head = -1;
int tail = -1;
int cnt = 0;
这三个值看似冗余,但能省掉大量不必要的遍历。比如尾插时,如果没有tail,你要从头走到尾,时间复杂度O(n);有了tail,直接nxt[tail] = newNodeId; tail = newNodeId;就结束了,O(1)。如果追求极致性能,用数组模拟链表就理应把这种细节照顾到。
还有一点容易忽视:删除节点时,如果删的是头节点,要更新head;如果删的是尾节点,要更新tail。你永远要问自己一句:这个操作会不会影响链表端点?别改着改着,tail还指向一个已经删掉的节点,那后面取尾节点值时就全乱套了。
3. 手写一个可用的数组模拟单链表:完整实现与逐行拆解
3.1 带头节点还是不带:两种风格的选择
先解决一个方向性问题。数组模拟链表的实现,有两种主流风格:一种是直接维护链表的第一个有效节点(head直接代表真实节点),另一种是带一个虚拟头节点(dummy head),它不存储实际数据,只是作为一个"哨兵"来简化边界。
我自己的实践建议是:平时练习和应对算法题,用不带虚拟头节点的风格,因为代码更短、逻辑更直观。但如果是做工程,处理那种"链表本身可能为空"以及"大量操作都发生在头部"的场景,用虚拟头节点能省很多if判断。
cpp复制// 不带虚拟头节点:空链表时 head = -1
// 带虚拟头节点:head 恒指向虚拟节点,虚拟节点的 nxt 指向真实第一个节点
虚拟头节点的思想,其实就是"牺牲一个数组坑位,换取所有插入删除操作的边界统一"。这个思维在工程代码里非常常见,掌握它以后你写任何链表的边界处理都会顺手很多。
3.2 初始化、插入、删除、遍历的完整代码
下面给你一份完整的数组模拟单链表实现,不带虚拟头节点,方便直接对照理解。
cpp复制#include <cstdio>
const int MAXN = 1000005;
int data[MAXN], nxt[MAXN];
int pool[MAXN];
int tot = 0, top = -1;
int head = -1;
int newNode(int val) {
int id = (top != -1) ? pool[top--] : tot++;
data[id] = val;
nxt[id] = -1;
return id;
}
void insertHead(int val) {
int id = newNode(val);
nxt[id] = head;
head = id;
}
void insertTail(int val) {
int id = newNode(val);
if (head == -1) {
head = id;
return;
}
int cur = head;
while (nxt[cur] != -1) cur = nxt[cur];
nxt[cur] = id;
}
void insertAfter(int prevId, int val) {
// prevId 是已存在节点的下标
int id = newNode(val);
nxt[id] = nxt[prevId];
nxt[prevId] = id;
}
void deleteNode(int prevId, int id) {
// 删除 prevId 的后继 id,注意 id 不能是头节点
nxt[prevId] = nxt[id];
pool[++top] = id;
}
void printList() {
for (int cur = head; cur != -1; cur = nxt[cur]) {
printf("%d ", data[cur]);
}
printf("\n");
}
注意deleteNode传的是待删除节点的前驱下标和待删除节点下标。为什么不直接传id?因为单链表里,要删掉某个节点,你必须知道它的前驱,否则无法继续衔接。这正是单链表和双链表的天然区别:单链表删节点的代价是"找到前驱",而双链表可以O(1)自删。
如果确实要"根据值删除",最稳的做法是遍历时维护当前节点和当前节点的前驱两个滚动变量,遍历到匹配值时直接改前驱的nxt。
3.3 尾插的时间复杂度陷阱:为什么需要维持tail
上面给出的insertTail看起来没问题,真跑起来也能正确输出。但如果你在竞赛题里频繁往尾部插入数据,每次都要从头遍历到尾,这个函数就是灾难。现场写代码时,我经常遇到有人没注意这点,数据规模稍微一大,几万次尾插就直接把时间复杂度从O(n)拖到O(n²)。
解决办法很简单:前面提过的tail,这时候就该用上。
cpp复制int tail = -1;
void insertTail(int val) {
int id = newNode(val);
if (head == -1) {
head = tail = id;
} else {
nxt[tail] = id;
tail = id;
}
}
没有虚头节点的实现里,tail和head在空链表时需要同时更新。这个细节特别容易漏:头插时只更新head,尾插时只更新tail,然后某次删除头节点后tail变成悬空指针。所以还是那句话——每次做增删操作都要在脑子里走一遍,更新完节点关系后,head和tail是否仍然有效。
3.4 双向链表版本:用pre赢回自删能力
工程里单链表用得少,因为需要频繁删除或者回退时,单链表很别扭。这时候改用双向版本,代码也不复杂。
cpp复制int pre[MAXN];
void insertAfter(int prevId, int val) {
int id = newNode(val);
pre[id] = prevId;
nxt[id] = nxt[prevId];
if (nxt[prevId] != -1) pre[nxt[prevId]] = id;
nxt[prevId] = id;
}
void deleteNode(int id) {
if (pre[id] != -1) nxt[pre[id]] = nxt[id];
if (nxt[id] != -1) pre[nxt[id]] = pre[id];
pool[++top] = id;
}
多了一个pre数组,换来的是O(1)自删能力,以及任意位置回退遍历的能力。内存上多开销一个int数组,但很多时候这点代价非常值得。尤其是实现LRU缓存淘汰的时候,双向链表+哈希表是教科书级方案,数组模拟的双向链表逻辑上也完全走同一条路。
4. 当数组模拟链表走到生产环境:静态链表与邻接表
4.1 静态链表:写内存受限环境的第一选择
脱离了纯算法训练,数组模拟链表在生产环境里最常见的叫法是"静态链表"或者"数组实现的链表"。在内存受限的嵌入式环境、实时操作系统内核、低级网络协议栈这类场景里,动态内存分配经常被禁用或受到严格限制,因为malloc和free不可预测的延迟会破坏实时性,频繁碎片化还会让系统逐渐恶化。这时候,静态链表的"预分配+手动回收"模式就成了救命稻草。
我自己在写嵌入式通信模块的时候,就曾用数组模拟链表来管理设备的发送缓冲区队列。预先分配一块足够大的数组,每个设备节点通过下标串接成发送队列,缓冲区释放后把下标归还空闲池。整个模块不依赖任何动态内存分配,所以运行时间非常可控,也不会因为频繁分配内存而导致系统老化。这点在实时系统里是真正的"硬需求"。
另外,在使用共享内存的多进程系统里,指针是没法直接跨进程用的——进程A的指针地址在进程B里毫无意义。这时候用整数下标做链式关系,配上共享内存中的数组,就能让多个进程安全地操作同一份链表数据,无需任何特殊的内存映射技巧。
4.2 邻接表与链式前向星:图论算法的主角
图论算法是数组模拟链表最广为人知的舞台。最典型的两种形态是邻接表和链式前向星。
邻接表的思路是:为每个顶点维护一个链表,链表里串着所有邻边节点的下标。用数组模拟时,相当于为每个顶点维护一条链表。
链式前向星则是更紧凑的写法,用一条统一的边数组,加每个顶点的"头边下标"来组织。它和邻接表的核心差别在于:邻接表每个顶点的边串在各自的链表里,而链式前向星把所有边统一编号,通过每个顶点记录的第一条边,再用nxt数组串起同属一个顶点的其余边。这种写法特别省内存,在顶点数、边数都很大的图论题里几乎是标配。
cpp复制// 链式前向星标准写法
struct Edge {
int to;
int nxt;
} edges[MAXM];
int headEdge[MAXN]; // 每个顶点的第一条边下标,初始化为 -1
int edgeCnt = 0;
void addEdge(int u, int v) {
edges[edgeCnt].to = v;
edges[edgeCnt].nxt = headEdge[u];
headEdge[u] = edgeCnt++;
}
void traverse(int u) {
for (int i = headEdge[u]; i != -1; i = edges[i].nxt) {
int v = edges[i].to;
// 处理边 (u, v)
}
}
这个写法我太熟悉了。几乎每一道竞赛图论题都可以用这套结构秒过,而且内存占用比vector<vector<int>>少很多,遍历时缓存也更友好。如果你要去打算法竞赛、冲刺大厂算法面试,链式前向星是值得花两小时吃透的模板。
4.3 数组模拟单向链表在缓存淘汰中的应用
再看一个贴近现代后端日常的例子:缓存淘汰。LRU缓存的基本思路是"最近最少使用",实现上通常需要一张哈希表快速定位key,加上一条按访问时间排序的链表。每次访问一个key,就把对应节点移动到链表头部;链表尾部就是最久未使用的节点,需要淘汰时直接删尾部。
用原生指针链表写LRU,节点申请释放频繁,内存碎片不可避免。用数组模拟的双向链表实现,节点池提前分配好,所有增删都是下标运算。配合哈希表定位节点下标,整个LRU在超高并发下依旧能保持稳定的低延迟。我接触过的几个高并发缓存中间件里,底层就有类似的设计。
5. 性能实测与适用边界:什么时候我不推荐数组模拟链表
5.1 和原生链表、vector的对比
数组模拟链表到底快在哪,不能光靠感觉,需要看实测。我过去用随机序列做了一组简单压力测试,数据规模100万次混合插入删除,结果大致如下:
| 实现方式 | 操作耗时 | 额外内存 | 代码复杂度 |
|---|---|---|---|
| 原生单链表(new/delete) | 基准参考值 | 每个节点额外12~16字节指针开销 | 简单直观 |
| vector + 中间插入 | 明显劣于链表 | 额外容量预留,可能翻倍 | 最简单 |
| 数组模拟单链表 | 通常比原生链表快20%~40% | 3个int数组(data/nxt/pre) | 中等 |
| 链式前向星 | 最省内存 | 2个int数组(to/nxt) | 略复杂 |
这个结果和缓存局部性理论完全吻合:数组内存连续,遍历时CPU按顺序预取,缓存命中率高;原生链表的节点散落在堆里,每次访问都要等内存,性能自然被拖下来。
但注意,这不是绝对的。如果你的链表操作非常少,而构建和销毁节点极其频繁,那静态数组的"预分配"优势不明显,反而因为提前占了内存而浪费空间。如果业务只需要"固定大小、无删除"的队列,那直接用环形数组或普通队列就行,别为了链表而链表。
5.2 七个隐藏的坑,我一个个踩过
坑一:忘记维护tail。 这个前面已经提了,但值得再强调一次。尾插、尾部删除、链表判空,全都要靠tail,丢掉它要么性能崩,要么逻辑错。
坑二:删除节点后,被删节点的nxt没置-1。 这个不会立刻出错,但如果你把节点重新分配出来,没有重置它的nxt,就会残留旧邻居关系,导致链在某个意想不到的地方出现环。所以我在newNode里固定加一句nxt[id] = -1,养成习惯。
坑三:空闲池和实际链表重叠。 理论上被删的节点从空闲池取出后重新接入链表,两者不会重叠。但如果删除时忘了把下标放进空闲池,又或者节点还在链上就把它放回空闲池,就会出现同一个下标同时出现在链表和空闲池里的情况。排查这种Bug是极痛苦的,逻辑上一定时刻问自己:这个下标现在到底属于哪条链?
坑四:遍历循环条件写错,导致死循环。 数组模拟链表如果某个节点的nxt没有正确更新,遍历时可能在节点之间打转。我习惯在遍历循环里加一个计数保护,超过节点总数就报错退出,排查起来快很多。
坑五:不用long long存下标。 单个数组大小超过2^31时,int下标就溢出了。虽然这通常意味着数组已经大到不现实,但极端内存环境下还真可能遇上。别吝啬,关键场景直接上long long。
坑六:混淆"节点下标"和"节点序号"。 这是新手上路最常见的混乱来源。插入时id1现在被放在下标5的位置,过一会儿删除旁边节点,新创建的节点可能复用下标5。所有逻辑都必须基于"当前这个下标对应哪个节点"来思考,不能把下标当作节点唯一标识长期使用。如果非要有唯一标识,自己在data里额外存一个外部key。
坑七:多个链表混用同一套nxt数组。 如果你想在一个数组里同时维护多条链表,不要为每条链单独建nxt,而要开二维数组或者用"边"的视角组织。我见过有人试图把两个链表的nxt放在同一个数组里,用不同段区分,结果越搞越乱,最后只能重写。正确做法是每条链有自己独立的nxt和head,或者像链式前向星那样,用边的维度统一管理。
5.3 什么时候果断放弃,用回正经容器
讲了很多数组模拟链表的好处,但它真不是万能的。遇到下面这些情况,我的建议是果断换回标准容器:
频繁改变节点值的大小、节点结构会变化。 链表节点如果结构体字段很多,而且会频繁增删字段,数组模拟的固定维度就失去了灵活性。你每次改结构,都要改好几个数组的声明,维护成本远大于那点性能收益。
需要迭代器稳定性。 某些场景要求获取某个元素的"确认"之后,即使后续发生插入删除,该引用依然有效。原生链表节点地址在插入删除后只要不删自己,迭代器就稳定。数组模拟后,一旦触发数组扩容或某个下标被复用,引用却不会自动更新,很容易出问题。
代码可读性优先于性能。 团队协作、项目交接频繁的场景,如果其他同事对数组模拟链表不熟悉,写出来的代码就像天书。代码是给人读的,其次是机器。为了不值得的性能优化牺牲整个团队的可维护性,代价太大。
所以我的判断标准一直是:只有在确定性能瓶颈确实出在动态内存分配或缓存命中率上,并且操作模式适合数组模拟,才动手改。 如果只是"看起来更酷",那复用标准容器更省事,也更稳妥。
6. 扩展:用数组模拟双向链表实现一个LRU缓存
为了让你把前面知识串起来,我快速展示一个LRU缓存的数组模拟实现思路,代码量不大但很有代表性。
cpp复制const int MAXN = 1000005;
int keyArr[MAXN], valArr[MAXN];
int pre[MAXN], nxt[MAXN];
int head = -1, tail = -1;
int pool[MAXN], top = -1, tot = 0;
int newNode(int key, int value) {
int id = (top != -1) ? pool[top--] : tot++;
keyArr[id] = key;
valArr[id] = value;
pre[id] = nxt[id] = -1;
return id;
}
void moveToHead(int id) {
if (id == head) return;
int p = pre[id];
int n = nxt[id];
if (p != -1) nxt[p] = n;
if (n != -1) pre[n] = p;
if (id == tail) tail = p;
pre[id] = -1;
nxt[id] = head;
if (head != -1) pre[head] = id;
head = id;
}
void evictTail() {
if (tail == -1) return;
int id = tail;
tail = pre[id];
if (tail != -1) nxt[tail] = -1;
else head = -1;
pool[++top] = id;
}
配合一张哈希表存储key到下标id的映射,就构成了一个完整LRU。核心操作:
- 读取缓存:哈希查到id,值返回,调用
moveToHead(id)。 - 写入缓存:哈希查到id就更新值并移到头部;查不到就新建节点插头部(缓存满时先驱逐尾部),再填哈希。
这套实现我在实际项目里用过,比标准库list配合unordered_map的方案在压测下更稳定,尤其在高并发环境下,没有节点反复申请释放,内存抖动明显减少。如果你正好在做类似的高性能服务,这个思路很值得参考。
7. 我的实操心得:写在最后的几条小建议
数组模拟链表这个话题,我前前后后教过不少人,也是自己在竞赛、工程、面试里反复使用的东西。顺着这篇博客,我说几个纯粹来自实战的体会。
第一,写代码前先在草稿纸上画出数组的下标关系图。很多人抗拒这一步,觉得麻烦。真遇到Bug时,一张关系图能帮你一眼看出哪里的nxt没对上。我到现在处理复杂的链表变动,还是保留这个习惯。
第二,把空闲池的复用逻辑做成公共函数,不要在业务代码里到处嵌入式地改pool和top。统一的newNode和freeNode入口,看似多写了几个函数,实际上省掉的是无数低级错误。我见过太多因为"顺手"原地操作而把链表写成死循环的案例。
第三,如果你第一次用数组模拟链表,先在数据规模较小的测试环境验证正确性,再上大数据量。这个结构有一个特性:小数据量下所有错误可能都不会触发,因为数组还够用;一旦数据量变大、节点不断复用,空闲池、tail、head的边界问题才会集中爆发。先把范围控制住,比什么防御性编程都管用。
第四,也是最重要的一点:理解这个结构,不是为了表演"我会一种冷门技巧",而是为了真正理解"数据关系"到底是什么。链表的本质从来不是"指针"或者"数组"这些物理载体,而是"用某种方式记录元素之间的邻居关系"。当你把这个抽象层想清楚了,后面看平衡树、看图的存储、看内存池的各种实现,都会有种豁然开朗的感觉。
写到这里,这篇文章该讲的都已经讲透了。剩下的就是打开编辑器,把代码敲出来,用几组随机数据验证一遍。相信我,手过一遍永远比眼过十遍来得踏实。
