Jack N @ GitHub

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

1. 题目

给定一个包含非负整数的 m × n 网格 grid,请找出一条从左上角到右下角的路径,使得路径上的数字总和为最小。

说明:每次只能向下或者向右移动一步。

示例:

1
2
3
4
5
6
7
输入: grid = [
[1,3,1],
[1,5,1],
[4,2,1]
]
输出: 7
解释: 路径 1→3→1→1→1 总和最小 = 7。

2. 解题思路

只能向右或向下 ⇒ 到达 (i, j) 的前驱只有 (i-1, j) 和 (i, j-1),是最经典的 DP。

2.1 状态定义

dp[i][j] = 从左上角到 (i, j) 的最小路径和。

转移方程:

1
dp[i][j] = grid[i][j] + min(dp[i-1][j], dp[i][j-1])

边界:第一行只能从左边来 dp[0][j] = dp[0][j-1] + grid[0][j];第一列只能从上边来 dp[i][0] = dp[i-1][0] + grid[i][0]。

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

2.2 空间优化到 O(n)

每行只依赖「上一行」和「本行左边」,可用一维滚动数组:dp[j] = grid[i][j] + min(dp[j](上一行的值), dp[j-1](本行左边的值))。

极限可原地改 grid 到 O(1)(若允许修改输入)。

3. TypeScript 实现

3.1 二维 DP(清晰)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function minPathSum(grid: number[][]): number {
const m = grid.length;
const n = grid[0].length;
const dp = Array.from({ length: m }, () => new Array(n).fill(0));

dp[0][0] = grid[0][0];
for (let j = 1; j < n; j++) dp[0][j] = dp[0][j - 1] + grid[0][j];
for (let i = 1; i < m; i++) dp[i][0] = dp[i - 1][0] + grid[i][0];

for (let i = 1; i < m; i++) {
for (let j = 1; j < n; j++) {
dp[i][j] = grid[i][j] + Math.min(dp[i - 1][j], dp[i][j - 1]);
}
}

return dp[m - 1][n - 1];
}

console.log(minPathSum([[1, 3, 1], [1, 5, 1], [4, 2, 1]])); // 7

3.2 一维滚动数组

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function minPathSumOpt(grid: number[][]): number {
const m = grid.length;
const n = grid[0].length;
const dp = new Array(n).fill(0);

for (let i = 0; i < m; i++) {
for (let j = 0; j < n; j++) {
if (i === 0 && j === 0) dp[j] = grid[0][0];
else if (i === 0) dp[j] = dp[j - 1] + grid[i][j]; // 第一行
else if (j === 0) dp[j] = dp[j] + grid[i][j]; // 第一列,dp[j] 仍是上一行值
else dp[j] = Math.min(dp[j], dp[j - 1]) + grid[i][j];
}
}

return dp[n - 1];
}

4. 面试延伸

  • 变体:不同路径(计数而非求和)把 min 换成 +,是同一网格 DP 骨架。
  • 带障碍 / 三角形最小路径和 / 地下城游戏:注意「地下城游戏」需从右下倒推(HP 约束),说明并非所有网格 DP 都能正向做。
  • 追问:为什么一维数组里 dp[j] 在更新前代表上一行?因为本行还没覆盖它,天然滚动——这是滚动数组的核心技巧。

难度:中等 | LeetCode 64 | 网格 DP 入门

1. 题目

给你一个二叉树的根节点 root,判断其是否是一个有效的二叉搜索树(BST)。

有效 BST 定义:

  • 节点的左子树只包含小于当前节点的数。
  • 节点的右子树只包含大于当前节点的数。
  • 所有左子树和右子树自身必须也是二叉搜索树。

示例:

1
2
3
4
5
6
7
8
输入: root = [5,1,4,null,null,3,6]
5
/ \
1 4
/ \
3 6
输出: false
解释: 根节点 5 的右子树里出现 3(< 5),违反 BST 定义。

2. 解题思路

2.1 常见错误:只比较父子

只判断「左子 < 父 < 右子」是不够的——如上图,节点 3 是其父 4 的合法左子,但它位于根 5 的右子树中,必须 > 5。约束是从根一路传递下来的区间。

