写单链表之前,先想清楚一个问题:为什么那么多初学数据结构的人,在“查找、插入、删除”这一节卡了很久?
因为数组太“温柔”了。数组的逻辑是连续的,你要删除一个元素,后面的全部往前挪;你要插入一个元素,后面的全部往后挪。这种“大规模搬移”在数据量小的时候无所谓,一旦数据量到达百万、千万级别,每一次插入删除都伴随着大量数据拷贝,性能立刻崩盘。
单链表之所以被设计出来,核心目的只有一个:牺牲随机访问的能力,换来插入和删除的 O(1) 复杂度。
但正因为它的物理存储不连续,导致“查”需要从头开始走,而“插”和“删”又容易把链子弄断。很多同学写代码时不是不会背逻辑,而是一到具体操作就不知道 next 指针该什么时候改、改完会不会丢结点、顺序反了会不会死循环。
这篇文章会从零开始,把单链表的查找、插入、删除三个核心操作完整拆开讲清楚。你会看到三个层面的东西:
- 逻辑层面:每一步操作的指针变化过程。
- 代码层面:基于严蔚敏《数据结构(C语言版)》带头结点单链表的完整实现。
- 实战层面:为什么 pre 指针这么重要、为什么删除后要 free、为什么很多教材课后题和考研 408 都在考这几个操作。
我们不用死记代码,而是把一个结点当成一个“纸条”,把 next 指针当成“纸条上的线索”。搞懂线索怎么传递,代码自然就写出来了。
1. 单链表的存储结构:先认识基本单元
在写查找、插入、删除之前,必须先搞清楚单链表在内存里长什么样。
单链表的基本存储单元是结点(Node),每个结点由两部分组成:
- 数据域(data):存放该结点的数据。
- 指针域(next):存放下一个结点的地址。
在 C 语言中,结点的定义通常长这样:
typedef struct LNode { int data; // 数据域 struct LNode *next; // 指针域,指向下一个结点 } LNode, *LinkList;注意一个细节:struct LNode *next不能写成LNode *next,因为在这个结构体声明的内部,类型别名LNode还没有定义完,必须用完整的struct LNode来声明指针。很多新手在这里报编译错误,就是没理解 C 语言的声明顺序。
再看看整个单链表的物理形态,假设有 3 个数据元素A、B、C,它们的实际存储可能是:
地址0x100: A | 0x108 地址0x108: B | 0x200 地址0x200: C | NULL每个结点的地址是分散的,靠next字段串成一条逻辑上的链。头指针L指向第一个结点。
这里有一个非常关键的概念:带头结点 vs 不带头结点。
带头结点的单链表会在第一个数据结点之前增加一个额外的结点,叫头结点。头结点的数据域可以留空,也可以存链表长度等信息,它的 next 才指向第一个真正存储数据的结点。头指针L指向头结点。
为什么很多教材(尤其是严蔚敏版和 408 统考教材)都默认带头结点?因为带头结点之后,空表和非空表的处理逻辑可以统一:
- 带头结点时,空表就是
L->next == NULL,而L始终有效。 - 不带头结点时,空表就是
L == NULL,一旦在头部插入或删除,必须修改头指针本身,需要用到二级指针或返回新头指针。
所以后面的所有代码,统一基于带头结点的单链表来写。这是最符合考试和工程使用习惯的写法。接下来先解决一个问题:如何把一个链表建立起来?因为查、插、删都得先有一张表。
2. 创建单链表:头插法和尾插法
很多教程把“创建”单独拆开讲,但实际上查找、插入、删除操作都会在创建过程中反复出现。头插法本质是“在头部反复插入”,尾插法本质是“在尾部反复插入”。搞懂创建过程,后面的核心操作就完成了一半。
下表是两种建表方式的对比:
| 建表方式 | 思路 | 插入位置 | 元素结果顺序 | 时间复杂度 |
|---|---|---|---|---|
| 头插法 | 每次新结点插入到链表头部 | 头结点之后 | 与输入顺序相反 | O(n) |
| 尾插法 | 每次新结点追加到链表尾部 | 链表尾部 | 与输入顺序一致 | O(n) |
2.1 头插法示例
#include <stdio.h> #include <stdlib.h> typedef struct LNode { int data; struct LNode *next; } LNode, *LinkList; // 头插法建立单链表 LinkList List_HeadInsert(LinkList &L, int arr[], int n) { L = (LNode *)malloc(sizeof(LNode)); L->next = NULL; // 初始为空表 for (int i = 0; i < n; i++) { LNode *s = (LNode *)malloc(sizeof(LNode)); s->data = arr[i]; s->next = L->next; // 新结点的 next 指向原本的第一个数据结点 L->next = s; // 头结点的 next 指向新结点 } return L; }这段代码中,最关键的是这两个赋值:
s->next = L->next; L->next = s;顺序不能颠倒。如果先执行L->next = s,那么原来的第一个数据结点地址就丢了,s->next = L->next这一步就会把 s 自己指向自己,造成链表断裂或死循环。
2.2 尾插法示例
尾插法需要一个额外的r指针始终指向当前链表的尾部:
LinkList List_TailInsert(LinkList &L, int arr[], int n) { L = (LNode *)malloc(sizeof(LNode)); L->next = NULL; LNode *r = L; // r 指向尾结点 for (int i = 0; i < n; i++) { LNode *s = (LNode *)malloc(sizeof(LNode)); s->data = arr[i]; s->next = NULL; r->next = s; // 当前尾结点的 next 指向新结点 r = s; // 更新尾指针 } return L; }尾插法的优点是生成的链表顺序和输入顺序完全一致,更符合人的直觉。在后面的完整示例中,默认使用尾插法。
这里要提醒一个重要问题:arr是在栈上分配的数组,malloc出来的结点在堆上。建表之后,栈上的数组生命周期结束并不影响链表,因为链表已经通过malloc在堆上给每个结点分配了独立空间。
3. 单链表的查找操作
查找是单链表最基础的操作,也是插入和删除的前置步骤。单链表是顺序存取结构,它没有数组那样的随机访问能力,所以查找只能从头开始,沿着 next 逐个遍历。
单链表的查找分成两种典型场景:
- 按位查找:给定序号 i,找第 i 个结点。
- 按值查找:给定值 e,找第一个 data 等于 e 的结点。
两种查找的时间复杂度都是 O(n),这正好呼应了数组 O(1) 随机访问和链表 O(n) 顺序访问的核心差异。
3.1 按位查找
按位查找的思路非常直接:从头结点的 next 出发,走 i-1 步,到达第 i 个结点。需要注意的是,这里有一个绝大多数新手都会犯的错误——第 i 个结点到底是哪一个结点。
头结点不算数据结点。所以p = L->next时,p 指向的是第 1 个数据结点。要找第 i 个结点,循环i - 1次。
// 按位查找:返回第 i 个结点 LNode *GetElem(LinkList L, int i) { if (i < 1) { return NULL; } LNode *p = L->next; // 第 1 个数据结点 int j = 1; while (p != NULL && j < i) { p = p->next; j++; } return p; // 如果 i 超出链表长度,返回 NULL }这里有一个「返回 NULL」的细节:如果 i 大于链表长度,循环会在p == NULL时提前退出,返回 NULL。所以调用者必须对 NULL 做判断,不能直接访问p->data,否则就会发生空指针访问。
3.2 按值查找
按值查找也很简单,从第一个数据结点开始,依次比较 data 字段,找到直接返回该结点,找不到返回 NULL:
// 按值查找:返回第一个 data 等于 e 的结点 LNode *LocateElem(LinkList L, int e) { LNode *p = L->next; while (p != NULL && p->data != e) { p = p->next; } return p; }这个函数返回的是第一个匹配的结点地址。如果链表里有多个相同值,它只返回第一个。如果希望返回所有匹配结点,就需要把逻辑改成在循环里逐个输出。
3.3 查找操作的时间复杂度分析
| 操作 | 最好情况 | 最坏情况 | 平均情况 |
|---|---|---|---|
| 按位查找第 i 个结点 | O(1),i=1 时 | O(n),i=n 时 | O(n) |
| 按值查找 | O(1),第一个就是 | O(n),最后一个才是 | O(n) |
这个 O(n) 不是缺陷,而是单链表为了支持 O(1) 插入删除所付出的代价。理解这一点,才能理解为什么工程上频繁查找的场景要选数组或哈希表,频繁插入删除的场景才选链表。
4. 单链表的插入操作
插入操作是单链表中最重要的部分,因为它决定了你是否真正理解了指针的概念。很多人觉得指针难,其实不是指针难,而是搞不清楚“一个指针变量里存的是谁的地址、修改这个指针会影响谁”。
单链表的插入按位置可以分成三种典型情况:
- 头插:在头结点之后插入新结点,新结点成为第 1 个数据结点。
- 尾插:在最后一个结点之后插入新结点,新结点成为新的尾结点。
- 中间插入:在第 i 个位置插入新结点,需要先找到第 i-1 个结点。
其中头插的本质就是在第 1 个位置插入,尾插本质是在第 n+1 个位置插入。所以只要把“在第 i 个位置前插入”这个核心操作写清楚,其他两种情况自然解决。
4.1 在第 i 个位置插入结点
分两步:
第一步,调用GetElem(L, i-1)找到前驱结点p。 第二步,让新结点s的 next 指向 p 原来的后继,再让 p 的 next 指向 s。
// 在第 i 个位置插入元素 e bool ListInsert(LinkList L, int i, int e) { if (i < 1) { return false; } LNode *p = GetElem(L, i - 1); // 找到第 i-1 个结点 if (p == NULL) { return false; // i 超出范围 } LNode *s = (LNode *)malloc(sizeof(LNode)); s->data = e; s->next = p->next; // 新结点先接上原来的下一个结点 p->next = s; // 前驱结点再接上新结点 return true; }这里的关键代码是:
s->next = p->next; p->next = s;如果顺序颠倒,先执行p->next = s,那 p 的原始后继就找不到了,s 的 next 不知道该指向哪里,链表在插入点之后的部分就彻底丢失。凡是涉及链表指针修改,永远先接后断,这句话值得反复默念。
4.2 前插操作:把“查前驱”省略掉的技巧
假设知道一个结点的地址p,想在 p 前面插入一个新结点 s,按常规思路必须从头遍历找到 p 的前驱。这是 O(n) 的操作。
但单链表有一个经典技巧:先把 s 插到 p 的后面,再交换 s 和 p 的 data。这样从最终结果看,s 就在 p 的前面了,而时间复杂度是 O(1)。
// 在结点 p 之前插入结点 s,时间复杂度 O(1) bool InsertPriorNode(LNode *p, LNode *s) { if (p == NULL || s == NULL) { return false; } s->next = p->next; // 先把 s 插到 p 后面 p->next = s; // 交换 data,相当于 s 到了 p 的前面 int temp = p->data; p->data = s->data; s->data = temp; return true; }这个技巧在考研 408 算法题里经常出现,因为题目往往只给某个结点的指针,不给你头指针。如果不会这个 O(1) 前插技巧,题目就会变成 O(n) 的遍历查找,虽然也能解,但复杂度不达标。
不过也要提醒一句:这个技巧只适用于可以交换数据域的场合。如果数据域是一个很大的结构体,交换 data 的代价可能比遍历还高。这时候就要评估到底用哪种方案。
4.3 插入操作的时间复杂度
| 插入位置 | 平均移动次数 | 时间复杂度 |
|---|---|---|
| 已知结点 p,在 p 之后插入 | 0 | O(1) |
| 已知结点 p,在 p 之前插入 | 0(交换数据) | O(1) |
| 按位置在第 i 个位置插入 | O(n),主要花在查找前驱 | O(n) |
单链表真正 O(1) 的效率体现在已经找到位置的前提下,而不是凭空说“单链表插入快”。如果你要插入的位置需要从头遍历才能找到,那整体依然是 O(n)。这个点面试时高频出现,不要答错。
5. 单链表的删除操作
删除操作与插入操作是对称的:插入的核心是改两个指针,删除的核心也是改两个指针,但多了一个释放内存的问题。
如果删除的是中间结点,核心步骤是:
- 找到前驱结点
p。 - 让
p->next指向被删除结点的后继。 - 保存被删除结点的数据(如果需要)。
free释放被删除结点的内存。
5.1 按位删除第 i 个结点
// 删除第 i 个结点,并用 e 返回被删除元素的值 bool ListDelete(LinkList L, int i, int *e) { if (i < 1) { return false; } LNode *p = GetElem(L, i - 1); // 找到第 i-1 个结点 if (p == NULL || p->next == NULL) { return false; // 第 i 个结点不存在 } LNode *q = p->next; // q 指向被删除结点 *e = q->data; // 取出数据 p->next = q->next; // 把 q 从链表中摘除 free(q); // 释放内存 return true; }这里的边界条件非常有讲究:
- 如果
i == 1,GetElem(L, 0)返回头结点,删除第一个数据结点的逻辑和删除中间结点完全一致。 - 如果
i > 链表长度,p == NULL或者p->next == NULL,直接返回 false。 - 删除后必须
free(q),否则就造成内存泄漏。
尤其要注意p->next == NULL这个判断。如果 p 是最后一个结点,那么 p->next == NULL,说明没有第 i 个结点可删。如果单独判断 i 是否超过长度但忘记了 p->next 这一判断,在某些边界情况下会操作空指针。
5.2 删除指定结点 p:又一个经典 O(1) 技巧
类似前插,如果只知道某个结点的地址 p,想删除它,常规思路是找到它的前驱。但有一个 O(1) 的替代方案:把后继 q 的数据拷贝到 p,然后删除 q。
// 删除指定结点 p,时间复杂度 O(1) bool DeleteNode(LNode *p) { if (p == NULL || p->next == NULL) { return false; // 如果 p 是最后一个结点,无法用此方法 } LNode *q = p->next; // q 是 p 的后继 p->data = q->data; // 把 q 的数据复制到 p p->next = q->next; // 把 q 从链表摘除 free(q); // 释放 q return true; }注意一个关键限制:这个方法不能用来删除链表的最后一个结点。因为 p 是最后一个结点时,p->next == NULL,没有后继可以复制数据。这种情况下,仍然需要从头遍历找到前驱才能删除。
所以这种“偷天换日”的删除技巧不是万能的。考试时如果题目说“删除给定指针 p 指向的结点,且 p 是唯一已知指针”,通常默认 p 不是最后一个结点。如果题目没有说明,你要在答案里补充边界情况的处理。
5.3 删除操作的时间复杂度
| 删除方式 | 时间复杂度 | 使用条件 |
|---|---|---|
| 已知前驱,删除后继 | O(1) | 需要能拿到前驱指针 |
| 按位删除第 i 个结点 | O(n),主要花在查找前驱 | 通用 |
| 已知结点 p,复制后继法删除 | O(1) | p 不能是最后一个结点 |
单链表的删除操作,时间复杂度的大头永远是“找前驱”。所以如果业务代码里大量出现“按值删除”且链表很长,需要评估是否改用其他数据结构,比如哈希表辅助索引。
6. 完整示例:带头结点单链表查插删综合演示
为了让你能看到完整可运行的代码,这一节给出一个基于 C 语言、带头结点头插法建表的综合示例。这个示例涵盖了:
- 尾插法建表
- 打印链表
- 按位查找
- 按位插入
- 按位删除
我建议你先自己读代码,在关键位置标注出指针变化过程,再动手编译运行。
#include <stdio.h> #include <stdlib.h> #include <stdbool.h> typedef struct LNode { int data; struct LNode *next; } LNode, *LinkList; // 尾插法建立带头结点的单链表 LinkList CreateList(int arr[], int n) { LinkList L = (LNode *)malloc(sizeof(LNode)); L->next = NULL; LNode *r = L; for (int i = 0; i < n; i++) { LNode *s = (LNode *)malloc(sizeof(LNode)); s->data = arr[i]; s->next = NULL; r->next = s; r = s; } return L; } // 按位查找 LNode *GetElem(LinkList L, int i) { if (i < 1) { return NULL; } LNode *p = L->next; int j = 1; while (p != NULL && j < i) { p = p->next; j++; } return p; } // 按位插入 bool ListInsert(LinkList L, int i, int e) { if (i < 1) { return false; } LNode *p = GetElem(L, i - 1); if (p == NULL) { return false; } LNode *s = (LNode *)malloc(sizeof(LNode)); s->data = e; s->next = p->next; p->next = s; return true; } // 按位删除 bool ListDelete(LinkList L, int i, int *e) { if (i < 1) { return false; } LNode *p = GetElem(L, i - 1); if (p == NULL || p->next == NULL) { return false; } LNode *q = p->next; *e = q->data; p->next = q->next; free(q); return true; } // 打印链表 void PrintList(LinkList L) { LNode *p = L->next; printf("链表内容:"); while (p != NULL) { printf(" %d", p->data); p = p->next; } printf("\n"); } int main() { int arr[] = {10, 20, 30, 40}; int n = sizeof(arr) / sizeof(arr[0]); LinkList L = CreateList(arr, n); PrintList(L); // 1. 查找第 3 个结点 LNode *p = GetElem(L, 3); if (p != NULL) { printf("第3个结点的值为: %d\n", p->data); } else { printf("第3个结点不存在\n"); } // 2. 在第 2 个位置插入 99 if (ListInsert(L, 2, 99)) { printf("在第2个位置插入99成功\n"); } else { printf("插入失败\n"); } PrintList(L); // 3. 删除第 4 个结点 int deletedValue = 0; if (ListDelete(L, 4, &deletedValue)) { printf("删除的第4个结点值为: %d\n", deletedValue); } else { printf("删除失败\n"); } PrintList(L); // 4. 释放全部结点,防止内存泄漏 LNode *cur = L; while (cur != NULL) { LNode *next = cur->next; free(cur); cur = next; } return 0; }6.1 编译和运行
如果你使用的是 GCC,可以直接在终端编译:
gcc linkedlist_demo.c -o linkedlist_demo ./linkedlist_demo如果你使用的是 Visual Studio,需要把代码保存为.c后缀,在“解决方案资源管理器”中查看源文件属性,确认编译方式为“编译为 C 代码”。
6.2 预期输出
链表内容: 10 20 30 40 第3个结点的值为: 30 在第2个位置插入99成功 链表内容: 10 99 20 30 40 删除的第4个结点值为: 30 链表内容: 10 99 20 40注意删除第 4 个结点后,链表内容变成了10 99 20 40。因为插入 99 之后,链表变成了10 99 20 30 40,第 4 个结点是原来的 30,删除之后就剩 4 个元素了。
6.3 如何验证代码是正确的
验证步骤很简单,但不只是看输出:
- 先验证查找:输出第 3 个结点是 30,说明查找逻辑正确。
- 再验证插入:插入后打印的顺序是
10 99 20 30 40,说明插入没有破坏链。 - 最后验证删除:删除后打印的顺序是
10 99 20 40,说明删除正确摘除了 30。 - 如果使用 Valgrind 检查内存,确保没有任何内存泄漏:
valgrind --leak-check=full ./linkedlist_demoValgrind 输出中definitely lost: 0 bytes表示没有结点泄漏。这通常不是初学阶段必须做的,但到考研、面试或实际工程阶段,会检查内存是否释放完整。
7. 常见错误与调试方法
我接触过大量学习链表的初学者,下面这些错误几乎每个人都踩过。把它们整理成一张排查表,遇到问题按表检查,效率会高很多。
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 程序运行直接崩溃 | 访问了 NULL 指针 | 检查 GetElem 返回值是否为 NULL;gdb 查看崩溃调用栈 | 每个返回结点指针的调用处都做 NULL 判断 |
| 打印链表时出现死循环 | 某个结点的 next 指向了自己 | 打印每个结点的 next 地址,检查循环 | 画图检查指针赋值顺序,必须“先接后断” |
| 打印链表时少了一个结点 | 插入或删除时指针顺序写反了 | 在每次操作前后打印整个链表 | s->next = p->next; p->next = s; 不能换序 |
| 删除后链表内容没错但内存越用越多 | 删除结点没有 free | 用 Valgrind 检查 | 删除后调用 free(q) |
| 链表内容乱序 | 头插法的结果是逆序 | 确认建表方式 | 想保持原序使用尾插法,想逆序使用头插法 |
| 在头部插入失败 | 头结点不为空,p 指向头结点而非 NULL | 检查 GetElem(L, 0) 是否返回头结点 | 带头结点时 GetElem(L, 0) 返回 L,是正确的 |
7.1 最简单的调试方法:画图 + 打印地址
很多同学写链表卡住,是因为在脑子里模拟指针变化太抽象。强烈建议:拿一张纸,画格子代表结点,画箭头代表 next,每一步操作都按代码顺序把旧箭头划掉、画上新箭头。
等你养成了画图习惯,再看代码就会有一种“画面感”,比如看见s->next = p->next就知道这是在把 s 接到 p 后面的第一个位置上。
同时,你可以在代码里临时打印地址,观察指针的指向关系:
printf("p 的地址=%p, p->next=%p\n", p, p->next); printf("s 的地址=%p, s->next=%p\n", s, s->next);通过这些输入输出,能非常直观地看到指针重连的过程。
7.2 gdb 定位崩溃位置的思路
如果你用的是 Linux 环境,编译时加上调试信息:
gcc -g linkedlist_demo.c -o linkedlist_demo gdb ./linkedlist_demo在 gdb 中输入run运行,崩溃后输入bt查看调用栈,可以快速定位到是哪个函数哪一行访问了非法内存。
8. 单链表查插删的面试与考点总结
8.1 高频考点
单链表的查插删是考研 408 和各大厂面试手撕算法的“基础题中的基础题”。高频考点包括:
- 头插法的逆序特性:常用于链表反转、两数相加等题目。
- O(1) 前插和 O(1) 删除结点:只给结点指针时高效操作。
- 倒数第 k 个结点:双指针技巧。
- 判断链表是否有环:快慢指针。
- 合并两个有序链表:需要用尾插法逐个比较。
- 单链表反转:三指针迭代法或递归法,核心就是反复执行“先接后断”。
8.2 典型变式题一:单链表反转
单链表反转本质就是不断使用“头插法”,把原链表中的结点依次摘下,头插到新链表中:
// 单链表反转 LinkList ReverseList(LinkList L) { LNode *prev = NULL; LNode *cur = L->next; while (cur != NULL) { LNode *next = cur->next; // 先保存后继 cur->next = prev; // 反转指针 prev = cur; // 移动 prev cur = next; // 移动 cur } L->next = prev; // 头结点指向新的第一个结点 return L; }这里的关键又回到了“先保存后继”,如果不先把cur->next存到next变量,一旦执行cur->next = prev,原链表在 cur 之后的部分就丢了,循环也无法继续。
8.3 典型变式题二:合并两个有序链表
已知两个长度为 m 和 n 的升序单链表,合并后仍然有序。这类题在热搜词里出现过,是链表算法题的经典入门题。
// 合并两个升序单链表 LinkList MergeList(LinkList La, LinkList Lb) { LinkList Lc = (LNode *)malloc(sizeof(LNode)); LNode *pa = La->next; LNode *pb = Lb->next; LNode *pc = Lc; while (pa != NULL && pb != NULL) { if (pa->data <= pb->data) { pc->next = pa; pc = pa; pa = pa->next; } else { pc->next = pb; pc = pb; pb = pb->next; } } // 把剩余部分直接接上 if (pa != NULL) { pc->next = pa; } else { pc->next = pb; } return Lc; }这个函数的时间复杂度是 O(m+n),因为两个链表都只遍历一遍。注意这里用的是“尾插法”的思路,每个结点只会被移动一次,所以不会额外申请新空间。
8.4 遇到链表题的通用思路
如果面试或考试遇到一条没见过的单链表题,建议按下面顺序思考:
- 题目给没给头指针?如果只有某个结点指针,能不能用 O(1) 前插、O(1) 删除?
- 能不能用双指针(快慢指针、前后指针)?
- 能不能用头插法改变顺序?
- 能不能用递归?递归的终止条件和递推关系是什么?
- 是否需要 dummy 头结点来统一逻辑?很多链表的删除题,尤其涉及删除头结点的情况,用 dummy 结点可以避免大量边界判断。
9. 最佳实践与工程建议
写到这里,单链表的查插删已经从原理到代码完整过了一遍。最后给几条工程和备考建议。
9.1 不要直接修改传入的链表而不说明
实际工程中,如果函数会修改链表结构,要么在函数命名上明确表达,比如ListInsert、ListDelete,要么传入二级指针LinkList *L。不要写一个名为PrintAndModify的函数,但内部却悄悄改了链表结构。命名要诚实,签名要能体现副作用。
9.2 malloc/free 必须成对出现
在编写插入、删除逻辑时,要形成条件反射:
- 插入一个新结点,必然有一次
malloc。 - 删除一个结点,必然有一次
free。 malloc之后必须判断是否失败,虽然考试代码里经常省略,但工程上必须处理。
如果团队规范要求严格,应该封装一个安全 malloc 宏或统一内存管理模块。
9.3 边界条件优先考虑
单链表最容易出错的位置就是“头和尾”。每次写插入、删除,先问自己三个问题:
- 如果链表为空,代码还能正常运行吗?
- 如果操作的是第一个数据结点,头指针会被影响吗?
- 如果操作的是最后一个结点,next 指针会指向 NULL 吗?
这三个问题想清楚了,边界条件基本就覆盖完了。
9.4 考研复习和面试准备建议
如果你正在准备考研 408、软考数据结构或大厂面试:
第一优先级是手写代码。不是看书看会,而是白纸上手写。合上书,自己从无到有写出建表、查找、插入、删除、反转、合并六个函数,每个都调试通过。这个过程看起来笨,但效率远高于一直“看懂”别人的代码。
第二优先级是复杂度分析。每次写完一个操作,立即写出它的最好、最坏、平均时间复杂度,并且解释清楚复杂度主要花在“查找前驱”而不是“修改指针”。这几乎是 408 每年必考的得分点。
第三优先级是背熟边界条件。面试官问到链表插入时,大概率会紧接着问“如果 i 不合法怎么办”。答出 NULL 判断和返回 false,基本就过关了;答不出,说明还没真正理解。
9.5 工程上什么时候真的用链表
以 Python/Java 为例,日常开发中很少手写单链表,因为list、ArrayList、LinkedList都封装好了。但不要因此觉得链表没用:
- 实现 LRU 缓存时,哈希表 + 双向链表是经典方案。
- 操作系统内存管理中的空闲块链表、页面缓冲队列,底层就是链表思想。
- 文件系统的链式分配,用每个块的指针字段指向下一个块。
- 底层中间件中大量使用无锁链表、无锁队列,其核心思想仍然是“先接后断,防止 ABA”。
所以学单链表不是学一个考试数据结构,而是在练“如何通过指针组织离散内存”这套思维。这套思维会一直影响你对缓存、索引、队列、树的理解。
10. 总结
单链表的查找、插入、删除,是数据结构入门的第一个分水岭。
- 查找:只能从头遍历,O(n)。
- 插入:核心是“先接后断”,关键两步是
s->next = p->next然后p->next = s。 - 删除:核心是“找到前驱,越过当前”,别忘了
free。 - 前插/删除指定结点:可以用交换数据的方式做到 O(1),但要注意限制条件。
建议你现在就做一件事:打开编译器,把这篇文章里的完整代码敲一遍,运行通过之后,再手写一遍创建、查找、插入、删除四个函数,最后试着写一下链表反转和有序链表合并。把这几道题全部跑通之后,再看后面的栈、队列和树,你会发现它们都建立在同一套指针操作思维之上。收藏这篇文章,写代码卡住的时候回来看看,相信会省下不少时间。