数据结构精讲:单链表从零到精通,一文搞定所有核心操作与高频考点
单链表像一列不知道总长的火车:每节车厢(节点)只记住「下一节是谁」,想找第 N 节只能从车头一节节数过去,但插一节新车厢只需改一根指针。
核心思想
单链表用分散的节点存储数据,每个节点 = 数据 + 一个 next 指针。节点在内存里不连续,靠指针串成一条链。和顺序表(数组)相比:
| 顺序表 | 单链表 | |
|---|---|---|
| 随机访问 | O(1) | O(n),必须从头遍历 |
| 插入/删除 | O(n),要搬移 | O(1),只改指针(前提是已找到位置) |
| 内存 | 连续,cache 友好 | 分散,有指针开销 |
单链表的所有难点都集中在三件事:二级指针(要不要改头指针)、找前驱(单链表只能往后走)、边界情况(空表 / 单节点 / 头节点 / 尾节点)。
数据结构定义
typedef int SLTDataType;
typedef struct SListNode
{
SLTDataType data; // 数据域
struct SListNode *next; // 指针域:指向下一个节点
}SLTNode;
内存模型(不带头节点,plist 是头指针):
plist -> [data|next] -> [data|next] -> [data|next] -> NULL
节点1 节点2 节点3
关键约定:链表不带头节点(哨兵),plist == NULL 表示空链表。最后一个节点的 next == NULL 是链表的终止标志。
为什么需要二级指针(最高频考点)
链表头指针 plist 是 SLTNode* 类型。如果一个函数要修改 plist 本身(比如空表头插,要让 plist 指向新节点),传 plist(一级指针)只拷贝了指针值,函数内改的是副本,外部的 plist 不变。必须传 &plist(二级指针 SLTNode**)。
传一级指针 plist: 传二级指针 &plist:
函数内 *pphead 副本被改 函数内 *pphead 直接改外部 plist
外部 plist 不变 ❌ 外部 plist 真的变了 ✅
plist ──┐ &plist ──┐
↓ ↓
[节点] <-- 副本指向它 plist ──→ [节点]
↑
*pphead 直接操作外部 plist
判断法则:函数内是否要改变头指针的指向?是 → 二级指针;否 → 一级指针即可。
关键操作实现
创建节点 SLTBuynode
SLTNode * SLTBuynode(SLTDataType x)
{
SLTNode *pnewnode = (SLTNode *)malloc(sizeof(SLTNode));
if (pnewnode == NULL) // malloc 可能失败,必须检查
{
perror("malloc fail!");
exit(1); // 节点都建不出来,后续没法玩
}
pnewnode->data = x;
pnewnode->next = NULL; // 新节点 next 置空,防止野指针
return pnewnode;
}
逐行解释:每个节点都从堆 malloc 来,必须检查返回值。next 初始化为 NULL 是好习惯——即使忘了接链,也不会指向随机内存。所有插入函数都应通过 SLTBuynode 创建节点,避免重复代码。
打印 SLTPrint
void SLTPrint(SLTNode *phead) // 只读不改头指针,一级指针即可
{
SLTNode *pcur = phead; // 用游标遍历,不动原头指针
while (pcur) // pcur != NULL,到尾节点终止
{
printf("%d->", pcur->data);
pcur = pcur->next; // 游标后移一格
}
printf("NULL"); // 链表以 NULL 收尾
printf("\n");
}
逐行解释:用临时游标 pcur 而非直接动 phead,保护原头指针。循环条件 pcur(非 NULL)保证遍历到尾节点的 next==NULL 时停下。
头插 SLTPush_Front
void SLTPush_Front(SLTNode **pphead, SLTDataType x)
{
assert(pphead); // 防御二级指针本身为空
SLTNode *pnewnode = SLTBuynode(x);
pnewnode->next = *pphead; // 新节点先指向原头
*pphead = pnewnode; // 再更新头指针指向新节点
}
逐行解释:头插 O(1),两步搞定。顺序很重要:必须先 pnewnode->next = *pphead(让新节点抓住原链表),再 *pphead = pnewnode(头指针改指向新节点)。如果反过来先改 *pphead,原链表的头就丢了。
插入前: plist -> A -> B -> NULL
步骤1: plist -> A -> B -> NULL
pnewnode -> A (新节点.next = plist)
步骤2: plist -> pnewnode -> A -> B -> NULL (plist = pnewnode)
尾插 SLTPush_Back(已修复)
void SLTPush_Back(SLTNode **pphead, SLTDataType x)
{
assert(pphead);
SLTNode *pnewnode = SLTBuynode(x);
if (*pphead == NULL) // 空链表:新节点直接当头
{
*pphead = pnewnode; // 改头指针 → 必须二级指针
}
else
{
SLTNode *pptail = *pphead;
while (pptail->next) // 遍历找尾节点(next==NULL 的)
{
pptail = pptail->next;
}
// 把新节点接到尾节点后面;赋值方向不能写反
pptail->next = pnewnode; // ✅ 尾节点.next 指向新节点
}
}
逐行解释:分两种情况。空表时新节点就是头,要改 *pphead,所以必须二级指针。非空表要遍历到尾节点(next==NULL 的节点),然后把尾节点的 next 指向新节点。
修复前的 bug:原来写成 pnewnode = pptail->next;,这是把新节点指针指向了「尾节点的 next(即 NULL)」,新节点根本没接进链表,数据丢失。正确方向是 pptail->next = pnewnode;——赋值方向 = 数据流方向,让旧尾指向新节点。
为什么尾插 O(n):每次都要从头遍历到尾。如果频繁尾插,应改用带头尾指针的结构,或用双向链表。
尾删 SLTPop_Back
void SLTPop_Back(SLTNode **pphead)
{
assert(pphead && *pphead); // 二级指针和链表都不能空
if ((*pphead)->next == NULL) // 只有一个节点
{
free(*pphead);
*pphead = NULL; // 删完变空表,改头指针
}
else
{
SLTNode *ptail = *pphead;
SLTNode *prev = *pphead; // 前驱指针,紧跟 ptail
while (ptail->next) // 找尾节点
{
prev = ptail; // prev 先停在 ptail 当前位置
ptail = ptail->next; // ptail 往后走
}
free(ptail);
ptail = NULL;
prev->next = NULL; // 新尾节点的 next 置空
}
}
逐行解释:尾删的难点在于单链表无法直接找前驱,删尾节点后要把倒数第二个节点的 next 置空,否则它变成悬空指针。所以用快慢双指针:ptail 跑在前面找尾,prev 紧跟其后始终落后一格,循环结束时 prev 恰好是尾节点的前驱。
边界情况:
- 空表:
assert拦下。 - 单节点:删完要改
*pphead = NULL,必须二级指针。 - 多节点:用双指针找前驱。
头删 SLTPop_Front
void SLTPop_Front(SLTNode **pphead)
{
assert(*pphead && pphead);
SLTNode *next = (*pphead)->next; // 先存第二个节点,防丢
free(*pphead);
*pphead = next; // 头指针指向原第二节点
}
逐行解释:头删 O(1)。必须先用 next 存住原头节点的下一个,否则 free(*pphead) 后原头内存释放,(*pphead)->next 就读不到了。这是「先存后删」的经典套路。
查找 SLTFind
SLTNode * SLTFind(SLTNode *phead, SLTDataType x)
{
SLTNode *pcur = phead;
assert(pcur); // ⚠️ 问题:空链表 phead==NULL 会断言失败
while (pcur)
{
if (pcur->data == x)
return pcur; // 找到返回节点指针
pcur = pcur->next;
}
return NULL; // 没找到
}
逐行解释:遍历比较,找到返回节点指针(供后续 Insert/Erase 用),找不到返回 NULL。
潜在 bug:assert(pcur) 在空链表(phead==NULL)时会直接断言退出,但「空链表里找不到元素」本应正常返回 NULL,不该崩溃。正确做法是删掉这行 assert,让空表正常返回 NULL。
指定位置前插 SLTInsert
void SLTInsert(SLTNode **pphead, SLTNode *pos, SLTDataType x)
{
assert(*pphead && pphead);
assert(pos);
if (*pphead == pos) // pos 是头节点,退化为头插
{
SLTPush_Front(pphead, x);
return;
}
SLTNode *pnewnode = SLTBuynode(x);
SLTNode *prev = *pphead;
while (prev->next != pos) // 找 pos 的前驱
{
prev = prev->next;
}
pnewnode->next = pos; // 先连新节点→pos
prev->next = pnewnode; // 再连 prev→新节点
}
逐行解释:在 pos 前面插,难点又是找前驱(单链表的痛)。pos 是头节点时没有前驱,单独走头插逻辑。否则遍历找 prev,然后两步接线。
接线顺序(关键):必须先 pnewnode->next = pos(新节点抓住后继),再 prev->next = pnewnode(前驱抓住新节点)。如果先 prev->next = pnewnode,则 prev 原来指向 pos 的链断了,pos 后面整条链丢失。
prev -> pos 插入 pnewnode 到中间
步骤1: pnewnode -> pos (新节点先抓 pos)
步骤2: prev -> pnewnode (prev 再抓新节点)
结果: prev -> pnewnode -> pos
指定位置后插 SLTInsertAfter
void SLTInsertAfter(SLTNode *pos, SLTDataType x) // 一级指针!
{
assert(pos);
SLTNode *pnewnode = SLTBuynode(x);
pnewnode->next = pos->next; // 新节点先抓原后继
pos->next = pnewnode; // pos 再抓新节点
}
逐行解释:在 pos 后面插,不需要找前驱,O(1)。而且不改头指针,所以只传一级指针 pos。接线顺序同样是「先抓后继,再改前驱」。
规范问题:头文件声明是 SLTInsertAfter(SLTNode *phead, SLTDataType x),形参叫 phead,但实现里叫 pos。虽然类型一致能编译,但名字误导(让人以为要传头指针)。声明和实现的形参名应一致。
删除 pos 节点 SLTErase
void SLTErase(SLTNode **pphead, SLTNode *pos)
{
assert(pphead && *pphead);
assert(pos);
if (pos == *pphead) // 删的是头节点
{
SLTNode *next = (*pphead)->next;
free(*pphead);
*pphead = next; // 改头指针
}
else
{
SLTNode *prev = *pphead;
while (prev->next != pos) // 找前驱
prev = prev->next;
prev->next = pos->next; // 前驱跨过 pos 连后继
free(pos);
pos = NULL; // 置空形参(但对外部指针无效,见下)
}
}
逐行解释:删头节点单独处理(改头指针)。否则找前驱,让前驱的 next 跨过 pos 直接连 pos->next,再 free(pos)。
陷阱:函数内 pos = NULL 只置空了形参,调用者手里的 pos 指针仍指向已释放内存(悬空指针)。所以 SLTErase 后调用者必须自己把持有的指针置空,否则再用 pos 会 UAF(use after free)。
删除 pos 之后的节点 SLTEraseAfter
void SLTEraseAfter(SLTNode *pos)
{
assert(pos && pos->next); // pos 和它的后继都不能空
SLTNode *del = pos->next;
pos->next = del->next; // pos 跨过 del 连 del 的后继
free(del);
}
逐行解释:删 pos 后面的节点,不用找前驱,O(1)(前驱就是 pos 本身)。让 pos->next 跨过 del 连到 del->next,再 free(del)。
销毁 SLDestroy
void SLDestroy(SLTNode **pphead)
{
assert(pphead && *pphead);
SLTNode *pcur = *pphead;
while (pcur)
{
SLTNode *next = pcur->next; // 先存下一个,否则 free 后读不到
free(pcur);
pcur = next;
}
*pphead = NULL; // 头指针置空,防悬空
}
逐行解释:逐个 free 节点。必须先用 next 存住当前节点的下一个,因为 free(pcur) 后 pcur->next 这块内存已释放,再读就是 UAF。最后 *pphead = NULL 让外部头指针置空(要二级指针)。
复杂度分析
| 操作 | 时间复杂度 | 说明 |
|---|---|---|
| 头插 / 头删 | O(1) | 只改头指针和一两个 next |
| 尾插 / 尾删 | O(n) | 必须遍历找尾 / 找前驱 |
| 指定位置后插 / 后删 | O(1) | 已知 pos,不用找前驱 |
| 指定位置前插 / 删 pos | O(n) | 要遍历找前驱 |
| 按值查找 | O(n) | 顺序遍历 |
| 销毁 | O(n) | 逐个释放 |
核心结论:单链表在头部操作 O(1),在尾部和中间操作要找前驱,O(n)。这正是双向链表要解决的痛点。
常见陷阱清单
- 该用二级指针却用一级 → 改了头指针但外部不生效,空表插入/头删失效。
free前没存next→ UAF,读到已释放内存。- 接线顺序错 → 断链,丢失整段链表。
- 尾删不找前驱 → 倒数第二个节点的
next变悬空指针。 SLTErase后不置空外部pos→ 悬空指针 UAF。SLTFind的assert(pcur)→ 空链表查找直接崩溃。SLTInsertAfter声明实现形参名不一致 → 误导调用者。- 赋值方向写反(如修复前的尾插)→ 数据没入链。
我的实现 vs 教科书实现
- 已修复尾插 bug:
pptail->next = pnewnode。 - 残留问题:
SLTFind的assert(pcur)在空表会崩;SLTInsertAfter声明用phead实现用pos,命名不一致。 - 缺失接口:没有
SLTSize、反转链表、判环等常用函数。 - 风格:用二级指针管理头指针,是 C 语言单链表的经典写法(C++ 会用引用
SLTNode*&)。
拓展知识点
变体与进阶
- 带头单链表:加一个不存数据的哨兵头节点,空表也有头节点,头插/头删逻辑统一,不用特判空表。
- 双向链表:见项目
List模块,每个节点多一个prev指针,找前驱 O(1)。 - 循环链表:尾节点
next指回头节点,适合解决约瑟夫环问题。
常见考点(必刷题)
- 反转链表:三指针法(prev/cur/next),迭代或递归。
- 找中间节点:快慢指针,快走两步慢走一步。
- 判断有环 / 找环入口:快慢指针相遇判环,再从相遇点和头同步走找入口。
- 合并两个有序链表:dummy 头 + 双指针归并。
- 删除倒数第 K 个节点:快指针先走 K 步,再快慢同走。
与其他结构的关系
单链表是许多结构的基础:哈希表用链地址法处理冲突时每个桶是链表;图的邻接表用链表存边;LRU 缓存用双向链表(要 O(1) 删任意节点)。栈和队列也可以用单链表实现(头插头删做栈,头删尾插做队列)。
一句话记忆法
单链表:头指针是指南针,next 是绳结;改头用二级指针,删节点先存 next,接线先抓后继再改前驱,找前驱只能从头数。
9683



被折叠的 条评论
为什么被折叠?



