Jack N @ GitHub

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

1. 题目

实现一个防抖函数 debounce(fn, wait, immediate):

  • 在事件被频繁触发时,只有在停止触发 wait 毫秒后,fn 才真正执行一次。
  • 每次新触发都会「重新计时」(把上一次的待执行取消)。
  • immediate = true 时,首次触发立即执行一次,之后的连续触发不再执行,直到静默 wait 后重新武装。
  • 要求保留正确的 this 与参数,并提供 cancel 方法取消待执行。

典型场景:搜索框输入联想、窗口 resize 重排、按钮防连点、滚动监听。

示例:

1
2
用户连续输入 "typescript"(每 50ms 一个字符)
debounce(search, 300) -> 停止输入 300ms 后才发一次请求

2. 解题思路

一句话:用一个闭包持有的定时器 ID,每次触发先 clearTimeout 再重新 setTimeout。这样只有「最后一次触发」的定时器能活到时间并执行回调。

要点:

  1. timer 存在闭包里,外界看不见,只在函数内部读写。
  2. 执行回调时用 fn.apply(this, args),保证 this 指向调用方(比如事件绑定的 DOM),参数也原样透传。
  3. immediate 版本:若已立即执行过,就把 timer 设为一个哨兵值表示「冷却中」,静默期结束后置空以重新武装。
  4. cancel:清掉当前定时器并把状态复位。
  • 时间复杂度:每次触发 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
function debounce<T extends (...args: any[]) => void>(
fn: T,
wait: number
): ((...args: Parameters<T>) => void) & { cancel: () => void } {
let timer: ReturnType<typeof setTimeout> | null = null;

const debounced = function (this: ThisParameterType<T>, ...args: Parameters<T>) {
if (timer !== null) clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
fn.apply(this, args);
}, wait);
};

debounced.cancel = () => {
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
};

return debounced;
}

3.2 支持 immediate(首次立即执行)

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

return function (this: any, ...args: Parameters<T>) {
// 用 timer 是否存在判断「是否处于冷却」
if (timer !== null) clearTimeout(timer);

if (immediate) {
const callNow = timer === null; // 首次或冷却结束
timer = setTimeout(() => {
timer = null; // 重新武装
}, wait);
if (callNow) fn.apply(this, args);
} else {
timer = setTimeout(() => {
timer = null;
fn.apply(this, args);
}, wait);
}
};
}

// 用法
const onSearch = debounce((kw: string) => console.log("fetch:", kw), 300);
window.addEventListener("input", () => onSearch("abc"));
// 组件卸载 / 不再需要时:
onSearch.cancel();

4. 面试延伸

  • 防抖 vs 节流:防抖「等安静了再执行一次」,节流「固定频率最多执行一次」。二者常一起考,见下一篇 [节流]。
  • 带返回值怎么办? 防抖天然会推迟结果,直接 return 拿不到。工程做法:返回 Promise,或用回调;能讲清这个取舍很加分。
  • leading / trailing 配置:lodash 的 debounce 有 leading(是否先行执行)、maxWait(最长等待,防止永远不执行,此时退化为带节流的防抖)。
  • React 中要配合 useMemo/useRef 保存同一个 debounce 实例,否则每次渲染都新建、定时器互相打架。

难度:简单 | 前端手写题必考 | 事件频率控制基石

1. 题目

实现一个函数,把可能嵌套多维的数组「摊平」成一维数组。前端面试手写题常客。

示例:

1
2
flatten([1, [2, [3, [4]], 5]])  ->  [1, 2, 3, 4, 5]
flatten([1, [2, 3], 4]) -> [1, 2, 3, 4]

进阶要求:支持指定「扁平化深度」 flatten(arr, depth),depth 表示要展开几层,Infinity 表示全部展开(对齐 Array.prototype.flat 的行为)。

2. 解题思路

嵌套结构 = 天然的递归场景。核心逻辑一句话:

遍历数组,遇到元素是数组就递归展开并把结果拼接,否则直接收集。

对于「限深度」版本,给递归加一个 depth 参数,每下钻一层 depth - 1,为 0 时不再展开直接返回当前层拷贝。

