单链表进阶操作详解(指定位置插入 / 删除 / 销毁)
前言
在上一篇《单链表基础操作详解》中,我们掌握了头插 / 尾插 / 头删 / 尾删 / 查找等基础操作,这些操作仅能满足链表的「头尾操作」需求。实际开发中,更多场景需要基于指定节点的插入 / 删除(比如有序链表插入、删除指定值节点)。本文继续沿用「思路 + 步骤 + 踩坑」的讲解方式,拆解单链表进阶操作的核心逻辑,并总结链表操作的通用方法论。
单链表基本功能实现代码仓库完整代码: Luminous/Luminousbegin
一、指定位置后插入(SLInsertAf)
功能说明
在已知节点pos的后方插入新节点,无需二级指针。
cpp
void SLInsertAf(LL* pos, DaTy x)
{
assert(pos); // 检查pos非空(避免传入NULL)
LL* newnode = SLTBuyNode(x);
// 核心步骤:先让新节点指向pos的下一个节点,再让pos指向新节点
newnode->next = pos->next;
pos->next = newnode;
}
设计思路与插入铁则
-
时间复杂度:O (1),不需要遍历,是单链表效率最高的指定位置插入。
-
为什么不用二级指针 :只修改
pos节点的next指针,不会改变头指针的指向,所以不需要二级指针。 -
插入铁则(顺序绝对不能反) :必须先给新节点接好后继,再改前驱的 next。如果反过来写:
cpppos->next = newnode; // 先改前驱 newnode->next = pos->next; // 这时候pos->next已经是newnode了,等于自己指向自己会直接导致链表断链,后面的节点全部丢失。记住口诀:先连后,再连前。
-
实现三步法 :
- 创建新节点
- 新节点的 next 指向 pos 原本的后继
- pos 的 next 指向新节点
二、指定位置前插入(SLInsertFr)
功能说明
在已知节点pos的前方插入新节点,需分「pos 是表头」和「pos 非表头」处理。
cpp
void SLInsertFr(LL** pphead, LL* pos, DaTy x)
{
assert(pphead && *pphead); // 检查表头和指针非空
assert(pos); // 检查pos非空
if (pos == *pphead) // pos是表头:复用头插法
{
PushFront(pphead, x);
}
else // pos非表头:找到pos的前驱节点prev
{
LL* newnode = SLTBuyNode(x);
LL* prev = *pphead;
// 遍历找到pos的前驱(prev->next == pos)
while (prev->next != pos)
{
prev = prev->next;
}
// 前驱节点指向新节点,新节点指向pos
prev->next = newnode;
newnode->next = pos;
}
}
设计思路与前驱查找法
- 为什么需要二级指针:如果 pos 是头节点,前插就等于头插,会修改头指针,所以必须传二级指针。
- 核心难点:找前驱 单链表没有反向指针,想在 pos 前面插节点,必须从头遍历找到 pos 的前一个节点。遍历终止条件是
prev->next == pos------ 当 prev 的下一个节点是 pos 时,prev 就是我们要找的前驱。 - 代码复用思想 :头节点的情况直接调用已有的
PushFront,不用重复写逻辑。重复代码越少,出错概率越低,维护成本也越低。 - 时间复杂度:O (n),最坏情况要遍历整个链表,这是单链表前插的性能代价。
- 易错踩坑 :循环条件容易写反成
while (prev->next == pos),会导致一次都不执行,prev 停在头节点,直接把链表插断。
三、删除指定节点(SLTErase)
功能说明
删除链表中指定的节点pos,需分「pos 是表头」和「pos 非表头」处理。
cpp
void SLTErase(LL** pphead, LL* pos)
{
assert(pphead && *pphead);
assert(pos);
if (pos == *pphead) // pos是表头:复用头删法
{
SLDeleteBack(pphead);
}
else // pos非表头:找到前驱节点,跳过pos
{
LL* prev = *pphead;
while (prev->next != pos)
{
prev = prev->next;
}
prev->next = pos->next; // 前驱指向pos的后继
free(pos); // 释放pos节点
pos = NULL;
}
}
设计思路与拓展思考
-
逻辑对称性:删除指定节点和前插逻辑完全对称,都是「找前驱 → 修改前驱的 next → 处理目标节点」。
-
拓展:O (1) 删除法(偷天换日) 面试中常考的优化方法:不需要找前驱,把 pos 下一个节点的数据拷贝到 pos 里,然后删除下一个节点。
cpp// 偷天换日法(仅作拓展,不推荐工程使用) void SLTEraseO1(LL* pos) { assert(pos && pos->next); LL* next = pos->next; pos->a = next->a; // 后继数据覆盖当前节点 pos->next = next->next; // 跳过后继 free(next); }局限:不能删除尾节点,且本质是删除了后继节点,并非真正删除 pos。工程中推荐使用找前驱的标准写法,逻辑严谨无边界问题。
-
删除通用步骤:先改链表结构(把节点从链上摘下来),再释放节点内存。顺序不能反。
四、删除指定节点的后继节点(SLTEraseAf)
功能说明
删除pos节点的下一个节点(后继节点),是指定位置删除的简化版。
cpp
void SLTEraseAf(LL* pos)
{
assert(pos && pos->next); // 检查pos非空、后继非空
LL* del = pos->next; // 保存待删除节点
pos->next = del->next; // pos指向del的后继
free(del); // 释放del
del = NULL;
}
设计思路与常见错误分析
-
为什么最简单:pos 本身就是待删节点的前驱,不需要遍历找前驱,时间复杂度 O (1)。
-
新手高频错误 :
cpp// 错误写法:先改指针,再free pos->next = pos->next->next; free(pos->next); // 此时pos->next已经变了,释放的是错的节点后果:删错节点 + 内存泄漏,是链表最经典的 bug 之一。
-
正确思路 :先把要删的节点地址存起来,再改链表结构,最后释放存好的地址。永远不要边改指针边释放。
五、销毁链表(SLDestroy)
功能说明
释放链表中所有节点的内存,避免内存泄漏,最终将表头置 NULL。
cpp
void SLDestroy(LL** pphead)
{
assert(pphead);
LL* cur = *pphead;
while (cur != NULL)
{
LL* next = cur->next; // 先保存下一个节点
free(cur); // 释放当前节点
cur = next; // 移动到下一个节点
}
*pphead = NULL; // 表头置NULL,避免野指针
}
设计思路与批量删除逻辑
- 本质:批量版的头删,循环执行「保存下一个 → 释放当前 → 指针后移」。
- 核心原则 :和单个删除完全一致 ------ 释放前必须保留后续节点的地址。不能边 free 边访问
cur->next,free 之后的内存已经不属于你了,再访问就是野指针。 - 收尾必须置空:释放完所有节点后,一定要把外部的头指针置为 NULL。否则头指针指向已释放的内存,后续误操作会直接崩溃。
- 思考方法:销毁是链表的「善后操作」,只要用了 malloc,就必须配套销毁函数,程序结束前调用,杜绝内存泄漏。
六、进阶操作测试代码与运行结果
cpp
int main()
{
LL* head = NULL;
// 初始化链表
PushBack(&head, 100);
PushBack(&head, 200);
PushBack(&head, 300);
PushBack(&head, 400);
printf("原链表:");
Print(head); // 100->200->300->400->NULL
printf("========== 1. 指定位置后插入 SLInsertAf ==========\n");
LL* pos3 = SLFin(head, 200);
if (pos3)
{
SLInsertAf(pos3, 250);
Print(head); // 100->200->250->300->400->NULL
}
printf("========== 2. 指定位置前插入 SLInsertFr ==========\n");
LL* pos4 = SLFin(head, 300);
if (pos4)
{
SLInsertFr(&head, pos4, 280);
Print(head); // 100->200->250->280->300->400->NULL
}
printf("========== 3. 删除指定节点 SLTErase ==========\n");
LL* pos5 = SLFin(head, 200);
if (pos5)
{
SLTErase(&head, pos5);
Print(head); // 100->250->280->300->400->NULL
}
LL* pos6 = SLFin(head, 100);
if (pos6)
{
SLTErase(&head, pos6);
Print(head); // 250->280->300->400->NULL
}
printf("========== 4. 删除指定节点的后继 SLTEraseAf ==========\n");
LL* pos7 = SLFin(head, 280);
if (pos7)
{
SLTEraseAf(pos7);
Print(head); // 250->280->400->NULL
}
// 销毁链表
SLDestroy(&head);
printf("========== 销毁链表后 ==========\n");
Print(head); // NULL
return 0;
}
运行结果:
cpp
原链表:100->200->300->400->NULL
========== 1. 指定位置后插入 SLInsertAf ==========
100->200->250->300->400->NULL
========== 2. 指定位置前插入 SLInsertFr ==========
100->200->250->280->300->400->NULL
========== 3. 删除指定节点 SLTErase ==========
100->250->280->300->400->NULL
250->280->300->400->NULL
========== 4. 删除指定节点的后继 SLTEraseAf ==========
250->280->400->NULL
========== 销毁链表后 ==========
NULL
七、进阶篇通用思考方法总结
- 插入铁则:先给新节点连好后继,再修改前驱的 next 指针,顺序颠倒必断链。
- 删除铁则:先把待删节点地址存到单独变量里,再改链表结构,最后释放。
- 前驱思维:往前操作(前插、删当前节点)必须找前驱;往后操作(后插、删后继)直接操作即可。
- 代码复用:边界情况能复用头尾操作的,直接调用已有函数,减少重复代码和 bug。
- 性能意识:清楚每个操作的时间复杂度,知道单链表适合什么场景、不适合什么场景,才算是真正理解数据结构。
八、总结
单链表的进阶操作,本质是对「指针操作」和「边界思维」的深度考验。没有什么高深的算法,核心就是多练、多踩坑、多总结。掌握完整的单链表操作后,可以进一步学习双向链表、循环链表,或者基于链表实现栈、队列等数据结构。后续我会继续更新数据结构的更多内容(单链表应用、双向链表以及用链表实现的小游戏等),敬请关注!