LeetCode 热门100题里,143 重排链表是我刷链表板块时翻车最惨的一道。题面很短,就一句话:把 L0 → L1 → … → Ln-1 → Ln 重排成 L0 → Ln → L1 → Ln-1 → L2 → Ln-2 → …。看着像一个简单的指针游戏,结果我第一次提交直接超时,第二次死循环,第三次才磕磕绊绊通过。后来我把这道题拆开看,才发现它本质上是“找中点 + 反转链表 + 合并链表”三个基础操作的组合版,任何一个环节没吃透,都会在这里原形毕露。这篇文章我会把完整思路、每一步的代码写法、边界条件、常见翻车点全部拆开讲清楚,适合正在刷热门100题、链表题老是靠背模板但换个场景就懵的读者。
1. 题目复盘:143题到底在考什么
1.1 题意拆解与核心考点
先看题目本身。给定一个单链表,头节点是 L0,最后一个节点是 Ln,要求重新排列成“头、尾、第二个头、倒数第二个头……”这种交替顺序。题目给了两个明确约束:不能修改节点的 val,只能改 next 指针;空间复杂度最好控制在 O(1) 的额外空间,也就是原地重排。
举个直观的例子。输入是 1 → 2 → 3 → 4,输出应该是 1 → 4 → 2 → 3。输入是 1 → 2 → 3 → 4 → 5,输出应该是 1 → 5 → 2 → 4 → 3。可以看到前一半节点还是保持相对顺序,只是被后一半节点“插空”了;后一半节点则是完全倒过来插进去的。这就是“重排”这个动作的本质:前半段保持原序,后半段逆序,然后两条链交替合并。
所以 143 题的考点非常清晰,拆出来就是三件事:
- 找到链表的中点,把链表分成前后两半;
- 把后半段链表反转;
- 把前半段和反转后的后半段交替拼起来。
这个拆解不是事后诸葛,而是做题时就应该有的反应。链表题最怕的就是一上来就想怎么移动多个节点的指针,其实任何复杂的链表操作,大概率都能拆成若干个已经学过的子问题。143 就是这样一道把基础操作“缝合”起来的典型题。
1.2 为什么说它是“三个基础操作的缝合怪”
如果把 143 对应的三个子问题映射到 LeetCode 上的原题,你会发现全是老朋友:找链表中点是 876 题,反转链表是 206 题,交替合并有点像 21 题合并两个有序链表的变种,只不过 21 题要求有序,这里只要求一个正序一个逆序。
很多刷题攻略会把 143 放在“链表二星难度”的位置,但真正让新手崩溃的不是算法本身,而是三个步骤衔接处的细节。比如找中点之后到底该不该断链?断早了后半段取不到,断晚了合并时指针绕圈。再比如反转后半段时用迭代还是递归?迭代写法里 cur.next 被覆盖之前有没有先保存下一个节点?这些“衔接处”的坑,才是刷 LeetCode 题解时最容易遗漏的东西——网上大部分题解直接甩一段完整代码,不会告诉你为什么这里要先保存指针、为什么那里要提前断开。
我后来看 LeetCode 周赛 430 相关讨论时也发现,大家把这类题目戏称为“基础操作缝合怪”:题目不是要你会多高级的算法,而是要看你能不能在三四个基础操作的组合里保持头脑清醒。143 就是这个类别里最适合用来练手的一道,因为它每一步单独拿出来都很简单,合在一起就非常考验对链表指针流动的理解程度。
2. 完整解题链路:三步法的由来与推导
2.1 第一步:快慢指针找中点的原理与写法
找链表的中点,最常见的方案是快慢指针。快指针一次走两步,慢指针一次走一步,当快指针走到末尾时,慢指针正好停在中间。这个方法之所以成立,是因为快指针速度是慢指针的两倍,相同时间内快指针走的距离是慢指针的两倍,那么快指针到终点时,慢指针自然走到了链表一半的位置。
但在写代码之前,有一个关键问题要确定:快慢指针的循环条件应该怎么写?先看两种常见写法。
写法 A:
slow, fast = head, head while fast.next and fast.next.next: slow = slow.next fast = fast.next.next写法 B:
slow, fast = head, head while fast and fast.next: slow = slow.next fast = fast.next.next这两种写法的差异在于:当链表长度为偶数时,slow 最终停在“左中点”还是“右中点”。写法 A 中,快指针每次判断的是 fast.next 和 fast.next.next,所以对于偶数长度链表,slow 会停在左侧中间节点的位置;写法 B 中,slow 会停在右侧中间节点的位置。
对 143 题来说,我们需要的恰恰是“左侧中间节点”,也就是 slow 最后停在前半段的最后一个节点,这样 slow.next 才是后半段的头节点。如果用了写法 B,得到的 slow 是后半段的头节点,那接下来反转后半段时还要多做一步处理。所以这里推荐写法 A,它天然让“前半段尾部”和“后半段头部”的边界变得很明确。
对应代码:
if not head or not head.next or not head.next.next: return slow, fast = head, head while fast.next and fast.next.next: slow = slow.next fast = fast.next.next这个提前 return 处理了链表为空、只有一个节点、只有两个节点的极端情况。两个节点时,fast.next 存在,但 fast.next.next 不存在,循环不会执行,slow 停在 head,slow.next 就是第二个节点,整体逻辑依然成立;但先 return 可以让后面的代码少一些边界负担。
2.2 第二步:迭代法反转链表的写法与易错点
找到中点后,slow.next 就是后半段的头节点。我们需要把 res = slow.next 这一整段反转过来。反转链表是 206 题的看家本领,迭代法的核心是三指针:prev 指向已经反转好的链表头,cur 指向当前要处理的节点,nxt 保存 cur 原本的下一个节点。每次循环做三件事:
- 用 nxt 保存 cur.next;
- 把 cur.next 指向 prev;
- 把 prev 更新为 cur,cur 更新为 nxt。
写成代码:
second = slow.next slow.next = None # 断链 prev = None cur = second while cur: nxt = cur.next cur.next = prev prev = cur cur = nxt很多人在反转这一步翻车,原因不是不懂逻辑,而是没有理解“断链”的时机。上面代码里有一个非常关键的动作:slow.next = None,必须在反转之前执行。为什么?因为如果不把前后两段断开,反转后半段时虽然后半段内部的指针会重新指向,但 slow 仍然指向原来的 second 节点,而 second 经过反转后变成了后半段的尾节点,它的 next 最终会变成 None,但 slow 的 next 还残留着指向它的引用。等到第三步合并时,链表中就会出现两条路径同时指向同一个节点的情况,最常见的结果就是形成环,程序直接卡在死循环里。
还有一个细节值得提:为什么选择迭代反转而不是递归反转?因为递归反转链表虽然代码更简短,但递归调用会使用系统栈,空间复杂度是 O(n),这和题目的 O(1) 额外空间要求相悖。面试时如果用了递归,面试官大概率会追问如何改成迭代写法,与其被动被问,不如一开始就用迭代。
2.3 第三步:双链表交叉合并的指针操作细节
后半段反转完成后,我们拿到了两个链表:l1 是前半段,头节点就是原链表的 head;l2 是反转后的后半段,头节点是 prev。这一步要做的,是把 l2 的节点逐个插入到 l1 的节点之间。
还是以 1 → 2 → 3 → 4 → 5 为例来走一遍。前半段是 1 → 2 → 3,后半段反转后是 5 → 4。合并的期望结果是 1 → 5 → 2 → 4 → 3,也就是:
- 1 的 next 指向 5;
- 5 的 next 指向原来的 1.next,也就是 2;
- 2 的 next 指向 4;
- 4 的 next 指向原来的 2.next,也就是 3;
- 3 的 next 指向 None。
注意这里每一步都涉及“保存原来的 next”。如果不保存,比如直接把 l1.next 指向 l2,那么原来 l1 后面的链表就丢了;如果不保存 l2.next,直接把 l2.next 指向原来的 l1.next,那 l2 后面的节点也丢了。所以合并代码的每个循环里必须先记录两个 next:
l1, l2 = head, prev while l2: nxt1, nxt2 = l1.next, l2.next l1.next = l2 l2.next = nxt1 l1, l2 = nxt1, nxt2循环条件是while l2,这个条件怎么理解?反转后的后半段长度最多和前半段相等(奇数长度时后半段比前半段少一个节点),所以合并过程中,l1 一定不会比 l2 更早耗尽。当 l2 变成 None,说明所有后半段节点都已经插入完毕,剩下的 l1 节点本来就在链表尾部,不需要额外处理,整个链表已经重排完成了。
到这一步,三步法的核心逻辑就完整了。整体代码如下:
class Solution: def reorderList(self, head: ListNode) -> None: if not head or not head.next or not head.next.next: return # 1. 快慢指针找中点 slow, fast = head, head while fast.next and fast.next.next: slow = slow.next fast = fast.next.next # 2. 反转后半段并断开前半段 second = slow.next slow.next = None prev = None cur = second while cur: nxt = cur.next cur.next = prev prev = cur cur = nxt # 3. 交替合并 l1, l2 = head, prev while l2: nxt1, nxt2 = l1.next, l2.next l1.next = l2 l2.next = nxt1 l1, l2 = nxt1, nxt2这段代码的时间复杂度是 O(n),因为每个节点最多被访问常数次;空间复杂度是 O(1),只用到了几个临时指针变量。
3. 边界条件与核心细节:调试两天才发现的坑
3.1 奇偶长度下的中点处理
链表长度奇偶不同,找中点后得到的两个链表长度也不同,这个差异直接影响合并循环的结束条件和最终形态。
先看偶数长度,以 1 → 2 → 3 → 4 为例。快慢指针走完之后,slow 停在 2,前半段是 1 → 2,后半段是 3 → 4,反转后变成 4 → 3。合并时 l2 长度和 l1 一样,所以while l2循环会完整地跑完所有插入步骤,最后由 l2 的最后一个节点指向 l1 的剩余部分或者 None,结果是 1 → 4 → 2 → 3,正确。
再看奇数长度,以 1 → 2 → 3 → 4 → 5 为例。slow 停在 3,前半段是 1 → 2 → 3,后半段是 4 → 5,反转后变成 5 → 4。l1 有 3 个节点,l2 有 2 个节点。合并时 l2 先耗尽,循环结束,此时 l1 剩余的最后一个节点 3 自动成为链表的末尾,结果是 1 → 5 → 2 → 4 → 3,也正确。
问题的关键点在于:奇数长度时 slow 正好是正中间节点,这个节点本身不需要参与和后半段的交替插入,它永远处于链表的最后一位;偶数长度时 slow 是左中节点,前半段的最后一个节点最终会指向合并后的链表的倒数第二个节点。理解这一点后,你就明白为什么循环条件判断的是 l2 而不是 l1——因为后半段不可能比前半段更长,用 l2 作为循环是否结束的标尺是最安全的。
3.2 断链时机与指针绕圈
前面已经提到了断链的重要性,这里再展开说一个常见错误:有些人把断链放在了反转之后。比如先反转后半段,再执行slow.next = None。看起来只是顺序换了一下,但实际结果完全不同。反转后半段时,slow.next 还指向原来的 second 节点,反转过程中 second 变成了新链表的尾节点,它的 next 已经指向了 None。此时再执行slow.next = None虽然也能把前后两段断开,但考虑到 slow 和 second 之间的引用经历了复杂的变化,一旦反转部分代码写得不严谨,比如没有正确更新最后一个节点的 next,就可能残留一条从 slow 到 second 的引用,合并时链表就会绕圈。
另一个常见的绕圈场景发生在合并阶段。如果你写成了这样:
while l2: l1.next = l2 l2.next = l1.next # 此时 l1.next 已经被改成 l2 了 l1 = l1.next.next l2 = l2.next问题很明显:第二行执行后,l1.next 已经是 l2,第三行再把 l2.next 指向 l1.next,就等于让 l2 指向了它自己,直接形成一个自环。这个错误在有经验的开发者看来很蠢,但在现场调试时非常容易被忽略,因为逻辑看着很像“把两个链表交叉连接”,实际上却把指针的读取顺序搞反了。正确的做法永远是:先保存,再修改。
3.3 空间复杂度与原地操作的取舍
142 题……不对,说回 143。有读者可能会问:既然找中点、反转、合并这么麻烦,能不能用数组先把所有节点存下来,然后用双指针重排?可以,而且代码非常短。
class Solution: def reorderList(self, head: ListNode) -> None: if not head: return nodes = [] cur = head while cur: nodes.append(cur) cur = cur.next i, j = 0, len(nodes) - 1 while i < j: nodes[i].next = nodes[j] i += 1 if i == j: break nodes[j].next = nodes[i] j -= 1 nodes[i].next = None数组法的时间复杂度同样是 O(n),但额外空间是 O(n)。LeetCode 的判题器不会因此拒绝你,因为题目只要求“原地修改链表”,并没有强制空间复杂度。但面试场景完全不同,面试官大概率会追问一句:“能不能把空间优化到 O(1)?”如果你答不出来,就说明你对链表指针操作的理解还停留在依赖额外存储的水平。
所以我个人的建议是:先用数组法理解重排的最终形态,再用三步法实现原地版本。这两种方案不矛盾,它们在思路上是递进关系——数组法帮助你明确“谁该接谁”,原地法帮助你练习“怎么在不能随机访问的情况下完成同样的操作”。
4. 从143延伸:同类型题与面试变体
4.1 同类题对比与进阶路线
143 不是孤立的一道题,它和链表板块的很多基础题都有千丝万缕的联系。我把相关题目放在一起做了一张对比表,能更清楚地看到每道题在技能点上的位置:
| 题号 | 题目 | 核心考点 | 与143的关系 |
|---|---|---|---|
| 876 | 链表的中间结点 | 快慢指针 | 143的第一步直接复用 |
| 206 | 反转链表 | 迭代反转 / 递归反转 | 143的第二步直接复用 |
| 21 | 合并两个有序链表 | 双指针合并 | 143的第三步是它的变体 |
| 234 | 回文链表 | 快慢指针 + 反转链表 | 用到的技巧和143几乎一样 |
| 143 | 重排链表 | 找中点 + 反转 + 合并 | 综合题 |
建议的刷题顺序是:先刷 876,确认自己能熟练写出快慢指针的两种循环条件;再刷 206,把迭代反转练到闭眼能写;然后刷 21,理解双链表合并时指针保存的节奏;接着刷 234,因为回文链表也需要“找中点 + 反转后半段”,但少了一步合并,难度比 143 低一些;最后再来啃 143。这样由分解到综合,每一步的挫败感都会小很多。
另外,热榜上还有一个讨论度很高的 073 爱吃香蕉的狒狒,也就是 875 题 Koko Eating Bananas。它属于二分答案题,和链表是完全不同的技能树。但如果你在准备面试,这类二分法基础题也要保持手感,不然会在“基础算法四大件”上偏科。我的建议是链表和二分这种题型交叉着刷,别连续一个星期只看同一类。
4.2 面试中的常见变体与应对
面试官如果要考 143,通常不会直接甩原题,因为原题已经被收录在热门100题里,候选人大概率刷过。他们更喜欢在 143 的基础上做变形,常见的变形方式有这么几类。
第一种变体是“只做前半段的重排”。比如把 1 → 2 → 3 → 4 → 5 → 6 改成 1 → 6 → 2 → 5 → 3 → 4,也就是前半段和后半段交替,但后半段不反转。这种题目其实比 143 简单,只需要把后半段整体移动到前半段的间隙中,不需要反转,但思路可以复用“找中点 + 双链表穿插”的框架。
第二种变体是“限制不能用递归,也不允许修改节点值”。这个限制其实和原题一致,真正要考察的是你能不能熟练写出迭代反转,以及能不能解释清楚为什么递归版本的空间复杂度不合格。应对方式很简单:提前把迭代反转的“三指针模型”写在纸上,讲给面试官听,边讲边写,基本不会出问题。
第三种变体是“要求返回一个新的链表,不能改动原链表”。这种情况下原地法就不适用了,你需要一边遍历原链表一边创建新节点,同时维持交替顺序。数组法在这种情况下反而更好用,因为你可以先收集节点地址,再构建新链表。这也解释了为什么我建议两种方法都要掌握——面试官可以通过变换条件,轻松让只会一种解法的人露出短板。
还有一种比较进阶的变体,是“判断链表是否为回文结构,并且要求重排后仍然保持某种性质”。这已经是 234 和 143 的复合题了,考察的是组合能力。遇到这种题不要慌,还是按那个老套路来:找中点 → 反转后半段 → 根据题目要求决定是“比较”还是“合并”。只要基础动作足够熟练,组合题本质上是多个步骤的串联。
5. 常见报错与排查技巧实录
5.1 三道高频报错与现场修复
我在本地调试 143 时,反复踩过几个典型错误,这里直接记录现场版本,方便你对照排查。
报错一:空指针异常,发生在快慢指针循环里。很多人会写while fast.next.next:,然后被NoneType对象没有属性next这种错误砸脸。原因很简单:当链表只有 1 个节点或 fast 已经走到最后一个节点时,fast.next 是 None,再访问.next就崩了。正确写法是同时判断 fast.next 和 fast.next.next:
while fast.next and fast.next.next:报错二:提交后不报编译错误,但显示 Time Limit Exceeded,多半是形成了环。最常见的原因是没有在反转前执行slow.next = None。想象一下,如果前后两段没有断开,合并时某个节点可能有两条路径通向同一个后继,遍历链表时就永远走不到 None。排查方法是在代码里临时加一个计数器,比如遍历到第 100 个节点就强制退出,然后打印当前节点值,你会很快看到节点值开始重复。
报错三:结果顺序错乱,比如输出是 1 → 5 → 4 → 3 → 2,而不是期望的 1 → 5 → 2 → 4 → 3。这种情况通常是合并时保存 next 的顺序出了问题。如果你先保存了 nxt1 却没有保存 nxt2,或者保存后更新 l1、l2 时用错了变量,就会打乱后半段的原始顺序。修复方式是严格按照“先保存两个 next,再修改两个 next 指针,最后移动两个遍历指针”的顺序来写,不要自作聪明交换步骤。
5.2 调试链表题的通用技巧
链表是少数“画图比看代码更有效”的题型。遇到 143 这种多步骤题,我的调试流程是这样的:先用纸笔画一个 5 个节点的链表,把每一步执行后的指针状态画出来,尤其要标清楚哪些节点的 next 被覆盖了、哪些引用还指向旧位置;然后打开本地 Python 环境,写一个打印函数,每次找完中点、反转完、合并后都打印一遍链表,确认形态是否符合预期。
一个实用的打印函数:
def print_list(head): res = [] cur = head while cur: res.append(str(cur.val)) cur = cur.next print(" -> ".join(res))测试用例集至少应该覆盖这几种情况:空链表、单个节点、两个节点、三个节点、四个节点、五个节点、含重复值的链表、较长的链表。四种节点数的链表分别对应了奇偶边界和循环边界的测试,重复值链表用来检验题目“不改 val 只改指针”的约束是否被遵守。
LeetCode 的 Playground 可以直接手写测试用例,但说实话调试链表题时,本地跑反而更舒服,因为你可以随意打印中间状态。我在本地用的就是最简单的 Python 文件加 print 输出,不涉及任何复杂工具。链表题不怕代码写得慢,就怕不画图直接改,改到最后都不知道自己在改哪条边。
个人经验与补充技巧
最后分享一个我自己的习惯。每次做完 143 这种综合题,我都会尝试把代码“重写一遍但不用变量名 l1、l2、prev、cur”,而是换成更语义化的名字,比如first、second、prevNode、currNode。这个动作看起来毫无意义,但能逼着你在脑中重新走一遍指针流动的过程,而不是机械地复制粘贴记忆中的代码。我靠这个方法,把链表题的稳定性提上了一个台阶。
如果 143 你也能一遍通过,那恭喜你,链表三大基础操作算是真正过关了。后续可以试试 25 题 K 个一组翻转链表、61 题旋转链表,它们都是在基础操作之上叠加复杂度的题目,思路相通,但在细节上又会给你新的惊喜。