obsidian-computer
前端识记前端面试

10-algorithms

面向大厂前端面试的算法标准答案集。所有代码用 JavaScript 编写(前端手撕默认语言),带中文注释。 每题结构:【思路 / 核心答案】→ 代码 → 复杂度 → 【追问 & 加分点】。 所有代码均…

最后更新 2026年8月29日

算法手撕

面向大厂前端面试的算法标准答案集。所有代码用 JavaScript 编写(前端手撕默认语言),带中文注释。 每题结构:【思路 / 核心答案】→ 代码 → 复杂度 → 【追问 & 加分点】。 所有代码均按「空输入 / 单元素 / 全重复值 / 边界越界」自查过。


目录

一、面经原题(字节 / 腾讯 / PDD)

  1. LC46 全排列 + LC47 全排列 II
  2. LC198 打家劫舍 + LC213 环形打家劫舍
  3. LC101 对称二叉树
  4. LC199 二叉树右视图(BFS + DFS 两解)
  5. 多个已排序数组求交集
  6. LC162 寻找峰值
  7. 快速排序(非原地 + 原地)
  8. 链表排序(归并)
  9. 数组取最大 K 个数(三解)
  10. 智力题:100 人 1 病毒 2 张试纸

二、多 offer 手撕高频清单

  1. 排序综述(冒泡/选择/插入/归并 + 对比表)
  2. 二分查找(递归 + 非递归 + 三大边界模板)
  3. 字符串逆序与回文判断
  4. 链表六连(反转/倒数K/环/合并/合并K)
  5. 二叉树遍历与深度、路径总和
  6. DP 精选(斐波那契/Kadane/LIS/LCS/硬币/背包)
  7. 附:数组 vs 链表底层差异 & "判断某值是否存在"选谁


一、面经原题

1. LC46 全排列 + LC47 全排列 II(字节一面)

【思路 / 核心答案】

全排列的本质是"决策树 + 回溯":第 1 层选谁做第一个数,第 2 层从剩下的里选第二个……走到叶子(路径长度 == n)就是一个解。

三个关键要素,面试时先说出来再写代码:

  1. 路径 path:已经做出的选择。
  2. 选择列表:本层还能选谁 —— 用 used[] 布尔数组标记(比每次 filter 生成新数组更省内存,也是面试官想看的写法)。
  3. 结束条件:path.length === nums.length。

LC47 去重的核心(这是考点,46 只是热身):

  • 前提:先排序,让相同元素相邻。
  • 剪枝条件:i > 0 && nums[i] === nums[i-1] && !used[i-1] → continue。
  • 为什么是 !used[i-1] 而不是 used[i-1]? 这是面试官最爱追问的点。对于一组相同的数,我们强制它们只能按下标从小到大依次被使用。当 used[i-1] === false 时,说明前一个相同的数在本层刚被撤销(回溯还原)过,此时再选 nums[i] 会产生和刚才完全一样的分支 —— 这是同层重复,必须剪。而 used[i-1] === true 表示前一个相同数在上层路径里已被选走,此时选 nums[i] 是合法的([1a, 1b] 这种)。
  • 补充:写成 used[i-1] === true 时(即 nums[i]===nums[i-1] && used[i-1] 才跳过)逻辑上也能去重,但会多走很多无效分支,剪枝效率低。标准答案用 !used[i-1]。

代码

/**
 * LC46 全排列(输入无重复数字)
 * @param {number[]} nums
 * @return {number[][]}
 */
function permute(nums) {
  const res = [];
  const n = nums.length;
  if (n === 0) return [[]];          // 边界:空输入返回 [[]](空排列本身是一个解)

  const path = [];
  const used = new Array(n).fill(false);

  const backtrack = () => {
    if (path.length === n) {
      res.push([...path]);           // 必须拷贝!path 是同一个引用,会被后续回溯改掉
      return;
    }
    for (let i = 0; i < n; i++) {
      if (used[i]) continue;         // 该下标已在当前路径中
      used[i] = true;                // 做选择
      path.push(nums[i]);
      backtrack();                   // 进入下一层
      path.pop();                    // 撤销选择
      used[i] = false;
    }
  };

  backtrack();
  return res;
}

/**
 * LC47 全排列 II(输入含重复数字,结果不能有重复排列)
 * @param {number[]} nums
 * @return {number[][]}
 */
function permuteUnique(nums) {
  const res = [];
  const n = nums.length;
  if (n === 0) return [[]];

  // 关键第一步:排序,让相同元素相邻,才能用「相邻比较」剪枝
  nums.sort((a, b) => a - b);

  const path = [];
  const used = new Array(n).fill(false);

  const backtrack = () => {
    if (path.length === n) {
      res.push([...path]);
      return;
    }
    for (let i = 0; i < n; i++) {
      if (used[i]) continue;
      // 同层去重:前一个相同的数「在本层刚被撤销」,说明这条分支已经走过
      if (i > 0 && nums[i] === nums[i - 1] && !used[i - 1]) continue;

      used[i] = true;
      path.push(nums[i]);
      backtrack();
      path.pop();
      used[i] = false;
    }
  };

  backtrack();
  return res;
}

// 自查
// permute([])         -> [[]]
// permute([1])        -> 1
// permute([1,2,3])    -> 6 个
// permuteUnique([1,1,2]) -> [[1,1,2],[1,2,1],[2,1,1]]  (3 个,不是 6 个)
// permuteUnique([1,1,1]) -> 1,1,1                  (1 个)

复杂度

时间空间
LC46O(n × n!):n! 个排列,每个拷贝 O(n)O(n) 递归栈 + used(不含结果集)
LC47上界同 46,剪枝后远小于O(n) + 排序 O(log n)

【追问 & 加分点】

  • Q:不用 used 数组,能不能原地交换做全排列? 能。swap(nums, start, i) → 递归 start+1 → 换回来。空间更省(不需要 used),但这种写法在 LC47 中不能直接用排序去重(交换会破坏有序性),得改用 Set 记录本层用过的值。
  • Q:LC47 用 Set 对结果字符串化去重行不行? 能出正确答案,但那是"先全生成再去重",时间和内存都浪费。面试官要的是剪枝(在生成过程中就砍掉),务必答剪枝。
  • Q:res.push(path) 会怎样? 所有结果都是同一个引用,回溯完 path 变空数组,最终输出 n! 个 []。这是最经典的低级错误,写完主动说一句"这里必须浅拷贝"是加分点。
  • 相关题延伸:子集(LC78/90)、组合总和(LC39/40)都是同一套回溯模板,去重逻辑一模一样,可以顺口带一句体现体系性。

2. LC198 打家劫舍 + LC213 打家劫舍 II(字节、腾讯WXG)

【思路 / 核心答案】

LC198(一排房子)

状态定义:dp[i] = 考虑前 i+1 间房子(下标 0..i)能偷到的最大金额。

转移方程 —— 对第 i 间房只有两种决策:

  • 偷:那第 i-1 间必须不偷,收益 dp[i-2] + nums[i]
  • 不偷:收益就是 dp[i-1]
dp[i] = max(dp[i-1], dp[i-2] + nums[i])

初始:dp[0] = nums[0],dp[1] = max(nums[0], nums[1])。

因为只依赖前两项,可以滚动变量压到 O(1) 空间 —— 面试务必写这版。

LC213(房子围成一圈)

环形的唯一新增约束:首尾相邻,不能同时偷。

关键转化:任何一个合法方案,必然满足「不偷第 0 间」或「不偷第 n-1 间」至少一个(两个都偷是违规的)。于是拆成两个线性子问题:

  • 情况 A:只考虑 nums[0 .. n-2](放弃最后一间)
  • 情况 B:只考虑 nums[1 .. n-1](放弃第一间)

答案 = max(A, B)。

注意:A 和 B 各自允许不偷首/尾,所以两种情况的并集覆盖了所有合法方案,不会漏解;同时每种情况都保证首尾不同时被偷,不会多解。这个"覆盖性 + 合法性"的论证是加分点。

边界:n === 1 时必须特判返回 nums[0],否则 nums[0..-1] 是空数组、nums[1..0] 也是空,会返回 0(错)。

代码

/**
 * LC198 打家劫舍(线性)—— 滚动变量版,O(1) 空间
 */
function rob(nums) {
  if (!nums || nums.length === 0) return 0;   // 边界:空数组
  let prev2 = 0;   // dp[i-2]
  let prev1 = 0;   // dp[i-1]
  for (const num of nums) {
    // 当前最优 = max(不偷这间, 偷这间)
    const cur = Math.max(prev1, prev2 + num);
    prev2 = prev1;
    prev1 = cur;
  }
  return prev1;
}

/**
 * LC213 打家劫舍 II(环形)
 */
function robCircle(nums) {
  if (!nums || nums.length === 0) return 0;
  const n = nums.length;
  if (n === 1) return nums[0];                // 关键边界:只有一间房,环退化成点
  if (n === 2) return Math.max(nums[0], nums[1]);

  // 在闭区间 [start, end] 上跑线性版
  const robRange = (start, end) => {
    let prev2 = 0, prev1 = 0;
    for (let i = start; i <= end; i++) {
      const cur = Math.max(prev1, prev2 + nums[i]);
      prev2 = prev1;
      prev1 = cur;
    }
    return prev1;
  };

  // A:不偷最后一间;B:不偷第一间
  return Math.max(robRange(0, n - 2), robRange(1, n - 1));
}

// 自查
// rob([])          -> 0
// rob([5])         -> 5
// rob([2,1,1,2])   -> 4   (偷 0 和 3)
// robCircle([2])   -> 2
// robCircle([2,3,2]) -> 3 (不能偷首尾的 2+2)
// robCircle([1,2,3,1]) -> 4

复杂度

  • LC198:时间 O(n),空间 O(1)
  • LC213:时间 O(n)(跑两遍线性),空间 O(1)

【追问 & 加分点】

  • Q:能不能只跑一遍就解决环形? 可以维护"是否偷了第 0 间"的额外状态维度,但代码复杂度上升、常数没优势,跑两遍更清晰,这是标准解法。面试直说"两遍线性,时间仍是 O(n)"即可。
  • Q:如果房子是一棵二叉树(LC337 打家劫舍 III)? 改成树形 DP:后序遍历,每个节点返回 [偷本节点的最大值, 不偷本节点的最大值]。 偷 = node.val + left[1] + right[1];不偷 = max(left[0],left[1]) + max(right[0],right[1])。
  • Q:要求输出偷了哪几间房,而不只是金额? 需要记录决策来源:额外开一个 choice[i] 数组记录 dp[i] 是从 dp[i-1] 还是 dp[i-2]+nums[i] 来的,最后从后往前回溯。此时空间不能压到 O(1)。
  • 加分:主动指出这是「不能选相邻元素的最大和」这一类模型,跟"删除并获得点数(LC740)"、"跳跃游戏"同族。

3. LC101 对称二叉树(字节二面)

【思路 / 核心答案】

最大的坑:这不是"判断左右子树相等",而是"判断左右子树互为镜像"。

镜像的递归定义 —— 两棵树 p、q 互为镜像,当且仅当:

  1. p 和 q 都为空 → 是;
  2. 只有一个为空 → 否;
  3. p.val === q.val,并且 p.left 与 q.right 互为镜像,p.right 与 q.left 互为镜像(交叉比较,这是精髓)。

所以要写一个双参数的辅助函数 isMirror(p, q),不能只靠单参数递归。

迭代版:用队列/栈成对入队(left.left, right.right 一对,left.right, right.left 一对),每次成对取出比较。

代码

/** 二叉树节点 */
// function TreeNode(val, left, right) { this.val = val; this.left = left; this.right = right; }

/**
 * LC101 对称二叉树 —— 递归版
 */
function isSymmetric(root) {
  if (!root) return true;              // 边界:空树视为对称

  // 判断 p 与 q 是否互为镜像
  const isMirror = (p, q) => {
    if (!p && !q) return true;         // 同时为空 -> 镜像
    if (!p || !q) return false;        // 一空一非空 -> 不镜像
    if (p.val !== q.val) return false; // 值不等 -> 不镜像
    // 交叉递归:外侧对外侧,内侧对内侧
    return isMirror(p.left, q.right) && isMirror(p.right, q.left);
  };

  return isMirror(root.left, root.right);
}

/**
 * LC101 迭代版 —— 用队列成对比较(面试官常追问"不用递归怎么写")
 */
function isSymmetricIterative(root) {
  if (!root) return true;
  const queue = [root.left, root.right];   // 成对入队

  while (queue.length) {
    const p = queue.shift();
    const q = queue.shift();

    if (!p && !q) continue;                // 这一对都空,跳过继续
    if (!p || !q || p.val !== q.val) return false;

    // 注意入队顺序:外侧一对,内侧一对
    queue.push(p.left, q.right);
    queue.push(p.right, q.left);
  }
  return true;
}

// 自查
// null                      -> true
// [1]                       -> true
// [1,2,2,3,4,4,3]           -> true
// [1,2,2,null,3,null,3]     -> false (结构不镜像)
// [1,2,2,2,null,2]          -> false

复杂度

  • 时间 O(n),每个节点最多访问一次。
  • 空间:递归 O(h),h 为树高(最坏退化成链表 O(n),平衡树 O(log n));迭代版队列同量级。

【追问 & 加分点】

  • Q:为什么不能直接比较「中序遍历序列是否回文」? 会错。中序序列相同不代表结构镜像,比如 [1,2,null,2] 这种单支树的遍历序列可能凑巧回文但结构不对称。必须比结构(除非把 null 也序列化成占位符,那本质就退化回结构比较了)。这是一个高频陷阱追问。
  • Q:isSameTree(LC100)和这题的区别? 只差一行:isSameTree 是 p.left vs q.left、p.right vs q.right(平行比较);本题是交叉比较。能一句话点出差别,说明真懂。
  • Q:迭代版用栈行不行? 行。BFS/DFS 在这题上都对,因为我们只是在成对校验,顺序不影响正确性。把 shift() 换成 pop() 即可(注意 shift() 在 JS 数组上是 O(n),数据量大时应改用双指针模拟队列或链表队列 —— 这个性能细节是前端候选人的加分点)。
  • 延伸:LC226 翻转二叉树 + LC100 相同的树 = 本题(先翻转再比相同)。

4. LC199 二叉树右视图(腾讯)

追问原文:「能否用 DFS 做?原理是什么?」

【思路 / 核心答案】

题意:站在树的右边往左看,每一层只能看到最右边的那个节点。所以本质是「每层取一个节点」。

解法一:BFS 层序遍历,取每层最后一个 标准层序遍历,用 size = queue.length 锁定当前层节点数,循环内 i === size - 1 时把该节点值收进结果。最直观。

解法二:DFS,先右后左,靠"深度首次到达"记录(追问的答案)

原理:

  • 按 根 → 右 → 左 的顺序做前序 DFS,那么对任意深度 d,第一个被访问到的深度为 d 的节点,一定是这一层最右边的节点。
  • 因为 DFS 会优先把右侧子树彻底走完,右子树中所有深度 d 的节点都排在左子树同深度节点之前;而右子树内部又递归地保持"更右优先"。
  • 判断"是否首次到达深度 d":用 depth === res.length。因为 res 是按深度顺序追加的,res.length 恰好等于"下一个还没被记录的深度"。

这个 depth === res.length 的技巧是加分点,比额外开一个 Set 记录访问过的深度更漂亮。

代码

/**
 * 解法一:BFS 层序遍历,取每层最后一个节点
 */
function rightSideView(root) {
  const res = [];
  if (!root) return res;                 // 边界:空树返回 []

  const queue = [root];
  while (queue.length) {
    const size = queue.length;           // 锁定当前层的节点数(必须先存,循环里会变)
    for (let i = 0; i < size; i++) {
      const node = queue.shift();
      if (i === size - 1) res.push(node.val);  // 当前层最后一个 = 最右
      if (node.left) queue.push(node.left);
      if (node.right) queue.push(node.right);
    }
  }
  return res;
}

/**
 * 解法二:DFS 先右后左,depth === res.length 时记录
 * 原理:根->右->左 的遍历顺序保证每层最右节点被最先访问到
 */
