☰
两两交换链表中的节点:迭代与递归详解,吃透指针重排
2026/10/20 16:05:26 网站建设 项目流程

今天来聊一道链表题,力扣每日一题里的常客:两两交换链表中的节点,对应原题编号第 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 = next

2.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 的地方。两两交换这道题作为链表重排的第一课,真的非常合适,花半小时把它彻底搞懂,后面一大批链表相关题目都会轻松不少。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询