2.2 上下界递归(正确)

给每个节点维护一个开区间 (min, max):

  • 根的区间是 (-∞, +∞)。

  • 走向左孩子:上界收紧为父节点值 → (min, node.val)。

  • 走向右孩子:下界收紧为父节点值 → (node.val, max)。

  • 节点值必须严格落在自己区间内,否则非法。

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

2.3 中序遍历递增

BST 的中序遍历结果严格递增。迭代中序遍历,比较当前值与前驱值 prev,若 val <= prev 则非法。用 prev 记录上一个访问值即可,天然利用「左→根→右」顺序。

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

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 上下界递归

1
2
3
4
5
6
7
8
9
10
11
12
function isValidBST(root: TreeNode | null): boolean {
const check = (node: TreeNode | null, min: number, max: number): boolean => {
if (!node) return true;
if (node.val <= min || node.val >= max) return false;
return (
check(node.left, min, node.val) &&
check(node.right, node.val, max)
);
};

return check(root, -Infinity, Infinity);
}

3.2 中序遍历(迭代)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function isValidBSTInorder(root: TreeNode | null): boolean {
const stack: TreeNode[] = [];
let cur = root;
let prev = -Infinity;

while (cur || stack.length) {
while (cur) {
stack.push(cur);
cur = cur.left; // 一路向左
}
cur = stack.pop()!;
if (cur.val <= prev) return false; // 非严格递增
prev = cur.val;
cur = cur.right;
}

return true;
}

4. 面试延伸

  • 为什么用严格 <、>(而非 <=):BST 定义里子树值必须严格小于/大于,相等也算非法;用 Infinity 做初始边界可避免「节点值恰好等于 INT_MIN/INT_MAX」的坑(用 null 表示无边界更稳妥)。
  • 传递区间 vs 传递父指针:区间法最直观;中序法把「树问题」转成「有序序列问题」是常用思维。
  • 关联题:BST 第 k 小、把有序数组转 BST、LCA of BST 都依赖「中序有序」或「值域可二分」性质。

难度:中等 | LeetCode 98 | BST 性质 / 遍历

1. 题目

给定一个经过编码的字符串,返回它解码后的字符串。

编码规则为: k[encoded_string],表示其中方括号内部的 encoded_string 正好重复 k 次。注意 k 保证为正整数。

你可以认为输入字符串总是有效的,输入中没有额外的空格,且方括号内总是含有小写字母或另外一个方括号。

示例:

1
2
3
输入: s = "3[a]2[bc]"    输出: "aaabcbc"
输入: s = "3[a2[c]]" 输出: "accaccacc"
输入: s = "2[abc]3[cd]ef" 输出: "abcabccdcdcdef"

2. 解题思路

存在嵌套结构 k[...],天然适合栈(或递归 DFS)。

2.1 双栈法(数字栈 + 字符串栈)

用两个栈分别保存括号层的「重复次数」和「进入括号前已拼好的字符串」:

  • curStr 累积当前层字符串,num 累积当前解析的数字。

  • 遇到 [:把当前的 num 压入 numStack、当前 curStr 压入 strStack,然后清空 num 和 curStr,进入内层。

  • 遇到 ]:弹出内层重复次数 k 和「外层字符串」prev,令 curStr = prev + curStr.repeat(k),回到外层。

  • 遇到字母:拼进 curStr;遇到数字:num = num * 10 + d(处理多位数)。

  • 时间复杂度:O(输出长度),空间复杂度:O(嵌套深度)。

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
22
23
24
25
26
27
function decodeString(s: string): string {
const numStack: number[] = [];
const strStack: string[] = [];
let curStr = "";
let num = 0;

for (const ch of s) {
if (ch >= "0" && ch <= "9") {
num = num * 10 + Number(ch); // 多位数字
} else if (ch === "[") {
numStack.push(num); // 保存本层重复次数
strStack.push(curStr); // 保存进入括号前的字符串
curStr = "";
num = 0;
} else if (ch === "]") {
const k = numStack.pop()!;
const prev = strStack.pop()!;
curStr = prev + curStr.repeat(k); // 内层展开并拼接外层
} else {
curStr += ch; // 普通字母
}
}

return curStr;
}

