面试手撕代码:从 LeetCode 中等题到 Transformer 组件

系列精华 · 十九篇的骨架、边界与追问,按 ↓ 看每个模式的一句话

arganzheng · 2025-12-20

这个系列的一句话主张

中等难度的面试题背后只有十来种模式,每种模式有一个可以背下来的骨架、两三处必错的边界、一组典型的追问;AI 岗的手撕题不考识别,考对组件的理解是否精确到能写出来、并且知道怎么验证。

  算法篇 01–13 AI 手撕篇 14–19
主线 识别信号 → 模板 → 主讲题推演 → 变式与追问 → 两种语言的坑 面试怎么出题 → 形状推演 → 从零实现 → 与 torch 对拍 → 数值与形状陷阱 → 追问
共用方法 先说清骨架与不变量,再写代码,写完用最小的边界输入走查  

识别信号 → 模式

%%{init: {"flowchart": {"wrappingWidth": 200}}}%%
flowchart TB
    Q{"题面里出现什么?"}
    Q -- "子数组 / 配对" --> A["滑动窗口(02)· 前缀和(01)<br/>单调栈(03)"]
    Q -- "链式 / 树 / 图" --> B["链表(04)· 树(05)<br/>图遍历 / 拓扑(06)"]
    Q -- "最值 / 方案数" --> C["二分(07)· 堆(08)<br/>回溯(09)· DP(11、12)"]
    Q -- "多操作结构" --> D["两结构互索引(13)"]

01–03 · 数组、双指针、单调栈

篇 一句话骨架 必错边界
01 哈希与前缀和 「把见过的记下来」:\(\text{sum}(i, j] = \text{pre}[j] - \text{pre}[i]\) count[0] = 1;原地哈希值 v 放下标 v − 1
02 双指针与滑动窗口 单调性:右扩不会让违规变合法、左收不会让合法变违规 含负数的「和 = k」不满足——用前缀和
03 单调栈与单调队列 弹出时结算:被弹出者的右侧第一个更大 = 当前元素、左侧 = 新栈顶 两端哨兵 0;窗口最值用单调队列 (O(n)) 不用堆 (O(n\log k))

要点

  • LC 76 用 missing 计数使每步 (O(1));接雨水水位 = (\min(\text{leftMax},\text{rightMax}))

04–06 · 链表、二叉树、图

篇 一句话骨架 必错边界
04 链表 哑节点统一头特判;三指针反转先存 nxt;快慢指针找中点 / 判环 Floyd \(a = (k-1)c + (c-b)\);归并切分 fast = head.next
05 二叉树 返回给父节点的是能继续向上延伸的链,答案在拐点处用全局变量更新 层序先记 len(q);BST 验证带上下界 (lo, hi) 不是比父子
06 图 五个算法:BFS、DFS、Kahn、并查集、Dijkstra;BFS 入队时标记 Kahn 出队数 < n 即有环;Dijkstra 堆 + 懒删除 (O(E\log E))

要点

  • 并查集路径压缩 + 按大小合并 (O(\alpha(n)));双向 BFS \(O(b^{d/2})\)

07–09 · 二分、堆与贪心、回溯

篇 一句话骨架 必错边界
07 二分 只有一个模板:在「假假假真真真」的单调谓词上找第一个真;[lo, hi)、真收 hi = mid、假收 lo = mid + 1 「有序数组找值」只是谓词 a[i] >= t 的特例;答案二分 (O(n\log V))
08 堆、Top-K、区间、贪心 第 K 大用大小为 K 的最小堆;区间按起点排一趟扫、选最多不重叠按终点;贪心靠交换论证 双堆先进 small 再倒;会议室 = 起点排序 + 结束时间最小堆
09 回溯 做选择 → 递归 → 撤销;排列用 used[]、组合用 start out.append(path[:]) 必须拷贝;剪枝不改量级:排列仍 (O(n\cdot n!))

要点

  • 子集 \(O(n \cdot 2^n)\)、括号 \(O(4^n/\sqrt n)\)、单词搜索 \(O(mn \cdot 3^L)\)

10–13 · 字符串、DP、设计题

篇 一句话骨架 必错边界
10 字符串 中心扩展(2n − 1 个中心)、KMP lps、状态机解析、竖式 res[i + j + 1]、a + b vs b + a atoi 溢出在乘 10 之前判
11 DP(一) 五步法:定义状态(「前 i 个」还是「以 i 结尾」)→ 枚举最后一步 → 初始 → 顺序 → 答案 LIS tails 只有长度是对的、不是子序列;INF = amount + 1
12 DP(二) 倒序 = 一次、正序 = 无限;区间枚举最后被处理的元素;状态机画图再写 组合数外层物品、排列数外层容量(LC 377);hold 初值 −∞
13 设计题 单一结构做不到的复杂度用两个结构互相索引:哈希 → 双向链表(LRU)、频次桶 + min_freq(LFU)、数组 + 值到下标((O(1)) 随机集) LFU min_freq 最多 +1 或重置为 1;树状数组 lowbit

14 · 手撕 attention 家族

mask 用 −∞ 或 finfo.min;KV 只缓存 K、V

