ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

链表算法面试全解析:虚拟头节点与快慢指针实战套路

链表算法面试全解析:虚拟头节点与快慢指针实战套路

我前一阵密集面了几家公司,发现一个挺有意思的现象:不管面试官是什么级别、什么方向,链表题几乎成了“标配开场”。尤其是准备过牛客面试热题101里链表系列之后,很多题你甚至不用等面试官开口,心里已经能预判他要考你哪个变体。这份题库的妙处在于——它不是堆题,而是把链表能考的点全给串起来了。

这篇文章我就以“面试拷打技术总监”的视角,把链表系列从头到尾拆一遍。不只是给答案,而是把为什么这么写、面试官想听到什么、实践里哪些坑最致命,全讲清楚。准备校招社招、转码刷题的朋友,这篇可以直接当作链表模块的复习提纲。

1. 链表在算法面试中的位置:它到底在考你什么

很多刷题新人会低估链表。觉得不就是节点 next 指针嘛,比数组难不到哪去。实际上链表题是面试里性价比极高的一类题,原因在于它同时考察了三件事。

第一是代码基本功。链表题没有现成的库函数能绕过去,每一步都是裸的指针/引用操作,写不写得干净利落、会不会多写空指针判断、断链之后还记不记得接回去,基本上一眼就能看出来有没有工程底子。

第二是边界条件意识。空链表、只有一个节点、两个节点、头尾删除、环的存在,这些边界在链表题里是高频考察点。面试官往往不会直接提醒你,他会在你写完代码后追问一句:“如果链表只有一个节点呢?如果 k 等于链表长度呢?”答不答得上来,直接决定了这题的评级。

第三是抽象建模能力。链表题里有相当一部分不是单纯考遍历,比如快慢指针、链表相加、合并 K 个排序链表,这些题本质是在考你能不能把一个复杂问题拆成简单的子问题,或者识别出某些题其实是双指针/归并模式的变体。

我面试的时候有个体会:面试官其实不太在意这道题你以前做没做过,他在意的是你有没有形成一套稳定的解题方法论。做准备要做到底层逻辑层面,而不是背题。

1.1 为什么我建议把 101 里的链表题当“模块”刷

刚开始刷题容易陷入一个误区——按题号顺序刷,今天刷到链表就刷链表,明天跳到二叉树就接着做。这样刷到后面会有一个严重的副作用:知识点是散的,面试时调用不出来。

我的做法是把链表系列拆成几个子模块,每个模块对应一种核心技法:

  • 基础操作与虚拟头节点:覆盖删除类、插入类题目;
  • 反转与穿针引线:覆盖从整体反转、区间反转到 K 个一组反转;
  • 快慢指针与环:覆盖判环、环入口、倒数节点、找中点;
  • 排序与合并:覆盖合并有序链表、K 路归并、链表排序;
  • 综合模拟:覆盖链表相加、重排链表这类需要边分析边处理的问题。

每个子模块集中刷 5 到 8 道题,刷完你会立刻发现:题目之间是互通的,很多题的核心就一两行代码,剩下全是边界处理。

1.2 面试官真正期待的能力模型

曾经有位面试官直接跟我说过一句话:链表题写到后面,判断一个人的水平不是看他能不能 AC,而是看他遇到“删除某个节点但只给你这个节点本身”这种问题时的第一反应。这其实是在说:你有没有养成“拿不到前驱就创造前驱”的思维习惯。

这就是为什么虚拟头节点在链表题里这么重要。它本质上是一个“桩子”,帮你把特殊位置(头部)变成普通位置,代码逻辑就统一了。这种在面对边界场景时主动加缓冲层的意识,放到工程里其实就是防御式编程。

所以每次答链表题,我脑子里会过一遍:这里有几种边界?哪些边界用统一的技巧能消化掉?时间复杂度是否仍是最优?这三个问题答完,基本就不用担心面试官再刁难。

2. 三个必会的链表底层技能,堆出所有解法的地基

链表题的花样再多,底层靠的还是那三板斧。把这三样练到肌肉记忆,剩下就是识别题型套框架。

2.1 虚拟头节点:让头部删除不再特殊

先看一个最简单的例子:删除单链表中等于给定值的所有节点。

如果不用虚拟头节点,你得先写一个 while 循环把开头的连续命中节点删掉,再处理中间部分。但如果带一个 dummy 头节点,代码就变成同一套逻辑,不会有任何位置分支:

def removeElements(head, val): dummy = ListNode(0) dummy.next = head pre, cur = dummy, head while cur: if cur.val == val: pre.next = cur.next else: pre = cur cur = cur.next return dummy.next

