
聊链表在算法面试里的地位几乎可以说是“必考题收割机”。移除链表元素、翻转链表、两两交换链表中的结点、删除链表的倒数第n个结点、环形链表再加上一道设计链表这六道题基本串起了链表面试题的整条主线。不管你是刚准备刷题、正在面试冲刺还是工作中想补齐数据结构短板这一组题都值得好好吃透。我当年备考的时候这六道题翻来覆去做了很多遍踩过的坑比做对的版本还多。现在回头看链表题其实并不难难的是很多人没有形成一套稳定的解题套路每次都在头结点、空指针、节点丢失这些地方翻车。这篇文章我会把每一道题的核心思路、代码实现、背后的“为什么”都拆开讲清楚再把我实操中踩过的一些典型坑整理出来希望能帮你把这些题一次弄明白。1. 链表题的底层逻辑为什么这些题“看似简单但总翻车”1.1 链表操作的核心基本功拆解链表题说穿了就是两件事读节点和改指针。读节点很简单从头往后遍历就行改指针才是真正让人头疼的地方。改指针的本质是“先保存再修改”顺序错了整条链就断了。我打个比方你就明白了。链表像一串用铁环扣住的链条你要把中间某一环拆下来换掉得先用手抓住它后面的那一环否则一松手后面的链子整个掉地上找都找不回来。写代码也一样当你执行node1.next node2这种操作时node1原本指向的后续节点如果没有提前用一个变量保存它就被“丢掉”了——不是内存泄漏而是你在这次操作后找不到它了链表就被拦腰截断。所以链表题的第一基本功是每次改变 next 指针之前先把这条分支上会被覆盖的后继节点用一个临时变量暂存下来。你做个两三次题就会形成肌肉记忆但这层意识必须在一开始就建立起来。1.2 虚拟头结点解决“头结点特判”的通用招数链表操作里最烦人的部分不是中间节点而是头结点。因为头结点没有前驱节点当你需要删除头结点、在头部插入节点时你没法用“找到前驱再操作”这种统一套路只能单独写if分支去处理。虚拟头结点dummy node就是为了消灭这种特判而生的。它的做法很简单在真正链表之前挂一个不存储业务数据的节点让原来的头结点变成“有前驱”的普通节点。这样一来所有插入和删除操作都统一成“找到前驱节点然后改它的next”代码逻辑完全一致不需要考虑头结点的情况。我强烈建议你在做所有链表题时都先习惯性地加上虚拟头结点尤其是移除链表元素、删除倒数第n个结点这类需要删除节点的题目它真的能帮你省掉大量边界处理的心智负担。实际上我在面试时见过太多候选人因为忘记处理头结点删除而翻车虚拟头结点一上这种低级错误就自动消失了。1.3 快慢指针链表题里的第二把钥匙链表题还有一个高频套路就是快慢指针。所谓快慢指针就是让两个遍历速度不同的指针同时从链表出发一段时间后它们的落点会产生某种数学关系而这种关系恰好能解决我们想要的问题。删除链表的倒数第n个结点用的是“快指针先走n步然后快慢同步走”的技巧环形链表用的是“快指针走两步、慢指针走一步如果有环它们终将相遇”的原理。表面上看是两个不同的题本质上是同一个数学模型的两种应用速度差和时间差最后会转化成一个确定的距离差。快慢指针的关键不是记住公式而是理解为什么这个速度差能带我们到正确的位置。后面我在拆解具体题目的时候会把每一步推导写清楚。2. 六个经典题逐题拆解与代码实现2.1 移除链表元素从“特殊处理头结点”到“统一逻辑”这道题的要求是给定一个链表的头结点 head 和一个目标值 val删除链表中所有节点值等于 val 的节点。LeetCode上对应第203题。最容易想到的直观思路是遍历链表如果某个节点的值等于 val就把它的前驱节点指向它的后继节点。但这里有个问题头结点如果要被删除它没有前驱怎么办一种做法是写两个分支先循环处理头结点等于 val 的情况再处理中间节点的情况。代码可以工作但逻辑上很啰嗦。用虚拟头结点就可以完美解决。初始化dummy ListNode(nexthead)然后让cur dummy遍历时只检查cur.nextdef removeElements(head, val): dummy ListNode(nexthead) cur dummy while cur.next: if cur.next.val val: cur.next cur.next.next else: cur cur.next return dummy.next这段代码的巧妙之处在于从头到尾我们只看cur.next而不看cur本身删除和移动两个动作被清晰地区分开当 cur.next 的值等于 val 时删除这个节点但 cur 不移动因为删除后新的 cur.next 可能仍然等于 val当 cur.next 的值不等于 val 时cur 才向后移动。这个细节特别容易写错如果你在删除后仍然执行cur cur.next那么被删除节点的下一个节点就会跳过检查可能导致某些等于 val 的节点漏删。复杂度上时间复杂度 O(n)空间复杂度 O(1)没毛病。2.2 设计链表看似简单其实最考基本功LeetCode第707题设计链表要求你实现一个链表类支持 get、addAtHead、addAtTail、addAtIndex、deleteAtIndex 五个方法而且让你自己选用单链表还是双链表。这道题之所以重要是因为前面那些“单点操作”的题目你做一次就结束了但设计链表要求你把链表的所有基础操作完整地实现一遍任何边界没想清楚都会在某个方法里暴露出来。我的建议是先用单链表实现一遍因为单链表实现更能暴露出你对前驱节点和边界条件的理解是否到位。下面是我在 LeetCode 上提交过很多次、最终稳定通过的做法class ListNode: def __init__(self, val0, nextNone): self.val val self.next next class MyLinkedList: def __init__(self): self.dummy ListNode() self.size 0 def get(self, index): if index 0 or index self.size: return -1 cur self.dummy.next for _ in range(index): cur cur.next return cur.val def addAtHead(self, val): self.addAtIndex(0, val) def addAtTail(self, val): self.addAtIndex(self.size, val) def addAtIndex(self, index, val): if index 0 or index self.size: return pre self.dummy for _ in range(index): pre pre.next new_node ListNode(val) new_node.next pre.next pre.next new_node self.size 1 def deleteAtIndex(self, index): if index 0 or index self.size: return pre self.dummy for _ in range(index): pre pre.next pre.next pre.next.next self.size - 1三个最容易出错的地方我单独拎出来说一下第一get 和 deleteAtIndex 的 index 不能等于 size。因为索引从0开始一个长度为 size 的链表最后一个节点的索引是 size - 1。index 等于 size 时意味着“访问越界”必须返回 -1 或直接忽略。但 addAtIndex 就不一样index 等于 size 时表示插入到链表末尾这是完全合法的操作。这个区别很多人第一次写都会混淆。第二addAtIndex 里 pre 的初始化位置。pre 初始指向 dummy然后循环for _ in range(index)走 index 步这样当 index 为0时pre 恰好是 dummy当 index 等于 size 时pre 恰好是最后一个节点。这里如果你把 pre 初始化为 dummy.next整个逻辑就全乱了。第三size 的维护。每次插入成功必须 size 1删除必须 size - 1get 直接读 size 做校验。如果忘记维护 size所有边界判断都会出问题。这种 bug 很难查因为代码逻辑看起来都对但 size 不对导致永远走不到正确的分支。2.3 翻转链表双指针与递归两种都要会翻转链表对应LeetCode第206题要求把整个链表原地反转。这也是面试中出镜率极高的题面试官通常不会满足于只看到一种解法迭代、递归基本都会追问。迭代法的核心逻辑是三个指针pre、cur、next。pre 一开始是 Nonecur 从 head 出发每一轮循环做四件事先用 next 保存 cur 的后继节点然后让 cur.next 指向 pre接着让 pre 移动到 cur 的位置最后让 cur 移动到 next 的位置。用代码写就是这样def reverseList(head): pre, cur None, head while cur: nxt cur.next # 1. 保存后继 cur.next pre # 2. 反向指向 pre cur # 3. pre 前进 cur nxt # 4. cur 前进 return pre注意最终返回的是 pre 而不是 head因为翻转完成后pre 指向的才是原来的链尾、现在的链头。很多人第一次写会习惯性地想 return cur但实际上 cur 在循环结束时已经变成 None 了。递归写法稍微难懂一点但理解后非常优雅def reverseList_recursive(head): if not head or not head.next: return head new_head reverseList_recursive(head.next) head.next.next head head.next None return new_head递归的思路是先把 head 后面的整条子链表翻转翻转后返回的新链头就是整个链表的链头。假设 head 后面那条子链表已经翻转好了那么当前要做的事情就是让 head 成为这条新子链表的最后一个节点也就是让 head.next现在是子链表最后一个节点的 next 指向 head然后把 head.next 置为 None。我当时学递归绕了半天后来发现关键是要想通“递归函数返回的是新链表的头结点也就是原链表的尾结点”。于是回溯返回时每一层都只需要接上自己这个节点即可。你如果实在想不通可以先画一个三个节点的链表手动模拟一遍比盯着代码揣摩快得多。2.4 两两交换链表中的结点画图画图画图两两交换相邻节点对应LeetCode第24题。这道题是“设计链表”和“翻转链表”的混合体因为它要求你同时掌握“找前驱”和“改指针顺序”两个能力。我见过太多人写这道题时指针乱飞最后越改越乱根本原因就是没画图就上手写代码。常规解法是使用虚拟头结点加三个指针。一图胜千言交换一对节点需要操作四个位置虚拟头结点 dummy、第一个节点 node1、第二个节点 node2、以及 node2 后面的后续节点 nxt。整个交换链条的指针修改顺序应该是node2.next node1node1.next nxtcur.next node2然后把 cur 移动到 node1注意这里不是移动到 node2因为下一轮需要处理的起点是 node1后面的那个节点。完整代码如下def swapPairs(head): dummy ListNode(nexthead) cur dummy while cur.next and cur.next.next: node1 cur.next node2 node1.next nxt node2.next node2.next node1 node1.next nxt cur.next node2 cur node1 return dummy.next循环条件cur.next and cur.next.next保证了“还有至少两个节点可以交换”如果链表长度是奇数最后一个节点不参与交换自然放着就行。这道题我最想强调的就是动手画图。你不需要画得多好看只要在草稿纸上画出 dummy、A、B、C 四个节点然后用手模拟每一步指针的变动做完一遍之后代码自然就出来了。很多看似“手到擒来”的大佬其实在脑子里飞快地画了很多遍图只不过你只看到了他写代码的剪影而已。2.5 删除链表的倒数第n个结点快慢指针的经典应用LeetCode第19题要求删除链表的倒数第 n 个节点进阶要求是只遍历一遍。当然你可以先遍历一遍算长度再遍历一遍找位置但那样就是两遍遍历了。面试官真正想考的是快慢指针。思路特别简单先用一个快指针先往前走 n 步然后快慢指针同步前进。当快指针走到链尾时慢指针恰好停在倒数第 n 个节点的前一个节点这时直接修改 it 的 next 即可完成删除。实现时为了统一处理“删除头结点”的情况还是建议加上虚拟头结点def removeNthFromEnd(head, n): dummy ListNode(nexthead) fast slow dummy for _ in range(n 1): fast fast.next while fast: fast fast.next slow slow.next slow.next slow.next.next return dummy.next注意这里 fast 走的是n 1步这是因为我们要让慢指针最终停在“待删节点的前一个节点”。如果 fast 只走 n 步就同步前进那么慢指针最后会正好停在待删节点本身那就还得额外保留一个 prev 指针比较麻烦。先让 fast 多走一步就能让慢指针天然落在正确的前驱位置上。你别小看这一步的差别面试时能把这一步讲清楚的人说明真的理解了代码的逻辑而不是背下来的。如果你不习惯 fast 先走 n1 步这种写法也可以先走 n 步然后循环条件写成while fast.next:两种写法殊途同归看你哪种更顺手。2.6 环形链表从“判断有环”到“找到入环点”环形链表分两个梯度LeetCode第141题要求判断链表是否有环第142题进一步要求找出环入口。后者是前者的数学升级版非常经典值得认真推一遍。判断是否有环很容易快慢指针即可。快指针每次走两步慢指针每次走一步如果链表里有环两个指针必然在环内某个节点相遇否则快指针会先走到 Nonedef hasCycle(head): slow fast head while fast and fast.next: slow slow.next fast fast.next.next if slow fast: return True return False那为什么快指针每次只走两步不走三步或四步这里其实有个隐藏的数学原因快慢指针的相对速度是1步每单位时间也就是快指针每个循环比慢指针多走一步。因为慢指针每次前进一格快指针每次前进两格所以两者之间的距离每次减少1不会出现“跳过”对方的情况也就不会漏掉相遇时刻。如果快指针每次走三步距离每次减少2那么当两者距离为奇数时快指针就可能“跨过”慢指针导致错失检测。找到入环点的代码也不复杂关键在于理解相遇之后的数学推导。我再说细一点假设链表起点到环入口的距离是 a环入口到两个指针相遇点的距离是 b相遇点继续到环入口的距离是 c那么环的周长就是 b c。慢指针从起点到相遇点一共走了 a b快指针速度是慢的二倍所以走了 2(a b)。同时快指针在环里可能已经绕了若干圈所以它的路程也可以写成 a n(b c) b其中 n 是绕的圈数。两个式子联立2(a b) a n(b c) b化简后得到a (n - 1)(b c) c这个式子的含义是从链表起点走到入口的距离恰好等于从相遇点继续走到入口的距离可能加上 n-1 圈。所以只要在相遇后让一个指针从 head 出发另一个指针从相遇点出发每次都走一步它们必然会在环入口处相遇。代码实现如下def detectCycle(head): slow fast head while fast and fast.next: slow slow.next fast fast.next.next if slow fast: # 找到相遇点后从head和slow同时走 start head while start ! slow: start start.next slow slow.next return start return None这段代码面试时值得手写并且把推导过程讲出来因为面试官如果听到你能把 a (n-1)L c 这种结论自己推出来对你代码能力的评价会直接上一个台阶。3. 实操过程中最爱踩的坑我帮你踩完了3.1 空指针与死循环两个最经典的翻车现场链表题最常见的报错就是 NullPointerException 或者 segmentation fault说白了就是你试图访问一个 None 节点的属性。最常见的场景有两个一是循环条件写错。比如移除链表元素时习惯性写成while cur:然后在循环体里访问cur.next.val当头结点为空或者遍历到最后一个节点时cur.next 可能是 None访问.val就直接崩了。正确做法是循环条件直接约束cur.next不为空用while cur.next:这样循环体里访问的cur.next.val必然安全。二是删除节点时丢掉了后继引用。很多人写cur.next cur.next.next之前没有想过这会让原来那个 next 节点变成游离状态。有时候这个操作是合法的删除节点时但如果你只是想改变指针指向却忘记先用临时变量保存下一个节点后面的遍历就断了。这个坑在两两交换链表节点时尤其常见。死循环也出现过不少次。比较典型的是翻转链表时如果循环体内忘记让 cur 前移就会无限处理同一个节点程序跑不完。我当时在本地测试的时候经常因为死循环连 IDE 都被卡半晌。排查方法很简单在循环体里加一个计数器超过链表长度直接报错或者在纸上手动模拟前两步。3.2 边界条件的自查清单写链表题最怕的是“代码看起来对但边界用例挂了”。我整理了一份自查清单你在提交前对照过一遍能避免九成以上的边界错误检查项具体问题应对方法空链表head 为 None 时代码是否安全退出所有解法的第一步都用逻辑判断或虚拟头结点覆盖只有一个节点翻转、删除、交换等操作后是否丢节点手动模拟一个节点的场景头结点是否需要操作删除头结点、在头部插入是否特判或使用虚拟头结点统一使用 dummy 节点index 恰好等于 sizeget / delete 与 add 的合法范围不同分别确认 index size 和 index size偶数/奇数长度两两交换、快慢指针需要确认循环条件能正确终止画图推演长度分别为奇数和偶数的情况删除倒数第一个节点快指针先走 n1 步时fast 可能先走到 None用 while 循环而不是 for 循环从头硬走这个清单不是白列的随便哪道题你只要把其中一条漏掉提交结果大概率就是 Wrong Answer。我建议你把它们当成默认的检查习惯而不是等出错了才翻出来看。3.3 递归与迭代的取舍与执行时机设计链表、翻转链表这类题有人喜欢用递归写有人坚持用迭代。二者没有绝对的好坏但面试时最好都准备。我个人的经验是链表题如果在面试中没明确要求递归优先写迭代法因为迭代的空间复杂度是 O(1)递归则需要 O(n) 的调用栈空间面试官比较喜欢在空间复杂度上追问优化。但递归并不等于“洪水猛兽”它只是让你把问题分解得更干净。翻转链表、环形链表这类结构的问题用递归时一旦明白“返回的是新链头”这个核心代码反而非常简洁。还要特别提醒一点递归写错了很难调试因为调用栈一层套一层你很难定位是哪一层出了问题。我的调试习惯是遇到递归错误不要立刻进 IDE 断点调试而是先在纸上写清楚“这一层应该做什么、返回值应该是什么”然后沿着调用栈走一遍走通了再碰键盘效率比盲调高非常多。4. 面试与刷题场景下的实战建议4.1 面试官追问时你最需要讲清楚的三件事链表题敲完代码只能算完成了三分之一面试官真正看的是你能否把思路讲明白。我总结下来拿到链表题后你需要在面试官面前主动说清楚三件事为什么用这个结构、边界条件有哪些、复杂度是多少。举个真实场景。如果面试官让你删除单链表的倒数第 n 个节点你上来就写出快慢指针但只说“我用 fast 先走 n 步”这还远远不够。你得能说出来为什么用快慢指针而不是两遍遍历因为题目可能要求只遍历一遍。慢指针为什么能停在待删节点的前驱因为 fast 领先 n 的距离当 fast 到达链表尾部时slow 和 tail 之间的距离就是 n。边界条件是什么n 等于链表长度时删除的是头结点所以要用虚拟头结点。复杂度是多少时间 O(n)空间 O(1)。这几句话一讲面试官就知道你是真的懂了而不是背了一版代码。反过来如果你沉默地写完代码哪怕全对也容易让对方觉得你只是刷过这道题。4.2 如何养成“写出来基本没bug”的代码习惯链表题想要减少 bug最好的办法不是反复试错而是在动手写代码之前建立一个稳定的思维流程。我个人的固定流程是四步走第一步先抽象出数据结构。确认 head 是否可能为空每个节点的结构是什么样的是否需要虚拟头结点。第二步确认循环的起止条件。链表题的灵魂就是遍历的边界你需要在动手前想清楚循环进入时和退出时分别满足什么条件。第三步设计指针修改顺序。那种涉及三四个节点指针变化的情况先在草稿纸上写一遍顺序原则永远是被覆盖的引用先保存。第四步对照边界值做快速推演。head 为空、只有一个节点、删除头结点、要到空指针等场景逐个在心里跑一遍。习惯的养成其实不复杂但得逼着自己头几次不直接写代码、而是先在纸上走流程。适应之后你会发现链表题几乎不用调试写完直接一次通过的概率会大幅提升。4.3 刷题路径与练习策略如果你刚开始接触链表建议按照我开篇列出的顺序走一遍先做移除链表元素和翻转链表这两道题是纯基本功用来建立节点操作和指针修改的直觉然后做设计链表把所有基本操作完整练一遍接着做两两交换链表中的结点这是综合应用练习复杂指针修改再做删除链表的倒数第n个结点理解双指针最后攻克环形链表。这个顺序是刻意设计过的难度逐渐爬坡而且每一道题都会复用前面学过的技巧。比如两两交换链表节点用到了虚拟头结点和指针顺序删除倒数第n个节点用到了快慢指针环形链表又用到了快慢指针的进阶版本。学到最后你会觉得这些题其实是一个整体并不是六个孤立的知识点。如果你时间紧张至少要把翻转链表和环形链表这两道练到条件反射的程度。前者是链表最基础的操作后者是最能体现思路深度的题。面试时这两道题出现频率极高练熟它们能给你极大的信心。最后再分享一个小技巧我面试别人时经常发现候选人写链表题时如果一边写一边小声解释每一步在做什么通常给面试官的观感会非常好。这不单是沟通问题更重要的是说话的过程会强迫你理清自己的思路避免无意识的笔误。链表题考察的从来不只是“能否 AC”而是你有没有能力用严谨的逻辑控制复杂的指针变化——这种能力在真实工程里同样极其值钱。