可以用多种流派展示功力:

  • reduce + 递归(最能体现思路,面试首推)。

  • 展开运算符 + 递归。

  • while + some + concat(检测是否还有数组,逐步拉平)。

  • toString 的副作用 trick(不推荐,会把 null 变空串)。

  • 生成器 function*(工程优雅,可迭代惰性展开)。

  • 时间复杂度:O(N),N 为所有层级元素总数。

  • 空间复杂度:O(N) + 递归栈 O(最大深度)。

3. TypeScript 实现

3.1 基础版:reduce + 递归

1
2
3
4
5
6
7
8
9
type Nested = number | Nested[];

function flatten(arr: Nested[]): number[] {
return arr.reduce<number[]>(
(acc, cur) =>
acc.concat(Array.isArray(cur) ? flatten(cur) : cur),
[]
);
}

3.2 进阶版:支持 depth(对齐 Array.flat)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function flattenDepth(arr: Nested[], depth = 1): number[] {
const res: number[] = [];

const dfs = (input: Nested[], d: number) => {
for (const item of input) {
if (Array.isArray(item) && d > 0) {
dfs(item, d - 1); // 下钻一层,深度预算 -1
} else {
res.push(item as number);
}
}
};

dfs(arr, depth);
return res;
}

3.3 生成器版:可迭代、优雅

1
2
3
4
5
6
7
8
function* genFlat(arr: Nested[]): Generator<number> {
for (const item of arr) {
if (Array.isArray(item)) yield* genFlat(item);
else yield item;
}
}

const flattenGen = (arr: Nested[]): number[] => [...genFlat(arr)];
1
2
3
4
// 测试
console.log(flatten([1, [2, [3, [4]], 5]])); // [1,2,3,4,5]
console.log(flattenDepth([1, [2, [3]]], 1)); // [1,2,[3]] -> [1,2,3 保留]
console.log(flattenDepth([1, [2, [3, [4]]], 5], Infinity)); // [1,2,3,4,5]

4. 面试延伸

  • flat vs flatMap:flatMap 只展开一层且能 map,性能略优于 map().flat()。
  • 对象/字符串边界:真实项目要判 Array.isArray,别用 typeof === 'object',否则 null、字典对象会被误展开。
  • 保持类型:泛型进阶 type Flat<T> = ... 能在类型层面推导扁平结果,展示 TS 功底。
  • 一句话:能默写递归版是底线,能顺手写出限深度 + 生成器版就是加分项。

难度:简单 | 前端手写题 Top 10 | 递归基本功

1. 题目

给定一个整数数组 nums,把它按升序排序。要求手写快速排序,并分析平均与最坏时间复杂度、空间复杂度。

示例:

1
2
输入: [5, 2, 9, 1, 5, 6]
输出: [1, 2, 5, 5, 6, 9]

2. 解题思路

快速排序基于分治(Divide & Conquer):

  1. 选基准 pivot:从数组里挑一个值(首/尾/随机/三数取中)。
  2. 分区 partition:重排数组,使「小于 pivot 的在左边、大于的在右边」,并把 pivot 放到它最终应在的位置 p。
  3. 递归:对左右两个子区间分别快排。

分区是灵魂。最常用 Lomuto 方案:以最后一个元素为 pivot,用指针 i 维护「小于区」的右边界,遍历指针 j,遇到比 pivot 小的就与 i+1 交换。

复杂度:

  • 平均时间 O(n log n),每次大致对半分。
  • 最坏时间 O(n²):数组已有序且总选端点为 pivot,退化为链式划分。随机化 pivot 可高概率避免。
  • 空间 O(log n):递归栈(原地版本),非原地拼接版本为 O(n)。
  • 快排是不稳定排序(相等元素相对顺序可能改变)。

3. TypeScript 实现

3.1 简洁版(易记,额外空间 O(n))

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function quickSortSimple(nums: number[]): number[] {
if (nums.length <= 1) return nums;

const pivot = nums[Math.floor(nums.length / 2)];
const left: number[] = [];
const mid: number[] = [];
const right: number[] = [];

for (const x of nums) {
if (x < pivot) left.push(x);
else if (x === pivot) mid.push(x);
else right.push(x);
}

return [...quickSortSimple(left), ...mid, ...quickSortSimple(right)];
}

