多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

回文链表判定:快慢指针与反转链表组合的O(1)空间最优解

回文链表判定:快慢指针与反转链表组合的O(1)空间最优解 刷力扣 Hot 100 刷到“回文链表”这道题时我第一反应是数组判断回文太简单了左右指针一夹就完事换成链表怎么连倒数第几个节点都没法直接访问这道题的核心考点其实不是“回文”这个概念本身而是单链表只能从 head 往后走的天然限制。题目描述很简短给定一个单链表的头节点判断它是否为回文链表也就是从头往后读和从尾往前读得到的节点值序列完全一致。它适合准备算法面试、正在啃链表题型、想彻底弄懂快慢指针和反转链表组合用法的人仔细看。这篇就按我从暴力解法到最优解法的完整推导过程写一遍顺带把面试官常追问的边界条件、复杂度问题都梳理清楚。1. 题目到底在考什么链表的“不能回头”才是关键1.1 回文定义与链表结构的天然限制回文的定义不需要多解释[1,2,2,1] 就是回文[1,2,3,2,1] 也是回文[1,2,3] 不是。如果放在数组里判断逻辑三行就能写完把数组首尾两个下标向中间移动只要出现不相等就返回 false。数组能这样做的原因是它支持随机访问我可以用arr[i]和arr[n-1-i]直接取到对称位置的值。链表不是这样。单链表每个节点只保存自己的值和指向下一个节点的指针我没有办法从一个节点直接跳回前一个节点更没有办法通过下标计算拿到“倒数第几个”。也就是说单链表天然是单向不可逆的。判断回文需要同时从两端向中间比较而链表的尾端恰恰是访问成本最高的位置这就是整道题的矛盾点。想通这一点后面所有解法都围绕同一个问题展开如何让链表具备“从后往前访问”的能力第一种办法是把链表值拷贝到支持随机访问的数组里第二种办法是利用递归调用栈让函数一层层压进去再弹出来间接实现反向遍历第三种办法是找到中点反转后半段让后半段从尾到头排列。第三种办法最后能做到时间 O(n)、额外空间 O(1)也是面试里最常要求给出的版本。1.2 暴力法并不丢人但要知道它的代价很多人一看到链表题就默认要写最优解结果在面试时卡在快慢指针细节上连最简单的暴力法都不敢先开口。我个人建议先把最直接的思路讲出来哪怕空间复杂度高至少证明你能把问题抽象清楚。暴力法的本质就是“把链表变成数组再用数组的双指针套路”。代价很清楚遍历链表需要 O(n) 时间把 n 个节点的值存进数组需要 O(n) 额外空间。很多题目对空间没有硬性要求所以暴力法在笔试场景下能拿到不错的分数。但面试官大概率会追问一句“能不能优化到 O(1) 空间”这时如果你能顺势讲出快慢指针加反转的方案反而会比直接甩最优解更显思路完整。用排队打比方数组像一支可以随便报号的队伍裁判说“第一个人和最后一个人出列比较”瞬间就能完成链表像一群人手拉手站成一排每个人都只知道后面那个人是谁裁判想找最后一个人只能从队头一路走到队尾。理解这个比喻就理解了回文链表为什么不是单纯考“回文判断”而是在考“数据结构限制下的路径设计”。2. 三种常规解法的思路演进以及为什么数组法最不该被鄙视2.1 数组副本法最容易写但别忽略两个细节先把链表完整遍历一遍把所有节点值按顺序放进 Python 的 list然后检查这个 list 是否和它的反转相同。这是所有解法里最容易写对的一种也是我建议初学者最先掌握的版本。class Solution: def isPalindrome(self, head: Optional[ListNode]) - bool: vals [] cur head while cur: vals.append(cur.val) cur cur.next return vals vals[::-1]这里有两个细节容易忽略。第一个是空链表和单节点的边界按照力扣默认约定空链表和只有一个节点的链表都算回文因为不存在不对称的位置所以vals[::-1]和vals相等上面代码不用特判也能返回 True。第二个细节是存“值”而不是存“节点对象”因为最终比较的是节点值存节点对象反而会引入不必要的内存引用也让逻辑变得不直观。这个写法的时间复杂度是 O(n)空间也是 O(n)。它最大的价值是作为“参照实现”后续所有的优化方案最终正确性都以它为基准去对拍验证。我在实际做题时遇到过一次奇怪的现象自己写的快慢指针解法在某个测试用例上报错最后就是把数组法跑一遍对比每一步结果才定位到中点定位写错了。所以数组法不是只能丢进垃圾桶的低级解它是调试工具。2.2 递归法利用系统栈实现“先走到尾再从尾回头”递归解法的灵感来源于一个事实函数递归调用的过程天然会先把当前层压栈等最深层返回后再逐层弹出。我可以在递归进入下一层节点之前不做事等递归回溯到当前层时再拿当前节点和“前方节点”比较。这样树都不用画就拿到了一个等价于从尾到头的访问顺序。class Solution: def isPalindrome(self, head: Optional[ListNode]) - bool: self.front head def dfs(back): if back is None: return True if not dfs(back.next): return False if self.front.val ! back.val: return False self.front self.front.next return True return dfs(head)这段代码里front指针始终从链表头开始向后移动back则是递归深入到的当前节点。递归一层层压到链表尾然后往回弹出时back依次是尾节点、倒数第二个节点……正好和从头开始的front形成一一对应。这个解法的时间是 O(n)但因为递归深度就是链表长度系统栈空间也是 O(n)。Python 默认递归深度通常在一千层左右链表稍长就会直接RecursionError。所以面试时写递归可以证明你思路灵活但真要处理超大链表它并不实用。实际工程里如果非要用递归思想也会改成显式栈去模拟避免爆栈。2.3 三种解法的取舍没有“绝对最优”只有场景适配把三种常见思路放在一起对比能更清楚地看出它们的差异。解法时间复杂度额外空间是否修改链表适合场景数组副本法O(n)O(n)否笔试快速拿分、验证其他思路递归法O(n)O(n)否短链表、展示递归思维找中点 反转后半O(n)O(1)是需恢复面试最优解、大链表数组副本法实现最简单但不满足“O(1) 空间”的进阶要求递归法思维上很优雅却受限于递归深度找中点加反转后半段虽然代码最长但空间占用最优而且它把“快慢指针”和“链表反转”两个高频考点串在了一起。后面这部分我会拆开细讲因为边界条件带来的坑比想象中多。3. 面试官最想看到的版本快慢指针配合分段反转空间O(1)到底怎么做到3.1 快慢指针的边界推导中间节点到底是哪一个既然要反转后半段第一步就得找到链表的中间位置。数组找中间用下标一算就行链表只能用快慢指针快指针每次走两步慢指针每次走一步当快指针走到尾时慢指针大约走到中间。但“大约”在代码里必须变成精确。我见过很多初学者照抄while fast and fast.next慢指针最终指向的是中间偏右的节点。这个写法也能做只是奇数长度时反转后的右半段会包含中间节点后面比较时要格外小心。更稳妥的做法是让慢指针停在左半段的最后一个节点也就是把中点定位成“偏左中点的前一个位置”slow, fast head, head while fast.next and fast.next.next: slow slow.next fast fast.next.next为什么循环条件是fast.next and fast.next.next而不是fast and fast.next因为我要保证快指针还能往后走两步。fast.next存在说明可以走第一步fast.next.next存在说明还能走第二步。慢指针这时才往后移一格。等循环结束slow停在左半段末尾。以 [1,2,3,2,1] 为例循环结束后slow正好指向中间节点 3而 [1,2,2,1] 循环结束后slow指向第一个 2。前者的右半段从 3 的下一个 2 开始后者的右半段从第二个 2 开始。找到左半段末尾之后立刻把链表从中间切断right_head slow.next slow.next None这里一定要先保存slow.next再置空顺序错了右半段就丢了。切断的目的是让反转操作只作用在右半段不破坏左半段原有的顺序后面恢复原链表时也只需要把右半段接回去。3.2 后半段反转的迭代写法三指针逐节点改向链表反转是另一道高频题在这里它是回文链表的核心组件。反转后半段的经典写法是维护三个指针prev表示已经反转好的当前节点cur表示正在处理的节点nxt先保存下一步要访问的节点避免改完cur.next之后断链。prev None cur right_head while cur: nxt cur.next cur.next prev prev cur cur nxt reversed_right prev用生活类比原本站成一列的人每个人都面朝前、手搭在前面人的肩膀上。现在我从队尾开始挨个让每个人转身同时把他搭手的对象改成原来身后的那个人。nxt就相当于我转身之前先记住“你刚才手搭着谁”防止一转身连方向都找不到了。反转结束后prev是右半段新链表的头也就是原链表尾节点。此时整个链表在逻辑上变成两段左半段仍然保持原方向右半段方向反转。我要做的工作就是让这两个方向相反的半段对齐比较。3.3 比较、恢复原链表容易被忽略的扣分点比较时左指针left从原链表头开始右指针right从reversed_right开始循环条件我习惯于用while right。因为左半段在奇数长度下比右半段多一个节点如果写while left and right循环结束时还是右半段先到头多写一个 left 条件并不会带来额外收益反而会增加一个空指针判断的噪音。left, right head, reversed_right result True while right: if left.val ! right.val: result False break left left.next right right.next如果一开始就用了“慢指针停在左半段末尾”的写法右半段不包含中间节点因此回文链表 [1,2,3,2,1] 比较的是 1 对 1、2 对 23 这个中间节点不需要参与比较完全符合回文定义。比较完成后我强烈建议把右半段再反转回来重新接回slow.next。力扣判题只需要返回布尔值即使不恢复单次执行也能 AC但面试官一旦问“你会不会修改输入数据”你就能理直气壮地说“我恢复了”。很多链表题在真实工程里会被函数外部继续使用临时改结构而不复原是隐患。恢复过程还是同一套三指针反转把reversed_right反转为原来的right_head然后执行slow.next prevprev None cur reversed_right while cur: nxt cur.next cur.next prev prev cur cur nxt slow.next prev到这里原链表顺序完全还原。完整的函数代码如下包含判断、比较、恢复三个部分class Solution: def isPalindrome(self, head: Optional[ListNode]) - bool: if head is None or head.next is None: return True # 1. 找左半段末尾节点 slow, fast head, head while fast.next and fast.next.next: slow slow.next fast fast.next.next # 2. 切断左右半段 right_head slow.next slow.next None # 3. 反转右半段 prev None cur right_head while cur: nxt cur.next cur.next prev prev cur cur nxt reversed_right prev # 4. 比较 left, right head, reversed_right result True while right: if left.val ! right.val: result False break left left.next right right.next # 5. 恢复原链表 prev None cur reversed_right while cur: nxt cur.next cur.next prev prev cur cur nxt slow.next prev return result细心看会发现恢复操作无论比较是否中途 break 都会执行因为在循环外重新遍历的是reversed_right整条链不会因为 right 指针停在中间而丢节点。这是一个“副作用可控”的完整解法复杂度和代码量都在面试可接受范围内。4. 从“会做题”到“能讲题”边界Case、复杂度追问和变体题4.1 边界 Case 设计别只盯回文例子很多题目出错不是主逻辑写错而是边界用例没想清楚。回文链表我自测时至少会跑下面这组数据输入链表期望结果说明NoneTrue空链表按约定算回文[1]True单节点没有不对称位置[1,1]True偶数长度最小回文[1,2]False偶数长度最小非回文[1,2,3,2,1]True奇数长度回文[1,2,3,3,2,1]True偶数长度回文[1,2,3,2,2]False不对称第一反应容易漏掉空链表和单节点只要代码里先写if not head or not head.next: return True这两种情况就一并解决了。需要注意的是“空链表算回文”是很多平台的默认约定面试时如果题目描述没说最好口头问一句既显得严谨也避免理解偏差。奇数长度和偶数长度必须分别测。因为快慢指针找中点时奇数长度会出现一个不需要比较的中间节点判断逻辑必须容忍左右半段长度差 1偶数长度则是两个半段严格相等。如果只用一个例子测试很可能你代码里藏着长度差相关 bug 却没发现。4.2 复杂度追问O(1) 空间到底指什么最优解法的时间复杂度是 O(n)理由是快慢指针扫描、反转、比较、恢复这四个阶段每阶段都是线性扫描常数倍数的 O(n) 依然是 O(n)。额外空间是 O(1)因为只用了prev、cur、nxt、slow、fast等有限个指针变量不随输入规模增长。面试官可能追问既然递归法也要 O(n) 空间为什么不直接递归这个问题就要回到系统栈本质递归空间来自函数调用栈不是显式申请的内存。它能解决反向访问但无法做到 O(1)。另外如果题目说“不得修改链表结构”最优解必须带上恢复步骤如果题目说“只需返回结果允许临时修改”那么恢复步骤可以去掉代码写完还能更短。看清题目要求再决定要不要写恢复这是实战中容易被忽略的战术选择。4.3 变体题从回文链表延伸到回文家族回文类题目在力扣里有好几道亲属关系。判断回文串可以用首尾双指针因为字符串支持随机访问判断回文数字通常用取余和整除把数字拆成两半比较前一半和后一半的反转回文链表则要用快慢指针加反转。三者的共同点是“利用对称性”只是数据结构不同解法载体也不同。如果把链表换成双向链表问题难度立刻下降每个节点既有next又有prev首尾双指针向中间移动即可不再需要反转。这个小变体能帮你看清“链表单向性才是回文链表的难点”这个结论。还有一个常见变体是节点存的不再是整数而是字符串或数组判断时不能偷懒地比较节点对象必须比较值是否相等。语言层面如果重写了equals逻辑会简化但面试时最好明确说明你比较的是值而不是引用。5. 我的刷题复盘三个容易翻车的细节和表达顺序建议5.1 翻车点一快慢指针的不同写法让中点位置飘忽不定我第一次自己写时直接用了while fast and fast.next慢指针停在中间偏右的位置。写反转和比较时为了处理奇数长度多出的中间节点我临时加了一堆左右指针长度判断代码越写越长最后还是跑挂了一个用例。后来改成while fast.next and fast.next.next让慢指针停在左半段末尾并用断链把左右半段清楚切开整个逻辑立刻清爽了。这不是说while fast and fast.next不能写而是说你要知道慢指针最终停在哪、右半段从哪里开始、中间节点到底算哪边。能和自己的写法自洽才是真正的会做。我在复盘时建议每个人至少把两种中点写法各推导一遍用 [1,2,3,2,1] 和 [1,2,2,1] 分别走一遍大脑里有过这个过程之后面试时就不会慌。5.2 翻车点二只反转不恢复代码短了但副作用留下了在线判题平台只需要返回值很多优秀题解也默认不恢复链表所以我在很长一段时间里都没把恢复当回事。直到有一次我在一个本地工程里复用这段判断逻辑判断完想继续用原链表遍历结果发现链表后半段方向反了遍历顺序彻底乱了才意识到“不修改输入结构”在真实代码里的分量。从那以后我给出的完整解法一定会带上恢复步骤。恢复代码只需把右半段再反转一次并接回slow.next成本很低却能让你在面试中被问到“是否修改链表”时不心虚。如果你担心恢复过程影响返回结果仔细看恢复发生在比较之后完全不影响result这个设计是安全且干净的。5.3 翻车点三比较时的循环条件用错导致中间节点被比较两次还有一个容易踩的坑和循环条件有关。如果采用“右半段包含中间节点”的写法比较时while right会让最后一步变成中间节点和自己比结果看起来是回文实际没问题但如果这时left和right指向同一个节点你可能会误以为算法出错然后乱加条件反而写出 bug。采用我上面这套“慢指针停在左半段末尾 断链”的写法右半段不包含中间节点循环while right就非常自然右半段从头到尾比较完左半段如果多一个节点也不用管因为那是奇数长度下的中间节点不需要对称比较。写代码前先把两种写法的中间节点归属搞清楚循环条件就不会犹豫。5.4 从这道题延伸出去整组链表题的通关思路回文链表的核心组件拆开看一个是“找中点”的快慢指针一个是“反转链表”的三指针外加一个“断开后恢复”的细心操作。这三个能力在力扣 Hot 100 链表题里反复出现。比如环形链表 II 用快慢指针判断环并找环入口相交链表用双指针消除长度差删除链表的倒数第 N 个节点用快慢指针拉开距离。都说链表题套路化套路其实就是这些基础操作的排列组合。做完回文链表之后我最建议顺势去刷“重排链表”和“排序链表”。重排链表同样要拆两半、反转后半段只是最后不是比较而是交替拼接排序链表里的找中点、断链、合并也能把这个解法里的技巧迁移过去。你会发现一道回文链表练熟收获的不只是一个孤立答案而是一组链表核心操作的肌肉记忆。最后有一点个人体会我刷这道题最大的收获不是背下这段代码而是理解了“单向结构无法回头就通过反转制造回头路径”的设计思路。面试官问回文链表表面上考你对回文字符串的敏感度实际考的是你面对结构限制时能不能找到一条等价转换路径。把数组解法作为起点把快慢指针和反转作为优化跳板每一步都解释清楚为什么能这么做、为什么空间能优化这比直接写出一行不解释的最优解要值钱得多。
返回列表