哨兵节点与快慢指针:轻松破解链表算法题🔗
在链表相关的算法题中,边界条件处理往往是最让人头疼的 —— 比如删除头节点、链表为空、环的判断等。今天就来聊聊两个 “神器”:哨兵节点(dummy node)和快慢指针,它们能帮我们优雅地解决这些问题,让链表操作变得简单起来!
一、删除链表节点:传统做法的 “小麻烦”📌
先从最基础的 “删除链表中值为 val 的节点” 说起。传统做法通常需要分两种情况处理:
- 若要删除的是头节点,直接返回
head.next即可; - 若要删除的是中间节点,需要遍历链表找到目标节点的前驱,再修改指针跳过目标节点。
来看一段传统做法的代码:
javascript
function remove(head, val) { // 单独处理头节点 if(head && head.val === val) { return head.next; } let cur = head; while(cur.next) { // 遍历寻找目标节点的前驱 if(cur.next.val === val) { cur.next = cur.next.next; // 删除节点 break; } cur = cur.next; } return head; }代码解释:
- 先判断头节点是否为目标节点,若是则直接返回下一个节点;
- 用
cur指针遍历链表,通过cur.next判断是否为目标节点,找到后修改指针完成删除。
缺点:需要单独处理头节点的情况,逻辑不够统一。如果链表为空(head是null),还可能出现空指针异常。这时候,“哨兵节点” 就要登场了!
二、哨兵节点(dummy):简化边界的 “万能钥匙”🔑
哨兵节点是一个不存储真实数据的假节点,通常放在链表的最前面(偶尔也在尾部)。它的核心作用是:消除头节点的特殊性,让所有节点的操作逻辑保持一致。
用哨兵节点优化删除操作
来看优化后的代码:
javascript
function remove(head, val) { 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; // 哨兵节点的next就是新的头节点 }代码解释:
- 创建
dummy哨兵节点,让它的next指向原链表的头节点,此时原头节点和其他节点一样,都有了前驱(dummy); - 用
cur从dummy开始遍历,通过cur.next判断目标节点,找到后直接修改cur.next即可完成删除,无论目标节点是头节点还是中间节点,逻辑完全一致; - 最后返回
dummy.next,避免了头节点被删除后返回null的处理难题。
有了哨兵节点,再也不用为 “头节点是否存在”“是否删除头节点” 这些问题纠结了,是不是很方便?😉
三、LeetCode 206. 反转链表:哨兵节点 + 头插法💫
反转链表是经典问题,用 “哨兵节点 + 头插法” 可以高效解决。头插法的核心是:把原链表的节点依次 “摘” 下来,插入到新链表的头部,而哨兵节点可以作为新链表的 “锚点”。
来看代码:
javascript
function reverseList(head) { const dummy = new ListNode(0); // 哨兵节点,其next始终指向已反转部分的头 let cur = head; // 当前要处理的节点 while(cur) { const next = cur.next; // 1. 保存下一个节点,避免丢失 cur.next = dummy.next; // 2. 让当前节点指向已反转的头 dummy.next = cur; // 3. 当前节点成为新的反转头 cur = next; // 处理下一个节点 } return dummy.next; // 反转后的新头节点 }代码解释:
dummy哨兵节点初始时next为null(因为刚开始反转部分为空);遍历原链表的每个节点
cur,分三步操作:- 先用
next保存cur的下一个节点(否则后续步骤会覆盖cur.next,导致原链表丢失); - 让
cur的next指向dummy.next(即已反转部分的头节点,第一次循环时指向null); - 让
dummy.next指向cur,此时cur成为新的反转头;
- 先用
循环结束后,
dummy.next就是反转后链表的头节点。
通过哨兵节点,我们不用单独处理 “第一个节点反转后指向 null” 的边界情况,逻辑清晰又简洁!
四、快慢指针:解决链表 “距离” 与 “环” 的利器🏃
快慢指针是另一种处理链表问题的经典技巧:两个指针往同一个方向移动,快指针速度比慢指针快(通常快 1 倍)。它能解决诸如 “环的判断”“倒数第 N 个节点” 等涉及 “距离” 或 “循环” 的问题。
LeetCode 141. 环形链表:用快慢指针找环
判断链表是否有环,用快慢指针再合适不过了 —— 就像跑步时,快的人总会追上慢的人(如果赛道是环形的话)。
来看代码:
javascript
function hasCycle(head) { let slow = head; let fast = head; while(fast && fast.next) { // 快指针先到尾部(无环) slow = slow.next; // 慢指针走1步 fast = fast.next.next; // 快指针走2步 if(slow === fast) { // 相遇则有环 return true; } } return false; // 快指针到尾部,无环 }代码解释:
初始时
slow和fast都指向头节点;循环中,
slow每次走 1 步,fast每次走 2 步:- 若链表无环,
fast会先到达末尾(fast或fast.next为null),循环结束,返回false; - 若链表有环,
fast会在环内循环,最终追上slow(因为速度快),此时slow === fast,返回true。
- 若链表无环,
这个逻辑就像 “套圈”—— 只要赛道是环形,快的人一定能追上慢的人,是不是很形象?😎
五、哨兵节点 + 快慢指针:综合运用的威力💥
LeetCode 141. 环形链表:用快慢指针找环
有些问题需要两种技巧结合,比如 “删除链表的倒数第 N 个节点”(LeetCode LCR 021)。既要找到倒数第 N 个节点,又要处理删除头节点的边界情况,这时候 “哨兵 + 快慢” 就是绝配!
来看代码:
javascript
const removeNthFromEnd = function (head, n) { const dummy = new ListNode(0); dummy.next = head; let fast = dummy; let slow = dummy; // 快指针先移动N步 for (let i = 0; i < n; i++) { fast = fast.next; } // 快慢指针一起移动,直到快指针到末尾 while (fast.next) { fast = fast.next; slow = slow.next; } // 慢指针指向倒数第N个节点的前驱,直接删除 slow.next = slow.next.next; return dummy.next; }代码解释:
哨兵节点
dummy的作用:避免删除头节点时的特殊处理(比如链表只有 1 个节点,删除后返回null),同时确保slow.next.next一定有效(不会出现空指针)。快慢指针的配合:
- 快指针先移动 N 步,此时快慢指针之间相差 N 个节点;
- 两者同时移动,当快指针到达链表末尾(
fast.next为null)时,慢指针恰好指向 “倒数第 N 个节点的前驱”; - 直接修改
slow.next即可删除目标节点。
通过两者结合,既高效找到目标节点(只需遍历一次链表),又完美处理了边界情况,简直是 “1+1>2” 的效果!
六、面试官可能会问这些问题🤔
- 哨兵节点的核心作用是什么?
答:消除头节点的特殊性,让所有节点的操作逻辑统一,简化边界条件(如空链表、删除头节点等)。
- 反转链表时,为什么要用头插法 + 哨兵节点?
答:头插法能高效反转(时间复杂度 O (n)),哨兵节点作为 “锚点”,避免处理 “第一个节点反转后指向 null” 的特殊逻辑。
3.环形链表中,快慢指针为什么一定会相遇?
答:若有环,快指针速度是慢指针的 2 倍,相对速度为 1 步 / 次,最终会追上慢指针(类似套圈)。
- 删除倒数第 N 个节点时,快慢指针的距离为什么是 N?
答:快指针先移 N 步,两者同步移动后,快指针到末尾时,慢指针刚好在目标节点的前驱(距离末尾 N 个节点)。
七、结语🌟
链表问题的难点往往在于边界条件,而哨兵节点和快慢指针就像两把 “钥匙”,能帮我们打开这些难题的大门:
- 哨兵节点让 “头节点” 不再特殊,统一操作逻辑;
- 快慢指针轻松解决 “环” 和 “距离” 相关问题,降低时间复杂度。
掌握这两个技巧后,再遇到链表题时,不妨先想想:“能不能用哨兵节点简化边界?”“快慢指针能帮我找到目标位置吗?” 多加练习,就能让链表操作变得像 “搭积木” 一样简单啦!🚀