
1. 为什么链表题是算法刷题里绕不开的必刷项说实话我在准备算法面试和带人刷题时见过太多人对链表题又爱又恨。爱的是它套路固定、代码不长恨的是它指针一多就容易绕晕写着写着就不知道当前这个结点的 next 到底该指向谁了。但不管怎么吐槽链表题在这些年的面试中地位非常稳——像移除链表元素翻转链表环形链表这几道题几乎就是算法面试里的标配。很多人会问链表题考来考去不就是那点指针操作吗为什么面试官这么喜欢出我个人的理解是链表题的价值不在会不会背代码而在于它同时考查了三层能力第一层是数据结构本身的空间结构理解你要清楚每个结点在内存里是散落的靠指针串联第二层是边界条件的敏感度空链表、单结点、头结点被删、尾结点被删每一种情况稍有疏忽就是空指针异常或者死循环第三层是抽象思维和推演能力尤其是涉及双指针、环检测时你需要能在地里画出指针的运动轨迹。这篇文章把我刷移除链表元素、设计链表、翻转链表、两两交换链表中的结点、删除链表的倒数第 n 个结点、环形链表这六道题时的完整思路、踩过的坑和最终沉淀下来的套路全部整理出来。这六道题不是随便凑的——它们恰好覆盖了链表题型的几个核心能力维度基础删除操作、链表结构的完整设计、指针的连续重连、双指针的距离控制、以及快慢指针的数学原理。把这六个题吃透链表类题目对你来说就不再是玄学而是有章可循的工程活。适合看这篇内容的人我觉得有三类一是刚开始刷题、对链表操作还不太熟悉的同学二是刷过题但总在边界条件上翻车的同学三是准备面试前想系统过一遍链表高频考点的同学。如果你已经能熟练手写这几道题那也可以看看我的分析角度尤其是那些为什么这么写的推导过程说不定能帮你建立更完整的知识框架。2. 把基本功打牢移除链表元素与设计链表里的指针操作细节2.1 虚拟头结点一个解决头结点可能被删问题的通用技巧先看移除链表元素这道题。题目要求是删除链表中所有值等于给定值的结点。很多新手第一版代码是这样的先判断头结点是否需要被删除然后进入循环处理后面的结点。逻辑上没错但代码里会充斥着对 head 的特判稍不留神就漏掉连续删除的情况比如[1,1,1,1]这种连续重复的用例删完一个又一个指来指去很容易把自己绕进去。我在实际写这道题时强烈建议直接引入虚拟头结点dummy。它是一个哨兵结点不存储实际数据dummy.next指向真正的头结点。为什么要这么干因为链表中真正需要删除的是某个结点的后继而不是某个结点本身。单链表结点是没有办法直接删除自己的你只能把前驱的 next 指针重新指向后继。如果删除的是头结点因为头结点没有前驱操作就变得非常别扭。有了 dummy头结点就变成了普通结点所有删除操作统一变成cur.next cur.next.next整个代码的处理逻辑一下子就干净了。class Solution: def removeElements(self, head, val): dummy ListNode(0) dummy.next head 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.next指向下下个结点但cur 本身不要移动。为什么要这样因为下下个结点很可能也是需要删除的。比如链表[1,2,2,2,3]要删掉所有值为 2 的结点走到第一个 2 时如果 cur 继续往后走后面的两个 2 就不知道被谁删了。这是个很典型的细节我自己见过太多人在这里写错原因就是下意识地让 cur 往后挪了一步。提示常规刷题实现里虚拟头结点用完直接丢弃即可不影响链表本身的引用关系。这也是 LeetCode 这类平台上最稳妥的写法。2.2 设计链表实现一个完整链表时最容易漏掉的操作约束设计链表这道题覆盖面很广要求实现get、addAtHead、addAtTail、addAtIndex、deleteAtIndex这五个方法。它不是一个单纯的技巧题更像是一个工程题考的是你对链表操作的完整掌控力。我建议实现这道题时加上头尾两个哨兵结点也就是 head 和 tail 都是哑结点。这样有几个好处头插和尾插不需要特判空链表遍历时也不需要担心 head 或 tail 为 null。不过更重要的不是代码本身而是addAtIndex和deleteAtIndex的边界判断。addAtIndex(index, val)的规则是这样的index 等于链表长度时插到末尾index 大于链表长度时什么都不做index 小于等于 0 时插到头部。很多人在实现时会少判index 等于链表长度这种情况导致尾插失效。如果你把 tail 也设为哨兵这几种情况的处理会清晰很多。还有个值得注意的操作是deleteAtIndex删除的是下标为 index 的结点。这里最容易踩的坑是——你找到的应该是 index 结点的前驱而不是它本身。还是那句话单链表没法自己删自己。你需要从哨兵结点出发向后走 index 步此时你站在第 index 个结点前面然后执行cur.next cur.next.next。我自己在写这道题时习惯用一个统一的 helper先不管具体操作把所有 index 规范到一个合理的循环次数里。比如要删除第 index 个结点循环 index 次之后cur 就是待删结点的前驱。这样做能避免到底是 index还是 index这种循环次数问题的反复纠结。简单列个对照操作循环步数终止位置的意义get(index)index 步cur 指向目标结点addAtIndex(index, val)index 步cur 指向目标位置的前驱插入新结点在 cur 后面deleteAtIndex(index)index 步cur 指向目标结点的前驱这三行对照表是我做这道题最大的收获它把三种操作统一成了同一个模型先找到位置再分情况处理。流程统一了边界条件自然就清晰了。2.3 链表操作中的两个千万不能做刷完这两道基础题我总结出两个新手最容易犯的低级错误写在这里提醒大家。第一个是在遍历过程中丢失 next 引用。单链表的结点没有回头路一旦你把cur.next改了原来的后继就找不回来了。所以在任何先改 next 再往后走的场景里都要确认你是否还需要旧的后继引用。需要就先用变量存下来。第二个是忘记处理空指针。比如 get 一个不存在的 index或者对空链表执行 delete前置判断没做好运行起来就是AttributeError: NoneType object has no attribute next。这类错误在本地调试时比较好发现但真正面试时紧张加上手速快最容易漏的就是这种判断。我的建议是每个方法入口先想三件事——链表是否为空、index 是否在合法范围内、涉及的操作是站在本结点还是前驱。想完这三件事再动手写代码基本就不会翻车。3. 指针重组的核心套路翻转链表与两两交换3.1 迭代翻转prev、curr、next 三指针模型的由来翻转链表可以说是链表题里出镜率最高的一道也是很多人的入门题。题目很直接把1→2→3→4→5变成5→4→3→2→1。这道题的核心问题是翻转的过程中你至少需要同时掌握三个结点的信息。想象一下当你想让当前结点 cur 指向前一个结点 prev 时cur 原本的后继 next 就会被丢掉因为链表里没有别的路径能访问到它。所以顺序必须是先用next cur.next把后继保存下来再执行cur.next prev然后把 prev 和 cur 都往前挪一位。class Solution: def reverseList(self, head): prev None # 前驱初始为 None cur head # 当前结点 while cur: next_node cur.next # 先保存后继 cur.next prev # 反转指针 prev cur # prev 前移 cur next_node # cur 前移 return prev # 新头结点这里有个细节值得停下来想一想为什么 prev 的初始值是 None而不是 dummy因为翻转后原头结点变成了新链表的尾结点尾结点的 next 必须是 None。所以第一个被反转的结点它的 next 直接指向 None 就可以了prev 从 None 开始天然完成这个任务。还有一个常见的疑问循环结束之后为什么返回 prev因为当 cur 走到 None 时prev 指着的恰好是原链表的最后一个结点也就是新链表的头结点。你返回 head 是错的head 已经变成了尾结点它的 next 是 None。3.2 两两交换画图定位指针的断链与重连两两交换链表中的结点比翻转链表高一个难度等级。题目要求把链表按照相邻两个结点为一组交换位置比如1→2→3→4变成2→1→4→3。如果结点数是奇数最后一个结点不用交换。我当年第一次做这道题时最大的感受就是不画图真的会迷路。四个指针的重复操作谁指向谁什么先后顺序代码里搞错一步整个链表就断成一串碎片。我建议的做法是在纸上画出这样一条链dummy → 1 → 2 → 3 → 4然后定义三个指针prev 指向 dummycur 指向 1next 指向 2。要完成交换需要做以下几步prev.next nextdummy 先指向 2也就是新的一组头结点cur.next next.next1 指向 3为下一步做准备next.next cur2 指向 1完成两个结点之间的反转prev curprev 移动到 1因为下一组交换中1 是 dummy 角色cur cur.nextcur 移动到 3开始下一轮这里最核心的一步是步骤 2。为什么要先让 1 指向 3因为如果不先把这个指针接好直接把 2 指向 1那么原来 1 和 3 之间的联系就断了后面的链表会丢失。交换的本质是在原有连接的基础上做局部重连而不是凭空创造连接所以每一步改动都必须先保证其他路径的安全。如果换成递归写法思路会清爽一些递归函数接收一个 head它只需要完成交换前两个结点这个任务后面的结点交给递归去处理。返回值是新的一组头结点class Solution: def swapPairs(self, head): if not head or not head.next: return head first head second head.next # 交换前两个后面的链表递归处理 first.next self.swapPairs(second.next) second.next first return second递归写法的好处是不需要手动维护多组指针的中间状态每次递归只需要处理两个结点。缺点是如果链表很长递归深度会比较大面试时如果面试官要求空间复杂度 O(1)还是得回到迭代版本。我建议两种写法都要会迭代练的是指针操作的严谨性递归练的是问题分解的抽象能力。3.3 一个值得尝试的进阶问题翻转链表的递归理解翻转链表这道题除了迭代写法递归写法也能帮我们深入理解链表的结构。递归版本的核心思路是反转head之后的链表假设返回结果是newHead然后让head.next.next head、head.next None。class Solution: def reverseList(self, head): if not head or not head.next: return head new_head self.reverseList(head.next) # 翻转后续链表 head.next.next head # 让 head 的后继指向 head 自己 head.next None # head 变成新尾结点 return new_head这段代码里最反直觉的是head.next.next head这一步。我第一次看这段代码时内心是崩溃的——什么head.next 不是已经被翻转了吗怎么还让它的 next 指向 head后来我自己画了张递归展开图才想明白递归的返回值是翻转后的新头但 head 的前驱和后继关系在递归返回时依然保留着原始串联关系。在递归最深处的栈帧返回后每一层拿到的 head 依然可以通过 head.next 找到翻转后的子链表中的当前尾结点正是这个结点需要把 next 指回 head。这个例子告诉我们理解递归版本的链表操作关键不在于逐行追踪指针而在于信任递归的假设假设子问题已经解决你只负责当前这一步。这也是一种很重要的工程思维——模块化解决问题时你不需要知道底层每一步怎么执行你只需要保证当前模块的正确性。4. 双指针的两大应用场景删除倒数第 n 个结点与环形链表4.1 删除倒数第 n 个结点为什么需要两个指针保持固定距离删除链表的倒数第 n 个结点这道题最直观的做法是两趟遍历第一趟算长度第二趟走到目标前驱完成删除。但这道题真正的考点是能不能用一趟遍历解决因为很多面试官会明确要求只遍历一次。一趟遍历的标准解法就是双指针。让 left 和 right 都从 dummy 出发right 先走 n 步然后 left 和 right 同步前进。当 right 走到链表末尾即 right 为 None时left 恰好站在倒数第 n 个结点的前驱位置。为什么这个方法是正确的关键在于两个指针之间保持一个长度为 n 的固定距离。当 right 到达链尾时left 距离链尾也是 n 步也就是说 left 的下一个结点就是倒数第 n 个结点。这里的倒数被巧妙地翻译成了正数距离差。class Solution: def removeNthFromEnd(self, head, n): dummy ListNode(0) dummy.next head left dummy right dummy # right 先走 n 步 for _ in range(n): right right.next # 两个指针同步走 while right.next: left left.next right right.next # left 是待删结点的前驱 left.next left.next.next return dummy.next注意这里循环条件是while right.next而不是while right。这是我自己踩过的一个坑如果条件是while right那么当 right 走到 None 时left 指向的是倒数第 n 个结点本身而不是它的前驱那就没法删了。让 right 停在前一个位置left 就自然停在待删结点的前一个位置。这道题我见过的最常见错误是忘记用 dummy然后当 n 恰好等于链表长度时也就是删除头结点代码崩了或者返回了错误的 head。还是那句老话涉及可能删除头结点的操作先放一个 dummy 永远是最省心的选择。4.2 环形链表检测快慢指针为什么一定能相遇环形链表这个系列有两道经典题第一道只判断有没有环第二道要找环的入口结点。先看第一道。判断有没有环最经典的解法是快慢指针slow 每次走一步fast 每次走两步。如果链表中存在环则两个指针最终一定在环内相遇如果不存在环则 fast 会先走到 None。很多人都知道这个结论但未必理解为什么 fast 每次走两步就一定相遇为什么不是三步、四步这里我用一个简单的追击模型来解释。假设链表在进入环之前有一段长度为 L 的直链环的长度为 C。slow 进入环时fast 已经在环里走了 L 步因为 fast 速度是 slow 的两倍。此时 fast 与 slow 在环内的距离差为(L mod C)或者从 fast 的角度看它距离追上 slow 还需要走C - (L mod C)步。由于 slow 每次走 1 步fast 每次走 2 步相对速度是 1 步/单位时间所以 fast 追上 slow 需要C - (L mod C)个单位时间这个值一定是有限整数。换句话说只要链表有环且两者都在环内运动相对速度是 1那么追击就一定会在有限步内完成。如果 fast 每次走三步呢相对速度是 2追击条件同样满足。但问题在于步长过大的时候fast 可能会跳过 slow 所在的位置。在环形链表里如果快慢指针的相对速度大于 1快指针可能刚好在某个时刻迈过慢指针由在后面变成在前面导致两者永远没有重叠的瞬间。步长为 2 时相对速度恰好是 1本质上是一个接一个位置地追不会跳过。这就是为什么惯例上 fast 每次走两步。注意在无环链表中fast 会先到达链尾。由于 fast 每次走两步循环条件需要同时检查fast和fast.next是否为空否则调用fast.next.next时会抛空指针。4.3 找环的入口从相遇点到入口的距离是怎么推导出来的第二道题环形链表 II要求返回链表开始入环的第一个结点。如果没有环则返回 null。基于上一节的结论假设 slow 和 fast 在环内相遇。设链表起点到环入口的距离为 L环入口到相遇点的距离为 S。当两者相遇时slow 走过的总路程为L Sfast 走过的总路程为L S n*Cn 是 fast 在环内额外绕的圈数。由于 fast 的路程是 slow 的两倍2 * (L S) L S n*C L S n*C L n*C - S这个式子的含义非常漂亮从相遇点到环入口的距离C - S恰好等于链表起点到环入口的距离 L当 n 1 时更一般地L 等于若干个完整的环长减去 S。于是我们有了解法当 slow 和 fast 在环内相遇后让一个指针从链表起点出发另一个指针从相遇点出发两者都每次走一步它们一定会在环入口处相遇。class Solution: def detectCycle(self, head): slow head fast head # 先找到相遇点 while fast and fast.next: slow slow.next fast fast.next.next if slow fast: # 有环找入口 start head while start ! slow: start start.next slow slow.next return start return None这段代码里第二个 while 的循环条件值得注意它没有检查start slow之外的条件因为数学上已经证明两者一定会在入口处相遇你不需要手动设置循环上限。这是整个算法最精妙的地方——数学推导保证了你不需要防御性编程。不过在实际面试中如果对推导过程记忆模糊我建议还是加一个计数器限制循环次数比如最多循环 2 倍的结点数防止自己推导错误导致死循环。这种安全阀是一个非常好的工程习惯虽然在常规实现中可加可不加但面试时可以提一句反而显得你有防御意识。我自己第一次做这道题时其实花了不少时间推导这个公式看了好几篇题解都是一笔带过相遇点到入口的距离等于起点到入口的距离但没说为什么尤其是 n 不等于 1 的情况。后来用具体的链表举例验证才真正相信这个结论。我的建议是大家自己动手画一个环长为 5、直链长为 3 的例子手动走一遍快慢指针的路径亲眼看到相遇点和入口的关系比背十遍公式都管用。5. 链表题的边界条件清单与调试技巧5.1 六个维度的自检清单刷完这六道题我最大的收获不是会了六种解法而是建立起了链表题的通用边界条件检查意识。你可以像我一样每次写完链表相关代码后按这个清单逐项自查空链表head 为 None 时函数能不能正常返回而不是抛异常单结点链表只有一个结点时所有指针操作是否符合预期删除头结点的场景使用 dummy 后返回值是否仍然正确指向新头结点指针移动的终点循环条件是cur还是cur.next写之前明确每个指针的停止位置链表的长度变化做插入删除操作时链表长度变化是否影响后续操作的 index 判断是否有环涉及快慢指针的题目先想清楚无环情况下循环能否正常退出这个清单看上去很简单但真正把它们刻进意识里需要大量练习。我见过不少经验丰富的开发者解题思路完全正确就因为在返回head时没意识到 head 已经被删除了结果整个函数返回了错误结果。这种错误非常隐蔽本地测试用例如果恰好没覆盖删除头结点这个场景很难发现。5.2 可视化调试法先画图再写代码链表题最忌讳的就是上来就写代码。以我的经验任何链表题第一步永远是画图。把链表画成盒子指针画成箭头然后手动推演几个关键步骤。比如两两交换这道题我每次都会在纸上画一个四结点的链表然后逐步用箭头表示每一步指针变化。推演完之后代码其实已经是水到渠成的事了。画图的过程中你会自然发现很多手工推演时会踩但是写代码时想不到的问题比如某一步如果先执行next.next cur那么cur原来的后继就丢了。这种问题在纸上推演时会一目了然但在代码里往往要调试很久才能发现。另一个调试技巧是打印链表函数。在本地调试时我会写一个简单的printList(head)函数每次操作完都打印一次链表结构。对于最常见的错误——链表中间断了出现循环指向打印结果都会直接暴露问题。之前我自己写翻转链表时由于循环条件的细微错误链表变成了一小段循环如果不打印光靠肉眼很难发现。def print_list(head): seen set() while head: if head in seen: print(Cycle detected!) return seen.add(head) print(head.val, end - ) head head.next print(None)这个函数里我特意加了一个seen集合来检测循环这在调试链表问题时非常实用。因为链表题的 bug 有时候不是输出错误而是死循环没有检测机制的话程序会一直卡在那里你还要靠日志去猜测是不是有环。5.3 关于这六道题我刷完之后最大的体会如果非要我总结这六道题给人留下的通用思维模型我想是三个。第一个模型是虚拟头结点。它解决的本质问题是把对特殊位置的操作转换成对普通位置的操作。这个思想不仅仅适用于链表删除也适用于数组、字符串等很多场景。遇到边界位置需要特殊处理的情况往边界外面放一个哨兵往往能让代码简洁很多。第二个模型是指针操作前先保存后继。无论是翻转、交换还是删除这个原则贯穿始终。它的本质是在修改指针之前先确保所有你需要的引用都已经被保存下来。这就像你在纸上改一段文字之前先拍照存档——不管你怎么改都有一个回退路径。第三个模型是双指针的距离控制。删除倒数第 n 个结点是固定距离同步移动环形链表是不同速度异步移动。两者的共性在于通过两个指针的位置关系把未知问题转化为可量化的数学关系。理解了这个思想后续遇到排序链表找中间结点判断回文链表等题目时你会发现它们都是同一个套路的不同变形。最后说点实际的。我在带人刷题时经常遇到一种情况看题解什么都懂关上答案自己写就废。如果你也有这种感觉多半是因为看题解时跳过了自问自答的环节。每一个关键步骤你都应该问自己一句为什么要这么做不这么做会怎样。上面我对每个题目的分析都尽量写了这层推导但真正要内化成自己的东西还是得在白纸上从零写一遍然后故意改掉某个关键条件观察错误输出再回头解释为什么错了。这个过程做完一道题才算真正吃透。我建议你也试试这个方式。