ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

链表学习笔记:从单链表到循环链表的操作详解与常见坑

链表学习笔记:从单链表到循环链表的操作详解与常见坑 1. 第29天外卖环境配完了改卷子改了三个小时1.1 外卖环境是什么为什么值得记一笔先交代一下背景。这个所谓的外卖环境是我们课程项目里那个外卖点餐系统的整套开发环境不是真的去开外卖店而是要把一个完整的外卖平台demo跑起来——前端页面、后端接口、数据库、缓存、消息队列一个都不能少。配置环境这四个字听起来轻飘飘的实际做起来全是坑。我第29天才配完倒不是每天只花十分钟随便弄弄而是中间断断续续踩了七八个坑。MySQL版本和驱动不匹配导致连不上库、Redis在Windows下默认配置不支持后台运行、后端接口跨域被前端拦、npm依赖版本冲突……每个问题单拎出来都不难但串在一起就非常消耗时间。卡了三天之后我学乖了改用Docker Compose做一键编排把MySQL、Redis、Nginx这些基础设施全部容器化十分钟就能把整套环境拉起来。想省时间的同学我真心建议直接从容器方案起步别在本地环境上死磕尤其是数据库这种重组件装完还得配权限、配编码、配远程连接每一步都是时间黑洞。这个环境配完的意义在于我终于可以开始真正的业务开发了——用户登录、商家管理、订单流转这些核心模块。而写这些模块的过程中你会发现一个躲不开的基础数据结构链表。订单队列的先进先出、缓存淘汰的LRU策略、操作记录的撤销回退到处都是链表的影子。所以今天配完环境之后我给自己定的任务是彻底把链表过一遍。这才有了后面改卷子改到怀疑人生、然后痛定思痛补链表的故事。1.2 三个小时改卷子改出了什么按理说改卷子不该算进学习计划里但今天这180分钟比我刷两道算法题收获还大。期末卷子里有一道链表大题三十分考的是单链表的插入和删除操作要求画出节点示意图并且补全代码。批改三十多份卷子三个小时下来我发现一个特别明显的规律凡是画图慢的同学代码基本都写不对。后来我想明白了——链表的本质就是指针在节点之间跳转画图就是在模拟指针的走向。如果你脑子里连节点和指针的图都构建不出来那写代码就只能靠瞎蒙。这个观察让我决定晚上必须好好整理一份链表的学习笔记把今天的所见所感沉淀成可以复用的东西也顺便给准备学链表的人一份能直接照着练的参考。2. 改卷子暴露的链表短板学生到底错在哪2.1 最高频的错误尾节点处理批改卷子的过程中我把错误归了一下类发现尾节点处理是最大的重灾区。具体来说很多同学在实现往链表尾部插入节点时思路是先创建一个新节点然后遍历到链表最后一个节点再把新节点接上去。听起来没问题但代码一写就露馅。最常见的情况是链表本身是空的遍历根本不会执行指针停在了头节点的位置上于是新节点被错误地放到了头节点后面甚至直接把头节点覆盖了。这是典型的没有考虑空链表边界情况。另一个高频错误是遍历条件写错。很多同学写成 while (p-next ! NULL)用这个条件本身没错但如果初始化的指针指向的是head本身那么循环条件成立时p已经跳到了最后一个节点再进去一次就访问了NULL的next。我在卷子上给学生标注的评语只有一句话先把图给我画出来再写循环条件。链表题最怕的就是不动笔、直接敲代码。实战提醒写链表遍历之前先在纸上画出 头节点 → 节点1 → 节点2 → NULL 的结构然后用手模拟指针移动。画不出来说明你对链表结构还没有真正理解这时候写的代码就是空中楼阁。2.2 第二类错误插入和删除的指针操作顺序第二类高频错误集中在插入和删除操作中指针调整的顺序。单链表插入一个节点标准操作是新节点的next先指向后继节点然后前驱节点的next再指向新节点。这个先后顺序非常关键——必须先让新节点勾住后面的节点再让前驱节点勾住新节点。但很多同学反着来先把前驱的next改到新节点上结果后面的整段链表就失联了链表直接断成两截。删除操作也一样。要删除节点B正确做法是让A-next指向C也就是需要先拿到B的下一个节点的地址再修改A的指针。很多同学直接free(B)之后再去访问B-next这在逻辑上完全说不通——B都释放了它还怎么告诉你后继在哪这是典型的对指针生命周期不理解。我把这几种错误集中在一起发现它们的共同根源是脑袋里没有指针只是地址的载体这个概念把指针当成了一个神秘的黑盒子。2.3 从错误反推链表到底应该怎么学改完卷子之后我一直在想一个问题为什么链表这么基础的内容到了考试还是有那么多人翻车我的结论是很多同学把链表当成了背代码而不是理解结构。链表不是一个需要背诵的模板而是一个需要脑子里有画面感的数据结构。每一个节点就像一个小盒子盒子里面装着一个数据还有一根线指向下一个盒子。操作链表本质就是拆线、接线。脑子里有了这个画面插入、删除、逆序这些操作全都是顺理成章的事情。所以这篇博文后面几节我不打算堆一堆空洞的理论而是从一个天天刷题、整天跟数据结构较劲的人的角度老老实实把链表的代码写一遍把每一步为什么这么做讲清楚。也算是对今天改卷子的一种回应吧——我自己先做到不犯这些错才有资格跟学生说你要这样学。3. 链表核心知识拆解从单链表到循环链表3.1 为什么有了数组还要用链表先把最基础的问题说清楚。数组和链表是两种最基础的线性存储结构各有各的脾气不存在谁完全替代谁。数组在内存里是一段连续的空间通过下标就能直接访问任意元素时间复杂度O(1)这是它的巨大优势。但数组有两个硬伤一是定义的时候长度就得定死想扩容得重新申请一块更大的内存再把所有数据搬过去二是插入和删除元素非常痛苦为了保证连续性每插入一个元素后面的所有元素都要往后挪最坏情况是O(n)的耗时。链表不一样它的每个节点在内存里是分散的节点之间靠指针串联。好处是插入和删除只需要改几个指针就行时间复杂度O(1)坏处是你没办法像数组那样直接按下标访问想找第k个节点只能从头开始走时间复杂度O(n)。维度数组链表内存分布连续空间节点分散指针串联随机访问O(1)O(n)必须遍历插入/删除O(n)需要搬移元素O(1)改指针即可扩容需要整体搬迁动态分配节点天然可扩展适用场景频繁查询、随机访问频繁插入删除、数据规模不确定说白了怎么选核心看场景。外卖系统的订单队列就特别适合用链表——新订单进来往尾部挂商家接单处理完从头部摘掉中间很少需要随机访问链表天然契合这种一边进一边出的流水线操作。3.2 单链表的定义和基本结构单链表英文叫Singly Linked List是最简单的链表形态。每个节点包含两部分数据域用来存数据指针域用来存下一个节点的地址。最后一个节点的指针指向NULL表示后面没有了。用C结构体来定义长这样struct ListNode { int val; // 数据域 ListNode *next; // 指针域指向下一个节点 ListNode(int x) : val(x), next(NULL) {} // 构造函数 };嵌入式场景没有STL可用C语言版本自己手写节点结构体typedef struct Node { int data; struct Node *next; } Node;注意C语言里结构体内部必须用 struct Node 来声明next指针因为 typedef 是在整个结构体定义完之后才生效的。这个细节我在改卷子时也看到有同学踩坑直接在结构体里写 Node *next编译直接报错。Python写链表更直观一个类就搞定class ListNode: def __init__(self, val0, nextNone): self.val val self.next nextPython里的next本质上是一个引用跟C/C的指针面对的是同样的底层结构——内存地址。理解这一点你在Python里调试链表问题就不会总觉得隔了一层。3.3 循环单链表环形结构的特殊之处循环单链表就是把单链表尾巴上的NULL改成指向头节点整个链表变成一个环。它解决了一个实际问题如果你希望链表支持从头到尾再回到头的无限遍历比如轮询调度、食堂排队叫号、游戏里的回合制轮转循环链表就很合适。循环单链表和单链表的核心区别就两点判空条件不同。单链表判断 head NULL 即为空循环链表如果是带头节点的写法判断 head-next head 表示空链表。遍历终止条件不同。单链表遍历到 NULL 停止循环链表没有NULL遍历时得靠回到起点来手动终止否则就会无限转圈。写循环链表遍历的时候最容易出的bug就是死循环。为什么因为环本身没有终点你必须靠程序自己喊停。常见写法是// 带头节点的循环单链表head为哨兵 ListNode* p head-next; while (p ! head) { // 处理 p-val p p-next; }这里的终止条件是 p ! head不是 p ! NULL。很多初学者下意识就写 p ! NULL前后两个判断混在一起放进循环链表里直接死循环连调试都不知道从哪下手。我建议第一次接触循环链表的人专门写一个只有两个节点的环然后手动在纸上模拟这个遍历过程跑两遍就再也不会忘了。4. 实操链表操作的完整代码实现4.1 链表创建和遍历学习链表最好的方式就是动手写。我用C把链表的常用操作完整过一遍Python版本挑重点讲。先写创建链表的函数从数组构建一个单链表ListNode* createList(const vectorint arr) { ListNode* dummy new ListNode(-1); // 哑节点 ListNode* cur dummy; for (int x : arr) { cur-next new ListNode(x); cur cur-next; } return dummy-next; }这里用了一个哑节点的技巧。哑节点的作用是让链表始终保持一个虚拟头这样即使在头部插入或删除节点也不需要额外处理头节点为空的特判代码会清爽很多。我在笔试面试里见过太多同学不用哑节点导致边界条件写一大堆if-else还容易漏情况。能熟练用好哑节点链表题的正确率会显著提升。然后是遍历。遍历是最基础的操作但里面也有讲究void printList(ListNode* head) { ListNode* p head; while (p ! NULL) { cout p-val ; p p-next; } cout endl; }循环条件是 p ! NULL也就是说最后一个节点的数据被处理完之后p继续往后走变成NULL循环才结束。这个逻辑虽然简单但它是理解所有高级操作的地基。如果在遍历时想做点别的比如统计长度、找中间节点都是在同一个框架上加东西。4.2 指定位置插入节点插入节点是链表的高频操作。假设有一个链表在第pos个位置插入一个值为x的新节点ListNode* insertNode(ListNode* head, int pos, int x) { ListNode* dummy new ListNode(-1); dummy-next head; ListNode* p dummy; for (int i 0; i pos; i) { if (p-next NULL) return head; // 位置越界 p p-next; } ListNode* newNode new ListNode(x); newNode-next p-next; // 第一步新节点先连后面 p-next newNode; // 第二步前驱节点指向新节点 return dummy-next; }请注意新增节点那两行代码是固定顺序不能交换。这个顺序我在改卷子部分已经吐槽过了但它是插入操作的核心值得再说一遍如果先把p-next改成newNode那原来p后面的整段链表就失联了因为它唯一的入口被切断了后面想把它跟newNode接起来已经没有指针能拿到它了。另外这题里我依然用哑节点处理pos0的情况。如果没有哑节点插入头部需要单独写一个分支newNode-next head; head newNode。用哑节点之后这些分支全部统一成一个逻辑对心智负担的降低是实打实的。4.3 删除指定位置节点删除操作和插入类似核心同样是保证指针不丢失ListNode* deleteNode(ListNode* head, int pos) { ListNode* dummy new ListNode(-1); dummy-next head; ListNode* p dummy; for (int i 0; i pos; i) { if (p-next NULL) return head; p p-next; } ListNode* target p-next; p-next target-next; // 先绕过目标节点 // delete target; // 需要释放内存的场合 return dummy-next; }删除的本质是绕过。让前驱节点的next直接指向目标节点的下一个节点目标节点就从链表中被摘下来了。摘下来之后在C/C里记得释放内存避免泄漏Java和Python有垃圾回收机制不用手动管。注意删除节点时一定要先完成 p-next target-next再去释放target。如果顺序反了target已经被回收target-next就成了野指针。这个细节就是很多同学明明删了节点链表却越走越怪的根源。4.4 链表的逆序逆置实现逆序链表也叫链表反转是链表里最经典的题目考的频率极高在面试里几乎是链表题的入场券。核心思路只有一个遍历链表把每个节点的next指向前一个节点。因为要改变方向所以必须先存住下一个节点否则一旦改了next就找不到后面的路了。ListNode* reverseList(ListNode* head) { ListNode* prev NULL; ListNode* cur head; while (cur ! NULL) { ListNode* nextTemp cur-next; // 先存下一个节点 cur-next prev; // 反转指针方向 prev cur; // 前驱移动 cur nextTemp; // 当前节点移动 } return prev; // 新的头节点 }这套代码的精髓是循环里的四步顺序不能乱存next、改next、移动prev、移动cur。我在面试别人时看到很多人卡在这一题基本都是同一个问题——忘记先存next导致改完next之后后面的节点再也找不到了。Python版本同样经典语法更简洁def reverse_list(head): prev None cur head while cur: next_temp cur.next # 先存后面 cur.next prev # 指针反转 prev cur # 整体前移 cur next_temp return prev还有一种递归写法面试偶尔会考ListNode* reverseListRecursive(ListNode* head) { if (head NULL || head-next NULL) return head; ListNode* newHead reverseListRecursive(head-next); head-next-next head; // 让下一个节点指回来 head-next NULL; return newHead; }递归写法理解起来要绕一点但核心就一句话先反转后面的链表然后把当前节点的下一个节点的next指向自己。建议初学者把迭代法练成肌肉记忆递归法理解思想就好面试时能写出来加分写不出来也不致命。4.5 嵌入式场景下的链表实现要点前面聊了C和Python最后补一个特殊场景嵌入式。热词里也提到了嵌入式链表代码示例这确实是特别实际的场景。嵌入式系统里动态内存分配要非常谨慎因为堆空间有限频繁new/free容易产生内存碎片。所以嵌入式场景下常见的做法是预分配节点池或者直接用静态链表。静态链表用数组来模拟链表每个数组元素既存数据也存下一个元素的下标用下标代替指针#define MAX_NODES 100 typedef struct { int data; int next; // 存的是下标 } StaticNode; StaticNode nodes[MAX_NODES]; int head -1; // -1 表示空 int freeHead 0; // 空闲节点链表头这个设计的好处是所有节点的内存在编译期就静态分配好了运行过程中不产生碎片坏处是节点数量有上限不能随便扩展。嵌入式里另一个常见的实践是侵入式链表——链表节点直接嵌入宿主结构体内部。Linux内核里著名的struct list_head就是这种思路节点结构体不存业务数据只存prev和next指针然后通过container_of宏从链表节点反推出宿主结构体的地址。这属于进阶话题了但理解这个思路会对链表是数据结构而非业务实体这句话有更深的体会。5. 常见问题与排查技巧实录5.1 空指针访问链表类的头号杀手日常写链表遇到最多的问题就是空指针访问。要么访问了NULL的next要么解引用了已经被释放的内存。排查经验是先在代码里搜 p-next 这种访问形式每出现一处就问自己三个问题——p会不会是NULL如果会循环条件里有没有拦截改完指针之后有没有可能把某个节点变成孤岛三个问题问完大多数空指针问题都能定位。做题时一个很实用的方法是画链表状态图。比如删除第2个节点把每一步的指针变化画出来初始dummy - (0) - (1) - (2) - (3) - NULLp走到值为1的节点p-next指向值为2的节点target p-next即值为2的节点p-next target-next即值为3的节点链表变为dummy - (0) - (1) - (3) - NULL画完这张图代码自然就写对了。我在改卷子时反复跟学生强调的就是这个多想一步、多画一笔胜过盲目敲一百行代码。5.2 死循环链表遍历的隐形陷阱死循环的典型来源前面已经提过循环链表的终止条件写错把 p ! head 写成了 p ! NULL。还有一个常见场景是反转链表时如果prev和cur的移动顺序搞反了会导致两个节点互相指形成局部环遍历时必然死循环。排查死循环也不难在循环里临时加一个计数器运行到比如一万次就强制退出打印当前指针指向节点的值快速判断是走进了环还是链表本身太长。真正的高手在写链表题时会养成一个习惯——循环体内只改两个指针、保证每个节点只会被访问一次从源头上避免产生环。5.3 内存泄漏和野指针C/C写链表内存这块必须很小心最常见的问题有两个。一是删除节点时没释放内存导致泄漏。这在考试代码里很多时候不会暴露但在长时间运行的服务里问题非常大。外卖系统的订单处理如果每删一个订单节点就泄漏一点内存进程跑一周下来内存占用会非常吓人。二是释放了节点之后还有指针指向它形成野指针。解决办法是删除操作中先完成前驱节点的指针重接再释放目标节点释放之后最好把指针设置为空防止误用ListNode* temp target-next; p-next temp; delete target; target NULL;5.4 链表的断链修复思路链表题里还有一种比较隐蔽的错误是断链——链表被切成两段一段丢失。这种情况经常会出现在复杂操作中比如两个链表合并、链表排序、区间反转。修复断链的一般思路是不要慌先从head出发重新遍历找到链路中断的位置——也就是某个节点的next本来应该指向后继结果却是空或者指向了一个不相关的节点。找到断点之后逆推是哪一步操作导致了断裂。更聪明的策略是在写复杂链表算法时就加保护性操作每修改一次指针就用一个临时变量把下一个节点的地址先存住。这个习惯看着笨但在考试和面试这种高压场景下是最稳的保险。今天我自己写反转链表时也差点把nextTemp的赋值顺序搞错还好事先养成了这个习惯扫一眼就发现了。6. 链表学习的路线图与效率心得6.1 为什么这个阶段必须把链表彻底搞定回到今天的主题。0x3f这个代号在算法圈子里通常跟无穷大绑定在一起0x3f3f3f3f是竞赛选手常用来表示正无穷的常量。为什么选这个数因为它足够大超过十亿又不会溢出32位整数而且两个0x3f3f3f3f相加还在int上限之内方便做最短路和动态规划里的无穷大加法。当然你也可以把0x3f理解成我这个学习挑战的代号——第29天恰好是一个需要跟自己较真的日子。链表在这个阶段必须彻底搞定不管是为了应付考试、准备面试还是为了写好业务代码它都是绕不过去的地基。外卖系统的订单队列、日志系统的消息缓冲、操作系统的进程调度、内存管理的空闲块链全是链表的实际应用。与其以后在复杂业务里被链表bug折磨不如现在就把它焊死。从今天改卷子看到的错误来看大部分学生不是学不会而是没给自己一个必须彻底理解的压力。刷了一遍代码以为会了实际一画图就露馅。我的建议很简单每个链表操作先画图再写码写完码再合上图用注释解释每一步在干什么。三遍下来想不会都难。6.2 我的链表刷题清单最后给出一份我自己实际用过的链表练习清单从小到大、从易到难基本覆盖了今天热词里提到的大部分知识点阶段练习目标核心操作入门实现单链表的基本操作创建、遍历、求长度基础链表插入与删除含头部/尾部指针调整顺序、边界处理进阶反转链表迭代递归双指针操作进阶链表逆序输出栈或递归进阶合并两个有序链表哨兵节点的使用提高环形链表检测快慢指针提高单循环链表的遍历与计数环形结构终止条件提高删除链表的倒数第N个节点双指针技巧综合LRU缓存淘汰策略哈希表加双向链表综合链表每K个节点一组反转区间反转加递归就算你目标只是做完课程项目、不准备面试我也建议至少把基础和进阶两个阶段练熟。因为链表的指针操作训练的是人脑对内存地址的直觉而这个直觉以后排查任何指针类bug都是通用的。6.3 整理完笔记之后的几句体会写下这篇博文之前我又把代码从头到尾跑了一遍顺手修了两个bug一个是循环链表遍历忘记改终止条件一个是反转链表时nextTemp的赋值写在了cur-next修改之后。这些小错误其实特别典型恰恰就是今天学生卷子上会出现的同类问题。所以写代码和改卷子是个双向验证的过程——你不亲手踩一遍坑永远不知道自己讲的东西在哪个环节容易被误会。我个人在实际操作中的体会是学习数据结构这种东西千万别迷信看懂。看懂一百行不如亲手写十行再亲手错三次。错过的坑会变成你的条件反射而条件反射才是考试和面试里真正救你的东西。今天改卷子改了三个小时表面上看是在消耗时间实际上是从三十多份卷子里提炼出了一张易错点地图。这张地图的含金量比我闷头刷十道链表题还要高。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进