console.log(decodeString("3[a2[c]]")); // accaccacc

3.2 单栈(把字符逐个压栈)

把 ] 之前的所有字符压栈,遇到 ] 时弹栈收集字母与数字,再展开重新压回。写法统一但弹栈拼接略繁。

4. 面试延伸

  • 递归解法:用全局指针 i,decode() 遇到 [ 递归取内层结果并 repeat,遇到 ] 返回——很多前端候选人觉得递归更直观。
  • 本题是「括号匹配 / 嵌套求值」家族,同族:基本计算器、有效括号、最小栈。看到「嵌套 + 需要回到上一层状态」就应条件反射想到栈/递归。
  • 坑点:数字可能是多位(如 12[a]),必须用 num = num*10 + d 累积,不能 Number(ch) 单独取。

难度:中等 | LeetCode 394 | 栈 / 递归

1. 题目

给定 n 个非负整数表示每个宽度为 1 的柱子的高度图,计算按此排列的柱子,下雨之后能接多少雨水。

示例:

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

输入: height = [4,2,0,3,2,5]
输出: 9

2. 解题思路

核心视角:按列计算。对每一列 i,它能接的水 = min(左侧最高, 右侧最高) - height[i](若为负则 0)。「木桶效应」取决于两边较矮的那个挡板。

2.1 动态规划预处理左右最大值

开两个数组 leftMax[i]、rightMax[i] 分别预处理「从两端到 i 的最大高度」,再逐列累加。O(n) 时间、O(n) 空间。

2.2 双指针(最优)

左右指针 l、r 向中间逼近,维护 leftMax、rightMax 两个标量:

  • 比较 height[l] 与 height[r],较矮的一侧决定该侧列的接水量:

    • 若 height[l] < height[r]:左列的左挡板是 leftMax,右挡板至少是 height[r](≥ height[l]),所以左侧当前列水位由 leftMax 决定 → 累加 leftMax - height[l],l++。
    • 右侧对称处理。
  • 边走边更新各自的 max。

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

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
22
23
24
25
26
27
28
29
function trap(height: number[]): number {
let l = 0;
let r = height.length - 1;
let leftMax = 0;
let rightMax = 0;
let water = 0;

while (l < r) {
if (height[l] < height[r]) {
if (height[l] >= leftMax) {
leftMax = height[l]; // 新的左挡板
} else {
water += leftMax - height[l]; // 填补凹处
}
l++;
} else {
if (height[r] >= rightMax) {
rightMax = height[r];
} else {
water += rightMax - height[r];
}
r--;
}
}

return water;
}

console.log(trap([0,1,0,2,1,0,1,3,2,1,2,1])); // 6

3.2 DP 预处理左右最大值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function trapDP(height: number[]): number {
const n = height.length;
if (n === 0) return 0;
const leftMax = new Array(n).fill(0);
const rightMax = new Array(n).fill(0);

leftMax[0] = height[0];
for (let i = 1; i < n; i++) leftMax[i] = Math.max(leftMax[i - 1], height[i]);

rightMax[n - 1] = height[n - 1];
for (let i = n - 2; i >= 0; i--) rightMax[i] = Math.max(rightMax[i + 1], height[i]);

let water = 0;
for (let i = 0; i < n; i++) water += Math.min(leftMax[i], rightMax[i]) - height[i];

return water;
}

4. 面试延伸

  • 栈解法:用单调递减栈按「行」积水(遇到更高柱子就弹出凹槽结算宽度),思路另类,可作加分。
  • 与盛最多水的容器对照:那道是「两条板一次盛水」,本题是「每列独立按左右挡板蓄水」,都是双指针经典,但计算方式不同。
  • 追问:为什么 height[l] < height[r] 时能直接对左列结算?因为右挡板已被 height[r] 保证不矮于当前左侧,水位由 leftMax 唯一确定,无需求真实的右侧最大值。

