Jack N @ GitHub

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

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.lengtharguments.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.prototypeObject.prototype.__proto__ === null(链顶)。
  • instanceof vs typeof vs Object.prototype.toStringtypeof 只分原始类型、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 === trueObject instanceof Function === true 这些「鸡生蛋」常用来考你原型图是否清晰。

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

1. 题目

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

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

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

2. 解题思路

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

2.1 call / apply

  1. thisArgnull/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),不能栈溢出。
  • 尽量还原特殊类型:DateRegExpMapSet,以及 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 → 直接返回。

  • Datenew Date(+value)

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

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

1. 题目

实现 promiseRace(iterables),行为对齐原生 Promise.race

  • 接收一个可迭代的 Promise(或普通值)集合,返回一个新 Promise。
  • 结果由**第一个 settle(无论 fulfilled 还是 rejected)**的成员决定:它成功就 resolve,它失败就 reject。
  • 关键区别于 Promise.all:race 不关心成败,只关心谁先落地
  • 空数组:原生 Promise.race([])永远 pending(没有任何成员能触发 settle),需照此实现。

示例:

1
2
promiseRace([慢成功, 快失败])  -> reject(快的那个先 settle)
promiseRace([快成功, 慢失败]) -> resolve(快的那个先 settle)

2. 解题思路

一句话:给每个成员同时挂上 resolve 和 reject,谁先调用谁就赢

因为 Promise 一旦 settle 就冻结,后续再调用 resolve/reject 都是空操作。所以完全不必自己加「是否已完成」的锁:

  1. 遍历集合,用 Promise.resolve(item) 包一层(兼容非 Promise 值,让它也进入微任务队列公平竞争)。
  2. 对每个包装后的 Promise,.then(resolve, reject)——把外层的 resolve、reject 直接透传。
  3. 第一个 settle 的成员会率先调用外层 resolve 或 reject,外层 Promise 随即定型,其余调用被忽略。
  • 空数组直接返回一个不 settle 的 Promise(new Promise(() => {}))以匹配原生语义。

  • 时间复杂度:O(n) 建立回调。

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
function promiseRace<T>(
iterables: Iterable<T | PromiseLike<T>>
): Promise<Awaited<T>> {
return new Promise((resolve, reject) => {
for (const item of iterables) {
// 用 Promise.resolve 包装,普通值也异步参与竞争
Promise.resolve(item).then(resolve, reject);
}
// 空集合:循环不执行,Promise 永久 pending(与原生一致)
});
}

// 测试
const slow = (v: string, ms: number) =>
new Promise<string>((r) => setTimeout(() => r(v), ms));
const fastFail = (ms: number) =>
new Promise<never>((_, rej) => setTimeout(() => rej("boom"), ms));

promiseRace([slow("slow-win", 300), fastFail(100)])
.then(console.log)
.catch(console.log); // "boom"(先 settle)

promiseRace([slow("first", 50), slow("second", 200)])
.then(console.log); // "first"

4. 面试延伸

  • race vs anyPromise.any 只在第一个成功时定胜负,忽略失败;只有全部失败才 reject 一个 AggregateError。别把 race 的「先 settle」和 any 的「先成功」搞混。
  • race vs allSettled:allSettled 等所有人落地并汇总状态,永不 reject;race 恰恰相反,抢跑即定。
  • 实战用法请求超时控制是 race 最典型场景——Promise.race([fetchData(), timeout(5000)]),超时的那个先 reject 就达成「N 秒没返回就报错」。
  • 能主动说出「空数组永远 pending」这个反直觉细节,是加分信号,说明你真用过而非只背签名。

难度:中等 | 手写 Promise 家族 | 超时控制基石

1. 题目

实现一个满足以下核心特性的迷你 MyPromise

  • 三种状态:pendingfulfilledrejected,状态一经改变不再变化
  • 构造器接收 executor(resolve, reject)立即执行;executor 内抛错则转为 rejected。
  • resolve/reject 可接收值;若 resolve 一个 Promise(thenable),要等它 settle 后再决定本 Promise 状态。
  • then(onFulfilled, onRejected) 支持链式调用(返回新 Promise),且回调异步在微任务里执行;支持值穿透(回调不是函数时透传)。
  • catch 复用 then

2. 解题思路

