Jack N @ GitHub

Full stack AI Engineer, focus on: React, Next.js, node.js and .Net

1. 题目

两个整数之间的汉明距离指的是这两个数字对应二进制位不同的位置的数目。

给你两个整数 x 和 y,计算并返回它们之间的汉明距离。

示例:

1
2
3
4
5
6
7
输入: x = 1, y = 4
输出: 2
解释:
1 (0 0 0 1)
4 (0 1 0 0)
↑ ↑
上面的箭头指出了对应二进制位不同的位置。

2. 解题思路

2.1 异或 + 统计 1 的个数

对应位不同 ⟺ 异或后该位为 1。所以:

  1. n = x ^ y
  2. 统计 n 的二进制中 1 的个数(population count)。

统计 1 的个数有两种经典写法:

  • 逐位 n & 1 后右移。

  • n & (n - 1) 每次抹掉最低位的 1,循环次数 = 1 的个数,更优。

  • 时间复杂度:O(1)(最多 32 位),空间复杂度:O(1)。

注意:JS 的位运算按 32 位有符号整数处理,本题 LeetCode 数据范围 0 ≤ x, y ≤ 2³¹-1,用 ^、& 安全;若涉及无符号右移请用 >>>。

3. TypeScript 实现

3.1 Brian Kernighan 算法(推荐)

1
2
3
4
5
6
7
8
9
10
11
function hammingDistance(x: number, y: number): number {
let n = x ^ y;
let count = 0;
while (n) {
n = n & (n - 1); // 抹掉最低位的 1
count++;
}
return count;
}

console.log(hammingDistance(1, 4)); // 2

3.2 内置位计数(一行流)

1
2
3
const hammingDistanceBitCount = (x: number, y: number): number =>
(x ^ y).toString(2).split("0").join("").length;
// 或 (x ^ y).toString(2).match(/1/g)?.length ?? 0

4. 面试延伸

4.1 总汉明距离(LeetCode 477)

给数组 nums,求所有数对汉明距离之和。逐位暴力 O(n²·32) 会超时,按位统计更优:

对第 i 位,统计数组中该位为 1 的个数 ones,为 0 的个数 zeros = n - ones,则该位对总距离的贡献为 ones * zeros(每一位独立)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function totalHammingDistance(nums: number[]): number {
let total = 0;
const n = nums.length;

for (let i = 0; i < 32; i++) {
let ones = 0;
for (const x of nums) {
ones += (x >>> i) & 1;
}
total += ones * (n - ones); // 该位 1 与 0 的两两配对数
}

return total;
}
  • 时间复杂度:O(32·n),空间复杂度:O(1)。

4.2 知识点

  • n & (n-1) 技巧:可用于判断 2 的幂、统计位 1 个数、求两数异或差异。
  • 汉明距离是「信息/编码」基础概念,延伸可谈海明码纠错。

难度:简单 | LeetCode 461 / 477 | 位运算

1. 题目

给你二叉树的根节点 root,返回其节点值的锯齿形层序遍历。(即先从左往右,再从右往左进行下一层遍历,以此类推,层与层之间交替进行)。

示例:

1
2
3
4
5
6
7
8
输入: root = [3,9,20,null,null,15,7]
3
/ \
9 20
/ \
15 7
输出: [[3],[20,9],[15,7]]
解释: 第 0 层从左到右 [3],第 1 层从右到左 [20,9],第 2 层从左到右 [15,7]。

2. 解题思路

本质还是层序遍历(BFS),只是在收集结果时按层的奇偶决定正序还是逆序。

2.1 BFS + 奇偶翻转(推荐)

  • 用队列逐层遍历,逻辑与普通层序遍历完全一致。

  • 维护 leftToRight 布尔标记,每处理完一层取反。

  • 该层节点值先按正常顺序存入 level,若当前层应从右到左,则 level.reverse() 后再 push 进结果。

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

2.2 双端队列 / 头尾插入

把每层结果用 unshift/push 分别从头或尾插入,避免整层 reverse。写法更巧但可读性略差。

3. TypeScript 实现