难度:困难 | LeetCode 42 | 双指针 / 单调栈

1. 题目

设计一个支持 push、pop、top 操作,并能在 O(1) 时间内检索到最小元素的栈。

实现 MinStack 类:

  • push(val) 将元素压入栈
  • pop() 移除栈顶元素
  • top() 返回栈顶元素
  • getMin() 返回栈中的最小元素

示例:

1
2
3
4
5
6
7
8
9
输入:
MinStack s = new MinStack();
s.push(-2);
s.push(0);
s.push(-3);
s.getMin(); → -3
s.pop();
s.top(); → 0
s.getMin(); → -2

2. 解题思路

普通栈取最小值需要 O(n) 遍历。要 O(1),需额外空间换时间。

2.1 辅助栈(同步记录最小值)

维护两个栈:data 存所有元素,minStack 存「对应 data 各状态下的最小值」。

  • push 时:data 正常入栈;minStack 入栈 min(val, 当前 minStack 栈顶)。
  • pop 时:两栈同时弹出。
  • getMin:直接看 minStack 栈顶。

这样 minStack 栈顶始终是 data 当前全部元素的最小值。

  • 时间复杂度:所有操作 O(1);空间复杂度:O(n)。

2.2 优化:最小栈只在「新最小或并列最小」时压入

minStack 只在 val <= 当前最小 时压入,pop 时若 data 弹出值等于栈顶再同步弹出 minStack,可省部分空间(注意 <= 以处理重复最小值)。

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
22
23
24
25
class MinStack {
private data: number[] = [];
private minStack: number[] = [];

push(val: number): void {
this.data.push(val);
const curMin = this.minStack.length
? this.minStack[this.minStack.length - 1]
: val;
this.minStack.push(Math.min(val, curMin));
}

pop(): void {
this.data.pop();
this.minStack.pop();
}

top(): number {
return this.data[this.data.length - 1];
}

getMin(): number {
return this.minStack[this.minStack.length - 1];
}
}

3.2 单栈存差值(进阶,省一个栈)

栈中每个元素存「压入时的值 − 当时的最小值」。用 min 记录当前最小:

  • 压入 val:若 val - min >= 0 直接入栈;否则说明出现更小值,入 val - min(负数)并更新 min = val。
  • pop 时:弹出 d,若 d >= 0 原值为 min + d;若 d < 0 说明弹出的是「旧的最小值」,需还原 min = min - d。

思路巧妙但边界易错,面试能讲清 3.1 已足够,3.2 作为加分。

4. 面试延伸

  • 核心思想:用空间换 O(1) 查询,「辅助栈记录历史状态」是通用技巧。
  • 追问:如果 getMin 之后还要能恢复?——正因为 pop 同步回退 minStack,历史最小值可正确还原,这是辅助栈方法的正确性来源。
  • 类似设计题:LRU 缓存、带最大值的队列(双栈模拟队列 + 单调队列)等,考察数据结构组合能力,可对照本博客 LRU Cache。
  • 语言细节:TS 里 private 字段、返回栈顶用 arr[arr.length-1],注意空栈的防御(LeetCode 保证合法调用)。

难度:中等 | LeetCode 155 | 设计 / 栈

1. 题目

给你一个整数数组 nums,有一个大小为 k 的滑动窗口从数组的最左侧移动到最右侧。你只可以看到在滑动窗口内的 k 个数字,滑动窗口每次只向右移动一位。

返回滑动窗口中的最大值组成的数组。

示例:

1
2
3
4
5
6
7
8
9
10
11
输入: nums = [1,3,-1,-3,5,3,6,7], k = 3
输出: [3,3,5,5,6,7]

窗口位置 最大值
--------------- -----
[1 3 -1] -3 5 3 6 7 3
1 [3 -1 -3] 5 3 6 7 3
1 3 [-1 -3 5] 3 6 7 5
1 3 -1 [-3 5 3] 6 7 5
1 3 -1 -3 [5 3 6] 7 6
1 3 -1 -3 5 [3 6 7] 7

2. 解题思路