四个关键点:

  1. 状态机:用一个 status 字段 + 只允许一次变更(用 changeStatus 守卫),保证不可逆。
  2. 异步执行回调then 的回调不能立即调用(可能在 resolve 之前就被 then),统一丢进 queueMicrotask(模拟微任务,等价 Promise.resolve().then())。
  3. 链式 & 值穿透then 永远返回一个新的 Promise;若传入的不是函数,用默认的 v => v(成功透传)和 err => throw err(失败下抛),从而支持 .then().then() 跳过。
  4. resolve thenableresolve 收到的若是带 then 方法的对象/ Promise,需递归地采用它的最终状态,不能直接 fulfilled。
  • 状态变更 O(1);链式每个 then 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
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
72
73
74
75
type Status = "pending" | "fulfilled" | "rejected";

class MyPromise {
private status: Status = "pending";
private value: any = undefined;
private reason: any = undefined;

constructor(executor: (resolve: (v?: any) => void, reject: (r?: any) => void) => void) {
try {
executor(this.resolve.bind(this), this.reject.bind(this));
} catch (e) {
this.reject(e);
}
}

private resolve(value: any): void {
if (this.status !== "pending") return; // 只允许一次
// resolve 了一个 thenable -> 采用其状态
if (value && (typeof value === "object" || typeof value === "function")
&& typeof value.then === "function") {
value.then(this.resolve.bind(this), this.reject.bind(this));
return;
}
this.status = "fulfilled";
this.value = value;
}

private reject(reason: any): void {
if (this.status !== "pending") return;
this.status = "rejected";
this.reason = reason;
}

then(onFulfilled?: any, onRejected?: any): MyPromise {
// 值穿透:非函数用默认处理器
onFulfilled = typeof onFulfilled === "function" ? onFulfilled : (v: any) => v;
onRejected = typeof onRejected === "function" ? onRejected : (e: any) => { throw e; };

return new MyPromise((resolve, reject) => {
const runFulfilled = () =>
queueMicrotask(() => {
try { resolve(onFulfilled(this.value)); }
catch (e) { reject(e); }
});
const runRejected = () =>
queueMicrotask(() => {
try { resolve(onRejected(this.reason)); }
catch (e) { reject(e); }
});

if (this.status === "fulfilled") {
runFulfilled();
} else if (this.status === "rejected") {
runRejected();
}
// 注意:简易版未处理 pending 时回调的收集;
// 完整版应把 runFulfilled/runRejected 存入回调数组,resolve/reject 时再依次触发。
});
}

catch(onRejected: any): MyPromise {
return this.then(undefined, onRejected);
}

static resolve(v: any): MyPromise {
if (v instanceof MyPromise) return v;
return new MyPromise((resolve) => resolve(v));
}
}

// 测试:链式 + 值穿透
new MyPromise<number>((resolve) => setTimeout(() => resolve(1), 50))
.then((v) => { console.log(v); return v + 1; }) // 1
.then(null) // 值穿透
.then((v) => console.log(v)); // 2

说明:为了讲解聚焦,上面 then 省略了「pending 状态下回调收集」的实现。完整版本必须在 Promise 尚未 settle 时把回调 push 进 onFulfilledCbs[] / onRejectedCbs[],并在 resolve/reject 里遍历触发——这是异步 executor 场景正确性的关键。

4. 面试延伸

  • 为什么用微任务而不是 setTimeout:Promise 回调属微任务,在同步代码之后、下一轮事件循环前统一执行;setTimeout 是宏任务,时序不同。能讲清宏/微任务队列即是加分。
  • then 返回新 Promise 且 resolve 回调返回值:正是这个「返回值再交给新 Promise 的 resolve」实现了链式传递和 thenable 展开。
  • resolve(new MyPromise(...)) 不会立刻定状态:因为 thenable 递归采用逻辑,能答出这点说明真懂。
  • 进阶补全:加上 pending 回调队列、静态 all/race/any/allSettled(见 [Promise.all]、[Promise.race] 两篇),即接近 A+ 规范。
  • 异常处理:回调内 throw 会被新 Promise reject 捕获,向下游 catch 冒泡,与原生一致。

难度:困难 | 手写 Promise 核心 | 异步机制试金石

1. 题目

给定字符串 haystackneedle,返回 needlehaystack第一次出现的下标,若不存在返回 -1。要求优于暴力的 O(m×n)

示例:

1
2
3
4
5
输入: haystack = "sadbutsad", needle = "sad"
输出: 0

输入: haystack = "leetcode", needle = "leeto"
输出: -1

2. 解题思路

暴力匹配在文本串上每失配一次就把模式串整体后移一位、指针回到开头,产生大量重复比较。KMP 的核心:利用「模式串自身的重叠信息」,失配时主串指针不回退,模式串指针跳到最长可用位置

2.1 前缀函数(next / lps 数组)

定义 lps[i] = 子串 needle[0..i]最长相等真前后缀长度(proper prefix 同时是 suffix)。