这个套路几乎贯穿整个 101 链表模块:删除链表的倒数第 N 个节点、删除排序链表中的重复元素、移除链表元素,都能用 dummy 把开头位置的“特殊性”抹平。你只需要记住一句口诀:不确定头节点会不会变,就加一个虚拟头节点。

2.2 三指针状态保持:移动顺序是唯一难点

链表的很多操作本质上是在维护一个“滑动窗口”。反转一个链表时维护 pre、cur、next 三个指针,判断环时维护 slow、fast 两个指针,删除倒数第 N 个时维护 first、second 两个指针。

指针本身的定义不难,难的永远是指针移动的先后顺序。

打个比方:这就像你在挪动一串手链上的珠子,你要把某个珠子换到别的位置,必须先松开前后两颗珠子的连接,然后把目标珠子取出来,再接到新位置。如果顺序错了,要么手链散架(丢失链表后半段),要么你自己都搞不清珠子是哪颗(指针错乱)。

所以我每次写链表题都会遵守一条铁律:修改任何节点的 next 之前,先把它的下一个节点用临时变量保存下来。我可以这样告诉你,所有链表题报错“运行超时”或“访问了空指针”,八成都是断链之后找不回来了。

2.3 画图是列公式,不是浪费时间

面试或者自己刷题时,遇到稍微绕一点的题,我建议直接在白板上画链表节点之间的指向变化。不要只用脑推,画出来之后代码就是照着图写的。

这个能力是可以刻意训练的。我刷 101 链表系列的时候,每道题都要求自己只对着图写代码。一开始画得很慢,后来发现这套视觉化的工作流优势很大——脑子不需要同时 hold 住“现在的状态”和“下一步要做什么”,画完图代码自然就出来了。

面试时还有个小技巧:如果你需要在白板上画图,面试官不但不会不耐烦,反而会认为你的解题方式可验证、可沟通,属于加分项。

3. 反转系列:从迭代到递归,把“穿针引线”想明白

反转链表是 101 链表系列里出现率肉眼可见高的题型。光一个整体反转就有迭代、递归两种写法,再往上加区间、按组翻转、两两交换,难度也会随之上升。但核心只有一条主线:段内反转的本质是逐个把节点挪到头部。

3.1 整体反转的迭代与递归实现

暴力解法就不提了,看迭代版。维护 pre、cur 两个指针,不断让 cur.next 指向 pre,然后整体右移:

def reverseList(head): pre = None cur = head while cur: nxt = cur.next # 保存后继,防止断链 cur.next = pre # 反转指向 pre = cur cur = nxt return pre

递归版会稍微烧脑一点。核心思路是把“反转整个链表”转化为“反转 head.next 这个子链表,再把 head 接到末尾”:

def reverseList(head): if not head or not head.next: return head new_head = reverseList(head.next) head.next.next = head head.next = None return new_head

面试时我更推荐先说迭代版,因为好解释、不会栈溢出。如果面试官追问“能不能用递归”,再补充递归版本,并说明递归会把大量栈帧压栈,链表太长有风险。

3.2 区间反转与 K 个一组反转的通用框架

区间反转(反转从 left 到 right 的部分)比较通用的写法是:先找到 left 的前驱节点,然后在这个区间内重复执行“头插法”。所谓头插法,就是每次把当前节点拎出来,插入到区间头部之后:

def reverseBetween(head, left, right): dummy = ListNode(0) dummy.next = head pre = dummy for _ in range(left - 1): pre = pre.next cur = pre.next for _ in range(right - left): nxt = cur.next cur.next = nxt.next nxt.next = pre.next pre.next = nxt return dummy.next

K 个一组反转只是把区间反转重复多遍:每轮找到第 K 个节点,然后套头插法。注意两点:一是 K 可能大于剩余长度,需要边数边处理;二是反转完一组后,pre 要移动到新的组头。

3.3 反转类题目最容易踩的坑

反转类题目我在实际写的时候踩过两次坑。

第一次是忘记把最后一段内部链接翻转完后,末尾接上新组头。其实这对应代码里 pre.next 的更新顺序,顺序不对就会接错。第二次是“区间反转”时,把 cur 和 nxt 搞混,导致链表成环。面试时这种成环错误特别致命——代码不会报错,但跑测试会直接超时。

我的调试经验是:遇到反转类题就打印整个链表,看看每个节点的 next 是不是和预期一致。比如打印 [1,2,3,4,5] 反转后变成 [5,4,3,2,1],每个节点的数值和顺序都能对上的话基本就稳了。