3.1 BFS + 层反转

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class TreeNode {
val: number;
left: TreeNode | null;
right: TreeNode | null;
constructor(val = 0) {
this.val = val;
this.left = this.right = null;
}
}

function zigzagLevelOrder(root: TreeNode | null): number[][] {
if (!root) return [];
const res: number[][] = [];
const queue: TreeNode[] = [root];
let leftToRight = true;

while (queue.length) {
const size = queue.length;
const level: number[] = [];

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(leftToRight ? level : level.reverse());
leftToRight = !leftToRight; // 下一层方向翻转
}

return res;
}

3.2 头尾插入(免 reverse)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function zigzagLevelOrderDeque(root: TreeNode | null): number[][] {
if (!root) return [];
const res: number[][] = [];
let queue: TreeNode[] = [root];
let leftToRight = true;

while (queue.length) {
const level: number[] = [];
const next: TreeNode[] = [];

for (const node of queue) {
leftToRight ? level.push(node.val) : level.unshift(node.val);
if (node.left) next.push(node.left);
if (node.right) next.push(node.right);
}

res.push(level);
queue = next;
leftToRight = !leftToRight;
}

return res;
}

4. 面试延伸

  • 与二叉树层序遍历、右视图是同一套 BFS 模板的小变形,考察「层」的边界控制(size 快照)。
  • DFS 也能做:把每层值按奇偶 push/unshift 到 res[depth],注意方向由 depth % 2 决定。
  • 坑点:level.reverse() 是原地反转,此处无副作用;但在其他共享引用场景要警惕。

难度:中等 | LeetCode 103 | BFS 层序变形

1. 题目

给定一棵二叉树的根节点 root,想象自己站在它的右侧,按照从顶部到底部的顺序,返回从右侧所能看到的节点值。

示例:

1
2
3
4
5
6
7
8
输入: [1,2,3,null,5,null,4]
1 <---
/ \
2 3 <---
\ \
5 4 <---
输出: [1,3,4]
解释: 每一层最右边的节点构成右视图。

2. 解题思路

「右视图」本质是求每一层的最右一个节点。两种主流做法:

2.1 BFS 层序遍历(最直观)

用队列逐层遍历,记录每层的节点个数 levelSize,取该层最后一个出队的节点值加入结果。

  • 时间复杂度:O(n),空间复杂度:O(n)(队列最宽一层的节点数)。

2.2 DFS(根 → 右 → 左)

关键是先遍历右子树。对每一层,第一个被访问到的节点就是右视图节点。用 depth 记录层数,当 res.length === depth 时说明这一层还没收集过节点,push 当前值。

  • 因为先走右,所以「第一个到达」的必然是该层最右侧节点。

3. TypeScript 实现

树节点定义:

1
2
3
4
5
6
7
8
9
class TreeNode {
val: number;
left: TreeNode | null;
right: TreeNode | null;
constructor(val = 0) {
this.val = val;
this.left = this.right = null;
}
}

