news 2026/9/9 18:31:50

LeetCode 24题详解:两两交换链表节点,递归与迭代全解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
LeetCode 24题详解:两两交换链表节点,递归与迭代全解析

昨天帮团队做链表专题分享,一个平时写业务很溜的同事问了我一句:"LeetCode 24 题我看了题解能看懂,自己一写就丢节点,这道题到底难在哪?" 这个问题其实问到了点子上。Leetcode 24. 两两交换链表中的节点,在题单上标着 Medium,但它最难的地方从来不是思路,而是"你以为你想清楚了,一动手指针就乱飞"。这题非常适合拿来检验链表基本功,尤其是用 JavaScript 写的时候,引用操作的直觉对不对,跑几个边界用例立刻现原形。

这篇文章就把这道题完整拆一遍:递归和迭代两个主流解法逐行讲透,附带我实际调试时用到的打印工具、边界用例矩阵,以及从这题延伸出去的 K 个一组翻转思路。适合正在准备算法面试的人,也适合刚学到链表、对 next 指针绕来绕去感到头大的前端开发者。

1. 两两交换考的不只是交换:先把链表的"基本操作闭环"说清楚

1.1 数组交换和链表交换的差异

很多人在数组里做交换做习惯了,下意识会觉得两两交换就是把两个节点对调一下。数组交换确实简单,[a, b] = [b, a],或者经典的临时变量三步,本质上是搬运数据。

链表不一样。链表节点之间的关系不是"位置相邻",而是"引用指向"。每个节点只有一个val和一个nextnext保存的是下一个节点的引用(JavaScript 里就是对象引用)。交换两个相邻节点,实际要做的是重新编排四个角色之间的关系:前驱节点、第一个节点、第二个节点、后继节点。

举个例子,链表是1 -> 2 -> 3 -> 4,要交换 1 和 2。如果只是把节点里存的val从 1 换成 2、2 换成 1,得到2 -> 1 -> 3 -> 4,从输出看确实"对了"。但这里有个本质问题:你只是做了数据搬运,节点本身的物理关系没有变,一旦这个节点还挂着其他属性(比如指向另一个数据结构的索引),或者题目改成"K 个一组翻转",这种交换值的思路立刻失效。

所以刷这题的正确姿势,是操作next指针,而不是操作val

1.2 交换一对节点,你必须同时掌握四个引用

链表的节点之间是通过next串起来的,交换两个相邻节点,本质上要回答一个问题:这两个节点从当前位置摘下来之后,怎么再插回去?

一个完整的两两交换操作,会涉及四个角色:

角色示例作用
前驱节点 prev交换 1、2 时,prev 是虚拟头节点它的 next 要指向交换后的第一个节点
第一个节点 first节点 1交换后变成第二个节点
第二个节点 second节点 2交换后变成第一个节点
后继节点 next节点 3它等待被 first 指向

如果不持有前驱节点prev,你就算交换了firstsecond,也没办法让前驱正确连上新的头部。这也解释了为什么头节点很特殊:头节点没有前驱。

1.3 头节点为什么麻烦

普通节点交换,前驱天然存在;但头节点的前面是null,你要么单独为它写 if 分支,要么用一个虚拟头节点,把"头节点"也变成"普通节点"。

这也是这道题最容易出 bug 的位置之一。很多人写迭代解法,交换逻辑本身没错,但head处理不好,最后返回的链表头部就不对。后面第 3 节会专门讲dummy哑节点的用法,那是最省心的方案。

2. 递归解法:只处理一对节点,剩下交给调用栈

2.1 递归的核心观察

递归解法的切入点很优雅:把链表拆成两部分。前两个节点是一部分,后面跟着的一整条链表是另一部分。

先递归处理后面的链表,让它自己完成"两两交换",返回一个新的头节点;然后再把当前这两个节点也交换一下,接上递归返回的结果。

这个思路避免了你手动遍历时对指针走向的反复推演,因为你只需要关心"当前这一对"怎么交换。听起来有点像把复杂问题外包给一个和你做一模一样事情的"分身",分身的子问题规模更小,直到链表长度不足两个节点为止。

2.2 递归代码逐行拆解

直接看代码:

var swapPairs = function (head) { // 终止条件:空链表,或者只剩一个节点,不需要交换 if (head === null || head.next === null) { return head; } // 记住当前这一对节点的第二个节点 const second = head.next; // 递归处理后面的链表,返回的是后面链表交换后的新头 const restHead = swapPairs(second.next); // 交换当前这一对: // 原来的第二个节点指向原来的第一个节点 second.next = head; // 原来的第一个节点指向递归处理完的后半部分 head.next = restHead; // 当前这一对交换后,新的头是原来的第二个节点 return second; };

重点理解这几行:

swapPairs(second.next)传进去的,是第二节点后面的整条链表。比如原始链表1 -> 2 -> 3 -> 4,这里传的就是3 -> 4。递归函数会把它变成4 -> 3,并返回节点 4。

然后执行second.next = head,也就是让2 -> 1

再执行head.next = restHead,也就是让1 -> 4

最终形成2 -> 1 -> 4 -> 3

每一步都只改变当前这一对两个节点的指针,后面的结构在递归返回时已经处理好了,不需要你操心。

2.3 递归的边界、复杂度和适用场景

递归的终止条件必须同时判断head === nullhead.next === null。前者处理空链表,后者处理奇数长度链表最后的落单节点。漏掉任何一个,都会导致空指针错误或者死递归。

时间复杂度是 O(n),每个节点被访问一次;空间复杂度是 O(n),因为递归调用栈的深度是 n/2。当链表长度为 100 万时,递归深度会达到 50 万层,JavaScript 运行时会直接抛出栈溢出错误(RangeError: Maximum call stack size exceeded)。工程代码里处理超长链表时慎用递归,但在 LeetCode 的测试数据规模下,这个解法没有任何问题。

递归版本的优点是代码简洁、逻辑清晰,面试时很好讲。缺点是空间复杂度比迭代高。如果面试官要求 O(1) 空间,你就需要切换到迭代写法。

3. 迭代解法:哑节点+三步重连,绕开头节点没有前驱的尴尬

3.1 为什么哑节点是必需品而非技巧

迭代解法里,头节点没有前驱这个事,必须正面解决。两种做法:

  • 单独处理头节点,交换完再更新head
  • 创建一个哑节点dummy,让dummy.next指向原头节点,然后从头开始统一操作。

我强烈推荐哑节点。它不仅省掉了一个 if 分支,更重要的是让循环逻辑对每一对节点完全一致,也避免了交换头节点后返回错误指针的低级失误。

var swapPairs = function (head) { // 哑节点:val 无所谓,重点是 next 指向真正的头节点 const dummy = new ListNode(0, head); let prev = dummy; while (prev.next !== null && prev.next.next !== null) { const first = prev.next; const second = first.next; // 步骤一:前驱指向第二个节点 prev.next = second; // 步骤二:第一个节点指向第三个节点(防止后半段丢失) first.next = second.next; // 步骤三:第二个节点指向第一个节点 second.next = first; // 移动 prev 到新的"前驱"位置 prev = first; } return dummy.next; };

下面是1 -> 2 -> 3 -> 4的完整变化过程,用文字模拟每一步的状态:

初始化:dummy -> 1 -> 2 -> 3 -> 4prev = dummy

第一轮循环,prev.next = second后:dummy -> 2 -> 1 -> 2 -> 3 -> 4(这时 1 和 2 之间还是互相指向的,中间状态有环,但马上会修正)。

first.next = second.next,也就是让1 -> 3dummy -> 2 -> 1 -> 3 -> 4

second.next = first,让2 -> 1dummy -> 2 -> 1 -> 3 -> 4,此时前两个节点的交换完成。

prev = first,也就是prev移到节点 1 的位置:dummy -> 2 -> 1 -> 3 -> 4,下一轮从这里继续。

第二轮循环处理 3 和 4,逻辑完全一样。最终dummy.next返回 2。

3.2 指针修改顺序:为什么是"先连前驱,再改内部"

三个步骤的顺序非常关键。一个常见的错误是先写second.next = first,再写first.next = second.next。第二行里的second.next已经被改掉了,引用丢失,后半段链表直接断开。

我的经验是遵守一个原则:在修改任何 next 之前,先确定你后面还要用的节点引用已经保存在某个变量里了。

标准三步的顺序是:

  1. prev.next = second:先把前驱连到 second,这样从整体链表来看,这一对节点已经"换头"了。
  2. first.next = second.next:此时second.next仍然指向真正的后继节点(还没被改过),把它保存到 first 的 next 上。
  3. second.next = first:最后才把 second 和 first 的内部连接翻转过来。

如果你更习惯另一种顺序,可以先把second.next存进临时变量,比如const third = second.next,这样就不依赖执行顺序了。两种写法都能过,但脑子里要清楚:核心是"别丢引用"。

3.3 迭代 vs 递归:怎么选

维度递归迭代
空间复杂度O(n),调用栈O(1)
代码可读性高,思路直观中,需要理解 prev 移动
超长链表的工程场景可能有栈溢出风险更适合
面试讲解难度如果你擅长递归,更好讲如果你能把 prev 移动讲清楚,也很好

我个人会先讲递归版本争取快速沟通思路,再用迭代版本展示工程化考量。这是面试刷题的一个常用组合拳。

4. 边界用例与排查:用打印函数把每一步看穿

4.1 给链表写一个"体检小工具"

链表题最头疼的问题是调试。你看不到"链表现在长什么样",只能靠脑内推演。我的做法是准备一个通用的打印函数,每次操作后输出一次链表当前状态:

function printList(head) { const values = []; let current = head; let count = 0; // 加一个安全上限,避免环链表导致死循环 while (current !== null && count < 10) { values.push(current.val); current = current.next; count++; } console.log(values.join(' -> ')); }

注意我加了一个计数器上限。这里的考虑很实际:如果代码有 bug 导致链表成环,没有这个限制,打印函数会无限循环,把调试过程搞得更痛苦。加一个 10 次的上限,至少能保证控制台不会卡死。

使用方式很简单,在 swapPairs 函数的关键位置插入打印,或者写一个测试函数,把每个输入跑一遍,直接看输出。

4.2 五组必须验证的用例

下面这五组用例是我刷链表题必测的,覆盖了所有边界类型:

输入期望输出说明
[][]空链表,验证终止条件
[1][1]单节点,验证奇数长度落单
[1,2][2,1]最小有效对
[1,2,3][2,1,3]奇数长度,最后一个节点不参与交换
[1,2,3,4][2,1,4,3]偶数长度,完整交换

有一个细节值得注意:奇数长度链表,最后一个节点会保持原位。这是由"两两交换"的定义决定的,不是 bug。如果面试官问起来,你可以直接说明,因为最后只剩下一个节点,没有配对对象。

4.3 我实际踩过的三个坑

坑一:忘了移动prev

迭代解法里,每一轮结束后必须把prev更新成first。如果忘了,循环条件永远基于同一个 prev 判断,要么死循环,要么只交换第一对。这个错误的隐蔽性在于:如果链表刚好只有两个节点,代码能跑出正确结果,你会误以为写对了。直到测试[1,2,3,4]才发现只交换了第一对。

坑二:递归终止条件只写了一半。

我一开始写过if (head.next === null) return head,没写head === null。看起来只差一个判断,实际跑空链表用例时直接报错,因为null.next本身就是非法访问。在 JavaScript 里这会抛出 TypeError,在 C/C++ 里就是空指针段错误。链表题的边界判断,永远要把空值放在条件表达式的前面,并且成对检查。

坑三:用 JSON.stringify 调试链表。

链表节点是互相引用的对象,JSON.stringify(head)遇到环状引用会抛错;即使没有环,它也会把整条链表的结构一次性序列化出来,输出冗长,完全不适合观察"交换过程中的某一步"发生了什么。正确做法就是上面那种逐节点遍历打印,一步一行,清清楚楚。

5. 从 24 题延伸开:K 个一组翻转和面试官的三个追问

5.1 这套方法论在 25 题上的复用

LeetCode 25 题"K 个一组翻转链表",其实就是这道题的超集。当 K = 2 时,它退化成今天这道题;K = 3 或更大时,核心思路依然是:确定这一段的前驱、段内重连、重新接上后继。你在 24 题里练会的"先保存引用,再改指针",在 25 题里依然是主角。区别只在于,K 个一组需要先数出这一组是否够 K 个节点,然后做一次组内反转。建议刷完 24 题后直接上 25 题,你会感觉熟悉很多。

5.2 面试官针对这题喜欢追问的三件事

追问一:"你能说说递归的空间复杂度是多少吗?可不可以优化?" 这是考察你是否知道递归调用栈消耗内存。回答时先给出 O(n),然后说迭代版本可以做到 O(1)。

追问二:"如果链表特别长,比如几千万个节点,递归会出什么问题?" 这个问题考察工程意识。要答出栈溢出的风险,以及迭代版本的长链表优势。

追问三:"如果不想交换节点,只交换 val,可以吗?有什么问题?" 这就是第 1 节说的"值交换"陷阱。要明确回答:可以,但对工程场景不适用,且无法推广到复杂节点结构或 K 个一组翻转。

5.3 链表操作在真实工程里的位置

写业务代码的人可能一年到头碰不到一次手写链表,但链表的思想无处不在。前端的虚拟 DOM 的 fiber 架构用链表组织节点,浏览器的事件循环队列本质上是链表结构,LRU 缓存的一种常见实现也是哈希表+双向链表。这些场景里你写不出"两两交换"这么纯粹的算法,但prevnext、临时保存引用、防止引用丢失,这套思维完全通用。

尤其值得说的是 JavaScript 的引用语义。很多人写链表题时总觉得 JS 里的"指针"模糊不清,其实你只需要抓住一点:对象类型的变量保存的是地址,赋给另一个变量,相当于两个变量指向同一块对象。修改node.next会直接影响所有持有该节点引用的变量。理解了这一点,再看链表操作就顺了。


最后分享一个我自己的习惯:每道链表题写完,我会假设自己在熟睡中被叫醒,还能不能闭着眼把"先保存引用、再修改指针"这个流程讲清楚。如果能,这道题才算真正过了。LeetCode 24 题不复杂,但它值得你多写两遍——一遍递归,一遍迭代,跑完上面五个边界用例,然后把它收进你的链表基本功清单里。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/9 18:31:13

C++运算符重载全面解析:以PTA Vec2题为例掌握底层机制

我记得当年在重庆大学的数据结构课上第一次碰到这道 PTA 题——“加、不等和输入输出的运算符重载&#xff08;2维向量 Vec2&#xff09;”时&#xff0c;整个人是懵的。明明 Vec2 就是一个装有 x、y 两个分量的简单结构体&#xff0c;为什么非得把、!、>>、<<全都…

作者头像 李华
网站建设 2026/9/9 18:30:10

C#开发HIS系统实战:从架构设计到设备对接避坑全解析

简介&#xff1a;C#医院HIS系统是一套面向医疗信息化开发者的完整项目源码&#xff0c;聚焦医院日常运营与临床决策支持场景。系统覆盖患者管理、挂号诊疗、电子处方、检验检查、财务收费、物资管理等核心环节&#xff0c;并包含基于角色的权限控制与外部系统集成设计&#xff…

作者头像 李华
网站建设 2026/9/9 18:29:14

C++内存泄漏的编译期拦截:Clang-Tidy与PVS-Studio实战

1. 为什么内存泄漏要提前到编译期来查1.1 一个典型的线上泄漏事故复盘先说一件真实的事。去年我维护的一个 C 网关服务&#xff0c;每天凌晨都会触发一次内存告警&#xff0c;RSS 稳定涨 200MB 左右。一开始怀疑是某个第三方库没释放&#xff0c;用 Valgrind 拷了两天没复现&am…

作者头像 李华
网站建设 2026/9/9 18:27:11

Hermes WebUI 数据库连接:3 类数据源接通的完整实战

Hermes WebUI 数据库连接&#xff1a;3 类数据源接通的完整实战 【免费下载链接】hermes-webui Hermes WebUI: The best way to use Hermes Agent from the web or from your phone! 项目地址: https://gitcode.com/GitHub_Trending/he/hermes-webui Hermes WebUI 数据库…

作者头像 李华