Jack N @ GitHub

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

1. 题目

给定一个升序整数数组 nums 和目标值 target:

  1. 基础版:若存在 target 返回其下标,否则返回 -1。
  2. 边界版:找出 target 在数组中的起始和结束位置(存在重复时);不存在返回 [-1, -1]。要求 O(log n)。

示例:

1
2
3
4
5
6
nums = [5,7,7,8,8,10], target = 8
基础版: 返回 3(任一 8 的下标)
边界版: 返回 [3, 4]

nums = [5,7,7,8,8,10], target = 6
返回 [-1, -1]

2. 解题思路

二分最坑的是边界与死循环。用「左闭右闭 [left, right]」这一套统一写法最省心。

2.1 基础版

1
2
3
4
5
6
7
while (left <= right) {        // 区间非空的条件
mid = left + ((right-left) >> 1) // 防两数相加溢出
if (nums[mid] === target) return mid
else if (nums[mid] < target) left = mid + 1
else right = mid - 1
}
return -1

2.2 边界版 = 两次「找第一个 >= / 第一个 >」

关键抽象成两个函数:

  • lowerBound(target):第一个 >= target 的下标(左边界)。
  • upperBound(target):第一个 > target 的下标(右边界的下一位)。

则 target 的区间是 [lowerBound, upperBound - 1];若两者相等说明 target 不存在。