q = self.wq(x).view(B, T, H, d).transpose(1, 2)          # [B, H, T, d]
att = (q @ k.transpose(-2, -1)) / math.sqrt(d)            # [B, H, T, T]
att = att.masked_fill(mask == 0, float('-inf')).softmax(-1)
y = (att @ v).transpose(1, 2).contiguous().view(B, T, D)  # 拼回去要 contiguous
账 数
参数 \(4D^2\);FLOPs \(8TD^2 + 4T^2D\)
每 token KV \(2LH_{kv}d \cdot\) bytes:Llama-3-8B 128 KB;GQA 缩 \(H/H_{kv}\) 倍

15 · 手撕 Transformer block 与反向传播

一层参数 (12D^2);CE 的梯度就是 (p-y)

量 数 / 公式
GPT-2 small 124,439,808
LayerNorm 反向 \(\frac{1}{\sigma}\big(d\hat x - \overline{d\hat x} - \hat x\,\overline{d\hat x \odot \hat x}\big)\)
softmax + CE 反向 \(p - y\)
残差 梯度直接相加——所以深了能训

16 · 手撕 tokenizer 与解码

BPE 按 merge 顺序不贪心;采样是 penalty → T → top-k/p → min-p

边界  
top-p 保留首个越界项
蓄水池采样 第 i 个以 k / i 替换
投机接受率 \(1 - \text{TV}(p, q)\)

17 · 手撕损失函数与训练算法

GAE 从末尾递推;AdamW 衰减解耦,L2 会被 (\sqrt{\hat v}) 归一化

# AdamW:衰减不进 m、v
m = b1*m + (1-b1)*g;  v = b2*v + (1-b2)*g*g
p -= lr * (m/(1-b1**t)) / ((v/(1-b2**t)).sqrt() + eps)
p -= lr * wd * p                      # 解耦:直接加在更新里

要点

  • Adam 第一步移动约 η · sign(g);每参数 16 字节训练状态;LoRA 参数 \(r(\text{in} + \text{out})\)

18 · 手撕经典 ML 与评测指标

模型 = 目标 + 优化——k-means 单调不增所以收敛

量 公式
NDCG 折扣 \(1/\log_2(i + 1)\)
卷积输出 \(H_{out} = \lfloor (H + 2p - k)/s \rfloor + 1\)
im2col 大 \(k_h k_w\) 倍

19 · Infra 岗手撕:并发与系统

一把锁保护什么、条件变量为什么 while

题 一句话
线程安全 LRU get 也要锁——它改链表顺序
有界阻塞队列 not_empty / not_full 两个条件变量,while 防虚假唤醒
线程池 异常进 Future 不吞掉
ring allreduce 每 rank \(2\frac{N-1}{N}V\),与 N 无关
矩阵乘法 ikj 比 ijk 快 11 倍;n = 512 时分块只加循环开销
paged KV fork 只加引用、写时复制一块

四条贯穿线

线 落点
识别信号 → 模式 一道题落在两个分支是常态(接雨水:双指针 / 单调栈;第 K 大:堆 / 快速选择;LC 287:原地哈希 / Floyd / 值域二分)——这正是追问的来源
不变量思维 窗口「每个 right 处合法」、对撞「被排除的一侧不可能更优」、tails「严格递增、tails[k] 是长度 k+1 的最小末尾」、双堆「small ≤ large 且大小差 ≤ 1」
Python 与 Java 的差异 溢出(long、lo + (hi − lo)/2、乘 10 之前判);容器(不用 java.util.Stack、int[] 不能做 key、[[0]*n]*m 是同一行)
AI 手撕的方法论 形状推演 → 从零实现 → 与 torch 对拍(allclose 的 atol / rtol)→ 数值陷阱(−∞、减最大值、fp16)→ 追问(账:参数、FLOPs、KV)

常见误区(一)

  • 「子数组和等于 k 用滑动窗口」——含负数不单调;前缀和 + 哈希
  • 「滑动窗口最大值用堆」——单调队列 (O(n))
  • 「验证 BST 比较父子即可」——带上下界
  • 「BFS 出队时标记」——入队时标记
  • 「二分是在有序数组里找值」——单调谓词找第一个真
  • 「第 K 大用最大堆」——大小 K 的最小堆

常见误区(二)

  • 「剪枝改变回溯量级」——只减实际访问
  • 「tails 就是一个 LIS」——只有长度对
  • 「完全背包计数内外层可换」——组合 vs 排列
  • 「mask 填 0 或 −1e4 就够」——−∞ 或 finfo.min
  • 「weight decay 加进梯度就是 AdamW」——那是 L2
  • 「矩阵乘法分块一定更快」——先测;ikj 快 11 倍

下一步

  • 算法篇的每一篇末尾有「两种语言的坑」表;AI 手撕篇的每一份实现都在 labs 里有 torch 对拍脚本
  • 往深:《Transformer 原理与实现》第 1–4 篇——attention 手算与 nanoGPT;《后训练》第 3–4 篇——PPO / DPO 的完整推导;《数学》07 篇——反向传播与策略梯度
  • 原文总纲:/coding-interview.html;通关自测 23 题在系列总结
← Arganzheng's Blog