4. 快慢指针:环、倒数 K、交点,三兄弟一次打尽

快慢指针是链表模块里最值得深挖的一类题。它表面是对两个指针速度的控制,实际上背后是一套数学关系。把数学关系想清楚,环形链表、环形链表入口、链表中倒数第 K 个节点、相交链表,就全部变成同一类题的变体。

4.1 判断链表是否有环:为什么快指针至少要快一步

判断环的经典做法是快指针每次走两步,慢指针每次走一步。如果存在环,两指针必然相遇。

有人会问:为什么快指针不走三步、四步?因为步幅太大有风险,比如链表很长但环很小,快指针可能反复跳过慢指针所在位置(在环内跳格子),导致迟迟不相遇。步幅差为 1 时最多走 O(n) 轮必然相遇,是最稳妥的。

def hasCycle(head): slow = fast = head while fast and fast.next: slow = slow.next # 慢指针走一步 fast = fast.next.next # 快指针走两步 if slow is fast: return True return False

4.2 找环形入口位置:记下一个等量关系

进阶题是:不仅要判断有环,还要找到环的入口。这题网上推导很多,这里给一个便于记忆的结论。

设链表头到环入口的距离为 a,入口到相遇点的距离为 b,相遇点到下一次回到入口的距离为 c。当快慢指针相遇时,慢指针走了 a+b,快指针走了 a+b + n*(b+c)。因为快指针行程是慢指针的两倍,可以推出 a = c + (n-1)*(b+c)。这个式子翻译成人话就是:从相遇点开始走,和一个从头节点出发的新指针同步走,两者会在环入口相遇。

代码实现很直接:

def detectCycle(head): slow = fast = head while fast and fast.next: slow = slow.next fast = fast.next.next if slow is fast: ptr = head while ptr is not slow: ptr = ptr.next slow = slow.next return ptr return None

当初刷到这里的时候,我还特意画了一堆图去验证结论。面试的时候如果有人追问“为什么是走两步而不是走一步”,能把上面的等量关系讲清楚,会显得你确实吃透了原理,而不是背了模板。

4.3 倒数第 K 个节点与相交链表

找链表的倒数第 K 个节点,用一个快指针先走 K 步,然后快慢同步走,快指针到底时慢指针正好指向倒数第 K:

def getKthFromEnd(head, k): fast = slow = head for _ in range(k): if not fast: return None fast = fast.next while fast: fast = fast.next slow = slow.next return slow

相交链表题的思路稍微需要变通一下:让两个指针分别从 A、B 头出发,走到结尾后切换到对方链表继续走。如果两个链表有交点,两个指针必定会在交点相遇;如果没有交点,它们会同时走到 None。这个思路本质是让两个指针走过相同长度。

这几个题掌握之后,快慢指针类题的规律就清楚了:先想清楚谁先走、速度差多少,再想清楚相遇之后要利用哪个等量关系。

5. 删除、排序与合并:高频中等题的重灾区

如果说反转和快慢指针是热身,那删除、排序、合并这几类题才是回回卡人的地方。它们不像反转那么“公式化”,每题都需要结合具体场景设计细节,非常考验边界处理。

5.1 删除链表的倒数第 N 个节点:虚拟头节点加双指针的标准配合

这题是快慢指针和 dummy 节点的组合应用。先让 first 走 N 步,然后 first 和 second 同步走,first 到尾时 second 正好指向待删节点的前一个节点。因为涉及删除头部的情况,直接给链表加一个虚拟头节点最稳妥:

def removeNthFromEnd(head, n): dummy = ListNode(0) dummy.next = head first = head for _ in range(n): first = first.next second = dummy while first: first = first.next second = second.next second.next = second.next.next return dummy.next

注意一个细节:这里 second 初始化为 dummy,而不是 head。这样能保证 first 到底时,second 指向“待删节点”的前驱。如果把 second 初始化为 head,遇到删除头节点的情况就会漏掉该有的处理。

5.2 合并两个有序链表与合并 K 个排序链表

两个有序链表合并是归并排序的雏形。迭代版用一个哨兵头节点维护结果链表的尾部指针;递归版更短,但要注意递归深度:

def mergeTwoLists(l1, l2): dummy = ListNode(0) tail = dummy while l1 and l2: if l1.val < l2.val: tail.next = l1 l1 = l1.next else: tail.next = l2 l2 = l2.next tail = tail.next tail.next = l1 or l2 return dummy.next

合并 K 个排序链表,常见思路有三种:两两合并、优先队列逐步取最小、分治归并。我推荐优先队列的实现,代码直观且时间效率稳定。面试时你可以先把两种思路都说出来,再选自己熟悉的那版写,这样会让面试官觉得你是在做方案选型而不是死记硬背。

