1. 先搞清楚链表的底层逻辑,再谈刷题
很多人刷 LeetCode 链表题的时候,上来就背:快慢指针找环、虚拟头节点处理删除、递归反转链表……代码确实能背下来,但换个问法就懵了。比如把“反转整个链表”改成“反转链表前 N 个节点”,立刻就不知道怎么改。原因很简单:你还不清楚链表到底是怎么在内存里串起来的。
1.1 数组和链表的本质差异:一段连续内存和一串散落的节点
数组在内存里是一段连续的地址空间,所以按下标访问能做到 O(1)。链表恰恰相反,每个节点都是一个独立的内存对象,靠指针或引用把前后节点串起来,想找第 k 个节点只能从头往后走,所以随机访问是 O(n)。
这个差异直接决定了刷题时的思维方式。数组题你常常思考“用双指针从两端往中间逼近”,因为你知道两端的位置;链表题你只能想着“怎么用有限的几个指针在链上滑”,因为你没有下标。链表题里 90% 的解法,本质都是指针位置的精确控制。
我见过不少同学在纸上画链表画得很顺,一写代码就崩,尤其是删除节点时p->next = p->next->next,表达式左边到底是谁,右边求值顺序是什么,脑子里完全是一团浆糊。这个问题的根源在于:链表的操作对象不是“节点本身”,而是“节点之间的连接关系”。想通这一点,后边所有操作都顺了。
1.2 链表节点的定义:三种主流语言的写法对比
先看最基础的节点定义。C/C++ 用结构体,Java 用类,Python 用类加__init__。看起来差不多,但细节里有坑。
// C/C++ 结构体链表基本语法 struct ListNode { int val; ListNode *next; ListNode() : val(0), next(nullptr) {} ListNode(int x) : val(x), next(nullptr) {} ListNode(int x, ListNode *next) : val(x), next(next) {} };// Java public class ListNode { int val; ListNode next; ListNode() {} ListNode(int val) { this.val = val; } ListNode(int val, ListNode next) { this.val = val; this.next = next; } }# Python class ListNode: def __init__(self, val=0, next=None): self.val = val self.next = nextC++ 的next是指针,Java 和 Python 的next是引用。指针和引用的关键区别在于:指针能被重新指向别处,也能被置空;引用一旦初始化,始终指向同一个对象。刷题时复现 bug 最多的就是 C++ 的悬空指针,delete掉一块内存之后再访问,行为是未定义的,而且这种问题在本地很可能测不出来,提交到 OJ 上才暴露。
刷 LeetCode 时建议统一用 LeetCode 自带的ListNode结构,不要自己改字段名。我见过有人把next写成nxt,本地跑通,复制到编辑器里编译不过,纯属给自己添堵。
2. 链表刷题必会的六个基础操作
先别急着刷题,先把六个基础操作练成肌肉记忆。这六个操作涵盖了 LeetCode 链表题 90% 的代码片段:遍历、插入、删除、反转、快慢指针、断链重建。
2.1 遍历链表:链表题的“呼吸”
遍历是所有操作的地基。一个链表给你,你要能在 10 秒内写出循环,且不错边界:
// C++ ListNode* cur = head; while (cur != nullptr) { // 访问 cur->val cur = cur->next; }注意这里有个约定俗成的细节:循环变量叫cur而不是p,遍历终止条件是cur != nullptr而不是cur->next != nullptr。后者会让最后一个节点被跳过,是新手最常见的 off-by-one 错误。
链表遍历的操作意图有三个:数长度、找位置、聚合计算。很多题表面上是“两数相加”“合并链表”,内里就是把每条链走一遍,边遍历边处理。搞清楚遍历时“当前能拿到什么、下一轮会失去什么”,比死记硬背模板更重要。
2.2 插入节点:头插、尾插、指定位置插入
插入操作分三种:头插、尾插、中间插入。头插代码最短,尾插需要维护尾指针,中间插入的关键是“先接后面,再接前面”,顺序反了会丢链。
头插最典型的使用场景是“反转链表”的迭代写法——每拿到一个新节点,就插到结果链的头部:
ListNode* prev = nullptr; ListNode* cur = head; while (cur) { ListNode* nxt = cur->next; // 先保存后继 cur->next = prev; // 指向前一个 prev = cur; // 前移 cur = nxt; // 后移 } return prev;插入的代码谁都会背,但“为什么先保存后继”?因为cur->next = prev执行之后,原来的后继就找不到了。不保存后继,循环就没法继续。这个教训我在反转链表这道题上踩过不下五次。后来想明白了:链表操作的本质就是“先切断、再连接、顺序不能乱”。
2.3 删除节点:虚拟头节点的魔力
删除链表中某个节点,常规写法要区分“删除头节点”和“删除非头节点”两种情况,代码写出来非常啰嗦。引入一个虚拟头节点(dummy node 或 sentinel),问题瞬间统一:
ListNode* dummy = new ListNode(0, head); ListNode* prev = dummy; ListNode* cur = head; while (cur) { if (cur->val == target) { prev->next = cur->next; // 跳过 cur // C++ 注意释放内存 delete cur; break; } prev = cur; cur = cur->next; } return dummy->next;虚拟头节点的本质是“用一个多余节点换掉对空指针的特殊判断”。它不仅让代码更简洁,更重要的是让你把注意力集中在业务逻辑上,而不是被边界条件反复打断。LeetCode 里面删除倒数第 N 个、删除排序链表中重复元素、移除链表中指定元素,全都可以用这个套路。
我在实际刷题中发现,很多人知道 dummy 的技巧,但返回值会写错。返回head而不是dummy->next:一旦头节点被删,head就指向一个已删除的节点,整个输出就错了。记住一句话:有 dummy,就从 dummy 出发取下一节点。
2.4 反转链表:迭代与递归两条路,两条都要会
反转链表是链表的“hello world”,考频率极高。迭代写法在上面已经给出。递归写法也别忽略,面试很爱让你做对比:
// 递归反转 ListNode* reverseList(ListNode* head) { if (head == nullptr || head->next == nullptr) return head; ListNode* newHead = reverseList(head->next); head->next->next = head; // 让下一个节点指回自己 head->next = nullptr; // 断开原来的正序连接 return newHead; }递归版本的关键是head->next->next = head,先把后面的链反转完,再回来处理当前节点。理解这个顺序,建议画一个三节点的链表,一步步展开递归。我教过的学生里,没有一个人能靠空想理解这行代码,全都是在纸上画了才懂的。
Python 里单链表的逆序也差不多,但 Python 的解构赋值让交换多指针变得异常简洁:
# Python 迭代反转 def reverseList(self, head): prev = None cur = head while cur: nxt = cur.next cur.next = prev prev = cur cur = nxt return prev2.5 快慢指针:找中点、找环、找倒数第 K 个
快慢指针本质上是用两个不同速度的指针,在一条链上制造“相对位移”。找中点时快指针到末尾,慢指针正好在中间;找环时快指针追上慢指针,就能断定存在循环。
// 找链表中点 ListNode* slow = head; ListNode* fast = head; while (fast != nullptr && fast->next != nullptr) { slow = slow->next; fast = fast->next->next; } // slow 就是中间节点,奇数长度时是正中间,偶数长度时是后一半的起点找环入口的数学推导经常让人头疼。快慢指针第一次相遇在环内某点,之后让一个指针从头出发、一个从相遇点出发,每次各走一步,再次相遇的地方就是环入口。这个结论背后的数学推导值得自己推一遍:设链表头到环入口距离为 a,环入口到相遇点距离为 b,环周长为 c,快指针走了a + b + k*c,慢指针走了a + b,又有快指针走路是慢指针两倍,解出a = (k-1)*c + (c-b),所以从相遇点和从头同步走,恰好会在环入口会合。我建议你把这个推导写在笔记本上,因为 LeetCode 热题 100 里的“环形链表 II”和“相交链表”全都依赖这类关系理解。
2.6 判断循环单链表:不要无限循环
循环单链表(也叫单循环链表)是一道经典的数据结构实验课题目,也是面试高频追问题。判断有没有环,可以用快慢指针;但如果是“给定一个循环链表,找出入口”,上面的推导就派上用场了。
热词里出现的“单循环链表”和“环形链表”是一回事吗?严格说不太一样。数据结构教科书里的循环链表多指“尾节点指向头节点,整个表首尾相接”,环形链表则可以是“链表中某段形成了一个环”,出口不一定是头节点。刷题时遇到的绝大多数是后者。遇到这种题,先画图,把环画出来,再套公式,比直接背代码靠谱得多。
3. 不同语言写链表,踩过的坑不一样
链表是少数“不同语言写起来风格差距极大”的数据结构。C 和 C++ 里你要自己管内存;Java 里你可以优雅地忽略释放问题,但要小心引用;Python 的写法最简洁,可性能相对吃紧。
3.1 C/C++:手动管理内存与二级指针
C 语言链表的基本操作是数据结构的必修课。定义节点、创建链表、插入、删除、遍历、销毁,每一步都涉及malloc/free。很多人实验课写单链表没问题,一到刷题就变“裸写”,结果挂在了内存泄漏上。
LeetCode 环境其实会自动释放进程内存,所以刷题时你不管delete也能过。但面试手写代码时,面试官很可能会追问“刚才删除的节点要不要释放?”“如果这是嵌入式环境呢?”这时候能答出内存管理细节,是明显的加分项。
如果函数需要修改头指针本身,C 里常见两种写法:一是返回新头节点(LeetCode 常用),二是用二级指针ListNode** head。后者理解起来更符合“直接在原链上改”的直觉,但可读性差一些。个人建议刷题用返回值,工程代码用二级指针。
3.2 Java:引用传递和虚拟头节点的意义
Java 里没有指针泄漏,但引用别弄混。比如写ListNode temp = a; temp.next = b;,temp和a指的是同一个对象,改temp就是改a。很多链表题的 bug 都源于“你以为自己复制了一份,其实操作的是同一份”。
Java 刷链表还有个隐蔽的坑:ListNode构造函数如果没初始化val,默认值是 0 还是未定义取决于你写的构造器。LeetCode 官方题解里的ListNode构造器通常给了默认值,但本地如果你自己定义类,写漏了默认构造器,声明ListNode node = new ListNode()就会直接编译报错。
3.3 Python:对象引用与切片陷阱
Python 刷链表最省事,但有两个典型问题。第一个是浅拷贝:cur = head之后改cur.next会直接影响原链表,这是你想要的没错,但如果想“复制”一条链表用于保留旧结构,就需要注意copy或深拷贝的问题。第二个是切片:head如果是对象列表,head[:]是浅拷贝,节点不变,你改了节点属性原链表照样变。链表题里基本不用切片,但一旦用了就容易被绕进去。
PyPy 的性能比 CPython 快不少,刷 LeetCode 时选 Python3 跑,复杂链表题如果超时,可以看题解的 C++ 版本了解最优思路,而不是死磕 Python 的常数优化。链表操作本身 O(n),Python 的类对象开销很大,题目数据量一大,Python 的劣势就比较明显。
3.4 嵌入式链表:另一种链表哲学
热词里出现了“嵌入式链表代码示例”,这背后是工程界非常经典的“侵入式链表”。Linux 内核里list_head结构体就长这样:
struct list_head { struct list_head *next, *prev; };你需要把list_head嵌入你自己的结构体里,而不是让结构体包含指针。这种设计的好处是:链表操作代码可以完全复用,不关心容器元素类型。通过container_of宏,从list_head字段反推出宿主结构体的起始地址。
这在刷题时不会遇到,但理解了侵入式链表,你会对“指针指向的到底是节点还是连接关系”有更深刻的认识。刷题链表和工程链表是两种不同的哲学:前者以节点为中心,后者以连接关系为中心。两者都明白,你的链表功底才真正过关。
4. LeetCode 热门 100 题里的链表题型拆解
LeetCode 热题 100 是很多人刷题的起点,里边的链表题数量大概在十几道左右,分散在链表、哈希表、栈、设计等标签下。把这些题按题型归类,比按难度刷更高效。
4.1 热门 100 题中值得反复做的链表题
我自己的刷题清单是这样分类的:
| 题型 | 代表题 | 核心考察点 |
|---|---|---|
| 反转系列 | 反转链表、反转链表 II、K 个一组翻转链表 | 迭代 + 递归 + 区间反转 |
| 环与交点 | 环形链表、环形链表 II、相交链表 | 快慢指针、数学推导、集合去重 |
| 合并系列 | 合并两个有序链表、合并 K 个升序链表 | 双指针、优先队列、分治 |
| 删除系列 | 删除链表倒数第 N 个节点、删除排序链表中的重复元素 II | 虚拟头节点、双指针 |
| 哈希 + 链表 | LRU 缓存、复制带随机指针的链表 | 哈希表与链表的交叉设计 |
| 模拟系列 | 两数相加、两两交换链表中的节点 | 遍历 + 进位/交换的边界控制 |
如果你时间有限,我建议优先做反转链表、合并两个有序链表、环形链表 II、LRU 缓存、K 个一组翻转链表。这五道题覆盖了链表题的大部分套路,而且面试命中率非常高。
4.2 经典题型的思考模板:看到题先想哪几步
链表题最怕上来就写代码。我的习惯是三步走:
第一步,问自己“这道题需要几个指针?”。反转需要三个(prev、cur、nxt),删除需要两个(prev、cur),找中点需要两个(slow、fast),合并需要三个(一个结果尾指针加两个各链表指针)。指针数量定下来,代码已经成功一半。
第二步,问自己“要不要虚拟头节点?”。任何涉及删除头节点、需要统一边界逻辑的题,答案都是“要”。两数相加这种需要一直新建节点的题,也建议用 dummy 节点,免得最后返回时还要单独处理头节点为空的状况。
第三步,问自己“遍历完后指针停在哪里?”很多题不是一次遍历就能完成的,比如 K 个一组反转,每组反转完,指针停在组尾;最后不够一组,要原样返回。提前想清楚退出状态,能省下大量调试时间。
4.3 周赛 430 的启发:怎么用好一场周赛
周赛 430 是最近一场值得复盘练习,赛后打开题解,你会发现很多参赛者用到的技巧其实都是套路:变量命名、边界处理、循环不变量。周赛题目不管难易,本质上考察的都是“在有限时间内把脑内思路变成正确代码”的能力。
建议每周周赛结束后,挑出链表相关题目单独整理:看自己的解法是不是最长/最丑/最慢,对比前排玩家的代码。我见过一个选手的链表题代码,全程只有一个循环,没有 if 分支,边界靠虚拟头节点消解掉了,看完之后我意识到代码的简洁程度反映了对问题的理解深度。
周赛不是用来“比分数”的,而是用来暴露短板的。我每次都把周赛里 WA(Wrong Answer)和 TLE(Time Limit Exceeded)的链表题收集起来,隔一周重做一遍,效果非常明显。
4.4 一个有意思的干扰项:爱吃香蕉的狒狒为什么总出现在链表搜索里
热词里出现了“leetcode 073 爱吃香蕉的狒狒”,它其实并不是链表题,而是一道二分答案题。但搜索“链表题解”时它经常一起出现,原因可能是某个平台的题目编号连续、或者推送算法的关联标签导致的。
这给了我们一个重要提醒:看题解之前先确定题目类型标签。很多人被热搜词带偏,把二分答案题当成链表题去刷,思路完全跑偏,最后挫败感很强。建议每道题先看题目描述前两行,确认数据结构类型,再看数据范围判断算法复杂度,最后再动手。链表题的数据范围通常是node number n <= 10^5左右,O(n) 标准可过,O(n^2) 偶尔能过,O(2^n) 基本别想。
5. 刷题过程中最常见的六个报错与排查思路
这部分是压箱底的干货。链表题报错非常重复,我整理了几类出镜率最高的错误,按频率降序排列。
5.1 空指针解引用:从“运行时错误”到“段错误”
LeetCode 上报错最常见的是Runtime Error,具体原因多半是空指针访问。比如cur->next->next,当cur->next是空指针时,这行代码直接崩溃。排查这类问题的核心技巧是:先找哪一行访问了.next或->next,再看这个点有没有可能为空。
我调试时有个笨办法,在访问cur->next前加一段判断:
if (cur && cur->next) { // 安全访问 }这种写法虽然多一个分支,但能立刻定位空指针来源。等代码逻辑稳定后,再回去删掉冗余判断。
5.2 指针更新顺序错误:永远是链表 bug 的最大来源
“先切断再连接”的顺序一旦反了,链表就断成几截。最典型的例子是删除一个节点时,你先把prev移动到cur,然后再想改prev->next,结果你发现 cur 已经不在原来的位置了。
这类问题的标准解法是“画图 + 逐步执行”。LeetCode 编辑器支持逐步调试,我强烈建议你在这个环节多花五分钟。有一次我反转链表反复报错,最后一个节点总是丢,后来逐步执行才发现,nxt在循环开头丢掉了,因为cur = nxt之前nxt已经指向了空指针。
5.3 死循环:忘记断链、环检测失效
死循环在链表题里最常见于两类:一是递归反转时漏了head->next = nullptr,导致反转后的链表尾巴连回自身;二是合并有序链表时,结果链的尾指针忘记后移,导致新链串进旧链的某个节点,形成环路。
测试死循环很简单:在本地跑一个总数不超过 100 的用例,如果程序超过 5 秒没结束,基本就是死循环。LeetCode 上时间限制比较严格,超时(TLE)基本等于死循环或复杂度太高。每提交一次前,先检查所有next指向是否都在预期范围内。
5.4 递归爆栈:反转链表的递归写法在长链上会炸
递归反转链表代码优雅,但数据量一大就爆栈。LeetCode 测试数据不会故意设成长链逼你爆栈,但面试官可能会问“递归空间复杂度是多少”。答案是 O(n),递归深度就是链表长度,而迭代版本可以做到 O(1)。
如果面试时被要求写反转链表,建议先给迭代版,再给递归版,并主动分析区别。如果你主写递归但忘了配置栈大小,在嵌入式环境里也会出问题。
5.5 测试用例不会构造:连自己写的代码都不信任
链表题的测试用例构造有个“三件套”模板:空链表、单节点、两个节点。这三个用例覆盖了 80% 的边界。再加“删除头节点”和“删除尾节点”两个场景,覆盖度就到 95%。这两条我自己踩过不少坑,比如 K 个一组翻转链表里最后不足一组的情况,空链表跑了一次对,单节点跑了一次对,但两节点加 K=2 就不对了,问题就出在“组内反转完成后,新链的段头段尾如何衔接”。
调试时多用print打印每一步的指针值。Python 里直接print(cur.val),C++ 里用cout << cur->val,把每一步的指针变化摊开看,比盯着代码发呆高效十倍。
5.6 测试心态:慢就是快
链表题出错后的第一反应不要是“改一行再交”,而应该是“把整段逻辑重新讲给自己听”。我刷了四五百道链表题之后,最大的心得是:链表题的时间复杂度几乎不可能优化到比 O(n) 更好,所以拼的是“一次写对”,而不是“写得快”。每次提交前,理一遍代码里的三个指针分别指向哪里,多花半分钟,能省下二十分钟的调试。
6. 链表不是只活在 LeetCode 里:从实验课到工程落地
链表题刷多了之后,你会慢慢发现一个事实:LeetCode 的链表题是“被简化过”的链表。真实的链表应用远不止反转和判环,但它们的底层逻辑相通。
6.1 单链表基本操作实验:从 B3631 到数据结构课设
热词里出现了“B3631 单向链表”和“单链表的基本操作实验”,这通常是面向新手的编程题或实验题。实验内容一般是:初始化链表、插入、删除、遍历、按值查找。这类题目刷起来比较枯燥,但它是后面一切的基础。
我在给学弟学妹讲单链表实验时,发现一个共性问题:很多人的链表头指针总是不动,插入完忘记更新头指针。后来我总结出一个口诀:“动链之前先存原后继,改头之后别忘新头是谁”。如果你也在做实验题,先把这个口诀背熟,比啥模板都管用。
6.2 基于链表的两个集合求差集:理论题也有工程味道
“基于链表的两个集合的差集”是一种常见的集合运算实现题。思路是把集合 A 和 B 分别存成两条链表,求 A - B 就是把 A 里也在 B 中的节点去掉。朴素做法是双重遍历 O(n*m),更优的做法是先把 B 的节点放进哈希集合,然后单遍历 A,边遍历边删除,复杂度降到 O(n+m)。
这道题我第一次做的时候,直接用双重遍历,跑大数据集挂了,改成哈希之后瞬间通过。这件事教育我:链表题不一定要“纯链表”解法,合理使用哈希表辅助,往往是更聪明的选择。
6.3 LRU Cache:双向链表在工业界的经典应用
LRU Cache 是热题 100 里难度较高的一道,也是链表工程价值的最佳证明。它要求你设计一个缓存淘汰策略:每次访问或插入时把节点移到链表头部,容量满了就删除链表尾部的节点。哈希表负责 O(1) 查找,双向链表负责 O(1) 删除和插入。
用双向链表实现 LRU 的代码很有模板感:懒删除 + 头尾哨兵节点。这里的“哨兵节点”就是虚拟头节点的变体,只不过它同时维护了链表的头和尾,让删除尾节点不必特判。这种结构在嵌入式缓存、数据库缓冲池里到处都是,理解了 LRU,你就能看懂很多底层组件的设计思路。
6.4 嵌入式里的链表:为什么不用数组
嵌入式环境里链表被大量使用,不是因为链表快,而是因为链表的内存可控、增删灵活。数组需要一块连续内存,很多时候单片机的 RAM 碎片化严重,找不到一块足够大的连续区域;链表把数据拆成小块,每一块都可以夹在任意空闲区间。
嵌入式链表代码示例通常很短,但定义了定长节点池、空闲节点列表,操作时从池里取节点、回收到池里。这种“手写内存池”的做法在 LeetCode 里完全不会出现,但在嵌入式面试里很加分。
所以我的建议是:如果你不是科班出身,刷链表题时额外看一点工程链表的资料(内核 list_head、内存池、LRU),对你理解“链表为什么无处不在”会有很大帮助。
最后分享一个我自己用的刷题小技巧
很多人在链表题上反复出错,是因为每次都是从“零”开始写。我自己后来养成了一个习惯:把链表题准备一套“万能模板”,每次提交前先按模板检查。
模板大概是这样的:先定义dummy = new ListNode(0, head),再定义prev = dummy和cur = head,然后根据题目决定是否引入nxt、slow、fast。凡是涉及删除的,一律从 dummy 出发;凡是涉及反转的,一律先保存下一步节点;凡是涉及遍历的,循环结束条件先写cur != nullptr。
这套模板并没有教我解决具体题目,但它让我每次写链表代码的“脚手架”是统一的,剩下的精力全放在核心逻辑上。刷题刷到后期,你追求的不是某一道题的 AC,而是一种“不管遇到什么链表题,脑子里都能迅速搭出骨架”的能力。链表这个数据结构本身不难,难的是把每一步指针变动都控制在预期范围内。多画图、多调试、多复盘,比多看题解有用得多。