例如 needle = "abab"

  • lps = [0, 0, 1, 2]
  • "abab" 前 4 个字符分别的最长相等前后缀:"a"->0"ab"->0"aba"->"a"=1"abab"->"ab"=2

2.2 构建 lps(也是双指针)

len 表示当前已匹配的前缀长度:

  • needle[i] === needle[len]lps[i] = ++len; i++
  • 不等且 len > 0len = lps[len-1](回溯到次长前后缀,i 不动)。
  • 不等且 len === 0lps[i] = 0; i++

2.3 匹配

主串 j、模式串 i 双指针,失配时 i = lps[i-1](不回退 j),直到 i === needle.length 命中。

  • 时间复杂度:O(m + n);空间复杂度:O(n)(lps 数组)。

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
function buildLPS(pattern: string): number[] {
const n = pattern.length;
const lps = new Array(n).fill(0);
let len = 0; // 前一个最长前后缀长度
let i = 1;

while (i < n) {
if (pattern[i] === pattern[len]) {
lps[i++] = ++len;
} else if (len > 0) {
len = lps[len - 1]; // 回溯,i 不前进
} else {
lps[i++] = 0;
}
}
return lps;
}

function strStr(haystack: string, needle: string): number {
const m = haystack.length;
const n = needle.length;
if (n === 0) return 0;

const lps = buildLPS(needle);
let i = 0; // haystack 指针(永不回退)
let j = 0; // needle 指针

while (i < m) {
if (haystack[i] === needle[j]) {
i++;
j++;
if (j === n) return i - j; // 完全匹配
} else if (j > 0) {
j = lps[j - 1]; // 部分匹配,跳回
} else {
i++; // j=0 直接前移
}
}
return -1;
}

console.log(strStr("sadbutsad", "sad")); // 0
console.log(strStr("ababcababc", "abc")); // 2
console.log(strStr("leetcode", "leeto")); // -1
console.log(buildLPS("abab")); // [0,0,1,2]

4. 面试延伸

  • 理解 j = lps[j-1] 的精髓:它把「已经匹配过的前缀」当作可复用的后缀,让 i 全程不回退,这是 KMP 达到线性的根本。
  • 最小覆盖子串 / 重复子串(459):用 n - lps[n-1] 判断字符串是否由某最短单元重复构成。
  • Rabin-Karp(滚动哈希):多模式匹配 / 查重更友好,平均线性但要处理哈希冲突。
  • BM 算法:编辑器/grep 的实战主力(坏字符 + 好后缀规则,实际常常比 KMP 快),面试聊到工程落地可提。
  • 手推 lps 时一定举例 "aabaaab" 之类复杂串验证,别只说结论。

难度:中等 | LeetCode 28 题 | 前缀函数 / 双指针

1. 题目

给定整数数组 nums 和整数 k,返回数组中第 k 的元素(排序后第 k 个位置的值,不是第 k 个不同元素)。

示例:

1
2
3
4
5
输入: nums = [3,2,1,5,6,4], k = 2
输出: 5

输入: nums = [3,2,3,1,2,4,5,5,6], k = 4
输出: 4

2. 解题思路

「第 K 大 / Top K」是超高频题型,有三种主流套路,各有适用场景。

2.1 排序法 O(n log n)

直接排序后取 nums[n - k]。简单,但不是最优,面试可作为保底并说明「有更快的」。

2.2 大小为 K 的最小堆 O(n log k) —— 求 TopK 首选

维护一个只装 k 个元素的最小堆

  • 遍历数组,堆大小 < k 就入堆。
  • 否则若当前值 > 堆顶(堆里最小的),弹出堆顶、压入当前值。
  • 遍历结束,堆顶就是第 k 大(堆里 k 个数中最小的那个,恰为整体第 k 大)。

优势:天然适合数据流 / 海量数据,内存只需 O(k),不必一次性拿到全部数据,这也是「TopK」在工程里(如热点搜索词)的常见解法。

2.3 快速选择(Quickselect)平均 O(n)

复用快排的 partition:每趟把基准放到最终位置 p,若 p === n-k 直接返回;否则只递归目标所在的那一半,平均线性、最坏 O(n²)(随机化 pivot 可高概率避免)。

  • 时间复杂度:平均 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
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
72
73
74
75
76
77
78
79
80
81
82
83
// 2.3 快速选择(原地,平均 O(n))
function findKthLargest(nums: number[], k: number): number {
const target = nums.length - k; // 转成升序下标

function partition(lo: number, hi: number): number {
// 随机化,避免有序退化
const rand = lo + Math.floor(Math.random() * (hi - lo + 1));
[nums[rand], nums[hi]] = [nums[hi], nums[rand]];
const pivot = nums[hi];
let i = lo;
for (let j = lo; j < hi; j++) {
if (nums[j] < pivot) {
[nums[i], nums[j]] = [nums[j], nums[i]];
i++;
}
}
[nums[i], nums[hi]] = [nums[hi], nums[i]];
return i;
}

let lo = 0;
let hi = nums.length - 1;
while (true) {
const p = partition(lo, hi);
if (p === target) return nums[p];
if (p < target) lo = p + 1;
else hi = p - 1;
}
}