function rightSideViewDFS(root) {
  const res = [];

  const dfs = (node, depth) => {
    if (!node) return;
    // 首次到达这个深度 —— 由于先右后左,它必是该层最右节点
    if (depth === res.length) res.push(node.val);
    dfs(node.right, depth + 1);          // 关键:先右
    dfs(node.left, depth + 1);           // 再左
  };

  dfs(root, 0);
  return res;
}

// 自查
// null              -> []
// [1]               -> [1]
// [1,2,3,null,5,null,4] -> [1,3,4]
// [1,2,null,3]      -> [1,2,3]  (全是左斜链,左节点也会被看到 —— 常见误解点)

复杂度

两解相同:时间 O(n);空间 —— BFS 最坏 O(n)(满二叉树最后一层约 n/2 个节点在队列里),DFS 为 O(h) 递归栈。

【追问 & 加分点】

  • Q:DFS 能不能"先左后右"? 能,但要改成覆盖式写入:res[depth] = node.val,让后访问的右节点覆盖前面的左节点。逻辑等价但多了无用写入,先右后左更优。
  • Q:只有左子树的斜链(如 1 -> 2(left) -> 3(left)),右视图是什么? 是 [1,2,3]。很多人会答 [1] —— 这是本题最常见的理解错误。题目是"每层可见的最右节点",不是"沿着右指针一路走"。面试时主动举这个反例,能立刻拉开区分度。
  • Q:BFS 和 DFS 该选哪个?
    • 树很宽(接近满二叉树)→ DFS 更省空间(O(h) vs O(n))。
    • 树很深(退化成链)→ BFS 更安全(DFS 有爆栈风险,JS 引擎栈深度约 1 万级)。
    • 面试标准答案:BFS 更直观、更好解释,先写 BFS;被追问再补 DFS。
  • 延伸:改成「左视图」= BFS 取每层第一个 / DFS 先左后右;「每层最大值(LC515)」「层平均值(LC637)」都是同一个层序模板。
  • 前端相关性:层序遍历就是 DOM 树的广度优先遍历,querySelectorAll 的结果顺序、虚拟 DOM 的层级 diff 都能扯上关系,答完可以顺一句体现工程感。

5. 多个已排序整数数组求交集(腾讯)

追问:输入有序时空间从 O(M) 还能怎么压?有序结构遍历怎么跳过元素?

【思路 / 核心答案】

设有 k 个数组,总元素数 M,最短数组长度 L。

解法一:哈希计数(不依赖有序,通用)

用 Map 记录"某个值出现在多少个数组中"。注意同一数组内可能有重复值,不能直接累加 —— 要么先对每个数组内部去重(Set),要么记录"上次贡献计数的数组编号"。最后 count === k 的值就是交集。

  • 时间 O(M),空间 O(M)。这是"不管有没有序都能用"的兜底解。

解法二:k 路指针(利用有序,空间 O(k))—— 追问要的答案

维护 k 个指针 ptr[0..k-1]。每轮:

  1. 取出所有指针当前指向的值,求 最大值 maxVal;
  2. 如果 k 个值全等(等价于全等于 maxVal)→ 命中交集,记录,然后所有指针都跳过这一段相等值(处理重复);
  3. 否则,把所有 值 < maxVal 的指针往前推进,直到 >= maxVal;
  4. 任一指针越界 → 结束。

空间从 O(M) 降到 O(k)(k 个下标)——不计输出的话就是常数级(k 通常远小于 M;若题目说 k 固定,就是 O(1) 额外空间)。这就是"输入有序换空间"的核心答案。

「有序结构怎么跳过元素」= 前进策略的优化

指针前进有三档,面试要能分层说清:

前进方式做法适用
线性推进while (a[i] < target) i++数组长度接近,交集稠密
二分前进在 [i, n) 上二分找第一个 >= target 的位置数组长度悬殊(长数组要跳很远),单次 O(log n)
指数跳跃 / galloping search先按 1,2,4,8,... 倍增探路,越界或超过 target 后在最后一段区间内二分最优。稠密时退化成线性(只多一次比较),稀疏时逼近二分。是工业级倒排索引求交(Lucene skipList)用的策略

指数跳跃是这道题的最高分答案,因为它自适应:不知道数据分布时,它同时不劣于线性和二分。

代码

/**
 * 解法一:哈希计数(通用,不要求有序)
 * @param {number[][]} arrays
 * @return {number[]}
 */
function intersectionByHash(arrays) {
  if (!arrays || arrays.length === 0) return [];
  if (arrays.length === 1) return [...new Set(arrays[0])];
  const k = arrays.length;

  const count = new Map();      // value -> 出现在多少个数组中
  const lastSeen = new Map();   // value -> 上一次贡献计数的数组下标(防同数组内重复重复计数)

  for (let i = 0; i < k; i++) {
    if (!arrays[i] || arrays[i].length === 0) return []; // 任一数组为空,交集必空
    for (const v of arrays[i]) {
      if (lastSeen.get(v) === i) continue;   // 同一数组里重复出现,只算一次
      lastSeen.set(v, i);
      count.set(v, (count.get(v) || 0) + 1);
    }
  }

  const res = [];
  for (const [v, c] of count) if (c === k) res.push(v);
  return res.sort((a, b) => a - b);
}

/**
 * 解法二:k 路指针(利用有序,额外空间 O(k),不含输出)
 * 内部用「指数跳跃 + 二分」推进指针
 */
function intersectionSorted(arrays) {
  if (!arrays || arrays.length === 0) return [];
  const k = arrays.length;
  for (const a of arrays) {
    if (!a || a.length === 0) return [];    // 边界:任一为空 -> 交集为空
  }
  if (k === 1) {                            // 边界:只有一个数组 -> 自身去重
    const out = [];
    for (const v of arrays[0]) if (out[out.length - 1] !== v) out.push(v);
    return out;
  }

  const ptr = new Array(k).fill(0);         // 唯一的额外空间:k 个下标
  const res = [];

  /**
   * 在有序数组 arr 的 [from, arr.length) 中,找到第一个 >= target 的下标
   * 指数跳跃(galloping)探路 + 区间二分,稠密数据近似 O(1),稀疏数据 O(log n)
   */
  const advanceTo = (arr, from, target) => {
    const n = arr.length;
    if (from >= n) return n;
    if (arr[from] >= target) return from;   // 已经满足,不用动

    let step = 1;
    let lo = from;
    let hi = from + step;
    while (hi < n && arr[hi] < target) {    // 倍增探路
      lo = hi;
      step <<= 1;
      hi = from + step;
    }
    hi = Math.min(hi, n - 1);

    // 在 (lo, hi] 上二分找第一个 >= target
    let left = lo + 1, right = hi;
    while (left <= right) {
      const mid = (left + right) >> 1;
      if (arr[mid] >= target) right = mid - 1;
      else left = mid + 1;
    }
    return left;                            // left 可能等于 n,表示走到头了
  };

  outer: while (true) {
    // 1) 求当前所有指针指向值的最大值
    let maxVal = -Infinity;
    for (let i = 0; i < k; i++) {
      if (ptr[i] >= arrays[i].length) break outer;   // 有数组走完了,直接结束
      if (arrays[i][ptr[i]] > maxVal) maxVal = arrays[i][ptr[i]];
    }

    // 2) 把所有 < maxVal 的指针推进到 >= maxVal
    let allEqual = true;
    for (let i = 0; i < k; i++) {
      if (arrays[i][ptr[i]] < maxVal) {
        ptr[i] = advanceTo(arrays[i], ptr[i], maxVal);
        if (ptr[i] >= arrays[i].length) break outer;
        allEqual = false;                   // 推进过,说明本轮还没对齐
      }
    }

    // 3) 全部对齐到同一个值 -> 命中交集
    if (allEqual) {
      res.push(maxVal);
      for (let i = 0; i < k; i++) {
        // 跳过该数组内所有等于 maxVal 的重复元素,保证结果去重
        while (ptr[i] < arrays[i].length && arrays[i][ptr[i]] === maxVal) ptr[i]++;
        if (ptr[i] >= arrays[i].length) break outer;
      }
    }
  }

  return res;
}

// 自查
// intersectionSorted([])                        -> []
// intersectionSorted(1,2,3)                 -> [1,2,3]
// intersectionSorted([[1,2,3],[]])              -> []
// intersectionSorted([[1,2,2,3],[2,2,4],[0,2,9]]) -> [2]   (重复值只出一次)
// intersectionSorted([[1,2],[3,4]])             -> []
// intersectionSorted([[1,5,100],[1,2,3,...,100]]) -> 跳跃前进优势明显

复杂度

解法时间额外空间(不含输出)
哈希计数O(M)(+ 最后排序 O(R log R))O(M)
k 路线性推进O(M)O(k)
k 路 + 指数跳跃最坏 O(M),稀疏时约 O(k · L · log(M/L))O(k)
两两求交(先短后长)O(M),但有中间结果内存O(交集大小)

【追问 & 加分点】

  • Q:输入有序时空间从 O(M) 还能怎么压? 标准答案:多指针,只保存 k 个下标,额外空间 O(k);若 k 是常数(比如题目就给 3 个数组),就是 O(1) 额外空间。哈希解法之所以是 O(M),是因为它没利用有序性,把所有值都装进了表。
  • Q:有序结构遍历怎么跳过元素? 答三档:线性推进 → 二分前进 → 指数跳跃(galloping)。并说明指数跳跃的自适应优势,以及它在**倒排索引求交(搜索引擎 skip list)**中的真实应用。这是这题的天花板答案。
  • Q:k 个数组长度差异极大怎么优化? 先按长度升序排列数组,用最短的数组驱动(交集大小 ≤ 最短数组长度),并且早期就能触发大量跳跃。另外可以先做范围剪枝:全局交集范围 = [max(各数组首元素), min(各数组尾元素)],如果这个区间为空直接返回 []。
  • Q:如果数据量大到内存装不下(海量)? 外部归并思路:每个数组当成一个有序流,k 路归并 + 小顶堆;或用 BitSet / 布隆过滤器 先粗筛。前端场景可以扯到 Web Worker 分片处理。
  • Q:如果要求保留重复次数(多重集交集,LC350 风格)? 去掉"跳过重复"那段,改成取各数组中该值出现次数的最小值。

6. LC162 寻找峰值(PDD)

题意:nums[i] !== nums[i+1],nums[-1] = nums[n] = -∞。找到任意一个峰值(比左右邻居都大)的下标。要求 O(log n)。

【思路 / 核心答案】

为什么无序数组也能二分?—— 这才是 PDD 面试官的真正考点。

二分的前提不是"数组有序",而是「能通过某个判断,安全地丢弃一半,且保证答案仍在剩下的一半里」。本题的不变式是:

不变式:区间 [left, right] 内一定存在至少一个峰值。

初始 [0, n-1] 显然满足(因为两端外侧是 -∞,整个数组必有峰)。

归纳步骤,看 nums[mid] 与 nums[mid+1]:

  • 若 nums[mid] < nums[mid+1](处在上坡):从 mid+1 往右走,要么一直升到 right(那 right 就是峰,因为右外是 -∞),要么中途下降(下降前那个点就是峰)。右半 [mid+1, right] 必有峰 → left = mid + 1。
  • 若 nums[mid] > nums[mid+1](处在下坡):同理往左看,要么一直升到 left(left 是峰,左外 -∞),要么中途出现下降。左半 [left, mid] 必有峰 → right = mid。
  • 题目保证相邻不等,所以不存在第三种情况。

每步区间减半,且不变式恒成立 → 收敛到 left === right 时,那个位置就是峰值。注意 right = mid 不能写 mid - 1,因为 mid 本身可能就是峰。

为什么循环条件是 left < right 而不是 <=? 因为我们不是在"找一个确定的目标值",而是在"收缩到唯一候选"。用 <= 且 right = mid 会死循环。

代码

/**
 * LC162 寻找峰值 —— 二分,O(log n)
 * 不变式:[left, right] 内一定存在峰值
 */
function findPeakElement(nums) {
  if (!nums || nums.length === 0) return -1;  // 边界:空数组(LC 保证非空,但面试要提)
  let left = 0;
  let right = nums.length - 1;                // 单元素时 left === right,直接返回 0

  while (left < right) {
    const mid = left + ((right - left) >> 1); // 防溢出写法(JS 其实不会溢出,但体现习惯)
    if (nums[mid] < nums[mid + 1]) {
      // 上坡:峰在右半(mid 本身不可能是峰,可以安全丢掉)
      left = mid + 1;
    } else {
      // 下坡:峰在左半,mid 本身可能就是峰,所以不能 mid - 1
      right = mid;
    }
  }
  return left;   // left === right,即为峰值下标
}

// 自查
// []            -> -1
// [1]           -> 0        (单元素,左右都是 -∞)
// [1,2]         -> 1
// [2,1]         -> 0
// [1,2,3,1]     -> 2
// [1,2,1,3,5,6,4] -> 1 或 5(任意一个都算对)
// 严格递增 [1,2,3,4,5] -> 4(最后一个)

复杂度

时间 O(log n),空间 O(1)。

【追问 & 加分点】

  • Q:为什么无序数组能二分?(必问) 按上面的不变式 + 归纳来答:二分的本质是"每次能安全排除一半候选",只要能证明"剩下的一半仍包含答案"就成立,跟有序无关。能主动说出「不变式」这个词,面试评价直接上一档。
  • Q:mid 和 mid+1 比,会不会越界? 不会。循环条件是 left < right,所以 mid = (left+right)>>1 < right ≤ n-1,因此 mid + 1 ≤ n-1 恒成立。这是必须能当场论证的边界点。
  • Q:如果去掉「相邻元素不相等」的条件呢? 出现平台(相等段)时无法判断该往哪边走 —— 最坏退化成 O(n) 线性扫描(类似 LC154 搜索旋转排序数组 II 有重复值的情况)。能指出这一点说明你理解二分成立的前提。
  • Q:要求返回「最大的那个峰」呢? 不能二分了,必须 O(n) 全扫。因为全局最大值的位置不满足"局部判断即可丢弃一半"的性质。
  • Q:二维版本(LC1901 寻找峰值 II)? 按列二分:对中间列找该列的最大值行,再和左右邻居比较决定往左半列还是右半列走,复杂度 O(m log n)。能提一句是很好的加分。
  • 同族题:LC153/154(旋转数组最小值)、LC33(搜索旋转排序数组)、LC852(山脉数组峰顶)——都是"局部信息决定丢弃哪半"的二分。

7. 快速排序(字节三面)

【思路 / 核心答案】

分治三步:

  1. 选基准 pivot;
  2. 分区 partition:把数组分成 < pivot 和 >= pivot 两部分;
  3. 递归左右两部分。

面试要准备两个版本,并说清取舍:

  • 非原地简洁版:用 filter 分三份再拼接。5 行写完,逻辑一眼看懂,适合先写出来展示思路。缺点:每层都新建数组,空间 O(n log n),不是真正的快排。
  • 原地分区版(Lomuto / Hoare):这才是面试官要的"手撕快排"。空间只有递归栈 O(log n)。

必须主动说的三个工程点(这是三面区分度所在):

  1. 随机化 pivot:固定取首/尾元素时,遇到已排序数组会退化成 O(n²) 且递归深度 O(n) 直接爆栈。随机选 pivot(或三数取中)把最坏情况的概率降到可忽略。
  2. 尾递归优化 / 先递归短的一侧:保证递归深度 O(log n)。
  3. 快排不稳定:分区时的交换会打乱相等元素的相对顺序。所以 V8 的 Array.prototype.sort 用的是 TimSort(归并 + 插入的混合,稳定),不是快排 —— 这一句能体现前端候选人对引擎的了解。

代码

/**
 * 版本一:非原地简洁版(展示分治思路用)
 * 空间 O(n log n),不适合大数据
 */
function quickSortSimple(arr) {
  if (!arr || arr.length <= 1) return arr;   // 边界:空数组 / 单元素直接返回

  const pivot = arr[arr.length >> 1];        // 取中间元素当基准
  const less = [], equal = [], greater = [];

  // 分成三份,把「等于 pivot」单独拎出来 —— 可以避免全相同元素时的死递归
  for (const x of arr) {
    if (x < pivot) less.push(x);
    else if (x > pivot) greater.push(x);
    else equal.push(x);
  }

  return [...quickSortSimple(less), ...equal, ...quickSortSimple(greater)];
}

