1. 题目理解与核心难点:为什么这道题适合卡在第11天
这是 Leetcode 24 题“两两交换链表中的节点”,也是我刷题打卡第 11 天遇到的题。说实话,Leetcode 前二十题里,链表相关题目看着简单,但实际动手写的时候特别容易翻车。这道题的标签是链表、递归、指针操作,用 JavaScript 实现时,难点反而不是语法,而是你能不能想清楚“当前节点到底是谁在指向谁”。很多朋友一开始会把交换两个节点的值理解成数组里的两两交换,直接改 val,这是走偏了。链表的结构性操作练的就是“改指针”,而不是“改值”,题目里真正想考察的也是前者。
这道题适合刚学会链表遍历、想进一步理解链表引用关系的人,也非常适合作为“指针操作入门题”来刷。它不涉及复杂算法,但是把链表里最核心的“重定向顺序”和“前置节点保存”这两个思想全包含了。如果能不看题解,独立写对迭代和递归两个版本,那说明你链表基础基本过关了。
另外,用 JavaScript 刷链表有个隐性门槛:我们平时写数组写习惯了,总觉得“对象赋值就是复制一份”,但链表节点本质是对象,变量存的是引用。操作链表就是操作一堆相互嵌套的 JS 对象,改 next 时如果顺序不对,几步之后整条链就会断掉或成环,而且控制台还不一定报错。所以借这道题,我正好把“JS 里对象引用到底怎么影响链表操作”这个底层问题彻底讲透。
1.1 题目到底在要求什么
给一个链表,比如 1 -> 2 -> 3 -> 4,要求每两个相邻节点交换位置,最终变成 2 -> 1 -> 4 -> 3。如果链表长度是奇数,最后一个节点保持不动,比如 1 -> 2 -> 3 变成 2 -> 1 -> 3。
注意几个边界条件:空链表返回空;只有一个节点,直接返回它自己;只有两个节点,交换后返回新的头节点。这些边界条件在 LeetCode 的测试用例中一定会覆盖,跑挂大多也是挂在这些地方。
题目要求“不能只是修改节点内部的值,而是要真正交换节点”。也就是说,不要写出下面这种取巧代码:
js复制var swapPairs = function(head) {
let cur = head;
while (cur && cur.next) {
[cur.val, cur.next.val] = [cur.next.val, cur.val];
cur = cur.next.next;
}
return head;
};
这段代码在 LeetCode 上其实能通过,因为判题机只看最终链表的值。但假如面试官追问“如果节点内部还有别的字段,你这么换会有什么问题?”你会发现这样写没有真正改变节点之间的链接关系。一般刷题训练不推荐这么做,因为后续做“反转链表 II”“K 个一组翻转链表”这类题目时,依赖的恰恰是改指针的思路,而不是投机取巧改值。
1.2 LeetCode 24 的核心考点与高频场景
这道题虽然难度是中等偏简单,但它是很多公司面试题的前置热身题。高频场景一般有几种:一是直接考原题,让你写递归;二是考“两两交换”的升级版,比如 K 个一组翻转链表;三是在系统设计面试中引申到“链表中相邻节点交换背后的引用传递问题”,考察你对 JS 对象引用的理解。
我自己面试别人的时候,也喜欢让候选人先写这道题。原因很简单:它可以快速区分三种人。
- 第一种人,连题目意思都要理解半天,说明链表基础比较薄弱;
- 第二种人,能写出来,但上来就改 val,说明没有理解链表的本质;
- 第三种人,能写出结构修改的正确解法,并且能把递归版本写清楚,说明对指针和函数调用栈有比较好的感觉。
所以别小看这题。把 Leetcode 24 吃透,后面做反转链表、删除倒数第 N 个节点,会顺利很多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 迭代解法:虚拟头节点把“指针游戏”理顺
迭代解法比较符合人脑的思考方式:从头到尾,每次处理两个节点,交换完往前移动。但是第一次写的人经常会遇到一个问题:交换完前两个节点后,头节点变了,你怎么把它返回出去?如果直接 return head,返回的还是原来的头节点,而原来的头节点已经被交换到第二位了。这时候就需要虚拟头节点出场。
2.1 虚拟头节点到底解决了什么问题
虚拟头节点(dummy node)是一个额外创建的空节点,它的 next 指向真正的头节点 head。不管链表内部怎么交换,dummy.next 始终指向整条链表最新的头节点,最后直接返回 dummy.next 就行。
很多新手不理解为什么要多创建一个节点,觉得多此一举。实际上它的核心作用有两个:
- 统一处理“头节点被修改”的情况,让代码循环部分不用为“头节点没有前驱”单独写分支;
- 配合一个 prev 游标,记录当前处理到的位置的前一个节点,保证后续操作不会丢失前面的链。
这有点像是在队伍前面站了一个“哨兵”,真正的队伍怎么变化,你只要看哨兵指向谁,就知道队首是谁。
以 1 -> 2 -> 3 -> 4 -> null 为例:
js复制dummy -> 1 -> 2 -> 3 -> 4 -> null
prev = dummy
我们要交换 1 和 2,先让两个指针从 prev 出发:
js复制let first = prev.next; // first = 节点1
let second = prev.next.next; // second = 节点2
接下来要想清楚:节点 1 的 next 要指向节点 3,节点 2 的 next 要指向节点 1,dummy 的 next 要指向节点 2。这三个步骤缺一不可,顺序也有讲究。
2.2 核心的三次重定向,顺序千万别搞错
第一次:让 first.next 指向 second.next。也就是先保存好第二组交换的起点,别让后续操作把 3 弄丢。
js复制first.next = second.next; // 1.next = 3
第二次:让 second.next 指向 first。此时 2 的下一个节点变成了 1。
js复制second.next = first; // 2.next = 1
第三次:让 prev.next 指向 second,也就是把新交换好的节点接回主链。
js复制prev.next = second; // dummy.next = 2
这里三次的执行顺序并不是唯一的,但要保证“不丢节点”和“不成环”。最稳妥的顺序是:第一步把即将断开的后半段链条先接好,第二步完成两个节点的内部反向,第三步接到 prev 上。如果你先执行第二、三步,再去执行第一步,很可能需要额外的临时变量保存 1 或 3 的引用,代码会变得拧巴。
交换完成后,链表变成了:
js复制dummy -> 2 -> 1 -> 3 -> 4 -> null
接下来 prev 要移动到哪里?不少新手会写成 prev = prev.next,这样会移动到节点 2。可下一次交换应该从 3 和 4 开始,显然 prev 应该指向节点 1,也就是当前这一对的第二个节点。这样下一轮里,prev.next 才是 3。
js复制prev = first; // first 就是交换前的节点1,现在位置在第二位
然后进入下一轮循环,直到链表中剩余节点少于 2 个。
2.3 迭代版 JavaScript 实现与复杂度分析
完整代码如下:
js复制var swapPairs = function(head) {
// 创建虚拟头节点
const dummy = new ListNode(0);
dummy.next = head;
let prev = dummy;
while (prev.next !== null && prev.next.next !== null) {
const first = prev.next;
const second = prev.next.next;
// 第一步:first 指向 second 后面的节点
first.next = second.next;
// 第二步:second 反指 first
second.next = first;
// 第三步:prev 指向新的小组头节点
prev.next = second;
// 移动 prev 到本次交换后的第二个节点
prev = first;
}
return dummy.next;
};
几个容易忽略的细节:
- 循环条件是
prev.next !== null && prev.next.next !== null,顺序不能反。prev.next为 null 时,再读prev.next.next会直接抛 TypeError。 ListNode不需要自己定义,LeetCode 的判题环境已经内置了。如果你在本地 Node.js 环境跑,需要自己声明class ListNode { constructor(val, next) { this.val = val; this.next = next ?? null; } }。- 链表操作中用
const声明 first 和 second 没问题,因为我们不会重新给这两个变量赋值,只改它们的属性。
时间复杂度是 O(n),因为每个节点只遍历一次。空间复杂度是 O(1),只用了几个指针变量,没有额外数组或递归栈。这版代码在 LeetCode 上运行耗时通常在 60ms 左右,击败率也还不错。
我自己第一次写这题时,卡在最容易出错的地方是:赋值完 prev.next = second 后,没有立刻把 prev 挪到 first,而是挪到了 second,结果第二轮循环直接处理了 2 和 3 而不是 3 和 4,链表输出成了 2 -> 3 -> 4 -> 1。调试十分钟后才意识到,prev 应该指向每一组交换后的“队尾”,而不是“队首”。
3. 递归解法:思路清奇但代码极短的写法
如果说迭代靠的是“流程控制”,那递归靠的是“相信子问题已经解决”。递归版本的代码短到让人怀疑人生,但理解起来比迭代难一点。核心思想是:每次只交换链表头部的前两个节点,剩下的部分交给递归函数处理。
3.1 递归子问题的拆分方式
假设递归函数 swapPairs(head) 的定义是“将以 head 为头节点的链表,两两交换后返回新头节点”。
对于 1 -> 2 -> 3 -> 4 -> null,我们想做的是:
- 把 1 和 2 交换;
- 3 -> 4 这后半段已经由
swapPairs(3)处理好,返回新头 4; - 最后把交换好的 1 接到后半段的头部 4 上。
写成文字就是:
js复制next = head.next; // next = 节点2
head.next = swapPairs(next.next); // 处理 3 -> 4,得到新头 4,然后让 1.next = 4
next.next = head; // 2.next = 1
return next; // 返回新头 2
这里面的关键点在于:swapPairs(next.next) 的返回值是整个后半段处理完后的新头,我们不能想当然认为它一定是 3 或者 4,而是应该把它当作“已经交换好的后半段链表头”。
有读者会问:为什么这里必须先调用 swapPairs(next.next),再让 next.next = head,换个顺序行不行?在递归版本里,因为我们要让 head.next 指向递归处理的结果,这个结果需要先算出来,不然 head.next 就不知道接谁。所以从逻辑上天然要求先递归,再改内部指向。
3.2 递归完整代码与链路演示
完整代码:
js复制var swapPairs = function(head) {
// 递归终止条件:空链表或只有一个节点
if (head === null || head.next === null) {
return head;
}
const next = head.next;
// 递归处理剩余部分
head.next = swapPairs(next.next);
// 当前两个节点交换
next.next = head;
return next;
};
这段代码初看有点魔幻,我建议你一步步推演一遍,尤其是 1 -> 2 -> 3 -> 4 的情况:
- 调用
swapPairs(1),head = 1,next = 2; - 执行
swapPairs(2.next),也就是swapPairs(3); - 调用
swapPairs(3),head = 3,next = 4; - 执行
swapPairs(4.next),也就是swapPairs(null),返回 null; - 回到第 3 层:
head.next = null(即 3.next = null),然后next.next = head(即 4.next = 3),返回 4; - 回到第 2 层:
head.next = swapPairs(4.next 的结果),这里 head 是 1,所以 1.next = 4;然后next.next = head,即 2.next = 1,返回 2。
最终结果:2 -> 1 -> 4 -> 3 -> null。
有没有发现一个非常神奇的点:在第 4 步,节点 3 原本指向 4,但我们先把 3.next 改成 null,然后才让 4.next 指向 3。为什么不担心 3 丢了呢?因为 head 和 next 两个变量仍然分别引用着 3 和 4,JS 引用类型不会被回收,所以安全。这也是递归递归里最核心的底气:只要变量还握着引用,哪怕断链了也能再接回去。
3.3 迭代和递归到底怎么选
递归版代码短,思考起来更像“分治”,但有两个不容忽视的问题:
- 空间复杂度是 O(n),因为递归深度等于链表长度的一半左右,极端情况链表很长时可能栈溢出;
- 对 JS 新手来说,递归调用栈不直观,一旦出错,调试难度比迭代大得多。
迭代版虽然代码啰嗦,但空间复杂度是 O(1),而且每一步都明确得像是手工操作。实际工程中我几乎不会用递归去处理链表,主要怕栈溢出,但 LeetCode 面试场景下,通常两种解法都会被接受,甚至会要求你两版都写一遍。
我的建议是:必须掌握迭代解法,递归解法至少能做到“看着代码能讲清逻辑”。面试时先给迭代、再补充递归,会显得你基础扎实。
4. 实际调试的坑与排查技巧
刷链表题最怕的不是没思路,而是感觉思路对了,提交却报错。LeetCode 的英文报错信息很考验人,中文环境还好,但有些问题不是报错,就是结果不对。这一节我把常见的坑按出现频率整理出来,并结合 JavaScript 的调试习惯给一些排查思路。
4.1 三类很容易犯的错误
第一类:返回值搞错。很多人没有用 dummy,迭代完直接返回 head。前面说过,交换后原来的 head 已经不是新链表的头了,所以返回的一定是 dummy.next。同理,递归版本里返回的是新的头节点 next,不是原来的 head。
第二类:节点丢失或产生环。比如交换 1 -> 2 -> 3 时,如果先执行 prev.next = second,又执行 second.next = first,但是忘记了 first.next = second.next,那结果就是 1 的后继节点没有更新,整个链表可能在第三轮陷入环。链表不像数组,你不给某个节点指明 next,它并不会自动失效,而是像一个断线的风筝一样和你再无关系。
第三类:移动 prev 的时机错误。不少人在交换完成后直接 prev = prev.next,这是错的。交换完成后,prev.next 指向的是新头节点(比如 2),但下一轮处理应该从 3 开始,prev 应该指向当前这一对的第二个节点。正确写法是用提前保存好的 first。这里你可以理解为:prev 永远是“已处理链表的尾部”。
下面这张表可以作为自查清单:
| 错误现象 | 可能原因 | 修改方式 |
|---|---|---|
| 输出还是原链表 | 只改了 val 或没接入 dummy | 走结构交换,返回值用 dummy.next |
| 结果少了后半段 | first.next 没接 second.next | 三连改中第一步绝不能省 |
| 结果成了环 | 指针反向顺序写错 | 按 first.next -> second.next -> prev.next 次序改 |
| 循环报空指针 | while 条件顺序写反 | 先判 prev.next 再判 prev.next.next |
| 递归栈溢出 | 递归终止条件漏了 head.next 判断 | 终止条件写完整 |
4.2 本地环境如何调试链表代码
LeetCode 自带的调试器在界面上能看变量,但用起来很别扭。我更推荐在本地 VS Code 里跑,关键是写好两个辅助函数:数组转链表、链表转数组。
js复制class ListNode {
constructor(val, next) {
this.val = val;
this.next = next ?? null;
}
}
function arrToList(arr) {
const dummy = new ListNode(0);
let cur = dummy;
for (const v of arr) {
cur.next = new ListNode(v);
cur = cur.next;
}
return dummy.next;
}
function listToArr(head) {
const res = [];
let cur = head;
while (cur) {
// 防止链表成环导致死循环的简单哨兵
if (res.length > 20) {
throw new Error('检测到环或链表过长');
}
res.push(cur.val);
cur = cur.next;
}
return res;
}
function swapPairs(head) {
// 具体实现
}
console.log(listToArr(swapPairs(arrToList([1, 2, 3, 4])))); // [2,1,4,3]
console.log(listToArr(swapPairs(arrToList([1, 2, 3])))); // [2,1,3]
console.log(listToArr(swapPairs(arrToList([])))); // []
console.log(listToArr(swapPairs(arrToList([1])))); // [1]
这里我只展示思路,真实调试时字段名和函数结构可以按 LeetCode 环境对齐。尤其建议把 listToArr 封装好,因为刷链表的绝大多数题都要用它验证输出,很实用。
一个挺重要的经验:在 JS 里调试链表时,最好在关键节点变化处打印一句话,比如 console.log('当前交换:', cur && cur.val, '下一节点:', cur && cur.next && cur.next.val)。这样你就能跟着循环一步步看你有没有漏接节点。
4.3 用多个测试用例做边界验证
空链表、单节点、双节点、奇数长度、偶数长度,这几类用例每组都要跑。很多同学只测了标准用例 1 -> 2 -> 3 -> 4 就提交,结果挂在长度为 1 的无意义 case 上,非常冤枉。
做边界验证时,还可以故意构造两个极端链表:一个是长链表(比如 1000 个节点),确认不会栈溢出;另一个是全相同值的链表,比如 5 -> 5 -> 5 -> 5,确认交换后顺序确实变化了而不是被误判为没变化。因为值都一样时,如果只靠肉眼检查输出数组,你会很难发现到底有没有交换,这个细节很实用。
5. 同题型的延伸:链表操作中值得掌握的模板套路
Leetcode 24 不止是题本身,它背后的套路能迁移到一堆题上。我把适配它的几个做题方法总结一下,作为之后刷题的模板。
5.1 链表题里的三种高频套路
第一种是虚拟头节点。凡是涉及头节点可能变化的题,几乎都可以无脑加一个 dummy,比如删除链表的倒数第 N 个节点、反转链表 II、合并两个有序链表,都会用到。加了 dummy 之后,你不必为头节点单独写 if 分支,代码的循环结构更统一。
第二种是保存前驱指针。每隔 k 个节点处理一组、反转局部链表这类题,都需要一个 prev 指针来标记已经处理完的链尾。Leetcode 24 里那个 prev 的移动逻辑,其实就是最基础的演示。
第三种是指针重连的“三步式”。先让第一个节点指向下一组节点,之后让第二个节点反向指回去,最后让 prev 指向新的头。之后的很多复杂题,本质上都是一组一组地重复这三个步骤。我把它们提炼为:先接后段、再转内部、最后接前驱。
经常有同学问我,链表遍历和数组遍历区别是什么?其实最大的区别是:数组遍历的索引独立于数据本身,你是通过下标去访问数据的;而链表里没有下标,你得靠一个变量不断移动到 next。Leetcode 24 这种题目逼着你必须用“当前节点”和“前驱节点”这两个视角去观察,这对理解链表非常重要。
5.2 推荐练习路径与面试表述建议
如果你刚做完这道题,接下来非常推荐按顺序刷以下几道:
| LeetCode 题号 | 题目 | 和本题的关系 |
|---|---|---|
| 206 | 反转链表 | 巩固迭代中 prev 指针的维护 |
| 92 | 反转链表 II | 需要在局部范围内维护多个指针 |
| 19 | 删除链表的倒数第 N 个节点 | 虚拟头节点 + 快慢指针综合运用 |
| 25 | K 个一组翻转链表 | Leetcode 24 的超级升级版 |
| 148 | 排序链表 | 链表遍历、合并、归并综合题 |
面试时如果被问到这道题,除了写出代码,最好能说清楚“为什么用 dummy”。一个不错的表述是:因为每次交换都可能改变头部,引入一个指向头部的虚拟节点后,head 变更不会再影响返回逻辑,也让循环的边界统一了。然后再提一句,递归版本可以把问题拆成当前节点和下一节点的交换加上子问题递归,但空间会到 O(n)。这样说,基本就把题目考察点覆盖全了。
自己在 LeetCode 上提交时,JavaScript 版本不需要引入额外依赖,直接粘贴函数即可。如果你想验证箭头函数写法的差异,可以试一下把递归版本改成函数表达式 const swapPairs = (head) => {...} 也能跑。不过箭头函数没有自己的 arguments,如果依赖这个的话要注意,这里完全不需要,所以没差。
有一个我踩过的小坑和 JS 函数声明提升有关:如果你用 function swapPairs(head) {...} 和 const swapPairs = function(head) {...} 两种方式定义,后面的定义会覆盖前面的,这在只有一个函数时没事,但你在本地调试多个版本时,容易因为重复命名而测到旧代码。建议不同解法用不同文件名隔开,别放在同一个文件里运行。
为了加深理解,你还可以自己给节点加一个 id 字段,然后打印链表的节点 id 顺序,而不是 val 顺序。因为如果链表里重复值很多,用 val 验证可能看不出交换前后到底变没变,但用唯一 id 就一定能看清结构是否真的改变。这么做虽然对解这题没用,但对理解引用关系帮助很大。
最后聊一点我自己的实操体会。以前我刷题有个毛病:一个解法跑通了就赶紧进入下一题,结果过两周再遇到链表题还是卡。后来我发现,链表题的解题手感非常依赖“对指针变化的图像记忆”,光看文字没用。Leetcode 24 这道题我写了至少三遍:第一遍看题解抄,第二天默写,下一周又强迫自己用递归重新做一遍。直到有一天能闭着眼睛画出 dummy、prev、first、second 四个节点的连线变化,才算真正会了。
所以如果你现在做完这题,但隔几天又忘了,不用焦虑,这很正常。拿一张纸,把 1 -> 2 -> 3 -> 4 画出来,把迭代解法三连改的每一步都在纸上画出箭头变化,再对照代码验证一遍。画三遍以后,你对链表的恐惧会明显减轻。这也是为什么我说这题值得在第 11 天、第 21 天、第 31 天都回头刷一遍,它真的能帮你把链表这块地基打牢。