核心口诀:收缩时只要 nums[mid] >= target 就 right = mid(否则 left = mid + 1),最终 left 落在第一个满足条件的位置。注意此时 while (left < right) 且 right = mid(不减一),保持「找最左」不变量。

  • 时间复杂度:O(log 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
// 基础版:精确匹配
function search(nums: number[], target: number): number {
let left = 0;
let right = nums.length - 1; // 左闭右闭

while (left <= right) {
const mid = left + ((right - left) >> 1);
if (nums[mid] === target) return mid;
if (nums[mid] < target) left = mid + 1;
else right = mid - 1;
}
return -1;
}

// 找第一个 >= target 的下标(可能返回 nums.length)
function lowerBound(nums: number[], target: number): number {
let left = 0;
let right = nums.length; // 左闭右开 [left, right)
while (left < right) {
const mid = left + ((right - left) >> 1);
if (nums[mid] < target) left = mid + 1;
else right = mid; // mid 可能是答案,保留
}
return left;
}

// 找第一个 > target 的下标
function upperBound(nums: number[], target: number): number {
let left = 0;
let right = nums.length;
while (left < right) {
const mid = left + ((right - left) >> 1);
if (nums[mid] <= target) left = mid + 1;
else right = mid;
}
return left;
}

function searchRange(nums: number[], target: number): number[] {
const lo = lowerBound(nums, target);
// lo 越界或值不等,说明不存在
if (lo === nums.length || nums[lo] !== target) return [-1, -1];
return [lo, upperBound(nums, target) - 1];
}

console.log(searchRange([5, 7, 7, 8, 8, 10], 8)); // [3, 4]
console.log(searchRange([5, 7, 7, 8, 8, 10], 6)); // [-1, -1]

4. 面试延伸

  • 两套写法别混用:left<=right / right=mid-1(闭合)与 left<right / right=mid(半开)各有约定,全程保持一致,中途换风格必死循环或漏元素。
  • 旋转数组找 target(33/153):判断哪半边有序再决定收缩方向,是二分的最高频变体。
  • 求平方根 / 二分答案:把「在答案空间里二分」讲出来(找第一个满足条件的 x),体现你懂二分的本质是单调性。
  • 溢出:JS 的 number 是双精度浮点,>>1 会把值转成 32 位整数,mid 溢出风险比 C++ 小,但写 left + (right-left)/2 仍是好习惯。
  • 记住 lowerBound === upperBound 即「target 出现次数为 0」,upperBound - lowerBound 就是频次,一行搞定计数。

难度:中等 | LeetCode 34/704 题 | 边界处理基本功

1. 题目

给你一个整数数组 coins 表示不同面额的硬币,以及一个整数 amount 表示总金额。计算凑成总金额所需的最少的硬币个数。每种硬币可以无限使用。如果无法凑出,返回 -1。

示例:

1
2
3
4
5
6
7
8
输入: coins = [1, 2, 5], amount = 11
输出: 3 (11 = 5 + 5 + 1)

输入: coins = [2], amount = 3
输出: -1 (凑不出)

输入: coins = [1], amount = 0
输出: 0

2. 解题思路

这是完全背包求最少数量的经典题。贪心(每次取最大面额)在这里不成立(如 coins=[1,3,4], amount=6 贪心得 4+1+1=3 枚,最优是 3+3=2 枚),必须用 DP。

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

转移:对于金额 i,枚举最后一枚用的硬币 c(c <= i),则

1
dp[i] = min(dp[i - c]) + 1

初始化:

  • dp[0] = 0(凑 0 需要 0 枚)。
  • 其余 dp[i] = amount + 1(当作正无穷,因为最多也不会超过 amount 枚),或 Infinity。

最后若 dp[amount] > amount 说明没被更新过,返回 -1。

  • 时间复杂度:O(amount × coins数)。
  • 空间复杂度:O(amount)。

3. TypeScript 实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function coinChange(coins: number[], amount: number): number {
const dp = new Array(amount + 1).fill(amount + 1); // 视作 +∞
dp[0] = 0;

for (let i = 1; i <= amount; i++) {
for (const c of coins) {
if (c <= i) {
dp[i] = Math.min(dp[i], dp[i - c] + 1);
}
}
}

return dp[amount] > amount ? -1 : dp[amount];
}

console.log(coinChange([1, 2, 5], 11)); // 3
console.log(coinChange([2], 3)); // -1
console.log(coinChange([1, 3, 4], 6)); // 2

4. 面试延伸

  • 零钱兑换 II(518,凑法总数):把 min+1 换成 dp[i] += dp[i-c],且外层遍历硬币、内层遍历金额以保证组合不计顺序,是完全背包计数的标准写法。
  • 完全背包 vs 0-1 背包:内层金额遍历方向决定「可重复用」——完全背包正序(能用多次),0-1 背包逆序(每枚只用一次)。这是背包问题的核心分水岭。
  • 要输出具体用了哪些硬币:额外记录 choice[i] 前驱,最后回溯。
  • 常见坑:求「最少」用 min,求「方法数」用「加法」;初始化 dp[0] 的含义要和题目对齐。
  • 若问「为什么不用贪心」,一定要举 [1,3,4] 凑 6 的反例,这是本题最重要的理解点。

难度:中等 | LeetCode 322 题 | 完全背包基石

1. 题目

给你一个整数数组 nums,取出其中严格递增的最长子序列,返回其长度。子序列不要求连续,但要保持相对顺序。

示例:

1
2
3
4
5
输入: nums = [10, 9, 2, 5, 3, 7, 101, 18]
输出: 4 (最长递增子序列为 [2, 3, 7, 101])

输入: nums = [0, 1, 0, 3, 2, 3]
输出: 4 ([0, 1, 2, 3])

2. 解题思路

2.1 动态规划 O(n²)

定义 dp[i] = 以 nums[i] 结尾的最长递增子序列长度。

对每个 i,回看所有 j < i:若 nums[j] < nums[i],则可以把 nums[i] 接在以 j 结尾的序列后面:

1
dp[i] = max(dp[j] + 1)  对所有 nums[j] < nums[i]

初始每个 dp[i] = 1(自己单独成列)。答案是 max(dp),注意不是 dp[n-1](结尾不一定是全局最长)。

2.2 贪心 + 二分 O(n log n)(最优)

维护数组 tails:tails[k] 表示「长度为 k+1 的递增子序列的最小可能结尾」。直觉:同样长度下,结尾越小越容易再接更大的数,越有潜力。

遍历 nums 中每个 x:

  • 用二分在 tails 中找第一个 >= x 的位置:
    • 找到 → 用 x 替换它(让这个长度的结尾更小)。
    • 没找到(x 比所有都大)→ push 到末尾,LIS 长度 +1。
  • tails 的长度即为答案。

注意:tails 本身不是某个真实的子序列,只是长度正确;不要试图从中还原路径。

  • 时间复杂度:O(n log n);空间复杂度: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
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// 2.1 DP:O(n²)
function lengthOfLIS(nums: number[]): number {
const n = nums.length;
const dp = new Array(n).fill(1);
let ans = 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);
}
}
ans = Math.max(ans, dp[i]);
}
return ans;
}