/**
 * 版本二:原地分区版(Lomuto 分区 + 随机化 pivot)—— 面试标准答案
 * @param {number[]} arr 会被原地修改
 */
function quickSort(arr, lo = 0, hi = arr.length - 1) {
  if (!arr) return arr;
  while (lo < hi) {                          // 用 while + 尾递归消除,控制递归深度
    const p = partition(arr, lo, hi);
    // 先递归「较短」的一侧,另一侧用循环处理 -> 递归深度稳定 O(log n)
    if (p - lo < hi - p) {
      quickSort(arr, lo, p - 1);
      lo = p + 1;
    } else {
      quickSort(arr, p + 1, hi);
      hi = p - 1;
    }
  }
  return arr;
}

/**
 * Lomuto 分区:把 arr[lo..hi] 按 pivot 划分,返回 pivot 最终落位的下标
 * 分区后:[lo, i-1] 都 < pivot,i 是 pivot,[i+1, hi] 都 >= pivot
 */
function partition(arr, lo, hi) {
  // 随机化:避免有序输入退化成 O(n^2)
  const r = lo + Math.floor(Math.random() * (hi - lo + 1));
  [arr[r], arr[hi]] = [arr[hi], arr[r]];     // 把随机基准换到末尾

  const pivot = arr[hi];
  let i = lo;                                // i 指向「下一个小于 pivot 的元素该放的位置」
  for (let j = lo; j < hi; j++) {
    if (arr[j] < pivot) {
      [arr[i], arr[j]] = [arr[j], arr[i]];
      i++;
    }
  }
  [arr[i], arr[hi]] = [arr[hi], arr[i]];     // 基准归位
  return i;
}

// 自查
// quickSort([])            -> []
// quickSort([1])           -> [1]
// quickSort([2,2,2,2])     -> [2,2,2,2]  (全重复,随机 pivot 下不会退化太惨)
// quickSort([5,4,3,2,1])   -> [1,2,3,4,5](逆序,随机化保证不爆栈)
// quickSort([3,-1,0,-1,9]) -> [-1,-1,0,3,9](含负数、重复)

复杂度

平均最坏空间稳定性
非原地版O(n log n)O(n²)O(n log n)三路版本稳定,两路不稳定
原地版(随机 pivot)O(n log n)O(n²)(概率极低)O(log n) 递归栈不稳定

【追问 & 加分点】

  • Q:什么情况下快排会退化?怎么避免? 基准选得极端(每次都把数组切成 1 和 n-1)时退化为 O(n²)。触发场景:固定取首/尾 + 输入已有序、或全部元素相同。对策:随机 pivot / 三数取中 / 三路分区(荷兰国旗,专治大量重复值)。
  • Q:Hoare 分区和 Lomuto 有什么区别? Hoare 用双指针从两端向中间靠,交换次数约为 Lomuto 的 1/3,性能更好,但返回的分割点语义不同(返回的不是 pivot 最终位置,递归要写成 [lo, p] 和 [p+1, hi]),更容易写错。面试写 Lomuto 更稳,但要能说出 Hoare 更快。
  • Q:为什么 JS 的 sort 不是快排? V8 从 7.0 起 Array.prototype.sort 用 TimSort(归并 + 二分插入的自适应混合排序),因为 ECMAScript 规范从 ES2019 起要求 sort 稳定,而快排不稳定。另外 TimSort 对"部分有序"的真实数据(前端里非常常见)有 O(n) 的最优表现。
  • Q:arr.sort() 不传比较函数排数字有什么坑? 默认按字符串 UTF-16 码元比较,[10, 9, 1].sort() 得到 [1, 10, 9]。必须传 (a,b) => a-b。这是前端一面就会问的常识题。
  • Q:小数组怎么优化? 子数组长度 < 10~16 时切换到插入排序(常数小、缓存友好),这是所有工业级快排(包括 introsort)的标准做法。
  • 加分:提一句 introsort —— 递归深度超过 2·log n 时切换成堆排序,把最坏复杂度硬性锁在 O(n log n)。C++ STL 的 sort 就是这么干的。

8. 链表排序(腾讯实习,ACM 格式)

LC148。要求 O(n log n) 时间、常数级空间(进阶)。

【思路 / 核心答案】

为什么链表选归并而不是快排?(必答,这是本题的灵魂)

维度数组链表
随机访问 a[mid]O(1)O(n),只能走
快排的分区需要双向移动指针(Hoare)/ 随机 pivot 需随机访问链表单向,取随机 pivot 要 O(n),Hoare 双向分区无法实现
归并的合并需要 O(n) 辅助数组只改指针,O(1) 额外空间
缓存局部性数组友好(快排优势主要来源)链表本来就跳着走,快排的缓存优势荡然无存

结论:链表的归并没有数组归并的空间劣势(不用辅助数组),而快排失去了它在数组上的全部优势(随机访问、缓存局部性)。所以链表排序 = 归并排序,这是标准答案。

三步走:

  1. 快慢指针找中点并断开:slow 走一步 fast 走两步。 关键细节:要用 prev 记录 slow 的前驱,或让 fast 从 head.next 起步,才能拿到左半段的末尾去 next = null 断开。如果不断开,递归永远不会终止(这是最常见的 bug)。
  2. 递归排序左右两段。
  3. 合并两个有序链表。

递归终止条件:if (!head || !head.next) return head; —— 空链表和单节点直接返回。

dummy 节点是否必须?

  • 在 merge 里:不是语法必须,但强烈推荐。没有 dummy 就要先比较两个头节点选出结果头,再进入循环,多一段特判代码,而且容易漏边界。有了 dummy,"头节点"和"中间节点"的插入逻辑完全统一,代码短且不易错。
  • 在 反转链表 里:不需要 dummy(用 prev = null 即可)。
  • 一句话总结面试话术:「当链表的头节点可能变化、或者需要统一"插入第一个节点"和"插入后续节点"的逻辑时,dummy 节点能消除特判。」

代码(含 ACM 格式的输入输出处理)

/** 链表节点 */
function ListNode(val, next = null) {
  this.val = val;
  this.next = next;
}

/**
 * LC148 链表排序 —— 自顶向下归并
 * @param {ListNode} head
 * @return {ListNode}
 */
function sortList(head) {
  // 递归出口:空链表 或 只有一个节点,已经有序
  if (!head || !head.next) return head;

  // ---- 1. 快慢指针找中点,并把链表从中间断开 ----
  let slow = head;
  let fast = head.next;          // fast 先走一步:保证偶数长度时 slow 落在「左半段的最后一个」
  while (fast && fast.next) {
    slow = slow.next;
    fast = fast.next.next;
  }
  const mid = slow.next;         // 右半段的头
  slow.next = null;              // 关键!断开,否则递归无法收敛(死循环 / 爆栈)

  // ---- 2. 递归排序两半 ----
  const left = sortList(head);
  const right = sortList(mid);

  // ---- 3. 合并 ----
  return mergeTwo(left, right);
}

/**
 * 合并两个有序链表(dummy 节点消除头节点特判)
 */
function mergeTwo(l1, l2) {
  const dummy = new ListNode(0);   // 哨兵:让「接第一个节点」和「接后续节点」逻辑一致
  let tail = dummy;

  while (l1 && l2) {
    if (l1.val <= l2.val) {        // 用 <= 保证稳定性(相等时 l1 在前)
      tail.next = l1;
      l1 = l1.next;
    } else {
      tail.next = l2;
      l2 = l2.next;
    }
    tail = tail.next;
  }
  tail.next = l1 || l2;            // 剩下的一整段直接接上,不用逐个搬
  return dummy.next;
}

/* ============ ACM 格式:输入输出模板(腾讯笔试/实习面试常见) ============ */
/*
输入示例(一行空格分隔):
4 2 1 3
输出:
1 2 3 4
*/
function main(input) {
  const nums = input.trim().split(/\s+/).filter(Boolean).map(Number);
  if (nums.length === 0) return '';          // 边界:空输入

  // 数组 -> 链表
  const dummy = new ListNode(0);
  let cur = dummy;
  for (const v of nums) {
    cur.next = new ListNode(v);
    cur = cur.next;
  }

  const sorted = sortList(dummy.next);

  // 链表 -> 输出字符串
  const out = [];
  for (let p = sorted; p; p = p.next) out.push(p.val);
  return out.join(' ');
}

// Node.js ACM 读入方式(牛客/腾讯笔试环境)
// const readline = require('readline');
// const rl = readline.createInterface({ input: process.stdin });
// rl.on('line', line => { console.log(main(line)); });

// 自查
// sortList(null)          -> null
// sortList([1])           -> [1]
// sortList([2,1])         -> [1,2]      (偶数长度,验证 fast=head.next 的必要性)
// sortList([3,3,3])       -> [3,3,3]    (全重复)
// sortList([-1,5,3,4,0])  -> [-1,0,3,4,5]

复杂度

  • 时间 O(n log n):log n 层,每层合并总代价 O(n)。
  • 空间 O(log n):递归栈。 → 想做到真正的 O(1) 空间,要用「自底向上归并」:按 step = 1,2,4,8... 逐轮两两合并,不递归。这是 LC148 进阶版的标准答案,面试能提一句是大加分。

【追问 & 加分点】

  • Q:为什么 fast = head.next 而不是 fast = head? 长度为 2 时([2,1]):若 fast = head,循环条件 fast && fast.next 成立一次,slow 走到第二个节点,mid = slow.next = null,右半段为空 → 左半段仍是原链表,无限递归爆栈。让 fast 先走一步,slow 就停在左半段末尾,切成 [2] 和 [1],正确收敛。这个边界必须能当场推演。
  • Q:dummy 节点是必须的吗? 不是语法必须,是工程必须。作用是消除"头节点特判"。不用 dummy 的写法要先手动选出较小的头,代码多且易错。反过来说,如果头节点确定不变(比如原地反转的某些写法),就没必要用。
  • Q:怎么做到 O(1) 空间? 自底向上归并(bottom-up merge sort):外层 for (step = 1; step < n; step *= 2),内层每次从链表里切出两段长度为 step 的子链表合并,用 dummy + tail 串起结果。没有递归,空间 O(1)。代码比自顶向下长约一倍,面试时先写自顶向下,被追问再讲思路。
  • Q:链表能用快排吗? 能写(单向分区:遍历一遍把小于 pivot 的挂一条链、大于的挂另一条链),但没有优势:pivot 只能取头节点 → 有序输入必退化 O(n²);没有缓存局部性红利。所以工业实现和面试标准答案都是归并。
  • Q:归并排序稳定吗? 稳定 —— 前提是 merge 里用 <= 而不是 <。这个细节写代码时主动点出来是加分点。

9. 数组取最大 K 个数(字节三面)

面试官原话:「排序当然可以,不用排序还能怎么做?」

【思路 / 核心答案】

三种方案,要能说清各自的适用场景 —— 这题考的是取舍判断而不是代码。

方案时间空间适用场景
① 全排序取前 kO(n log n)O(1)~O(n)k 接近 n;或本来就需要有序结果;代码最短
② 小顶堆维护 size=kO(n log k)O(k)k 远小于 n;数据是流式的/内存装不下(海量数据 Top-K 的唯一解)
③ 快速选择(BFPRT/快排分区)平均 O(n)O(1)(迭代版)数据全在内存里、只要"哪 k 个"不要有序、追求最快

② 小顶堆的关键思维:要求最大的 k 个,反而用小顶堆。堆顶是当前 k 个候选里最小的那个,新元素只要比堆顶大就"顶掉"它。堆里始终只有 k 个元素,所以每次调整是 log k 而不是 log n。 → 这是海量数据 Top-K 的标准工业解:数据可以是一条流,永远不需要全量载入内存。

③ 快速选择的关键思维:借用快排的 partition,但只递归一侧。

  • 分区后 pivot 落在下标 p,此时 [0, p-1] 都小于它、[p+1, n-1] 都不小于它。
  • 目标:让 pivot 落在 n - k 这个位置(升序下 n-k 及其右边共 k 个元素,就是最大的 k 个)。
  • p === n-k → 找到;p < n-k → 只在右侧继续;p > n-k → 只在左侧继续。
  • 因为每次只做一侧,期望代价是 n + n/2 + n/4 + ... = 2n → 平均 O(n)。
  • 最坏 O(n²),随机化 pivot 后概率可忽略;理论上 BFPRT(中位数的中位数)能保证最坏 O(n),但常数大,工程不用。

代码

/* ---------- 方案 ①:排序 ---------- */
function topKBySort(nums, k) {
  if (!nums || k <= 0) return [];
  if (k >= nums.length) return [...nums].sort((a, b) => b - a);
  return [...nums].sort((a, b) => b - a).slice(0, k);   // 拷贝一份,不污染原数组
}

/* ---------- 方案 ②:手写小顶堆,维护大小为 k ---------- */
/**
 * 最小堆(数组实现的完全二叉树)
 * 父节点下标 i,左孩子 2i+1,右孩子 2i+2;孩子 i 的父亲 (i-1)>>1
 */
class MinHeap {
  constructor() {
    this.data = [];
  }
  size() { return this.data.length; }
  peek() { return this.data[0]; }

  push(val) {
    this.data.push(val);
    this._siftUp(this.data.length - 1);
  }

  pop() {
    if (this.data.length === 0) return undefined;
    const top = this.data[0];
    const last = this.data.pop();
    if (this.data.length > 0) {
      this.data[0] = last;      // 把最后一个搬到堆顶,再下沉
      this._siftDown(0);
    }
    return top;
  }

  /** 替换堆顶(比 pop + push 少一次调整,Top-K 场景常用) */
  replaceTop(val) {
    this.data[0] = val;
    this._siftDown(0);
  }

  _siftUp(i) {
    const d = this.data;
    while (i > 0) {
      const p = (i - 1) >> 1;
      if (d[p] <= d[i]) break;              // 父节点更小,满足小顶堆性质,停止
      [d[p], d[i]] = [d[i], d[p]];
      i = p;
    }
  }

  _siftDown(i) {
    const d = this.data;
    const n = d.length;
    while (true) {
      const l = 2 * i + 1, r = 2 * i + 2;
      let min = i;
      if (l < n && d[l] < d[min]) min = l;
      if (r < n && d[r] < d[min]) min = r;
      if (min === i) break;                 // 已经比两个孩子都小
      [d[min], d[i]] = [d[i], d[min]];
      i = min;
    }
  }
}

/**
 * 小顶堆求最大的 K 个数 —— O(n log k),空间 O(k)
 * 适用:k << n,或数据是流式的
 */
function topKByHeap(nums, k) {
  if (!nums || k <= 0) return [];
  const heap = new MinHeap();

  for (const num of nums) {
    if (heap.size() < k) {
      heap.push(num);                       // 前 k 个直接进堆
    } else if (num > heap.peek()) {
      heap.replaceTop(num);                 // 比堆里最小的还大 -> 顶掉它
    }
    // 否则:比 k 个候选里最小的还小,直接丢弃
  }

  // 堆里就是最大的 k 个(无序);若要降序输出再排一次 O(k log k)
  return heap.data.slice().sort((a, b) => b - a);
}

/* ---------- 方案 ③:快速选择,平均 O(n) ---------- */
/**
 * 快速选择求最大的 K 个数 —— 平均 O(n)
 * 会原地打乱数组
 */
function topKByQuickSelect(nums, k) {
  if (!nums || k <= 0) return [];
  const arr = [...nums];                    // 拷贝,避免污染入参
  const n = arr.length;
  if (k >= n) return arr.sort((a, b) => b - a);

  const target = n - k;                     // 升序排列后,下标 [target, n-1] 就是最大的 k 个
  let lo = 0, hi = n - 1;

  while (lo < hi) {
    const p = partition(arr, lo, hi);
    if (p === target) break;
    if (p < target) lo = p + 1;             // 目标在右侧,丢掉左半
    else hi = p - 1;                        // 目标在左侧,丢掉右半
  }

  return arr.slice(target).sort((a, b) => b - a);
}

