
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, val0, nextNone): 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 分支边界靠虚拟头节点消解掉了看完之后我意识到代码的简洁程度反映了对问题的理解深度。周赛不是用来“比分数”的而是用来暴露短板的。我每次都把周赛里 WAWrong Answer和 TLETime 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 个一组翻转链表里最后不足一组的情况空链表跑了一次对单节点跑了一次对但两节点加 K2 就不对了问题就出在“组内反转完成后新链的段头段尾如何衔接”。调试时多用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(nm)。这道题我第一次做的时候直接用双重遍历跑大数据集挂了改成哈希之后瞬间通过。这件事教育我链表题不一定要“纯链表”解法合理使用哈希表辅助往往是更聪明的选择。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而是一种“不管遇到什么链表题脑子里都能迅速搭出骨架”的能力。链表这个数据结构本身不难难的是把每一步指针变动都控制在预期范围内。多画图、多调试、多复盘比多看题解有用得多。