很多学数据结构的朋友一提到链表,第一反应就是 struct Node*,然后 malloc、free 走起。但在算法竞赛、嵌入式开发、操作系统内核这类对运行效率极其敏感的场景里,真正的链表实现往往不是那个样子,而是一排预先分配好的数组,加几个整数下标在充当“指针”。这种写法在教材里叫静态链表,做工程的人通常叫它数组模拟链表。
它解决的核心问题很明确——把链式结构落进连续内存里,让节点的分配和回收变得极其可控。这篇文章不复制教材定义,只讲实操时真正需要盯住的那些细节:空闲表怎么建、插入时谁先谁后、删除后节点怎么回收、遍历的停止条件为什么总在出 bug。不管你是正在备考数据结构,还是写邻接表存图,或者打算用定长内存池代替频繁 malloc,这些东西应该都用得上。
1. 为什么放着动态链表不用,非要用数组模拟
1.1 数组模拟链表的底层逻辑:用整数下标代替真实指针
链表的核心是什么?是一堆节点,每个节点除了存数据,还要存一个“下个节点在哪”的信息。正常做法是让节点里存一个真实指针,指向堆内存里某个对象。数组模拟链表换了个思路:所有节点提前申请好,放进一个连续数组里,节点里存的不再是地址,而是数组下标。
比如定义一个节点池:
c复制#define MAXN 100000
int data[MAXN]; // 节点数据域
int nxt[MAXN]; // 节点“指针”域,存的是下一个节点的下标
int head; // 链表的头节点下标
这个 nxt 数组看起来是个 int,实际上语义就是链表里的 next 指针。head 也不再是地址,而是头节点在数组里的位置。当 nxt[p] = -1,就表示 p 节点后面的链断了,也就是链表到了尾端。
这里 -1 就是模拟了空指针 NULL。为什么用 -1 而不是 0?因为数组下标从 0 开始,0 本身可能是一个有效节点,拿 0 当空指针,等于白白牺牲掉第一个槽位,而且判断条件容易和“下标为 0 的节点”混在一起。我见过不少初学代码在 while(p++) 和 p != -1 之间来回横跳,最后定位到根因,基本都是这个约定没统一。
1.2 优缺点差距:省时省内存,但不省心
数组模拟链表最直接的优势有三个:分配快、缓存友好、无内存碎片。动态链表每创建一个节点都要走一次 malloc,每删除一个节点还要 free,频繁调用会有不小的系统开销,时间长了还有内存碎片问题。数组模拟就是在初始化时把所有槽位管理好,要节点时从空闲表拿一个下标,不要了再还回去,整个过程就是几次整数赋值,常数时间。
用一张表看两者的差异更清楚:
| 对比维度 | 动态链表 | 数组模拟链表 |
|---|---|---|
| 节点分配 | 每次 malloc,开销大 | 从空闲表取下标,O(1) 常数时间 |
| 节点回收 | free,可能产生碎片 | 归还到空闲表,O(1) |
| 内存连续性 | 不连续,缓存命中率一般 | 连续数组,遍历时缓存较友好 |
| 容量弹性 | 可一直申请到堆内存耗尽 | 初始化定死,满了就是满了 |
| 调试难度 | gdb 里看指针地址 | 实际是下标,需要打印 data 数组 |
代价也很明显:数组长度是写死的。如果你预估 N 不够大,链表满了之后再想插新节点,没有任何办法。教科书里讲它“省内存”,很多人误以为它能动态伸缩——它省的是内存碎片和管理开销,不是省容量。
1.3 什么场景我建议直接上数组模拟
不是所有链表都值得用数组模拟。我自己的判断标准很简单:如果链表的数量、长度在写代码前能估出上界,并且对插入删除性能有要求,就用数组模拟。最典型的是图论里的邻接表,每条边就是一个链表节点,总共多少条边是确定的,提前开一个 to[MAXM] + nxt[MAXM] 就完事。还有哈希表拉链法,每个桶都是一条链表,节点总数不会超过插入次数,也特别适合数组模拟。
反过来,如果链表节点增减极其剧烈,完全估不准规模,或者你只是写业务代码不追求极限性能,老老实实用动态链表和标准库容器就是更稳的选择。数组模拟不适合任何“大得没边”的场景。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 初始化阶段:空闲表建不好,后面全白搞
2.1 数据结构定义:两条平行数组还是结构体数组
两种写法都能用。第一种就是刚才那种 data[] 和 nxt[] 两条平行数组。第二种是把节点定义成一个结构体,然后用结构体数组充当节点池:
c复制struct Node {
int data;
int nxt;
};
struct Node pool[MAXN];
两种写法在逻辑上完全等价。平行数组的好处是访问节点数据时少一层点操作,读取起来 data[p] 很直接,代码在编译器眼里也没什么区别。结构体数组的好处是语义更聚合,一个节点该有的内容都在一起,写复杂操作时不会被数组名绕晕。
我个人偏向双数组写法,尤其是处理邻接表这类要同时维护多个链表的情况。为什么?因为邻接表里的节点池本身就是共享的,所有链表都从同一个池子里拿节点,用 to[]、nxt[]、head[] 三块区域各司其职,比塞进一个大结构体更直观。初始化的时候也更方便直接对 nxt 整体赋值。
2.2 空闲链表是怎么建立的
刚初始化时,所有节点都没有被使用,但它们也不是孤零零待在那里等着被随机挑中的。我们需要提前把所有空闲槽位串成一个链表,这叫空闲链表。头指针记为 freeHead,每次要新节点,就从 freeHead 取出一个;每次删除节点,就把这个被删的旧的槽位还给 freeHead。
建立空闲表的代码是数一数二的简单:
c复制void init(int total) {
for (int i = 0; i < total - 1; i++) {
nxt[i] = i + 1; // 先把每个槽位指向下一个槽位
data[i] = 0;
}
nxt[total - 1] = -1; // 最后一个槽位后面没有空闲槽了
freeHead = 0; // 空闲表头从 0 开始
head = -1; // 真实链表为空
}
这就是一个“工厂流水线”:0 号槽位指向 1 号,1 号指向 2 号……串到最后用 -1 收尾。freeHead 指向第一个空闲槽位,分配节点时直接取 freeHead 就行。此时真实链表 head = -1,表示一个节点也没有。
2.3 两个非常容易踩的初始化坑
第一个坑:把所有 nxt[i] 初始化成 -1。这么做确实让每个节点看起来清爽,但空闲表怎么办?如果每个槽位的 nxt 都是 -1,那你想按顺序去找“下一个空闲槽”就完全找不到入口,只能在几百个槽位里瞎碰。要分配节点就得增加一个“找一个没被用的槽位”的搜索过程,得不偿失。反正这些槽位最后要靠 nxt 连接起来,与其逐条置 -1,不如一开始就把它们串起来。
第二个坑:忽略 freeHead 和 head 是两个独立概念。head 是真实链表(也叫使用中链表)的头,freeHead 是空闲表的头。很多初学代码只维护了 head,删完节点就把旧槽位扔在那里不管,后面插入时又从 freeHead 开始取——但 freeHead 从来没更新过。于是新节点不断从同一个下标取出来,把正在使用的节点数据覆盖掉,整条链表就乱了。正确的做法是任何一次删除都必须把槽位归还到 freeHead,任何一次插入都必须从 freeHead 取出槽位并更新它。
3. 插入操作:先接还是先断,顺序有讲究
3.1 头插、尾插、中间插各自的连接顺序
数组模拟链表的插入分三种情况,说难不难,但出错率极高,尤其是中间插入。
头插法最简单。新节点先指向原来的头,再让 head 指向新节点:
c复制int newNode = getNode();
data[newNode] = val;
nxt[newNode] = head;
head = newNode;
先让 nxt[newNode] = head,再更新 head = newNode。如果你反过来先写 head = newNode,再写 nxt[newNode] = head,那 nxt[newNode] 拿到的其实是自己,链表起点就变成一个回环,后面的节点全部丢了。
中间插入需要给定一个前置节点 prev,新节点插在 prev 后面:
c复制int newNode = getNode();
data[newNode] = val;
// 必须先让新节点指到 prev 原来的后继
nxt[newNode] = nxt[prev];
// 再让 prev 指向新节点
nxt[prev] = newNode;
这两步顺序绝不能换。很多人刚切换成数组模拟,觉得反正只是下标赋值,把顺序写得随意一点也没关系。实际上它和真实指针是一样的链式结构,先断后接必然丢链。先改 nxt[newNode] 只是把新节点安顿好,不破坏现有结构;再改 nxt[prev] 才能真正把新节点串进去。
尾插法更繁琐,需要先找到链表的最后一个节点:
c复制// 先找尾巴
int p = head;
if (p == -1) {
// 链表为空,直接设为头
head = newNode;
} else {
while (nxt[p] != -1) p = nxt[p];
nxt[p] = newNode;
nxt[newNode] = -1;
}
找尾巴的循环条件容易写错,比如写成 while (nxt[p] != -1) 时没问题,但如果在循环体里直接 p++,那就不是“按链表走”,而是“按数组下标往后挪”。这里只能写 p = nxt[p],一旦写成 p++,当链表的物理顺序和逻辑顺序不一致时,就完全走偏了。
3.2 分配槽位和 freeHead 更新的先后顺序
getNode() 是数组模拟链表里最容易被写错的小函数。它要做两件事:从空闲取一个槽位,然后更新 freeHead:
c复制int getNode() {
if (freeHead == -1) {
// 池子已满,处理办法以具体场景为准
return -1;
}
int newNode = freeHead;
freeHead = nxt[freeHead]; // 空闲表的头跳到下一个
return newNode;
}
注意这里的顺序同样是先保存 newNode,再更新 freeHead。如果写成:
c复制freeHead = nxt[freeHead];
newNode = freeHead; // 错!新的空闲头被当成新节点了
那就把一个刚空闲出来的槽位当成了分配给新节点,而原本应该被分配的槽位反而留在了空闲链里,链表逻辑直接错乱。取节点本质上是“从空闲表头部弹出一个元素”,这和普通链表的头删操作一模一样,顺序上先存弹出的节点,再改头指针,这是铁律。
还有一个容易忽略的细节:getNode() 取出来的 nxt[newNode] 是旧空闲表里的内容,也就是“同一个空闲表的下一个槽位”。如果插入时不立刻给它赋值,它带着一个不可控的下标进入使用中链表,遍历时会跑到莫名其妙的位置。所以每次拿到新节点后,第一件事就是把它 nxt 字段设置好:要么指向某个真实节点,要么置 -1。
4. 删除、回收与逆置:三个最常见的“翻车”点
4.1 删除节点的一条龙流程
删除节点比插入麻烦,因为单链表只能往后走,不能回头。要删掉某个节点 p,必须知道它的前置节点 prev。常规做法是遍历链表,维护两个下标:一个指向当前节点,一个指向它的前驱。
c复制int removeByValue(int target) {
int prev = -1;
for (int p = head; p != -1; p = nxt[p]) {
if (data[p] == target) {
if (prev == -1) {
// 删的是头节点
head = nxt[p];
} else {
nxt[prev] = nxt[p];
}
// 回收节点到空闲表
nxt[p] = freeHead;
freeHead = p;
return 1;
}
prev = p;
}
return 0;
}
删除的核心是三件事:记住旧后继、跳过待删节点、回收槽位。顺序上,先用 nxt[prev] = nxt[p] 跳过 p,这一步不会丢链,因为 nxt[p] 在赋值前已经读取过了。如果把它理解成“先把 p 指回 prev,再改 prev”,就会在回收前把 p 的 next 信息抹掉,导致整条链表断在中间。
如果删的是头节点,情况略有不同——没有 prev,直接 head = nxt[head],然后再回收旧的头节点。对头节点缺失判断,是删除操作的另一个高频犯错点。我看到很多人的代码删除普通节点没问题,但删头时忘了更新 head,结果原头节点已经回收,head 还指向一个空闲槽位,数据全乱套。
4.2 逆置链表时为什么不保存后继必炸
链表逆置的代码我看过无数版本,动态链表写法里容易错的点,在数组模拟里一个不少,而且因为节点的 next 是整数下标,错误会被隐藏得更深。
正确写法如下:
c复制void reverse() {
int prev = -1;
int cur = head;
while (cur != -1) {
int temp = nxt[cur]; // 保存后继
nxt[cur] = prev; // 当前节点反指
prev = cur; // prev 前移
cur = temp; // cur 走向旧后继
}
head = prev;
}
这里的 temp = nxt[cur] 是逆置的命根子。如果不提前保存,一执行 nxt[cur] = prev,当前节点的后继就被覆盖成前驱了,循环体里再也找不到原链的下一个节点,遍历当场中断。我见过有人把它写成了先 nxt[cur] = prev 再 cur = nxt[cur],结果 cur 不断向前驱方向移动,链表从中间折成了两截,调试半天才反应过来。
逆置结束后,head = prev 必须执行。很多人循环写对了,最后忘了更新 head,列表仍然停留在原来的头节点,遍历结果完全看不出变化。经验是逆置完一定要在草稿纸上画一遍:原来的尾节点是否成了新 head,原来头节点的 nxt 是否变成了 -1。
4.3 回收空闲槽位为什么必须头插
删除节点后,被删的槽位要还给空闲表。注意这里的回收方式:头插法,不是尾插法。
c复制nxt[p] = freeHead;
freeHead = p;
把 p 插入到空闲表头部即可,因为空闲表不需要关心槽位的物理顺序,谁在最前面无所谓,下次分配拿最新的就行。如果用尾插法,就要从头遍历空闲表找到最后一个槽位,再把它指向 p,把 O(1) 操作变成 O(n),还容易写错。这一点是数组模拟链表和动态链表在“回收”语义里最大的相通点——动态链表 free 再 malloc 同样不关心地址顺序,空闲表头插就是这个道理。
不过这里有个细节很多人容易漏:被回收的节点 p,它的 data[ p ] 要不要清空?视场景而定。如果是存储指针或者字符串引用,建议清空,避免后续调试时看到旧数据产生混淆;如果只是存整数,可以不清,因为分配节点后迟早会覆盖 data[p] 重新赋值。但 nxt[p] 必须赋值成 freeHead,否则这个节点就没法被空闲表串起来。
5. 遍历、查找和长度统计:-1 哨兵的正确打开方式
5.1 单链表的遍历条件
数组模拟单链表的遍历,标准写法是全代码里最老实的一行:
c复制for (int p = head; p != -1; p = nxt[p]) {
// 处理 data[p]
}
停止条件 p != -1 意味着链表尾部必须以 -1 为终止标记。这就要求你每次插入节点时,如果它被放在链尾,nxt[newNode] 必须被设成 -1。很多 bug 的根源在于插入代码只在头插或者中间插时赋值了 nxt,到了尾插时忘了把最后的 newnode 的 next 置成 -1,结果遍历一跑就冲到数组里的越界下标去了。
Curious: 越界下标有两种情况。一种正好落进了空闲表里,遍历看到了一些本不属于链表的数据;另一种直接跑出数组边界,程序崩溃或者产生未定义行为。前者最迷惑人,因为它不报错,但是结果错得毫无规律。这种情况下我一般建议第一步就在循环里加个防御判断:
c复制if (p < 0 || p >= MAXN) {
printf("指针出现非法下标 %d,链结构已损坏\n", p);
break;
}
调试完再删掉。别小看这一句,它能帮你把“数据不对”快速定位成“哪一步连接写坏了”。
5.2 循环单链表的遍历不能再用 -1
数组模拟循环单链表是另一个容易踩碎的点。循环单链表的特点是最后一个节点的 nxt 不再指向 -1,而是指向头节点(或者某个指定的入口节点)。于是遍历不能再用 p != -1 来判断,因为永远等不到 -1,循环条件永远为真。
处理办法有两种。第一种是记住起点,用 do-while 或者 while 对比:
c复制int start = head;
int p = head;
do {
// 处理 data[p]
p = nxt[p];
} while (p != start);
第二种是计数法,先算出链表中节点数量,然后遍历 count 次。实际写下来我更推荐 do-while,因为初始化状态是“两个指针都指向 start”,即使链表只有一个节点,至少也执行了一次体内容,符合循环链表的语义。如果你用 while (p != head) 开头,遇到初始化 p == head 时循环体一次都不执行,单节点循环链表就会被误判为空链表。
循环链表还有一个衍生坑:初始化时误把最后一个节点的 next 置成 -1,然后某段代码又在找 -1 时挂掉。写循环链表时,脑子里要保持“根本没有 -1 终点”这个概念。尾插的时候尤其不要下意识补一句 nxt[newNode] = -1,那一下就会把环从中间扯断。
5.3 遍历时对无效下标保持警惕
数组模拟链表有一个动态链表没有的隐患:下标本身是整数,哪里都可以指向,编译器不会帮你检查。动态链表里如果有个野指针,访问大概率立刻段错误,你能快速意识到是指针问题;数组模拟中越界下标可能落在另一个节点的 data 上,运行照常,但结果就是不对。
降低这类概率的做法很简单:任何从 nxt 数组取出来的下标,在使用前先过一遍合法性判断。特别是在从空闲表取节点、删除节点时,要检查取回来的 nxt[p] 是否是一个合理范围的下标。加上这条约束后,至少能保证数组模拟链表在出错时是“暴露出错”,而不是“默默错算”。
还有一个规范性问题——全代码的哨兵值要统一。你用了 -1 就一路都用 -1,不要在一个项目里有人用 NULL,有人用 -1,还有人用 0。我见过最崩溃的调试现场,是一份代码里 p != -1 和 if(p) 混着写,结果节点下标为 0 时被当成空处理,整个逻辑全废。数组模拟链表的所有“空”和“不存在”都应该统一成同一个值:-1。
6. 容易被问晕的“兄弟概念”和一个经典变种
6.1 邻接表存储其实也是数组模拟链表
如果说数组模拟链表有一个应用能排第一,那一定是图论里的邻接表。这里我不介绍完整存图框架,只展示核心部分——它和普通单链表的头插法完全同构:
c复制const int MAXN = 10005;
const int MAXM = 200005;
int head[MAXN]; // head[u] 表示节点 u 的第一条边的编号
int to[MAXM]; // to[e] 表示边 e 指向的终点
int nxt[MAXM]; // nxt[e] 表示下一条边的编号
int cnt = 1;
void addEdge(int u, int v) {
to[cnt] = v;
nxt[cnt] = head[u]; // 新边先指向旧的“头边”
head[u] = cnt; // 头边更新为新边
cnt++;
}
void visit(int u) {
for (int e = head[u]; e != -1; e = nxt[e]) {
int v = to[e];
// 处理 v
}
}
这里的 head 数组等于给每个图节点都开了一条“链表”,每条边的节点来自同一个全局数组 to 和 nxt。新增一条边就是一次头插,完全不需要动态内存分配,而且顶点数、边数都可以提前预估,在竞赛和系统代码里几乎是标配写法。理解数组模拟链表后,你再看这种邻接表就会觉得毫无神秘感——它只是多个链表共享一个数组池的典型案例。
6.2 指针数组、结构体数组、可变数组不是一回事
搜索热词里经常有人把指针数组和数组模拟链表放一起问,这两个概念差别很大。指针数组是一个数组,数组里每个元素是真实指针,比如 int *arr[N],它本身只是一排指针,并没有任何链表语义。数组模拟链表却不存真实地址,只存整数下标。两者解决的问题完全不同。
结构体数组也常被误解。struct Node pool[MAXN] 只是节点池,如果你不用 nxt 把节点串起来,它仍然只是“一排连续节点”,并不是链表。链表和数组的本质区别在于逻辑相邻不等于物理相邻,数组模拟链表靠 nxt 字段在逻辑上重新排列了物理相邻的节点。这点一定要在脑子里立住:有没有 nxt 字段,决定了它是不是链表。
可变数组、动态数组则是另一类东西。它们解决的痛点是数组定长问题,比如 C++ 的 std::vector、Java 的 ArrayList,扩容时重新申请内存并拷贝已有元素。它们的逻辑结构仍是线性表,而不是链表。有些博客会从“数组模拟链表”一路扯到“动态数组”,读者以为两者是同一种技术,其实是完全不同的两种思路:一个用逻辑指针打散物理顺序,一个用连续内存保序存储。
6.3 单循环链表和循环队列别混为一谈
这两个名字都带“循环”,实际语义差得很远。循环单链表是用 next 把节点串成一个环,它的插入、删除、遍历都是链表逻辑,只是尾部不再用 -1 收尾。循环队列则是用数组下标 front、rear 配合取模运算实现环形复用,比如“假设以数组 q[m] 存放循环队列中的元素,同时以 rear 和 length 分别指示队尾元素和队列长度”这类题,它的核心是取模,不是指针跳转。
我见过不少人把循环队列的取模思想套到链表上,写着写着让 nxt[tail] = head % MAXN 之类的诡异代码,逻辑完全对不上。记住一个判断方法:如果操作里需要不断做 p = nxt[p],本质是链表,终止条件要么回到起点要么遇到哨兵;如果操作里需要不断做 (rear + 1) % m,本质是数组队列,不存在 next 字段。两者唯一相似之处就是都用了“环形”这一概念,实现层面没有任何交叉。
我自己的实操习惯是把数组模拟链表当成一套“手工内存管理”来练。每写一个插入、删除、逆置函数,就在边上写一行注释:当前操作拿的是哪个槽位,改的是哪条链,是不是会破坏空闲表。这样坚持一段时间后,再看邻接表、内存池、哈希拉链之类的工程代码,基本都能一眼看出作者在管理哪些链表。你如果正在学这段,可以先把最基础的 100 个节点跑熟,然后把 head、freeHead、哨兵 -1 三个概念在纸上画一遍,比死记十道题都有用。