// 2.2 最小堆版(适合数据流 / 海量 TopK)
// 借助一个数值小顶堆:这里用极简实现演示思路
class MinHeap {
private h: number[] = [];
get size() { return this.h.length; }
peek() { return this.h[0]; }
push(v: number) {
const h = this.h;
h.push(v);
let i = h.length - 1;
while (i > 0) {
const p = (i - 1) >> 1;
if (h[p] <= h[i]) break;
[h[p], h[i]] = [h[i], h[p]];
i = p;
}
}
pop() {
const h = this.h;
const top = h[0];
const last = h.pop()!;
if (h.length) {
h[0] = last;
let i = 0;
while (true) {
const l = 2 * i + 1;
const r = 2 * i + 2;
let s = i;
if (l < h.length && h[l] < h[s]) s = l;
if (r < h.length && h[r] < h[s]) s = r;
if (s === i) break;
[h[s], h[i]] = [h[i], h[s]];
i = s;
}
}
return top;
}
}

function topKKthLargest(nums: number[], k: number): number {
const heap = new MinHeap();
for (const x of nums) {
if (heap.size < k) heap.push(x);
else if (x > heap.peek()) {
heap.pop();
heap.push(x);
}
}
return heap.peek();
}

console.log(findKthLargest([3, 2, 1, 5, 6, 4], 2)); // 5
console.log(topKKthLargest([3, 2, 3, 1, 2, 4, 5, 5, 6], 4)); // 4

4. 面试延伸

  • 前 K 个高频元素(347):先哈希统计频次,再对频次跑「大小 k 的小顶堆」或快速选择,是 TopK + 堆的经典组合。
  • 海量数据(如 10 亿数取前 100):绝不可能全放内存,答案就是大小为 k 的小顶堆分块处理,凸显堆相对于快选的优势。
  • 第 K 小的元素 / 有序矩阵第 K 小(378):可加一条「二分答案」解法——在值域二分,用 O(n) 统计 <= mid 的个数,O(n log(range))
  • 一定要区分:快速选择平均 O(n) 但会打乱原数组;堆法保证 O(n log k) 且能处理流式数据。选型看是否可修改原数组、是否一次性数据。

难度:中等 | LeetCode 215 题 | 堆 / 分治双高频

1. 题目

给定一个由 '1'(陆地)和 '0'(水)组成的二维网格,计算岛屿的数量。岛屿由水平或垂直方向上相邻的陆地连接形成,且四周都被水包围。

示例:

1
2
3
4
5
6
输入:
[["1","1","0","0","0"],
["1","1","0","0","0"],
["0","0","1","0","0"],
["0","0","0","1","1"]]
输出: 3

2. 解题思路

典型的洪水填充(Flood Fill)/ 连通块计数

遍历每个格子:

  • 遇到 '1',说明发现一座还没统计过的岛屿,count++
  • 立刻从该点做一次 DFS/BFS,把与它四连通的所有 '1' 全部「淹掉」(标记为 '0' 或写入 visited),这样同一座岛不会重复计数。
  • 继续扫描,直到所有格子处理完。

「淹掉」即去重:一座岛只需在遇到它的第一个陆地格时计一次,之后整片都变 '0' 就不会再触发计数。

  • 时间复杂度:O(m × n),每个格子最多访问常数次。
  • 空间复杂度:O(m × n) 最坏(递归栈或队列,全陆地时)。

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
function numIslands(grid: string[][]): number {
if (grid.length === 0) return 0;
const m = grid.length;
const n = grid[0].length;

// DFS 淹没整座岛
function sink(r: number, c: number): void {
if (r < 0 || r >= m || c < 0 || c >= n || grid[r][c] !== "1") {
return;
}
grid[r][c] = "0"; // 标记已访问(淹掉)
sink(r + 1, c);
sink(r - 1, c);
sink(r, c + 1);
sink(r, c - 1);
}

let count = 0;
for (let r = 0; r < m; r++) {
for (let c = 0; c < n; c++) {
if (grid[r][c] === "1") {
count++;
sink(r, c);
}
}
}
return count;
}