/** 随机化 Lomuto 分区(同第 7 题) */
function partition(arr, lo, hi) {
  const r = lo + Math.floor(Math.random() * (hi - lo + 1));
  [arr[r], arr[hi]] = [arr[hi], arr[r]];
  const pivot = arr[hi];
  let i = lo;
  for (let j = lo; j < hi; j++) {
    if (arr[j] < pivot) {
      [arr[i], arr[j]] = [arr[j], arr[i]];
      i++;
    }
  }
  [arr[i], arr[hi]] = [arr[hi], arr[i]];
  return i;
}

// 自查(三种方案结果应一致)
// topKByHeap([], 3)              -> []
// topKByHeap([1,2,3], 0)         -> []
// topKByHeap([5], 3)             -> [5]        (k > n)
// topKByHeap([3,3,3,3], 2)       -> [3,3]      (全重复)
// topKByHeap([3,2,1,5,6,4], 2)   -> [6,5]
// topKByQuickSelect([-1,-5,-3], 2) -> [-1,-3]  (全负数)

复杂度

见上表。补充两点:

  • 堆的建堆(Floyd 自底向上)是 O(k),不是 O(k log k);本题因为是逐个 push,是 O(k log k),但被 n 次循环摊掉后总体仍是 O(n log k)。
  • 快速选择的 O(n) 是期望复杂度,依赖随机化。

【追问 & 加分点】

  • Q(原题):不用排序还能怎么做? 按"堆 O(n log k) / 快速选择 O(n)"两条线答,并强调各自的适用边界。只说一种是不够的。
  • Q:为什么求最大 K 个用小顶堆而不是大顶堆? 大顶堆要装下全部 n 个元素再弹 k 次 → O(n + k log n)、空间 O(n)。小顶堆只装 k 个 → 空间 O(k),且能处理无限流。堆顶是"当前候选里的门槛值",这个直觉要能说出来。
  • Q:10 亿个数、内存只有 1G,求 Top 100 怎么办? 这是本题的真实业务版:
    1. 小顶堆(size=100)单遍流式扫描 —— 内存占用固定 100 个数,一遍过。首选答案。
    2. 若数据分布在多台机器:每台机器各自算局部 Top 100 → 汇总后再求全局 Top 100(MapReduce 思路)。
    3. 若数值范围有限:桶排序 / BitMap 分桶 先定位到哪个桶,再桶内精算。
  • Q:能不能用 sort 之后 slice? 能,但要说清代价:O(n log n) 且需要全量数据在内存里。当 k 很小、n 很大时明显劣于堆和快速选择。
  • Q:JS 里 Array.prototype.sort 的比较函数返回什么? 返回负数 a 在前、正数 b 在前、0 视为相等(且 ES2019 后保证稳定)。降序写 (a,b) => b - a。
  • 加分:提一句 replaceTop 比 pop() + push() 少一次 siftUp,这是 Top-K 场景的常数优化,说明你真手写过堆。

10. 智力题:100 人,1 人带病毒,2 张试纸

【思路 / 核心答案】

这道题的第一步不是算,是澄清规则。 面试官考的就是你会不会先问清楚约束 —— 直接开算是减分项。要问清三件事:

  1. 检测结果是立刻可见的吗(能否根据上一次结果决定下一次怎么测)?→ 自适应 vs 非自适应
  2. 试纸用完是否报废?阴性能不能复用?阳性呢?
  3. "尽量少检测次数"指的是试纸使用总次数,还是检测轮数(可并行)?

共同的推理框架:混检(分组混合检测)

核心洞察:一次检测不必只测一个人。把若干人的样本混在一起测:

  • 结果阳性 → 病毒携带者在这组里(因为只有 1 个感染者);
  • 结果阴性 → 这组人全部排除。

于是一次检测就从"排除 1 个人"升级为"排除/锁定一整组",这就是二分/多分的基础。

信息量上界(用来验算答案,必须会算)

每次检测输出 1 bit(阳/阴)。要从 100 个等可能候选中唯一确定 1 个,至少需要 log₂(100) ≈ 6.64 → 不少于 7 bit,即至少 7 次二值检测。这是任何方案都突破不了的下界。


解读 A:试纸可反复使用、串行自适应 → 答案 7 次

纯二分混检:

  • 第 1 次:测 50 人的混合样本。阳性 → 目标在这 50 人;阴性 → 在另外 50 人。无论哪种结果,候选都从 100 降到 50。
  • 第 2 次:在剩下 50 人中取 25 人混检 → 降到 25。
  • 依次:100 → 50 → 25 → 13 → 7 → 4 → 2 → 1。

共 7 次,正好达到信息论下界 ⌈log₂100⌉ = 7。此时1 张试纸就够了,第 2 张是冗余。


解读 B:2 张试纸可并行使用 → 答案 5 轮

如果两张试纸能同时上(一轮里做两次独立检测),每轮的结果组合有 3 种有效状态(不可能两组同时阳性,因为只有 1 个感染者):

  • 把候选分成 A / B / C 三组,同时测 A 和 B:
    • (阳, 阴) → 在 A
    • (阴, 阳) → 在 B
    • (阴, 阴) → 在 C(不用测就排除了)
  • 每轮候选缩小到 1/3。

3⁴ = 81 < 100 ≤ 3⁵ = 243 → 5 轮(试纸使用总次数仍是 10 次左右,但轮数是 5)。

这就回答了"2 张试纸到底提供了什么价值":它把串行的 7 轮压缩成并行的 5 轮。


解读 C:试纸阳性即报废(污染)、阴性可复用 → 双蛋模型,答案 14 次

这是最有意思、也最可能是原题本意的版本(试纸检出阳性后被污染,不能再用)。

把 100 人排成一条线,用前缀混检:第 i 次测"第 1 到第 x 个人"的混合样本。

  • 阴性 → 试纸完好可继续用,候选区间右移;
  • 阳性 → 这张试纸报废,且已知目标在刚才那一段里,只剩最后一张试纸时,必须逐个线性测试(不能再冒险混检,因为再报废就没试纸了)。

这与经典的「2 个鸡蛋 100 层楼扔」(LC887 鸡蛋掉落)完全同构:

  • 鸡蛋碎 ↔ 试纸阳性报废
  • 楼层 ↔ 人的编号
  • 最后一个鸡蛋只能从下往上逐层试 ↔ 最后一张试纸只能逐个测

推导:设最坏情况需 k 次。第 1 次测前 k 个人:

  • 若阳性,剩 1 张试纸 + k-1 个候选,逐个测最多再花 k-1 次,总计 k 次 ✓
  • 若阴性,第 2 次再往后测 k-1 个人(累计到第 k + (k-1) 个)……

所以能覆盖的最大人数是 k + (k-1) + (k-2) + ... + 1 = k(k+1)/2。 要求 k(k+1)/2 ≥ 100 → k = 14(14×15/2 = 105 ≥ 100,而 13×14/2 = 91 < 100)。

答案:14 次。 测试点位是 14, 27, 39, 50, 60, 69, 77, 84, 90, 95, 99, 100(步长递减)。

代码(解读 C:通用双试纸最优次数 DP)

/**
 * 通用解:m 张试纸、n 个候选人,最坏情况最少检测次数
 * dp[k][m] = 用 m 张试纸、检测 k 次,最多能确定多少人
 * 转移:dp[k][m] = dp[k-1][m-1]   (这次阳性,试纸报废,往「上一段」查)
 *                + dp[k-1][m]     (这次阴性,试纸保留,往「后面」查)
 *                + 1              (本次混检覆盖的分界点本身)
 * 与 LC887「鸡蛋掉落」同构
 */
function minTests(n, m) {
  if (n <= 0) return 0;          // 边界:没人要测
  if (m <= 0) return Infinity;   // 边界:没试纸,无解
  if (m === 1) return n;         // 只有 1 张试纸,只能逐个线性测(最坏 n 次)

  const dp = new Array(m + 1).fill(0);   // dp[j] = 当前次数下、用 j 张试纸能覆盖的人数
  let k = 0;
  while (dp[m] < n) {
    k++;
    // 必须从后往前更新,保证 dp[j-1] 用的是「上一轮 k-1」的值
    for (let j = m; j >= 1; j--) {
      dp[j] = dp[j - 1] + dp[j] + 1;
    }
  }
  return k;
}

// 自查
// minTests(100, 2)  -> 14   (2 张试纸 100 人,双蛋模型)
// minTests(100, 1)  -> 100  (只有 1 张、阳性即废 -> 只能线性)
// minTests(0, 2)    -> 0
// minTests(1, 2)    -> 1
// minTests(105, 2)  -> 14   (k(k+1)/2 = 105 恰好卡满)
// minTests(106, 2)  -> 15
// minTests(1000, 10) -> 10  (试纸够多时退化为纯二分 log2(1000)≈10)

/**
 * 解读 A 的模拟:试纸可复用时的二分混检
 */
function binaryGroupTest(people, isPositive) {
  let lo = 0, hi = people.length - 1, count = 0;
  while (lo < hi) {
    const mid = (lo + hi) >> 1;
    count++;
    // 混检 [lo, mid] 这一组
    if (isPositive(people.slice(lo, mid + 1))) hi = mid;
    else lo = mid + 1;
  }
  return { index: lo, tests: count };   // 100 人时 tests === 7
}

【追问 & 加分点】

  • 必答的第一句:「我先确认几个约束 —— 结果是否即时可见、试纸阳性后是否报废、次数是指试纸用量还是轮数?」这是这道题真正的考点,考的是把模糊需求转成明确模型的能力。
  • 信息量上界的讨论(题目明确要求):
    • 如果限定"只能做 2 次二值检测,且不可自适应",那总共只有 2² = 4 种可区分结果,4 < 100,信息论上根本不可能定位。所以题目一定不是这个意思,必须重新解读为"2 张试纸可多轮使用"。
    • 主动指出这一点,比闷头算一个错答案强得多。
  • Q:如果不止 1 个感染者呢? 变成组合群体检测(Group Testing)问题。d 个感染者、n 人时,信息下界是 log₂(C(n,d))。经典方案有 Dorfman 两阶段法(先分组粗筛,阳性组内再逐个测)和非自适应的编码矩阵法(每人对应一个编码列,用 CS 稀疏恢复)。这正是新冠期间"10 混 1 / 20 混 1"核酸的数学基础 —— 能把题目连到真实世界是很强的加分。
  • Q:混检有假阴性怎么办? 样本被稀释后灵敏度下降,实际操作中组大小有上限(所以是 10 混 1 而不是 100 混 1)。这也是理论最优 ≠ 工程最优的典型例子。
  • Q:和 LC887 鸡蛋掉落的关系? 完全同构,能一眼看出来并写出 DP,说明有模型迁移能力。上面的 minTests 就是 LC887 的 O(k·m) 解法(比 O(n·m·log n) 的朴素 DP 快得多)。


二、多 offer 手撕高频清单

11. 排序综述

【思路 / 核心答案】

四种基础排序 + 前面写过的快排,构成完整的排序知识体系。面试常问「说说你知道的排序算法,各自复杂度和稳定性」。

稳定性定义:排序后,值相等的元素相对顺序不变。 为什么前端要在意稳定性? 表格多列排序(先按价格排、再按销量排,要求价格相同的保持销量顺序)、Vue/React 列表渲染的 key 顺序,都依赖稳定排序。ES2019 起规范强制 Array.prototype.sort 稳定。

代码

/**
 * 冒泡排序:相邻两两比较交换,每轮把最大的「冒」到末尾
 * 优化:一轮没发生交换 -> 已有序,提前退出(对近乎有序数据 O(n))
 */
function bubbleSort(arr) {
  const a = [...arr];
  const n = a.length;
  if (n <= 1) return a;                       // 边界

  for (let i = 0; i < n - 1; i++) {
    let swapped = false;
    // 每轮结束后末尾 i 个元素已就位,无需再比
    for (let j = 0; j < n - 1 - i; j++) {
      if (a[j] > a[j + 1]) {                  // 严格大于才交换 -> 相等不动 -> 稳定
        [a[j], a[j + 1]] = [a[j + 1], a[j]];
        swapped = true;
      }
    }
    if (!swapped) break;                      // 本轮无交换,已经有序
  }
  return a;
}

/**
 * 选择排序:每轮从未排序区选出最小值,放到已排序区末尾
 * 不稳定!例:[5a, 5b, 1] -> 第一轮 5a 与 1 交换 -> [1, 5b, 5a],5a/5b 顺序被打乱
 */
function selectionSort(arr) {
  const a = [...arr];
  const n = a.length;
  if (n <= 1) return a;

  for (let i = 0; i < n - 1; i++) {
    let minIdx = i;
    for (let j = i + 1; j < n; j++) {
      if (a[j] < a[minIdx]) minIdx = j;
    }
    if (minIdx !== i) [a[i], a[minIdx]] = [a[minIdx], a[i]];
  }
  return a;
}

/**
 * 插入排序:把当前元素插入到左侧已排序区的正确位置(像整理扑克牌)
 * 对「近乎有序」的数据是 O(n),常数极小 -> TimSort 在小数组上就用它
 */
function insertionSort(arr) {
  const a = [...arr];
  const n = a.length;
  if (n <= 1) return a;

  for (let i = 1; i < n; i++) {
    const cur = a[i];
    let j = i - 1;
    // 严格大于才后移 -> 相等时停下、插在其后 -> 稳定
    while (j >= 0 && a[j] > cur) {
      a[j + 1] = a[j];
      j--;
    }
    a[j + 1] = cur;
  }
  return a;
}

/**
 * 归并排序:分治,先递归排好两半,再线性合并
 * 稳定(merge 用 <=),时间恒定 O(n log n),代价是 O(n) 辅助空间
 */
function mergeSort(arr) {
  if (!arr || arr.length <= 1) return arr ? [...arr] : [];

  const mid = arr.length >> 1;
  const left = mergeSort(arr.slice(0, mid));
  const right = mergeSort(arr.slice(mid));
  return merge(left, right);
}

function merge(left, right) {
  const res = [];
  let i = 0, j = 0;
  while (i < left.length && j < right.length) {
    // <= 保证稳定性:相等时优先取左半(原来在前面的)
    if (left[i] <= right[j]) res.push(left[i++]);
    else res.push(right[j++]);
  }
  // 剩余部分直接拼接
  while (i < left.length) res.push(left[i++]);
  while (j < right.length) res.push(right[j++]);
  return res;
}

// 自查(四个函数都应通过)
// f([])            -> []
// f([1])           -> [1]
// f([2,1])         -> [1,2]
// f([3,3,3])       -> [3,3,3]
// f([5,4,3,2,1])   -> [1,2,3,4,5]
// f([0,-1,2,-1])   -> [-1,-1,0,2]

对比表(面试直接背这张)

算法平均时间最好最坏空间稳定性一句话记忆
冒泡O(n²)O(n)(已有序+提前退出)O(n²)O(1)稳定相邻交换,最大值上浮
选择O(n²)O(n²)O(n²)O(1)不稳定每轮选最小,交换次数最少(n-1 次)
插入O(n²)O(n)(近乎有序)O(n²)O(1)稳定打扑克牌整理手牌
归并O(n log n)O(n log n)O(n log n)O(n)稳定分治,性能最稳,外部排序基石
快排O(n log n)O(n log n)O(n²)O(log n)不稳定分区递归,常数最小,实测最快
堆排O(n log n)O(n log n)O(n log n)O(1)不稳定原地 + 最坏也 O(n log n)
计数/桶/基数O(n + k)O(n + k)稳定(实现得当)非比较排序,需知道值域

记忆口诀:

  • 不稳定的四个:快、选、希(希尔)、堆 → "快选希堆"。
  • O(1) 空间的:冒泡、选择、插入、堆排。
  • 不受输入影响、恒定 O(n log n) 的:归并、堆排。