2.1 暴力法

每个窗口遍历求最大,O(n·k),k 大时超时,仅作对比。

2.2 单调递减双端队列(最优)

维护一个存下标的双端队列,保证队列对应元素值从队头到队尾单调递减,队头始终是当前窗口最大值的下标。

对每个新元素 i:

  1. 淘汰过期:若队头下标已滑出窗口(queue[0] <= i - k),从队头弹出。
  2. 维护单调性:从队尾开始,凡是值 <= nums[i] 的下标全部弹出——它们被 nums[i]「又新又大」地压制,永远不可能再成为答案。
  3. 把 i 入队尾。
  4. 当 i >= k - 1 开始收集答案,队头即当前窗口最大值。
  • 时间复杂度:O(n),每个元素最多入队出队各一次;空间复杂度:O(k)。

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
function maxSlidingWindow(nums: number[], k: number): number[] {
const n = nums.length;
const deque: number[] = []; // 存下标,对应值单调递减
const res: number[] = [];

for (let i = 0; i < n; i++) {
// 1. 队头过期(滑出窗口)
if (deque.length && deque[0] <= i - k) deque.shift();

// 2. 队尾所有 <= 当前值的元素失去意义,弹出
while (deque.length && nums[deque[deque.length - 1]] <= nums[i]) {
deque.pop();
}

// 3. 当前下标入队
deque.push(i);

// 4. 窗口形成后收集队头最大值
if (i >= k - 1) res.push(nums[deque[0]]);
}

return res;
}

console.log(maxSlidingWindow([1, 3, -1, -3, 5, 3, 6, 7], 3));
// [3,3,5,5,6,7]

说明:JS 数组的 shift() 是 O(n),严格最坏复杂度会退化;面试可指出用「头指针 index」模拟队列头、或双向链表实现真正 O(1) 出队。此处为清晰用 shift。

4. 面试延伸

  • 单调栈 vs 单调队列:单调栈解决「下一个更大元素」等;单调队列额外处理「窗口左边界过期」,本题是其模板题。
  • 变形:窗口最小值(改成单调递增队列)、绝对值不超过 limit 的最长子数组等,都基于单调队列。
  • 追问:为什么队列存下标而不是值?因为需要靠下标判断队头是否滑出窗口。
  • 另一路线:分块 / 稀疏表(ST) 预处理区间最大值,也能做到接近 O(n),可作为思路拓展。

难度:困难 | LeetCode 239 | 单调队列模板

1. 题目

给定两个用字符串表示的非负整数 num1 和 num2,计算它们之和,并用字符串形式返回。

要求:不能直接使用任何内置的大整数库,也不能把整个输入字符串直接转换成整数(会溢出)。

示例:

1
2
3
4
5
输入: num1 = "11", num2 = "123"
输出: "134"

输入: num1 = "999", num2 = "1"
输出: "1000"

2. 解题思路

模拟小学竖式加法,从**个位(字符串末尾)**开始逐位相加并处理进位。

2.1 双指针从尾部向前

  • i、j 分别指向 num1、num2 的末尾,carry 为进位。

  • 每一步:取当前位数字(指针越界则视为 0),sum = d1 + d2 + carry。

  • 当前结果位 = sum % 10,新的 carry = Math.floor(sum / 10)。

  • 结果字符往前拼接(或 push 后反转)。

  • 循环条件:i >= 0 || j >= 0 || carry——最后的 carry 不能漏。

  • 时间复杂度:O(max(m, n)),空间复杂度:O(max(m, n))(结果串)。

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
22
function addStrings(num1: string, num2: string): string {
let i = num1.length - 1;
let j = num2.length - 1;
let carry = 0;
const res: string[] = [];

while (i >= 0 || j >= 0 || carry > 0) {
const d1 = i >= 0 ? num1.charCodeAt(i) - 48 : 0; // '0'.charCodeAt = 48
const d2 = j >= 0 ? num2.charCodeAt(j) - 48 : 0;
const sum = d1 + d2 + carry;

res.push(String(sum % 10));
carry = Math.floor(sum / 10);

i--;
j--;
}

return res.reverse().join("");
}

