10-algorithms
面向大厂前端面试的算法标准答案集。所有代码用 JavaScript 编写(前端手撕默认语言),带中文注释。 每题结构:【思路 / 核心答案】→ 代码 → 复杂度 → 【追问 & 加分点】。 所有代码均…
算法手撕
面向大厂前端面试的算法标准答案集。所有代码用 JavaScript 编写(前端手撕默认语言),带中文注释。 每题结构:【思路 / 核心答案】→ 代码 → 复杂度 → 【追问 & 加分点】。 所有代码均按「空输入 / 单元素 / 全重复值 / 边界越界」自查过。
目录
一、面经原题(字节 / 腾讯 / PDD)
- LC46 全排列 + LC47 全排列 II
- LC198 打家劫舍 + LC213 环形打家劫舍
- LC101 对称二叉树
- LC199 二叉树右视图(BFS + DFS 两解)
- 多个已排序数组求交集
- LC162 寻找峰值
- 快速排序(非原地 + 原地)
- 链表排序(归并)
- 数组取最大 K 个数(三解)
- 智力题:100 人 1 病毒 2 张试纸
二、多 offer 手撕高频清单
- 排序综述(冒泡/选择/插入/归并 + 对比表)
- 二分查找(递归 + 非递归 + 三大边界模板)
- 字符串逆序与回文判断
- 链表六连(反转/倒数K/环/合并/合并K)
- 二叉树遍历与深度、路径总和
- DP 精选(斐波那契/Kadane/LIS/LCS/硬币/背包)
- 附:数组 vs 链表底层差异 & "判断某值是否存在"选谁
一、面经原题
1. LC46 全排列 + LC47 全排列 II(字节一面)
【思路 / 核心答案】
全排列的本质是"决策树 + 回溯":第 1 层选谁做第一个数,第 2 层从剩下的里选第二个……走到叶子(路径长度 == n)就是一个解。
三个关键要素,面试时先说出来再写代码:
- 路径 path:已经做出的选择。
- 选择列表:本层还能选谁 —— 用
used[]布尔数组标记(比每次filter生成新数组更省内存,也是面试官想看的写法)。 - 结束条件:
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 个)复杂度
| 时间 | 空间 | |
|---|---|---|
| LC46 | O(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 互为镜像,当且仅当:
p和q都为空 → 是;- 只有一个为空 → 否;
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]。每轮:
- 取出所有指针当前指向的值,求 最大值
maxVal; - 如果
k个值全等(等价于全等于maxVal)→ 命中交集,记录,然后所有指针都跳过这一段相等值(处理重复); - 否则,把所有 值 < maxVal 的指针往前推进,直到
>= maxVal; - 任一指针越界 → 结束。
空间从 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. 快速排序(字节三面)
【思路 / 核心答案】
分治三步:
- 选基准 pivot;
- 分区 partition:把数组分成
< pivot和>= pivot两部分; - 递归左右两部分。
面试要准备两个版本,并说清取舍:
- 非原地简洁版:用
filter分三份再拼接。5 行写完,逻辑一眼看懂,适合先写出来展示思路。缺点:每层都新建数组,空间 O(n log n),不是真正的快排。 - 原地分区版(Lomuto / Hoare):这才是面试官要的"手撕快排"。空间只有递归栈 O(log n)。
必须主动说的三个工程点(这是三面区分度所在):
- 随机化 pivot:固定取首/尾元素时,遇到已排序数组会退化成 O(n²) 且递归深度 O(n) 直接爆栈。随机选 pivot(或三数取中)把最坏情况的概率降到可忽略。
- 尾递归优化 / 先递归短的一侧:保证递归深度 O(log n)。
- 快排不稳定:分区时的交换会打乱相等元素的相对顺序。所以 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) 额外空间 |
| 缓存局部性 | 数组友好(快排优势主要来源) | 链表本来就跳着走,快排的缓存优势荡然无存 |
结论:链表的归并没有数组归并的空间劣势(不用辅助数组),而快排失去了它在数组上的全部优势(随机访问、缓存局部性)。所以链表排序 = 归并排序,这是标准答案。
三步走:
- 快慢指针找中点并断开:
slow走一步fast走两步。 关键细节:要用prev记录slow的前驱,或让fast从head.next起步,才能拿到左半段的末尾去next = null断开。如果不断开,递归永远不会终止(这是最常见的 bug)。 - 递归排序左右两段。
- 合并两个有序链表。
递归终止条件: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 个数(字节三面)
面试官原话:「排序当然可以,不用排序还能怎么做?」
【思路 / 核心答案】
三种方案,要能说清各自的适用场景 —— 这题考的是取舍判断而不是代码。
| 方案 | 时间 | 空间 | 适用场景 |
|---|---|---|---|
| ① 全排序取前 k | O(n log n) | O(1)~O(n) | k 接近 n;或本来就需要有序结果;代码最短 |
| ② 小顶堆维护 size=k | O(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 怎么办?
这是本题的真实业务版:
- 小顶堆(size=100)单遍流式扫描 —— 内存占用固定 100 个数,一遍过。首选答案。
- 若数据分布在多台机器:每台机器各自算局部 Top 100 → 汇总后再求全局 Top 100(MapReduce 思路)。
- 若数值范围有限:桶排序 / 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 张试纸
【思路 / 核心答案】
这道题的第一步不是算,是澄清规则。 面试官考的就是你会不会先问清楚约束 —— 直接开算是减分项。要问清三件事:
- 检测结果是立刻可见的吗(能否根据上一次结果决定下一次怎么测)?→ 自适应 vs 非自适应
- 试纸用完是否报废?阴性能不能复用?阳性呢?
- "尽量少检测次数"指的是试纸使用总次数,还是检测轮数(可并行)?
共同的推理框架:混检(分组混合检测)
核心洞察:一次检测不必只测一个人。把若干人的样本混在一起测:
- 结果阳性 → 病毒携带者在这组里(因为只有 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 张试纸可多轮使用"。 - 主动指出这一点,比闷头算一个错答案强得多。
- 如果限定"只能做 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。闭区间 →
while (left <= right),收缩left = mid+1/right = mid-1。 - 左边界(lower_bound):第一个
>= target的位置。用途:找目标的第一次出现、插入位置。 - 右边界(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+ 栈。一路向左压栈到底 → 弹出访问 → 转向右子树。 - 后序(最难):两种写法
- 改造前序:按
根 → 右 → 左遍历,结果反转,就是左 → 右 → 根。5 行搞定,面试首选。 - 单栈 + 标记 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) |
| 层序 BFS | O(n) | O(w),w 为最大层宽,最坏 O(n) |
| maxDepth / minDepth | O(n) | O(h) |
| hasPathSum | O(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 题,按四步走,说出来比直接写代码更能拿分:
- 定义状态:
dp[i]表示什么?(必须精确到"以...结尾"还是"前 i 个") - 推导转移方程:
dp[i]由哪些更小的状态推出? - 确定初始化和边界。
- 确定遍历顺序(正序/倒序,这在背包里是决定性的)。
- (可选)空间优化:只依赖前几项 → 滚动数组/滚动变量。
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 / indexOf | O(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) 了。这是"摊还复杂度"的教科书例子。
结语:面试时的表达框架
手撕算法时,说的比写的重要。推荐固定流程:
- 确认题意和边界(30 秒):「输入可能为空吗?有重复元素吗?数值范围?要返回下标还是值?」—— 这一步能救回很多误解题意的场面。
- 先说思路再动手(1 分钟):说清状态定义/不变式/为什么这样能行,让面试官确认方向对了再写。方向错了写完也是白写。
- 写代码:变量名有意义,关键行加注释,注意缩进。
- 主动自查边界(30 秒):当场用「空输入 / 单元素 / 全重复 / 最大最小值」跑一遍,指着代码说"这里 xxx 情况会走到这个分支"。
- 报复杂度:时间 + 空间,并说明瓶颈在哪一步。
- 主动提优化方向:「如果数据量到十亿级,我会改成 XXX」——把话题引到自己准备好的深水区。
记住:面试官招的是能解决问题的工程师,不是背题机器。展现"分析问题的框架"永远比"背出最优解"分数更高。
反向链接(0)
还没有笔记链接到这里。在 Obsidian 里用 [[笔记名]] 引用它,这里就会出现,关系图谱上也会多出一条连线。