如何两两交换链表中的节点?为什么不能只交换节点值?
简化版
两两交换链表节点通常用虚拟头节点 dummy 简化头节点变化,每轮处理 prev -> a -> b -> next,把它改成 prev -> b -> a -> next,再让 prev 前进到 a。面试里要强调这是交换节点连接关系,不是只交换节点值。
详细版
这题的核心是维护一段长度为 2 的局部链表。假设当前有 prev -> a -> b -> next,交换后应该变成 prev -> b -> a -> next。
常见写法:
- 新建
dummy指向head,避免头两个节点交换时单独处理。 - 每轮要求
prev.next和prev.next.next都存在。 - 记录
a = prev.next、b = a.next、next = b.next。 - 依次改指针:
prev.next = b、b.next = a、a.next = next。 - 交换完成后,
a已经变成这一组的尾节点,所以下一轮prev = a。
时间复杂度是 O(n),每个节点最多访问常数次;空间复杂度是 O(1)。如果面试官强调“交换节点”,不要只交换 val,因为真实节点可能携带额外引用、对象身份或外部引用。
完整版教学
一、这题真正考的是“局部重连”
两两交换看起来像一道简单模拟题,但它真正考的是你能不能把链表操作缩小到一个稳定局部。链表没有数组下标,不能直接说交换第 i 和第 i+1 个位置;你只能通过前驱节点把下一段摘下来再接回去。
最小局部可以写成:
交换前:prev -> a -> b -> next
交换后:prev -> b -> a -> next
这里 prev 很关键。只盯着 a 和 b 不够,因为交换后的新头是 b,必须让前一段链表指向 b。这也是为什么链表题经常要维护“前驱指针”,它不是为了多写一个变量,而是为了有能力修改当前片段的入口。
二、为什么 dummy 能让头节点也按同一套逻辑处理
如果链表是 1 -> 2 -> 3 -> 4,第一组交换后头节点会从 1 变成 2。没有 dummy 时,你需要单独保存新头,否则函数最后不知道返回谁。
dummy 的作用是在人造的头部增加一个永远存在的前驱:
dummy -> 1 -> 2 -> 3 -> 4
prev = dummy
这样第一组也能统一看成 prev -> a -> b -> next。交换后是 dummy -> 2 -> 1 -> 3 -> 4,最终返回 dummy.next 就行。它减少的不是时间复杂度,而是边界分支数量;分支少,链表断链和漏接的概率就低。
三、指针修改顺序为什么要先保存 next
链表重连最怕“改掉以后找不回后半段”。在 prev -> a -> b -> next 中,如果你先写 b.next = a,再想通过 b.next 找原来的 next,已经不可能了,因为 b.next 被覆盖成了 a。
安全顺序是先保存变量:
const a = prev.next;
const b = a.next;
const next = b.next;
prev.next = b;
b.next = a;
a.next = next;
prev = a;
数字例子:0(dummy) -> 1 -> 2 -> 3,如果忘记 next = 3,把 2.next = 1 后,节点 3 就可能从链上丢失。链表题里“先保存后修改”几乎是铁律,因为指针字段本身就是通往后续节点的道路。
四、循环条件为什么是两个节点都存在
两两交换要求当前组必须有 2 个节点。也就是说,prev.next 是第一个节点,prev.next.next 是第二个节点,两者都存在才能交换。
如果链表长度是 5:
1 -> 2 -> 3 -> 4 -> 5
交换后:2 -> 1 -> 4 -> 3 -> 5
最后的 5 没有配对节点,必须原样保留。循环条件写成 while (prev.next && prev.next.next) 就刚好表达这个语义。只判断 prev.next 会让奇数长度链表在最后一轮访问空指针;判断过度又可能漏掉长度正好为 2 的尾段。
五、为什么不建议只交换节点值
在刷题平台里,节点可能只有 val 和 next,交换值似乎也能得到一样的输出。但面试题如果明确说“交换节点”,通常希望你改的是链表结构。
对比如下:
| 做法 | 改动对象 | 适合场景 | 隐患 |
|---|---|---|---|
| 交换值 | 节点内容 | 节点只含简单值,题目允许 | 对象身份没有变化 |
| 交换节点 | next 指针 | 真实链表结构题 | 指针顺序要求更严格 |
| 重建链表 | 新建节点 | 允许额外空间时 | 空间 O(n),外部引用丢失 |
真实工程里,节点可能被别的结构引用,比如 LRU 的双向链表节点还挂在哈希表里。只交换值会让“哪个对象代表哪个缓存项”变得混乱,所以链表结构题要优先回答指针重连。
六、复杂度和可验证性怎么说
每一轮处理 2 个节点,做常数次指针赋值,所以总时间复杂度是 O(n)。除了 dummy 和几个指针变量,没有随输入增长的额外存储,所以空间复杂度是 O(1)。
可验证时可以覆盖 4 类输入:
空链表:null -> null
单节点:1 -> 1
偶数长度:1->2->3->4 -> 2->1->4->3
奇数长度:1->2->3->4->5 -> 2->1->4->3->5
记忆钩子:链表成对交换不是“交换两个值”,而是固定看
prev -> a -> b -> next,先保存next,再改成prev -> b -> a -> next。
七、常见误区与追问
- 误区:交换
val就等于交换节点。 输出值可能一样,但节点对象身份、外部引用和附加字段都没有交换,面试里通常不算结构层面的答案。 - 误区:第一组需要特殊处理。 用 dummy 后第一组也有前驱节点,可以和后续组使用完全相同的逻辑。
- 误区:改指针前不用保存
next。 一旦覆盖了b.next,后半段入口可能丢失,链表会断。 - 追问:为什么交换后
prev = a? 因为交换后a是这一组的尾节点,下一组应该从a.next开始。 - 追问:递归能做吗? 可以,递归写法把后续链表先两两交换,再让
b.next = a、a.next = swappedRest,但调用栈空间是 O(n)。
八、加强记忆
这题记住四个角色就稳了:prev 负责接入新组头,a 是组内第一个节点,b 是组内第二个节点,next 是后半段入口。每轮只做一件事:把 prev -> a -> b -> next 改成 prev -> b -> a -> next。只要返回 dummy.next,奇数尾巴自然留下,头节点变化也自然处理。