console.log(addStrings("999", "1")); // "1000"

3.2 变体:字符串相乘(大数乘法)

同族题,用 i + j、i + j + 1 定位乘积位在结果数组中的落点,双循环累加后再统一处理进位。面试常作为追问。

4. 面试延伸

  • 为何用 charCodeAt 而非 parseInt:逐字符 + '0' 或 charCodeAt - 48 更快,且体现「不整体转 int」的约束。
  • JS 的坑:Number 只有 53 位安全整数(Number.MAX_SAFE_INTEGER),超大数相加若直接 + 会丢精度——这正是本题要求模拟竖式的现实动机;实际项目大数运算常用 BigInt。
  • 相关:链表版本「两数相加」(LeetCode 2),用链表节点从低位到高位模拟同样的进位逻辑。
  • 结果拼接用数组 push + 反转,避免字符串反复 += 头部的 O(n²) 开销。

难度:简单 | LeetCode 415 | 字符串/模拟

1. 题目

给你一个未排序的整数数组 nums,请你找出其中没有出现的最小的正整数。

要求:时间复杂度 O(n),并且只使用常数级别额外空间。

示例:

1
2
3
4
5
6
输入: nums = [3,4,-1,1]
输出: 2
解释: 缺失的最小正整数是 2。

输入: nums = [7,8,9,11,12]
输出: 1

2. 解题思路

关键结论:长度为 n 的数组,答案一定落在 [1, n + 1] 区间内(最理想情况数组恰好是 1..n,则答案为 n+1)。

2.1 原地哈希(把 i 放到位置 i-1)

目标:让数组「归位」——值 x(1 ≤ x ≤ n)应放到下标 x - 1。

  • 遍历数组,对每个 nums[i],只要它在 [1, n] 且「它该去的位置上的值还不等于它」,就交换过去(循环,因为换回来的值可能也需归位)。

  • 归位后再扫一遍,第一个 nums[i] !== i + 1 的位置,i + 1 就是缺失的最小正数。

  • 时间复杂度:O(n)(每个元素最多被交换归位一次),空间复杂度:O(1)。

2.2 为什么不能用哈希集合?

可以用 Set 存所有正数再从 1 开始找,满足 O(n) 时间,但空间 O(n),不满足「常数空间」的进阶要求——面试先讲 Set 思路,再优化到原地哈希。

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
22
23
function firstMissingPositive(nums: number[]): number {
const n = nums.length;

for (let i = 0; i < n; i++) {
// 只要 nums[i] 在 [1,n] 且没归位,就把它换到 nums[i]-1 处
while (
nums[i] > 0 &&
nums[i] <= n &&
nums[nums[i] - 1] !== nums[i]
) {
const target = nums[i] - 1;
[nums[i], nums[target]] = [nums[target], nums[i]];
}
}

for (let i = 0; i < n; i++) {
if (nums[i] !== i + 1) return i + 1;
}

return n + 1; // 1..n 全在,答案为 n+1
}

console.log(firstMissingPositive([3, 4, -1, 1])); // 2

坑点:解构交换 [nums[i], nums[target]] = [...] 中 target 必须先算好;若写成 nums[nums[i]-1] 内联,赋值左侧求值顺序会导致索引错乱。

3.2 标记法(取负,等价思路)

先把所有 ≤0 的数替换成 n+1(移出有效区),再对每个值的绝对值 v(1≤v≤n)把 nums[v-1] 取负作为「出现过」标记,最后第一个非负下标 +1 即答案。同样 O(n) 时间 O(1) 空间。

4. 面试延伸

  • 原地哈希 vs 排序:排序 O(n log n),原地哈希利用「答案范围有界」把空间压到 O(1),是本题精髓。
  • 变形题「数组中重复的数字 / 找丢失的数字」都能复用「下标当哈希表」这一模式。
  • 追问:为什么 while 而不是 if?因为一次交换换来的新值可能也需要归位,必须持续下沉。

难度:困难 | LeetCode 41 | 原地哈希经典

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 层序变形

0%