单链表详解:从指针操作到增删改查,彻底攻克边界条件

📅 发布时间:2026/10/20 5:18:14
单链表详解:从指针操作到增删改查,彻底攻克边界条件
写链表最容易踩的坑是什么我在带新人和面候选人的时候发现十个人里有九个都栽在“指针到底指向谁”上。链表这东西核心就一句话用指针把零散的内存串起来。每一个节点里不仅存数据还得存下一个节点在哪儿。这种结构听起来简单但真要动手写增删改查边界条件一多脑子就浆糊。这篇文章我打算只讲透单链表。先把链表的基本性质拆明白再给出可以直接抄的代码实现最后把我这些年调试链表遇到的高频问题整理成速查表。不管是刚学数据结构的新手还是准备面试的开发者看这一篇基本够用。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; }这里有三个边界条件i1非法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、nextcur从首元结点开始逐个把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。光这一步就足以筛掉一批候选人。按位插入的时候你要检查i1非法和定位之后的pNULL删除的时候要额外检查p-nextNULL这样才不会有越界访问。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表示临时保存的后继。每个变量职责单一读代码的时候逻辑链路清楚自己排查也快。我在代码评审里看到候选人的链表代码逻辑混乱多半就是变量名起得随意逻辑跟着也乱。最后说一句学链表的过程其实就是学指针的过程也是学内存管理的过程。能把单链表玩明白后面栈、队列、树、图很多数据结构都会触类旁通。别急着上双向链表、跳表这些进阶玩意儿单链表的基本性质和操作练扎实了比看过一百种花哨设计都管用。