【追问 & 加分点】

  • Q:比较排序的时间下界为什么是 O(n log n)? 决策树模型:n 个元素有 n! 种排列,每次比较产生 1 bit 信息,决策树高度至少 log₂(n!) ≈ n log n(斯特林公式)。所以任何基于比较的排序都不可能优于 O(n log n)。计数排序/基数排序不是比较排序,所以能做到 O(n),代价是要求值域有限。
  • Q:n 很小的时候选哪个? 插入排序。虽然是 O(n²),但常数极小、缓存友好、无递归开销,n < 16 时实测比快排/归并都快。工业排序(TimSort、introsort)都在小数组上切到插入排序。
  • Q:前端场景真会手写排序吗? 基本不会 —— 但会写比较函数。高频坑:① sort() 不传比较函数默认按字符串排;② 比较函数必须是一致的全序(不能返回随机数,V8 会抛错或结果不可预测);③ sort 原地修改原数组,React 里直接 state.sort() 会导致引用不变、不触发重渲染,必须 [...state].sort()。这三个点答出来比手写冒泡更能证明工程能力。
  • Q:怎么用 JS 实现"多字段排序"?
    arr.sort((a, b) => a.price - b.price || b.sales - a.sales || a.name.localeCompare(b.name));
    用 || 短路串联,前一个字段相等(返回 0)时才比下一个。localeCompare 处理中文/多语言排序。

12. 二分查找与边界模板

【思路 / 核心答案】

二分的所有 bug 都来自三个地方:循环条件(< 还是 <=)、区间收缩(mid 还是 mid±1)、返回值。解决办法:固定用「闭区间 [left, right]」这一套模板,不要每题现想。

三个必须掌握的模板:

  1. 标准查找:找到目标返回下标,找不到返回 -1。闭区间 → while (left <= right),收缩 left = mid+1 / right = mid-1。
  2. 左边界(lower_bound):第一个 >= target 的位置。用途:找目标的第一次出现、插入位置。
  3. 右边界(upper_bound):第一个 > target 的位置。upper - lower 就是 target 的出现次数。

统一记忆法:lower_bound(target) 和 upper_bound(target) 写法只差一个符号(>= vs >),返回值都是"插入点",都在 [0, n] 范围内(可能等于 n)。

代码

/**
 * 模板 0:递归版标准二分(面试有时会点名要递归)
 */
function binarySearchRecursive(arr, target, left = 0, right = arr.length - 1) {
  if (!arr || arr.length === 0) return -1;
  if (left > right) return -1;                    // 递归出口:区间为空

  const mid = left + ((right - left) >> 1);       // 防溢出习惯写法
  if (arr[mid] === target) return mid;
  if (arr[mid] < target) return binarySearchRecursive(arr, target, mid + 1, right);
  return binarySearchRecursive(arr, target, left, mid - 1);
}

/**
 * 模板 1:非递归标准二分 —— 闭区间 [left, right]
 * 找到返回下标,找不到返回 -1
 */
function binarySearch(arr, target) {
  if (!arr || arr.length === 0) return -1;
  let left = 0;
  let right = arr.length - 1;                     // 闭区间:right 是可取的

  while (left <= right) {                         // 闭区间 -> 用 <=(left===right 时区间非空)
    const mid = left + ((right - left) >> 1);
    if (arr[mid] === target) return mid;
    if (arr[mid] < target) left = mid + 1;        // 目标在右侧,mid 已排除
    else right = mid - 1;                         // 目标在左侧,mid 已排除
  }
  return -1;
}

/**
 * 模板 2:左边界 lower_bound —— 第一个 >= target 的下标
 * 若所有元素都 < target,返回 arr.length(即插入位置)
 */
function lowerBound(arr, target) {
  if (!arr) return 0;
  let left = 0;
  let right = arr.length;                         // 注意是 length,搜索区间 [left, right)

  while (left < right) {
    const mid = left + ((right - left) >> 1);
    if (arr[mid] < target) left = mid + 1;        // mid 太小,排除
    else right = mid;                             // arr[mid] >= target,mid 可能就是答案,保留
  }
  return left;                                    // left === right,即答案位置
}

/**
 * 模板 3:右边界 upper_bound —— 第一个 > target 的下标
 */
function upperBound(arr, target) {
  if (!arr) return 0;
  let left = 0;
  let right = arr.length;

  while (left < right) {
    const mid = left + ((right - left) >> 1);
    if (arr[mid] <= target) left = mid + 1;       // 只差这里:<= 而不是 <
    else right = mid;
  }
  return left;
}

/** 应用:统计 target 出现次数 */
function countTarget(arr, target) {
  return upperBound(arr, target) - lowerBound(arr, target);
}

/** 应用:LC34 查找元素的第一个和最后一个位置 */
function searchRange(nums, target) {
  const lo = lowerBound(nums, target);
  // 没找到:lo 越界 或 该位置不是 target
  if (lo >= nums.length || nums[lo] !== target) return [-1, -1];
  return [lo, upperBound(nums, target) - 1];
}

// 自查
// binarySearch([], 1)              -> -1
// binarySearch([1], 1)             -> 0
// binarySearch([1], 2)             -> -1
// binarySearch([1,2,3,4,5], 5)     -> 4   (找末尾元素)
// lowerBound([1,2,2,2,3], 2)       -> 1
// upperBound([1,2,2,2,3], 2)       -> 4
// countTarget([1,2,2,2,3], 2)      -> 3
// lowerBound([1,2,3], 99)          -> 3   (全都小于 target,返回 length)
// lowerBound([1,2,3], 0)           -> 0   (全都大于 target)
// searchRange([5,7,7,8,8,10], 8)   -> [3,4]
// searchRange([5,7,7,8,8,10], 6)   -> [-1,-1]

复杂度

时间 O(log n);空间:非递归 O(1),递归 O(log n) 栈(注意 JS 引擎默认不做尾调用优化,但 log n 深度不会爆栈)。

【追问 & 加分点】

  • Q:mid = (left + right) / 2 有什么问题? ① 整数溢出(Java/C++ 里 left + right 可能超 int 上限;JS 的 Number 是 double,安全整数到 2⁵³,实际不会溢出,但要能说出这个经典陷阱);② JS 里必须取整,用 Math.floor(...) 或 >> 1。推荐写 left + ((right - left) >> 1),兼具防溢出和取整。 ⚠️ 注意 >> 会把数转成 32 位有符号整数,超过 2³¹ 会出错 —— 数组长度不会这么大,但知道这个细节是加分点。
  • Q:怎么记住什么时候用 < 什么时候用 <=? 看搜索区间的定义:
    • 闭区间 [left, right](right = n-1)→ while (left <= right),因为 left === right 时区间还有一个元素。
    • 左闭右开 [left, right)(right = n)→ while (left < right),因为 left === right 时区间已空。 写代码前先声明区间定义,然后条件和收缩就自动确定了。这是最能体现"有体系"的答法。
  • Q:二分的适用前提只有"有序"吗? 不是(见第 6 题寻找峰值)。真正的前提是「存在一个单调的判定函数」:把问题转成 check(x) 返回 true/false,且随 x 增大 check 的结果单调变化(FFFTTT)。二分找的就是那个分界点。这个视角能解锁「二分答案」类题目(如"分割数组的最大值"LC410、"koko 吃香蕉"LC875)。
  • Q:旋转有序数组怎么二分(LC33)? 先判断 mid 落在哪一段(nums[left] <= nums[mid] 说明左半有序),再判断 target 是否落在那段有序区间内,决定往哪边走。有重复值时(LC81/154)最坏退化到 O(n)。

13. 字符串逆序 / 判断回文

【思路 / 核心答案】

字符串逆序 —— 这题真正的考点是 JS 的字符编码坑,不是循环。

  • str.split('').reverse().join('') 是最短写法,但会破坏 emoji 和部分中日韩生僻字。 原因:JS 字符串内部是 UTF-16 码元序列,split('') 按码元切分。而 emoji(如 😀 U+1F600)是代理对(surrogate pair),占 2 个码元,切开再反转就变成了乱码。
  • 正确做法:用 [...str] 或 Array.from(str) —— 它们走 字符串迭代器,按码点(code point) 遍历,能正确处理代理对。
  • 更极端的情况:👨‍👩‍👧‍👦(家庭 emoji)是用零宽连接符 ZWJ 连起来的字素簇(grapheme cluster),[...str] 仍会拆散。要完全正确得用 Intl.Segmenter(现代浏览器已支持)。

判断回文 —— 双指针从两端向中间夹逼,比"反转后比较"更优(不额外分配内存,且可提前返回)。

代码

/**
 * 字符串逆序 —— 三个版本,逐层递进
 */
// 版本 1:最短,但有 emoji 坑
function reverseStrNaive(str) {
  if (!str) return '';                       // 边界:null / undefined / ''
  return str.split('').reverse().join('');
}
// reverseStrNaive('abc')  -> 'cba'   ✓
// reverseStrNaive('a😀b') -> 'b\uDE00\uD83D a' ✗ emoji 被拆坏

// 版本 2:按码点,正确处理 emoji / 生僻字
function reverseStr(str) {
  if (!str) return '';
  return [...str].reverse().join('');        // 字符串迭代器按 code point 遍历
}
// reverseStr('a😀b') -> 'b😀a'  ✓

// 版本 3:按「字素簇」,连 ZWJ 组合 emoji 也正确(最严谨)
function reverseStrGrapheme(str) {
  if (!str) return '';
  if (typeof Intl === 'undefined' || !Intl.Segmenter) return reverseStr(str);
  const seg = new Intl.Segmenter('zh', { granularity: 'grapheme' });
  return [...seg.segment(str)].map(s => s.segment).reverse().join('');
}
// reverseStrGrapheme('a👨‍👩‍👧‍👦b') -> 'b👨‍👩‍👧‍👦a'  ✓

// 版本 4:手写循环(不用内置 API,面试官可能要求)
function reverseStrManual(str) {
  if (!str) return '';
  let res = '';
  for (let i = str.length - 1; i >= 0; i--) res += str[i];
  return res;
  // 注:JS 字符串不可变,+= 在 V8 里用 rope/ConsString 优化,实际不是 O(n^2),
  //     但大量拼接仍建议用数组 push + join
}

/**
 * 判断回文 —— 双指针,O(1) 额外空间
 */
function isPalindrome(str) {
  if (str == null) return false;
  if (str.length <= 1) return true;          // 边界:'' 和单字符都是回文

  let left = 0;
  let right = str.length - 1;
  while (left < right) {
    if (str[left] !== str[right]) return false;   // 一旦不等立刻返回,无需走完
    left++;
    right--;
  }
  return true;
}

/**
 * LC125 验证回文串 —— 忽略大小写和非字母数字字符
 * 用双指针原地跳过,避免生成新字符串(比先 replace 再判断更省内存)
 */
function isPalindromeLoose(s) {
  if (s == null) return false;
  const isAlnum = c => /[a-z0-9]/i.test(c);

  let left = 0, right = s.length - 1;
  while (left < right) {
    while (left < right && !isAlnum(s[left])) left++;     // 左指针跳过非字母数字
    while (left < right && !isAlnum(s[right])) right--;   // 右指针同理
    if (left < right) {
      if (s[left].toLowerCase() !== s[right].toLowerCase()) return false;
      left++;
      right--;
    }
  }
  return true;
}

// 自查
// isPalindrome('')          -> true
// isPalindrome('a')         -> true
// isPalindrome('aa')        -> true
// isPalindrome('ab')        -> false
// isPalindrome('aba')       -> true
// isPalindromeLoose('A man, a plan, a canal: Panama') -> true
// isPalindromeLoose('race a car')                     -> false
// isPalindromeLoose(' ')                              -> true (全是非字母数字)

复杂度

  • 逆序:时间 O(n),空间 O(n)(字符串不可变,必须新建)。
  • 回文判断(双指针):时间 O(n),空间 O(1);比"反转后比较"(O(n) 空间)更优。

【追问 & 加分点】

  • Q:split('').reverse().join('') 有什么问题?(高频) 代理对被拆散 —— 这是本题唯一的技术含量。能主动说出 UTF-16 / 码元 vs 码点 / [...str] 走迭代器,立刻区别于背模板的候选人。再补一句 Intl.Segmenter 处理字素簇,就是满分。
  • Q:str.length 返回的是什么? UTF-16 码元数,不是字符数。'😀'.length === 2,[...'😀'].length === 1。前端做输入框字数限制时踩这个坑非常常见(微博/推特的字数统计就得按码点或字素算)。
  • Q:判断回文能不能用 str === reverse(str)? 能,但要说清代价:多分配一份 O(n) 内存,且必须完整反转完才能比较(不能提前返回)。双指针更优。
  • Q:最长回文子串怎么做? ① 中心扩展法 O(n²) 时间 O(1) 空间(注意奇偶两种中心都要试)—— 面试首选;② 动态规划 O(n²) 时间 O(n²) 空间;③ Manacher 算法 O(n) —— 能提名字并说清"用已算出的回文半径做对称复用"即可,不用背代码。
  • Q:回文链表(LC234)怎么 O(1) 空间做? 快慢指针找中点 → 反转后半段 → 双指针从两端比 → (加分)比完再反转回去恢复原链表。这是把本题和链表题结合的经典追问。

14. 链表六连

14.1 反转链表(LC206,迭代 + 递归)

【思路】

迭代:三指针 prev / cur / next。每步把 cur.next 指向 prev,然后三个指针整体右移。核心是先存 next 再断链,否则丢失后继。

递归:

  • 出口:if (!head || !head.next) return head; 返回新的头(原链表最后一个节点)。
  • 递归体:先反转 head.next 之后的部分,得到 newHead。此时 head.next 仍指向原来的下一个节点(它现在是反转后子链表的尾巴),所以 head.next.next = head 把自己挂到尾巴后面,再 head.next = null 断开旧指向(否则成环)。

head.next.next = head 这一行是全题的关键,要能用"head.next 现在是反转后子链的尾节点"来解释。

/** 迭代版:三指针,O(n) 时间 O(1) 空间 —— 面试首选 */
function reverseList(head) {
  let prev = null;
  let cur = head;
  while (cur) {
    const next = cur.next;   // 1. 先保存后继,防止断链后找不到
    cur.next = prev;         // 2. 反转指针方向
    prev = cur;              // 3. prev 右移
    cur = next;              // 4. cur 右移
  }
  return prev;               // cur 走到 null 时,prev 是新头
}

/** 递归版:O(n) 时间 O(n) 栈空间 */
function reverseListRecursive(head) {
  // 出口:空链表 或 只剩一个节点(它就是反转后的新头)
  if (!head || !head.next) return head;

  const newHead = reverseListRecursive(head.next);  // 先把后面全部反转好
  head.next.next = head;    // head.next 此刻是「已反转子链的尾节点」,把自己挂到它后面
  head.next = null;         // 断开旧的指向,否则 head 和 head.next 互指成环
  return newHead;           // 新头一路向上传递,不变
}

// 自查
// reverseList(null)     -> null
// reverseList([1])      -> [1]
// reverseList([1,2])    -> [2,1]
// reverseList([1,2,3])  -> [3,2,1]

复杂度:迭代 O(n) / O(1);递归 O(n) / O(n)。


14.2 链表倒数第 K 个节点(剑指 22)

【思路】

双指针(快慢指针)一次遍历:让 fast 先走 k 步,然后 fast 和 slow 同速前进;fast 到达 null 时,slow 正好在倒数第 k 个。 原理:两指针始终相差 k 个节点,fast 走到末尾(第 n+1 个位置)时,slow 在第 n+1-k 个位置 = 倒数第 k 个。

必须处理的边界:k > 链表长度 时,fast 提前变 null,应返回 null(或按题意报错),不能崩。

/**
 * 返回链表倒数第 k 个节点,O(n) 一次遍历,O(1) 空间
 */
function getKthFromEnd(head, k) {
  if (!head || k <= 0) return null;          // 边界:空链表 / k 非法

  let fast = head;
  // fast 先走 k 步
  for (let i = 0; i < k; i++) {
    if (!fast) return null;                  // 边界:k 超过链表长度
    fast = fast.next;
  }

  let slow = head;
  while (fast) {                             // 同速前进,保持 k 的间距
    fast = fast.next;
    slow = slow.next;
  }
  return slow;
}

/** 变体 LC19:删除倒数第 n 个节点 —— 用 dummy 处理「删除头节点」的情况 */
function removeNthFromEnd(head, n) {
  const dummy = new ListNode(0, head);       // dummy 让「删头」和「删中间」逻辑统一
  let fast = dummy, slow = dummy;
  for (let i = 0; i <= n; i++) {             // 注意是 <=,让 slow 停在待删节点的「前驱」
    if (!fast) return head;
    fast = fast.next;
  }
  while (fast) { fast = fast.next; slow = slow.next; }
  slow.next = slow.next.next;                // 跳过待删节点
  return dummy.next;                         // 用 dummy.next 而不是 head(头可能被删了)
}