// 2.2 贪心 + 二分:O(n log n)
function lengthOfLISNLogN(nums: number[]): number {
const tails: number[] = [];

for (const x of nums) {
// 二分找第一个 >= x 的下标(lowerBound)
let lo = 0;
let hi = tails.length;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (tails[mid] < x) lo = mid + 1;
else hi = mid;
}

if (lo === tails.length) tails.push(x); // x 最大,延长
else tails[lo] = x; // 替换,压小结尾
}

return tails.length;
}

console.log(lengthOfLIS([10, 9, 2, 5, 3, 7, 101, 18])); // 4
console.log(lengthOfLISNLogN([0, 1, 0, 3, 2, 3])); // 4

4. 面试延伸

  • 非严格递增(允许相等):把二分的 lowerBound 换成 upperBound(找第一个 > x)。一个字符之差,常考。
  • 俄罗斯套娃信封(354):先按宽升序、宽相同按高降序排序,再对高求 LIS,是 LIS 的二维包装。
  • 最长递增子序列的个数(673):在 DP 里再维护一个 count[] 数组。
  • 要求输出具体子序列:只能用 O(n²) DP + parent[] 回溯,贪心二分法无法直接还原(可另存下标)。
  • 面试写二分要能讲清 lo 的含义(第一个 >= x),否则容易 off-by-one。

难度:中等 | LeetCode 300 题 | DP + 二分双考点

1. 题目

假设你正在爬楼梯,需要 n 阶你才能到达楼顶。每次你可以爬 1 或 2 个台阶。问共有多少种不同的方法可以爬到楼顶?

示例:

1
2
输入: n = 2  -> 输出: 2  (1+1, 2)
输入: n = 3 -> 输出: 3 (1+1+1, 1+2, 2+1)

2. 解题思路

到达第 n 阶的最后一步,要么从第 n-1 阶跨 1 步上来,要么从第 n-2 阶跨 2 步上来,两者互斥且覆盖所有情况:

1
f(n) = f(n-1) + f(n-2)

这就是斐波那契数列,f(1)=1, f(2)=2。

  • 直接递归会有大量重复子问题,指数级 O(2ⁿ),需要记忆化或自底向上 DP。
  • 由于 f(n) 只依赖前两项,可用两个变量滚动,把空间优化到 O(1)。

DP 五步(入门示范):

  1. 状态定义:dp[i] = 到第 i 阶的方法数。
  2. 转移方程:dp[i] = dp[i-1] + dp[i-2]。
  3. 初始化:dp[1]=1, dp[2]=2。
  4. 遍历顺序:从小到大。
  5. 返回值:dp[n]。
  • 时间复杂度: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
// 滚动变量(最优:O(n) 时间 / O(1) 空间)
function climbStairs(n: number): number {
if (n <= 2) return n;
let prev = 1; // f(i-2)
let curr = 2; // f(i-1)
for (let i = 3; i <= n; i++) {
const next = prev + curr;
prev = curr;
curr = next;
}
return curr;
}

