从面试官视角审视单链表:Dummy节点、快慢指针与经典操作

73 阅读5分钟

从面试官视角审视单链表:Dummy节点、快慢指针与经典操作

大家好,今天来讲讲在面试中经常考察的链表相关题目。为什么链表这么受欢迎?因为它能很好地考察候选人对指针操作的掌握、对边界条件的处理能力,以及对底层逻辑的理解。链表不像数组有随机访问,操作时稍有不慎就会出现空指针、死循环或内存泄漏,这些都是面试中容易暴露的问题。

今天,我们以几个经典题目为核心,深入剖析Dummy哨兵节点快慢指针以及链表反转等技巧。这些技巧几乎覆盖了80%的链表面试题。内容基于LeetCode高频题(如203、206、19、141等),结合实际代码和图解,帮助你彻底吃透链表。

一、Dummy哨兵节点:统一边界,优雅删除

在处理链表删除时,最头疼的就是头节点可能被删除的情况。如果没有特殊处理,代码会多出一堆if判断,非常丑陋。

Dummy节点(也叫哨兵节点)就是一个假节点,值随意(通常设为0或-1),它的next指向原head。这样,整个链表就都有了“前驱节点”,删除操作统一了。

经典题目:删除链表中所有等于val的节点(LeetCode 203)

原始代码容易出错的版本(不使用Dummy):

function remove(head, val) {
    // 特殊处理头节点
    while (head && head.val === val) {
        head = head.next;
    }
    let cur = head;
    while (cur && cur.next) {
        if (cur.next.val === val) {
            cur.next = cur.next.next;
        } else {
            cur = cur.next;
        }
    }
    return head;
}

你看,头节点需要单独处理,代码冗长。

使用Dummy后的优雅版本:

function remove(head, val) {
    const dummy = new ListNode(0);
    dummy.next = head;
    let cur = dummy;                    // 从dummy开始遍历
    while (cur.next) {
        if (cur.next.val === val) {
            cur.next = cur.next.next;   // 删除下一个节点
        } else {
            cur = cur.next;
        }
    }
    return dummy.next;                  // 新头节点
}

底层逻辑:Dummy相当于给原链表加了一个“保险前驱”。无论删除头节点还是中间节点,操作都是cur.next = cur.next.next,完美统一。

易错提醒

  • 忘记返回dummy.next,直接返回head会导致头节点丢失。
  • 在循环条件里用cur.next而非cur,防止cur已经是null时访问cur.next。
  • 如果链表可能为空,直接返回dummy.next就行,无需特殊判断。

面试扩展:如果要删除所有重复元素保留一个(LeetCode 83),或删除所有重复元素一个不留(LeetCode 82),同样可以用Dummy统一处理。

二、链表反转:头插法 + Dummy的完美结合

反转链表(LeetCode 206)是面试必考。常见有三种实现:三指针迭代、递归、头插法。

这里重点讲头插法 + Dummy,因为它最直观,也最容易扩展到局部反转(LeetCode 92)。

function reverseList(head) {
    const dummy = new ListNode(0);      // dummy作为新链表的“头”
    let cur = head;
    while (cur) {
        const next = cur.next;          // 1. 保存原链表下一个节点
        cur.next = dummy.next;          // 2. 当前节点指向已反转部分的头
        dummy.next = cur;               // 3. dummy.next成为新头(头插)
        cur = next;                     // 移动到原链表下一个
    }
    return dummy.next;
}

底层逻辑图解(想象链表1→2→3→4→5):

  • 初始:dummy → null
  • 处理1:cur=1 → dummy → 1 → null
  • 处理2:cur=2 → dummy → 2 → 1 → null
  • 处理3:cur=3 → dummy → 3 → 2 → 1 → null
  • ...
  • 最终:dummy → 5 → 4 → 3 → 2 → 1 → null,返回dummy.next即为反转后链表

这就是经典的“头插法”。每处理一个节点,就把它插到dummy后面,成为新的头节点。

易错提醒

  • 必须先保存cur.next,否则断了链就找不回来了。
  • 三步顺序不能乱:先改cur.next,再改dummy.next。
  • 递归实现虽然代码短,但栈深度O(n),长链表可能栈溢出,生产环境慎用。

面试扩展:局部反转(反转从第m到第n个节点),同样用Dummy找到m的前一个节点,然后在[m,n]区间用头插法反转即可。

三、快慢指针:解决“倒数”和“环”问题

快慢指针是链表中最强大的技巧之一。两个指针,一个快一个慢,共同出发。

1. 判断链表是否有环(LeetCode 141)
function hasCycle(head) {
    let slow = head;
    let fast = head;
    while (fast && fast.next) {         // 快指针能走就走两步
        slow = slow.next;
        fast = fast.next.next;
        if (slow === fast) return true; // 相遇即有环
    }
    return false;
}

底层逻辑:像操场跑圈,快指针每次两步,慢指针一步。如果有环,快指针必然会“追上”慢指针(在环内相遇)。如果无环,快指针先到末尾。

易错提醒

  • 判断条件是fast && fast.next,防止fast为null或fast.next为null时访问。
  • 相遇判断用===,比较的是节点地址,不是val值。
2. 删除链表的倒数第N个节点(LeetCode 19)

这题完美结合了Dummy + 快慢指针。

function removeNthFromEnd(head, n) {
    const dummy = new ListNode(0);
    dummy.next = head;
    let slow = dummy;
    let fast = dummy;
    
    // 快指针先走n+1步(因为要让slow停在待删节点的前一个)
    for (let i = 0; i <= n; i++) {
        fast = fast.next;
    }
    
    // 快慢一起走,直到fast为null
    while (fast) {
        slow = slow.next;
        fast = fast.next;
    }
    
    // slow.next就是要删的节点
    slow.next = slow.next.next;
    return dummy.next;
}

底层逻辑:快指针先领先n+1步,当快指针走到末尾(null)时,慢指针正好在倒数第n+1个节点,即待删节点的前驱。配合Dummy,即使删除头节点也无压力。

易错提醒

  • 快指针要先走n+1步(不是n步),否则slow会停在待删节点上。
  • n是合法的(题目保证),无需判断n>链表长度。

四、面试官视角:我为什么爱考链表?

  1. 指针功底:链表操作全是指针改来改去,一不小心就空指针或死循环。
  2. 边界处理:空链表、单节点、头尾节点删除……这些都是雷区。
  3. 代码规范:是否用Dummy简化代码?是否考虑内存泄漏(JS不用担心,但C/C++要free)?
  4. 扩展能力:给你一个题,能否举一反三?比如从删除一个值到删除重复值,从全局反转到局部反转。

备战建议

  • 熟练掌握Dummy + 快慢指针 + 头插法这三大武器。
  • 多画图!链表题必须手动画指针移动过程。
  • 代码写完后,用极端案例验证:空链表、单节点、两个节点、全相同值等。

链表并不难,难在细节和边界。掌握了这些技巧,面试官再考链表,你也能游刃有余。