今天是我刷 LeetCode 的第 11 天,做到第 24 题:《两两交换链表中的节点》。这题标的是 Medium,但说难不难,说简单也不简单——如果你用惯了 JavaScript 的数组和对象,第一次接触链表操作时很容易懵,因为链表题本质上考的不是 for 循环怎么写,而是你对“引用”和“指针指向”的理解。这一篇我不会只贴个能过审的答案,而是把题目背后的链表思路、迭代与递归两种解法、为什么哑节点能省掉一堆 if 判断,以及我实际调试时踩过的几个坑一起过一遍。适合刚开始刷链表、或者对指针操作还不太放心的同学,看完之后你可以照着代码自己跑一遍,再顺手做两道相似题巩固。
1. 题目拆解:两两交换到底在交换什么
1.1 题目需求与边界条件
题面本身很直白:给你一个链表,把相邻的两个节点交换位置,然后返回交换后链表的头节点。比如输入 1 -> 2 -> 3 -> 4,期望输出是 2 -> 1 -> 4 -> 3;如果链表只有一个节点,比如 1,那它没法配对,直接返回 1;如果链表为空,直接返回 null。
这里有两个容易被忽略的边界:第一,链表长度为奇数时,最后一组只有一个节点,这个节点不需要交换,保持原样就行。例如 1 -> 2 -> 3 的输出是 2 -> 1 -> 3,不是 2 -> 1,更不是把 3 丢掉。第二,交换之后链表的头节点变了,原来的 head 变成了第二个节点,所以返回值不能简单写成 head,而是要找到交换后的新头。
很多人第一次看到这题会觉得很简单:不就把 val 两两交换吗?确实,力扣判定时只检查节点的值顺序,如果你只交换 node.val,用例大概率也能过。但题目要求里明确写了“不能只是改值”,而是要真实地交换节点。我个人建议哪怕判题系统没有强制,也要坚持做“节点交换”,因为这才是这题想让你练的东西——操作 next 指针。
1.2 为什么数组思维在这里会失效
如果你平时写 JavaScript 主要操作数组,遇到这道题的第一反应可能是:先遍历一遍链表,把所有值存进数组,然后两两交换数组元素,再用数组重新构建一个链表。这个思路能过吗?大部分用例能过,而且代码写起来很简单。
但我不推荐刷题阶段这么做,原因有三点:第一,这题的空间复杂度要求是 O(1),如果用数组再重建链表,额外空间是 O(n),不符合最佳解法;第二,你练链表题本来就是为了熟悉节点引用、指针连接这些底层能力,绕过去等于白练;第三,在面试场景里,面试官大概率会追问“如果节点对象里有其他业务字段怎么办”,你只换 val 的方案立刻站不住脚。
链表和数组最大的区别在于:数组是一块连续内存,元素之间的“前后关系”是靠下标天然确定的;链表则是一连串对象,每个对象里保存着“下一个节点的引用”,关系是显式挂在节点上的。要做“交换两个节点”,本质上就是重新调整这些引用关系,让它们的前后顺序发生改变。
1.3 从示例推演看核心难点
拿 1 -> 2 -> 3 -> 4 举例。我们要把 1 和 2 交换,再把 3 和 4 交换。第一组交换前,需要考虑 1 的前一个节点是谁。如果 1 本身就是头节点,那它的前一个位置是空的,这就引出了后面要讲的哑节点问题。如果你只处理“当前节点”和“下一个节点”,不考虑前驱,交换到第二组时就会遇到麻烦:第二组的前驱是 1,而 1 在第一轮已经变成第二个节点了,这个“前驱关系”是动态变化的。
所以这道题真正的难点不是“交换”动作本身,而是交换前如何保存好各个相关节点,交换后如何把新的前驱和后继正确接回去。这也是为什么不管用迭代还是递归,核心都在处理那三四个 next 指向,而不是处理 val。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 动手前补课:JavaScript 链表的核心是“对象引用”
2.1 节点在 JavaScript 里到底长什么样
LeetCode 的 JavaScript 环境里,链表节点一般是这样定义的:
javascript复制function ListNode(val, next) {
this.val = val === undefined ? 0 : val;
this.next = next === undefined ? null : next;
}
每个节点就是一个普通对象,val 存放值,next 存放另一个节点的引用。比如 1 -> 2 本质上是:
javascript复制const node2 = new ListNode(2);
const node1 = new ListNode(1, node2);
换句话说,node1.next 不是把 node2 复制了一份进去,而是说“node1 的 next 指向 node2 这个内存对象”。你可以把 next 理解成一个人手里拿着的“下一个人联系方式”,当你改变 node1.next 的指向时,不是在修改 node2 本身,而是在修改 node1 里的那条联系方式。
JavaScript 里其实没有 C 语言那种“指针”语法,但它有对象引用,理解和操作方式是完全一样的。你声明 const p = head 时,p 和 head 指向同一个对象;通过 p 修改这个对象的 next 属性,head 也能感知到变化,因为它们本来就是同一个对象的两个别名。这一点后面调试时很重要。
2.2 用“排队名单”来理解 next 修改
如果觉得“引用”太抽象,我最常给朋友打的比方是:链表像一个排队的队伍,每个队员手里拿着一张纸条,纸条上写着“下一个人是谁”。交换两个节点的位置,不是让人物本身瞬移,而是把各自的纸条内容换掉。
比如 A 后面本是 B,B 后面本是 C。要让 A 和 B 交换位置,最终顺序变成 B -> A -> C,那么要改的是三张纸条:前驱节点原先指向 A,现在改成指向 B;A 的纸条原本指向 B,现在改成指向 C;B 的纸条原本指向 C,现在改成指向 A。如果你少改其中一张,队伍就会出现两个人同时指向同一个人,或者队伍中间断掉,结果就是链表丢节点或者出现循环。
这个比喻基本覆盖了所有链表指针题的操作套路:改动 next 时,先把要“断掉”的后面节点用临时变量接住,再改指向,最后重新接上。顺序错了,节点就可能找不回来。
2.3 三个高频认知误区
误区一:以为 p = p.next 会改变原链表。这句话只是让局部变量 p 往后移动一个位置,就像你用鼠标在图片上移动光标,不会改图片本身。迭代链表时经常写这个,但它本身不改链表结构。
误区二:连续写很长的 node.next.next.next,没有用临时变量缓存中间节点。链表操作里,一旦某个节点不再被任何引用指向,你就永远找不到它了。写代码时要随时问自己:我正准备覆盖的 next,后面还有没有别人需要先接住?
误区三:把“只改 val 然后提交通过”当成大功告成。这个前面说过,能骗过判题系统,但骗不过面试官,也骗不过你自己对链表操作能力的提升。真正的技能点在于:当你需要交换节点时,能不能靠调整 next 引用完成,并且保证不丢节点、不产生环。
3. 迭代解法:哑节点 + 三指针保平安
3.1 为什么需要一个哑节点
先说结论:处理链表头节点可能变化的题,我很推荐先在头部加一个哑节点(dummy node),让操作逻辑统一。哑节点本身不存业务数据,它的作用只是提供一个“头节点前面的位置”。
这题如果不加哑节点,你得单独处理“第一组交换前没有前驱”的问题。要么你写个 if 判断头节点的特殊情况,要么交换完之后再去找新头。这两种做法都会让代码分支变多、更容易出错。
加了哑节点后,dummy.next 永远指向新的头节点。哪怕原来的头节点被换到第二位,你最后返回 dummy.next 也不会错。你可以把 dummy 理解成一个固定的“入口指针”,它不参与业务,但让指针操作从第一组到最后一组都有一致的处理模式。
3.2 核心迭代代码
先放上我最终提交的版本:
javascript复制var swapPairs = function (head) {
const dummy = new ListNode(0);
dummy.next = head;
let prev = dummy;
while (prev.next && prev.next.next) {
const first = prev.next;
const second = first.next;
prev.next = second;
first.next = second.next;
second.next = first;
prev = first;
}
return dummy.next;
};
这个版本我自认为比较清晰,核心就是三个指针:prev 指向待交换组的前一个节点,first 指向这组的第一个节点,second 指向这组的第二个节点。循环能顺利进行的前提是“这组确实有两个节点”,也就是 prev.next 和 prev.next.next 都存在。
3.3 循环体逐步拆解
很多人看这种代码容易眼花,因为 next 指向改来改去。我建议照着例子手推一遍。
初始链表:dummy -> 1 -> 2 -> 3 -> 4
- 第一次进入循环:prev 是 dummy,first 是 1,second 是 2。
prev.next = second:dummy 的 next 从 1 改成 2。此时dummy -> 2 -> 3 -> 4,但 1 还在 2 后面吗?不,目前 2 的 next 还是 3,1 的 next 也是 2。链表其实处于一种中间状态,像两条线暂时交叉。first.next = second.next:把 1 的 next 从 2 改成 3。此时 1 指向 3。second.next = first:把 2 的 next 从 3 改成 1。此时局部顺序变成2 -> 1 -> 3 -> 4,整条链表是dummy -> 2 -> 1 -> 3 -> 4。
第一次交换完成。注意,原来第 2 个节点 2 现在已经变成了这一组的头,而 prev 需要前进到“下一组的前驱”,也就是 1。因为下一组要交换的是 3 和 4,而 1 正好是它们的上一个节点。
于是执行 prev = first,也就是 prev 指向 1。下一轮循环,prev.next 是 3,prev.next.next 是 4,条件成立,继续同样的操作。最后 prev 会走到 3 上,此时 prev.next 是 4,prev.next.next 是 null,循环退出,返回 dummy.next,也就是 2。
这里最容易出错的就是忘记把 prev 往后移。如果少了 prev = first,循环会一直处理同一组节点,要么死循环,要么现象很奇怪。
3.4 循环条件与边界情况的选择
我把循环条件写成 while (prev.next && prev.next.next),而不是常见的 while (cur && cur.next),因为这样让逻辑更聚焦:只要当前组还有两个节点,就继续处理。为什么不用 cur?如果用一个 cur 指针指向第一个节点,交换完之后 cur 应该跳到什么位置?很容易绕晕。直接用 prev 这个“前驱”视角,反而清晰:我只要看前驱后面的两个节点是不是都活着。
这个条件天然处理了三种边界:空链表时 prev.next 为 null,不进入循环;只有一个节点时 prev.next.next 为 null,不进入循环;奇数长度时,最后一组只剩一个节点,条件为 false,节点保持不动。整个过程不需要额外写 if,很干净。
当然了,你也可以在 while 里用 const first = prev.next; const second = prev.next.next;,让可读性更好,而不是到处写 prev.next.next。如果连续写太深,肉眼检查很容易漏掉某个 next。
4. 递归解法:让子问题先搞定
4.1 递归的等价关系怎么找
迭代解法是“从上到下,一组一组处理”,递归则是“先假设后面的链表已经被处理好了,我只关心当前这一组怎么接”。
拿 1 -> 2 -> 3 -> 4 来说,递归的视角是:我先把 1 和 2 后面的那部分 3 -> 4 交给 swapPairs 处理,等它返回 4 -> 3 之后,我再把 1 接到 4 的后面,把 2 接到 1 的前面。这样整个链表就变成了 2 -> 1 -> 4 -> 3。
所以递归的终止条件很自然:如果 head 为 null,或者 head.next 为 null,说明没有节点或者只剩一个节点,没法组成一对,直接返回 head。
许多链表递归题都是这个套路:先递归处理子链表,拿到一个“已经处理好的头”,然后再把当前节点接上去。它和树的递归很像,你需要相信子问题能正确返回结果,不需要在脑海里把整条递归栈都展开。
4.2 递归代码与执行链路示例
递归版本的代码非常短:
javascript复制var swapPairs = function (head) {
if (head === null || head.next === null) {
return head;
}
const newHead = head.next;
head.next = swapPairs(newHead.next);
newHead.next = head;
return newHead;
};
我们一步步看递归怎么执行。首先调用 swapPairs(node1),node1 的 next 是 node2,所以 newHead 就是 node2。然后递归调用 swapPairs(node3),处理后面两个节点。
在 swapPairs(node3) 内部:newHead 是 node4;递归调用 swapPairs(null),返回 null;于是 node3.next = null;再让 node4.next = node3;最终返回 node4。也就是说,3 -> 4 变成了 4 -> 3,而这个返回值会赋给外层 head.next。
外层此时 node1 还在,执行 node1.next = node4,把 1 接到已经交换好的后半段头部 4 上。接着执行 node2.next = node1,让 2 指向 1。最后返回 node2。全程下来,链表变成 2 -> 1 -> 4 -> 3。
注意看最后一句,递归函数返回的是 newHead,也就是交换后的新头。很多人会想当然地 return head,那样交上去会发现结果还是 1,因为当前层的 head 在这一轮已经变成第二个节点了。
4.3 递归在真实刷题中的适用边界
递归版本的优点是代码简洁、思路好表达,尤其在面试时你可以先给出递归版本,让面试官看到你对“子问题”的理解。缺点也很明显:递归深度等于链表长度,如果链表特别长,比如几万个节点,JavaScript 引擎的调用栈可能不够用,直接报 Maximum call stack size exceeded。
力扣的测试用例一般不会故意给到几万级别来卡递归,但你心里要有数:真实生产环境里,链表长度是不可控的,这时候迭代版本更稳妥。所以我的建议是两种解法都掌握,不要只背一个。先递归理解思路,再迭代落地优化,这也是刷链表题比较健康的节奏。
如果后续做“K 个一组翻转链表”这类扩展题,你会发现递归思路同样适用:每次找到一组节点,反转这一组,然后递归处理剩下的部分。递归的价值在处理复杂分组时会更明显,因为它把“当前组”和“剩余链表”拆开了。
5. 我实际调试时踩过的坑
5.1 高频报错和现象速查
这题我刷的时候,本地控制台报错过好几次。你可以对照下面这张表快速定位问题:
| 现象 | 大概率原因 | 排查方向 |
|---|---|---|
| 输出结果和原链表一样 | 只交换了 val,或返回值写成了 head | 检查是否修改了 next 指向 |
| 结果后半段丢失 | 某个 next 指向被覆盖前没保存 | 检查 first.next = second.next 是否写错 |
| 运行超时或死循环 | 循环内 prev / cur 没有向后移动 | 检查循环结尾是否更新 prev |
| 报错:Cannot read properties of null | 访问了 null 节点的 next | 检查 while 条件是否覆盖了空链表情况 |
输出变成 2 -> 1,后半段没了 |
递归或迭代时没有把已处理的后半段接回来 | 重点看 head.next 是否接到递归返回值 |
这些坑很典型,基本每道链表题都会碰到。核心原因只有一个:对节点的引用关系把握不够,覆盖 next 之前没有先考虑“我待会儿还要不要这个引用”。
5.2 只交换 val 的“伪通过”
我第一次在编辑器里试的时候,曾经偷懒写过类似 [head.val, head.next.val] = [head.next.val, head.val] 的方式。提交到力扣确实能过,因为返回值只比 val。但我后来自己反思,如果只满足于这种解法,链表操作能力完全没有提升。面试官一旦问“如果这个节点还保存着其他引用字段呢”,你总不能说我只交换 val 吧。
更麻烦的是,只交换 val 的做法在一些变体题里根本不管用,比如“K 个一组翻转链表”,如果节点除了 val 还有其他状态,你只换 val 可能违背题目语义。所以我的建议是:这题老老实实操作 next,把每一步指向都画清楚。刷题不是只为了那个绿色对勾。
5.3 next 指错导致的断链与循环
最常见的一种错误代码长这样:
javascript复制prev.next = second;
second.next = first; // 这里如果直接写,会丢掉 second 原本的 next
first.next = ???;
问题在于 second 原本的 next 是后面一组节点,比如 3 和 4 中的 3。如果不先用变量把 3 保存下来,一旦执行 second.next = first,原来的 3 就找不到了,链表后半段直接丢失。
正确顺序应该是先把 first 的 next 指向 second.next 后面的节点 3,再让 second.next 指向 first。顺序不同,结果完全不同。这就是链表题最讲究“语句顺序”的地方。我自己的习惯是:每次执行一条 next 赋值前,先问自己一句——这一步会覆盖谁的引用?被覆盖的那个引用,我还需不需要?如果需要,就在前面加个临时变量先保存。
还有一种情况是出现环,明明没丢节点,但输出无限循环。这通常是因为某两个节点的 next 互指了。比如你先执行 prev.next = second,又执行 second.next = prev,如果不小心让 prev 变成了当前节点的前驱,就可能把链表头往回指。出现这种问题,最好的排查方式不是瞪眼,而是打印链表,限定循环次数,看打印结果是不是重复。
5.4 自建辅助函数,跑通本地用例
力扣上调试只能看到结果对不对,但本地跑的时候,我会多写两个辅助函数,把数组转换成链表、把链表转换成数组,这样测试起来特别直观:
javascript复制function buildList(arr) {
const dummy = new ListNode(0);
let cur = dummy;
for (const val of arr) {
cur.next = new ListNode(val);
cur = cur.next;
}
return dummy.next;
}
function toArray(head) {
const res = [];
while (head) {
res.push(head.val);
head = head.next;
}
return res;
}
这样我可以在 Node.js 控制台直接跑用例:
javascript复制console.log(toArray(swapPairs(buildList([1, 2, 3, 4])))); // [2, 1, 4, 3]
console.log(toArray(swapPairs(buildList([])))); // []
console.log(toArray(swapPairs(buildList([1])))); // [1]
console.log(toArray(swapPairs(buildList([1, 2, 3])))); // [2, 1, 3]
这几组用例一跑,大部分边界问题就暴露了。尤其空链表和单节点,很多看起来对的代码在这两组用例上就会报错,因为循环里没有判空。数组转链表的辅助函数在网络题里也很通用,后面刷反转链表、合并链表都能复用,建议直接存进自己的代码片段里。
6. 复杂度对比与常见扩展题
6.1 时间与空间复杂度对比
迭代版每轮处理两个节点,交换操作是常数时间,所以总时间复杂度是 O(n),n 是链表长度。因为只用了 dummy、prev、first、second 这几个额外变量,没有随着链表长度增长,所以空间复杂度是 O(1)。
递归版同样遍历了所有节点,时间复杂度也是 O(n),但每次递归都会占用一层调用栈,最坏情况递归 n/2 层,空间复杂度是 O(n)。这也是链表题里少见的“时间一样、空间变差”的例子,原因不是递归逻辑有错,而是函数调用自带栈开销。
两个版本刷题都能过,但如果在面试中被追问哪个更好,我会说:递归表达思路更简洁,迭代在极端长链表下更稳定。实际工程里如果我要写链表工具方法,大概率会选择迭代方案,省去栈溢出的风险。
6.2 扩展方向:反转链表、K 个一组翻转
刷完这道题,最值得立刻做的扩展是两个:第 206 题“反转链表”和第 25 题“K 个一组翻转链表”。
反转链表是更基础的操作,核心思路是每遍历到一个节点,就把它的 next 改成前一个节点,需要一个 prev 变量保存前驱。两两交换链表本质上是“每次处理两个节点的组内反转”,把一组内两个节点的顺序反过来,但操作细节比单纯反转更复杂。
K 个一组翻转更是这道题的加强版:给定一个 k,按 k 个节点一组翻转链表,最后一组如果不足 k 个就不翻转。你可以把本题看成 k = 2 的特例。理解了“分组处理后递归处理剩余链表”的思路,再去做第 25 题会顺很多。这题的官方难度是 Hard,但如果你真正搞懂了第 24 题,它的核心一点都不神秘。
6.3 这类题在真实业务背景下还有什么用
有人会觉得链表在业务开发里几乎见不到,刷它纯粹是为了面试。这话有一定道理,但也不完全对。JavaScript 里很多数据结构的底层实现确实会用链式思想,比如队列、消息队列、撤销栈;浏览器里 DOM 节点的兄弟关系、React 的 fiber 链表、Vue 的更新队列等,都能看到链式结构的影子。哪怕你不直接写链表,理解对象引用传递之后,也能帮助你在调试复杂嵌套对象时少踩坑。
尤其是 React、Vue 这类框架源码里,大量使用“单链表 + 指针游走”的方式完成遍历和更新。你看源码时如果对 next 指向不敏感,很容易被绕晕。反过来,刷过链表题之后,再看人家的源码会对“为什么要保存前驱”“为什么要用多个指针”有更具体的体感。
我个人觉得,链表题练的不是背诵,而是建立一种“保护现场”的意识:修改引用之前,先判断哪些节点可能被覆盖丢失,哪些前驱需要保存,访问嵌套 next 之前先检查对方是不是 null。这种意识在很多需要操作复杂数据结构的代码里都用得上。
最后再分享一个小习惯,也是我到 Day 11 之后总结出来的:链表题写完,不要只在编辑器里跑一组 happy path。顺手测空链表、单节点、偶数长度、奇数长度这四组边界,再用自己写的数组转链表辅助函数多模拟几组随机数。这些边界在面试里特别容易被问到,多测几次之后,你对 next 的掌控会比死记题解扎实得多。