3.1 BFS

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function rightSideView(root: TreeNode | null): number[] {
if (!root) return [];
const res: number[] = [];
const queue: TreeNode[] = [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;
}

3.2 DFS(先右后左)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function rightSideViewDFS(root: TreeNode | null): number[] {
const res: number[] = [];

const dfs = (node: TreeNode | null, depth: number) => {
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;
}

4. 面试延伸

  • 左视图:BFS 取每层 i === 0;DFS 改成「先左后右」。
  • 俯视图 / 剖面等变形考察的是对遍历顺序与层/列归属的理解。
  • 追问:DSF 解法空间上省去队列,但递归栈最坏 O(h),h 为树高; skewed tree 下退化为 O(n)。
  • queue.shift() 在 JS 中是 O(n),严格优化可用双指针索引或自定义队列,面试点出即可。

难度:中等 | LeetCode 199 | BFS/DFS 层序思想

1. 题目

给定一个没有重复数字的数组 nums,返回其所有可能的全排列。可以按任意顺序返回答案。

示例:

1
2
输入: nums = [1,2,3]
输出: [[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]

2. 解题思路

排列与组合/子集的最大区别:顺序有关,每层都要从头开始选。

2.1 回溯 + used 标记

  • 每一层递归都遍历整个数组,用 used[i] 记录 nums[i] 是否已在当前路径中。
  • 选中一个数就 path.push + used[i] = true,递归进入下一层;返回后撤销(pop + used[i] = false)。
  • 当 path.length === nums.length 时到达叶子节点,收集一个完整排列。

排列数 n!,故:

  • 时间复杂度:O(n·n!),空间复杂度:O(n)。

2.2 交换法(原地生成,省空间)

在 index 位置,依次把后面的元素换到前面,递归处理 index+1。优点是不需要额外 used 数组,但顺序不保证字典序。

3. TypeScript 实现

3.1 回溯 + used

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
function permute(nums: number[]): number[][] {
const res: number[][] = [];
const path: number[] = [];
const used = new Array<boolean>(nums.length).fill(false);

const backtrack = () => {
if (path.length === nums.length) {
res.push([...path]); // 到达叶子才收集
return;
}

for (let i = 0; i < nums.length; i++) {
if (used[i]) continue; // 同一条路径不能重复用同一个元素
used[i] = true;
path.push(nums[i]);

backtrack();

path.pop();
used[i] = false; // 撤销选择
}
};

backtrack();
return res;
}

console.log(permute([1, 2, 3]));

3.2 交换法(原地)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function permuteSwap(nums: number[]): number[][] {
const res: number[][] = [];

const dfs = (first: number) => {
if (first === nums.length) {
res.push([...nums]);
return;
}
for (let i = first; i < nums.length; i++) {
[nums[first], nums[i]] = [nums[i], nums[first]];
dfs(first + 1);
[nums[first], nums[i]] = [nums[i], nums[first]]; // 回溯
}
};

dfs(0);
return res;
}

4. 面试延伸

  • 变体:Permutations II(含重复数字):先排序,同层去重 if (i > 0 && nums[i] === nums[i-1] && !used[i-1]) continue;——前一个相同值未被使用时跳过,保证相同元素只按固定相对顺序出现一次。
  • 子集 vs 组合 vs 排列 的递归起点:子集/组合用 start(不回头),排列每层从 0 开始(靠 used 去重)。这是最容易混的考点。
  • JS 中 path.push(...) 传引用会污染结果,务必 [...path] 或 path.slice() 拷贝。

难度:中等 | LeetCode 46 | 回溯法核心题

1. 题目

给你一个整数数组 nums,数组中的元素互不相同。返回该数组所有可能的子集(幂集)。

解集不能包含重复的子集,你可以按任意顺序返回解集。

示例:

1
2
输入: nums = [1,2,3]
输出: [[],[1],[2],[1,2],[3],[1,3],[2,3],[1,2,3]]

2. 解题思路

子集总数为 2ⁿ,所以任何解法时间复杂度下限就是 O(n·2ⁿ)。常见三种套路:

2.1 回溯法(选 / 不选)

把问题看成遍历一棵决策树:对每个元素做「选它」或「跳过它」的决定。为了避免产生重复子集,引入 start 参数——每层只允许从当前下标往后选,保证结果按下标递增顺序排列。

  • 递归到任意节点时,path 都是一个合法子集(不只是叶子节点),所以进入递归就收集。

  • 时间复杂度:O(n·2ⁿ),空间复杂度:O(n)(递归栈)。

2.2 迭代法(逐步扩张)

从 [[]] 开始,每遇到一个新数 x,就把已有所有子集各追加一个 x 形成新子集,并入结果。

2.3 二进制枚举

用 0~2ⁿ-1 的每个整数的二进制位表示「第 i 个元素选或不选」,适合炫技。

3. TypeScript 实现

3.1 回溯法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function subsets(nums: number[]): number[][] {
const res: number[][] = [];
const path: number[] = [];

const backtrack = (start: number) => {
// 每个节点都是一个合法子集
res.push([...path]);

for (let i = start; i < nums.length; i++) {
path.push(nums[i]); // 选
backtrack(i + 1); // 只往后看,避免重复
path.pop(); // 不选(撤销选择)
}
};

backtrack(0);
return res;
}

console.log(subsets([1, 2, 3]));
// [[],[1],[2],[1,2],[3],[1,3],[2,3],[1,2,3]]

3.2 迭代法

1
2
3
4
5
6
7
8
9
10
function subsetsIterative(nums: number[]): number[][] {
let res: number[][] = [[]];

for (const x of nums) {
// 基于「本轮开始前」的子集扩张,不能边遍历边 push 同一数组
res = [...res, ...res.map((set) => [...set, x])];
}

return res;
}

3.3 二进制枚举

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function subsetsBitmask(nums: number[]): number[][] {
const n = nums.length;
const res: number[][] = [];

for (let mask = 0; mask < 1 << n; mask++) {
const subset: number[] = [];
for (let i = 0; i < n; i++) {
if (mask & (1 << i)) subset.push(nums[i]);
}
res.push(subset);
}

return res;
}

4. 面试延伸

  • 变体:Subsets II(含重复元素):先排序,同一层中跳过与前一个相同的值 if (i > start && nums[i] === nums[i-1]) continue;。
  • 回溯三板斧:路径(path)→ 选择列表(start 之后的元素)→ 结束条件(遍历完当前层)。收集时机是本题关键:子集问题在每个节点收集,组合问题通常在叶子收集。
  • 与全排列对比:排列每层从 0 开始并用 used 数组去重,子集/组合用 start 剪枝。

难度:简单 | LeetCode 78 | 回溯入门必考

1. 题目

给定一个长度为 n 的整数数组 height,第 i 条线的两个端点是 (i, 0) 和 (i, height[i])。

找出其中的两条线,使得它们与 x 轴共同构成的容器可以容纳最多的水,返回容器可以储存的最大水量。

说明:你不能倾斜容器。

示例:

1
2
3
4
输入: [1,8,6,2,5,4,8,3,7]
输出: 49
解释: 选择第 2 条线(height=8)和第 9 条线(height=7),
宽度 = 9 - 2 = 7,高度取较短板 min(8,7) = 7,面积 = 7 * 7 = 49。

2. 解题思路

2.1 暴力法(不可取)

枚举所有左右边界对 (i, j),计算 (j - i) * min(height[i], height[j]),时间复杂度 O(n²),面试中只作为铺垫。

2.2 对撞双指针(最优)

从最宽区间开始,左右指针 l = 0、r = n - 1,每步计算当前面积并更新最大值,然后移动较短的那一边:

  • 容器高度由短板决定。若 height[l] < height[r],则所有以 l 为左边、右边界在 (l, r) 之间的方案,宽度更小且高度不会超过 height[l],必然不如当前解——所以可以直接排除 l,令 l++。
  • 反过来移动长板则可能错过更优解,这是正确性的关键。
  • 相等时移动任意一边均可。

每一步排除一个不可能的边界,n-1 步内完成。

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

3. TypeScript 实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function maxArea(height: number[]): number {
let l = 0;
let r = height.length - 1;
let best = 0;

while (l < r) {
const h = Math.min(height[l], height[r]);
best = Math.max(best, h * (r - l));

// 移动较短的一边,才可能找到更大的面积
if (height[l] < height[r]) {
l++;
} else {
r--;
}
}

return best;
}

console.log(maxArea([1, 8, 6, 2, 5, 4, 8, 3, 7])); // 49

4. 面试延伸

  • 为什么移动短板? 反证法:移动长板后宽度必减小,而高度受短板限制不会增大,面积只可能变小;移动短板则高度有可能提升,值得一试。
  • 双指针家族对照:两数之和 II、三数之和、接雨水都用到了对撞指针思想。
  • 常见追问:如果要求输出是哪两条线,只需在更新 best 时记录 l、r 即可。

难度:中等 | LeetCode 11 | 对撞双指针经典题

1. 题目

实现一个通用柯里化函数 curry(fn),把一个「需要 n 个参数」的函数,转换成可以一次传一部分参数、持续返回自身、直到收集够 n 个参数后才真正调用原函数的函数。

示例:

1
2
3
4
5
6
const add = (a, b, c) => a + b + c;
const curried = curry(add);

curried(1)(2)(3); // 6
curried(1, 2)(3); // 6
curried(1)(2, 3); // 6

进阶要求:支持占位符 curry._,允许跳过某参数稍后再填:

1
2
3
const _ = curry._;
curried(_, 2)(1)(3); // 6
curried(1, _, 3)(2); // 6

2. 解题思路

柯里化的本质是闭包累积参数。

2.1 基础版(按 arity 触发)

  1. 用 fn.length 得到原函数所需参数个数 n。
  2. 返回一个 acc 函数,每次调用把新参数并入已累积的 args(闭包持有)。
  3. 若 args.length >= n,用 fn.apply(this, args) 真正执行;否则返回一个把新参数继续累积的函数(保持链式)。

判断「够了就执行、没够就返回函数」是核心。为了让 curried(1) 后还能 curried(1)(2),返回的必须是函数——用 function () { return acc(...args.concat(...arguments)); } 包一层。

2.2 占位符版

在累积参数后,做两步清洗:

  • 记录本次调用是否以「尾部实参」结束,用于决定何时可执行。
  • 只有当「非占位符参数数量 >= n」时才执行;执行前把参数按先前逻辑压缩(去掉多余的前导 _),保证位置对应。

这部分逻辑较绕,面试能写基础版 + 口头讲清占位符思路已足够。

  • 时间复杂度:每次调用 O(已收集参数)。

3. TypeScript 实现

3.1 基础版

1
2
3
4
5
6
7
8
9
10
11
12
13
function curry(fn: Function) {
return function curried(this: any, ...args: any[]): any {
if (args.length >= fn.length) {
return fn.apply(this, args);
}
return (...rest: any[]) => curried.apply(this, [...args, ...rest]);
};
}

const add = (a: number, b: number, c: number) => a + b + c;
const curriedAdd = curry(add);
console.log(curriedAdd(1)(2)(3)); // 6
console.log(curriedAdd(1, 2)(3)); // 6

3.2 支持占位符版

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
const _ = Symbol("placeholder");

function curryWithPlaceholder(fn: Function): any {
const judge = (...args: any[]): any =>
// 非占位符参数足够时执行
args.length >= fn.length &&
args.slice(0, fn.length).every((a) => a !== _)
? fn(...args.filter((a) => a !== _))
: (arg: any, ...rest: any[]) => judge(...replace(args, arg), ...rest);

// 用新传入的 arg 填补 args 中第一个占位符;没有则追加
function replace(args: any[], arg: any): any[] {
const idx = args.indexOf(_);
if (idx !== -1 && arg !== _) {
const copy = [...args];
copy[idx] = arg;
return copy;
}
return [...args, arg];
}

return judge;
}

const sum3 = (a: number, b: number, c: number) => a + b + c;
const c3 = curryWithPlaceholder(sum3);
console.log(c3(_, 2)(1)(3)); // 6
console.log(c3(1, _, 3)(2)); // 6
console.log(c3(1)(2)(3)); // 6

// 暴露占位符
curryWithPlaceholder._ = _;

4. 面试延伸

  • 柯里化 vs 部分应用(partial):柯里化把「多元函数」拆成「一连串一元函数」;部分应用是一次固定若干参数。JS 里 bind 天然就是部分应用:fn.bind(null, 1)。
  • 实战价值:参数复用、延迟执行、函数组合(compose/pipe 需要一元函数)、创建更专用的小函数(如把通用的 log(date, level, msg) 柯里化成 errorLog = log(now, 'error'))。
  • TS 类型级柯里化是炫技加分项:用递归条件类型 Curry<F> 依据 Parameters<F> 推导出重载签名,展示你对类型体操的掌握。
  • 常见追问:为什么判断用 >= 而非 ===?(箭头函数/默认参数会让 fn.length 变小,容错用 >= 更稳);fn.length 与 arguments.length 的区别是什么。

难度:中等 | 函数式入门必考 | 闭包与参数复用

1. 题目

实现一个函数 myInstanceof(left, right),等价于原生 instanceof 运算符:

  • 判断 left 是否是 right 的实例,即 right.prototype 是否出现在 left 的原型链上。
  • 是返回 true,否则返回 false。

示例:

1
2
3
4
myInstanceof([], Array)      // true
myInstanceof([], Object) // true(Array.prototype 的原型是 Object.prototype)
myInstanceof(1, Number) // false(原始值不在原型链上;new Number(1) 则 true)
myInstanceof(null, Object) // false

2. 解题思路

instanceof 的本质:沿 left 的原型链逐级向上查找,看能否碰到 right.prototype。

关键概念澄清:

  • instanceof 判断的不是「谁 new 出来的」,而是「left.__proto__ 链上有没有 right.prototype 这个对象引用」。
  • 原型链靠 __proto__(即 Object.getPrototypeOf)向上走;构造函数的 prototype 挂在实例的 __proto__ 上。

算法步骤:

  1. 若 left 是原始值或 null,直接 false(原始值没有原型链,原生也会返回 false)。
  2. 取 right.prototype 作为目标。
  3. 从 Object.getPrototypeOf(left) 开始,沿 __proto__ 上溯:命中目标即 true;到 null(原型链顶)仍未命中则 false。

进阶:原生 instanceof 会先调用 right 上的静态方法 Symbol.hasInstance(若定义),能提到这点是满分细节。

  • 时间复杂度:O(原型链长度)。

3. TypeScript 实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
function myInstanceof(left: any, right: any): boolean {
// 原始值 / null / undefined 直接 false
if (left === null || (typeof left !== "object" && typeof left !== "function")) {
return false;
}

// 支持 Symbol.hasInstance(对齐原生优先级)
const hasInstance = right[Symbol.hasInstance];
if (typeof hasInstance === "function") {
return !!hasInstance.call(right, left);
}

if (typeof right !== "function") {
throw new TypeError("Right-hand side of 'instanceof' is not callable");
}

const proto = right.prototype; // 要找的目标
let cursor = Object.getPrototypeOf(left);

while (cursor !== null) {
if (cursor === proto) return true;
cursor = Object.getPrototypeOf(cursor); // 沿原型链上溯
}

return false;
}

// 测试
class Animal {}
class Dog extends Animal {}

console.log(myInstanceof(new Dog(), Dog)); // true
console.log(myInstanceof(new Dog(), Animal)); // true(继承链)
console.log(myInstanceof(new Dog(), Object)); // true
console.log(myInstanceof("str", String)); // false(原始值)
console.log(myInstanceof(new String("s"), String)); // true

4. 面试延伸

  • 原型链三条铁律要能脱口而出:实例.__proto__ === 构造函数.prototype;构造函数.__proto__ === Function.prototype;Object.prototype.__proto__ === null(链顶)。
  • instanceof vs typeof vs Object.prototype.toString:typeof 只分原始类型、instanceof 查原型链判引用类型、toString.call(x) 最精确(能区分 Array/Date/RegExp)。三者的适用边界是高频追问。
  • 跨 realm 失效:iframe 里的数组用 instanceof Array 会 false(不同全局的 Array.prototype 不同),此时应改用 Array.isArray。能点出这个坑非常加分。
  • class 语法糖:extends 建立的正是一条 Dog.prototype.__proto__ === Animal.prototype 的链,instanceof 才因而能向上命中父类。
  • Function instanceof Function === true、Object instanceof Function === true 这些「鸡生蛋」常用来考你原型图是否清晰。

难度:中等 | 手写题 | 原型链理解试金石

1. 题目

在 Function.prototype 上实现三个方法(挂到自定义名字以免污染):

  • myCall(thisArg, ...args):以 thisArg 为 this、逐个传参,立即调用函数。
  • myApply(thisArg, argsArray):同上,但参数以数组形式传入。
  • myBind(thisArg, ...partialArgs):返回一个新函数,绑定 this 并可柯里化预置部分参数;新函数被 new 时,this 绑定失效(指向新建实例),但预置参数仍生效。

要求处理 thisArg 为原始值(应装箱/或在全局环境忽略)、null/undefined(指向全局)等边界。

2. 解题思路

三者核心都是一句话:让函数以某个对象作为 this 来执行。技巧是把函数临时设为该对象的属性,通过 obj.fn() 调用,此时 this 自然指向 obj,调用完删除临时属性。

2.1 call / apply

  1. thisArg 为 null/undefined 时用全局对象(浏览器 globalThis)。
  2. 把原始值 Object(thisArg) 装箱成对象(严格模式下 call 传入原始值 this 仍是原始值,这里按非严格近似)。
  3. 用唯一的 Symbol 作为临时属性名,避免与已有 key 冲突。
  4. 调用后 delete 掉临时属性。
  5. apply 只是把数组参数展开传入。

2.2 bind

bind 不立即执行,而是返回一个 boundFunction:

  • 普通调用时,this 固定为绑定对象,且把预置参数和调用时参数拼接(柯里化)。

  • 用 new 调用 boundFunction 时,ES 规范要求忽略绑定的 this,创建全新实例,但预置参数仍前置。实现关键:new bind(Fn) 时原型链上 boundFunction.prototype === Fn.prototype,因此判断 this instanceof boundFunction 来决定用 thisArg 还是新建对象。

  • 时间复杂度:调用时才确定,均 O(参数)。

3. TypeScript 实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
// myCall
Function.prototype.myCall = function (this: Function, thisArg?: any, ...args: any[]): any {
const ctx = thisArg == null ? globalThis : Object(thisArg);
const key = Symbol("fn");
(ctx as any)[key] = this; // this 即调用者函数
const result = (ctx as any)[key](...args);
delete (ctx as any)[key];
return result;
};

// myApply
Function.prototype.myApply = function (this: Function, thisArg?: any, argsList?: any[]): any {
const ctx = thisArg == null ? globalThis : Object(thisArg);
const key = Symbol("fn");
(ctx as any)[key] = this;
const result = argsList
? (ctx as any)[key](...argsList)
: (ctx as any)[key]();
delete (ctx as any)[key];
return result;
};

// myBind
Function.prototype.myBind = function (this: Function, thisArg?: any, ...boundArgs: any[]): Function {
const original = this;

// 用一个空函数承接原型链,保证 new bound() 能拿到 original.prototype
const bound = function (this: any, ...callArgs: any[]): any {
const allArgs = [...boundArgs, ...callArgs]; // 柯里化:预置参数在前
// 被 new 调用时 this 是新建实例,忽略绑定的 thisArg
const wasNew = new.target !== undefined;
if (wasNew) {
return new (original as any)(...allArgs);
}
return original.apply(thisArg, allArgs);
};

if (original.prototype) {
bound.prototype = Object.create(original.prototype);
}
return bound;
};

// 测试
const obj = { name: "jack" };
function greet(this: any, a: string, b: string) {
return `${a} ${this.name} ${b}`;
}

console.log(greet.myCall(obj, "hi", "!")); // "hi jack !"
console.log(greet.myApply(obj, ["hello", "?"])); // "hello jack ?"

const boundGreet = greet.myBind(obj, "hey");
console.log(boundGreet("there")); // "hey jack there"

4. 面试延伸

  • bind 的 new 语义是本题最大的区分点:很多人 bind 只会 apply,答不出 new.target 判断与 boundFunction.prototype 的桥接,原型链一断 instanceof 就错。
  • Symbol 临时属性名:用 Symbol() 或 fn_${Date.now()} 避免覆盖对象上同名方法,比直接写 ctx.fn 严谨。
  • 严格模式差异:严格模式下 call(1) 的 this 就是 1 而非装箱对象,Object(thisArg) 是近似;能点出这条即显深度。
  • 三者关系:call/apply 只传参方式不同、都立即执行;bind 返回新函数且能柯里化,底层常复用 apply。
  • 关联:instanceof 手写见下一篇 [手写 instanceof],同属「理解原型与 this」的必考组合。

难度:中等 | 手写原型三件套 | this 绑定机制

1. 题目

实现一个 deepClone(source),对一个值进行深拷贝,要求:

  • 对象、数组递归拷贝,新旧互不影响(改新不影响旧)。
  • 正确处理循环引用(a.self = a),不能栈溢出。
  • 尽量还原特殊类型:Date、RegExp、Map、Set,以及 Symbol 作为 key。
  • 函数、原始值直接返回(浅拷贝即可)。

示例:

1
2
3
4
const obj = { name: "jack", tags: ["ts", "algo"], born: new Date() };
obj.self = obj; // 循环引用
const copy = deepClone(obj);
copy.self === copy; // true(保持了引用关系而非死循环)

2. 解题思路

深拷贝 = 递归遍历 + 类型分发。两大难点:

2.1 循环引用:WeakMap 记忆化

若对象 A 引用了 B,B 又引用回 A,朴素递归会无限下钻爆栈。解决:用一个 WeakMap<源对象, 克隆对象> 记录「已经克隆过的源对象 -> 其克隆结果」。每次要克隆一个对象前,先查 WeakMap,命中就直接返回缓存的克隆体,从而打断回环并保持引用一致性。用 WeakMap 而非 Map 是为了不阻止源对象被 GC。

2.2 类型分发

  • 原始值(typeof !== "object" 且非函数)→ 直接返回。

  • null → 直接返回。

  • Date → new Date(+value)。

  • RegExp → new RegExp(source, flags)。

  • Map / Set → 新建并递归克隆每个值(键一般也克隆以保险)。

  • 数组 → map 递归。

  • 普通对象 → 遍历自有属性(含 Symbol key)递归。

  • 时间复杂度:O(n),n 为节点数。

  • 空间复杂度:O(n)(递归栈 + WeakMap)。

3. TypeScript 实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
function deepClone<T>(source: T, cache = new WeakMap<object, any>()): T {
// 原始值 / null / 函数:直接返回
if (source === null || typeof source !== "object") {
return source;
}

// 命中缓存:处理循环引用的关键
if (cache.has(source as object)) {
return cache.get(source as object);
}

// Date
if (source instanceof Date) {
return new Date(source.getTime()) as unknown as T;
}

// RegExp
if (source instanceof RegExp) {
return new RegExp(source.source, source.flags) as unknown as T;
}

// Map
if (source instanceof Map) {
const result = new Map();
cache.set(source as object, result);
for (const [key, value] of source.entries()) {
result.set(deepClone(key, cache), deepClone(value, cache));
}
return result as unknown as T;
}

// Set
if (source instanceof Set) {
const result = new Set();
cache.set(source as object, result);
for (const value of source.values()) {
result.add(deepClone(value, cache));
}
return result as unknown as T;
}

// 数组
if (Array.isArray(source)) {
const result: any[] = [];
cache.set(source as object, result);
for (let i = 0; i < source.length; i++) {
result[i] = deepClone(source[i], cache);
}
return result as unknown as T;
}

// 普通对象(含 Symbol key)
const result = Object.create(Object.getPrototypeOf(source));
cache.set(source as object, result); // 先建立映射再递归,才能扛住循环引用
for (const key of Reflect.ownKeys(source)) {
const descriptor = Object.getOwnPropertyDescriptor(source, key)!;
if (descriptor.enumerable) {
result[key] = deepClone((source as any)[key], cache);
} else {
Object.defineProperty(result, key, descriptor);
}
}
return result as T;
}

// 测试
const obj: any = { name: "jack", tags: ["ts", "algo"], born: new Date(0) };
obj.self = obj;
const copy = deepClone(obj);
console.log(copy.self === copy); // true
console.log(copy.tags !== obj.tags); // true(独立副本)

划重点:cache.set 必须在递归子属性之前执行。若等子树克隆完再写缓存,遇到 obj.self = obj 时缓存里还没有 obj 的克隆体,就会无限递归。

4. 面试延伸

  • JSON.parse(JSON.stringify(obj)) 的坑:会丢失 function、Symbol、undefined,把 Date 变字符串、RegExp 变 {},遇循环引用直接抛错——是必答的反面教材。
  • structuredClone:现代浏览器/Node 内置的深拷贝,支持循环引用与多种内建类型,但不克隆函数、DOM 节点、原型链方法。能提到它说明关注标准 API。
  • 进阶要求:拷贝不可枚举属性 / getter / 原型链(本题用 Reflect.ownKeys + getPrototypeOf 已部分覆盖);Symbol 作 key 的还原。
  • 讲题结构建议:先点出「循环引用 + 特殊类型」两大难点,再用 WeakMap 破第一个,体现你先识别问题再逐个击破的思路。

难度:困难 | 前端手写题压轴 | 引用类型与递归

0%