今天来聊一道链表题,力扣每日一题里的常客:两两交换链表中的节点,对应原题编号第 24 题。题目本身只有一句话“给定一个链表,两两交换其中相邻的节点,并返回交换后的链表”,看起来人畜无害,但真动手写的时候就会发现,它既考指针操作,又考代码的组织能力。Python 的迭代与递归两种解法各有各的坑,我第一次刷的时候,第一版写的就是“交换节点值”,跑一遍发现用例竟然能过,后来被自己一句“你改成大对象节点试试”问住了。这篇文章会把题目真正的考点、迭代解法和递归解法的完整实现、以及在调试过程中最容易踩的细节全部铺开,适合刚接触链表的朋友,也适合刷了好几遍但想彻底把思路理清的人。
1. 先把题目拆明白:它真正考你的是指针重排
1.1 题目到底在说什么
假设输入链表是1 -> 2 -> 3 -> 4 -> 5,那么期望输出是2 -> 1 -> 4 -> 3 -> 5。规律很明确:第 1 个节点和第 2 个节点交换,第 3 个节点和第 4 个节点交换,依次向后推进;如果链表节点个数是奇数,最后一个节点保持原位置不动,因为它没有下一个节点可以配对。
边界条件也要先说清楚:空链表直接返回空,只有一个节点的链表直接返回它本身。这两种情况如果不在代码开头处理掉,后面所有基于“成对存在”的逻辑都会崩。
这题看起来就是一个简单的“相邻交换”,但注意题面用词:“交换其中相邻的节点”。这里的核心动作是交换节点,不是交换节点的值。这一点非常重要,也是很多刷题党第一个栽跟头的地方。
1.2 为什么直接交换节点值是不行的
我第一次写这题时,代码长这样:
if head and head.next: head.val, head.next.val = head.next.val, head.val跑了一下示例用例,输出完全正确,我当时还挺高兴,觉得这题就这么简单?后来琢磨了一会儿才发现问题:我根本没有操作链表的连接关系,只是把两个节点内部的val换了一下。
判题系统通常只对比最终链表“从头到尾读出来的值序列”,所以值交换的版本在 Online Judge 上大概率能过。但面试场景里,面试官一句话就能问住你:“如果节点里存的不是 int,而是一个很大的对象,里面有好几个字段,你还敢随便复制值吗?”更本质的问题是:链表这种数据结构存在的意义,就是通过改变引用关系来低成本地调整顺序;值交换相当于把链表退化成了一个数组,完全绕过了考点。
还有个实际工程里的理由:如果节点本身携带复杂状态,或者被多个对象同时引用,你复制值就可能产生不一致。所以这道题必须老老实实做指针重排。
1.3 把指针重排看成“剧组换座位”
理解指针操作,我习惯用一个生活类比。假设舞台上有四个演员按顺序站成一排:A、B、C、D。现在节目组要求变成 B、A、D、C。你要做的不是把每个演员的名字换掉(那对应值交换),而是让每个人走到新的位置上,同时重新确认“谁站在谁后面”。
链表里的“谁站在谁后面”就是每个节点的next指针。和现实不同的是,代码里改指针是有顺序的:一旦你先把 A 的next改成了 D,那 B 和 A 之间的关系就被切断了,如果你之前没保存 B 的引用,后面就再也找不回 B 了。
这就引出这道题真正的难点:在修改指针的过程中,不能用“画在纸上的最终效果”来指导代码顺序,你必须在每步修改前想清楚“原来的引用链路还在不在”。迭代法的所有细节,本质上都在解决这个问题。
2. 迭代解法:哑节点与四步指针重排
2.1 为什么必须用哑节点
先思考一个简单问题:如果不用额外技巧,交换前两个节点后,链表头变成了原来的第二个节点,你怎么把这个新头部返回出去?
最直接的写法是在函数开头判断:
if not head or not head.next: return head new_head = head.next # ... 交换第一对 ... return new_head但这只是第一对。接下来处理第二对、第三对时,每一对都需要“前一个节点”来接线。第一对的前驱是 dummy,第二对的前驱是第一个节点,第三对的前驱是第三个节点……如果所有逻辑都靠特判,代码会越来越乱。
正确做法是引入一个哑节点dummy,让dummy.next指向原来的头节点。这样原来的头节点也有了一个“虚拟前驱”,所有配对交换的逻辑就可以完全统一,最后直接返回dummy.next就是新链表的头。
提示:哑节点是链表重排题里非常通用的技巧。不只是这道题,后面做反转链表 II、K 个一组翻转时,你都会反复用到它。
2.2 迭代版完整代码
先上代码,再逐对拆解:
from typing import Optional class Solution: def swapPairs(self, head: Optional[ListNode]) -> Optional[ListNode]: dummy = ListNode(0, head) prev = dummy # 只有后面还存在“一对”节点时,才需要继续交换 while prev.next and prev.next.next: first = prev.next # 当前这一对的第一个节点 second = first.next # 当前这一对的第二个节点 # 四步指针重排 first.next = second.next # 第一步:first 跨过 second,指向后续链表 second.next = first # 第二步:second 反过来指向 first prev.next = second # 第三步:前驱指向新的第一个节点 second prev = first # 第四步:移动 prev 到这一对的“尾部” return dummy.next链表节点定义默认是:
class ListNode: def __init__(self, val=0, next=None): self.val = val self.next = next2.3 逐对拆解:从 dummy 出发走一轮
以1 -> 2 -> 3 -> 4为例,手动走一遍循环。
初始化后:dummy -> 1 -> 2 -> 3 -> 4,prev = dummy。
第一次循环:
first = 1,second = 2。- 第一步
first.next = second.next:节点 1 的next从 2 改成 3,链表瞬间变成dummy -> 1 -> 3 -> 4,但注意2仍然被局部变量second指着,没有丢。 - 第二步
second.next = first:节点 2 的next指向 1。此时局部视角里,2 指向 1,1 指向 3。 - 第三步
prev.next = second:dummy的next从 1 改成 2。链表结构变成dummy -> 2 -> 1 -> 3 -> 4。 - 第四步
prev = first:prev指向节点 1,因为下一轮中,节点 1 是后面那一对(3 和 4)的前驱。
第二次循环:
first = 3,second = 4。- 重复四步操作,最终链表变成
dummy -> 2 -> 1 -> 4 -> 3。
prev.next此时指向 3,prev.next.next指向 None,循环条件不满足,跳出。返回dummy.next,也就是节点 2。
这里有个反直觉的点:为什么第一步要先改first.next,而不是先改prev.next?原因很简单:first.next = second.next这一步的唯一目的,是把“后面那串还没处理的链表”挂到 first 身上。如果先改prev.next = second,虽然 dummy 先指向了 2,但一切还很乱,尤其是 first 和 second 的交叉关系没定好,后面几步容易漏。按上面这个顺序写,每一步都在缩小“尚未确定的引用范围”,记忆中也不容易乱。
2.4 写迭代时最容易翻车的三处细节
我在这道题上帮人 review 过很多版代码,常见的错误集中在三处。
第一处是循环条件写错。有人写成while head and head.next:,这不对,因为 head 在整个过程中没有变化,循环不会推进;有人写成while prev.next.next:,这在链表只有 0 或 1 个节点时会直接抛AttributeError。正确写法是:
while prev.next and prev.next.next:必须同时确认prev.next和prev.next.next都不为空,因为这两个节点分别对应“这一对的 first 和 second”,少一个都没法交换。
第二处是四步顺序错乱。最常见的是先写prev.next = second,再写first.next = second.next,看起来最终效果一样,但中间过程会临时产生一个“把 second 指向 first、又把 first 指向 3”的交叉状态,容易让自己困惑。我的建议是严格按照first.next -> second.next -> prev.next的顺序,理由上面已经说过了。
第三处是忘记移动 prev 或移动错了节点。交换完一对后,prev必须指向这一对的尾节点,也就是原来的 first。有人写成prev = second,那下一轮的前驱就变成了新链表的第一个节点,整个链会越接越乱。
提示:调试链表题,最快的办法是写一个把链表转成列表的辅助函数,每交换完一轮打一次 Python 列表。你很快就能看到是哪一步把顺序弄坏了。
3. 递归解法:把交换问题切成最小子问题
3.1 递归的视角:每次只处理“一对 + 一串剩余”
迭代法是从前往后逐对处理,递归法的思路完全不同:每一层递归只处理当前链表头部的两个节点,剩下的链表交给下一层递归,然后利用递归的返回值把两部分接起来。
把当前链表看成三段:
first -> second -> rest我们希望交换后变成:
second -> first -> 已经处理好的 rest这里的“已经处理好的 rest”不是原封不动的 rest,而是把 rest 也两两交换之后的新链表。这个新链表从哪来?从self.swapPairs(second.next)来。
递归的最小子问题是什么?就是head为空,或者head.next为空。此时没有可交换的对,直接返回head。这个 base case 和迭代法里while prev.next and prev.next.next的条件是对应的。
3.2 递归版完整代码
from typing import Optional class Solution: def swapPairs(self, head: Optional[ListNode]) -> Optional[ListNode]: # 没有节点或只有一个节点,无法交换 if not head or not head.next: return head first = head second = head.next # 关键:先处理后面的链表,再把 first 接在后面 first.next = self.swapPairs(second.next) second.next = first return second代码很短,但短代码背后藏着一个最容易写崩的顺序问题。
3.3 顺序讲解:为什么必须先递归、再改 second.next
这版递归代码里,唯一可能写错的地方就是前两行:
first.next = self.swapPairs(second.next) second.next = first有人会改成:
second.next = first first.next = self.swapPairs(second.next)你觉得这样行不行?不行,而且会死循环。
原因在于:second.next原本指向的是 rest 的第一个节点,也就是第三个节点。如果先执行second.next = first,那 second 和 rest 之间的引用关系就被切断了。紧接着执行self.swapPairs(second.next),这时的second.next已经是 first 了,于是递归调用传入的是 first 节点,函数内部又把 first 当成一个新的子问题去处理,而 first 的 next 还指向 second……这样一来,递归参数在 first 和 second 之间打转,无限递归,直接爆栈。
所以顺序必须固定:先递归,让first.next指向“处理好的 rest”,此时 second 还抱着原来 rest 的引用;然后second.next = first,把这一对的顺序翻转。用一句话记:先给 first 找新的后路,再让 second 指回 first。
3.4 调用过程模拟:从后往前拼
拿1 -> 2 -> 3 -> 4来推演递归过程,帮助建立直觉。
最外层调用swapPairs(1):
first = 1,second = 2。- 需要先算
self.swapPairs(second.next),也就是swapPairs(3)。
进入swapPairs(3):
first = 3,second = 4。- 需要先算
self.swapPairs(second.next),而second.next是 None,于是递归返回 None。 - 继续执行:
first.next = None,也就是 3 指向 None。 second.next = first,也就是 4 指向 3。- 返回 4。这一层的结果是
4 -> 3。
回到最外层:
- 现在拿到了内层返回值 4。
first.next = 4,也就是 1 指向 4。second.next = first,也就是 2 指向 1。- 返回 2。
最终链表:2 -> 1 -> 4 -> 3。
注意这个过程的推进方向:内层先完成,外层后拼装。所以递归的代码虽然短,理解成本其实比迭代高,它要求你在写的时候脑子里始终保留“返回值代表新链表头”这个抽象概念。
4. 双解法横评:面试怎么选、实测表现、常见误区
4.1 时间和空间对比
先给一个直观的对比表:
| 解法 | 时间复杂度 | 空间复杂度 | 代码量 | 调试难度 |
|---|---|---|---|---|
| 迭代 | O(n) | O(1) | 稍长 | 顺序易错 |
| 递归 | O(n) | O(n) | 短 | 引用易断 |
迭代版每个节点访问一次,没有任何额外数据结构,空间是常数级。递归版每次递归调用都会占用一层函数调用栈,n 个节点最多会有 n/2 层递归,渐近空间复杂度是 O(n)。这是两者最本质的差异。
时间上两者都是 O(n),没有区别。但实际运行时的常数项略有差异,递归调用有函数栈的进出开销,节点数量很大时迭代通常略快。这个差异在刷题场景不必太在意,但在面试里可以主动提一句,展示你对性能的理解。
4.2 面试现场的取舍思路
如果面试官让你现场写这道题,我的建议是:先用递归版本讲思路,再用迭代版本写代码。
递归版本很短,非常适合用来表达你对问题的建模能力:你只需要说清楚 base case 和返回值,面试官就能看到你理解“子问题”的视角。但说完之后要主动补一句:“递归需要 O(n) 的栈空间,如果要求常数空间,我可以改成迭代实现。”这句话往往很加分。
迭代版本更稳,因为空间是常数,而且不依赖递归深度。面试官如果明确要求“不要用递归”,那就直接写迭代。实际代码量无非多一个哑节点和四步重排,只要多练几遍,写起来比递归还快。
我个人的偏好是:如果这题出现在笔试阶段,直接迭代;如果出现在面试对话中,先聊递归建模,再落笔迭代。两个版本都写过的人,在面对追问时会非常从容。
4.3 实测踩坑:我在调试中遇到的三类失败
说几个我在各种练习和 review 里真实遇到过的翻车现场。
第一类是值交换。前面已经说了,虽然用例能过,但它不是这道题的考点,面试官一眼就能看穿。如果你在一道要求“交换节点”的题里写值交换,大概率会在后面的追问里被打得措手不及。
第二类是无限循环。递归版本里把顺序写反,就会导致链表成环。怎么快速发现?本地跑的时候程序迟迟不退出,或者打印链表内容时看到重复节点。如果你在限时环境中遇到这种现象,十有八九是递归参数变成了自己,先去检查second.next的赋值顺序。
第三类是边界漏判。迭代法里while prev.next and prev.next.next这个条件,含义是“后面还有两个节点”。有次我把条件简写成while prev.next.next,自测时链表只有一个节点,prev.next本身就已经是 None,再取.next直接抛异常。更隐蔽的是链表为空时,连prev.next都访问不到。所以循环条件一开始就必须把两个可选项都查一遍。
提示:无论迭代还是递归,写完请立刻用四个用例做基本自测:空链表、单节点、双节点、多个节点。手动跑一遍比反复提交判题系统快得多。
5. 从这道题长出来的通用套路:链表重排的思维模板
5.1 一轮指针重排的三大难关:断链、悬空、循环
把这道题吃透之后,你会发现链表重排的所有题,翻来覆去就三个坑。
第一个是断链。你修改了一个节点的next,但它原来指向的节点还没被其他引用保存,于是后面那段链表找不回来了。解决方式是在修改前把所有需要保留的节点都放进局部变量。
第二个是悬空。你修改了某个节点的next,但没有任何节点再指向这个被修改的节点,链表从中间断开了。在 Python 里因为有垃圾回收,这个节点不会长期存在,但它已经从链表上掉下来了,结果就是遍历时短了一截。
第三个是循环。你把某个节点的next指回了链表前段,导致遍历永远走不完,或者递归永远调不完。判断循环的简单办法:手动模拟三到四步,如果发现某个节点两次被访问,就说明出问题了。
一个通用的自查方法,适用于所有链表重排题:在写下xxx.next = yyy之前,问自己两个问题——yyy 这个引用现在还能拿到吗?xxx 原来的 next 还需要用吗?需要就先保存,不需要就直接改。
5.2 进阶:从交换一对到 K 个一组翻转
两两交换其实是更一般问题的特例。如果题目变成“K 个一组翻转链表”,也就是每 K 个节点翻转一次,不足 K 个保持原样,那核心思路和本题很像:
- 先检查当前节点开始还有没有 K 个节点;
- 不够 K 个,直接返回当前头节点;
- 够 K 个,在组内做 K-1 次局部翻转;
- 翻转后,把组尾接到下一组的结果上。
这题同样需要 dummy 节点,同样需要处理组间连接。把第 24 题做熟,你其实已经掌握了 K 个一组翻转的大部分框架,差的只是组内做 k-1 次循环翻转而已。所以不要把这题当独立题目看,它是链表重排系列的基础件。
5.3 我的做题习惯
最后分享几个我自己一直沿用的小习惯。
第一,动笔前先画箭头图。我在纸上画dummy -> a -> b -> c -> d,然后把每一步修改后的箭头用不同颜色标出来。画完再写代码,写错率会低很多。链表题最忌讳不看图硬写。
第二,给每个解法都补边界用例。空链表、单节点、两个节点、奇数长度、偶数长度,这几个用例跑一遍,能覆盖绝大多数的边界问题。
第三,刷完这题顺手刷关联题。我自己的顺序是:206 反转链表 → 24 两两交换 → 25 K 个一组翻转。这三题放在一起刷,才能真正建立“指针重排”的手感,而不是背一题会一题。
第四,不要只在判题系统里验证。本地写一个小脚本,把输出的链表转成 Python 列表打印出来,每交换一对打一次,能非常直观地看到结构变化。
最后再提一个我踩过几次坑之后总结的经验:写链表题时,每改一个next,都停下来在心里默读一遍当前链表长什么样。默读不出来的地方,就是代码出 bug 的地方。两两交换这道题作为链表重排的第一课,真的非常合适,花半小时把它彻底搞懂,后面一大批链表相关题目都会轻松不少。