5.3 链表排序:归并排序的“找中点 + 合并”组合拳

单链表排序不能像数组那样随便随机访问,所以最好写的排序算法是归并排序。步骤就三步:

  1. 用快慢指针找链表中点;
  2. 递归排序左右两段;
  3. 用上面的 mergeTwoLists 合并。

中点注意用 fast 和 fast.next 同时判空,否则会取不到正确的左半部分。写完后跑一个 5 个节点的奇数长度和一个 4 个节点的偶数长度用例,基本就能确认没问题。

5.4 链表相加:进位处理的两种方向

链表相加有两种变体:正序相加和逆序相加。逆序(数字低位在前)比较好处理,直接同时遍历两条链表,维护进位即可。但正序相加时,数字的低位在链表尾部,此时可以先把两条链表反转,再按逆序相加,最后把结果反转回来。

这个思路本身不难,难的是你当场能不能意识到“反转一下就能把问题转化成熟悉的形态”。所以平时刷题我养成了一个习惯:遇到不顺手的题目,先想想能不能通过一次反转、加虚拟头、或者双指针,把它变成做过的题。这一招在面试当场非常救命。

6. 综合题与易错点:面试场上最怕的翻车现场

刷完上面几大模块后,基本能做到 101 链表系列里的题一眼有思路,但离“面试不翻车”还有一段距离。我觉得最后一段必须单独聊聊面试现场的真实情况,以及怎么在实战中稳定发挥。

6.1 成环死循环的典型场景与快速排查

链表题最常见的严重错误就是成环——不是因为链表本身有环,而是你在反转、删除、重排的过程中把 next 指错了,造成循环。

这种错误最迷惑的地方在于:程序不报错,但测试时会超时。我自己就有一次在“重排链表”这道题上,把 pre.next 和 cur.next 接反了,代码跑起来直接死循环。当时完全没想到是逻辑问题,还以为是编译器卡了。

排查方法很老套但有效:不要直接盲试,先打印整个链表每次操作后的状态。用 Python 的话可以写一个辅助打印函数,每次修改完链表就打印一遍。链表的节点数一般不大,打印成本完全能接受。

6.2 递归深度和栈溢出:不是所有链表题都适合递归

链表题里很多经典解法都有递归版本,比如反转链表、合并有序链表、归并排序。但面试时要多考虑一层:如果这是一个几万节点的超长链表,递归会怎样?

递归版本会占用 O(n) 的调用栈。面试官如果问“如果链表非常长,你的解法还适用吗”,其实就是在考察你有没有意识到栈溢出风险。我的习惯是:先说清楚递归版是一种优雅的写法,然后主动补充迭代版作为短链表的稳妥选择。

不光是链表,任何递归题我都很认可“先讲清楚两种方案的取舍”的表达方式。这是高级工程师跟初级开发拉开差距的地方。

6.3 面试表达:从“会写代码”到“讲清楚方案”

代码写对了,并不代表面试分数一定高。我面过的所有公司里,那些给出高评价的候选人,共同点是都能用很清晰的逻辑把自己的解题过程讲出来,而不是闷头写完让面试官自己看。

链表题我建议按这个顺序组织表达:

  • 先说出题型的核心模式,比如“这题是快慢指针 + 虚拟头节点的典型应用”;
  • 再分析关键边界,比如删除头部、链表长度为 1、k 大于链表长度;
  • 然后写代码,边写边解释每个关键赋值动作的目的;
  • 最后主动分析时间复杂度和空间复杂度。

这套表达框架其实也是在向面试官展示工程能力:面对一个需求,你懂得先梳理方案、再处理边界、最后落地实现。

6.4 我练完 101 链表系列后的做题感受

最后说一点个人体会。刚刷链表题的时候,我最容易陷入“对着答案抄一遍就觉得自己会了”的假状态。后来我换了个策略:每天只刷 3 道链表题,但每道题必须做到三遍。第一遍不看代码自己试着写;第二遍对答案分析差距;第三遍在完全不看笔记的情况下重新实现,并加上边界测试用例。

这种方法比较笨,但坚持下来之后,链表题在面试里基本不会再让我恐慌。而且练到后期,你会发现自己真的能跟面试官聊出些门道来——他抛一个变体,你就能大概猜到他想听哪个思路。所谓“拷打技术总监”,其实就是把这道题的所有角度都提前想过一遍,等他开口提问时,你已经有备而来。

RELATED READING

延伸阅读

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