🧠 从双指针到递归:用 JavaScript 攻克 LeetCode 高频链表与数组题

30 阅读6分钟

一句话概括:掌握「三指针」、「哨兵节点」、「快慢指针」、「头插法」和「递归遍历」五大核心技巧,轻松拿下数组合并、链表操作、二叉树遍历等高频面试题!


📌 前言:为什么这些题反复考?

在大厂算法面试中,数组和链表是最基础的数据结构,而 “合并”、“删除”、“反转”、“检测环”、“遍历” 是最常考察的操作。
它们看似简单,却能深度考察你对 指针移动、边界处理、空间优化、递归思维 的理解。

本文将结合 真实可运行的 JavaScript 代码,带你逐题拆解,掌握底层逻辑。


🔢 一、合并两个有序数组 —— 三指针 + 原地操作

💡 题目要求

给定两个升序数组 nums1(长度为 m+n,后 n 位为 0)和 nums2(长度为 n),将 nums2 合并到 nums1 中,使其仍有序。

✅ 关键洞察

  • 如果从前向后合并,会覆盖 nums1 中未处理的元素。
  • 从后往前合并,利用 nums1 末尾的空位,实现 O(1) 额外空间

🧩 三指针策略

  • i:指向 nums1 有效元素末尾(m-1
  • j:指向 nums2 末尾(n-1
  • k:指向 nums1 最终位置(m+n-1

✅ JS 实现

js
编辑
function merge(nums1, m, nums2, n) {
    let i = m - 1;
    let j = n - 1;
    let k = m + n - 1;

    // 从后往前比较
    while (i >= 0 && j >= 0) {
        if (nums1[i] > nums2[j]) {
            nums1[k--] = nums1[i--];
        } else {
            nums1[k--] = nums2[j--];
        }
    }

    // 若 nums2 还有剩余,直接复制(nums1 剩余无需处理)
    while (j >= 0) {
        nums1[k--] = nums2[j--];
    }
}

⏱️ 复杂度

  • 时间:O(m + n)
  • 空间:O(1) —— 原地合并,极致优化!

🔗 二、删除链表的倒数第 N 个节点 —— 快慢指针 + 哨兵节点

💡 为什么需要哨兵节点(dummy)?

  • 删除头节点时,常规方法需特殊处理。
  • 哨兵节点统一所有情况:dummy.next = head,返回 dummy.next 即可。

🧩 快慢指针思路

  • 快指针先走 N 步
  • 快慢指针同步走,当快指针到末尾(fast.next === null),慢指针正好在 倒数第 N+1 个节点
  • 执行 slow.next = slow.next.next 删除目标节点

✅ JS 实现

js
编辑
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;
    }

    // 同步移动,直到 fast 到达最后一个节点
    while (fast.next) {
        fast = fast.next;
        slow = slow.next;
    }

    // 删除倒数第 n 个节点
    slow.next = slow.next.next;

    return dummy.next;
};

✅ 注意:快指针停在 最后一个节点(不是 null),这样慢指针才指向 前驱节点


🔁 三、判断链表是否有环 —— 快慢指针(Floyd 判圈算法)

🧠 核心思想

  • 快指针每次走 2 步,慢指针走 1 步
  • 若有环,快指针必会追上慢指针(就像操场跑步)
  • 若无环,快指针先到达 null

❌ 你代码中的错误修正

js
编辑
// 原代码有笔误:
// fast - fast.next.next;  → 应为 =
// 缺少 slow 初始化

function hasCycle(head) {
    if (!head) return false;
    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;
}

🗑️ 四、删除链表中等于 val 的节点 —— 哨兵节点简化边界

✅ 优势

  • 无需判断是否删除头节点
  • 统一处理所有节点

✅ JS 实现(修正拼写)

js
编辑
function removeElements(head, val) {
    const dummy = new ListNode(0); // ← 修正:ListNode 拼写
    dummy.next = head;
    let cur = dummy;

    while (cur.next) {
        if (cur.next.val === val) { // ← 修正:vall → val
            cur.next = cur.next.next;
        } else {
            cur = cur.next; // 只有不删除时才前进
        }
    }

    return dummy.next;
}

⚠️ 注意:不要加 break!题目要求删除所有等于 val 的节点。


🔄 五、反转链表 —— 哨兵节点 + 头插法

🧠 头插法三步曲

  1. 保存当前节点的下一个节点:next = cur.next
  2. 当前节点插入到已反转部分的头部:cur.next = dummy.next
  3. 更新新头:dummy.next = cur
  4. 移动到原链表下一个节点:cur = next

✅ JS 实现(修正拼写)

js
编辑
function reverseList(head) {
    const dummy = new ListNode(null);
    let cur = head;

    while (cur) {
        const next = cur.next;
        cur.next = dummy.next;   // 插入到反转链表头部
        dummy.next = cur;        // 更新头
        cur = next;              // 移动
    }

    return dummy.next;
}

✅ 虽然比“三指针法”多一个 dummy 节点,但逻辑更直观,适合理解。


🌲 六、二叉树的四种遍历 —— 递归是灵魂

📌 二叉树节点定义

js
编辑
class TreeNode {
    constructor(val, left, right) {
        this.val = val === undefined ? 0 : val;
        this.left = left === undefined ? null : left;
        this.right = right === undefined ? null : right;
    }
}

🔄 四种遍历的本质区别:根节点的访问时机

遍历方式访问顺序应用场景
前序根 → 左 → 右复制树、序列化
中序左 → 根 → 右二叉搜索树 → 有序输出
后序左 → 右 → 根删除节点、计算目录大小
层序从上到下,从左到右广度优先、求宽度/深度

✅ 递归实现(模板)

js
编辑
// 前序
function preorder(root, res = []) {
    if (!root) return res;
    res.push(root.val);          // 根
    preorder(root.left, res);    // 左
    preorder(root.right, res);   // 右
    return res;
}

// 中序
function inorder(root, res = []) {
    if (!root) return res;
    inorder(root.left, res);
    res.push(root.val);          // 根
    inorder(root.right, res);
    return res;
}

// 后序
function postorder(root, res = []) {
    if (!root) return res;
    postorder(root.left, res);
    postorder(root.right, res);
    res.push(root.val);          // 根
    return res;
}

🌐 层序遍历(BFS,用队列)

js
编辑
function levelOrder(root) {
    if (!root) return [];
    const queue = [root];
    const res = [];

    while (queue.length) {
        const level = [];
        const size = queue.length;
        for (let i = 0; i < size; i++) {
            const node = queue.shift();
            level.push(node.val);
            if (node.left) queue.push(node.left);
            if (node.right) queue.push(node.right);
        }
        res.push(level);
    }
    return res;
}

🎯 总结:五大核心技巧

技巧应用场景关键点
三指针合并有序数组从后往前,避免覆盖
哨兵节点删除/反转链表统一边界,无需特判头节点
快慢指针判环、找中点、倒数第 N 个节点速度差制造位置关系
头插法链表反转新节点插入到已反转部分头部
递归遍历二叉树根的访问时机决定遍历类型

💼 面试高频问题延伸

  1. Q:合并数组为什么不能从前向后?
    A:会覆盖 nums1 中尚未处理的元素,导致数据丢失。
  2. Q:快慢指针为什么一定能相遇?
    A:设环长为 L,当慢指针进入环时,快指针已在环内。相对速度为 1,最多 L 步必相遇。
  3. Q:中序遍历 BST 为什么是有序的?
    A:BST 性质:左子树 < 根 < 右子树,中序(左→根→右)自然升序。
  4. Q:哨兵节点算额外空间吗?
    A:算 O(1),因为只创建一个节点,与输入规模无关。

🌟 结语

这些题目看似独立,实则共享同一套 “指针思维”“边界处理哲学”
掌握它们,不仅是为刷题,更是为了培养 清晰的逻辑抽象能力

GitHub 地址:欢迎 Star 我的算法仓库!
在线练习:LeetCode #88, #19, #141, #206, #144/94/145

希望这篇文章助你在面试中游刃有余!如有疑问,欢迎评论区交流 😊


作者:无敌的拖拉斯旋风
标签:#JavaScript #LeetCode #算法 #链表 #二叉树 #前端面试