用 mid 单独收集等于 pivot 的元素,能正确处理重复值且不重复比较,避免部分实现卡在相等上。

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
function quickSort(nums: number[], lo = 0, hi = nums.length - 1): number[] {
if (lo >= hi) return nums;

const p = partition(nums, lo, hi);
quickSort(nums, lo, p - 1);
quickSort(nums, p + 1, hi);
return nums;
}

function partition(nums: number[], lo: number, hi: number): number {
// 随机化 pivot,避免有序数据退化
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; // i 指向「小于区」的下一个位置

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]]; // pivot 归位
return i;
}

// 测试
console.log(quickSort([5, 2, 9, 1, 5, 6])); // [1,2,5,5,6,9]

4. 面试延伸

  • 为什么快排实践中比归并快? 原地、缓存友好、常数小;归并需要额外数组。
  • ** nth element / Top-K(快速选择 215 题)**:partition 后只递归一边,平均 O(n) 找第 K 大,是快排的孪生应用。
  • 三路快排(Dutch National Flag):把区间分成 < == > 三段,专门治「大量重复元素」,此时等于段直接不再递归。
  • 手写必问:pivot 怎么选?最坏什么情况?是否稳定?——三点答上来就是满分。

难度:中等 | 手写排序基本功 | 快速选择衍生高频

1. 题目

以数组 intervals 表示若干个区间的集合,其中单个区间为 intervals[i] = [start_i, end_i]。请你合并所有重叠的区间,并返回一个不重叠的区间数组,该数组需恰好覆盖输入中的所有区间。

示例:

1
2
3
4
5
6
7
输入: [[1,3],[2,6],[8,10],[15,18]]
输出: [[1,6],[8,10],[15,18]]
解释: [1,3] 与 [2,6] 重叠,合并为 [1,6]

输入: [[1,4],[4,5]]
输出: [[1,5]]
解释: 端点相接也算重叠

2. 解题思路

区间能否合并,取决于它们在数轴上的相对位置。若无序,两个区间可能隔着十万八千里却要判重,非常麻烦。核心思路:先按左端点排序,让可能重叠的区间彼此相邻,然后一次线性扫描。

排序后遍历,维护结果数组 res,对当前区间 cur:

  1. 若 res 为空,或 cur.start > res 最后一个.end(不重叠)→ 直接 push 新区间。
  2. 否则说明重叠 → 合并:更新最后一个区间的 end = max(旧end, cur.end)。

