☰
单链表详解:从指针操作到增删改查,彻底攻克边界条件
2026/10/20 5:18:09 网站建设 项目流程

写链表最容易踩的坑是什么?我在带新人和面候选人的时候,发现十个人里有九个都栽在“指针到底指向谁”上。链表这东西,核心就一句话:用指针把零散的内存串起来。每一个节点里不仅存数据,还得存下一个节点在哪儿。这种结构听起来简单,但真要动手写增删改查,边界条件一多,脑子就浆糊。

这篇文章我打算只讲透单链表。先把链表的基本性质拆明白,再给出可以直接抄的代码实现,最后把我这些年调试链表遇到的高频问题整理成速查表。不管是刚学数据结构的新手,还是准备面试的开发者,看这一篇基本够用。

1. 链表的核心性质:为什么它敢让逻辑顺序和物理位置没关系

1.1 链式存储的本质:一套“顺序信息”额外存储方案

数组在内存里是连续的一整块,a[0]后面紧跟着a[1],所以找第i个元素直接算地址就行。链表不干这事儿,它的每个节点可以住在内存的任意角落,节点之间通过指针互相指引。这样一来,顺序信息被单独拎出来存储,这就是“链式存储”的本质——逻辑上相邻的元素,物理上可以相隔十万八千里。

理解这个性质,才明白为什么插入删除快,为什么查找慢。数组插入要挪动之后的所有元素,链表只需要改两条指针。查找也一样,数组是O(1)随机访问,链表只能从头指针开始一个个往后走,O(n)顺序查找。这不是谁更优秀的问题,是存储结构决定的天然特性。

很多教材喜欢用“寻宝游戏”来类比。一张纸条上写着宝藏在哪棵树底下,到了那棵树底下的另一张纸条又告诉你下一条线索在哪儿。链表就是这种行为逻辑:头指针是起跑线,每个节点既是线索内容,也存放着下一条线索的位置。这种类比我建议记下来,后面写代码脑子要能映射出这幅画面。

1.2 单链表的边界定义:头指针、头结点、首元结点三者的关系

单链表有三个概念,初学者特别容易混:头指针、头结点、首元结点。

头指针是个指针变量,存的是链表中第一个结点(可能是头结点,也可能是首元结点)的地址。它是访问链表的唯一入口,以单链表为参数的函数,传的本质上就是这个头指针。

头结点是在首元结点之前额外加的一个结点,它的数据域一般不存有效数据,也可以存链表长度之类的附加信息。加头结点的最大好处是让“空表”和“非空表”的处理逻辑统一,这在代码里体验特别明显——后面实现部分我会反复强调。

首元结点才是真正存放第一个有效数据的结点。很多教材里带头结点的链表这么画:头指针 -> 头结点 -> 首元结点 -> 第二个结点 -> ... -> NULL。

尾结点的指针域一定指向NULL。这个NULL就是链表的终止标志,遍历的时候判断p->next != NULL,本质上就是在问“下一个结点还在吗”。

1.3 为什么宁可多花一个头结点,也别省这点空间

带头结点还是不带,争论由来已久。我的观点非常明确:做工程和学习,一律带头结点,别为了省那8个字节给自己找麻烦。

不带头结点的链表,插入第一个元素的时候,头指针本身要变,因为新节点变成了第一个,所以得传入二级指针,或者返回值是新的头指针。删除最后一个元素同理。而带了头结点,头指针指向的对象永远不变,所有操作看起来就像在链表的“内部”活动。这避免了将近一半的边界条件错误。

// 带头结点初始化的核心代码 #include <stdio.h> #include <stdlib.h> typedef struct LNode { int data; // 数据域 struct LNode *next; // 指针域,存直接后继的地址 } LNode; LNode* initList() { LNode *head = (LNode*)malloc(sizeof(LNode)); if (head == NULL) { return NULL; // 申请失败,标准做法是检查 } head->data = 0; // 数据域不用,可以存链表长度 head->next = NULL; // 空表标准形态 return head; }

2. 单链表的增删改查:每种操作的边界和指针顺序

2.1 head头插法:为什么它永远是最快的插入方式

头插法就是在头结点和首元结点之间插入新节点。每次插完,新节点都跑到链表最前面,所以如果反复用头插法构建链表,最终数据的顺序和输入顺序正好相反。