// 自查
// getKthFromEnd(null, 1)       -> null
// getKthFromEnd([1,2,3], 1)    -> 节点 3
// getKthFromEnd([1,2,3], 3)    -> 节点 1
// getKthFromEnd([1,2,3], 4)    -> null (k 超长)
// removeNthFromEnd([1], 1)     -> null (删掉唯一节点,验证 dummy 的必要性)

复杂度:时间 O(n),空间 O(1)。


14.3 判断链表有环 + 找入环节点(LC141 / LC142)

【思路 / 数学证明】

判环(Floyd 判圈 / 龟兔赛跑):slow 每次 1 步、fast 每次 2 步。有环则 fast 必然在环内追上 slow(每步差距缩小 1,一定能追上,不会跳过);无环则 fast 先到 null。

找入环节点的数学证明(面试常要求现场推):

设:

  • a = 头节点到入环点的距离
  • b = 入环点到相遇点的距离(沿环方向)
  • c = 相遇点回到入环点的距离,环长 L = b + c

相遇时:

  • slow 走了 a + b(slow 第一圈内必被追上,不会多绕圈)
  • fast 走了 a + b + k·L(k ≥ 1,fast 多绕了 k 圈)
  • 又因为 fast 速度是 slow 的两倍:2(a + b) = a + b + k·L

化简得:a + b = k·L → a = k·L - b = (k-1)·L + (L - b) = (k-1)·L + c

含义:从头节点走 a 步到入环点 等价于 从相遇点走 c 步(再绕 k-1 圈)到入环点。

所以:把一个指针放回 head,另一个留在相遇点,两者同速(每次 1 步)前进,必在入环点相遇。

/** LC141 判断链表是否有环 */
function hasCycle(head) {
  if (!head || !head.next) return false;    // 边界:空链表 / 单节点无自环
  let slow = head, fast = head;
  while (fast && fast.next) {
    slow = slow.next;
    fast = fast.next.next;
    if (slow === fast) return true;         // 相遇 -> 有环
  }
  return false;                             // fast 到头 -> 无环
}

/** LC142 返回入环的第一个节点,无环返回 null */
function detectCycle(head) {
  if (!head || !head.next) return null;

  let slow = head, fast = head;
  // 阶段一:找相遇点
  while (fast && fast.next) {
    slow = slow.next;
    fast = fast.next.next;
    if (slow === fast) {
      // 阶段二:一个回头,同速前进,相遇处即入环点
      // 依据 a = (k-1)·L + c
      let p = head;
      while (p !== slow) {
        p = p.next;
        slow = slow.next;
      }
      return p;
    }
  }
  return null;
}

// 自查
// hasCycle(null)                    -> false
// hasCycle([1])                     -> false
// hasCycle([1] 自环)                 -> true
// detectCycle([3,2,0,-4], pos=1)    -> 节点 2
// detectCycle([1,2], pos=0)         -> 节点 1
// detectCycle([1,2,3], 无环)         -> null

复杂度:时间 O(n),空间 O(1)(哈希表法也能做,但空间 O(n),快慢指针更优)。


14.4 合并两个有序链表(LC21)

见第 8 题的 mergeTwo。核心:dummy 哨兵 + tail 指针,最后 tail.next = l1 || l2 一次性接上剩余段。

function mergeTwoLists(l1, l2) {
  if (!l1) return l2;                     // 边界:任一为空直接返回另一个
  if (!l2) return l1;

  const dummy = new ListNode(0);
  let tail = dummy;
  while (l1 && l2) {
    if (l1.val <= l2.val) { tail.next = l1; l1 = l1.next; }
    else                  { tail.next = l2; l2 = l2.next; }
    tail = tail.next;
  }
  tail.next = l1 || l2;                   // 剩余段整体接上,不用逐个搬
  return dummy.next;
}

复杂度:时间 O(m + n),空间 O(1)(原地穿针引线,不新建节点)。


14.5 合并 K 个有序链表(LC23,分治)

【思路】

三种方案的复杂度对比是考点(设 k 条链表、总节点数 N):

方案时间说明
顺序两两合并O(kN)第 i 次合并的链表长度是 i·(N/k),累加成 k²·(N/k) = kN。差
分治两两合并O(N log k)log k 轮,每轮所有节点被扫一次共 O(N)。首选
小顶堆(size=k)O(N log k)每个节点出入堆一次,各 O(log k)。空间 O(k)

分治的关键洞察:两两合并的顺序会影响总代价。顺序合并让"越来越长的结果链表"被反复扫描;分治保证每个节点只被扫 log k 次。这跟归并排序是同一个道理。

/**
 * LC23 合并 K 个升序链表 —— 分治,O(N log k)
 */
function mergeKLists(lists) {
  if (!lists || lists.length === 0) return null;   // 边界:空数组
  return mergeRange(lists, 0, lists.length - 1);
}

function mergeRange(lists, lo, hi) {
  if (lo > hi) return null;
  if (lo === hi) return lists[lo];                 // 只剩一条,直接返回(可能是 null)

  const mid = lo + ((hi - lo) >> 1);
  const left = mergeRange(lists, lo, mid);
  const right = mergeRange(lists, mid + 1, hi);
  return mergeTwoLists(left, right);               // 复用 14.4
}

/**
 * 小顶堆版 —— 同样 O(N log k),适合链表数极多 / 流式场景
 * 复用第 9 题的 MinHeap 思路,但比较的是节点的 val
 */
function mergeKListsByHeap(lists) {
  if (!lists || lists.length === 0) return null;

  // 简化实现:用数组模拟小顶堆(生产中应封装成通用堆)
  const heap = [];
  const push = (node) => {
    heap.push(node);
    let i = heap.length - 1;
    while (i > 0) {
      const p = (i - 1) >> 1;
      if (heap[p].val <= heap[i].val) break;
      [heap[p], heap[i]] = [heap[i], heap[p]];
      i = p;
    }
  };
  const pop = () => {
    const top = heap[0];
    const last = heap.pop();
    if (heap.length) {
      heap[0] = last;
      let i = 0;
      while (true) {
        const l = 2 * i + 1, r = 2 * i + 2;
        let m = i;
        if (l < heap.length && heap[l].val < heap[m].val) m = l;
        if (r < heap.length && heap[r].val < heap[m].val) m = r;
        if (m === i) break;
        [heap[m], heap[i]] = [heap[i], heap[m]];
        i = m;
      }
    }
    return top;
  };

  for (const node of lists) if (node) push(node);  // 边界:跳过空链表

  const dummy = new ListNode(0);
  let tail = dummy;
  while (heap.length) {
    const node = pop();
    tail.next = node;
    tail = tail.next;
    if (node.next) push(node.next);               // 该链表的下一个节点补位
  }
  tail.next = null;                                // 断尾,防止残留旧指针
  return dummy.next;
}

// 自查
// mergeKLists([])                   -> null
// mergeKLists([null])               -> null      (链表数组里全是空链表)
// mergeKLists([null, [1], null])    -> [1]
// mergeKLists([[1,4,5],[1,3,4],[2,6]]) -> [1,1,2,3,4,4,5,6]

【追问 & 加分点(链表整体)】

  • Q:dummy 节点什么时候必须用? 当头节点可能被改变或删除时(删除倒数第 n 个、删除所有值为 x 的节点、合并链表)。它把"操作头节点"和"操作中间节点"统一成一种逻辑,消除特判。反转链表就不需要 dummy(prev = null 已经起了同样作用)。
  • Q:链表题为什么容易出 bug?怎么防? 三条纪律:① 改指针前先存 next;② 画图,把每一步的指针指向画出来;③ 拿 2 个节点和 3 个节点的最小用例手动跑一遍。面试时主动说"我先用 [1,2] 验证一下边界"是很强的信号。
  • Q:能用哈希表判环吗? 能:遍历时把节点存入 Set,遇到已存在的就是入环点。时间 O(n)、空间 O(n)。快慢指针空间 O(1) 更优,但哈希法代码简单且能直接给出入环点 —— 面试可以两个都提,说明取舍。
  • Q:JS 里链表相关的实际应用? React Fiber 的 child/sibling/return 指针构成的树其实是链表结构(为了可中断遍历);Hooks 的 memoizedState 是单向链表;requestIdleCallback 调度的任务队列也常用链表。能扯到框架源码是很强的加分。

15. 二叉树遍历 + 深度 + 路径总和

【思路 / 核心答案】

递归三序的唯一区别是「处理当前节点」这行代码放在哪:

  • 前序:根 → 左 → 右
  • 中序:左 → 根 → 右(BST 的中序是升序,这是最重要的性质)
  • 后序:左 → 右 → 根(需要子树信息才能处理父节点时用,如求高度、树形 DP)

非递归实现要点:

  • 前序:栈,先压右再压左(栈是后进先出,保证左先出)。
  • 中序:指针 cur + 栈。一路向左压栈到底 → 弹出访问 → 转向右子树。
  • 后序(最难):两种写法
    1. 改造前序:按 根 → 右 → 左 遍历,结果反转,就是 左 → 右 → 根。5 行搞定,面试首选。
    2. 单栈 + 标记 lastVisited:正统做法,判断右子树是否已访问过。逻辑绕,容易写错。

层序 BFS:队列 + size 锁定当前层。

代码

/* ============ 递归三序 ============ */
function preorderRecursive(root, res = []) {
  if (!root) return res;
  res.push(root.val);                    // 根
  preorderRecursive(root.left, res);     // 左
  preorderRecursive(root.right, res);    // 右
  return res;
}

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

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

/* ============ 非递归(栈)实现 ============ */

/** 前序非递归:栈,先压右后压左 */
function preorderIterative(root) {
  const res = [];
  if (!root) return res;                 // 边界:空树
  const stack = [root];
  while (stack.length) {
    const node = stack.pop();
    res.push(node.val);
    // 栈是 LIFO,想让左子树先被处理,就后压左
    if (node.right) stack.push(node.right);
    if (node.left) stack.push(node.left);
  }
  return res;
}

/** 中序非递归:cur 指针一路向左压栈,弹出访问后转右 */
function inorderIterative(root) {
  const res = [];
  const stack = [];
  let cur = root;
  while (cur || stack.length) {
    while (cur) {                        // 一路向左,把路径上的节点全压栈
      stack.push(cur);
      cur = cur.left;
    }
    cur = stack.pop();                   // 走到最左端,弹出访问
    res.push(cur.val);
    cur = cur.right;                     // 转向右子树,重复上述过程
  }
  return res;
}

/** 后序非递归(写法 1:根右左 + 反转)—— 面试首选,最不易错 */
function postorderIterative(root) {
  const res = [];
  if (!root) return res;
  const stack = [root];
  while (stack.length) {
    const node = stack.pop();
    res.push(node.val);
    // 注意:这里是先压左后压右 -> 出栈顺序 根、右、左
    if (node.left) stack.push(node.left);
    if (node.right) stack.push(node.right);
  }
  return res.reverse();                  // 反转「根右左」得到「左右根」
}

/** 后序非递归(写法 2:单栈 + lastVisited)—— 正统解法 */
function postorderIterativeStrict(root) {
  const res = [];
  const stack = [];
  let cur = root;
  let lastVisited = null;                // 记录上一个被访问(输出)的节点

  while (cur || stack.length) {
    while (cur) { stack.push(cur); cur = cur.left; }
    const peek = stack[stack.length - 1];
    // 右子树存在 且 还没被访问过 -> 先去右子树
    if (peek.right && lastVisited !== peek.right) {
      cur = peek.right;
    } else {
      res.push(peek.val);                // 左右都处理完了,可以访问根
      lastVisited = stack.pop();
    }
  }
  return res;
}

/* ============ 层序 BFS ============ */