为什么是 max?考虑 [[1,10],[2,3]],第二个区间完全被包含,end 不能取 cur.end,要保留较大的那个。

  • 时间复杂度:O(n log n),排序主导。
  • 空间复杂度:O(n)(存结果;不计则为 O(log 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
function merge(intervals: number[][]): number[][] {
if (intervals.length === 0) return [];

// 按左端点升序
intervals.sort((a, b) => a[0] - b[0]);

const res: number[][] = [];

for (const cur of intervals) {
const last = res[res.length - 1];

// 无结果 或 与上一个不重叠 -> 开新区间
if (!last || cur[0] > last[1]) {
res.push([...cur]);
} else {
// 重叠 -> 合并,取右端点较大者
last[1] = Math.max(last[1], cur[1]);
}
}

return res;
}

// 测试
console.log(merge([[1, 3], [2, 6], [8, 10], [15, 18]]));
// [[1,6],[8,10],[15,18]]
console.log(merge([[1, 4], [4, 5]])); // [[1,5]]
console.log(merge([[1, 10], [2, 3]])); // [[1,10]]

4. 面试延伸

  • 插入区间(57 题):找到插入位置,把与新区间相交的全部吞并,取新端点。
  • 区间相交 / 无重叠最少删除(435 题):按右端点排序 + 贪心,是另一条主线,别和本题的左端点排序搞混。
  • 会议室 II(253 题):求同时进行的最多区间数,用最小堆或「起点/终点分别排序 + 双指针扫描线」。
  • 通用套路:区间题先想排序策略(按左?按右?),再决定扫描时维护什么状态。

难度:中等 | LeetCode 56 题 | 排序 + 扫描线经典

1. 题目

给你二叉树的根节点 root,返回其节点值的层序遍历(逐层从左到右访问所有节点),结果按层分组。

示例:

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

输入: root = [3, 9, 20, null, null, 15, 7]
输出: [[3], [9, 20], [15, 7]]

节点定义同前(TreeNode)。

2. 解题思路

层序遍历 = 广度优先搜索(BFS),天然对应队列(先进先出)。

难点在于「如何把结果按层分开」。关键技巧:

每轮 while 开始时,队列里的元素恰好就是「当前层的全部节点」,数量为 queue.length。用 for (let i = 0; i = levelSize) 固定处理这一层,处理时把下一层的孩子依次入队。

流程:

  1. 根节点入队。
  2. 当队列非空:取 levelSize = queue.length,循环 levelSize 次,每次出队一个节点记录其值,并把它的左右孩子入队。
  3. 一层结束后,把这层的值数组 push 进结果。
  • 时间复杂度:O(n)。
  • 空间复杂度:O(n),队列最多容纳一层的节点(完全二叉树最后一层约 n/2)。

3. TypeScript 实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function levelOrder(root: TreeNode | null): number[][] {
if (root === null) return [];

const res: number[][] = [];
const queue: TreeNode[] = [root];

while (queue.length > 0) {
const levelSize = queue.length;
const level: number[] = [];

for (let i = 0; i < levelSize; 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;
}

性能提示:Array.shift() 是 O(n),节点很多时可改用索引指针 head 模拟队列头,或直接用两个数组交替,避免频繁移动。面试手写版本用 shift 完全 OK。

4. 面试延伸

  • 锯齿形层序遍历(103 题):偶数层反转 level,或双端队列两头取。
  • 自底向上层序(107 题):正常 BFS 最后 res.reverse()。
  • 右视图(199 题):每层只取最后一个节点值 level[levelSize - 1]。
  • 最小深度 / 最短路径类:BFS 求「层数」正是 [最大深度] 一文的 BFS 解法,也常用于网格最短步数。
  • 一句话记忆:BFS 用队列、DFS 用栈(或递归);「按层」是 BFS 的标志。

难度:中等 | LeetCode 102 题 | BFS 模板题

1. 题目

给定一个二叉树 root,返回其最大深度。

二叉树的深度是指从根节点到最远叶子节点的最长路径上的节点数。

示例:

1
2
3
4
5
6
7
8
9
10
11
        3
/ \
9 20
/ \
15 7

输入: root = [3, 9, 20, null, null, 15, 7]
输出: 3 (最长路径 3 -> 20 -> 15 或 3 -> 20 -> 7,共 3 个节点)

输入: root = []
输出: 0

节点定义:

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 === undefined ? 0 : val;
this.left = left ?? null;
this.right = right ?? null;
}
}

2. 解题思路

2.1 DFS 递归(最直观)

最大深度满足递归定义:

1
2
depth(node) = 1 + max(depth(node.left), depth(node.right))
depth(null) = 0

即:当前节点深度 = 左右子树更深的那个 + 1(加自己这一层)。这是典型的「把子问题答案直接组合」的递归。

  • 时间复杂度:O(n),每个节点访问一次。
  • 空间复杂度:O(h),递归栈,最坏 O(n)(退化成链表)。

2.2 BFS 层序

按层遍历,遍历到第几层,深度就是几。用队列 + for 循环固定「一层」的节点数,每处理完一层 depth++。适合顺带求最小深度、按层输出等变体。

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
// DFS 递归
function maxDepth(root: TreeNode | null): number {
if (root === null) return 0;
return 1 + Math.max(maxDepth(root.left), maxDepth(root.right));
}

// BFS 层序
function maxDepthBFS(root: TreeNode | null): number {
if (root === null) return 0;

const queue: TreeNode[] = [root];
let depth = 0;

while (queue.length > 0) {
const levelSize = queue.length; // 锁定当前层节点数
for (let i = 0; i < levelSize; i++) {
const node = queue.shift()!;
if (node.left) queue.push(node.left);
if (node.right) queue.push(node.right);
}
depth++; // 完成一层
}

return depth;
}

4. 面试延伸

  • 最小深度(111 题) 的坑:只有一个孩子时,不能简单 min(左, 右),要排除值为 0 的那侧空子树。
  • 平衡二叉树判断(110 题):DFS 求深度时顺带检查任意节点左右子树高度差 ≤ 1,用「自底向上 + 提前终止(返回 -1 作标记)」优化到 O(n)。
  • 树的直径(543 题):求任意两点最长路径,需要在全局记录 左高 + 右高 的最大值,是最大深度的高频变体。
  • 递归三要素:明确返回值含义、写好 base case(null -> 0)、信任递归不需要手动展开。

难度:简单 | LeetCode 104 题 | 二叉树递归入门

1. 题目

给定一个只包含 '('、')'、'{'、'}'、'['、']' 的字符串 s,判断字符串是否有效。

有效字符串需满足:

  1. 左括号必须用相同类型闭合。
  2. 左括号必须以正确的顺序闭合。
  3. 每个右括号都有一个对应的相同类型左括号。

示例:

1
2
3
4
5
"()"        -> true
"()[]{}" -> true
"(]" -> false
"([)]" -> false
"{[]}" -> true

2. 解题思路

括号匹配是栈的招牌应用场景。核心规律:最近打开的括号,必须最先被闭合(后进先出)。

遍历字符串:

  • 遇到左括号,压栈。
  • 遇到右括号,看栈顶:
    • 栈为空 → 没有可匹配的左括号,false。
    • 栈顶不是对应的左括号 → 顺序或类型错误,false。
    • 匹配成功 → 弹出栈顶。
  • 全部遍历结束后,栈必须为空(所有左括号都被闭合),否则 false。

一个常见优化技巧:遇到右括号时,如果它应该匹配的左括号不在栈顶,立即失败,无需额外判断。

  • 时间复杂度:O(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
function isValid(s: string): boolean {
const pairs: Record<string, string> = {
')': '(',
']': '[',
'}': '{',
};
const stack: string[] = [];

for (const ch of s) {
// 右括号:栈顶必须是对应的左括号
if (ch in pairs) {
const top = stack.pop(); // 空数组 pop 返回 undefined,天然处理空栈
if (top !== pairs[ch]) return false;
} else {
// 左括号:入栈
stack.push(ch);
}
}

return stack.length === 0;
}

// 测试
console.log(isValid("{[]}")); // true
console.log(isValid("([)]")); // false
console.log(isValid("(")); // false

4. 面试延伸

  • 最长有效括号(LeetCode 32) 用栈记录下标,求长度;进阶可用 DP,是难题级别。
  • 括号生成(22 题) 是回溯:维护左右括号计数,left < n 加左括号、right < left 加右括号。
  • 有效括号 III / 通配符(1047 删重复) 等都用栈,掌握「遇右查栈顶」这一招就能举一反三。
  • 扩展字符集(如标签嵌套 <div>)时,把 Record 换成 Map 支持多字符 key 即可。

难度:简单 | LeetCode 20 题 | 栈应用第一课

1. 题目

设计并实现一个 LRU(Least Recently Used,最近最少使用)缓存,支持 get 和 put 操作,要求两者都是 O(1) 时间复杂度。

  • 构造时给定正整数 capacity 表示缓存容量。
  • get(key):存在则返回 value,否则返回 -1。
  • put(key, value):写入数据。若写入导致容量超限,应驱逐最久未使用的键。
  • 关键:get 和 put 都要更新「使用顺序」,被访问的键变成「最近使用」。

示例:

1
2
3
4
5
6
7
LRUCache cache = new LRUCache(2);
cache.put(1, 1);
cache.put(2, 2);
cache.get(1); // 返回 1,此时 1 变为最新,2 变为最旧
cache.put(3, 3); // 容量满,驱逐最旧的 2
cache.get(2); // 返回 -1(未找到)
cache.get(3); // 返回 3

2. 解题思路

LRU 的两大需求互相矛盾:

  1. O(1) 按 key 查找 —— 需要哈希表。
  2. O(1) 维护使用顺序、淘汰尾部 —— 需要能在头部/尾部任意增删的有序结构。

答案:哈希表 + 双向链表。这正是浏览器缓存、Redis allkeys-lru、各类内存缓存的标准实现。

  • 双向链表:越靠近 head 越「新」,越靠近 tail 越「旧」。淘汰时直接删 tail 前一个。
  • 哈希表:key -> 链表节点,实现 O(1) 定位到某个节点。
  • 命中或写入后,把该节点用 addToHead(先 remove 再插入头部)移到最前。

为什么是双向而非单向?因为删除任意节点时需要 O(1) 拿到它的前驱,单向链表做不到。

巧用语言特性:JavaScript 的 Map 本身就维护插入顺序,把 key 重新删除再插入等价于「移到最新」,可以极简实现。面试时建议两种都讲:Map 版展示语言理解,手写双向链表版展示数据结构功底。

3. TypeScript 实现

3.1 极简版:利用 Map 的插入有序性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class LRUCache {
private map = new Map<number, number>();
constructor(private capacity: number) {}

get(key: number): number {
if (!this.map.has(key)) return -1;
const value = this.map.get(key)!;
// 删除后重新插入 = 移动到最新(Map 尾部为最近使用)
this.map.delete(key);
this.map.set(key, value);
return value;
}

put(key: number, value: number): void {
if (this.map.has(key)) {
this.map.delete(key);
} else if (this.map.size >= this.capacity) {
// 删除第一个 key,即最久未使用(队首)
const oldest = this.map.keys().next().value!;
this.map.delete(oldest);
}
this.map.set(key, value);
}
}

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
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
interface DLinkedNode {
key: number;
value: number;
prev: DLinkedNode | null;
next: DLinkedNode | null;
}

class LRUCacheAdvanced {
private map = new Map<number, DLinkedNode>();
private size = 0;
// 哨兵节点,避免大量 null 判断
private head: DLinkedNode = { key: 0, value: 0, prev: null, next: null };
private tail: DLinkedNode = { key: 0, value: 0, prev: null, next: null };

constructor(private capacity: number) {
this.head.next = this.tail;
this.tail.prev = this.head;
}

get(key: number): number {
const node = this.map.get(key);
if (!node) return -1;
this.moveToHead(node); // 命中即刷新为最新
return node.value;
}

put(key: number, value: number): void {
const node = this.map.get(key);
if (node) {
node.value = value;
this.moveToHead(node);
return;
}

const newNode: DLinkedNode = { key, value, prev: null, next: null };
this.map.set(key, newNode);
this.addToHead(newNode);
this.size++;

if (this.size > this.capacity) {
const removed = this.removeTail(); // 淘汰最旧
this.map.delete(removed.key);
this.size--;
}
}

private addToHead(node: DLinkedNode): void {
node.prev = this.head;
node.next = this.head.next;
this.head.next!.prev = node;
this.head.next = node;
}

private removeNode(node: DLinkedNode): void {
node.prev!.next = node.next;
node.next!.prev = node.prev;
}

private moveToHead(node: DLinkedNode): void {
this.removeNode(node);
this.addToHead(node);
}

private removeTail(): DLinkedNode {
const node = this.tail.prev!;
this.removeNode(node);
return node;
}
}
  • 时间复杂度:get / put 均为 O(1)。
  • 空间复杂度:O(capacity)。

4. 面试延伸

  • LFU 缓存(LeetCode 460) 是 LRU 的进阶,需要频率桶 + 双向链表,更烧脑。
  • 真实工程:Redis 用近似 LRU(随机采样淘汰)省内存;前端 keep-alive、图片缓存、请求缓存都用 LRU 思想。
  • 讲题要点:先说清「为什么单结构做不到 O(1)」,再自然引出「哈希 + 双向链表」的组合,展示的是权衡能力而非背代码。

难度:中等 | LeetCode 146 题 | 全栈数据结构压轴题

1. 题目

将两个升序链表合并为一个新的升序链表并返回。新链表是通过拼接给定的两个链表的所有节点组成的。

示例:

1
2
3
4
5
输入: 1 -> 2 -> 4 , 1 -> 3 -> 4
输出: 1 -> 1 -> 2 -> 3 -> 4 -> 4

输入: l1 = [], l2 = []
输出: []

节点定义:

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

2. 解题思路

经典的**归并(merge)**操作,也是归并排序的核心子步骤。

引入一个哑节点 dummy 作为结果链表的哨兵头,避免讨论「第一个节点是谁」的边界。再用 curr 指向结果链表尾部:

  1. 同时遍历 l1、l2,比较当前值,把较小的接到 curr.next,对应链表指针前移。
  2. curr 也前移一位。
  3. 有一方走完,把另一方剩余部分整段接上(它本身已有序)。
  4. 返回 dummy.next。
  • 时间复杂度:O(m + 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
function mergeTwoLists(
l1: ListNode | null,
l2: ListNode | null
): ListNode | null {
const dummy = new ListNode(-1);
let curr = dummy;

while (l1 !== null && l2 !== null) {
if (l1.val <= l2.val) {
curr.next = l1;
l1 = l1.next;
} else {
curr.next = l2;
l2 = l2.next;
}
curr = curr.next;
}

// 接上剩余部分(最多一条链表还有货)
curr.next = l1 !== null ? l1 : l2;

return dummy.next;
}

// 递归写法(更短,栈空间 O(m+n))
function mergeTwoListsRecursive(
l1: ListNode | null,
l2: ListNode | null
): ListNode | null {
if (l1 === null) return l2;
if (l2 === null) return l1;

if (l1.val <= l2.val) {
l1.next = mergeTwoListsRecursive(l1.next, l2);
return l1;
} else {
l2.next = mergeTwoListsRecursive(l1, l2.next);
return l2;
}
}

4. 面试延伸

  • 合并 K 个有序链表(LeetCode 23) 是本题升级,可用「最小堆」或「分治两两合并」,复杂度 O(N log k)。
  • dummy 哑节点是链表题的通用技巧,几乎能消除所有头节点特判,务必形成肌肉记忆。
  • 稳定性:比较时用 <= 保证相等时优先取 l1,这正是归并排序「稳定」的来源。

难度:简单 | LeetCode 21 题 | 归并思想入门

1. 题目

给你单链表的头节点 head,请你反转链表,并返回反转后的链表头。

示例:

1
2
3
4
5
输入: 1 -> 2 -> 3 -> 4 -> 5
输出: 5 -> 4 -> 3 -> 2 -> 1

输入: head = []
输出: []

链表节点定义:

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

2. 解题思路

2.1 迭代(首选)

反转的本质是把每个节点的 next 指针掉头,指向它原来的前驱。用三个指针:

  • prev:已反转部分的头,初始 null。
  • curr:当前正在处理节点,初始 head。
  • nextTemp:在处理前先保存 curr.next,否则断链后找不到后继。

每一步:暂存后继 → curr.next = prev → prev 前移到 curr → curr 前移到暂存的后继。循环结束时 prev 就是新头。

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

2.2 递归

递归到链表末尾,回溯时把后一个节点的 next 指回自己:head.next.next = head,再断开 head.next = null 防止成环。简洁但栈空间 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 reverseList(head: ListNode | null): ListNode | null {
let prev: ListNode | null = null;
let curr = head;

while (curr !== null) {
const nextTemp = curr.next; // 1. 暂存后继
curr.next = prev; // 2. 反转指针
prev = curr; // 3. prev 前进
curr = nextTemp; // 4. curr 前进
}

return prev;
}

// 递归
function reverseListRecursive(head: ListNode | null): ListNode | null {
if (head === null || head.next === null) return head;

const newHead = reverseListRecursive(head.next);
head.next.next = head; // 让后继指回自己
head.next = null; // 断开原指针,避免环
return newHead;
}

4. 面试延伸

  • 反转区间(LeetCode 92 反转链表 II) 或 每 k 个一组翻转(25 题) 是高频进阶,核心仍是本节的指针操作,外加「哑节点 dummy」简化边界。
  • 判断回文链表(234):快慢指针找中点 + 反转后半段 + 逐一比较,空间 O(1)。
  • 递归版本要能徒手画图讲清 head.next.next = head 的含义,这是面试官最爱追问的点。

难度:简单 | LeetCode 206 题 | 链表题地基

0%