int insertAtHead(LNode *list, int value) { LNode *node = (LNode*)malloc(sizeof(LNode)); if (node == NULL) return 0; node->data = value; node->next = list->next; // 新节点先挂上旧的首元结点 list->next = node; // 头结点再接上新节点 return 1; }

这里有一个铁律:先接后断,先把新节点的next指向旧首元结点,再让头结点的next指向新节点。反过来,如果先执行list->next = node,旧首元结点就被丢了,整个链表从第二个结点开始全丢,这种错误我见过无数次。

时间复杂度O(1),不需要遍历,因为永远插在头部。这个操作常被用来做单链表的头插反转,后续会提到。

2.2 尾插法:先跑到底,再挂节点

尾插法就是找到链表的最后一个结点(next为NULL的那个),然后把新节点接上去。

int insertAtTail(LNode *list, int value) { LNode *node = (LNode*)malloc(sizeof(LNode)); if (node == NULL) return 0; node->data = value; node->next = NULL; LNode *p = list; while (p->next != NULL) { // 循环结束时 p 就是尾结点 p = p->next; } p->next = node; return 1; }

尾插法保持了数据的原始顺序,代价是每次都要从头遍历到尾部,时间复杂度O(n)。如果程序需要频繁尾插,工程上通常额外维护一个尾指针来优化,插入操作变成O(1),代价是删除尾结点的时候依然要遍历找前驱。空间换时间的思想在链表里体现得很充分。

2.3 按位插入:在位序i处插一个结点,核心是找到i-1

很多教材里的接口是ListInsert(list, i, e),表示在第i个位置插入元素e。注意,头结点算第0位。第i个位置的结点,前驱恰恰是第i-1个位置的结点。

int insertAtIndex(LNode *list, int i, int value) { if (i < 1) return 0; // 位置非法 LNode *p = list; int j = 0; while (p != NULL && j < i - 1) { // p最终指向第i-1个结点 p = p->next; j++; } if (p == NULL) return 0; // i超出链表长度 LNode *node = (LNode*)malloc(sizeof(LNode)); if (node == NULL) return 0; node->data = value; node->next = p->next; // 经典的先接后断 p->next = node; return 1; }

这里的边界条件值得反复咀嚼。p == NULL意味着第i-1个结点不存在,那第i个结点更不可能存在,说明i不合法。如果p存在但p->next == NULL,那它就是尾结点,新节点接后面完全没问题。别把这两种情况混为一谈。

2.4 按值删除与按位删除:一切删除的前提是先找到前驱

删除第i个结点,必须拿到第i-1个结点。如果直接拿到第i个结点就删,那就不知道第i-1个结点是谁,没法把前面的next绕过这个结点。除非你再从头遍历一遍。这就是“删除必须找前驱”的由来。

int deleteNode(LNode *list, int i, int *deletedValue) { // i表示位序,头结点不做计数,所以i从1开始 if (i < 1) return 0; LNode *p = list; int j = 0; while (p != NULL && j < i - 1) { p = p->next; j++; } if (p == NULL || p->next == NULL) return 0; // 前驱或者目标结点不存在 LNode *target = p->next; *deletedValue = target->data; p->next = target->next; // 让前驱绕过目标结点 free(target); // 释放内存,工程必修课 return 1; }

这里有三个边界条件:i<1非法;p为NULL说明第i-1个结点不存在;p->next为NULL说明目标结点本身不存在。判断p == NULL || p->next == NULL就是这两个边界情况同时兜底。

2.5 查找和修改:遍历是单链表的宿命

按值查找:从头结点后的第一个结点开始,逐个比较数据域,返回找到的结点或NULL。按位查找:走j < i步,注意这里和插入删除的前驱遍历不同,查找目标是第i个结点本身,不需要再往前多走一步。

LNode* findByIndex(LNode *list, int i) { if (i < 0) return NULL; LNode *p = list; int j = 0; while (p != NULL && j < i) { p = p->next; j++; } return p; // 可能为NULL,调用方自行判断 }

修改操作就是先定位再改数据域,复杂度跟查找一致,O(n)。链表在查找上不如数组,这是所有链式结构的通病。

3. 单链表进阶操作:反转、判空和构建一个完整可用的链表

3.1 单链表反转的两种主流写法

单链表反转是面试里最频繁出现的算法题。我见过太多种写法,最推荐下面两种。

就地反转法:准备三个指针pre、cur、next,cur从首元结点开始,逐个把next指往前驱,然后整体前进。

void reverseList(LNode *list) { if (list->next == NULL) return; // 空链表直接返回 LNode *pre = NULL; // 反转后,原首元结点变尾结点,next置NULL LNode *cur = list->next; // 从头结点的下一个开始 LNode *next; while (cur != NULL) { next = cur->next; cur->next = pre; pre = cur; cur = next; } list->next = pre; // 原链表尾结点变成了新首元结点 }

头插反转法:把链表从头到尾摘下来,再用头插法插回头结点后面。

void reverseByHeadInsert(LNode *list) { LNode *p = list->next; list->next = NULL; // 先把链表拆空 while (p != NULL) { LNode *temp = p->next; // 先保存后继 p->next = list->next; // 头插 list->next = p; p = temp; } }

两种方法时间复杂度一样,O(n),空间O(1)。就地在转的是pre指针,头插反转更容易理解,代码也更短。我的建议是两个都写熟练,因为不同的面试官喜欢不同的写法,你自己也要能解释清楚为什么变回了头结点。

3.2 链表长度与查找中间结点:快慢指针的运用

链表长度就是从头结点开始遍历,计数直到next为NULL。单链表不能像数组那样sizeof(a)/sizeof(a[0]),它没有内置长度信息。很多C工程的单链表会额外维护len字段,在头结点的data里存长度,插入+1,删除-1,这样查长度就是O(1),代价是多维护一层一致性逻辑。

查找中间结点有一个经典技巧:快慢指针。快指针每次走两步,慢指针每次走一步,快指针到末尾时,慢指针恰好指向中间结点。

LNode* findMiddle(LNode *list) { if (list == NULL || list->next == NULL) return NULL; LNode *fast = list->next; LNode *slow = list->next; while (fast != NULL && fast->next != NULL) { fast = fast->next->next; slow = slow->next; } return slow; }

这个操作不需要知道链表长度,一次遍历就完成,时间复杂度O(n),空间O(1)。快慢指针思路在链表题里到处可见——环的检测、倒数第k个结点都可以用它。

3.3 完整实战:读入n个整数,构建链表并支持三个基本操作

这一节我给一个可以直接编译运行的完整程序,整合了前面所有核心操作。场景是:读入n个整数建链表,然后执行若干次插入、删除、打印操作。

#include <stdio.h> #include <stdlib.h> typedef struct LNode { int data; struct LNode *next; } LNode; LNode* initList() { LNode *head = (LNode*)malloc(sizeof(LNode)); if (head == NULL) exit(1); head->next = NULL; return head; } void buildFromArray(LNode *list, int arr[], int n) { // 尾插法构建,保持原顺序 LNode *p = list; for (int i = 0; i < n; i++) { LNode *node = (LNode*)malloc(sizeof(LNode)); if (node == NULL) exit(1); node->data = arr[i]; node->next = NULL; p->next = node; p = node; } } void printList(LNode *list) { LNode *p = list->next; while (p != NULL) { printf("%d ", p->data); p = p->next; } printf("\n"); } int main() { int arr[] = {10, 20, 30, 40, 50}; int n = sizeof(arr) / sizeof(arr[0]); LNode *list = initList(); buildFromArray(list, arr, n); printf("初始链表: "); printList(list); insertAtHead(list, 5); printf("头插5后: "); printList(list); insertAtIndex(list, 3, 25); printf("在第3位插25后: "); printList(list); int delVal; deleteNode(list, 4, &delVal); printf("删除第4位(值%d)后: ", delVal); printList(list); reverseList(list); printf("反转后: "); printList(list); LNode *mid = findMiddle(list); if (mid != NULL) printf("中间结点值: %d\n", mid->data); // 释放内存 LNode *p = list; while (p != NULL) { LNode *temp = p; p = p->next; free(temp); } return 0; }

这里插入和删除实现可以翻译成更自然的形式,比如insertAtHead我用的是上一节定义的方法,你实际运行的时候把函数定义放在main之前即可。

3.4 链表的销毁:不释放内存的代码就是耍流氓

用malloc申请的节点,最后不释放,内存泄漏没跑。C语言的链表回收需要从头开始,逐个节点free。上面main函数末尾的回收代码就是标准写法。

void destroyList(LNode **list) { LNode *p = *list; while (p != NULL) { LNode *temp = p; p = p->next; free(temp); } *list = NULL; // 头指针置空,防止野指针 }

注意这里传入的是二级指针LNode **list,因为要修改头指针的值,同时在调用方把这个指针归零。很多面试题会拿这个考察传递机制,值传递不能改指针本身,这算一个经典考点。

4. 单链表常见运行错误与调试技巧实录

核心代码能跑通只是第一步,调链表程序才是真正的噩梦来源。

4.1 空指针和野指针:九成运行错误的元凶

空指针不多说了,就是没判断NULL就用了它。野指针更隐蔽,一般是从一个函数返回局部变量的地址,或者free之后指针还留着没置空。典型代码:

LNode* makeNode(int x) { LNode node; // 局部变量,函数一结束就失效 node.data = x; node.next = NULL; return &node; // 返回了栈上地址,调用方拿到的是野指针 }

正确写法:用malloc在堆上创建。

还有一种更隐蔽的:一个结点被free了,但链表的某个地方还残存着指向它的指针。这时候访问它,数据还在但已经不可控,随着后续malloc复用这块内存,就会出现时好时坏的诡异bug。排查链表问题时,先问自己:这块内存是不是已经释放了?

4.2 死循环:链表的周期律问题

打印链表时程序不退出,基本就可以判断链表里有环。排查思路有三种。

第一种,看链表构建和反转过程,特别留意尾结点的next。链表中最后一个节点的next必须为NULL,如果某次操作让某个节点的next指回了它自己或者前面某个节点,就形成环。检查p->next == p就能看出自环。

第二种,代码review确认循环终止条件。while (p->next != NULL)和while (p != NULL)是完全不同的两种循环,看你要在循环体内访问p还是p->next。如果循环体内访问p->next->data,那循环条件必须保证p->next不为NULL,否则就崩。

第三种,用快慢指针检测环是否存在,这是个很通用做法,快指针追上慢指针就说明有环。

4.3 插入删除顺序错误:先接后断永远不要忘

链表插入有个铁律:先接后断,严格说就是先让新节点next指向后继,再让前驱的next指向新节点。

举个例子,插入错误版:

// 错误示范 p->next = node; // p原来的后继被覆盖 node->next = p->next; // node->next指向了自己

这样写的结果就是链表断成两截,新的节点接上了,原链表的剩余部分找不到了。理解代码的正确做法:先把新节点的next绑定好后继,再改前驱的next,顺序不对一定会出问题。

删除也是同样的逻辑:让前驱next指向目标结点的后继,然后free目标结点。如果先free了目标结点再改前驱,就找不到目标结点后继了,或者已经在free之后访问这个内存,同样违规。

4.4 考试和面试会挖的坑:位序到底从0还是从1开始

教科书里的位序,有从0开始的也有从1开始的,不同教材做法不同。最合理的判断方式是看头结点计数与否。如果头结点算第0位,那第一个有效数据结点就是第1位。很多教材接口定义的时候会特别说明位序含义,面试的时候要主动问清楚,不要默认。我习惯让头结点不算位序,首元结点是第1位,这样插入和删除代码统一,不容易混。

面试里另一个高频坑是:用户传入的i等于链表长度或者大于链表长度时,你的代码有几个return。光这一步就足以筛掉一批候选人。按位插入的时候你要检查i<1非法和定位之后的p==NULL,删除的时候要额外检查p->next==NULL,这样才不会有越界访问。

5. 单链表的延伸与应用场景:学完基础之后往哪走

5.1 静态链表:没有指针的语言怎么模拟链表

C语言用指针实现链表很自然,但如果没有指针(比如早期的BASIC),逻辑上还是需要链表的结构。静态链表用数组模拟,每个数组元素是一个结构体,包含数据域和游标(存下一个元素在数组中的下标)。数组的下标关系替代指针的指向关系。

#define MAXSIZE 1000 typedef struct { int data; int cur; // 游标,存下一个结点的数组下标,相当于next指针 } StaticNode;

静态链表的插入删除仍然不需要移动大量元素,只是要维护空闲链表的分配和回收。它在教科书里地位独特,实际工程中很少直接用,但理解它有助于看懂某些内存池或文件系统索引的实现思路,算是一次思维拓展。

5.2 带头结点的循环单链表:解决最后一个结点找第一个的问题

把尾结点的next从NULL改回指向头结点,就得到了循环单链表。它解决了什么问题?从表尾走到表头不需要遍历,O(1)。如果同时维护尾指针,那从尾部访问头部,从头访问尾部,都只需要一步。

约瑟夫环问题用循环单链表的写法极其舒服。模拟n个人围成一圈报数,每次数到m就删除该节点,继续从下一个人开始报数,用循环链表的节点删除正好对应这个游戏规则。算法复杂度O(n*m),代码比数组模拟简短很多,思路也贴合问题本身。

5.3 单链表在LRU缓存和哈希表中的应用

LRU缓存淘汰算法的经典实现就是双向链表加哈希表。哈希表负责O(1)查找结点位置,双向链表负责O(1)插入删除。单链表行不行?理论上哈希表已经定位到了某个节点,删除它的时候还需要知道它的前驱,单链表需要遍历O(n)才能知道前驱在哪,所以必须用双向链表。这也是为什么很多工程上的链表都带两个方向的指针。

哈希表处理冲突的链地址法,用的就是单链表。哈希值相同的元素都挂在同一个桶的单链表上,这也是单链表最常见的工业应用。单个桶的数据量通常不大,单链表足够。

5.4 链表和数组怎么选:从内存与操作差异找到标准答案

这是一个面试常问问题,我给你一个标准答案框架。

数组的优势在于随机访问,按下标直接寻址,常数时间。缺点是插入删除要搬动元素,且需要连续内存,扩容成本高。链表恰恰相反,插入删除只需要改指针,不需要连续内存,但查找必须遍历,没有随机访问能力。

具体的场景选择:

  • 主要操作是按下标访问,选数组。
  • 数据量不确定,经常插入删除,选链表。
  • 需要极致的内存效率,数组紧凑,链表每个结点多一个next指针的开销。
  • 循环队列用数组实现,有成熟优雅的公式管理头尾指针;如果非要用链表实现循环队列,又要多维护一份头尾逻辑,自讨苦吃。

在工程里,数组和链表经常组合使用。哈希表的桶既可以选数组也可以选链表,取决于负载因子和冲突概率。没有绝对优选的答案,只有适不适合当前场景。

6. 实战心得:链表到底是难在哪里,怎么练才能彻底过关

练链表唯一的办法就是白纸上手写,别开IDE。当你能把链表创建、打印、反转、合并、删除倒数第k个节点全部闭卷写出来,而且一次性编译通过,你的链表基本功才算真正过关。

我见过不少候选人,算法题能秒出思路,一讲到链表细节就露馅——内存泄漏、边界条件考虑不全、插入顺序写反。说实话思路只是第一步,代码能不能跑、有没有内存问题,才是真实力的试金石。

调试链表程序,我个人的经验是三步走。第一步先画图,把每个节点的next画出来,判断指向关系对不对。第二步打印足够多的信息,每次操作后都打印全链表,不要只打印结果不打印中间态。第三步善用断言和防御性检查,在关键操作前检查指针是否为NULL,检查链表是否成环。把这三步用熟,链表就没有调不出来的bug。

再分享一个小技巧:写链表代码的时候,用变量名说话。p表示游走指针,pre表示前驱,target表示目标节点,temp表示临时保存的后继。每个变量职责单一,读代码的时候逻辑链路清楚,自己排查也快。我在代码评审里看到候选人的链表代码逻辑混乱,多半就是变量名起得随意,逻辑跟着也乱。

最后说一句:学链表的过程其实就是学指针的过程,也是学内存管理的过程。能把单链表玩明白,后面栈、队列、树、图,很多数据结构都会触类旁通。别急着上双向链表、跳表这些进阶玩意儿,单链表的基本性质和操作练扎实了,比看过一百种花哨设计都管用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询