如果让我给算法面试划一条“最快见效”的线,我会把 LeetCode Hot 100 里的链表题单独拎出来。原因很简单:链表题的题型数量一只手数得过来,但出现的频率却出奇地高。很多朋友刷题时优先刷数组、动态规划,链表题总觉得“简单”,结果一到面试现场,头节点该不该加哨兵、快慢指针什么时候初始化、K 个一组翻转的递归返回什么,写着写着就开始慌了。
我写这篇文章,不是想再罗列一遍答案,而是想把这些年反复刷链表题之后的思考方式分享出来。Hot 100 里的链表题大概十几道,覆盖了反转、合并、环形检测、两两交换、按 K 组翻转、相交链表等经典考法。你如果能把这十几道题的底层套路吃透,链表模块基本就拿下了。这篇文章适合两类人:一类是准备面试、想把 Hot 100 里链表题一次性搞定的求职者;另一类是已经刷过部分链表题、但总是在边界条件和指针操作上翻车的人。文章里的代码以 Python 为主,思路同样适用于其他语言。
1. 链表题的价值被低估了:Hot 100 里的链表题型全貌
先说结论:链表题是 Hot 100 里“套路密度”最高的模块。数组题可能涉及二分、前缀和、滑动窗口、动态规划,五花八门;链表题翻来覆去就是两类基础操作——改指针、走指针。因为在单链表里,你能做的只有两件事:改变节点的 next 指向,以及沿着 next 移动。这两个简单动作组合起来,却演化出了数量可观的经典题目。
我把 Hot 100 中的链表题按自己的刷题习惯做了个分类,未必和官方分类一一对应,但很能说明问题:
| 题型分类 | 代表题目 | 核心手段 |
|---|---|---|
| 反转类 | 反转链表、反转链表 II、回文链表 | 迭代反转/递归反转、快慢指针找中点 |
| 合并与排序 | 合并两个有序链表、合并 K 个升序链表、排序链表 | dummy 节点、归并思想 |
| 双指针与环 | 环形链表、环形链表 II、相交链表、删除链表的倒数第 N 个节点 | 快慢指针、双指针接力 |
| 重排类 | 两两交换链表中的节点、K 个一组翻转链表、重排链表 | 递归、区间反转、找中点 |
| 综合设计 | LRU 缓存 | 哈希表 + 双向链表 |
LRU 缓存严格来说不是纯链表题,但它本质上是把双向链表的增删操作和哈希表的 O(1) 查找结合起来,背包思想进入链表模块的典型代表。Hot 100 里能出现这道题也说明一个事实:链表不只是考“你有多少种遍历方式”,还在考“你能否用链表这种结构组织更复杂的数据结构”。
为什么说链表题值得单独花时间?第一,题型数量有限。十几道题的量级,不像动态规划那样需要大量类型积累。第二,解题模型极其固定:dummy 节点解决“头节点被修改”的尴尬,快慢指针解决环和位置问题,递归解决“从后往前处理”的重排问题。第三,链表题很适合用来练“边界感”,而边界感是面试官相当看重的素质。链表题的空指针、头指针、尾指针处理,几乎每一道题都在考这些。
还有一个容易被忽略的点:链表题的区分度很高。同样的思路,有的人五分钟写对,有的人半小时写出来还带 bug。中间的差距通常不是智力,而是是否建立了“预判指针状态”的能力。换句话说,高手不是背题,而是每动一步都知道当前指针在哪里、下一步要去哪里、可能踩什么空。这篇文章后面会专门讲这件事。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 四个贯穿链表题的核心动作:从 dummy 到快慢指针
我把链表题的常用动作收敛成四板斧:dummy 哨兵、快慢指针、递归反转、穿针引线。理解了这四个动作,Hot 100 里的链表题大部分都能往里面套。这四个动作不是孤立的,它们经常组合出现,比如“K 个一组翻转”就是 dummy + 区间反转 + 递归的合体。
2.1 dummy 哨兵:让头节点的操作不再特殊
单链表的头节点是最容易引发边界处理的地方。举个例子,删除倒数第 N 个节点,如果删的是头节点,那么原来的 head 引用就失效了,你得专门写一段特判;如果删的是中间节点,又要分类讨论。为了不写这种丑陋的特判,我们引入 dummy 节点:
python复制dummy = ListNode(0)
dummy.next = head
之后所有操作都建立在 dummy 这个节点上,最终返回 dummy.next。这样,哪怕原来的头节点被删除,dummy 依然能帮你找到新的头。这个技巧在合并两个有序链表、两两交换节点、K 个一组翻转里几乎都是标配。
我也见过一些朋友问:为什么不直接在原链表上改,非要弄个假头?原因很简单:当链表头部可能发生变化时,如果没有 dummy,你就必须维护一个“头节点是否变化”的布尔值,代码分叉会明显增多。有了 dummy,头节点的变化就退化成了普通节点变化,所有指针处理都统一了,代码写起来也顺。这里的附加提醒是:dummy 节点本身不要被误判成结果节点,最后一定要返回 dummy.next。
2.2 快慢指针:环、中点、倒数第 K 个都在这里
快慢指针是链表模块里最神奇的一个技巧。两个指针从同一位置出发,一个每次走一步,一个每次走两步,相对速度差是 1。正因为有这个速度差,slow 在很多场景下会被“卡”在特殊位置。
最广为人知的用法是环形链表检测:如果有环,fast 最终会和 slow 相遇。为什么是两步而不是三步?因为两步能保证在 O(n) 时间内检测出环,且不会出现 fast 跳过 slow 后恰好“完美错开”的循环情况;三步也可能成立,但分析更复杂,没有必要。面试时能说出“步长差 1 其实就够了,选 2 是为了直观且不会越界”这句话,会显得你理解更深入,而不是只是背模板。
快慢指针另外两个高频用法是找中点和找倒数第 K 个节点。找中点:fast 每次两步,slow 每次一步,fast 到达链表尾部时,slow 正好在中点或偏左的位置,这个性质在回文链表、排序链表里是前置条件。找倒数第 K 个:先让 fast 走 K 步,然后 fast 和 slow 同步走,当 fast 走到 null 时,slow 就在倒数第 K 个位置。这个思路配合 dummy 使用,就是删除倒数第 N 个节点的标准解法。注意一个细节:使用快满指针遍历时,循环体开头通常要判断 fast 和 fast.next 都不为空,否则容易出现空指针。
2.3 迭代与递归反转:几乎所有链表题的底线
反转链表是链表题的基本功。迭代版用三个指针 prev、cur、nxt,循环里先保存 cur.next,再把 cur.next 指回 prev,然后整体右移。写的时候最容易犯的错误是:改变了 cur.next 之后,再想向前移动,发现原来的后继已经找不到了。所以“先保存,再修改,再移动”是铁律。
递归版思路完全不一样:先递归处理当前节点后面的部分,拿到后半段反转后的新头,再把当前节点接到后半段之后。这一步的核心关系式是这样:
python复制head.next.next = head
head.next = None
很多初学者看不懂这两行,其实是在说:让“我的下一个节点”反过来指向我,同时切断我原来的后继,防止成环。递归版比较适合在面试里展示思路,但要注意它天然带 O(n) 的栈空间,如果面试官要求 O(1) 空间,你需要能切回迭代版。
反转链表本身不难,但很多复杂题都建立在它之上:回文链表要反转后半段,K 个一组翻转要反转局部区间,反转链表 II 要反转指定区间。所以这个动作必须到“不看也能写对”的程度。
2.4 穿针引线与断链顺序:链表操作的本质
最后一个动作最抽象,也最容易被忽略。单链表的增删改,本质都是“断链”和“重连”。穿针引线要求你始终记得:在修改一个节点的 next 之前,一定要保证这个 next 指向的节点有人引用着。就好比你在队伍里调整两个人的位置,得先抓住两个人的肩膀,否则一松手人就丢了。
以区间反转为例:要反转指定区间,你需要找到该区间的前驱节点,然后从区间开头开始,一遍遍地“头插”到前驱后面。每次头插都要先记录当前遍历节点的后继,再把当前节点的 next 指向前驱的 next。这个过程里,任何一个“后继记录”被遗漏,都会导致链表断裂或成环。
这四个动作里,dummy 最容易理解,快慢指针最需要推导,递归反转最考验思维,穿针引线最容易在边界出错。把这四件事吃透,再看 Hot 100 里的链表题,你的感受会完全不同:不再是面对一道新题硬想,而是自动开始识别“这道题用哪个动作组合”。
3. Hot 100 链表真题逐一拆解:六道题覆盖全部套路
这一节,我挑六道有代表性的题走一遍完整思路。六道题不是乱选的:反转链表覆盖反转基础,合并链表覆盖穿针引线,环形链表 II 覆盖快慢指针的数学本质,两两交换覆盖递归技巧,K 个一组翻转覆盖区间反转加递归,相交链表覆盖双指针路径推理。六道题吃透,剩下的链表题基本都会出现“熟悉的影子”。
3.1 反转链表:先写能跑通的那版
反转链表是入门题,也是最容易被低估的题。很多人刷过一遍就觉得会了,但一个月后再让写,还是会犹豫。我建议先掌握迭代版:
python复制# 206. 反转链表:迭代版
class Solution:
def reverseList(self, head: Optional[ListNode]) -> Optional[ListNode]:
prev = None
cur = head
while cur is not None:
nxt = cur.next
cur.next = prev
prev = cur
cur = nxt
return prev
关键点是初始 prev 一定是 None,而不是 head。因为新的尾节点是原头节点,它的 next 必须指向空。循环结束后 cur 为 null,prev 指向的是新的头节点,直接返回。
递归版长这样:
python复制class Solution:
def reverseList(self, head: Optional[ListNode]) -> Optional[ListNode]:
if head is None or head.next is None:
return head
new_head = self.reverseList(head.next)
head.next.next = head
head.next = None
return new_head
递归版的 base case 是“没有节点或者只有一个节点”。函数返回的是整条链表反转后的新头,但真正理解递归版的关键,是明白每个递归层返回的都是“子链表反转后的头”,而不是当前节点。面试时可以先讲迭代版,因为空间 O(1);再提递归版,展示你对问题拆解的理解,顺便提一句“递归虽然优雅,但栈空间是 O(n),如果对内存敏感可以改成迭代”。
3.2 合并两个有序链表:dummy 节点最典型的应用
这道题是“穿针引线 + dummy”的教科书。两条链表都有序,每次比较头部大小,把较小的节点接到结果链上:
python复制# 21. 合并两个有序链表:迭代版
class Solution:
def mergeTwoLists(self, l1: Optional[ListNode], l2: Optional[ListNode]) -> Optional[ListNode]:
dummy = ListNode(0)
cur = dummy
while l1 and l2:
if l1.val < l2.val:
cur.next = l1
l1 = l1.next
else:
cur.next = l2
l2 = l2.next
cur = cur.next
cur.next = l1 if l1 else l2
return dummy.next
注意循环结束后,两条链表里可能还剩一个尾巴,直接让 cur.next 接上剩余那条即可。这里为什么能用 dummy?因为结果链表的头节点在一开始未知,可能是 l1 的头也可能是 l2 的头,dummy 让我们不需要单独讨论哪种情况。这段代码的边界非常少,是很多链表题的模板。
这道题也可以递归写,甚至更短:merge(l1, l2) 等价于“较小节点的 next 指向 merge(较小节点的 next,另一条链表)”。但我建议面试时优先写迭代版,因为它不依赖系统栈,也不会带来额外空间。
3.3 环形链表 II:不只是判断是否有环,还要找入口
Hot 100 里这道题比“环形链表”更值得重点看,因为需要数学推导。基本思路还是快慢指针:fast 每次两步,slow 每次一步。如果无环,fast 会先走到 null;如果有环,两者最终会在环内某点相遇。
找到相遇点之后,把一个指针放回 head,另一个留在相遇点,两个指针每次都走一步,再次相遇的位置就是环入口。这个结论很多人记得,但不知道为什么。我用通俗的方式重新推一遍:
设链表头到环入口的距离为 a,环入口到相遇点的距离为 b,环周长为 C。相遇时 slow 走了 a+b,fast 走了 a+b+nC(n 是 fast 在环内多绕的圈数)。因为 fast 速度是 slow 两倍,所以 2(a+b) = a+b+nC,化简得到 a = nC - b。当 n=1 时,a = C-b,意思是:从链表头走到入口的距离,恰好等于从相遇点继续绕着环走到入口的距离。所以两个指针同速出发,一个从链表头,一个从相遇点,一定在入口相遇。
python复制# 142. 环形链表 II:快慢指针找环入口
class Solution:
def detectCycle(self, head: Optional[ListNode]) -> Optional[ListNode]:
slow = fast = head
while fast and fast.next:
slow = slow.next
fast = fast.next.next
if slow == fast:
ptr1 = head
ptr2 = slow
while ptr1 != ptr2:
ptr1 = ptr1.next
ptr2 = ptr2.next
return ptr1
return None
如果你只是背代码,遇到“说明为什么”就会卡壳。面试官非常喜欢追问这一段推导,建议把上面的公式自己推两遍,别只是记住结论。这道题的另一点是:即使有环,快慢指针在某个链表里也可能不会相遇,具体取决于步长和环长;但两步一步的组合一定会在有限次内相遇,这是 Floyd 判圈算法的保证。
3.4 两两交换链表中的节点:递归版重点在“返回什么”
两两交换的题目描述是把相邻两个节点交换。迭代版略繁琐,递归版却出奇的干净。核心思路是:先记住当前节点的下一个节点是 second,接下来要递归处理 second.next 开始的链表,递归返回“从后面交换完成后的新头”;然后把 second.next 指向 head,head.next 指向递归结果,最后返回 second。
python复制# 24. 两两交换链表中的节点:递归版
class Solution:
def swapPairs(self, head: Optional[ListNode]) -> Optional[ListNode]:
if not head or not head.next:
return head
second = head.next
head.next = self.swapPairs(second.next)
second.next = head
return second
这里的关键词是“返回什么”:递归函数永远返回当前这段处理完后的新头节点。你只需要保证当前层把新旧关系接正确,剩下的交给递归。很多人递归学不会,其实就是没确定好每一层递归的返回值到底是什么。在这道题里,当前层的新头是 second,因为交换后它排到了前面。
如果面试官要求迭代版,你可以用 dummy 记录新头,然后按“前驱节点 cur、第一个节点 first、第二个节点 second”三指针循环处理。思路不复杂,但边界情况比递归版多,需要格外注意循环结束条件。
3.5 K 个一组翻转链表:综合题的终极考验
K 个一组翻转链表可以说是链表模块的压轴题。它同时考区间反转、递归思维和边界判断。我的做法是:先检查从当前节点开始是否还够 K 个节点,不够就直接返回;够的话,反转当前长度为 K 的区间,然后递归处理后续链表。
关键点在于反转区间的实现:用 prev、cur、nxt 三指针,在 K 个节点范围内做反转。反转完成后,原来的 head 变成了这段的尾节点,它的 next 应该指向递归结果。
python复制# 25. K 个一组翻转链表:递归版
class Solution:
def reverseKGroup(self, head: Optional[ListNode], k: int) -> Optional[ListNode]:
cur = head
for _ in range(k):
if not cur:
return head
cur = cur.next
prev = None
cur = head
for _ in range(k):
nxt = cur.next
cur.next = prev
prev = cur
cur = nxt
head.next = self.reverseKGroup(cur, k)
return prev
观察这个代码:反转 k 个节点后,cur 指向下一组的起点;head 此时是本段的尾节点;prev 是本段的新头。于是 head.next = 递归处理下一组的结果,返回 prev。理解之后你会发现,它只是“反转链表”的局部版加递归外壳。
这道题的易错点在于:第一段反转时,剩下不足 k 个节点的情况要保留原顺序;反转完一段后,尾节点要和下一组的头正确连接,不能把整个链表分成两段。我建议你在纸上按照代码画一遍节点变化,尤其是 prev、cur、head 三个引用在每一轮循环后的状态,画完一遍基本就不会错了。
3.6 相交链表:双指针“接力赛”
相交链表的常规解法是让两个指针分别走两条链表,走到末尾就切换到对方的头节点继续走。如果两条链表相交,那么两个指针在第一次相遇时一定位于交点;如果不相交,它们最终会同时走到 null。原因是“接力”让两个指针走的总路程都是 A 的长度加 B 的长度,所以它们会同时到达两条链表的共同尾部。
python复制# 160. 相交链表
class Solution:
def getIntersectionNode(self, headA: ListNode, headB: ListNode) -> Optional[ListNode]:
if not headA or not headB:
return None
pA, pB = headA, headB
while pA != pB:
pA = pA.next if pA else headB
pB = pB.next if pB else headA
return pA
这道题看起来简单,但很容易在“pA 为空时应该切到哪一边”写反。记住:pA 走完 A 后切到 B 的头部,pB 走完 B 后切到 A 的头部,而不是回到自身。如果两条链表完全不相交,两个指针最终都会变成 null,循环退出,返回 None,和题目要求一致。
理解这道题还可以换一个角度:把相交链表想象成两条路从某个点后开始重合,你让 A 队的人走完自己的路后转去走 B 队的路,B 队的人走完自己的路后转去走 A 队的路,两拨人最终会在同一个时刻到达同一个路口,那个路口就是交点。
4. 链表题踩坑实录:空指针、死循环、断链三个高频事故现场
理论再多,最后都要落到“代码能跑通”。链表题的 bug 非常集中,我总结三个高发事故现场,每一个我都亲自踩过。如果你能在写代码时主动规避这三类问题,调试时间至少可以减少一半。
4.1 空指针:对 null.next 动手最致命
大多数空指针来自循环条件的判断失误。比如 while cur.next 和 while cur 完全不同:前者假设 cur 不为空,如果 cur 是 null 就会直接崩溃;后者假设 cur.next 可以访问。在反转链表里,如果循环条件写成 while cur.next,而循环体内又把 cur.next 改成了 prev,那么 cur 最终会变成 null,下一轮判读 null.next 就直接炸了。
建议每次写循环前先问自己:这个循环退出时,cur 到底在哪个位置?循环体内有没有可能让 cur 变成 null,而下一轮还在访问 cur.next?如果有,要么把条件改成 while cur,要么在访问前加判空。链表的空指针问题不像数组越界那样能靠异常捕获,它是静默的,出现一次整个程序就终止了。
4.2 死循环:环是怎么来的
死循环通常是反转或插入时没有切断旧指针。最经典的案例是递归反转链表时,如果漏了 head.next = None,那么原本链表的尾节点还会指向倒数第二个节点,链表内部出现循环。虽然单向链表本身依赖 next 走到尾,一旦成环,while 遍历就永远结束不了。
另一个容易成环的场景是区间反转。反转完一个区间后,如果没有把区间尾节点的 next 接到正确位置,下一轮会重复访问已经处理过的节点。我的习惯是:反转结束后,立刻在纸上模拟一条“从新头走到尾”的路径,确认没有某个节点被两次经过。这个验证只需要一分钟,但能省下大量调试时间。
4.3 断链:先保存后继,再修改指针
“断链”和“死循环”往往是一体两面:你把一个节点的 next 改了,却没有保存原来的后继,后面想继续走就找不到路了。比如三指针反转,如果你在循环里先执行 cur.next = prev,再执行 cur = cur.next,此时 cur.next 已经是 prev,实际上你在往回走,链表就乱了。
正确顺序永远是:先 nxt = cur.next,再 cur.next = prev,然后 prev = cur,最后 cur = nxt。这个顺序要背下来,像肌肉记忆一样。很多链表题的复杂操作,比如头插法、区间反转,本质上都是这个顺序的变体:先把后继摘出来,再改指针,再移动。
4.4 我常用的调试方法:print 和画图
链表题的调试和数组题不一样。数组打印出来一眼能看到越界,链表打印出来是一串地址和值。我的做法是写一个辅助函数:
python复制def print_list(head, limit=20):
res = []
step = 0
while head and step < limit:
res.append(str(head.val))
head = head.next
step += 1
return " -> ".join(res)
每操作几步就打印一次结果,观察链表变成什么样子。如果是环形链表题,打印时要人为设置步数上限,避免真死循环拖死调试环境。
另一个更朴素也很有效的方法是画图。把每个节点画成一个小方框,next 画成箭头,每一步操作都更新箭头方向。很多人懒得画图,觉得浪费时间,实际上遇到 K 个一组翻转这类题,画图十分钟能省下半小时的调试。尤其是递归题,画图能帮你理解每一层返回后指针的指向,比干瞪代码有效得多。
5. 链表思维迁移:从链表到二叉树,一次打通递归与双指针
链表学完之后,你可能会发现它不只是几道题,而是一套底层思维,可以平移到其他数据结构上。这一节我想聊聊两个特别实在的迁移方向。
5.1 链表反转与二叉树翻转的递归同构
二叉树的题目里,我们经常写递归:先处理左子树,再处理右子树。链表反转的递归其实也是类似结构:递归处理子问题,拿到子问题的返回值,再处理当前节点。区别只是链表的子问题只有一个方向,二叉树有两个方向。一旦你习惯了“递归函数返回什么”这个思维,反过来看二叉树的最大深度、翻转二叉树,都会觉得顺畅很多。
我把反转链表的递归和翻转二叉树的递归放在一起对比:
python复制# 链表反转的核心递归步骤
head.next.next = head
head.next = None
python复制# 二叉树翻转的核心递归步骤
root.left, root.right = root.right, root.left
两者都是在“处理完子树之后,再整合当前节点”。链表只要维护 next 的方向,二叉树要同时维护 left 和 right。练链表递归时养成的“先处理子问题,再处理当前层”的思维,在二叉树题目里可以直接复用。
5.2 双指针是滑动窗口、二分查找的通用骨架
快慢指针本质上是“两个指针在不同节奏下游走”。这个思想可以平移到数组题:盛最多水的容器、最长无重复子串、环形数组问题,大量题目都用双指针。链表里你练的是“什么时候移动谁、移动几步、在什么条件下相遇”,数组里不过是从 next 指针变成了下标移动,逻辑完全相通。
一个典型例子是“寻找数组中的重复数”这类题,快慢指针找环入口的思路和链表环形检测一模一样,只是把数组下标当成 next 指针来用。这也是为什么我说链表模块值得认真刷:它教你的不只是链表本身,而是一套“指针状态控制”的通用技能。
5.3 推荐练习节奏:从模仿到组合
如果你准备面试时间紧张,我建议这样安排链表模块的练习:
第一步,先用两三天把反转链表、合并链表、环形链表三道基础题写到“不假思索”的程度,目标是随手就能写对,不需要停下来思考。
第二步,把 dummy、快慢指针四个动作每天过一遍,在纸上画出来,理解每个动作解决了什么问题。
第三步,去做两两交换、K 个一组翻转、相交链表、回文链表,每道题都要求自己能说出“我用到了哪个动作、为什么用”。如果说不出来,说明还没真正掌握。
第四步,尝试做复合题,比如“重排链表”,它整合了找中点、反转后半段、再合并两段三个动作,能独立写出来,说明你已经能从单一动作组合成完整解法了。
这套节奏适用于大多数算法模块,不只是链表。核心思路是:先掌握最小单元,再做有限组合,最后挑战综合题。
最后再说一点个人体会。链表题刷多了以后,我看到一道题的第一反应不再是“我要背这道题的答案”,而是自动开始问:头节点会不会变?会不会出现空指针?有没有环?是反转还是重排?需要递归吗?我用到了几个核心动作?这套条件反射不是一天形成的,是踩了无数个 null 和死循环以后才建立起来的。如果你现在正处于刷题有点混乱的阶段,不要急着增加刷题量,先把链表这几个核心动作吃透,再回头看 Hot 100,你会发现很多题目其实就是几种基本操作的组合变体。链表模块的投入产出比在算法面试里是最高的之一,值得你认真花点时间。
