链表与虚拟头结点
链表最容易卡住的地方,通常不是“节点怎么定义”,而是指针到底应该站在哪里,以及修改前后谁还指向谁。虚拟头结点(dummy head)正是用来降低这种思维负担的技巧:它不存放业务数据,只负责让链表的边界变得统一。
配合阅读:链表与虚拟头结点交互式图解
1. 链表到底是什么
一个单链表节点包含两部分:数据 val 和指向下一个节点的指针 next。
type ListNode struct {
Val int
Next *ListNode
}假设有 1 → 2 → 3:
head
│
▼
[1 | next] → [2 | next] → [3 | nil]head 只保存第一个节点的地址。最后一个节点的 next == nil,这就是链表结束的标志。链表节点在内存中不要求连续,因此插入和删除不需要搬动后面的元素;代价是不能像数组那样通过下标 O(1) 访问第 k 个元素。
| 操作 | 数组 | 单链表 |
|---|---|---|
| 按下标访问 | O(1) | O(n) |
| 头部插入/删除 | O(n) | O(1) |
| 已知前驱后的插入/删除 | O(n) | O(1) |
| 搜索一个值 | O(n) | O(n) |
这里的“链表插入是 O(1)”有一个前提:你已经拿到了插入位置的前驱节点。如果还要先走到那个位置,寻找过程仍然是 O(n)。
2. 为什么头结点最容易写错
普通链表的第一个节点同时承担了两种角色:它是一个真实数据节点,也是整条链的入口。删除第一个节点时,入口本身要改变:
head = head.Next而删除中间节点只需要改前驱:
prev.Next = prev.Next.Next于是同一个“删除节点”问题出现了两套代码,还必须额外处理空链表、只有一个节点、删除头节点等边界情况。很多 bug 都来自这里:循环逻辑本身是对的,却忘了更新 head。
3. 虚拟头结点:一个不属于数据的哨兵
虚拟头结点是一个额外创建的节点,放在真实头结点之前:
dummy → 1 → 2 → 3 → nil它的值没有意义,重要的是它始终存在。真实链表的头结点变成 dummy.Next,而所有真实节点都有一个前驱:
dummy := &ListNode{Next: head}现在删除第一个真实节点,不需要特殊处理:dummy 就是它的前驱。
dummy.Next = dummy.Next.Next
head = dummy.Next可以把它理解成在链表入口前修了一小段“缓冲路”。它不改变数据,只让“第一个节点”和“中间节点”拥有相同的结构。
4. 最重要的操作模板
4.1 删除指定值的节点
func removeElements(head *ListNode, target int) *ListNode {
dummy := &ListNode{Next: head}
cur := dummy
for cur.Next != nil {
if cur.Next.Val == target {
cur.Next = cur.Next.Next
} else {
cur = cur.Next
}
}
return dummy.Next
}注意循环条件是 cur.Next != nil,因为我们要检查并删除的是 cur.Next,而不是 cur。删除后不要移动 cur:新的 cur.Next 还没有检查,连续出现 2 → 2 时才能一次删干净。
4.2 头部插入
node := &ListNode{Val: 0, Next: dummy.Next}
dummy.Next = node先让新节点接上旧链,再让 dummy.Next 指向新节点。改变指针时,通常遵循“先保存/连接后继,再改入口”的顺序,避免把链断掉。
4.3 反转链表
反转不依赖虚拟头结点,但最能体现指针操作的核心:每一步都要先保存 cur.Next。
func reverseList(head *ListNode) *ListNode {
var prev *ListNode
cur := head
for cur != nil {
next := cur.Next // 保存还未处理的部分
cur.Next = prev // 箭头反向
prev = cur
cur = next
}
return prev
}循环不变量是:prev 指向已经反转好的前缀,cur 指向尚未处理的部分。每轮只完成一个节点的归属变化。
4.4 合并两个有序链表
这里虚拟头结点尤其自然:新链表的第一个节点也由同一套“把节点接到尾部”的逻辑完成。
func mergeTwoLists(a, b *ListNode) *ListNode {
dummy := &ListNode{}
tail := dummy
for a != nil && b != nil {
if a.Val <= b.Val {
tail.Next, a = a, a.Next
} else {
tail.Next, b = b, b.Next
}
tail = tail.Next
}
if a != nil {
tail.Next = a
} else {
tail.Next = b
}
return dummy.Next
}5. 什么时候应该使用虚拟头结点
看到下面任意一种信号,就可以优先考虑它:
- 需要删除、插入、重排,且操作可能发生在第一个真实节点处;
- 循环中要频繁写
prev.Next,但prev可能是“头结点的前一个位置”; - 合并多个链表,需要不断维护结果链表的尾指针;
- 代码里出现大量
if head == nil、if head.Val == target之类的头部特判。
它不是必须的。只读遍历、反转链表、快慢指针找中点等场景,用普通 head 更直观。虚拟头结点的目标也不是让复杂度变低,而是把边界条件纳入统一的结构。
6. 高频题型背后的统一思路
找倒数第 k 个节点
让 fast 先走 k 步,再让 fast、slow 同步走。两者保持固定距离,fast 到尾部时 slow 就在目标位置。
判断环
快指针一次两步,慢指针一次一步。若相遇则有环;若 fast 或 fast.Next 为空则无环。判断环入口时,相遇后把一个指针放回头部,两者每次一步再次相遇的位置就是入口。
删除倒数第 k 个节点
这正是虚拟头结点的典型题:让 fast 比 slow 先走 k + 1 步,最终 slow 停在待删节点的前驱,直接执行 slow.Next = slow.Next.Next,删除头节点也无需特判。
两两交换节点
把 prev 放在每一对节点之前。交换 a、b 时只改三条边:
prev → a → b → next
prev → b → a → nextdummy 负责第一对节点之前的 prev,之后每次把 prev 移到交换完成的 a。
7. 调试链表的三个习惯
- 每次改指针前画图:至少画出
prev、cur、next和目标节点。 - 检查入口和终点:返回值通常是
dummy.Next或新的头指针;尾节点最终必须指向nil。 - 用小样例覆盖边界:空链表、单节点、删除头部、连续重复值、
k等于长度、k大于长度。
如果链表“遍历不结束”,优先检查是否误把某个 Next 指回了已经访问过的节点;如果节点“凭空消失”,优先检查是否在保存后继节点之前覆盖了 Next。
8. 一句话记忆
链表题的本质是维护连接关系;虚拟头结点只是给第一个真实节点补上一个稳定的前驱。写题时先确定“我需要站在待操作节点,还是它的前驱”,再按照“保存后继、修改连接、移动指针”的顺序执行,绝大多数边界 bug 都会消失。