// 记忆化递归(自顶向下)
function climbStairsMemo(n: number, memo: Map<number, number> = new Map()): number {
if (n <= 2) return n;
if (memo.has(n)) return memo.get(n)!;
const res = climbStairsMemo(n - 1, memo) + climbStairsMemo(n - 2, memo);
memo.set(n, res);
return res;
}

console.log(climbStairs(3)); // 3
console.log(climbStairs(5)); // 8

4. 面试延伸

  • 变形:每次可爬 1..k 阶 —— dp[i] = sum(dp[i-1] ... dp[i-k]),滑动窗口求和。
  • 最小花费爬楼梯(746):把「计数」换成「求最优(min)」,是 DP 的另一大流派。
  • 不同路径(62 网格)/ 打家劫舍(198):转移方程类似,考察你能否从题面识别出「斐波那契结构」。
  • 追问「为什么不能用组合数直接算」:可以,Σ C(n-k, k),但要处理大数与取模,DP 更稳更通用。
  • 若面试官提到溢出 / 取模 1e9+7,说明考的是大数斐波那契 + 矩阵快速幂(O(log n))。

难度:简单 | LeetCode 70 题 | 动态规划 Hello World

1. 题目

给定一个二叉树,找到树中两个指定节点 p、q 的最近公共祖先(Lowest Common Ancestor)。

最近公共祖先定义:对于有根树 T 的两个节点 p、q,最近公共祖先表示为一个节点 x,满足 x 是 p、q 的祖先且 x 的深度尽可能大(一个节点也可以是它自己的祖先)。

示例:

1
2
3
4
5
6
7
8
9
10
        3
/ \
5 1
/ \ / \
6 2 0 7
/ \
7 4

输入: p = 5, q = 1 -> 输出: 3
输入: p = 5, q = 4 -> 输出: 5(5 是 4 的祖先,也是自身,故为最近公共祖先)

2. 解题思路

2.1 通用二叉树:后序递归(最优)

自底向上。定义 lca(root, p, q) 返回「以 root 为根的子树中,p 和 q 的最近公共祖先或其中之一」。

对当前节点:

  • base case:root 为空,或 root === p 或 root === q,直接返回 root。

  • 递归左右子树得到 left、right。

  • 分类讨论:

    • left 和 right 都非空 → p、q 分居两侧,当前 root 即为 LCA。
    • 只有一侧非空 → 说明两节点都在那一侧(或找到一个就把它返回),返回那侧的结果。
    • 都空 → 返回 null。
  • 时间复杂度:O(n)。

  • 空间复杂度:O(h) 递归栈。

2.2 若是二叉搜索树(BST 235 题)

利用有序性:从根开始,p、q 都小于当前值走左、都大于走右,一旦分裂或等于即为 LCA,无需递归,O(h) 时间 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
function lowestCommonAncestor(
root: TreeNode | null,
p: TreeNode,
q: TreeNode
): TreeNode | null {
if (root === null || root === p || root === q) return root;

const left = lowestCommonAncestor(root.left, p, q);
const right = lowestCommonAncestor(root.right, p, q);

// p、q 分居两侧,当前节点即最近公共祖先
if (left !== null && right !== null) return root;
// 否则返回非空的那一侧
return left !== null ? left : right;
}

// BST 版本
function lowestCommonAncestorBST(
root: TreeNode | null,
p: TreeNode,
q: TreeNode
): TreeNode | null {
let curr = root;
while (curr) {
if (p.val < curr.val && q.val < curr.val) {
curr = curr.left;
} else if (p.val > curr.val && q.val > curr.val) {
curr = curr.right;
} else {
return curr; // 分裂点或命中其一
}
}
return null;
}

4. 面试延伸

  • 理解「一个节点是自身祖先」:所以 p 是 q 的祖先时,LCA 就是 p,这正是 base case 命中 root === p 后直接返回的原因。
  • 若 p、q 不一定存在? 需要额外标记是否都找到,或用父指针法(记录路径再比对)。
  • 多叉树 LCA / 带 parent 指针的 LCA(相交链表思路):把两条到根的路径当作「找第一个公共节点」,与 [相交链表] 同源。
  • 递归返回值的语义要讲清:它同时承担了「找到一个就上报」和「两侧都命中就在根结算」两种职责,是本题最精妙处。

