1. 链表题刷了忘、忘了刷?问题多半出在"死记题解"上
很多人在链表这块的体验是这样的:今天看完反转链表的题解,觉得"就这?",明天碰到反转链表 II 又懵了;后天看到环形链表,记得要用快慢指针,但"为什么快指针走两步、慢指针走一步就一定能相遇"根本说不清楚。于是陷入循环——刷题、遗忘、再刷、再遗忘,最后怀疑自己的记忆力。
我在某平台把链表专项刷了三轮之后,一个很深的体会是:链表题根本不需要背,它需要的是"拆解"。链表这个数据结构一共就那么几种变化:指针怎么走、节点怎么换、边界怎么处理。把这些底层逻辑吃透,题目怎么包装都绕不开这几件事。这篇文章就是把我在刷题过程中整理出的链表核心框架、高频题型的拆解思路、以及那些容易让人栽跟头的边界细节完整记录下来,希望对正在刷链表专项的朋友有帮助。
链表题适合谁看?一类是刚开始刷 LeetCode、对指针操作还很生疏的新手,你可以把这篇当成一个带原理的索引;另一类是已经刷过一些题、但总在"会做和做对"之间反复横跳的人,这篇的重点就是帮你把错误模式归类,知道下次该往哪个方向检查。
2. 链表题的根本:指针操作只有三种动作
在拆具体题目之前,先把链表题最底层的东西说清楚。链表题不管怎么出,落到代码层面就是三个动作:改 next、移动指针、处理边界。你仔细观察每道题的解法和 Bug,几乎都能归类到这三件事里。
2.1 改 next:所有链表操作的原子动作
链表节点就是一堆"值 + 指向下一个节点的引用"。你做任何操作,本质都是在改某个节点的 next 指向谁。比如反转链表,就是把每个节点的 next 从"指向后一个"改成"指向前一个";删除节点,就是把前一个节点的 next 从"指向被删节点"改成"指向被删节点的下一个";合并两个有序链表,就是比较两个链表的头节点,把较小的那个的 next 指向"剩下链表合并后的头"。
这里容易忽略的一点是:改 next 是有顺序的。在链表中,如果你先把 A 的 next 改成 B,但 B 原本是 A 的下一个节点,那么 B 之后原来的节点就找不回来了——除非你提前用一个变量把它存下来。这也是很多人写链表题总出 Bug 的第一大原因:指针覆盖的顺序错了。
2.2 移动指针:循环里的推进步调
移动指针是链表题循环体的基本动作,常见的模式是 cur = cur.next。但不同题型里,指针移动的步调和数量完全不一样,这也是需要专门理解的部分。
- 遍历链表:一个指针,每次走一步,到 null 停下。
- 找中点:快慢指针,快指针每次走两步,慢指针每次走一步,快指针到末尾时慢指针就在中点。
- 找倒数第 N 个节点:两个指针,第一个先走 N 步,然后两个一起走,第一个走到 null 时第二个就在目标位置。
- 判断环:快慢指针,同上,但需要额外判断相遇条件。
指针移动的问题还涉及"循环终止条件"。很多人写 while 循环时只记得 p != null,但在需要用到 p.next 的场景里,得改成 p != null && p.next != null,否则循环体里访问 p.next.next 就会空指针。这种错误特别典型,几乎每个链表题的新手都栽过。
2.3 边界处理:空链表、单节点、头节点变更
链表题有一个特点:代码主体的逻辑并不难,真正决定你能不能一次通过的,是边界条件。最典型的就是这三类:
- 空链表:head 为 null 时,所有操作都应该直接返回 null 或不执行。
- 单节点:只有一个节点时,反转、删除、找中点等操作是否正确。
- 头节点被改变:删除头节点、反转后头节点变成原尾节点,这时要知道怎么返回新的头。
这些边界问题用通用手段就能解决,其中最常用的就是虚拟头节点(dummy node)。给链表强行加一个"前置空节点",让头节点的处理和中间节点完全一致,很多分类讨论直接消失。这个技巧在链表题里出现的频率极高,后面每个高频题型里都会用到。
3. 专项拆解:五类高频链表题的通用解法框架
链表题刷到一定量之后,你会发现高频题型其实是有限的。LeetCode 链表篇的核心题,大致可以归纳成五大类:反转类、合并类、删除类、环与相交类、以及综合重排类。下面按类别拆它们的核心套路。
3.1 反转类:从"迭代三指针"到"递归理解"
反转链表是链表题的基础款,也是后续好多题的地基。比如反转链表 II、K 个一组翻转链表、回文链表等,全都依赖反转操作。
迭代法的核心是三个指针:prev、cur、next。
- prev 初始为 null,表示"当前节点的前一个节点"。
- cur 初始为 head,表示"当前要处理反转的节点"。
- 每轮循环先存 next = cur.next(防止断链),然后把 cur.next 指向 prev,接着 prev 移动到 cur,cur 移动到 next。
这个过程中最关键的操作就是"先存 next"。我在前面提到过"改 next 有顺序",反转链表就是最经典的例子:如果不先把 cur.next 存下来,cur.next 被改成 prev 之后,原来的后续节点就全丢了。
递归法的思路不太一样,它的核心是"把当前节点的后继节点的 next 指向当前节点"。递归函数 reverse(head) 的返回值是反转后的新头。对递归版本,我的建议是:先把它当成"黑盒"理解,即假设 reverse(head.next) 已经帮我们把后面那段反转好了,我们只需要处理 head 和 head.next 的关系。等用熟了,再去深入理解递归栈的展开过程。
实测建议:面试或笔试场景优先迭代法,因为好写、好调试、空间复杂度是 O(1)。递归法虽然代码短,但递归深度受链表长度影响,链表特别长时(比如上万节点)容易出现栈溢出,而且递归版本的空间复杂度是 O(n)。
3.2 合并类:两种合并,一个套路
合并类题目主要包括"合并两个有序链表"和"合并 K 个有序链表"。前者是基础,后者是进阶。
合并两个有序链表的思路非常直观:用两个指针分别指向两个链表头部,比较值的大小,把较小者接到结果链表上,然后移动对应指针。这里有一个常用技巧——虚拟头节点 + 尾指针。
- 虚拟头节点 dummy,作为结果链表的头之前的占位节点。
- 尾指针 tail 一开始指向 dummy,每次把选中的节点接到 tail.next,然后 tail 向后移动。
- 循环直到其中一个链表为空,然后把另一个链表的剩余部分直接接上。
直接接上剩余部分这个操作,很多人会写成循环逐个接。其实链表可以整体接,因为剩余部分本身就已经是有序链表了,尾指针的 next 直接指向剩余链表的头节点即可,这是链表操作里非常实用的小优化。
合并 K 个有序链表的朴素做法是每两个合并一次,但那样效率低。面试中更推荐用优先队列(小顶堆)来做:把所有链表的头节点放进堆,每次弹出最小的节点接入结果链表,同时把该节点的下一个节点入堆。这样每个节点只会入堆出堆各一次,时间复杂度是 O(n log k),其中 n 是总节点数,k 是链表数量。
3.3 删除类:虚拟头节点是低配玩家的神
删除类题目的典型代表是"删除链表的倒数第 N 个节点",以及"删除排序链表中的重复元素"系列。
删除倒数第 N 个节点的核心是"找到要删节点的前一个节点"。因为单链表只能从前往后走,删除操作必须操作前驱节点。最常用的方法就是双指针,或者叫"延迟指针"思路:
- 第一个指针先走 N 步。
- 然后第二个指针从 head 出发,两个指针同步走,第一个指针走到 null 时,第二个指针正好停在倒数第 N 个节点的前一个位置。
- 执行删除,即 prev.next = prev.next.next。
最容易出 Bug 的点:如果要删的就是头节点怎么办?比如链表只有 5 个节点,要删倒数第 5 个(也就是头节点)。这时如果按上面的双指针来走,第二个指针停在 head 上,没法通过"前驱节点的 next"来删除。解决方法仍然是虚拟头节点——让两个指针都从 dummy 出发,第二个指针最终会停在 dummy(也就是头节点的前驱),删除操作就统一了。
删除排序链表重复元素有两个版本。一个是"只保留一个",用 cur 指针遍历,如果 cur.next 的值和 cur 相同,就跳过 cur.next;另一个是"重复的全删",这种需要额外注意:如果头节点本身是重复的,头节点也要删,所以更推荐用 dummy 统一处理。
3.4 环与相交类:快慢指针的数学原理
环形链表和相交链表这两类题,表面上看场景完全不同,但底层的双指针思想是相通的。
判断链表是否有环:快慢指针的经典应用。slow 每次走一步,fast 每次走两步,如果链表有环,两者一定会在环内相遇。为什么?可以这样理解:当 slow 进入环时,fast 已经在环里了。假设环的长度为 L,此时 fast 离 slow 的距离是 d(0 < d ≤ L),每走一轮,fast 相对 slow 的位移是 1,所以最多 L 轮之后 fast 一定会追上 slow。这就是"快指针走两步"的数学依据。有人问为什么不走三步?走三步也能追上,但不是每轮追一步,可能跨过 slow 而造成"跳过"的情况,还需要处理步长和环长的整除问题,实现更复杂。
找到环的入口:在快慢指针相遇后,把其中一个指针移到 head,然后两个指针都以步长 1 前进,再次相遇的位置就是环入口。这个结论看着像魔术,其实可以推导:假设 head 到环入口的距离为 a,环入口到相遇点的距离为 b,环长度为 L。相遇时 slow 走了 a + b,fast 走了 a + b + kL(k 是 fast 在环里多绕的圈数),又因为 fast 走的路程是 slow 的两倍,所以 2(a + b) = a + b + kL,推出 a = kL - b。也就是说,head 到入口的距离,等于相遇点继续走到入口的距离。所以让一个指针从 head 出发、一个从相遇点出发,同步走,一定会在入口相遇。
相交链表的常规解法是先分别求两个链表的长度,让长链表先走步数差,然后两个指针同步走,第一个相等节点就是交点。更简洁的做法是"双指针互相走完对方的链表":pA 从 A 头出发,走到末尾后跳到 B 头;pB 从 B 头出发,走到末尾后跳到 A 头。如果两个链表相交,它们会在交点相遇;如果不相交,会在最后同时到达 null。这个解法代码短,但第一次看的时候很难想明白,建议配合链表长度差的思路去理解。
3.5 重排类:三种操作的组合拳
重排类题目比如"重排链表"(L0 → Ln → L1 → Ln-1 → ...)和"回文链表",通常可以拆成三步组合:
- 找中点:用快慢指针找到链表的中点,或者中点前一个节点。
- 反转后半段:将中点之后的链表反转。
- 合并/比较两段:把前半段和后半段进行合并,或者逐节点比较值。
以"重排链表"为例:
- 找中点,将链表切成前后两半。
- 把后半段反转,得到新链表。
- 将两个链表交替合并:先取前半段第一个节点,再接后半段第一个节点,再取前半段第二个节点……
这类题的价值在于,它把前面对反转、双指针、遍历的理解全串起来了,属于综合题。如果你前面几类都掌握得还行,但重排类老是不能一遍写对,大概率是卡在"找中点的边界"或者"链表中点后面那段的起点确认"上。
具体来说,找中点有两种写法:一种是让 fast 从 head 出发,走两步,slow 走一步,循环结束后 slow 指向中间节点;另一种是让 fast 从 head.next 出发,循环结束后 slow 指向前半段的最后一个节点。两种写法在"节点个数是偶数"时,slow 的落点不同。刷题时要明确自己用的是哪种,不然下一步反转后半段时会把中间节点搞重复。
4. 刷题实战中的血泪教训:必须避开的五个坑
链表题的代码一般不长,20 到 40 行就能写完,但 LeetCode 的提交记录里,链表题的提交错误率一直不低。我自己刷完三轮后复盘,发现错误主要集中在下面五个坑里,而且这些坑是有规律地反复出现的。
4.1 空指针访问:p.next 之前必须确认 p 不为 null
这是链表题第一大类错误。典型场景是在循环里直接写 cur.next.next,却没有保证 cur 和 cur.next 都不为 null。
我踩过的一个非常具体的场景是"删除链表的倒数第 N 个节点"的双指针写法里,第一个指针先走 N 步时,如果 N 正好等于链表长度,那么第一个指针刚好移到 null。接着第二个指针开始同步走,循环条件是第一个指针不为 null,这个没问题;但如果在循环体内访问第一个指针的 next 做判断,就会空指针。
经验法则:凡是代码里出现 p.next,你就要问自己:p 有没有可能是 null?凡是出现 p.next.next,你就要问自己:p.next 有没有可能是 null?做题时先把所有边界情况列出来,再动手写。
4.2 断链:被覆盖的 next 没有提前保存
链表操作中等价于"拆链再挂链"。拆链之前,被拆的那个节点的 next 可能指向后续节点,如果你把它覆盖成别的值,又没保存原 next,后续节点就丢了。
反转链表是最典型的情况。很多人在写反转循环时,习惯先把 cur.next 改了,然后再去找原来的下一个节点,结果发现找不到了。正确顺序永远是:先用临时变量保存 next,再改 cur.next,最后移动指针。
这个"先保存再修改"的习惯,在几乎所有会修改节点间关系的题目里都通用,包括删除节点、交换相邻节点、重排链表、反转链表 II 等。
4.3 循环边界:while 条件里 p 与 p.next 的搭配
链表题的循环条件有时候是 p != null,有时候是 p.next != null,选错会直接导致漏处理尾节点或者多处理一个空节点。
我自己的使用习惯是:
- 如果循环体里需要"当前节点"和"下一个节点"的比较(比如删除重复元素、合并有序链表),通常用 cur != null && cur.next != null。
- 如果循环体只处理当前节点本身,比如反转链表,用 cur != null 即可。
- 找中点时,让快指针走到尽头,循环条件一般是 fast != null && fast.next != null。
一个比较通用的做法是:写循环之前先想清楚退出循环时,指针停在哪里。这个"停点"决定了你接下来要处理的节点是哪一个,也决定了边界判断怎么写。
4.4 中间节点归属:偶数长度链表的"中点模糊"
找中点在很多题里是第一步,但中点的定义在链表中有歧义:偶数长度的链表,中点到底偏左还是偏右?
比如链表 1 -> 2 -> 3 -> 4,中点可以是 2,也可以是 3。两种定义对应的代码写法不一样,后续操作也不一样。重排链表一般希望把链表分成两半,前半段是 1 -> 2,后半段是 3 -> 4,那么中点取 2(前半段末尾)更合适。而回文链表比较前后两半时,把中点取成 2 或 3 都能工作,只要后面比较的范围写对。
我的建议:在练习每道题时,主动用偶数长度链表面试自己——如果把链表的长度改成偶数,我的代码还能跑对吗?不能的话,就是中点归属的处理有问题。
4.5 头节点可变:忘记更新返回值
这个问题在新手里太常见了。写完反转链表,函数最后返回的还是 head,但此时 head 已经变成末尾节点了。链表的头变成了原来的尾节点,如果返回 head,整个链表相当于只剩一个节点,或者直接形成环。
养成一个习惯:如果一个操作可能让头节点发生变化,先明确新的头节点是谁。反转链表的新头是原链表的尾节点;删除头节点后的新头是原第二个节点;交换相邻节点时新头是原第二个节点。要么单独用一个变量保存新头,要么用虚拟头节点让头节点相对位置不变,返回 dummy.next。
5. 实操记录:一道综合题的完整推导过程
光讲框架不给案例,等于没说。我用一道中等难度的综合题来完整走一遍推导过程,题目是"两两交换链表中的相邻节点"(LeetCode 24)。
这个题的描述很简单:给定链表 1 -> 2 -> 3 -> 4,返回 2 -> 1 -> 4 -> 3。看起来很容易,但实际写的时候,指针步调只要乱一步就出错。
5.1 第一步:确定要操作的节点
两两交换,意味着每轮要处理两个节点。以第一轮为例,涉及 1、2 两个节点,而它们的上一个节点是 dummy。我们需要的指针有:
- prev:当前待交换的一对节点的前一个节点。
- first:第一个待交换节点。
- second:第二个待交换节点。
如果用 prev.next 表示 first,prev.next.next 表示 second,那么两个节点交换后,顺序变为 second -> first,而 second 原本的 next 要接回原链表剩余部分。
5.2 第二步:写出每一轮的指针变动序列
我来写具体代码,并标注每一步的意义:
var swapPairs = function(head) { let dummy = new ListNode(0, head); let prev = dummy; while (prev.next !== null && prev.next.next !== null) { let first = prev.next; let second = prev.next.next; // 第一步:first 指向 second 的下一个节点 first.next = second.next; // 第二步:second 指向 first second.next = first; // 第三步:prev 指向 second prev.next = second; // 第四步:移动 prev 到 first,准备处理下一对 prev = first; } return dummy.next; };这里有一个非常关键的细节:四步操作的顺序不能变。
如果你先把 second.next 改成 first,那么 second 原本的下一个节点(第三对及以后)就找不到了,因为 first.next 还没被更新,而且你也没有提前保存 second.next。正确顺序是先让 first 挂到 second 的后面(也就是剩余链表),再让 second 指向 first,这样原链表剩余部分不会丢。
5.3 第三步:边界检查
写完代码,别急着提交。先用三个边界测一下:
- 空链表:head 为 null,dummy.next 为 null,返回 null,正确。
- 单节点:循环条件不满足(prev.next.next 为 null),直接返回 dummy.next,仍然是原链表,正确。
- 奇数长度链表:比如 1 -> 2 -> 3,循环走完第一轮后 prev 移到 1,此时 prev.next 是 3,prev.next.next 是 null,循环终止,3 保持不动,正确。
这道题如果不用 dummy,处理头节点交换时会很麻烦,因为头节点的前驱不存在。而用了 dummy,整个过程就和中间节点的处理完全统一了。
5.4 第四步:从这道题里提炼通用技巧
这道题最有价值的点在于,它把链表操作里最常见的三个注意事项全部体现了一遍:先保存后修改、操作前驱节点、用 dummy 统一边界。
做完这道题,你再回头去看"反转链表 II"、"K 个一组翻转链表",会发现它们的代码结构非常相似——都有一个 prev 指向待处理区间前的节点,都在循环里先保存 next,再改指向,再移动 prev。这些题的核心框架其实是一样的,只是每次要反转的节点数量从 2 变成 k,边界判断更复杂了一点。
6. 链表专项刷题路线与效率工具
如果你准备系统地刷链表专项,我建议按下面的顺序推进,这条路线的设计逻辑是:先用基础题建立"改指针"的感觉,再用中等题强化边界处理,最后用综合题把各个技能串起来。
6.1 推荐刷题顺序
第一阶段:基础操作题。刷"反转链表"、"合并两个有序链表"、"删除链表的倒数第 N 个节点"。这三道题分别对应反转、合并、删除三个基础操作,而且代码量都不大,适合建立手感。
第二阶段:结构变换题。刷"两两交换链表中的节点"、"反转链表 II"、"K 个一组翻转链表"。这三道题逐渐增加区间处理的复杂度,需要你对 prev、cur、next 的移动非常敏感。
第三阶段:双指针技巧题。刷"环形链表"、"环形链表 II"、"相交链表"、"删除排序链表中的重复元素 II"。这类题的重点是指针的数学关系和边界分类。
第四阶段:综合题。刷"重排链表"、"回文链表"、"排序链表"。这三道题都需要你把前面的技巧组合使用,能独立写对这三道,链表专项基本就算是过关了。
6.2 用什么语言刷
语言选择上,我的建议是:如果是练习阶段,用你面试或工作中最常用的语言。链表操作在 C++ 里涉及指针,在 Java/JavaScript 里涉及引用,在 Python 里涉及对象引用,语法细节不同,但核心逻辑完全一致。我在本地练习时用 JavaScript 多一些,因为写起来快,不需要考虑内存管理,适合快速验证思路;但如果你要准备面试,建议用目标公司的面试语言练熟,因为"链表的指针操作"在很多面试里会要求手写,语言不熟会很吃亏。
6.3 做题时的笔记方法
我刷链表题时养成了一个习惯:每道题提交通过之后,用两句话总结"这道题的核心操作"和"这道题的边界陷阱",记在表格里。比如:
| 题目类型 | 核心操作 | 最常见 Bug |
|---|---|---|
| 反转链表 | 三指针迭代 | 忘记先保存 next |
| 删除倒数第 N 个节点 | 双指针延迟 + dummy | 头节点被删时前驱缺失 |
| 环形链表 | 快慢指针 | 循环条件写成 fast.next 而不判 fast |
| 重排链表 | 找中点 + 反转后半段 + 交替合并 | 偶数长度中点偏左偏右混淆 |
这个表格不需要很复杂,但它能帮你快速定位薄弱点。等到第二轮刷题时,你可以只看这个表,回忆每道题的完整解法,回忆不出来的再回去看代码。
7. 写在最后:链表题到底该怎么练?
链表题刷到后面,我最大的感受是:它其实是最接近"物理操作"的一类算法题。树的题有递归和遍历框架可以套,动态规划的题有状态定义和转移方程可以依赖,而链表题就是纯粹的指针游戏——你把节点想成真实的盒子,每个盒子上有一根绳子指向下一个盒子。你的任务就是不断拆绳子、绑绳子、换绳子,而且要保证过程中没有任何一个盒子被"弄丢"。
所以练习链表题,最好的方式真的就是在纸上画。每道题,先把链表画出来,标上指针,然后手动走一遍循环,看看每一步指针怎么变。很多在代码里看不出来的问题,一画就清楚了。我个人刷完三轮之后,几乎每道链表题都能做到不看题解写出来,靠的就是这个"画图 + 手动模拟"的习惯。
如果你现在正处于"链表题总是差一点"的状态,别急着加题量。找一道你做过的题,用画图的方式重新走一遍整个流程,把边界情况都列出来,多半会有一种"原来如此"的感觉。这个数据结构就是纸老虎,捅破那层窗户纸之后,你会发现 LeetCode 链表篇的题目,翻来覆去就是那么回事。