/** LC102 层序遍历,返回二维数组(每层一个数组) */
function levelOrder(root) {
  const res = [];
  if (!root) return res;
  const queue = [root];
  while (queue.length) {
    const size = queue.length;           // 必须先固定当前层大小
    const level = [];
    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;
}

/* ============ 深度 ============ */

/** LC104 最大深度 */
function maxDepth(root) {
  if (!root) return 0;                   // 边界:空树深度 0
  return 1 + Math.max(maxDepth(root.left), maxDepth(root.right));
}

/**
 * LC111 最小深度 —— 陷阱题!
 * 定义:根到「最近叶子节点」的距离。叶子 = 左右子树都为空。
 * 不能直接写 1 + min(left, right):单支树时空的那一边会返回 0,答案就错了。
 */
function minDepth(root) {
  if (!root) return 0;
  // 关键:只有一个子树存在时,必须走那个存在的子树(另一边不是叶子路径)
  if (!root.left) return 1 + minDepth(root.right);
  if (!root.right) return 1 + minDepth(root.left);
  return 1 + Math.min(minDepth(root.left), minDepth(root.right));
}

/** 最小深度的 BFS 版本 —— 找到第一个叶子就返回,通常更快 */
function minDepthBFS(root) {
  if (!root) return 0;
  const queue = [root];
  let depth = 1;
  while (queue.length) {
    const size = queue.length;
    for (let i = 0; i < size; i++) {
      const node = queue.shift();
      if (!node.left && !node.right) return depth;   // 遇到第一个叶子,立即返回
      if (node.left) queue.push(node.left);
      if (node.right) queue.push(node.right);
    }
    depth++;
  }
  return depth;
}

/* ============ 路径总和 ============ */

/** LC112 路径总和 I:是否存在 根->叶 路径,其和等于 targetSum */
function hasPathSum(root, targetSum) {
  if (!root) return false;               // 边界:空树没有任何根到叶路径
  // 到达叶子节点:检查剩余值是否恰好被消耗完
  if (!root.left && !root.right) return targetSum === root.val;
  const rest = targetSum - root.val;
  return hasPathSum(root.left, rest) || hasPathSum(root.right, rest);
}

/** LC113 路径总和 II:返回所有满足条件的路径 */
function pathSum(root, targetSum) {
  const res = [];
  const path = [];

  const dfs = (node, remain) => {
    if (!node) return;
    path.push(node.val);
    remain -= node.val;

    if (!node.left && !node.right && remain === 0) {
      res.push([...path]);               // 必须拷贝
    } else {
      dfs(node.left, remain);
      dfs(node.right, remain);
    }
    path.pop();                          // 回溯:撤销选择
  };

  dfs(root, targetSum);
  return res;
}

// 自查
// preorderIterative(null)      -> []
// inorderIterative([1])        -> [1]
// levelOrder(null)             -> []
// maxDepth(null)               -> 0
// minDepth([1,2])              -> 2   (不是 1!左单支,验证陷阱)
// minDepth([1,null,2,null,3])  -> 3   (右斜链)
// hasPathSum(null, 0)          -> false (空树永远 false,即使 target 是 0)
// hasPathSum([1,2], 1)         -> false (1 不是叶子,不能中途结束)
// hasPathSum([-2,null,-3], -5) -> true  (含负数)
// pathSum([5,4,8,11,null,13,4,7,2,null,null,5,1], 22) -> [[5,4,11,2],[5,8,4,5]]

复杂度

时间空间
三序遍历(递归/非递归)O(n)O(h),最坏 O(n)
层序 BFSO(n)O(w),w 为最大层宽,最坏 O(n)
maxDepth / minDepthO(n)O(h)
hasPathSumO(n)O(h)
pathSum(LC113)O(n²) 最坏(每条路径拷贝 O(n))O(h)

【追问 & 加分点】

  • Q:minDepth([1,2]) 是 1 还是 2? 是 2。这是 LC111 的经典陷阱:根节点有一个孩子时,它不是叶子,不能算深度 1。写代码时如果直接 1 + Math.min(...),空子树返回 0,会错误地得到 1。面试主动指出这个坑是明确加分点。
  • Q:为什么 BFS 求最小深度比 DFS 快? BFS 是逐层扩展,遇到第一个叶子节点立刻就是答案,可以提前返回;DFS 必须遍历完所有路径才知道最小值。最坏都是 O(n),但平均 BFS 明显更快(尤其叶子靠近根时)。反过来,求最大深度用 DFS 更自然。
  • Q:非递归后序为什么难? 因为后序访问根节点时,需要知道"右子树是否已经处理完",而栈本身不携带这个信息。所以要么用 lastVisited 显式记录,要么用"根右左 + 反转"绕开。
  • Q:Morris 遍历知道吗? 用线索二叉树思想,把中序遍历的空间从 O(h) 降到 O(1):利用叶子节点的空右指针临时指向后继节点,访问完再恢复。能说出名字和"用空指针建立临时线索、访问后恢复"的原理即可,不用背代码。
  • Q:遍历和前端有什么关系? ① 虚拟 DOM diff 是树的遍历(React 用深度优先);② document.querySelectorAll 返回的是文档序(≈ 前序遍历);③ Vue 的编译器把模板 AST 做前序/后序两阶段处理(transform 的进入和退出钩子,正好对应前序和后序);④ React Fiber 用 child/sibling/return 三个指针把递归改成可中断的循环遍历 —— 这就是"非递归遍历"在真实框架里的价值。
  • Q:给定前序 + 中序能否唯一确定一棵树? 能(LC105)。前序 + 后序不能唯一确定(除非是满二叉树)。中序 + 后序能(LC106)。这是高频概念追问。

16. DP 精选

【统一方法论 —— 面试先说这个】

任何 DP 题,按四步走,说出来比直接写代码更能拿分:

  1. 定义状态:dp[i] 表示什么?(必须精确到"以...结尾"还是"前 i 个")
  2. 推导转移方程:dp[i] 由哪些更小的状态推出?
  3. 确定初始化和边界。
  4. 确定遍历顺序(正序/倒序,这在背包里是决定性的)。
  5. (可选)空间优化:只依赖前几项 → 滚动数组/滚动变量。

16.1 斐波那契(滚动变量)

/**
 * 斐波那契 —— 滚动变量,O(n) 时间 O(1) 空间
 * dp[i] = dp[i-1] + dp[i-2]
 */
function fib(n) {
  if (n < 0) return 0;         // 边界:非法输入
  if (n < 2) return n;         // 边界:fib(0)=0, fib(1)=1

  let prev2 = 0, prev1 = 1;
  for (let i = 2; i <= n; i++) {
    const cur = prev1 + prev2;
    prev2 = prev1;
    prev1 = cur;
  }
  return prev1;
}
// 自查:fib(0)=0, fib(1)=1, fib(2)=1, fib(10)=55
// 注意:n > 78 时超过 Number.MAX_SAFE_INTEGER,需要 BigInt

复杂度:O(n) / O(1)。 追问:① 朴素递归是 O(2ⁿ)(重复计算,画递归树能看出 fib(n-2) 被算了多次),加记忆化降到 O(n);② 矩阵快速幂可到 O(log n);③ 这题的模型和"爬楼梯 LC70"、"打家劫舍"完全一样。


16.2 最大子数组和(Kadane,LC53)

状态定义是关键:dp[i] = 以 nums[i] 结尾的最大子数组和(不是"前 i 个的最大和",那样无法转移)。

转移:dp[i] = max(nums[i], dp[i-1] + nums[i]) —— 要么把前面的接上,要么另起炉灶(前面的和是负的,拖后腿)。

/**
 * LC53 最大子数组和 —— Kadane 算法,O(n) 时间 O(1) 空间
 */
function maxSubArray(nums) {
  if (!nums || nums.length === 0) return 0;   // 边界:空数组(题目保证非空,但要提)

  let cur = nums[0];        // 以当前元素结尾的最大和
  let best = nums[0];       // 全局最大(必须用 nums[0] 初始化,不能用 0!)

  for (let i = 1; i < nums.length; i++) {
    // 前面的累积和为负 -> 抛弃它,从当前元素重新开始
    cur = Math.max(nums[i], cur + nums[i]);
    best = Math.max(best, cur);
  }
  return best;
}

// 自查
// maxSubArray([1])                        -> 1
// maxSubArray([-1])                       -> -1   (全负数!best 初始化为 0 会错答成 0)
// maxSubArray([-2,-1,-3])                 -> -1
// maxSubArray([-2,1,-3,4,-1,2,1,-5,4])    -> 6    ([4,-1,2,1])

复杂度:O(n) / O(1)。 追问:

  • best 初始化为 0 是最常见的 bug:全负数数组会错误返回 0。必须初始化为 nums[0] 或 -Infinity。
  • 要求返回子数组本身(不只是和)? 记录 start/end:当 cur 重新从 nums[i] 开始时更新临时 start;当 best 被更新时固化 start/end。
  • 环形版(LC918)? max(普通Kadane结果, 总和 - 最小子数组和)。特判"全负数"时后者会得到 0(空数组),要排除。

16.3 最长上升子序列 LIS(LC300)

前端必答点:Vue 3 的 diff 算法用的就是 O(n log n) 的贪心 + 二分版 LIS。

解法一:O(n²) DP dp[i] = 以 nums[i] 结尾的 LIS 长度。转移:dp[i] = max(dp[j]) + 1,其中 j < i && nums[j] < nums[i]。答案是 max(dp),不是 dp[n-1]。

解法二:O(n log n) 贪心 + 二分(重点)

维护数组 tails,tails[k] = 所有长度为 k+1 的上升子序列中,结尾元素的最小值。

  • 贪心思想:结尾越小,后面越容易接上更多元素。
  • tails 一定是严格递增的(可反证)→ 所以能二分。
  • 遍历每个 num:在 tails 中二分找第一个 >= num 的位置 pos(即 lowerBound):
    • pos === tails.length → num 比所有结尾都大,追加,LIS 长度 +1;
    • 否则 → 替换 tails[pos] = num,让长度为 pos+1 的子序列有更小的结尾。
  • 最终 tails.length 就是 LIS 的长度。

极其重要的坑:tails 本身不是一个合法的最长上升子序列!它只保证长度正确。要还原真实序列,必须额外记录每个元素的前驱下标再回溯 —— Vue 3 的 getSequence 正是这么做的(它必须拿到真实的下标序列来决定哪些 DOM 节点不用移动)。

/** LIS 解法一:O(n²) DP */
function lengthOfLISDP(nums) {
  if (!nums || nums.length === 0) return 0;   // 边界
  const n = nums.length;
  const dp = new Array(n).fill(1);            // 每个元素自身就是长度 1 的子序列
  let best = 1;

  for (let i = 1; i < n; i++) {
    for (let j = 0; j < i; j++) {
      if (nums[j] < nums[i]) {                // 严格上升
        dp[i] = Math.max(dp[i], dp[j] + 1);
      }
    }
    best = Math.max(best, dp[i]);
  }
  return best;                                // 注意是 max(dp),不是 dp[n-1]
}

/** LIS 解法二:O(n log n) 贪心 + 二分 —— Vue3 diff 用的就是这个 */
function lengthOfLIS(nums) {
  if (!nums || nums.length === 0) return 0;
  const tails = [];                           // tails[k]:长度 k+1 的上升子序列的最小结尾

  for (const num of nums) {
    // 二分找第一个 >= num 的位置(lowerBound)
    let left = 0, right = tails.length;
    while (left < right) {
      const mid = (left + right) >> 1;
      if (tails[mid] < num) left = mid + 1;
      else right = mid;
    }
    if (left === tails.length) tails.push(num);   // 比所有结尾都大 -> 长度 +1
    else tails[left] = num;                        // 替换,让结尾变小
  }
  return tails.length;
}

/**
 * 还原真实的 LIS 下标序列(Vue3 getSequence 的核心思路)
 * 额外维护 prev[i]:nums[i] 在 LIS 中的前驱下标
 */
function getSequence(nums) {
  if (!nums || nums.length === 0) return [];
  const n = nums.length;
  const prev = new Array(n).fill(-1);   // prev[i] = i 的前驱下标
  const tailsIdx = [];                  // 存下标而不是值,便于回溯

  for (let i = 0; i < n; i++) {
    const num = nums[i];
    let left = 0, right = tailsIdx.length;
    while (left < right) {
      const mid = (left + right) >> 1;
      if (nums[tailsIdx[mid]] < num) left = mid + 1;
      else right = mid;
    }
    // 记录前驱:新元素接在长度 left 的那条链后面
    if (left > 0) prev[i] = tailsIdx[left - 1];
    if (left === tailsIdx.length) tailsIdx.push(i);
    else tailsIdx[left] = i;
  }

  // 从最后一个下标回溯,得到真实序列
  const res = [];
  let k = tailsIdx[tailsIdx.length - 1];
  while (k !== -1) {
    res.push(k);
    k = prev[k];
  }
  return res.reverse();
}

// 自查
// lengthOfLIS([])                        -> 0
// lengthOfLIS([1])                       -> 1
// lengthOfLIS([7,7,7,7])                 -> 1   (严格上升,全相等只算 1)
// lengthOfLIS([10,9,2,5,3,7,101,18])     -> 4
// lengthOfLIS([5,4,3,2,1])               -> 1   (全递减)
// getSequence([10,9,2,5,3,7,101,18])     -> [2,4,5,6] (下标,对应值 2,3,7,101)

复杂度:DP 版 O(n²)/O(n);二分版 O(n log n)/O(n)。

【追问 & 加分点】

  • Q:Vue 3 的 diff 为什么用 LIS? 在"未知子序列"的乱序比对阶段,Vue 3 需要把新旧节点对上号后,尽可能少地移动 DOM。新节点在旧列表中的下标构成一个序列,这个序列的最长递增子序列对应的节点是"相对顺序没变"的,可以原地不动,其余节点才需要 insertBefore。这样 DOM 移动次数从 O(n) 降到"n - LIS长度"。React 没做这一步(用的是"最后一个不需移动的下标"的贪心),所以 Vue 3 在大列表乱序场景下 DOM 操作更少。这个答案能直接把算法题拉到框架源码层面,是前端候选人的高分点。
  • Q:tails 数组是不是 LIS 本身? 不是。tails 只保证长度正确,内容可能是"拼凑"的(比如 [10,9,2,5,3,7,101,18] 最后 tails 可能是 [2,3,7,18],虽然这次恰好合法,但一般情况下不保证)。要真实序列必须用 prev 回溯 —— Vue3 源码里的 p 数组就是干这个的。
  • Q:如果要「非严格」上升(允许相等)? 二分条件从 lowerBound 改成 upperBound(tails[mid] <= num 时 left = mid+1)。
  • Q:LIS 的变体? 最长递减子序列(反转数组或改比较符)、俄罗斯套娃信封(LC354,二维排序 + LIS)、最少箭引爆气球(贪心)。

16.4 最长公共子序列 LCS(LC1143)

状态定义:dp[i][j] = text1 前 i 个字符 与 text2 前 j 个字符 的 LCS 长度。(用"前 i 个"而不是"以 i 结尾",是为了让 dp[0][*] 天然为 0,省掉初始化特判。)

转移:

  • text1[i-1] === text2[j-1] → dp[i][j] = dp[i-1][j-1] + 1(这个字符一定在 LCS 里)
  • 不等 → dp[i][j] = max(dp[i-1][j], dp[i][j-1])(丢弃其中一个字符)

注意下标偏移:dp 的下标是 1-based(表示"前 i 个"),字符串是 0-based,所以比较的是 text1[i-1]。

/** LC1143 最长公共子序列 */
function longestCommonSubsequence(text1, text2) {
  if (!text1 || !text2) return 0;              // 边界:任一为空,LCS 为 0
  const m = text1.length, n = text2.length;

  // dp[i][j]:text1 前 i 个 与 text2 前 j 个 的 LCS 长度
  // 多开一行一列,dp[0][*] 和 dp[*][0] 天然为 0
  const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));

  for (let i = 1; i <= m; i++) {
    for (let j = 1; j <= n; j++) {
      if (text1[i - 1] === text2[j - 1]) {
        dp[i][j] = dp[i - 1][j - 1] + 1;       // 字符匹配,长度 +1
      } else {
        dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);  // 舍弃其一,取较大
      }
    }
  }
  return dp[m][n];
}

/** 空间优化:只依赖上一行 -> 滚动数组,空间 O(min(m,n)) */
function lcsOptimized(text1, text2) {
  if (!text1 || !text2) return 0;
  // 让较短的串做列,空间更省
  if (text1.length < text2.length) [text1, text2] = [text2, text1];
  const m = text1.length, n = text2.length;

  let prev = new Array(n + 1).fill(0);
  for (let i = 1; i <= m; i++) {
    const cur = new Array(n + 1).fill(0);
    for (let j = 1; j <= n; j++) {
      if (text1[i - 1] === text2[j - 1]) cur[j] = prev[j - 1] + 1;
      else cur[j] = Math.max(prev[j], cur[j - 1]);
    }
    prev = cur;
  }
  return prev[n];
}

// 自查
// longestCommonSubsequence('', 'abc')       -> 0
// longestCommonSubsequence('a', 'a')        -> 1
// longestCommonSubsequence('abc', 'def')    -> 0    (无公共)
// longestCommonSubsequence('abcde', 'ace')  -> 3    ('ace')
// longestCommonSubsequence('aaa', 'aa')     -> 2    (重复字符)

复杂度:时间 O(mn),空间 O(mn)(优化后 O(min(m,n)))。

【追问 & 加分点】

  • Q:子序列和子串的区别? 子序列不要求连续(可以跳着取),子串必须连续。最长公共子串的转移不同:不匹配时 dp[i][j] = 0(必须断开重来),答案是所有 dp[i][j] 的最大值而不是 dp[m][n]。这是极高频的混淆点。
  • Q:LCS 的实际应用? git diff / diff 命令的核心算法(实际用的是 Myers diff,本质是求最短编辑脚本,与 LCS 对偶);前端的文本 diff 组件、代码 review 高亮、版本对比都基于它。能说出这一点很加分。
  • Q:编辑距离(LC72)和 LCS 什么关系? 同一族的二维字符串 DP。编辑距离多了"替换"操作:dp[i][j] = min(dp[i-1][j]+1 删, dp[i][j-1]+1 增, dp[i-1][j-1]+1 改),字符相等时直接 dp[i-1][j-1]。

16.5 硬币找零(LC322)

状态定义:dp[i] = 凑出金额 i 所需的最少硬币数。

转移:dp[i] = min(dp[i - coin] + 1),遍历所有 coin。

初始化的关键:dp[0] = 0(凑 0 元需要 0 枚),其余初始化为 Infinity 表示"不可达"。用 Infinity 而不是 amount+1 也行,但要注意 Infinity + 1 仍是 Infinity,不会误判成可达。

这是「完全背包求最小值」:每种硬币可以用无限次 → 内层容量正序遍历。

/**
 * LC322 零钱兑换:凑出 amount 的最少硬币数,凑不出返回 -1
 */
function coinChange(coins, amount) {
  if (amount < 0) return -1;                   // 边界:非法金额
  if (amount === 0) return 0;                  // 边界:凑 0 元用 0 枚
  if (!coins || coins.length === 0) return -1; // 边界:没有硬币

  // dp[i] = 凑出金额 i 的最少硬币数,Infinity 表示不可达
  const dp = new Array(amount + 1).fill(Infinity);
  dp[0] = 0;

  for (let i = 1; i <= amount; i++) {
    for (const coin of coins) {
      if (coin <= i && dp[i - coin] !== Infinity) {
        dp[i] = Math.min(dp[i], dp[i - coin] + 1);
      }
    }
  }
  return dp[amount] === Infinity ? -1 : dp[amount];
}

/** LC518 零钱兑换 II:凑出 amount 的「组合数」(完全背包求方案数) */
function change(amount, coins) {
  if (amount < 0) return 0;
  const dp = new Array(amount + 1).fill(0);
  dp[0] = 1;                                   // 凑 0 元有 1 种方法(什么都不选)

  // 关键:外层遍历硬币、内层遍历金额 -> 求「组合数」(不区分顺序)
  // 如果反过来(外层金额、内层硬币),求的是「排列数」(区分顺序)—— 高频陷阱
  for (const coin of coins) {
    for (let i = coin; i <= amount; i++) {
      dp[i] += dp[i - coin];
    }
  }
  return dp[amount];
}

