十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

回文链表详解:从双指针到反转链表的O(1)空间解法

回文链表详解:从双指针到反转链表的O(1)空间解法 回文链表这道题,我在面试候选人的时候几乎必考,自己也前前后后写过不下十遍。它看起来简单,却能同时考察链表遍历、双指针、空间复杂度意识、以及递归思想,是一个性价比极高的题目。网上讲回文链表的文章很多,但大部分都停留在贴代码的层面,很少有人把“为什么这么做”“换了什么条件会挂”讲透。这篇文章我按自己的理解,把这道题从读题到最优解再到边界陷阱完整拆一遍,希望能帮你把这道题吃透。1. 把题目先读懂回文链表到底在考什么1.1 一句话说清楚“回文”回文这个词,最早来源于文学作品里的回文诗,正着读反着读都一样。放到数据结构里,意思完全一样一个序列,从前往后遍历和从后往前遍历,得到的元素顺序完全一致。比如字符串abba是回文,abcba也是回文,但abcda不是。链表里的回文判断,就是把同样的规则套用在单向链表上。比如1 - 2 - 3 - 2 - 1这是一个回文链表,因为从前往后是1,2,3,2,1,从后往前也是1,2,3,2,1。1 - 2 - 3 - 3 - 1这不是回文,因为反过来的序列是1,3,3,2,1,对不上。你可能会觉得这有什么好讲的,把链表转成数组,双指针一夹不就完了问题就出在这里。链表和数组最大的不同,是它不支持随机访问。数组你只要知道下标,arr[0]和arr[4]可以同时拿到,但链表你要想访问最后一个节点,只能从头节点开始一个一个next下去,时间复杂度是O(n)。如果你在判断过程中反复做这种操作,整体复杂度会变成O(n²),这在数据量大的时候根本没法看。1.2 为什么链表场景会让回文变难我见过不少刚学算法的同学,一上来就写这样的代码def isPalindrome(head): values [] while head: values.append(head.val) head head.next return values values[::-1]这段代码对不对对。能不能用能。但它有两个问题。第一个问题,它无视了空间复杂度。你申请了一个和链表等长的数组,空间复杂度O(n)。在很多面试场景下,面试官会追问一句“能不能用O(1)的空间解决”如果你答不上来,这道题就从“会做”变成了“只会一种做法”。第二个问题,它没有触及这道题真正的考点。回文链表这道题,最优雅的解法,是用快慢指针找到链表中点,然后把后半段反转,再逐节点比对。整个过程只用了常数个指针变量,空间复杂度O(1),时间复杂度O(n)。这才是面试官想看到的思路。换句话说,这道题表面考的是“回文判断”,实际上考的是三个底层技能的叠加快慢指针找中点、链表原地反转、双指针逐节点比对。任何一个环节不扎实,整体就会卡住。1.3 面试官真正想看到的东西站在面试官的角度,我抛出回文链表这道题,心里其实有三个评估维度第一个维度,是你能不能给出一个能跑的暴力解。这决定了你的基本功在不在线。很多候选人连边界条件都处理不好,空链表、单节点链表这种case一测就挂,这属于基本功不牢。第二个维度,是你能不能主动优化空间复杂度。如果我在你给出数组解法之后追问“能不能不用额外空间”,你能否意识到需要“反转后半段链表”这个操作这一步考察的是你对链表结构特性的理解——链表虽然不支持随机访问,但它支持O(1)的原地修改。第三个维度,是你的代码细节是否经得起推敲。比如快慢指针的初始化方式、while循环的终止条件、反转链表时指针的交接顺序、偶数长度和奇数长度链表的差异。这些细节每个都能单独出一道考察题,而回文链表把它们全串在了一起。所以这道题,不同水平的候选人写出来的代码,从代码风格到变量命名,从边界处理到注释习惯,差距一目了然。这也是我为什么依然坚持在面试中用这道题的原因。2. 三种主流解法从暴力到最优的思路演进2.1 方案一数组缓存加双指针最简单的起点先说暴力解。它的思路极其直白链表不支持随机访问,那我就把它拷贝到一个支持随机访问的结构里,然后再用双指针从两端往中间夹。def isPalindrome(head): values [] cur head while cur: values.append(cur.val) cur cur.next left, right 0, len(values) - 1 while left right: if values[left] ! values[right]: return False left 1 right - 1 return True这个解法的时间复杂度是O(n),空间复杂度也是O(n)。它最大的意义,是让你快速得到一个正确答案,用来验证思路、跑通测试。在真正的面试中,如果你的时间只够写一版代码,写这个至少能拿到基础分。但它也有一个隐藏的坑如果链表节点存储的不是整数,而是复杂的对象,那么拷贝数组时的内存开销会更大。而且你后续想优化成O(1)空间,就必须推翻重写,而不是在原有代码上小改。2.2 方案二递归对比思路最优雅但工程上最不实用还有一种解法,用递归实现链表的“倒序访问”。它的核心思想是递归函数一路走到底,然后在回溯的过程中,和从头节点开始的指针逐一比对。def isPalindrome(head): front head def check(cur): nonlocal front if cur is None: return True if not check(cur.next): return False if front.val ! cur.val: return False front front.next return True return check(head)这段代码非常优雅,它利用了函数调用栈天然的后进先出特性,让cur指针在回溯时从链表尾部开始移动,而front指针从头部开始移动,两者一前一后完成比对。但它的致命伤在于空间复杂度。每一层递归都会占用一个栈帧,递归深度等于链表长度,所以空间复杂度依然是O(n)。更麻烦的是,当链表长度达到几万甚至几十万时,递归会直接导致栈溢出。在真实的工程环境和面试场景中,这种解法只适合作为思路拓展,不适合作为最终方案。2.3 方案三快慢指针加原地反转最优解终于到正题了。最优解的核心思路分三步第一步,用快慢指针找到链表中点。慢指针每次走一步,快指针每次走两步。当快指针到达链表末尾时,慢指针正好在中点位置。这个技巧在很多链表题目中都会用到,比如判断链表是否有环、寻找链表中间节点。第二步,把中点之后的后半段链表原地反转。这一步需要你熟练掌握三指针反转法或头插法,确保不丢节点、不产生环。第三步,用两个指针分别指向原链表头和中点之后的“新链表头”,逐节点比对,直到后半段走完。如果全部相等,就是回文链表。这个解法的时间复杂度是O(n),空间复杂度是O(1),是这道题的标准答案。我用它解决了无数次问题,也是我认为最值得反复练习的解法。3. 手写实现快慢指针加反转的完整代码走一遍3.1 核心实现下面我直接给出完整可运行的Python代码,然后逐行解释。这里我假设链表节点的定义是class ListNode: def __init__(self, val0, nextNone): self.val val self.next next完整代码如下def isPalindrome(head): if not head or not head.next: return True # step 1: 快慢指针找中点 slow, fast head, head while fast and fast.next: slow slow.next fast fast.next.next # step 2: 反转后半段 prev None cur slow while cur: nxt cur.next cur.next prev prev cur cur nxt # step 3: 逐一比对 left, right head, prev while right: if left.val ! right.val: return False left left.next right right.next return True这段代码,我用它过了LeetCode上所有的测试用例,也帮不少朋友改过作业。下面我拆开讲每个步骤为什么这么写。3.2 断链与恢复几个关键细节先看第一步找中点。slow, fast head, head while fast and fast.next: slow slow.next fast fast.next.next很多初学者会问,为什么这里fast的循环条件是fast and fast.next,而不是fast.next原因很简单如果链表长度是偶数,最后一次快指针会走到None,这时候你再访问fast.next就会报错。所以必须同时判断fast本身和fast.next都不为空。另外一个容易忽略的点是,这样的循环结束后,slow到底指向哪里我直接用例子推演链表1 - 2 - 3 - 2 - 1初始时,slow 1,fast 1。 第一次循环,slow走到2,fast走到3。 第二次循环,slow走到3,fast走到5最后一个1。 此时fast.next为None,循环退出。slow正好指向中间节点3。再看偶数长度的情况链表1 - 2 - 2 - 1初始时,slow 1,fast 1。 第一次循环,slow走到第一个2,fast走到第二个2。 第二次循环,slow走到第二个2,fast走到None。循环退出。这时候slow指向的是后半段的第一个节点,也就是第二个2。你会发现,对于偶数长度的链表,slow其实指向的是“下半段的起点”,这正好是我们需要反转的部分的头部。接着看第二步反转后半段。prev None cur slow while cur: nxt cur.next cur.next prev prev cur cur nxt这个反转逻辑,本质上就是经典的单链表反转。你可以把它理解成“拆链再接链”每次把当前节点的next指向前一个节点,然后把三个指针整体后移。我用一个口语化的类比,这就好比有一串珠子,你要把它们一个个摘下来,再反着串回去。如果你在大脑里模拟一遍这个过程,就会发现它不会丢节点,也不会产生环。第三步比对,这里有一个细节需要格外注意。left, right head, prev while right: if left.val ! right.val: return False left left.next right right.next为什么while条件是right而不是left因为整个反转后的链表是以prev为头节点的后半段,它的长度要么等于前半段偶数长度,要么比前半段少一个节点奇数长度,中间节点不用参与比对。所以我们只需遍历right,就能保证比对完整。这里也顺带解释一下,为什么奇数长度时,中间节点不用参与判断。因为回文的中间节点无论是什么值,都不会影响两端的对称性。比如1 - 2 - 3 - 2 - 1,中间节点是3,两端是1和1、2和2,3自己跟自己对称,天然成立。4. 边界情况与常见坑4.1 边界清单必须测试的几种输入我在面试和写题时,总结了一个回文链表的边界测试清单,分享给你。每一条都值得你亲手跑一遍场景示例期望结果空链表NoneTrue单节点链表1True两个相同节点1 - 1True两个不同节点1 - 2False奇数长度回文1 - 2 - 3 - 2 - 1True偶数长度回文1 - 2 - 2 - 1True奇数长度非回文1 - 2 - 3 - 3 - 1False首尾不同但中间相同1 - 2 - 3 - 2 - 4False我自己经常看到有人只测了回文的例子,没测非回文的例子,结果一提交就挂。其实非回文的情况更容易暴露逻辑错误,尤其是“后半段没有完全走完就提前返回”这类问题。4.2 我复盘时发现的具体错误样例分享几个我实际遇到过的错误写法,这些坑真的很容易踩。错误一快慢指针初始化时绕晕了。# 错误写法 slow, fast head, head.next while fast and fast.next: slow slow.next fast fast.next.next这种写法在链表长度为2时会直接出问题。比如1 - 1,初始时fast指向第二个节点,循环条件fast and fast.next中,fast.next是None,循环直接跳过,slow还停留在第一个节点。后续反转和比对就全乱套了。错误二反转后半段时,最后一步之后忘记更新指针。# 错误写法 prev None cur slow while cur: nxt cur.next cur.next prev prev cur # 漏了 cur nxt这个错误很隐蔽,因为代码不会报错,但会陷入死循环。你调试的时候会发现程序卡住不退出,其实就是cur永远停在原节点,循环永远跑不完。错误三比对时遍历条件写错。# 错误写法 while left: if left.val ! right.val: return False left left.next right right.next如果是奇数长度链表,right会先走到None,而left还剩下中间节点。在left不为空的情况下,访问right.val就会报空指针异常。所以记得,遍历条件一定是while right,不是while left。5. 进阶思考与个人经验5.1 变体和扩展这些衍生题也值得练回文链表这道题,并不是孤立存在的。它和一些常见题型之间,有很强的关联性,我建议你连着一起刷第一个是“反转链表”。这是回文链表的核心子步骤,如果你反转链表本身不熟练,回文链表就不用谈了。可以用LeetCode的“反转链表”来练手。第二个是“寻找链表中间节点”。快慢指针找中点的技巧,同样可以单独出题。熟练掌握之后,你在回文链表里找中点就会非常自然。第三个是“判断链表是否有环”。同样是快慢指针,同样是环形链表和回文链表里的经典题,两个题一起学,你就能举一反三。第四个是“回文数组”或者“回文字符串”。如果你不做链表,只做数组版本的回文判断,你会发现思路几乎一样,但因为数组支持随机访问,两端的双指针可以直接操作,不需要反转这一步。这种对比能帮助你理解“链表为什么需要反转”这个问题。第五个是“分离链表”的题目,比如“分隔链表”或“奇偶链表”。这些题目同样要求你精准操作指针,并且对链表边界有清晰理解。刷完这些,回文链表的“断链”“拼接”操作就会熟练很多。5.2 实际工程中的回文判断你可能会想,链表回文判断这种题目,真实项目中真的会用到吗说实话,直接用到的场景不多。但它的底层思维,在很多地方有体现。举个例子,在文本编辑器的“撤销栈”设计里,为了判断当前编辑操作是否构成某种对称模式,可能会用到类似的“从两端向中间比较”的思路。再比如,在分布式系统的一致性检查里,两个节点上的数据快照,如果按同样的规则序列化,可以用双指针从头部和尾部同时校验,减少不必要的全量比对。这种“两头夹逼”的思维,训练多了,你在设计系统时会比旁人更敏锐。另外,快慢指针的思想,在查找链表中间节点、检测循环依赖、甚至在一些流式数据处理场景中都有应用。你学的时候不要只盯着题目本身,多想想这些底层技巧可以用在哪,这才算真正学透。5.3 我给读者的六个实操建议最后,说几点我从反复练习和面试中总结的经验,希望能帮你少走弯路。第一,别背代码,背思路。你最终要记住的是“找中点、反转后半段、双指针比对”这三个步骤,而不是某一种语言的细节实现。思路记住了,换语言只是语法翻译的问题。第二,画图。链表题最怕的就是空想。我在白板上讲题的时候,一定会画出节点和指针的每一步变化。你自己刷题时,打开画图工具,把slow、fast、prev、cur的位置都标出来,比你盯着代码看十遍都有用。第三,先写暴力解,再优化。你可以在纸上先写数组缓存版本,确认思路正确,再往O(1)空间的方向优化。不要一上来就追求最优解,那样容易卡在细节里,连暴力解都写不出来。第四,测边界。提交之前,把自己当作测试工程师,专门想一些奇怪的输入空链表、单节点、两个节点、一长一短、全是相同元素。这些边界用例能帮你挡掉80%的隐藏bug。第五,手写代码。我强烈建议你找一张白纸,或者用记事本,不要用IDE的自动补全,手写一遍完整代码。手写和敲键盘的体感完全不同,它逼着你去想每一个指针变化的细节。第六,用Python的话,注意列表反转的语法糖。很多人会用values[::-1],它确实简洁,但你要清楚这会产生一个全新的列表,额外占用O(n)的空间。面试中如果说“空间O(1)”,你还用这种写法,那就是直接露馅了。6. 一道题延伸出的思考方式回文链表这个题目,我第一次接触时也走了不少弯路。当时我还在刷题入门阶段,脑子里只有数组版回文判断的思路,一看到链表就懵了。后来反复练了几遍,才真正明白“链表不支持随机访问”这句话,到底意味着什么——它逼着你去思考如何通过修改指针方向来模拟“从后往前遍历”。这种“看透数据结构本质”的能力,才是回文链表真正想教给你的东西。我现在在面试候选人的时候,如果他能在提示下,自己想出“反转后半段”这个方案,我会对他的链表功底很认可。如果不需要提示就能写出完整代码,而且边界处理得很干净,我基本上会给这道题的满分评价。我个人的建议是,不要觉得自己会写一个解法就够了。你可以把这道题当作一面镜子,照一照自己对链表操作的理解程度。如果中间任何一个环节卡住了,就去把对应的基础题补上,比如单独练反转链表、单独练快慢指针找中点。基础补牢了,回头再做回文链表,你会发现自己一下子就通了。
返回列表