难度:中等 | LeetCode 236 题 | 后序递归经典

1. 题目

给定二叉树的根节点 root,分别返回它的前序(根左右)、中序(左根右)、后序(左右根)遍历,要求使用迭代(显式栈)而非递归。

示例:

1
2
3
4
5
6
7
8
9
        1
\
2
/
3

前序: [1, 2, 3]
中序: [1, 3, 2]
后序: [3, 2, 1]

节点定义:

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

2. 解题思路

递归的本质是系统调用栈,迭代就是自己用一个显式栈模拟。

  • 前序:栈弹出即「访问」。为了让左子树先处理,压栈时先压右、再压左。

  • 中序:一路 push 左孩子到底(此时栈顶是最左节点),弹出访问,再转向它的右子树。核心是「访问时机 = 左边走到头之后」。

  • 后序:最难。技巧——后序 = 左、右、根,若按「根、右、左」访问再整体反转,就得到「左、右、根」。于是复用前序框架(先压左、再压右),最后 reverse()。

  • 时间复杂度:均为 O(n)。

  • 空间复杂度:O(h),h 为树高(栈大小),最坏 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
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
// 前序:根 -> 左 -> 右
function preorder(root: TreeNode | null): number[] {
const res: number[] = [];
if (!root) return res;
const stack: TreeNode[] = [root];

while (stack.length) {
const node = stack.pop()!;
res.push(node.val); // 访问根
if (node.right) stack.push(node.right); // 先压右
if (node.left) stack.push(node.left); // 再压左,保证左先弹出
}
return res;
}

// 中序:左 -> 根 -> 右
function inorder(root: TreeNode | null): number[] {
const res: number[] = [];
const stack: TreeNode[] = [];
let curr = root;

while (curr || stack.length) {
while (curr) { // 一路向左压栈
stack.push(curr);
curr = curr.left;
}
curr = stack.pop()!; // 弹出最左节点
res.push(curr.val); // 访问根
curr = curr.right; // 转向右子树
}
return res;
}