// 自查
// coinChange([1,2,5], 11)   -> 3    (5+5+1)
// coinChange([2], 3)        -> -1   (凑不出)
// coinChange([1], 0)        -> 0
// coinChange([], 5)         -> -1
// coinChange([5,10], 3)     -> -1   (最小面额都大于目标)
// change(5, [1,2,5])        -> 4

复杂度:时间 O(amount × coins.length),空间 O(amount)。

【追问 & 加分点】

  • Q:能不能贪心(每次取最大面额)? 不能。反例:coins = [1, 3, 4],amount = 6。贪心取 4 + 1 + 1 = 3 枚,最优是 3 + 3 = 2 枚。只有在"面额构成规范系统"(如人民币 1/5/10/50/100)时贪心才正确。能给出反例是这题最重要的加分点。
  • Q:求组合数和排列数的循环顺序为什么不同?
    • 外层硬币、内层金额 → 每种硬币只会在其"轮次"里被考虑,天然避免了 [1,2] 和 [2,1] 被算两次 → 组合数。
    • 外层金额、内层硬币 → 每个金额都会尝试所有硬币 → 顺序不同算不同方案 → 排列数(LC377 组合总和 IV 就是这个)。
  • Q:这题的 BFS 解法? 把金额看成图的节点,减去一枚硬币是一条边,求 0 到 amount 的最短路 → BFS 层数就是最少硬币数。思路新颖,能提一句体现建模能力。

16.6 0-1 背包(一维滚动)

问题:n 个物品,第 i 个重 weights[i]、值 values[i],背包容量 W,每个物品最多选一次,求最大价值。

二维状态:dp[i][w] = 前 i 个物品、容量 w 时的最大价值。 dp[i][w] = max(dp[i-1][w] 不选, dp[i-1][w - weights[i]] + values[i] 选)

压成一维的关键:容量必须倒序遍历!

  • 一维 dp[w] 在更新到第 i 个物品时,dp[w] 的旧值代表 dp[i-1][w]。
  • 转移需要 dp[i-1][w - weight],即上一轮的、更小容量的值。
  • 倒序遍历 w(从 W 到 weight)时,dp[w - weight] 还没被本轮更新,仍是上一轮的值 ✓
  • 正序遍历时,dp[w - weight] 已被本轮更新过,变成了 dp[i][w-weight],意味着物品 i 被重复使用 → 那就变成了完全背包!

记忆法:0-1 背包倒序,完全背包正序。 这是背包问题最核心的考点。

/**
 * 0-1 背包(一维滚动数组)
 * @param {number[]} weights 物品重量
 * @param {number[]} values  物品价值
 * @param {number}   W       背包容量
 * @return {number}          最大价值
 */
function knapsack01(weights, values, W) {
  if (!weights || weights.length === 0 || W <= 0) return 0;   // 边界
  const n = weights.length;

  // dp[w] = 容量为 w 时的最大价值
  const dp = new Array(W + 1).fill(0);

  for (let i = 0; i < n; i++) {
    // 关键:容量倒序!保证 dp[w - weights[i]] 用的是「上一轮」的值(物品 i 只用一次)
    for (let w = W; w >= weights[i]; w--) {
      dp[w] = Math.max(dp[w], dp[w - weights[i]] + values[i]);
    }
  }
  return dp[W];
}

/**
 * 完全背包(每个物品可无限次选)—— 只有一处不同:容量正序
 */
function knapsackComplete(weights, values, W) {
  if (!weights || weights.length === 0 || W <= 0) return 0;
  const dp = new Array(W + 1).fill(0);
  for (let i = 0; i < weights.length; i++) {
    for (let w = weights[i]; w <= W; w++) {   // 正序!允许同一物品被重复使用
      dp[w] = Math.max(dp[w], dp[w - weights[i]] + values[i]);
    }
  }
  return dp[W];
}

/**
 * LC416 分割等和子集 —— 0-1 背包的判定型变体
 * 能否把数组分成两个和相等的子集 <=> 能否用部分元素凑出 sum/2
 */
function canPartition(nums) {
  if (!nums || nums.length < 2) return false;
  const sum = nums.reduce((a, b) => a + b, 0);
  if (sum % 2 !== 0) return false;             // 总和为奇数,不可能均分

  const target = sum / 2;
  const dp = new Array(target + 1).fill(false);
  dp[0] = true;                                // 凑出 0 总是可行(什么都不选)

  for (const num of nums) {
    for (let w = target; w >= num; w--) {      // 倒序:0-1 背包
      dp[w] = dp[w] || dp[w - num];
    }
    if (dp[target]) return true;               // 提前返回优化
  }
  return dp[target];
}

// 自查
// knapsack01([], [], 10)                  -> 0
// knapsack01([5], [10], 3)                -> 0    (装不下)
// knapsack01([1,3,4], [15,20,30], 4)      -> 35   (选 1 和 3)
// knapsackComplete([2], [3], 5)           -> 6    (选 2 次,剩 1 容量浪费)
// canPartition([1,5,11,5])                -> true  ([1,5,5] 和 [11])
// canPartition([1,2,3,5])                 -> false (和为 11,奇数)
// canPartition([1])                       -> false
// canPartition([2,2])                     -> true

复杂度:时间 O(n × W),空间 O(W)(一维滚动,从 O(nW) 优化而来)。

【追问 & 加分点】

  • Q:为什么 0-1 背包一维版容量要倒序?(几乎必问) 按上面的推导答:倒序保证 dp[w-weight] 还是"上一轮"(不含物品 i)的值;正序会让物品 i 被重复计入,从而退化成完全背包。能当场用一个小例子(如 weights=[2], W=4)推演正序为什么会选两次,是满分答案。
  • Q:0-1 背包的伪多项式复杂度是什么意思? O(nW) 里的 W 是数值而非输入规模。W 的输入长度是 log W 位,所以复杂度对输入长度是指数级的 —— 这就是"伪多项式"。0-1 背包是 NP 完全问题(判定版本),没有真正的多项式解。能说出这个概念是算法功底的体现。
  • Q:背包问题家族有哪些? 0-1 背包(每个 1 次,倒序)、完全背包(无限次,正序)、多重背包(每个有限次,二进制拆分优化)、分组背包(每组选一个)。求最大值 / 求方案数 / 求可行性(布尔)三种目标。
  • Q:常见的 LeetCode 背包题? LC416 分割等和子集(判定型 0-1)、LC494 目标和(转化成 0-1 求方案数)、LC474 一和零(二维费用背包)、LC322/518 零钱兑换(完全背包)、LC139 单词拆分(完全背包判定型)。

17. 附:数组 vs 链表底层差异

字节三面原题:「判断某个值是否存在,你选数组还是链表?」 标准答案:都不选 —— 选 Set / 哈希表。

【核心答案:底层差异】

维度数组(连续内存)链表(离散节点 + 指针)
内存布局一整块连续内存节点散落在堆各处,靠指针串联
随机访问 a[i]O(1) —— 地址 = 基址 + i × 元素大小,一次寻址O(n) —— 只能从头顺着 next 走
头部插入/删除O(n) —— 后面所有元素要整体搬移O(1) —— 改两个指针
中间插入/删除O(n) 搬移找到位置 O(n) + 操作 O(1)(如果已持有该节点的引用,则是纯 O(1))
尾部追加摊还 O(1)(扩容时 O(n),需要重新分配 + 拷贝)O(1)(若维护 tail 指针)
额外内存开销几乎没有(可能有预留容量)每个节点多一个(或两个)指针,内存开销大 30%~100%
CPU 缓存局部性极好 —— 顺序遍历时,一次 cache line(通常 64 字节)能装下多个元素,预取器(prefetcher)能准确预测极差 —— 每次跳转都可能是一次 cache miss(约 100+ 个 CPU 周期),几乎无法预取

"CPU 缓存局部性"是这题的深度考点:教科书说链表插入是 O(1) 比数组快,但实际测量中,几万个元素以内的顺序遍历和中间插入,数组往往比链表快得多,就是因为缓存命中率的巨大差异。这也是为什么现代 C++ 社区普遍建议"默认用 vector,除非有明确理由用 list"。能讲出这一层,说明你不是只背了复杂度表。

【关于 JS 的特殊性 —— 前端必答】

JS 的 Array 不是真正的连续内存数组。 V8 内部有两种模式:

  • 快速模式(Fast Elements / PACKED):元素类型一致、下标连续无空洞时,V8 用真正的连续内存存储(甚至有 PACKED_SMI_ELEMENTS、PACKED_DOUBLE_ELEMENTS 等针对数字的紧凑布局)。此时性能接近原生数组。
  • 字典模式(Dictionary Elements / HOLEY):一旦出现空洞(arr[100] = 1 但 arr.length 原本是 3)或类型混杂,V8 降级成哈希表存储,访问从 O(1) 退化成哈希查找,性能骤降一个数量级。

工程结论:

  • 不要用 delete arr[i](会造成空洞),用 splice。
  • 不要跳跃赋值下标。
  • 尽量保持数组元素类型一致(全数字或全对象)。
  • 预分配用 new Array(n).fill(0) 而不是 new Array(n)(后者是 HOLEY)。

这三条能直接答出来,是前端候选人区别于"只会刷题"的关键。

【"判断某值是否存在"选什么?—— 原题标准答案】

方案查找时间说明
无序数组 includes / indexOfO(n)逐个比较
链表遍历O(n)且缓存不友好,实际比数组慢
有序数组 + 二分O(log n)但插入是 O(n),且需要先排序 O(n log n)
Set / Map(哈希表)平均 O(1)正确答案

回答的层次感(面试话术):

「如果只是判断存在性,数组和链表都不是好选择 —— 它们都是 O(n) 线性查找。我会用 Set(或 Map),基于哈希表,平均 O(1)。

具体权衡还要看场景:

  • 数据量小(< 几十):数组 includes 反而更快,因为哈希计算和对象分配有固定开销,而小数组能完全放进 CPU 缓存里线性扫。
  • 数据一次构建、多次查询:Set 绝对最优,构建 O(n) 摊到多次查询上可以忽略。
  • 需要有序 / 需要范围查询(找出所有 5~10 之间的值):哈希表做不到,得用有序数组 + 二分 或 跳表 / 平衡树。
  • key 是对象且不想阻止垃圾回收:用 WeakSet / WeakMap。
  • 数据是海量的、允许极小误判率:布隆过滤器,空间比 Set 小几个数量级(代价是有假阳性、不能删除)。」

这个分层回答能同时展示:算法基础、JS 语言特性、工程权衡意识。

【追问 & 加分点】

  • Q:Set 的查找真的是 O(1) 吗? 是平均 O(1),最坏 O(n)(所有 key 哈希冲突时退化成链表)。现代实现(V8 用的是开放寻址 + 有序哈希表)通过良好的哈希函数和扩容策略把最坏情况的概率降到可忽略。另外 V8 的 Set/Map 保证插入顺序,这是 ES 规范要求的,实现上是"哈希表 + 有序数组"的组合。
  • Q:什么时候链表真的比数组好? ① 频繁在已知位置做插入/删除(已持有节点引用,如 LRU 缓存的双向链表);② 不需要随机访问;③ 元素数量剧烈波动,数组反复扩容/缩容的拷贝代价高;④ 元素本身很大,数组扩容时拷贝成本高。 LRU Cache(LC146)= 哈希表 + 双向链表,是"两种结构互补"的最佳案例:哈希表提供 O(1) 定位,双向链表提供 O(1) 移动/删除。面试举这个例子很有说服力。
  • Q:JS 里有真正的连续内存数组吗? 有 —— TypedArray(Int32Array、Float64Array 等)和 ArrayBuffer。它们是固定类型 + 固定长度 + 真正连续内存,没有 V8 的模式降级问题。在 WebGL、Canvas 像素处理、音视频、WASM 交互、大规模数值计算场景应该用它们,性能能提升数倍。能提到这一点在图形/性能岗位是明显加分。
  • Q:数组扩容的摊还分析? 按倍增(×1.5 或 ×2)扩容时,n 次 push 的总拷贝代价是 1 + 2 + 4 + ... + n ≈ 2n,摊还到每次是 O(1)。如果按固定量(+10)扩容,摊还就变成 O(n) 了。这是"摊还复杂度"的教科书例子。


结语:面试时的表达框架

手撕算法时,说的比写的重要。推荐固定流程:

  1. 确认题意和边界(30 秒):「输入可能为空吗?有重复元素吗?数值范围?要返回下标还是值?」—— 这一步能救回很多误解题意的场面。
  2. 先说思路再动手(1 分钟):说清状态定义/不变式/为什么这样能行,让面试官确认方向对了再写。方向错了写完也是白写。
  3. 写代码:变量名有意义,关键行加注释,注意缩进。
  4. 主动自查边界(30 秒):当场用「空输入 / 单元素 / 全重复 / 最大最小值」跑一遍,指着代码说"这里 xxx 情况会走到这个分支"。
  5. 报复杂度:时间 + 空间,并说明瓶颈在哪一步。
  6. 主动提优化方向:「如果数据量到十亿级,我会改成 XXX」——把话题引到自己准备好的深水区。

记住:面试官招的是能解决问题的工程师,不是背题机器。展现"分析问题的框架"永远比"背出最优解"分数更高。

反向链接(0)

还没有笔记链接到这里。在 Obsidian 里用 [[笔记名]] 引用它,这里就会出现,关系图谱上也会多出一条连线。

评论

关系图谱

01-js-core02-browser03-network04-css-html05-react06-vue07-engineering08-scenarios09-handwritten-js10-algorithms11-ai-frontend前端识记前端面试

On this page

算法手撕目录一、面经原题(字节 / 腾讯 / PDD)二、多 offer 手撕高频清单一、面经原题1. LC46 全排列 + LC47 全排列 II(字节一面)【思路 / 核心答案】代码复杂度【追问 & 加分点】2. LC198 打家劫舍 + LC213 打家劫舍 II(字节、腾讯WXG)【思路 / 核心答案】代码复杂度【追问 & 加分点】3. LC101 对称二叉树(字节二面)【思路 / 核心答案】代码复杂度【追问 & 加分点】4. LC199 二叉树右视图(腾讯)【思路 / 核心答案】代码复杂度【追问 & 加分点】5. 多个已排序整数数组求交集(腾讯)【思路 / 核心答案】代码复杂度【追问 & 加分点】6. LC162 寻找峰值(PDD)【思路 / 核心答案】代码复杂度【追问 & 加分点】7. 快速排序(字节三面)【思路 / 核心答案】代码复杂度【追问 & 加分点】8. 链表排序(腾讯实习,ACM 格式)【思路 / 核心答案】代码(含 ACM 格式的输入输出处理)复杂度【追问 & 加分点】9. 数组取最大 K 个数(字节三面)【思路 / 核心答案】代码复杂度【追问 & 加分点】10. 智力题:100 人,1 人带病毒,2 张试纸【思路 / 核心答案】代码(解读 C:通用双试纸最优次数 DP)【追问 & 加分点】二、多 offer 手撕高频清单11. 排序综述【思路 / 核心答案】代码对比表(面试直接背这张)【追问 & 加分点】12. 二分查找与边界模板【思路 / 核心答案】代码复杂度【追问 & 加分点】13. 字符串逆序 / 判断回文【思路 / 核心答案】代码复杂度【追问 & 加分点】14. 链表六连14.1 反转链表(LC206,迭代 + 递归)【思路】14.2 链表倒数第 K 个节点(剑指 22)【思路】14.3 判断链表有环 + 找入环节点(LC141 / LC142)【思路 / 数学证明】14.4 合并两个有序链表(LC21)14.5 合并 K 个有序链表(LC23,分治)【思路】【追问 & 加分点(链表整体)】15. 二叉树遍历 + 深度 + 路径总和【思路 / 核心答案】代码复杂度【追问 & 加分点】16. DP 精选【统一方法论 —— 面试先说这个】16.1 斐波那契(滚动变量)16.2 最大子数组和(Kadane,LC53)16.3 最长上升子序列 LIS(LC300)16.4 最长公共子序列 LCS(LC1143)16.5 硬币找零(LC322)16.6 0-1 背包(一维滚动)17. 附:数组 vs 链表底层差异【核心答案:底层差异】【关于 JS 的特殊性 —— 前端必答】【"判断某值是否存在"选什么?—— 原题标准答案】【追问 & 加分点】结语:面试时的表达框架