单链表的查找、插入和删除,是很多人第一次认真写数据结构代码时就会翻车的三兄弟。稍不注意就是“程序崩了”“结果不对”“删除结点之后链表丢了”。在期末考试、考研真题和笔试手撕代码里,这三个操作又几乎是必考项,考完容易忘,忘了又得回来看。
这一类题真正难的不是语法,而是两件事:第一,指针连接顺序,顺序一颠倒,要么断链,要么覆盖,要么人为形成环;第二,边界条件,比如空表、头结点、尾结点、位置越界。只要把这两个问题想清楚,三个操作就能一次性写对。
这篇文章我不会只给结论,而是按实际落地顺序走一遍:先理解为什么容易写错,再准备环境,然后逐个实现查找、插入、删除,最后给出测试和排查方法。如果你是期末冲刺、考研复习或者准备手撕代码,这篇文章可以帮你把这些操作从“背得出”变成“能现场写对”。
1. 先看清问题的本质:单链表操作容易错在哪
1.1 单链表的基本结构
单链表是一组结点组成的线性结构,每个结点包含数据域和指针域。指针域只保存下一个结点的地址,所以任何一个结点想找前驱,必须从头重新遍历。这是它和顺序表最大的区别:顺序表可以通过下标直接访问,单链表只能沿着 next 逐个移动。
查找、插入、删除,本质都是“在指针链上找到目标位置,再修改若干指针指向”。如果能画出链表图,大部分错误都能避免。很多教科书代码看不出问题,是因为人脑很难模拟“下一步到底指向谁”的过程。我建议你练习时准备好纸笔,每个操作都画一遍,再对照代码看。
1.2 带头结点和不带头结点的差别
教学中最常见的写法是带头结点的单链表。头结点是一个不存实际数据的额外结点,好处很多:插入、删除在表头时,不需要单独判断“插入位置是不是第一个元素”,位置逻辑可以统一处理;空表也有一个稳定的结点,不会让头指针直接变成 NULL。
但要注意:头结点不是第一个数据元素,第一个数据元素是L->next指向的结点。很多人操作时会把头结点当成数据结点,从而多算一个位置,结果查找、插入、删除全乱。
不带头的写法也能实现,但边界判断会比较琐碎。比如在第一个位置插入时,需要直接修改头指针 L 本身;删除第一个结点时也要修改 L。刚学链表,建议先统一使用带头结点版本,先跑通,再考虑不加头的变体。
1.3 为什么建议先画图再写代码
我见过很多学生一上来就写代码,写完运行,崩溃,再改,再崩。问题不是不会写,而是没有在动手前把指针顺序想清楚。
插入操作至少要画清楚新结点 s 和插入位置前后的两个结点。删除操作至少要画清楚被删结点 q 和它的前驱 p。画完之后,把“先让谁指向谁”写清楚,再动手写。这样能避免最常见的问题——先改了p->next,后面拿不到原来下一个结点的地址,导致断链。
判断标准很简单:如果写插入时先执行p->next = s,再执行s->next = p->next,代码一定会错,因为第二个步骤拿到的变成 s 自己。这种错误靠调试不如靠画图。另外,写完后一定要手动模拟一次空表插入、尾部插入、删除最后一个元素。这三个场景最容易把人打回原形。
2. 搭建最小可运行环境,先把链表建出来
2.1 环境与准备
单链表不依赖特殊库,使用 C 语言标准库即可。Windows 下可以用 Dev-C++、Visual Studio,Linux 下用 gcc,macOS 下用 clang。关键是确保编译器支持 C99 或更新版本。如果编译器默认不支持bool类型,可以引入stdbool.h,或者直接用 int 返回 0/1。
我建议初学者不要只依赖 IDE 的图形化调试,先把printf打印练熟。单链表代码短,打印输出足够定位大部分问题。下面统一用 C 语言,结构体定义如下:
#include <stdio.h> #include <stdlib.h> #include <stdbool.h> typedef struct LNode { int data; struct LNode *next; } LNode, *LinkList;LNode是结点类型,LinkList是指向结点的指针类型。使用LinkList L时,L 就是头指针。有的教材写typedef struct LNode* LinkedList;,效果一样。
2.2 初始化带头结点的空链表
bool InitList(LinkList *L) { *L = (LNode *)malloc(sizeof(LNode)); if (*L == NULL) { return false; } (*L)->next = NULL; return true; }这里传入头指针的地址,是为了让函数外部也能拿到分配出来的头结点。如果你在 C++ 编译环境写代码,可以简写成bool InitList(LinkList &L),但 C 语言没有引用,所以上面给的是 C 语言写法。
初始化为什么必须把 next 置为 NULL?因为malloc分配的内存不是自动清零的。如果不置空,后续遍历可能访问到随机地址,这是很多“莫名其妙崩溃”的根源。每次分配结点后,都要养成手动赋值 next 的习惯。
2.3 建立链表:头插法和尾插法
初始化之后需要先有数据。常见有两种建表方式:头插法每次把新结点插到头部,得到的结果和输入顺序相反;尾插法保持输入顺序。
// 头插法建立带头结点的单链表 void CreateListHead(LinkList L) { LNode *s; int x; scanf("%d", &x); while (x != 9999) { s = (LNode *)malloc(sizeof(LNode)); s->data = x; s->next = L->next; L->next = s; scanf("%d", &x); } }// 尾插法建立带头结点的单链表 void CreateListTail(LinkList L) { LNode *r = L; int x; scanf("%d", &x); while (x != 9999) { LNode *s = (LNode *)malloc(sizeof(LNode)); s->data = x; s->next = NULL; r->next = s; r = s; scanf("%d", &x); } }这里的 9999 是终止输入符,也可以先用 n 表示元素个数,再循环读入。重点不是怎么读数据,而是理解每个新结点的插入位置。头插法修改的是头结点的 next,尾插法必须维护一个尾指针 r。
为什么尾插法要维护尾指针?如果每次都遍历到链表尾部再插入,插入 n 个结点的时间会变成 O(n^2)。维护尾指针后,每次插入都是 O(1),整体建表时间是 O(n)。这也是一个通用经验:频繁在尾部追加元素时,不要反复从头遍历找尾结点。
打印函数是所有调试的基础。建议第一时间写好:
void PrintList(LinkList L) { LNode *p = L->next; while (p != NULL) { printf("%d -> ", p->data); p = p->next; } printf("NULL\n"); }打印结果中的最后一个输出是 NULL,能直接帮你判断遍历有没有越界、链表是否成环。如果出现无限打印,优先怀疑链表成环。
3. 查找操作:先解决“找到位置”的问题
3.1 按位查找
按位查找返回第 i 个数据结点。通常约定第 1 个数据结点是第一个实际存储的元素,不是头结点。为了让插入和删除能统一处理“第 i-1 个前驱”,我一般把按位查找写成:传入 0 时返回头结点。
LNode *GetElem(LinkList L, int i) { if (i < 0) { return NULL; } LNode *p = L; int j = 0; while (p != NULL && j < i) { p = p->next; j++; } return p; }这个实现里,i = 1时从 L 走一步到第一个数据结点;i = 0时不进入循环,直接返回 L,也就是头结点。返回 NULL 的情况有两种:链表长度不够,或者 i 传成了负数。循环条件天然保护了 p,不会访问空指针。
按位查找的时间复杂度是 O(n)。这一点和顺序表完全不同:顺序表是 O(1),单链表不管查哪个位置,都要从头走起。所以“在链表中按位置找元素”并不适合频繁使用,如果经常按下标访问,应该选顺序表或数组。
3.2 按值查找
按值查找用于找到第一个 data 等于 e 的结点:
LNode *LocateElem(LinkList L, int e) { LNode *p = L->next; while (p != NULL && p->data != e) { p = p->next; } return p; }返回值的理解方式很简单:找到就返回结点地址,找不到就返回 NULL。调用方拿到返回结果后,第一件事是判空:
LNode *res = LocateElem(L, e); if (res != NULL) { printf("found: %d\n", res->data); } else { printf("not found\n"); }如果出现“找到了但结果不对”,先检查 L 是否带头结点。很多人在按值查找里写while (L != NULL && L->data != e),结果把头结点的随机数据当成有效数据。按值查找应该从L->next开始,因为第一个有效结点才是数据结点。
3.3 查找操作的常见误区和效率判断
单链表不能使用二分查找。很多人学完折半查找后,会幻想“链表有序,所以可以用二分查找”,但二分查找的前提是随机访问,链表不能满足。即使对升序链表查找,也只能用顺序遍历,最好情况 O(1),最坏 O(n)。
查找操作经常作为插入删除的前置步骤。比如按位插入要先找到第 i-1 个结点,这本质上是一次按位查找;按值删除要先找到目标结点的前驱。所以查找写不对,插入删除都会跟着错。
为了避免每次演示都重复这段查找逻辑,建议直接把 GetElem 封装好,插入删除函数内部复用它。基础操作先写好,上层操作就不用到处复制 while 循环。这也是写链表代码时很重要的小重构:复用比复制安全。
4. 插入操作:修改指针的顺序不能乱
4.1 指定位置插入
带头结点单链表的按位插入,核心是先拿到第 i-1 个结点,再把新结点接进去:
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)); if (s == NULL) { return false; } s->data = e; s->next = p->next; p->next = s; return true; }插入时最容易犯的错误是顺序颠倒。假如先写p->next = s;,再写s->next = p->next;,那么第二个操作等于让 s 指向自己,后面的部分全部丢失。正确做法永远是:先让新结点指向后一个结点,再让前驱指向新结点。
这段代码里有两个失败点:一是 i 小于 1 直接返回 false;二是第 i-1 个结点不存在。如果允许在链表末尾插入,那么 i 可以等于当前长度加 1,此时第 i-1 个结点是尾结点,GetElem 能找到。如果 i 大于长度加 1,GetElem 会返回 NULL。这个判断标准很明确:长度加 1 是合法插入上限。
4.2 指定结点后插和前插
有时候已经拿到了某个结点指针 p,不是按位插入,而是要在它后面插入一个新结点。这种情况比按位插入更简单,上面 ListInsert 内的两步已经覆盖了。单独封装成函数,更便于复用:
bool InsertNextNode(LNode *p, int e) { if (p == NULL) { return false; } LNode *s = (LNode *)malloc(sizeof(LNode)); if (s == NULL) { return false; } s->data = e; s->next = p->next; p->next = s; return true; }如果在给定结点 p 之前插入一个新结点,但只有 p 的地址,没有头结点,也没有 p 的前驱,严格说做不到 O(1) 插入。不过有一个经典的“偷天换日”技巧:先申请 q,把 q 插入到 p 后面,然后把 p 的 data 和 q 的 data 交换。从数据顺序上看,新元素确实出现在 p 前面。
bool InsertPriorNode(LNode *p, int e) { if (p == NULL) { return false; } LNode *q = (LNode *)malloc(sizeof(LNode)); if (q == NULL) { return false; } q->next = p->next; p->next = q; q->data = p->data; p->data = e; return true; }这个技巧只改变数据域,不改变结点物理位置,所以很多教材说它是“逻辑前插”。它能工作的前提是 p 不是孤立结点,并且应用场景允许通过数据交换模仿前插。实际工程里如果对结点地址有强依赖,就要谨慎使用。面试手撕代码时可以先说清楚:给定前驱的前插需要从头遍历;如果只给 p,可以用“后插 + 交换数据”的办法。
4.3 头插法和尾插法的本质
创建链表时用到的头插法,本质上就是“每次都执行一次在头结点后面的插入”。尾插法本质上就是“每次都执行一次在尾结点后面的插入”。所以插入函数一旦写对,建表函数就不会错。
选头插还是尾插,取决于是否需要保持输入顺序。头插法反转输入顺序,适合“把已有链表逆序建立”这类场景;尾插法保持顺序,适合正常建表。如果不小心把顺序搞反,用打印函数检查输出顺序即可。
插入操作做完之后,判断标准很简单:打印链表,元素顺序应该和预期一致;如果每次插入后打印,能看到新结点出现在正确位置。如果只在最后打印一次,一旦出错很难定位。学习阶段建议在关键操作后都调用 PrintList。
5. 删除操作:注意内存释放和尾结点边界
5.1 按位删除
按位删除的基本思路:找到第 i-1 个结点 p,判断它的下一个结点是否存在,然后让 p->next 跨过待删结点 q,再把 q 释放。
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; }删除操作最容易漏掉的判断是p->next == NULL。如果 p 是尾结点,那 p->next 本来就是 NULL,说明第 i 个结点不存在。此时如果直接执行q = p->next,q 为 NULL,后面访问 q->data 就会崩溃。
删除另一个容易犯的错误是顺序问题。如果先执行free(q),再让 p->next 跨过 q,q 已经被释放,q->next 属于非法访问。正确顺序是:先取得被删结点的数据,再把被删结点的后继接到前驱上,最后 free(q)。从代码看就是先在p->next = q->next之前或之后取值都可以,但在free(q)之前完成剩下的指针修改。
5.2 删除指定结点
和前插类似,如果已经拿到某个结点 p 的地址,要求删除 p 本身,但不知道前驱,通常也用“偷天换日”处理:把 p 的后继 q 的数据复制到 p,再把 p 指向 q 的下一个,释放 q。从数据上看,p 所在位置的元素被替换成了后继的值,等效于删除了 p 这个逻辑结点。
bool DeleteNode(LNode *p) { if (p == NULL || p->next == NULL) { return false; } LNode *q = p->next; p->data = q->data; p->next = q->next; free(q); return true; }这里有一个必须说清的边界:如果 p 是最后一个结点,p->next 为 NULL,上面的方法无法使用,只能从头遍历找到 p 的前驱,再让前驱的 next 跨过 p,最后释放 p。这是无法回避的 O(n) 场景。这也是单链表的特点:删除已知结点时看似给了指针,但没有前驱信息,仍然可能退化成顺序查找。
有的教材把“删除指定结点”称为 O(1) 操作,前提是不允许删尾结点,或者不要求释放真实地址对应的内存。实际代码中要区分两种场景:一种传入前驱和待删结点,一种只传入待删结点。面试时最好主动确认是否允许删除尾结点。
5.3 内存释放与指针置空
C 语言中用 free(q) 释放结点内存后,q 指针本身仍然保存着旧地址,会变成野指针。虽然它在函数结束后不再使用,但为了防止误用,可以在 free 之后写q = NULL;。这在大型程序里尤其重要:一个被释放的指针如果继续参与后续逻辑,轻则读到脏数据,重则二次 free 导致崩溃。
删除链表所有结点时也一样,不能直接while (L != NULL) { free(L); L = L->next; },因为 free(L) 之后 L->next 已经不可访问。正确做法是先用临时变量保存下一个结点,再释放当前结点:
void DestroyList(LinkList L) { LNode *p = L; while (p != NULL) { LNode *next = p->next; free(p); p = next; } }批量删除、销毁链表这类操作,核心原则都是:先保存后继,再释放当前。这和单个删除中“先保存 q->next,再 free(q)”是一脉相承的。
6. 测试方法与判断标准:怎么确认代码真的写对了
6.1 从最小样例开始
很多人写完链表代码,直接用一个很长的输入测试,结果出错后完全不知道问题在哪一步。我更建议先跑三个最小样例:
- 初始化空链表后直接打印,应该输出 NULL。
- 在空链表中插入第一个元素,打印应该看到这一个元素。
- 删除最后一个元素,打印应该回到 NULL。
这三个样例分别对应空表创建、表头插入、尾删除三个边界。能把这三个跑通,基础正确性就有一半保证。
之后再加普通场景:连续插入 1 2 3,按顺序插入或头插,验证顺序;删除中间结点,验证链是否连续;按值查找存在的和不存在的元素,验证返回值。
6.2 测试用例设计表
测试不要瞎写,可以按下表设计,覆盖正常、边界、异常三类:
| 操作 | 输入/场景 | 预期结果 |
|---|---|---|
| 初始化 | 空表 | 打印 NULL |
| 按位插入 | 第一个位置插入 5 | 打印 5 -> NULL |
| 按位插入 | 在尾部插入 | 新元素在末尾 |
| 按位插入 | i 大于长度+1 | 返回 false,链表不变 |
| 按位查找 | 查找第 1 个元素 | 返回第一个数据结点 |
| 按值查找 | 查找不存在的值 | 返回 NULL |
| 按位删除 | 删除中间元素 | 返回被删数据,链表连续 |
| 按位删除 | 删除最后一个元素 | 结束后链表不为 NULL 但尾元素改变 |
| 指定结点删除 | p 是尾结点 | 函数返回 false 或遍历前驱 |
| 链表销毁 | 多次调用 | 不崩溃,不重复释放 |
这里的关键判断标准是:每次操作后链表仍然能够从头完整遍历到 NULL,没有丢元素,没有形成环,也没有访问到无效地址。
6.3 打印与返回值检查
测试阶段我建议在插入、删除后调用 PrintList,观察元素顺序。如果结果不对,按“刚刚执行了哪个操作”缩小范围。不要等到所有代码写完再统一调试,那样排查成本会高很多。
对返回值也要检查。插入、删除函数返回 bool 却没有检查,是很多“运行不报错但结果不对”的根源。调用方可以这样写:
if (ListInsert(L, 1, 10)) { PrintList(L); } else { printf("insert failed\n"); }如果能用断言,可以在逻辑上强制校验。比如删除后确认p->next == NULL或链表能完整走完。不过断言只在调试版本有效,发布版本通常会被禁用,所以关键校验还是应该写清楚。
7. 常见报错与排查链路
7.1 程序崩溃、卡死、无输出的排查顺序
链表相关问题,症状常常很吓人,但原因基本集中在几个点。按下面的顺序排查,比直接翻代码更高效:
- 先看现象:是启动就崩,还是操作某个位置后崩?是无限打印还是直接无输出?
- 再看输入:链表是否带头结点?位序从 1 开始还是 0 开始?终止输入符是否正确?
- 再看指针:插入时是否先改 p->next 导致断链?删除时是否访问了 free 之后的结点?
- 再看内存:malloc 是否判空?free 后是否置 NULL?是否重复释放?
- 最后看逻辑:循环条件里 p 和 p->next 谁为空?有没有把头结点当成数据结点?
这个顺序不是随意排的。前两步定位外部条件,第三步定位结构破坏,第四步定位资源问题,第五步定位边界设计。直接跳到代码里找问题,很多时候会忽略输入约定不同带来的差异。
7.2 断链和野指针怎么判别
断链的典型表现是:链表只打印出前半段,后面的元素不见了。原因是某个结点的 next 被改成 NULL 或错误地址,导致无法继续遍历。定位方法:在每次插入后打印,看是哪个操作造成断裂。
野指针的典型表现是:程序在遍历过程中崩溃,或者打印出超大随机数。原因是访问了已经释放或未初始化的内存。定位方法:检查所有 malloc 后是否赋值,free 后是否仍被使用。尤其要注意分配头结点后没有置 next 为空,大概率就是野指针来源。
假设代码在删除最后一个元素后崩溃,优先看删除条件。因为最后一个元素没有后继,像 DeleteNode 这种“偷天换日”方法不适用,必须走前驱删除。很多教科书里的 O(1) 删除实现都没有覆盖尾结点,这是一个重要边界。
7.3 内存泄漏和重复释放
内存泄漏在短小程序里不容易看到,但反复插入删除时,如果每次申请结点都没有在删除时 free,内存会持续增长。判断标准是:连续执行一万次插入删除,内存不会出现明显增长。Linux 下可以观察进程内存变化,Windows 下可以用任务管理器观察。
重复释放是另一个常见问题。比如删除函数中已经 free(q),外面又把 q 当作有效结点处理,导致二次释放。对策:free 后立刻置 NULL,每次调用删除后不要再使用被删结点的指针。销毁链表同理,要用临时变量保存 next。
7.4 手撕代码和在线评测时的建议
如果题目让你写出完整函数,建议先确认接口。比如函数参数是LinkList L还是LinkList &L,删除值是用出参传出还是用 int 返回值。不同平台要求不同,接口错了代码再对也可能拿不到分。
写手撕代码时,我一般先写 GetElem 和 PrintList,再写插入删除,最后写 main 做验证。不要一上来就写完整程序,而是核心函数逐个验证。手撕题通常不会要求打印详细日志,但会在函数里暴露大量空指针和边界问题,所以宁可写得保守:每次访问 next 之前先判空。
如果出现“返回结果不对,但不崩溃”,优先检查位序约定。有的教材把第 0 个元素作为第一个数据结点,有的从 1 开始。带头结点时,我习惯把“1”约定为第一个有效数据元素,GetElem(0) 返回头结点,这样插入删除的前驱查找最自然。
单链表这套操作,真正让我从“背代码”变成“会写代码”的转折点,就是开始画图和设计边界测试。很多人觉得自己懂了,结果一运行就崩,原因不是智商问题,而是把指针顺序和边界条件当成细节忽略了。建议你回到编辑器,先初始化一个带头结点的空链表,然后跑完最小样例,再逐步加入插入删除。能把每一步打印结果都解释清楚,这三个操作才算是真正过关了。