// BFS 版(防止超深网格递归爆栈)
function numIslandsBFS(grid: string[][]): number {
const m = grid.length;
const n = grid[0].length;
const dirs = [
[1, 0], [-1, 0], [0, 1], [0, -1],
];
let count = 0;

for (let r = 0; r < m; r++) {
for (let c = 0; c < n; c++) {
if (grid[r][c] !== "1") continue;
count++;
grid[r][c] = "0";
const queue: [number, number][] = [[r, c]];
while (queue.length) {
const [x, y] = queue.shift()!;
for (const [dx, dy] of dirs) {
const nx = x + dx;
const ny = y + dy;
if (nx >= 0 && nx < m && ny >= 0 && ny < n && grid[nx][ny] === "1") {
grid[nx][ny] = "0";
queue.push([nx, ny]);
}
}
}
}
}
return count;
}

4. 面试延伸

  • DFS 爆栈风险:超大网格(如 1000×1000 全陆地)递归深度可达百万级,BFS 或显式栈更稳,这是全栈工程意识的加分点。
  • 不允许修改原网格?visited 布尔矩阵替代「淹成 0」,逻辑不变。
  • 岛屿最大面积(695)/ 岛屿周长(463):DFS 时顺带累加面积 / 统计临海面数。
  • 封闭岛屿、地图分析(多源 BFS 求离陆地最远的海) 都是 Flood Fill 家族的变体。
  • 并查集解法:把相邻陆地 union,最后数连通分量,能顺带动态加陆地,体现数据结构选型能力。
  • 四连通 vs 八连通(含对角)只需增减 dirs,注意题意。

难度:中等 | LeetCode 200 题 | DFS/BFS 网格模板

1. 题目

给你一个字符串 s,找到其中最长的回文子串。回文指正读反读都一样。

示例:

1
2
3
4
5
输入: s = "babad"
输出: "bab"("aba" 同样是合法答案)

输入: s = "cbbd"
输出: "bb"

2. 解题思路

2.1 中心扩散(推荐,直观且高效)

回文串一定围绕某个「中心」对称展开。长度为 n 的字符串共有 2n - 1 个中心:

  • n 个单字符中心(产生奇数长度回文,如 “aba”)。
  • n - 1 个双字符间隙中心(产生偶数长度回文,如 “abba”)。

枚举每个中心,向两边同时扩展直到不再相等,记录最长的一段。每个中心调用一次扩散函数,分别以 i(奇)和 i, i+1(偶)为初始左右指针。

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

2.2 动态规划 O(n²) 空间

dp[i][j] 表示 s[i..j] 是否回文:dp[i][j] = (s[i]===s[j]) && dp[i+1][j-1]。按长度从小到大填表。空间更大,思路清晰可作为对照。

存在 O(n) 的 Manacher 算法,面试一般不要求手写,能提名字并说明它是「利用已知回文的对称性避免重复扩展」即为加分。

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
function longestPalindrome(s: string): string {
if (s.length < 2) return s;

let start = 0;
let maxLen = 1;

// 从 (left, right) 向两边扩散,返回回文长度
function expand(left: number, right: number): number {
while (left >= 0 && right < s.length && s[left] === s[right]) {
left--;
right++;
}
return right - left - 1; // 注意循环退出时各多走了一步
}

for (let i = 0; i < s.length; i++) {
const oddLen = expand(i, i); // 奇数中心
const evenLen = expand(i, i + 1); // 偶数中心
const len = Math.max(oddLen, evenLen);

if (len > maxLen) {
maxLen = len;
// 由中心反推起点:奇偶统一用这个式子
start = i - Math.floor((len - 1) / 2);
}
}

return s.slice(start, start + maxLen);
}

console.log(longestPalindrome("babad")); // "bab" 或 "aba"
console.log(longestPalindrome("cbbd")); // "bb"
console.log(longestPalindrome("a")); // "a"

4. 面试延伸

  • 起点公式 i - (len-1)/2 是易错点:奇偶都适用,因为 (len-1)/2 向下取整正好覆盖两种中心的偏移差异。
  • 最长回文子序列(516):子序列可不连续,转成「s 与 reverse(s) 的 LCS」或区间 DP dp[i][j],别和本题混淆。
  • Manacher:把 O(n²) 优化到 O(n),核心是维护「右边界最大的回文」及其对称中心,复用已知信息。
  • 字符串匹配类(回文、子串、子序列)先问自己:中心?双指针?区间 DP?——建立这三选一的直觉。

难度:中等 | LeetCode 5 题 | 中心扩散模板

0%