链表作为基础数据结构,因其动态性和指针特性,在算法题中频繁出现。然而,处理链表时常常面临边界问题——尤其是头节点的删除或插入,容易导致代码冗余或逻辑混乱。幸运的是,通过引入哨兵节点(Dummy Node) 和 快慢指针 等技巧,我们可以将复杂操作统一化、简洁化,大幅提升代码的健壮性与可读性。
哨兵节点:消除边界特例
在删除链表中指定值的节点时,若目标恰好是头节点,常规写法需单独判断:
if (head && head.val === val) {
return head.next;
}
这种“特殊处理”不仅增加分支,还容易遗漏。而使用哨兵节点,可将头节点“降级”为普通节点:
const dummy = new ListNode(0);
dummy.next = head;
let cur = dummy;
while (cur.next) {
if (cur.next.val === val) {
cur.next = cur.next.next;
break;
}
cur = cur.next;
}
return dummy.next;
这里,dummy 是一个不存储真实数据的假节点,其 next 指向原链表头。由于所有真实节点都有前驱(即使是原头节点),删除逻辑变得统一:只需找到目标节点的前一个节点,修改其 next 指针即可。最终返回 dummy.next,自然跳过哨兵,得到新链表头。
链表反转:头插法 + 哨兵
链表反转是另一经典问题。借助哨兵节点,可优雅实现头插法:
const dummy = new ListNode(0);
let cur = head;
while (cur) {
const next = cur.next;
cur.next = dummy.next; // 插入到已反转部分的头部
dummy.next = cur; // 更新新头
cur = next;
}
return dummy.next;
每轮循环中,当前节点 cur 被“拔出”,插入到 dummy 之后,成为新的反转头。哨兵在此充当了反转链表的“锚点”,避免了对原头节点的特殊处理。整个过程仅需一次遍历,时间复杂度 O(n),空间 O(1)。
快慢指针:探测环与定位倒数节点
快慢指针是解决链表距离类问题的利器。例如,判断链表是否有环:
let slow = head, fast = head.next;
while (slow !== fast) {
if (!fast || !fast.next) return false;
slow = slow.next;
fast = fast.next.next;
}
return true;
慢指针每次走一步,快指针走两步。若有环,快指针终将追上慢指针;若无环,快指针会先到达末尾。该方法无需额外空间,且效率高。
同样,删除倒数第 n 个节点也可用双指针:
const dummy = new ListNode(0);
dummy.next = head;
let fast = dummy, slow = dummy;
for (let i = 0; i < n; i++) fast = fast.next;
while (fast.next) {
fast = fast.next;
slow = slow.next;
}
slow.next = slow.next.next;
return dummy.next;
快指针先走 n 步,随后快慢同步前进。当快指针到达末尾时,慢指针恰好停在倒数第 n 个节点的前驱位置。此时执行删除,逻辑清晰且无需计算链表长度。
总结
哨兵节点与快慢指针,看似简单,却是处理链表问题的两大核心范式。前者通过“虚拟前置”统一操作逻辑,消除头节点的边界特例;后者利用指针速度差,在单次遍历中完成距离定位或环检测。这些技巧不仅适用于面试题,在实际工程中(如 LRU 缓存、图遍历等)也广泛存在。掌握它们,意味着你已具备将复杂链表操作转化为简洁、鲁棒代码的能力——这正是算法思维的精髓所在。