如果你正在学数据结构(初阶),大概率已经受够了顺序表:在头部或中部插入一个元素,整条数组都要往后挪,挪完还可能发现容量不够,又得扩容。单链表就是在这个时候出场的——它用“后一个节点记住前一个节点的地址”这种方式,把插入删除的搬移成本直接降下来,这也是很多人第一次意识到,同一个操作,不同结构做起来差别可以这么大。
这篇笔记归纳4要聊的就是单链表的实现。我会从“为什么顺序表不够用”讲起,然后拆解节点结构、头指针这些最基础的概念,再给出头插、尾插、任意位置插入删除、查找、销毁这些核心操作的完整代码和每一步的理由。最后一部分,我会还原一个我在调试中遇到的“删除节点后链表消失”的完整排错过程。适合正在学链表、或者学完一遍想做查漏补缺的读者看。零基础的话别跳着读,跟着代码敲一遍收获最大。
1. 顺序表不够用了:单链表解决的真实痛点
1.1 顺序表在中间位置的“搬家”成本
顺序表的本质是一段连续的内存,底层就是一个数组。连续内存带来的好处很明显:按下标访问任意元素是 O(1),这是它最强大的地方。但坏处也藏在连续里——要在中间位置插入或删除一个元素,必须把后面的所有元素都往前或往后挪。
我举个例子你就明白了。假设顺序表里有 n 个元素,现在要往头部插入一个新数据。第一步,把下标 0 到 n-1 的全部元素都往后移动一位,空出下标 0;第二步,把新数据放进去。这一步就是 n 次搬移。如果 n 是十万,一次头插就要搬十万个数据。即使插入在中间,平均也要搬 n/2 个数据。
删除同理。删掉头部元素,后面所有元素必须整体前移一位,照样是 O(n) 的搬移。这种结构在“频繁在端点或中间增删”的场景下,时间成本非常难看。
还有一个隐藏成本是扩容。顺序表容量不够时要重新申请一块更大的内存,再把旧数据整体搬过去。如果在线性增长的过程中,每次都触发扩容,那么整体搬移成本会被放大很多。数组这种东西,本质上更适合“读多写少”的场景。
1.2 链表用“多存一个地址”换“不需要搬移”
单链表的结构和顺序表完全相反。它不在内存中要求节点连续存放,而是每个节点单独存放数据,再用一个指针(地址)指向下一个节点。内存里看起来是一个节点散落在不同位置,但从逻辑上通过 next 指针串成了一条链。
你可以把单链表理解成一个寻宝游戏:每个人手里只有一张纸条,写着“下一个宝物在哪里”。你从第一个宝物开始,顺着纸条一路找下去,才能找到所有宝物。想在张三和李四之间插入一个王五,你只需要改两张纸条——先让王五知道李四在哪里,再让张三把纸条改成指向王五。后面的所有人都不用动。
这就是链表的核心优势:插入和删除只需要调整几个指针,不需要搬移数据。给定一个已知节点的前提下,插入一个节点或删除一个节点,时间都是 O(1)。哪怕链表已经有一百万个节点,插入操作本身也只是几次指针赋值。
但代价同样明显:每个节点都要额外存一个 next 指针,空间上比数组多出一部分开销;而且链表无法像数组那样按下标随机访问,想找第 k 个节点必须从头一个接一个走,最坏是 O(n)。另外,节点在堆上零散分配,内存访问时无法像数组那样利用缓存局部性,这也是链表在实际性能测试里经常吃亏的原因。
顺序表和单链表的整体对比,可以这样看:
| 对比维度 | 顺序表 | 单链表 |
|---|---|---|
| 随机访问 | 按下标 O(1) | 必须遍历,O(n) |
| 已知位置的插入/删除 | 需要搬移数据,O(n) | 改指针即可,O(1) |
| 头插/头删 | 整体移动,O(n) | 直接改头指针,O(1) |
| 空间开销 | 可能有预留空间的浪费 | 每个节点多一个指针 |
| 缓存友好度 | 连续内存,相对友好 | 节点分散,不友好 |
1.3 初学阶段为什么一定要手写一遍
很多初学者会问:我们有现成的封装好的链表容器可以用,为什么还要自己手写单链表?我的观点是:数据结构初阶这个阶段,手写一遍的价值不在“做出一个能用的链表”,而在强迫你理解“指针即地址”这个概念。
顺序表阶段,你操作的是数组下标和连续内存,思维还停留在“一块内存 + 偏移量”。到了链表,你突然要面对“节点的地址散落在堆里,只能用指针把它们关联起来”,这是编程思维的一次升级。
更关键的是,单链表把所有边界情况都摆在你面前:空链表怎么处理、只有一个节点怎么处理、删除头节点怎么处理、删除尾节点怎么处理。这些边界如果只在别人封装好的接口里用,你一辈子都感受不到。亲手写一遍,踩过几个崩溃的坑,你才算真正明白链表到底是怎么“链”起来的。后面学双向链表、树、甚至操作系统内核里常见的链式组织,思路都从这里延伸。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 先搞清楚节点和指针:单链表最核心的两个概念
2.1 用一个结构体表达“一个节点”
单链表里的节点,本质上是一个结构体,里面装着两部分:一个是数据域,用来存业务数据;一个是指针域,用来存下一个节点的地址。
C 语言里的定义长这样:
c复制typedef struct SListNode
{
int data; // 数据域,这里假设存放 int
struct SListNode* next; // 指针域,指向下一个节点
} SLNode;
这里有一个值得反复看的细节:next 的类型为什么是 struct SListNode*,而不是 SLNode*?因为 typedef 的别名要到整个语句结束之后才生效,在结构体内部,类型名还没定义完,所以只能用原始的结构体名字 struct SListNode 来声明。这个细节虽然小,但很多刚写链表的人会纠结半天,甚至怀疑自己语法写错了。
next 必须是指针,这一点也要说清楚。如果 next 不是指针,而是直接放置一个完整的结构体,那每个结构体里又要包含另一个完整结构体,后者再包含另一个……这就会形成无限递归的嵌套,理论上永远无法定义完。用指针就不同了,指针只保存地址,不保存完整的节点内容。地址本身的大小是固定的,所以结构体可以完美自引用。
可以这样理解:节点是一张线索卡,data 是卡片上的信息,next 是写着“下张卡片放在哪个位置”的坐标。另外,在实际写链表代码时,我不建议一开始就做一个 typedef struct SListNode* SLNodePtr; 这样的别名。初学阶段这会让你分不清自己操作的是“节点本身”还是“指向节点的指针”。我更喜欢直接用 SLNode*,所有指针都摆在明面上,出错时更容易看出来。
2.2 头指针、头结点(哨兵位)与首节点:三种东西别混
学链表最容易在名词上绕晕,尤其是“头指针”和“头结点”这两个字相近的东西。
头指针是一个指针变量,它存放的是链表中第一个真实节点的地址。链表为空时,头指针的值是 NULL。这是最基础、最常用的东西。你声明一个链表,通常就是声明一个 SLNode* head = NULL;。
首节点是链表中第一个存有真实数据的节点,也就是头指针指向的节点。比如说有三个节点存放了 1、2、3,那么首节点的 data 就是 1。
头结点,也叫哨兵位,就不一样了。它是在首节点之前人为附加的一个节点,这个节点通常不存放有效数据(或者 data 字段随便填点东西),它的 next 才指向首节点。加了头结点之后,链表从“空的时候 head == NULL”变成了“永远有一个哨兵节点存在,空链表表现为哨兵的 next 为 NULL”。
哨兵位有什么用?它可以让“空链表”和“非空链表”的插入删除逻辑尽量统一,避免大量“如果 head 为空就怎么怎么样”的分支。很多书籍和库实现里都会用哨兵位。但它也给初学者增加了一道理解障碍:为什么空链表还要有一个不存数据的节点?这让人很不舒服。
我的建议是:初学阶段先不引入哨兵位。你先体会“空表就是 NULL,改头就是改指针”的原生逻辑,把边界情况都处理一遍,后面再学哨兵位,反而会豁然开朗。这部分我在后面专门展开。
2.3 没有头结点时,二级指针为什么躲不掉
这是单链表实现里最劝退的一个点。你可能会疑惑:为什么很多链表函数形参是 SLNode** ppHead,双星号代表什么?
先说 C 语言的基础规律:函数传参是值传递。你传一个指针进去,函数里能通过这个指针修改它指向的内容,但如果你在函数里重新给形参赋值,外面的变量是不会变的。
比如下面这段代码:
c复制void change_head(SLNode* head)
{
head = NULL; // 只改了形参本身
}
在 main 里调用 change_head(head); 之后,外部的 head 还是原来的地址,一点变化都没有。因为 head 这个变量本身是按值拷贝给形参的,函数内对形参的修改无法影响实参。
那如果我不能通过形参本身去修改外部头指针,链表却要求在空表头插时把头指针改成新节点的地址,怎么办?答案就是:既然想修改 head 这个指针变量,就必须把“head 的地址”传进去,也就是 SLNode**。函数里通过解引用 *ppHead 来读写外部那个头指针变量。
这个逻辑可以类比成:你想让另一个人帮你把钱包里的钱换成另一张,你不能把钱从钱包里抽出来交给他,而要把整个钱包交给他,让他自己从钱包里取、往钱包里放。这里钱包就是指针变量的地址。
所以后续很多需要“修改头指针本身”的操作,形参都是 SLNode**。比如头插、头删、尾插空表情况、销毁链表之后的置空,这些都是这个原因。理解了这一点,单链表的一半难度就已经解决了。
3. 手写单链表:初始化、插入、删除、销毁的思路拆解
3.1 初始化与遍历打印:先把地基打牢
单链表的初始化非常简单。“空链表”就是头指针为 NULL。所以初始化根本不需要什么 init 函数,你只需要:
c复制SLNode* head = NULL;
接下来的第一个操作通常是创建新节点。我习惯把它封装成一个独立的函数,因为后面头插、尾插、指定位置插入都会反复用到:
c复制SLNode* BuyListNode(int x)
{
SLNode* node = (SLNode*)malloc(sizeof(SLNode));
if (node == NULL)
{
perror("malloc");
exit(EXIT_FAILURE);
}
node->data = x;
node->next = NULL;
return node;
}
这里有两个经验点。第一,malloc 之后必须判空。虽然自己的测试程序里 malloc 失败概率很低,但这是好习惯,尤其是内存受限或者数据量大的时候,不判空直接解引用,后果是解引用空指针。第二,新节点的 next 要在一开始就置为 NULL。很多莫名其妙的链表 bug 都源于新节点创建后 next 没初始化,里面是随机垃圾值,打印时链表会“越链越长”甚至直接崩溃。
有了节点,先写一个打印函数,方便后面一边操作一边看结果:
c复制void SListPrint(const SLNode* head)
{
const SLNode* cur = head;
while (cur != NULL)
{
printf("%d -> ", cur->data);
cur = cur->next;
}
printf("NULL\n");
}
打印函数里两个细节值得注意。第一,我用的是临时变量 cur 做游标,而不是直接拿 head 去遍历。因为遍历完链表的 head 会被推到最后,变成 NULL,后来的操作全都没法做了。第二,我加了 const,遍历时只是读数据,不改数据,这样编译器能帮我们拦住误操作。
3.2 头插、尾插与任意位置插入:为什么头插最简单
头插的逻辑是所有插入里最直白的,因为它只动头指针和原第一个节点:
c复制void SListPushFront(SLNode** ppHead, int x)
{
SLNode* newnode = BuyListNode(x);
// 新节点先指向原来的第一个节点
newnode->next = *ppHead;
// 再把头指针改到新节点
*ppHead = newnode;
}
这段代码的精髓是“先后再前”。第一步让新节点的 next 指向原来的头节点,第二步再更新外部头指针指向新节点。如果顺序反过来,先把 *ppHead = newnode 改了,那么原本链表的第一个节点地址就丢了,后面的所有节点都找不回来,直接丢失整条链表。这个反序错误可以说是链表新手的第一大杀器。
头插的时间复杂度是 O(1),因为它不需要遍历,只需要改两个指针。所以如果业务上不要求顺序,用头插往往是最高效的。
尾插就麻烦一点:
c复制void SListPushBack(SLNode** ppHead, int x)
{
SLNode* newnode = BuyListNode(x);
// 空链表:新节点就是链表的第一个节点
if (*ppHead == NULL)
{
*ppHead = newnode;
return;
}
// 非空:找到当前尾节点
SLNode* tail = *ppHead;
while (tail->next != NULL)
{
tail = tail->next;
}
tail->next = newnode;
}
空链表的判断必须写在前面。如果链表为空还直接走 tail->next,那就是对空指针解引用,程序当场崩溃。这个分支不是“可加可不加”的优化,而是必写的边界保护。
非空情况下,循环结束的位置是“当前最后一个节点”,也就是 next 为 NULL 的节点,然后把新节点接上去。尾插的时间复杂度是 O(n),每次都要从头走到尾。如果业务上频繁尾插,可以加一个 tail 尾指针把复杂度降到 O(1),但那是后面要讲的内容,初学阶段先不展开。
任意位置插入,我最推荐的是“在已知节点的后面插入”,代码非常简单:
c复制void SListInsertAfter(SLNode* pos, int x)
{
if (pos == NULL)
return;
SLNode* newnode = BuyListNode(x);
newnode->next = pos->next;
pos->next = newnode;
}
这里为什么不需要二级指针?因为插入的位置是 pos->next,不是外部头指针。即使 pos 是最后一个节点,我们改的也只是 pos 的 next 字段,不涉及外部 head 变量本身。理解了这一点,你就明白“什么时候要传二级指针”的判断标准了。
如果你非要在一个节点的前面插入(InsertBefore),单链表就比较尴尬了,因为你不知道谁的前驱是 pos。除非从头部开始遍历找到 pos 的前一个节点,否则没法改前驱的 next。这也是单链表不如双向链表灵活的根本原因。遇到这种需求,最简洁的做法就是调用 SListInsertAfter(pos, x),然后交换两个节点的 data,从效果上模拟“前插”。很多教材和练习题都用这个技巧。
3.3 删除节点的两个细节:最前端判断与两种实现路线
删除操作比插入更容易踩坑,因为删除不仅要改指针,还要负责把节点释放掉。这里有一条铁律:先让链表跨过待删节点,再释放内存。顺序反了,链表就断链了。
先看最简单的头删:
c复制void SListPopFront(SLNode** ppHead)
{
if (*ppHead == NULL)
return;
SLNode* del = *ppHead;
*ppHead = del->next; // 先把新的头指针指到原第二节点
free(del);
del = NULL;
}
这里需要二级指针,因为删除头节点后外部头指针必须更新。先备份 del,再把 *ppHead 指向第二个节点,最后 free。如果先 free 再改指针,就已经访问了被释放的内存,属于未定义行为,很危险。单节点的情况也不需要额外分支,因为 del->next 是 NULL,*ppHead = NULL 之后链表就干净地变成空表。
尾删复杂一些,要点在于找到倒数第二个节点:
c复制void SListPopBack(SLNode** ppHead)
{
if (*ppHead == NULL)
return;
// 只有一个节点:删完就是空链表
if ((*ppHead)->next == NULL)
{
free(*ppHead);
*ppHead = NULL;
return;
}
SLNode* prev = NULL;
SLNode* cur = *ppHead;
while (cur->next != NULL)
{
prev = cur;
cur = cur->next;
}
// cur 是尾节点,prev 是它的前驱
prev->next = NULL;
free(cur);
cur = NULL;
}
这里必须单独处理“只有一个节点”的情况。如果只有一个节点,prev 永远是 NULL,循环结束后再去执行 prev->next = NULL,就是对空指针解引用,直接崩溃。代码里那个 if 分支就是为这个边界写的。
有人会觉得两个分支很啰嗦,想用二级指针技巧写一个“统一版”,我后面会讲。但初学者先把这个带分支的版本写对,理解“什么时候需要修改头指针”比背技巧更重要。
删除指定位置的下一个节点也很常用:
c复制void SListEraseAfter(SLNode* pos)
{
if (pos == NULL || pos->next == NULL)
return;
SLNode* del = pos->next;
pos->next = del->next; // 跨过 del
free(del);
del = NULL;
}
所谓“跨过”,就是让 pos 的 next 直接指向 del 的下一个节点,中间那个节点从链路里剥离开来。删除的本质,说白了就是“把前驱的 next 越过待删节点,然后再 free”。
3.4 查找、修改与销毁:收尾工作别马虎
查找就比较直接了:
c复制SLNode* SListFind(SLNode* head, int x)
{
SLNode* cur = head;
while (cur != NULL)
{
if (cur->data == x)
return cur; // 返回目标节点的地址
cur = cur->next;
}
return NULL;
}
重点在于返回值是一个节点指针,而不只是“找到了没”。有了这个节点地址,你就能直接修改它的 data,或者在它后面插入、删除后面的节点。这也是链表常用套路:先用 Find 拿到目标节点,再用 InsertAfter/EraseAfter 完成增删。
销毁链表是很多人忽视的操作,初学者经常陷入“程序都结束了还销毁个啥”的误区。但你随便一写就可能泄漏大量堆内存。教科书上的销毁是这么写的:
c复制void SListDestroy(SLNode** ppHead)
{
SLNode* cur = *ppHead;
while (cur != NULL)
{
SLNode* next = cur->next; // 先保存下一个节点的地址
free(cur); // 再释放当前节点
cur = next;
}
*ppHead = NULL; // 防止头指针变成悬空指针
}
这里的要点是“先保存下一个节点,再释放当前节点”。如果顺序反过来,先用 cur = cur->next 再去 free,那 free 之后再去读取 cur->next 就是访问已释放内存,等于是违章操作。销毁链表的过程本质上就是一边遍历一边释放,必须在释放之前把下一步的路记住。
最后还要把外部头指针置空。如果不置空,调用方手里的 head 仍然指向一块已经归还给操作系统的内存,下一次打印或插入就会踩到野指针。
4. 一次经典翻车:删除节点后链表“消失”的完整排查链路
4.1 事故现场:代码不长但结果不对
常见案例很容易遇到。我当时带着一个学习者写链表,他写了一个“删除第一个匹配指定值的节点”的函数,代码如下:
c复制// 错误版:看起来逻辑没问题
void SListRemove(SLNode* head, int x)
{
SLNode* cur = head;
while (cur != NULL && cur->data != x)
{
cur = cur->next;
}
if (cur != NULL)
{
free(cur);
}
}
运行结果是:如果删除的是中间某个节点,链表打印时会出现一堆乱码,甚至直接崩溃;如果删除的是第一个节点,链表仿佛“凭空少了一个开头”,头指针还是指向原来的地址,但那个地址已经被 free 了,打印时输出的第一步就是垃圾数据。
表面看,这个函数好像挺合理的:找到目标节点,然后 free 掉。问题出在哪里?这个案例非常适合拿来讲,因为错误不在“调用 free 之后”,而在于 free 之前,链表结构根本没有被修复。
4.2 定位过程:把函数内外部的指针变化画出来
排查这类问题,第一步不是改代码,而是把指针关系画出来。
假设现在链表是:head -> A -> B -> C -> NULL,我们要删除节点 B。正确做法应该是让 A 的 next 直接指向 C,再 free B。这样链依然是完整的:head -> A -> C -> NULL。
而这个错误版的函数里,cur 一路走到 B,然后把 B free 了。但是注意:A 的 next 字段里存的还是 B 的地址。free 之后,B 这块内存里的内容并不保证被清掉,链表的“下一站地址”也在那块内存里。打印时,程序从头出发,访问 A 的 next,发现指向 B 的旧地址,再顺着那个地址去读 data,读到的东西可能是随机值,可能已经被其他数据覆盖,于是乱码和崩溃就出现了。
如果删除的是头节点 A,情况更明显:函数形参里的 head 只是外部 head 的一个拷贝,函数内部 free(cur) 后,外部 head 依然指向 A 的旧地址,而这会儿 A 已经不属于程序了。之后任何从头遍历的操作,都在访问非法内存。
所以这个 bug 的本质有两个:第一,没有更新前驱节点的 next;第二,删除头节点时没有更新外部头指针。一句话总结:删除操作不只包含 free,还包含“把链修好”。
4.3 根因确认:函数形参是副本,前驱的 next 没有被更新
用调试器或者打印语句验证一下就很清楚了。在错误版函数入口打印 head 的地址,在 main 里打印 head 的地址,你会发现函数内部拿到的值虽然一样,但函数内对形参赋值也好、将 head 向后移也好,都不会改写 main 里的 head。因为形参是按值传递的副本。这就是为什么删除可能涉及头指针时必须传二级指针。
正确写法需要同时解决两件事:删除中间节点时,让前驱的 next 跨过待删节点;删除头节点时,更新外部头指针。于是需要两个变量:一个 prev 记录前驱,一个 cur 指向当前节点,同时形参改成二级指针:
c复制void SListRemove(SLNode** ppHead, int x)
{
if (ppHead == NULL || *ppHead == NULL)
return;
SLNode* prev = NULL;
SLNode* cur = *ppHead;
while (cur != NULL)
{
if (cur->data == x)
{
if (prev == NULL)
{
// 删除的是头节点:更新头指针
*ppHead = cur->next;
}
else
{
// 删除的是中间或尾节点:前驱直接跨过 cur
prev->next = cur->next;
}
free(cur);
cur = NULL; // 局部指针置空,避免接下来误用
return;
}
prev = cur;
cur = cur->next;
}
}
这段代码跑一遍,原来的乱码现象都会消失。
如果觉得 prev 版本有点啰嗦,还有一个很漂亮的“二级指针遍历”写法,适合有一定基础的人学:
c复制void SListRemove(SLNode** ppHead, int x)
{
if (ppHead == NULL)
return;
SLNode** cur = ppHead;
while (*cur != NULL)
{
if ((*cur)->data == x)
{
SLNode* del = *cur;
*cur = del->next; // 自动更新头指针或前驱的 next
free(del);
del = NULL;
return;
}
cur = &((*cur)->next);
}
}
这里 cur 指向的是“前一个节点的 next 字段”或者“头指针变量本身”,所以 *cur = del->next 这行代码,无论待删节点是头节点还是中间节点,都能把链条正确地跨越过去。理解了这版写法,你不仅会删节点,还对“指针的指针”有了更通透的认识。
4.4 内存层面的验证手段:释放置空与泄漏检查
代码改完之后,不要急着觉得自己全对了,最好做一轮内存层面的验证。
一个便宜又有效的做法是释放后立刻置空。置空并不能让内存变安全,但它能让程序后续误用这个地址时更容易暴露问题,而不是“碰运气”地读到旧数据。比如 free(cur); cur = NULL; 之后,如果有人还拿着 cur 去解引用,你很快就能定位到错误位置。
如果是 Linux 环境,还可以用内存调试工具跑一遍。它检测出来的典型报错是 “Invalid read of size 4”,通常意味着你访问了一块已经 free 的内存。另一个常见报告是 “definitely lost: X bytes in Y blocks”,大概率说明某次 malloc 后没有配对 free,多半是销毁函数漏掉了一些节点,或者写链表过程中覆盖了头指针,导致链表尾部在某处断了,后续节点没被释放。
做完这些验证之后,你才可以说这个删除函数真的写对了。
5. 单链表最容易被忽视的五个细节,以及我现在的习惯
5.1 每一处循环都要先问“这会不会遇到 NULL”
单链表几乎所有崩溃,都发生在对空指针解引用。细分下来,又是两种循环写法搅混了。
第一种是遍历所有节点,用 while (cur != NULL)。这种写法的前提是你想“把每个节点都过一遍”,比如打印、查找。
第二种是“走到最后一个有效节点就停下”,用 while (cur->next != NULL)。这种写法常见于尾插找尾节点、尾删找倒数第二个节点。
初学者最容易犯的错,是在一个空链表上直接执行第二种循环。链表为空时,cur 是 NULL,你还要判断 cur->next,就是在空指针上取字段,必崩。所以每次写链表循环前,先问自己:这个循环从哪个节点出发?如果链表为空,会发生什么?空表、单节点表、尾节点,这三个特殊位置必须提前想好。
5.2 malloc 检查、释放置空、断言:三条保命习惯
链表操作就是堆内存操作,我自己的写码习惯是三条保命底线。
第一,malloc 之后判空。虽然测试环境和工程里 malloc 失败概率很低,但判空是专业素养。真到了内存紧张时,没有判空的代码就是一颗随时会爆的雷。最简单的做法是 if (node == NULL) { perror("malloc"); exit(EXIT_FAILURE); },当然在真正的库函数里用 exit 很粗暴,工程上更合适的是把错误码返回给调用方处理。但初学测试程序里,及时终止总比一路崩下去好。
第二,free 之后立刻把指针置空。注意置空的是谁:局部指针置空,能防止局部后续误用;外部头指针置空,能防止外部悬空。置空不是让它真的消失,而是把“误用”变成“崩溃”,把问题暴露出来。这是防御性的好习惯。
第三,进出函数的顺序要稳定。比如插入函数先创建节点再做指针操作,删除函数先改链再 free。顺序一旦乱,链表要么丢头,要么悬空。我后来总结成一句口诀:先接后换、先跨后删、释放置空。这句话基本覆盖了链表最危险的几个操作场景。
5.3 我现在的建议:初学阶段先不用哨兵位,写顺再加
说了这么多,回到“头结点/哨兵位”的选择问题。很多教材或代码仓库直接用哨兵位实现,代码看起来确实简洁。但我建议初学者第一阶段故意不用哨兵位。
原因很简单:没有哨兵位时,所有边界情况都赤裸裸地摆在你面前,你被迫搞清楚“空链表怎么处理”“头节点怎么改”“二级指针什么时候用”。这个过程虽然痛苦,但收获是实打实的。等这些边界你都处理熟悉了,再看哨兵位版本,你会发现它只是“把特殊情况提前构造掉”的一种手段,很容易理解。
| 场景 | 无哨兵位(head 指向首节点) | 有哨兵位(head 指向哨兵) |
|---|---|---|
| 空链表表示 | head == NULL | 哨兵存在,哨兵->next 为 NULL |
| 头插 | 需要二级指针,修改外部头指针 | 在哨兵之后插入,头指针不动 |
| 删除头节点 | 需要更新头指针 | 删除哨兵后的节点,头指针不动 |
| 接口形式 | 很多函数传 SLNode** |
多数函数传 SLNode* 即可 |
| 初学理解成本 | 边界全部暴露,容易踩坑但也容易理解 | 代码统一,但容易忽略哨兵的本质 |
如果你已经能把无哨兵位版本写得毫无崩溃,再去练习哨兵位版本,你会明显感觉到代码在很多地方变顺了:空表和非空表的插入删除逻辑可以统一处理,函数签名也更简洁。这时候哨兵位就不是负担,而是工具。
我自己刚学链表时,曾在删除函数上调了大半个晚上,最后多亏了一步步画图才定位到前驱没有更新。这个坎过去之后,再学双向链表、循环链表,几乎没再被指针绕晕过。如果你现在也被单链表搞得很烦,千万别怀疑自己能力,不是你笨,是你还没有把“指针之间的连接关系”可视化在脑子里。打开一个画图工具,把每个节点画成一个小盒子,把指针画成箭头,跟着每个操作手动挪一遍箭头,很快就能想明白。
希望这一篇归纳能让你少踩几个我当年踩过的坑。单链表这个难点一旦攻克,你后面所有的链式数据结构学习都会顺很多。