// 后序:左 -> 右 -> 根(用「根右左 + 反转」)
function postorder(root: TreeNode | null): number[] {
const res: number[] = [];
if (!root) return res;
const stack: TreeNode[] = [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(); // 反转为「左右根」
}

console.log(preorder(new TreeNode(1, null, new TreeNode(2, new TreeNode(3)))));
// [1,2,3]
console.log(postorder(new TreeNode(1, null, new TreeNode(2, new TreeNode(3)))));
// [3,2,1]

4. 面试延伸

  • 统一模板:可给栈里放 {node, visited} 标记,用「访问」和「展开孩子」两态统一三种序,写法一致但空间略大。
  • Morris 遍历:利用叶子节点的空闲右指针(线索化)做到 O(1) 额外空间,是迭代之上的高区分度加分项。
  • 层序遍历(BFS) 用队列,与本题用栈(DFS)形成对照,别混。
  • 追问常考:为什么后序用「根右左再反转」而不是直接模拟?——直接模拟需要记录「右子树是否已访问」,逻辑更绕,反转法最简洁。

难度:中等 | LeetCode 144/94/145 题 | 栈模拟递归

1. 题目

给定一个链表的头节点 head,返回链表开始环路的节点。如果链表无环,则返回 null。

不允许修改链表。要求尽量用 O(1) 额外空间。

示例:

1
2
3
4
5
输入: head = [3,2,0,-4],尾连回索引 1(值为 2 的节点)
输出: 索引为 1 的节点(环入口是值为 2 的节点)

输入: head = [1,2],2 -> 1
输出: 值为 1 的节点

2. 解题思路

先判断有环(快慢指针),再定位入口。定位靠一个数学结论。

设:

  • head 到环入口距离为 a;
  • 环入口到「快慢相遇点」距离为 b;
  • 相遇点再走回环入口距离为 c(即环长 = b + c)。

相遇时:

  • slow 走了 a + b;
  • fast 走了 a + b + n(b + c)(在环里多绕了 n 圈)。

又因为 fast 速度是 slow 两倍:2(a + b) = a + b + n(b + c),化简得:

1
a = (n - 1)(b + c) + c

含义:从 head 再走 a 步到达入口,等价于从相遇点走 c 步(再加若干整圈)也到达入口。

因此:相遇后,令一个指针 p1 回到 head,p2 留在相遇点,两者都每次走一步,再次相遇处即为环入口。

  • 时间复杂度: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 detectCycle(head: ListNode | null): ListNode | null {
let slow = head;
let fast = head;

// 阶段一:判断是否有环,找到相遇点
while (fast !== null && fast.next !== null) {
slow = slow!.next;
fast = fast.next.next;
if (slow === fast) {
// 阶段二:一个指针回头,同速前进,再相遇即入口
let p = head;
while (p !== slow) {
p = p!.next;
slow = slow!.next;
}
return p;
}
}

return null; // 无环
}

4. 面试延伸

  • 记住结论而非硬推:面试若时间紧,先说「用 Set 记录访问节点,第一个重复的就是入口」拿分,再补充 Floyd 法把空间降到 O(1)。
  • a = c + (n-1)·环长 是本题的灵魂,能手推这段等式是区分度所在。
  • 关联题:寻找重复数(LeetCode 287) 把「数组下标 -> 值」看成链表 next 指针,同样用 Floyd 找环入口,是本模板的巧妙复用。
  • 注意:相遇判断要放在两指针移动之后,否则初始都在 head 会误判。

难度:中等 | LeetCode 142 题 | Floyd 判圈算法

1. 题目

给定一个链表头节点 head,判断链表中是否存在环。若存在环返回 true,否则返回 false。

进阶:你能用 O(1)(即常量)内存解决此问题吗?

示例:

1
2
3
4
5
6
7
8
输入: head = [3, 2, 0, -4],且尾节点连回索引 1 的节点
输出: true(存在环)

输入: head = [1, 2],2 -> 1 成环
输出: true

输入: head = [1],无环
输出: false

节点定义:

1
2
3
4
5
6
7
8
class ListNode {
val: number;
next: ListNode | null;
constructor(val?: number, next?: ListNode | null) {
this.val = val ?? 0;
this.next = next ?? null;
}
}

2. 解题思路

2.1 哈希集合(直观)

遍历链表,把访问过的节点存进 Set。若再次遇到已在集合里的节点,说明成环。

  • 时间 O(n),空间 O(n)。缺点:不满足「常量内存」。

2.2 快慢指针(Floyd 判圈,最优)

想象跑道:快的和慢的在一个环形跑道上一定会相遇。

  • slow 每次走 1 步,fast 每次走 2 步,都从 head 出发。

  • 若有环,fast 最终会从后面追上 slow,两者相遇 → 返回 true。

  • 若无环,fast(或 fast.next)会先到 null → 返回 false。

  • 时间复杂度: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
function hasCycle(head: ListNode | null): boolean {
let slow = head;
let fast = head;

while (fast !== null && fast.next !== null) {
slow = slow!.next; // 走一步
fast = fast.next.next; // 走两步

if (slow === fast) return true; // 相遇即有环
}

return false; // fast 到头,无环
}

// 测试:构造 3 -> 2 -> 0 -> -4 -> (回到 2)
const n = new ListNode(3, new ListNode(2, new ListNode(0, new ListNode(-4))));
n.next.next.next.next = n.next; // 造环
console.log(hasCycle(n)); // true
console.log(hasCycle(new ListNode(1))); // false

4. 面试延伸

  • 为什么快指针不能走 3 步? 走 2 步能保证追上;步长差为 1 时相遇最有保证,是经典设定。
  • 求环入口节点 是本题的孪生题(LeetCode 142),见下一篇 [寻找环形链表入口节点]。
  • 快慢指针的通用套路还能解:链表中点、判断回文链表、数组找重复数(287) 等「成环」问题。
  • 边界:fast && fast.next 的判空顺序不能反,否则访问 fast.next.next 会空指针。

难度:简单 | LeetCode 141 题 | 快慢指针代表作

1. 题目

实现一个函数 promiseAll(iterables),行为对齐原生 Promise.all:

  • 接收一个(Promise 或普通值可迭代)数组,返回一个新 Promise。
  • 全部成功时,新 Promise 以「与输入顺序一致」的结果数组 resolve。
  • 任意一个失败时,立即 reject 第一个失败的原因(快速失败)。
  • 要能处理非 Promise 元素(普通值直接当作已成功)。
  • 空数组应立即 resolve 为 []。

示例:

1
2
3
4
5
promiseAll([1, Promise.resolve(2), fetchUrl])
-> resolve 为 [1, 2, <fetch结果>](顺序保持)

promiseAll([ok1, fail, ok2])
-> reject(fail 的原因)

2. 解题思路

三个关键点:

  1. 顺序保持:结果数组按输入下标写入,不能用 push(谁先完成谁 push 会乱序)。给每个任务捕获其 index,完成时 results[index] = value。
  2. 完成计数:用一个计数器 completed,每当一个任务成功就 +1;当 completed === 总长度 时 resolve(results)。因为异步完成顺序不定,只能靠计数判断「全好了」。
  3. 快速失败:在任一任务 catch 到错误时,直接 reject(reason)。Promise 一旦 settle 就冻结,后续再 reject 无效,所以无需额外加锁,天然只 reject 第一次。

用 Promise.resolve(x).then(...) 包裹每个元素,可同时兼容「普通值」和「Promise」——普通值会被包装成已完成的 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
25
26
27
28
29
30
31
32
33
34
35
36
37
38
function promiseAll<T>(
iterables: Iterable<T | PromiseLike<T>>
): Promise<Awaited<T>[]> {
return new Promise((resolve, reject) => {
const promises = Array.from(iterables);
const n = promises.length;
const results = new Array(n);
let completed = 0;

if (n === 0) {
resolve(results as Awaited<T>[]);
return;
}

for (let i = 0; i < n; i++) {
// 用 Promise.resolve 兼容非 Promise 值
Promise.resolve(promises[i]).then(
(value) => {
results[i] = value; // 按下标写入,保证顺序
completed++;
if (completed === n) {
resolve(results as Awaited<T>[]);
}
},
(reason) => {
reject(reason); // 快速失败
}
);
}
});
}

// 测试
promiseAll([1, Promise.resolve(2), new Promise((r) => setTimeout(() => r(3), 100))])
.then(console.log); // [1, 2, 3]

promiseAll([Promise.resolve("a"), Promise.reject("boom"), Promise.resolve("c")])
.catch(console.log); // "boom"

4. 面试延伸

手写 Promise 家族常一起问,趁热记住区别:

  • Promise.allSettled:等全部 settle(不管成败),返回 {status, value/reason} 数组,永不 reject。
  • Promise.race:第一个 settle(含 reject)的结果定胜负。
  • Promise.any:第一个成功的结果;全部失败才 reject 一个 AggregateError。
  • 并发控制(重点):真实场景不能无脑并发几千个请求。进阶手写 promiseAllLimit(tasks, limit),用「任务池 / 索引指针」维持最多 limit 个在跑,是字节/腾讯高频加试题。
  • 别忘了答「为什么按 index 写而不是 push」——这几乎是本题的核心得分点。

难度:中等 | 前端手写 Promise 必考 | 异步并发基石

1. 题目

实现一个节流函数 throttle(fn, wait):

  • 事件被频繁触发时,每 wait 毫秒最多执行一次 fn。
  • 与防抖不同:节流不要求「安静下来」,而是给执行限速,保证一段时间内至少/至多按节奏跑。
  • 保留正确的 this 与参数。

典型场景:滚动加载、拖拽、游戏连续射击、鼠标 mousemove 计算、大屏 resize。

示例:

1
2
滚动页面每 5ms 触发一次 scroll
throttle(onScroll, 200) -> 每 200ms 才真正执行一次

2. 解题思路

节流有两种主流实现,各自优缺点分明,面试最好都会写。

2.1 时间戳版(保证间隔,可能丢失最后一次)

记录上次执行时间 last。每次触发比较 now - last >= wait:满足就执行并更新 last;否则这次丢弃。

  • 优点:首次触发立即执行(leading)。
  • 缺点:如果最后一次触发落在间隔内,会被丢弃,即「尾巴」不执行(trailing 缺失)。

2.2 定时器版(保证最后一次会执行)

用 timer 作为「是否处于执行中」的锁。触发时若空闲,延迟 wait 后执行并上锁;执行完释放锁。这样停手后仍会把最后一次补执行一次。

  • 优点:保证 trailing 执行。
  • 缺点:首次不是立即执行,而是延迟 wait。

2.3 结合版(leading + trailing)

时间戳 + 定时器合体:既保证首次立即执行,又保证最后一次停止触发后一定收尾。这是 lodash 的行为。

  • 时间复杂度:每次触发 O(1)。

3. TypeScript 实现

3.1 时间戳版

1
2
3
4
5
6
7
8
9
10
11
12
13
function throttleTimestamp<T extends (...args: any[]) => void>(
fn: T,
wait: number
) {
let last = 0;
return function (this: any, ...args: Parameters<T>) {
const now = Date.now();
if (now - last >= wait) {
last = now;
fn.apply(this, args);
}
};
}

3.2 定时器版

1
2
3
4
5
6
7
8
9
10
11
12
13
function throttleTimer<T extends (...args: any[]) => void>(
fn: T,
wait: number
) {
let timer: ReturnType<typeof setTimeout> | null = null;
return function (this: any, ...args: Parameters<T>) {
if (timer !== null) return; // 执行中,丢弃
timer = setTimeout(() => {
timer = null;
fn.apply(this, args);
}, wait);
};
}

3.3 结合版(leading + trailing,推荐)

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
function throttle<T extends (...args: any[]) => void>(fn: T, wait: number) {
let timer: ReturnType<typeof setTimeout> | null = null;
let last = 0;

return function (this: any, ...args: Parameters<T>) {
const now = Date.now();
const remaining = wait - (now - last);

if (remaining <= 0) {
// 到点了:立即执行
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
last = now;
fn.apply(this, args);
} else if (timer === null) {
// 没到点且没有排队:挂一个定时器保证最后一次执行
timer = setTimeout(() => {
last = Date.now();
timer = null;
fn.apply(this, args);
}, remaining);
}
};
}

// 用法
const onScroll = throttle(() => console.log("handle scroll"), 200);
window.addEventListener("scroll", onScroll);

4. 面试延伸

  • 节流 vs 防抖 一句话:节流是「匀速放行」,防抖是「攒一波再放」;打字联想用防抖,滚动/射击用节流。
  • requestAnimationFrame 节流:滚动/resize 类视觉更新用 rAF 对齐浏览器帧率比 setTimeout 更流畅省电。
  • passive: true:给 scroll/touch 监听加被动选项,配合节流进一步消除卡顿,全栈性能考点。
  • lodash throttle 本质就是 debounce(fn, wait, { leading: true, trailing: true }) 的特例,理解这层关系能秒杀追问。

难度:中等 | 前端手写题必考 | 性能优化基本功

0%