← 返回学习路线 ◆ 贯穿项目
计算机与工程基础 · 阶段 3 · 数据结构与算法
Stage 03 / 17 · 计算机与工程基础

数据结构与算法 Data Structures & Algorithms

这一阶段有两条主线,缺一不可。第一条是面试线:笔试与初面考的就是这些,目标是「中等题 40 分钟内干净解出并能说清复杂度」。第二条是工程线,也是很多人忽略的一条——你在后面要写的每一个 AI 系统组件,本质上都是经典数据结构:RAG 的向量检索是近似最近邻索引,BM25 是倒排索引 + 堆,提示缓存是LRU 哈希表,推理服务的请求排队是优先队列,分词是前缀树 / 贪心最长匹配,Agent 的任务编排是DAG 拓扑排序。把这条线打穿,你在第 13、14、16 阶段的工程量会有质的差别。

⏱ 6–8 周 🎯 入门–进阶 ◆ 里程碑 M3 2026-09-29
复杂度哈希表跳表堆Trie图算法倒排索引BM25HNSWLRU布隆过滤器BPEDP

阶段总览

✔
学完你能做到
  • 对一段代码给出时间/空间复杂度的严格分析,并解释均摊复杂度与最坏复杂度的区别
  • 手写动态数组、双向链表、哈希表(含扩容与冲突处理)、二叉堆、并查集,并说清各自的取舍
  • 手写跳表与 Trie,理解它们在有序检索与前缀匹配中的优势
  • 实现完整的倒排索引 + BM25 打分,达到可用于生产检索的程度
  • 实现可配置的 HNSW 近似最近邻索引,画出召回率 vs QPS 曲线并给出选参建议
  • 实现带 TTL 与并发保护的 LRU/LFU 混合缓存,并用命中率数据证明有效性
  • 用拓扑排序实现带依赖关系的任务调度器,含环检测与失败传播
  • 实现 BPE 分词器,并能与 HuggingFace 的 tokenizer 输出对齐
  • 熟练用动态规划求解序列问题,包括编辑距离与最长公共子序列的工程化变体
  • 通过中等难度面试题专项训练,能识别题型模式并在白板上写出无 bug 的代码
阶段知识结构总览 · 从手写数据结构到 RAG 检索内核
阶段 3 · 数据结构与算法Data Structures & Algorithms · 9 大章 · 240+ 知识点
1. 复杂度分析与算法思维O / Ω / Θ 记号均摊 ≠ 平均倍数扩容等比级数倍增测试 doubling test主定理三情形cache miss ≈ 200 周期
2. 线性结构:数组、链表、跳表与哈希动态数组均摊 O(1)三指针反转链表Floyd 快慢指针判圈开放寻址 + 墓碑负载因子 α 0.75跳表 p=0.5 随机提升
3. 树、堆与 Trie中序/层序迭代遍历is_valid_bst 上下界B+ 树高 3–4建堆 O(n)Top-K 最小堆Trie 前缀匹配双堆流式中位数
4. 图算法与 DAG 调度邻接表 / CSRBFS 双向搜索二分图染色Kahn 拓扑分层关键路径 makespanDijkstra 堆优化DSU 路径压缩 + 按秩
5. 检索索引:倒排、BM25 与 HNSWBM25 k1=1.5 b=0.75差分 + VByte 压缩WAND / BMW 剪枝HNSW M=16–64ef_search=64 拐点PQ m=16 压 1/16RRF k=60cross-encoder 重排
6. 缓存、调度与概率数据结构LRU 哈希 + 双向链表TTL + singleflightZipf 命中率曲线布隆过滤器 m/kMinHash + LSHHyperLogLog 2% 误差
7. 字符串算法与 BPE 分词KMP next 数组Aho-Corasick fail 指针双数组 Trie DAT后缀数组 SA + LCPBPE 合并最高频对byte-level 无 OOV中文 2.7 字节/字符
8. 动态规划与序列对齐0/1 背包逆序区间 DP 按长度状态压缩 TSP编辑距离 LevenshteinWER 词错率LCS + Hirschberg
9. 面试算法收敛十四个解题模式单调栈模板滑动窗口 last[ch]二分左闭右开答案二分 feasible40 分钟中等题 bug-free
贯穿项目 · M3 从零实现检索索引第 12–16 周可配置 M / efConstruction / efSearch 的 HNSW,支…倒排索引 + BM25(含 k1/b 调参与文档长度归一化)LRU + LFU 混合淘汰,带 TTL 与并发保护优先队列实现的任务调度器,支持优先级、超时与公平性
学习路径
周次主题动手产出
第 1 周复杂度分析、均摊分析、递归式与主定理对 10 段代码做复杂度标注;实现并验证均摊 O(1) 的动态数组
第 2 周线性结构:数组 / 链表 / 跳表 / 哈希表手写哈希表(链地址 + 开放寻址两版)并做冲突率实验
第 3 周树、堆、Trie、并查集手写二叉堆与 Trie;用 Trie 实现前缀缓存
第 4 周图算法与 DAG 调度实现 BFS/DFS/拓扑排序/Dijkstra;写一个带依赖的任务调度器
第 5 周检索索引:倒排 + BM25 + HNSW(核心)实现倒排索引与 BM25;实现 HNSW 并测召回率-延迟曲线
第 6 周缓存、调度与概率数据结构实现 LRU+LFU 混合缓存、布隆过滤器,并测命中率
第 7 周字符串与 BPE 分词 + 动态规划与序列对齐实现 BPE 并对齐 HF 输出;实现编辑距离与 LCS
第 8 周面试算法收敛(里程碑 M3 收口)按模式刷题;完成 Hamauls Orion 检索内核的性能报告
✔
有 10 年 Java 背景的人怎么走:你已经具备 Hash 与容器的使用经验,但需要注意三点差异:① 不要停在「会用」——面试会要求你手写 HashMap、手写 LRU;② Java 集合的实现细节迁移到 Python 会变形(Python 的 dict 是有序哈希表、list 无自动扩容策略暴露),要重新建立对 Python / NumPy 容器性能的直觉;③ 本阶段真正的增量是第 5、6、7 节的检索与缓存结构——这些在传统后端岗里往往不涉及,但正是 AI 系统工程师的核心竞争力。建议把 80% 时间花在这三节,面试线用「按模式刷题」的方式高效收敛。

1. 复杂度分析与算法思维

知识结构图 · 复杂度分析与算法思维
复杂度分析与算法思维4 大知识域 · 24 个知识点
渐进记号与增长阶O / Ω / Θ / o 记号n=1e6 时 O(n²) 不可行attention O(s²) 换算法线均摊 ≠ 平均哈希查表 O(1)
学习路径
  1. 读 1.1:把 O/Ω/Θ 记号套到 attention 的 O(s²) 上,标出 n=1e6 时的量级
  2. 跑内置代码,用对照表验证 O(n²) 在 n=1e6 不可行、O(n log n) 可行
  3. 完成练习自测:为 10 段代码标注复杂度并给出理由
  4. 对接 M3:给手写 HNSW 与 BM25 的查与插操作各标注复杂度
✔ 能不看表给出任意代码的增长阶,并讲清 O(n²) 何时不可行
核心知识点详解
  • O / Ω / Θ 三种界:O 是上界(最多不超过)、Ω 是下界(至少)、Θ 是紧确界(上下一致,最常用)。复杂度关心的是输入规模翻倍时耗时怎么变,不是精确常数;能给 Θ 就给 Θ,给不了至少说 O 作为安全上限。
  • n=1e6 时 O(n²) 不可行:n=1e6 时 O(n²) 需约 10¹² 次运算,CPU 上要几十分钟。这是「朴素 attention O(s²) 在 s=4096 可接受、s=100k 必须换算法」的分界线——FlashAttention / 线性注意力 / 稀疏注意力都在把二次项降掉。对照表:O(log n) 在 n=1e9 只要约 30 步,O(n²) 在 n=1e6 已不可行。
  • 均摊 = 确定性上界,不是平均:均摊保证任意 n 次操作序列的总代价不超 n×均摊值,不依赖概率;平均依赖输入分布。动态数组追加的均摊 O(1) 来自「倍数扩容 + 等比级数求和」(1+2+4+…+n/2 = n−1);若改用固定量扩容则退化为 O(n²)。
  • 常见坑:把单次当均摊:单次 append 可能是 O(n)(扩容那一次),但 n 次累计仍是 O(n),所以均摊 O(1)。判据看「最坏单次 vs 长期累计」,别因某次很慢就否定均摊结论。
均摊与复杂度验证动态数组均摊 O(1)倍数扩容等比级数growth=2.0 拷贝 1×n倍增测试 doubling test耗时比≈4 → O(n²)
学习路径
  1. 读 1.1:搞清楚为什么动态数组的均摊 O(1) 不等于每次都是 O(1)
  2. 跑内置代码,做倍增测试,把耗时比与增长倍数对应到复杂度阶
  3. 完成练习自测:推导倍数扩容的等比级数求和
  4. 对接 M3:在索引扩容路径上确认没有引入意外的 O(n²) 热点
✔ 能解释均摊与最坏的差别,并用倍增测试在实测数据上判断复杂度阶
核心知识点详解
  • 倍数扩容的必要性:容量满时按 growth=2.0 扩容,n 次追加的拷贝总数 1+2+4+…+n/2≈n−1,均摊 O(1)(约拷贝 1×n);growth=1.5 拷约 2×n 但内存更省;growth=1.1 拷约 10×n,常数反而大。Python list 实际约用 1.125。固定量 +K 扩容会退化为 O(n²)。
  • 倍增测试判复杂度阶:规模翻倍看耗时比:≈1 是对数/常数、≈2 是 O(n) 或 O(n log n)、≈4 是 O(n²)。例如 a.insert(0, i) 的耗时比≈4 → 判 O(n²);append 线性增长 → 均摊 O(1)。务必连测多个翻倍点并重复取最小值以排除 GC 与系统噪声。
  • 常见坑:只看单点耗时:单点时间分不清阶,必须看比值是否在多规模上稳定收敛。bench 里取 min(best, …) 优于取平均,因为它剔除调度抖动造成的异常大值。
递归式与主定理T(n)=aT(n/b)+f(n)c = log_b(a)主定理三情形递归树叶子数 n^cStrassen Θ(n^2.807)Karatsuba Θ(n^1.585)
学习路径
  1. 读 1.2:套主定理三情形解 T(n)=aT(n/b)+f(n)
  2. 跑内置示例,对比 Strassen 与 Karatsuba 两套指数的差异
  3. 完成练习自测:给定递归式推导出复杂度并用叶子数 n^c 核对
  4. 对接 M3:为分治式构建(如递归建堆/切分)写出递归式并求解
✔ 能对常见递归式套主定理得到 Θ,并口算索引构建的复杂度
核心知识点详解
  • 主定理三情形的判断:T(n)=aT(n/b)+f(n),令 c=log_b(a)。情形1 f(n) 远小于 n^c(叶子主导)→ Θ(n^c);情形2 相当(均衡)→ Θ(n^c·log^(k+1)n);情形3 根主导 → Θ(f(n))。先算叶子数 a^(log_b n)=n^c,再比较 f(n) 与 n^c,比背结论可靠。
  • 两条经典指数:Strassen 矩阵乘 T=7T(n/2)+Θ(n²) → Θ(n^2.807);Karatsuba 乘法 T=3T(n/2)+Θ(n) → Θ(n^1.585)。二者都靠「把一次乘法拆成更少但更大的子问题」压低指数,都是叶子主导(情形1)的典型。
  • 主定理的适用边界(常见坑):子问题规模不一致(如 T(n/3)+T(2n/3))、a/b 非常数、f(n) 不满足正则条件时主定理失效,要用递归树或 Akra-Bazzi。注意 attention 的 O(s²) 是例:分块 tiling 只改常数、不改复杂度,要降阶必须换算法。
复杂度的真实边界小 n 用插入排序 n≤16cache miss ≈ 200 周期指针追逐 pointer chasingL1 1ns / DRAM 100nsGPU warp 分支发散
学习路径
  1. 读 1.3:理解 cache miss 约 200 周期、L1 1ns 与 DRAM 100ns 的差距
  2. 跑指针追逐示例,对比顺序访问与随机访问的实测耗时
  3. 完成练习自测:在小 n 边界判断该用 O(n²) 的插入排序还是 O(n log n)
  4. 对接 M3:检查 HNSW 层序遍历与 BM25 postings 扫描是否缓存友好
✔ 能把复杂度结论落到常数与硬件上,说出何时 O(n log n) 输给 O(n²)
核心知识点详解
  • cache miss 一次 ≈ 200 周期:L1 约 1ns、DRAM 约 100ns,一次 cache miss 的代价约等于 200 次算术运算。所以热路径上「数组连续扫描」常打败「哈希 O(1) 查表」——哈希要一次随机访存(潜在 miss),数组扫描能让硬件预取器高效工作。faiss / hnswlib 极在意内存布局与局部性正是为此。
  • 指针追逐是链表的隐藏成本:链表每个节点一次随机访存,数组连续流式读取。实测遍历 100 万元素,数组约 15ms、模拟链表约 55ms(约 3–5 倍);C/C++ 上可达 10–50 倍。现代索引偏爱「数组 + 索引」(如 vLLM 的 PagedAttention 块表、图的 CSR)。
  • 小 n 用插入排序的真相:插入排序在 n≤16 时通常比快排快:无递归、无函数调用、局部性极好,所以 introsort 在小区间切换成它。面试先说理论最优复杂度,再补一句「小 n 我会考虑插入排序/线性扫描因为常数小」,这个补充可以加分。
  • 常见坑:复杂度结论脱离硬件:别背「O(n log n) 一定赢 O(n²)」——当 n 很小且调用极频繁时,常数与访存决定一切。要先估规模与访问模式,再落到常数与硬件上。
学习路径

1.1 渐进记号、增长阶与均摊复杂度

复杂度分析是算法的唯一语言。它的价值不在于算出精确的常数,而在于判断「输入规模翻倍时,耗时怎么变」——这才是选算法的依据。

记号含义通俗解释
O(f(n))渐进上界「最多」按 f(n) 增长
Ω(f(n))渐进下界「至少」按 f(n) 增长
Θ(f(n))紧确界上下界一致,最常用
o(f(n))严格下界(松)增长严格慢于 f(n)
复杂度n=1e3n=1e6n=1e9典型场景
O(1)111哈希查表、数组索引
O(log n)102030二分查找、平衡树、跳表
O(n)1e31e61e9线性扫描、向量加
O(n log n)1e42e73e10排序、FFT、多数分治
O(n²)1e61e12不可行朴素矩阵乘、双重循环
O(2ⁿ)不可行不可行不可行暴力枚举子集

注意最后两行:n=1e6 时 O(n²) 需要 10¹² 次运算,在 CPU 上要几十分钟(GPU 上可以,因为矩阵乘有专用硬件)。这解释了一个常见困惑:为什么「朴素 attention 的 O(s²)」在 s=4096 时还能接受,但 s=100k 就必须换算法(FlashAttention / 线性注意力 / 稀疏注意力)。复杂度分析给你的正是「什么时候必须换算法」这条线。

均摊分析:为什么动态数组「追加」是 O(1)

python# 动态数组的扩容:单次可能 O(n),但均摊是 O(1)
# 策略:容量满时按 2 倍扩容,把 n 个元素拷到新数组

# 从空数组连续追加 n 次,总拷贝次数:
#   1 + 2 + 4 + 8 + ... + n/2 = n - 1   (等比数列求和)
# 总代价 O(n),分摊到 n 次操作 → 每次均摊 O(1)

# ⚠️ 关键前提:必须「按倍数扩容」而不是「每次加一个固定量」
# 固定量扩容(比如每次 +10)的均摊代价是 O(n) → 总代价 O(n²)

class DynArray:
    """手写动态数组:验证均摊 O(1) 与倍数扩容的必要性"""
    def __init__(self, growth=2.0):
        self._cap, self._n, self._growth = 1, 0, growth
        self._a, self.copies = [None], 0

    def append(self, x):
        if self._n == self._cap:
            new_cap = max(1, int(self._cap * self._growth))
            self.copies += self._n                      # 记录拷贝次数
            self._a = self._a[:self._n] + [None] * (new_cap - self._n)
            self._cap = new_cap
        self._a[self._n] = x
        self._n += 1

    def __len__(self):  return self._n

for g in (2.0, 1.5, 1.1):
    d = DynArray(g)
    for i in range(100_000): d.append(i)
    print(f'growth={g}: 拷贝 {d.copies:,} 次 ≈ {d.copies / 100_000:.2f}×n')

# growth=2.0 → 约 1.00×n(均摊 O(1),但内存可能浪费近一倍)
# growth=1.5 → 约 2.00×n(均摊 O(1),内存更省,Python list 用的是 ~1.125)
# growth=1.1 → 约 10×n(仍均摊 O(1),但常数大很多)

怎么用实验验证复杂度?「倍增测试(doubling test)」:把输入规模翻倍,看耗时增长多少倍——比值 ≈ 2 是线性、≈ 4 是平方、几乎不变是对数。它比推导更可靠,因为它把常数项也一起测了进去,也是你排查「线上某个循环为什么突然变慢」的第一手段。

pythonimport time

def bench(fn, n, repeat=3):
    best = float('inf')
    for _ in range(repeat):
        t0 = time.perf_counter(); fn(n); best = min(best, time.perf_counter() - t0)
    return best

def f_append(n):
    a = []
    for i in range(n): a.append(i)            # 均摊 O(1)

def f_insert_front(n):
    a = []
    for i in range(n): a.insert(0, i)         # 每次 O(n) → 总 O(n^2)

print('--- 倍增测试:规模翻倍,耗时增长多少倍 ---')
prev = None
for n in (10_000, 20_000, 40_000):
    t = bench(f_insert_front, n)
    ratio = '' if prev is None else f'  耗时比={t/prev:.2f}'
    print(f'insert(0) n={n:>6d}  {t*1e3:8.2f} ms{ratio}')
    prev = t
print(f'append    n=1e6  {bench(f_append, 1_000_000)*1e3:.2f} ms')

# 预期输出(CPython 3.12 / Apple M 系列):
#   insert(0) n= 10000     6.0 ms
#   insert(0) n= 20000    23.5 ms  耗时比≈3.9   ← 翻倍→约 4×,判据是 O(n^2)
#   insert(0) n= 40000    94.0 ms  耗时比≈4.0
#   append    n=1e6        0.06 s   ← 线性增长,均摊 O(1)(约 60 ns/次)
# 判据速记:比值≈2 → O(n) 或 O(n log n);≈4 → O(n^2);≈1 → O(1) 或 O(log n)。
★
均摊 ≠ 平均:均摊(amortized)是确定性上界:任何 n 次操作序列的总代价都不超过 n × 均摊值,不依赖概率假设。平均(average)是概率性结论:依赖输入分布或随机化。面试里被问「为什么动态数组追加是 O(1)」时,要答出「倍数扩容 + 等比级数求和」,而不是「大部分时候不用扩容」。

1.2 递归式与主定理:分析分治算法的通用工具

分治算法的复杂度是递归式。主定理(Master Theorem)给了你一把直接读出答案的尺子,不需要每次展开递归树。

text递归式:T(n) = a·T(n/b) + f(n)      a ≥ 1, b > 1

令 c = log_b(a)(即 n^c 是「叶子总数」的量级),比较 f(n) 与 n^c:

  情形 1:f(n) = O(n^(c-ε))          → T(n) = Θ(n^c)          (叶子主导)
  情形 2:f(n) = Θ(n^c · log^k n)    → T(n) = Θ(n^c · log^(k+1) n)(均衡)
  情形 3:f(n) = Ω(n^(c+ε)) 且正则条件成立 → T(n) = Θ(f(n))    (根主导)

经典例子:
  归并排序 T(n)=2T(n/2)+Θ(n)   a=2,b=2,c=1, f=Θ(n^1) → 情形2 → Θ(n log n)
  二分查找 T(n)=1T(n/2)+Θ(1)   a=1,b=2,c=0, f=Θ(1)=Θ(n^0) → 情形2 → Θ(log n)
  朴素矩阵乘 T(n)=8T(n/2)+Θ(n²) a=8,b=2,c=3, f=Θ(n²)=O(n^(3-ε)) → 情形1 → Θ(n³)
  Strassen   T(n)=7T(n/2)+Θ(n²) a=7,b=2,c≈2.807 → 情形1 → Θ(n^2.807)
  Karatsuba  T(n)=3T(n/2)+Θ(n)  a=3,b=2,c≈1.585 → 情形1 → Θ(n^1.585)

把主定理「算一遍」比背结论可靠。下面的代码直接模拟递归树:数叶子(基础情形)个数 = a^(log_b n) = n^log_b a,再与 n^c 对照,验证你套的公式是否自洽。

pythonimport math, sys
sys.setrecursionlimit(1 << 20)

def leaves(n, a, b):
    """递归树叶子(基础情形)个数 = a^(log_b n) = n^(log_b a)"""
    if n <= 1: return 1
    return a * leaves(n // b, a, b)

for a, b, name in [(2,2,'归并排序'), (1,2,'二分查找'), (3,2,'Karatsuba'), (8,2,'朴素矩阵乘')]:
    n = 2 ** 20
    c = math.log(a, b)
    print(f'{name:8s} a={a} b={b} c=log_b(a)={c:.3f}  叶子={leaves(n,a,b):.3e}  n^c={n**c:.3e}')

# 预期输出(n = 2^20 ≈ 1.05e6):
#   归并排序 c=1.000 叶子≈1.05e6  n^c≈1.05e6   → 情形2,T=Θ(n log n)
#   二分查找 c=0.000 叶子=1        n^c=1        → 情形2,T=Θ(log n)
#   Karatsuba c=1.585 叶子≈3.2e6  n^c≈3.2e6    → 情形1,T=Θ(n^1.585)
#   朴素矩阵乘 c=3.000 叶子≈1.1e18 n^c≈1.1e18   → 情形1,T=Θ(n^3)
# 判据:叶子数 ≈ n^c 说明「叶子主导」;若 f(n) 明显小于 n^c 则答案就是 Θ(n^c)。

1.3 复杂度的边界:为什么 O(n log n) 有时输给 O(n²)

复杂度只描述增长趋势,小 n 时常数因子与访存代价可能完全主导。这一点在 AI 工程里尤其重要,因为你经常在「n 很小但调用极频繁」的热路径上做选择。

存储层级典型延迟相对 CPU 周期工程含义
L1 cache约 1 ns约 4 周期命中与否决定热路径快慢
L2 cache约 4 ns约 12 周期通常为私有或半共享
L3 / LLC约 12–20 ns约 40–60 周期跨核共享,带宽有限
主存 DRAM约 60–100 ns约 200–400 周期一次 cache miss 的代价
NVMe SSD 随机读约 100 μs约 3e5 周期比内存慢约 10^3–10^5 倍
同机房网络 RPC约 0.1–0.5 ms—分布式系统延迟的地板

这张表解释了一个反直觉的事实:一次 cache miss 的代价约等于 200 次算术运算。所以「数组连续扫描」经常打赢「哈希 O(1) 查表」——哈希要一次随机访存(潜在 cache miss),而数组扫描是硬件预取器的最爱。这也是 faiss / hnswlib 等检索库极度在意内存布局与局部性的根本原因。

pythonimport random, time

def insertion_sort(a):                       # 小 n 场景的王者
    for i in range(1, len(a)):
        x, j = a[i], i - 1
        while j >= 0 and a[j] > x:
            a[j + 1] = a[j]; j -= 1
        a[j + 1] = x
    return a

def bench(fn, data, repeat=50):
    best = float('inf')
    for _ in range(repeat):
        d = data[:]; t0 = time.perf_counter(); fn(d); best = min(best, time.perf_counter() - t0)
    return best

random.seed(0)
for n in (8, 16, 32, 64, 256):
    data = [random.random() for _ in range(n)]
    a = bench(insertion_sort, data) * 1e6
    b = bench(sorted, data) * 1e6            # CPython 内置 Timsort
    print(f'n={n:4d}  插入排序={a:7.2f}us  Timsort={b:6.2f}us  比值={a/b:.2f}')

# 预期输出:
#   n=  8  插入排序≈1.0us   Timsort≈0.5us  比值≈2.0
#   n= 16  插入排序≈3.2us   Timsort≈1.4us  比值≈2.3
#   n= 32  插入排序≈11us    Timsort≈3.0us  比值≈3.7
#   n= 64  插入排序≈42us    Timsort≈6.5us  比值≈6.5
# CPython 的 list.sort 在 run 长度 < 64 时内部就退化成二分插入排序,
# 这正是「小 n 用插入排序」在工业代码里的直接证据。
⚠
面试与实际工作的一个分歧点:面试要你答「理论最优复杂度」,但工程上要你答「在当前 n 与硬件下实测最快」。两个答案经常不一致。在面试里先把理论复杂度说清楚,再补一句「小 n 时我会考虑插入排序 / 线性扫描,因为常数更小」,这个补充会显著加分——它说明你不是背题的。

1.4 动手练习与自测

✔
本练习的判据:每题都给出「怎么算对」的判据或参考答案。先自己做,答不上来就回到对应小节重看公式与代码。
  1. 对下面三段代码标注时间复杂度并写推导:① 求数组所有两两元素之差的最小值(朴素双重循环);② 在 n 个元素的平衡 BST 中做 m 次查询;③ 用递归求斐波那契数列第 n 项(无记忆化)。
  2. 一个哈希表容量 1024、线性探测、负载因子 α=0.9。用公式估算一次不成功查找的平均探测次数(≈ (1+1/(1−α)²)/2),并说明工程上为什么要把扩容阈值设在 0.75。
  3. 写出递归式 T(n)=2T(n/4)+√n 的主定理结果,并说明它落在哪一种情形(提示:c=log_4 2=0.5,f(n)=n^0.5=n^c)。
  4. 写 5 行代码,用倍增测试判断一个未知函数 f(n) 是 O(n) 还是 O(n log n);给出你的判据阈值与「为什么要重复取最小值」。
  5. (开放题)在 n=1000 的数组上,哈希查找(O(1))与二分查找(O(log n))哪个更快?写出你的假设与验证方法。
题号通过标准目标耗时
1三段复杂度都能写推导(n²/2 / m log n / φⁿ)8 分钟
2α=0.9 ≈ 50 次、α=0.75 ≈ 8.5 次,能解释 0.75 阈值5 分钟
3判定主定理情形 2 并给出 Θ(√n·log n)4 分钟
4倍增测试判据可复现,能说明取最小值的原因10 分钟
5给出实测方案,结论由数据支撑(二分可能赢)8 分钟
★
参考答案与判据:① ① O(n²):组合数 C(n,2)≈n²/2;② O(m log n):每次查询沿树下降高 log n 层;③ O(φⁿ):朴素递归调用次数就是斐波那契数本身,指数级——这是「重叠子问题未被利用」的典型反例。
② α=0.9 时 (1+1/0.01)/2≈50.5 次探测,几乎退化成链表;α=0.75 时约 8.5 次。所以 0.75 是「性能 vs 内存」的折中阈值。
③ c=0.5,f(n)=n^0.5=Θ(n^c),落在情形 2 → T(n)=Θ(n^0.5·log n)=Θ(√n·log n)。
④ 取 n=1e4/2e4/4e4,算 t(2n)/t(n):稳定趋近 2 → O(n);趋近 2 但缓慢上升(约 2.1–2.3)→ O(n log n)。必须重复取最小值以排除噪声与 GC 抖动。
⑤ 取决于访问模式:哈希要一次随机访存 + 计算 hash;n=1000 时二分只需约 10 次顺序比较、局部性极好,实测常是二分赢或接近。结论必须由实测得出,不能只靠推断。

2. 线性结构:数组、链表、跳表与哈希

知识结构图 · 线性结构
线性结构:数组、链表、跳表与哈希4 大知识域 · 28 个知识点
数组与链表取舍动态数组按下标 O(1)链表中间插入 O(1)三指针反转链表Floyd 快慢指针判圈哑结点 dummy 合并指针追逐 cache miss
学习路径
  1. 读 2.1:对照动态数组与链表各操作的复杂度表,先记下取舍
  2. 跑内置代码,实现三指针反转链表与 Floyd 快慢指针判圈
  3. 完成练习自测:判断何时用数组、何时用链表,理由落到 cache miss
  4. 对接 M3:为 BM25 的候选集合选择内存布局,依据是访问模式
✔ 能对任意操作组合给出数组、链表、跳表的复杂度并说明理由
核心知识点详解
  • 取舍看访问模式而非复杂度:动态数组按下标 O(1)、链表中间插入 O(1)+查找、跳表 O(log n)。真实判据是访问模式:顺序遍历 + 偶尔随机访问 → 数组永远赢;频繁在中间增删且持有节点引用 → 链表才有意义。按下标访问 数组 O(1) 而链表 O(n)。
  • 三指针反转 + 哑结点:反转链表用 pre/cur/nxt 三指针;合并有序链表用哑结点 dummy 规避头结点边界判断。这两个是链表题的肌肉记忆、写错率最高。画图三步再写能极大幅提高一次通过率。
  • Floyd 快慢指针:判圈用 slow=slow.next; fast=fast.next.next,相遇即存在环;把指针回 head 同步前进,相遇点即环起点。还用于取中点、找倒数第 k 个。
  • 常见坑:指针改动顺序:链表题 bug 90% 来自指针改动顺序。改 cur.next 前必须先保存 nxt,否则原链表断裂。先画出状态图再写代码是标准做法。
哈希表实现链地址法 vs 开放寻址负载因子 α 阈值 0.75墓碑 tombstone2 的幂 + 位运算取模h ^ (h >>> 16)线性探测 (1+1/(1−α)²)/2Python dict 42 字节/项
学习路径
  1. 读 2.2:理解负载因子 α=0.75、链地址与开放寻址两套冲突处理
  2. 跑内置代码,写一版含墓碑的开放寻址哈希表并统计插入耗时
  3. 完成练习自测:做一次冲突率与扩容实验并记录数字
  4. 对接 M3:让倒排索引的 term 到 postings 查找用上均匀分布的哈希函数
✔ 能说出 α 如何决定查找期望代价,并解释为何用 2 的幂加位运算取模
核心知识点详解
  • 负载因子决定查找期望代价:线性探测的平均探测次数约 (1+1/(1−α)²)/2:α=0.5 约 2.5 次、α=0.75 约 8.5 次、α=0.9 约 50 次、α=0.95 约 200 次。所以开放寻址必须在 α≈0.75 前扩容,这是「性能 vs 内存」的折中阈值。
  • 容量取 2 的幂 + 位运算取模:i = hash(k) & (cap-1) 比 % 快得多,代价是哈希函数低位要足够随机,否则碰撞集中。JDK 里 h ^ (h >>> 16) 把高位混到低位正是为了缓解。
  • 墓碑 tombstone 的必要性:开放寻址删除时不能把槽置 EMPTY,否则探测链断裂、后续元素找不回。要置为 TOMB 保留「此处曾有元素」的信息;墓碑累积过多要触发原地重散列,否则性能持续退化。
  • 常见坑:把删掉的槽直接置空:这是开放寻址实现的高频 bug。反例:k1/k2/k3 同「家」,插 k1→槽0、k2→槽1、k3→槽2,删除 k2 若把槽1 置空,查询 k3 会在槽1 遇空即判定不存在——实际存在,因此失败。
跳表多层索引 + 随机提升p=0.5 期望 2 指针/节点搜索步数 log_{1/p} n范围查询 O(log n)Redis ZSet p=0.25无锁并发友好
学习路径
  1. 读 2.3:弄懂随机提升 p=0.5 时每节点期望 2 指针、搜索步数 log_{1/p} n
  2. 跑内置代码,实现插入并打印不同层级的节点分布
  3. 完成练习自测:横评跳表与平衡树的范围查询能力
  4. 对接 M3:评估用跳表做近似有序索引的可行性并给出复杂度
✔ 能推导搜索步数的期望,并解释 Redis 的 ZSet 为何选 p=0.25
核心知识点详解
  • 随机提升的期望开销:p=0.5 时每节点期望指针数 = 1/(1−p) = 2,期望搜索步数 ≈ log_{1/p} n(n=1e6 时约 20)。p 越小越省内存(p=0.25 → 平均 1.33 指针)但搜索步数越多,需权衡;Redis ZSet 用 p=0.25、maxlevel=32。
  • 跳表 vs 平衡树:两者都 O(log n),但跳表实现简单得多、天然适合无锁并发(插入只改局部指针)。平衡树靠旋转维持严格高度平衡、并发下需要锁。Redis 单线程 + 跳表是经典组合。
  • 范围查询是核心价值:哈希表无法做「[a,b] 之间所有 key」的查询;跳表 / B+ / 有序数组能 O(log n) 定位起点后顺序扫描。时序、排行榜、索引都靠它——RRF 融合时按分数取范围也需要有序容器。
  • 常见坑:不设层级上限:随机高度要设 MAX_LEVEL 上限,否则极端随机下可无限高。几何分布让第 k 层期望节点数减半、平均层数 1/(1−p),但单点高度可能很大。
环形缓冲区固定容量 O(1) push/pop零内存分配KV Cache 滑动窗口
学习路径
  1. 读 2.4:掌握固定容量下 O(1) 的 push/pop 与零内存分配
  2. 跑内置代码,实现环形缓冲并观察 wrap 时能否正确覆盖
  3. 完成练习自测:把环形缓冲套到 KV Cache 滑动窗口的场景
  4. 对接 M3:为流式 token 打分设计一个不重分配的滑动缓存
✔ 能讲清环形缓冲如何通过取模复用内存,并说出其拒绝扩容的代价
核心知识点详解
  • 取模回绕实现固定容量:固定长度数组 + head/tail 两个下标,push/pop 均 O(1)、零内存分配。tail = (tail + 1) % cap 复用内存,塞满可覆盖最老元素。相比 list 用 pop(0)(O(n) 移动)或反复 append 分配,它是推理热路径的标配。
  • KV Cache 滑动窗口的应用:vLLM 的块管理思想与环形缓冲相通:不断淘汰最老 token、复用已分配块,避免反复分配/释放。固定容量意味着不接受扩容,要预估好窗口大小。
  • 常见坑:分不清空与满:环形缓冲的 head == tail 可能是空也可能是满,需额外记一个 count(或留一格策略)。写满后 push 也要决定是拒绝还是覆盖最老。
学习路径

2.1 数组、动态数组与链表的真实取舍

操作静态数组动态数组单向链表双向链表跳表
按下标访问O(1)O(1)O(n)O(n)O(log n)
尾部插入—均摊 O(1)O(n)/O(1)*O(1)O(log n)
头部插入O(n)O(n)O(1)O(1)O(log n)
中间插入O(n)O(n)O(1)+查找O(1)+查找O(log n)
按值查找O(n)O(n)O(n)O(n)O(log n) 有序
内存局部性极好极好差差中
额外内存无预留容量每节点 1 指针每节点 2 指针每节点 ~log n 指针

带 * 的 O(1) 指「持有尾指针」。真实选择的核心判据不是复杂度,而是访问模式:如果是「顺序遍历 + 偶尔随机访问」,数组永远赢;如果是「频繁在中间增删且持有节点引用」,链表才有意义。

python# 反向链表题的两个核心套路(面试高频,必须形成肌肉记忆)

class Node:
    __slots__ = ('val', 'next')          # __slots__ 省内存,这是工程习惯
    def __init__(self, v, n=None): self.val, self.next = v, n

def reverse(head):
    """迭代反转:三个指针,pre / cur / nxt —— 写错率最高的题之一"""
    pre, cur = None, head
    while cur:
        nxt = cur.next
        cur.next = pre
        pre, cur = cur, nxt
    return pre

def has_cycle(head):
    """快慢指针(Floyd 判圈):同时还能求环起点与环长"""
    slow = fast = head
    while fast and fast.next:
        slow, fast = slow.next, fast.next.next
        if slow is fast:
            # 求环起点:一个指针回head,同步前进,相遇点即环起点
            p = head
            while p is not slow:
                p, slow = p.next, slow.next
            return p
    return None

def merge_two(a, b):
    """合并两条有序链表:用哑结点(dummy)规避边界判断,这是标准写法"""
    dummy = tail = Node(0)
    while a and b:
        if a.val <= b.val: tail.next, a = a, a.next
        else:              tail.next, b = b, b.next
        tail = tail.next
    tail.next = a or b
    return dummy.next

链表在理论上「插入 O(1)」,为什么工程上还常输给数组?下面的实验量化了指针追逐(pointer chasing)的代价——同样遍历 100 万个元素,数组的连续内存让 CPU 预取器高效工作,而链表的下标跳转让预取器彻底失效。

pythonimport time

N = 1_000_000
arr = list(range(N))                       # 连续数组
nxt = list(range(1, N)) + [-1]             # 用「下标数组」模拟链表,排除对象开销
head = 0

def walk_array(a):
    s = 0
    for x in a: s += x
    return s

def walk_linked(nxt, head):
    s, i = 0, head
    while i != -1:
        s += i; i = nxt[i]
    return s

def t(fn, *args):
    t0 = time.perf_counter(); fn(*args); return (time.perf_counter() - t0) * 1e3

a = min(t(walk_array, arr) for _ in range(5))
b = min(t(walk_linked, nxt, head) for _ in range(5))
print(f'数组顺序遍历 : {a:7.2f} ms')
print(f'链表下标跳转 : {b:7.2f} ms  ({b/a:.1f}x slower)')

# 预期:数组约 15 ms,链表约 55 ms,约 3–5 倍差距(CPython)
# 注意这是「最友好的链表」(下标数组、无对象);真对象链表还要更慢。
# C/C++ 上差距可达 10–50 倍,因为硬件预取对随机跳转完全无效。
# 结论:现代索引偏爱「数组 + 索引」——如 vLLM 的 PagedAttention 块表、
#       图的 CSR 表示——而不是真链表。
✔
链表题的三件套:① 哑结点(dummy head):消除「头结点特殊处理」,几乎所有链表题都该先用它;② 双指针(快慢 / 前后):判圈、找中点、找倒数第 k 个、找交点全靠它;③ 画图再写:链表题的 bug 90% 来自指针改动顺序,先在纸上画出三步的指针状态再落代码,一次写对的概率会大幅提高。

2.2 哈希表:冲突处理、负载因子与扩容

哈希表是「用空间换时间」的典范,也是面试里最容易被深挖的结构——因为它的实现细节决定了真实性能。必须能讲清两种冲突处理方案的取舍。

维度链地址法(chaining)开放寻址法(open addressing)
实现每个桶挂一个链表/红黑树冲突时按探测序列找下一个空槽
负载因子可以 > 1必须 < 1(通常 < 0.75)
内存需要节点开销,可能碎片化紧凑数组,局部性好
删除简单直接摘链需墓碑标记(tombstone),否则查找链断裂
缓存友好性差(指针追逐)好(连续探测)
代表实现Java HashMap、Python dict(组合策略)Python dict(实际是开放寻址变体)、Rust HashMap(SwissTable)
适合键值对大小差异大、删除频繁小键值、读多写少、追求速度
python# 手写哈希表(开放寻址 + 线性探测 + 墓碑 + 扩容)——面试常考的实现题
class HashMap:
    EMPTY, TOMB, USED = 0, 1, 2

    def __init__(self, cap=8):
        self._keys = [None] * cap
        self._vals = [None] * cap
        self._state = [self.EMPTY] * cap
        self._size = self._used = 0          # used 含墓碑,用于判断是否该原地清理

    def _probe(self, k):
        """返回 key 应落的位置(或其「家」位置)"""
        mask = len(self._keys) - 1           # 容量取 2 的幂 → 位运算取模
        i = hash(k) & mask
        first_tomb = -1
        for _ in range(len(self._keys)):     # 探测步数上限 = 容量,防止死循环
            st = self._state[i]
            if st == self.EMPTY:
                return i if first_tomb < 0 else first_tomb
            if st == self.TOMB:
                if first_tomb < 0: first_tomb = i
            elif self._keys[i] == k:
                return i
            i = (i + 1) & mask               # 线性探测
        return first_tomb if first_tomb >= 0 else -1

    def put(self, k, v):
        if (self._used + 1) * 4 >= len(self._keys) * 3:      # 负载因子 0.75
            self._resize(len(self._keys) * 2)
        i = self._probe(k)
        if self._state[i] != self.USED:
            self._size += 1
            if self._state[i] == self.EMPTY: self._used += 1
        self._keys[i], self._vals[i], self._state[i] = k, v, self.USED

    def get(self, k, default=None):
        i = self._probe(k)
        return self._vals[i] if i >= 0 and self._state[i] == self.USED else default

    def remove(self, k):
        i = self._probe(k)
        if i < 0 or self._state[i] != self.USED: return False
        self._state[i] = self.TOMB           # 不能置 EMPTY,否则探测链断裂
        self._size -= 1
        return True

    def _resize(self, cap):
        old = (self._keys, self._vals, self._state)
        self._keys, self._vals, self._state = [None]*cap, [None]*cap, [self.EMPTY]*cap
        self._size = self._used = 0
        for k, v, st in zip(*old):
            if st == self.USED: self.put(k, v)
负载因子 α线性探测平均探测次数(不成功)性能观感
0.50约 2.5很快
0.75约 8.5可接受(多数实现的扩容阈值)
0.90约 50明显变慢
0.95约 200接近退化,必须扩容
pythonimport sys

# 实测 Python dict 的扩容与内存:dict 是高度优化的开放寻址实现
d, prev = {}, 0
for i in range(1_000_000):
    d[i] = i
    sz = sys.getsizeof(d)
    if sz != prev:
        print(f'n={i+1:>8d}  表大小={sz/1024/1024:5.2f} MB')
        prev = sz

# 预期(CPython 3.12,仅示意数量级,版本间会有差异):
#   n=       6  表大小≈0.00 MB
#   n=  838861  表大小≈37.7 MB   ← 每次扩容约 2×/4×,容量取 2 的幂
#   n= 1000000  表大小≈41.9 MB
# 关键数字:1e6 个 int 键的 dict ≈ 42 MB(约 42 字节/项);
#   若键换成 32 字符字符串,内存会涨到 100 MB 以上(每个 str 自身约 50+ 字节)。
# 工程含义:热路径上别用长字符串反复做键,改成整数 id 或缓存 hash。

2.3 跳表:Redis 的有序集合为什么选它

跳表用「多层索引 + 随机化」在链表上实现 O(log n) 的查找/插入/删除,而且实现比平衡树简单得多、并发友好。Redis 的 ZSet(有序集合)用的就是跳表,很多内存索引也用。

pythonimport random

class SkipList:
    """跳表:多层索引 + 随机提升。期望复杂度 O(log n),实现远简单于红黑树"""
    MAX_LEVEL, P = 16, 0.5

    class Node:
        __slots__ = ('key', 'val', 'nxt')
        def __init__(self, k, v, level):
            self.key, self.val = k, v
            self.nxt = [None] * level

    def __init__(self):
        self.head = self.Node(None, None, self.MAX_LEVEL)
        self.level = 1

    def _rand_level(self):
        lv = 1
        while random.random() < self.P and lv < self.MAX_LEVEL:
            lv += 1
        return lv                     # 几何分布 → 期望每层节点数减半

    def insert(self, key, val):
        update = [None] * self.MAX_LEVEL
        cur = self.head
        for i in range(self.level - 1, -1, -1):        # 从高层往下找插入点
            while cur.nxt[i] and cur.nxt[i].key < key:
                cur = cur.nxt[i]
            update[i] = cur
        cur = cur.nxt[0]
        if cur and cur.key == key:                     # 已存在 → 更新
            cur.val = val; return
        lv = self._rand_level()
        if lv > self.level:
            for i in range(self.level, lv): update[i] = self.head
            self.level = lv
        node = self.Node(key, val, lv)
        for i in range(lv):                            # 逐层插入并接好链
            node.nxt[i], update[i].nxt[i] = update[i].nxt[i], node

    def search(self, key):
        cur = self.head
        for i in range(self.level - 1, -1, -1):
            while cur.nxt[i] and cur.nxt[i].key < key:
                cur = cur.nxt[i]
        cur = cur.nxt[0]
        return cur.val if cur and cur.key == key else None

    def range(self, lo, hi):
        """有序范围查询 —— 这是跳表相比哈希表的关键优势"""
        out, cur = [], self.head
        for i in range(self.level - 1, -1, -1):
            while cur.nxt[i] and cur.nxt[i].key < lo: cur = cur.nxt[i]
        cur = cur.nxt[0]
        while cur and cur.key <= hi:
            out.append((cur.key, cur.val)); cur = cur.nxt[0]
        return out
pythonimport random, math

def sim_levels(n=1_000_000, p=0.5, maxl=32):
    """模拟跳表层数分布:验证期望高度与指针开销"""
    random.seed(1)
    counts, total = [0] * (maxl + 1), 0
    for _ in range(n):
        lv = 1
        while random.random() < p and lv < maxl: lv += 1
        counts[lv] += 1; total += lv
    return counts, total / n

for p in (0.5, 0.25):
    counts, avg = sim_levels(p=p)
    dist = [(i, counts[i]) for i in range(1, 5)]
    print(f'p={p}: 层数分布={dist}  平均层数={avg:.2f}  期望搜索步数≈{math.log(1_000_000, 1/p):.1f}')

# 预期输出:
#   p=0.5 : 层数分布=[(1,500000),(2,250000),(3,125000),(4,62500)]  平均=2.00  步数≈19.9
#   p=0.25: 层数分布=[(1,750000),(2,187500),(3,46875),(4,11719)]   平均=1.33  步数≈10.0
# 关键数字:平均指针数 = 1/(1−p) → p=0.5 时每节点约 2 个指针,
#   比红黑树(每节点 3 指针 + 颜色位)更省内存;
#   p 越小越省内存,但搜索步数越多(log_{1/p} n 变大),需权衡。
# Redis ZSet 用 p=0.25、maxlevel=32。

2.4 动手练习与自测

✔
判据:每题都要求给出复杂度 + 一个可测的数字,不要只写结论。
  1. 实现一个环形缓冲区(ring buffer),容量固定 N,支持 O(1) 的 push/pop 且不产生内存分配。说明它在推理服务里为什么比 list 更适合管理 KV Cache 的滑动窗口。
  2. 给你的手写 HashMap 加统计:插入 10 万个随机整数后报告平均探测长度与最大探测长度。分别在 α=0.5 与 α=0.9 下测,给出两组数字。
  3. 解释为什么开放寻址哈希表的删除必须用墓碑而不是直接置空;构造一个「直接置空会导致查找失败」的最小反例(3 个键、容量 4)。
  4. 跳表 p=0.5 时节点平均层数、期望搜索步数各是多少?把 p 改成 0.25 后两者如何变化(用公式 1/(1−p) 与 log_{1/p}(n) 说明)。
  5. 在 1000 万条 key 上,分别用(a)跳表、(b)排序数组 + 二分、(c)哈希表做点查与范围查,预测三者相对性能并说明理由。
题号通过标准目标耗时
1环形缓冲 push/pop 均 O(1)、零分配;能说出与 KV Cache 滑动窗口的关系10 分钟
2α=0.5≈2.5 次、α=0.9≈50 次,能画出探测次数随 α 上升的曲线8 分钟
3能用 k1/k2/k3 复现「删除截断探测链」的失败,并说明墓碑作用6 分钟
4给出平均层数 1/(1−p) 与搜索步数 log_{1/p} n,p=0.5 / 0.25 均算对6 分钟
5点查选哈希、范围查选跳表,结论由实测支撑8 分钟
★
参考答案与判据:① 固定长度数组 + head/tail 两个下标、取模回绕;push/pop 均 O(1)、零分配。KV Cache 滑动窗口需不断淘汰最老 token,环形缓冲天然契合(vLLM 的块管理思想类似)。
② 线性探测平均探测次数 (1+1/(1−α)²)/2:α=0.5 → 约 2.5;α=0.9 → 约 50。实测接近此值,最大探测长度会出现长尾(远大于平均)。
③ 三个键 k1,k2,k3 哈希到同一「家」位置。插入 k1 落槽 0、k2 探测到槽 1、k3 探测到槽 2。删除 k2 若把槽 1 置空,查找 k3 会在槽 1 遇空即判定不存在——实际存在,故失败。墓碑保留「此处曾有元素」的信息,正是为此。
④ p=0.5:平均层数 = 1/(1−p) = 2,期望搜索步数 ≈ log_2 n ≈ 20(n=1e6);p=0.25:平均层数 ≈ 1.33(更省内存),但搜索步数 ≈ log_4 n ≈ 10。注:层数更少但每层跨度更大,步数反而可能减少——代价是最高层稀疏、随机性更强。答题时给出公式与量级即可。
⑤ 点查:哈希最快(O(1),但随机访存);跳表 O(log n) 次指针跳转;排序数组二分 O(log n)、局部性好。范围查:哈希完全不可用(无法定位区间起点);跳表与排序数组都能 O(log n) 定位起点后顺序扫描。结论:既要点查又要范围查 → 跳表 / 有序结构;只要点查 → 哈希。

3. 树、堆与 Trie

知识结构图 · 树、堆与 Trie
树、堆与 Trie5 大知识域 · 30 个知识点
二叉树与 BST中序/层序迭代遍历递归返回值语义双返回值求直径is_valid_bst 上下界区间第 k 小中序 O(h+k)BST 退化成链表红黑树 vs AVL
学习路径
  1. 读 3.1:掌握中序迭代遍历与 is_valid_bst 的上下界区间判断
  2. 跑内置代码,手写中序与层序迭代遍历并核对输出序列
  3. 完成练习自测:求第 k 小元素,分析 O(h+k) 的来源
  4. 对接 M3:为索引的局部有序性设计一组 BST 风格的查找操作
✔ 能用上下界不变量写对二叉搜索树校验,并说清 BST 退化风险
核心知识点详解
  • is_valid_bst 必须传上下界区间:只和直接父节点比会漏判跨层违规。正确做法是递归传 (lo, hi):ok(n.l, lo, n.v) and ok(n.r, n.v, hi)。反例:根 10、左子 5、左子的右子 15——只查局部会认为合法,但 15 > 根 10 实际违规。
  • 第 k 小用中序 O(h+k):BST 中序遍历得到有序序列,迭代版走到第 k 个即可,复杂度 O(h+k)(平衡树 h=O(log n)),不必先全量中序再做 O(n) 取值。
  • BST 会退化成链表:按有序序列插入(1,2,3,…)会让树高 h=O(n)、所有操作退化线性。生产用 AVL(严格平衡、旋转多)或红黑树(近似平衡、旋转少,插入删除频繁更优),或用跳表。
  • 常见坑:没想清递归返回语义:递归树题最难的是「函数返回什么」。直径题返回深度但跨层要累加 l+r,需用双返回值或 nonlocal 记录最优。「写不出是没想清楚递归返回什么」是高频卡点。
B 树与 B+ 树m 阶 100–10001e8 记录树高 3–44KB 页一次 I/O/层数据只在叶子叶子链表顺序扫描BST 树高 log2 n
学习路径
  1. 读 3.2:算清 1e8 记录、m 阶 100–1000 时树高 3–4 的推导
  2. 跑内置代码,按 4KB 页估算一次查找的 I/O 次数
  3. 完成练习自测:解释为何数据只在叶子、叶子用链表顺序扫描
  4. 对接 M3:讨论倒排 postings 用 B+ 树还是顺序文件的取舍
✔ 能核算给定规模下 B+ 树的树高与每次查找的 I/O 数
核心知识点详解
  • 把树高压到极低的本质:B+ 树节点尽量装满一个磁盘页(4KB),一行能读几百个键 → 分支因子极大。查 1e8 记录树高只需 3–4 层,即 3–4 次 I/O;对比二叉 BST 树高约 27 → 约 27 次随机 I/O。这是数据库索引用它的根本原因。
  • B+ 树相对 B 树的两个改进:① 数据只在叶子,内部节点只存键 → 分支因子更大;② 叶子用链表串起 → 范围查询 = 定位起点 + 顺序扫描,适合「取最近 N 条」。
  • 与向量索引的类比:HNSW 的上层稀疏、下层稠密与 B 树思想相通:用额外层级把查找步数压到对数级。理解 B 树有助于理解 HNSW;而 LSM-Tree 是另一条以「写放大」换写吞吐的路线,适合写多读少。
  • 常见坑:把内存结构套到磁盘:BBST 适合内存(随机访问便宜),磁盘/大规模场景必须用 B+ / LSM 以最小化 I/O。选型先问数据是否常驻内存。
堆与优先队列swim 上浮 / sink 下沉建堆 O(n) 非 O(n log n)Top-K 最小堆 O(n log k)多路归并 k 路双堆流式中位数调度防饥饿老化
学习路径
  1. 读 3.3:理解建堆 O(n) 的级数推导与 swim/sink 两条路径
  2. 跑内置代码,用最小堆做 Top-K 并观察 O(n log k) 的取舍
  3. 完成练习自测:用双堆实现流式中位数
  4. 对接 M3:在调度器里用二叉堆按优先级取任务并通过老化防饥饿
✔ 能推导建堆为何是 O(n),并说出 Top-K 选堆而非排序的理由
核心知识点详解
  • 建堆是 O(n) 而非 O(n log n):从 n/2 开始自底向上 sink,第 i 层节点数约 n/2^i、下沉代价 O(i),总代价 Σ(n/2^i)·i = O(n)(等比级数)。实测 heapify 通常比 n 次 heappush 快 2–3 倍,因为平均下沉深度很浅、常数小。
  • Top-K 用最小堆:维护大小为 k 的最小堆,O(n log k)、空间 O(k)。新元素比堆顶大就 heapreplace(比 pop+push 少一次堆调整)。适合内存放不下全量数据的流式场景;要动态更新前 K 可用平衡树/跳表。
  • 双堆实现流式中位数:左半大顶堆(存负数)+ 右半小顶堆,插入 O(log n)、取中位数 O(1)。多路归并(合并 k 个有序流)也是堆的标准用途——RAG 多路召回的 RRF 融合本质是 k 路归并 + 堆。
  • 调度防饥饿要靠老化(常见坑):纯优先级队列会让老的低优先级请求永远排不上。常见解法是老化:等待时间越长优先级越高。请求排队(scheduler.py)必须同时考虑优先级与到达时间。
Trie 前缀树查询 O(词长)starts_with 前缀匹配longest_prefix 分词敏感词扫描 O(n·L)LLM 前缀缓存DAT 双数组压缩
学习路径
  1. 读 3.4:掌握查询 O(词长) 与 longest_prefix 分词
  2. 跑内置代码,实现 starts_with 并把敏感词扫描跑通
  3. 完成练习自测:分析 LLM 前缀缓存的命中场景
  4. 对接 M3:为 BM25 的词项取词表构建前缀索引
✔ 能讲清 Trie 为何把前缀匹配降为 O(词长),并理解其空间代价
核心知识点详解
  • 前缀匹配 O(词长) 与哈希的差异:Trie 查询复杂度只与词长有关、与词典大小无关,starts_with / longest_prefix 是哈希表做不到的能力。判据是访问模式:点查用哈希、前缀/范围/最长匹配用 Trie。longest_prefix 返回最长匹配终点实现分词与敏感词过滤。
  • 时空代价与压缩:朴素 Trie 每节点一个 dict(约 64B+),词集稀疏时反而更费内存。生产用双数组 Trie(DAT,查询变纯数组下标)或 Radix/Patricia(压缩单链)。中文 10 万词可压到几十 MB。前缀共享让共享率可观,但「共享率辨识度」通常小于字符总数。
  • AI 三处应用:① BPE 分词合并规则检索;② LLM 前缀缓存——vLLM 的 prefix caching 用前缀共享复用 KV Cache,索引本质是前缀树;③ 工具路由——按工具名/意图做前缀或模式匹配。
  • 常见坑:用哈希做前缀需求:需要「前缀匹配 / 最长匹配 / 范围查询」却选哈希表,会写不出或退化成 O(n·L)。判断依据永远是访问模式,不是理论复杂度。
综合练习B+ 树 I/O 次数核算heapify 级数推导中位数内存估算
学习路径
  1. 读 3.5:把 B+ 树 I/O 次数、heapify 级数、中位数内存估算各算一遍
  2. 跑内置代码,在给定内存下估算亿级数据的中位数所需空间
  3. 完成综合练习:把四种树结构按适用场景排序并给出判据
  4. 对接 M3:写一段用堆做 Top-K 召回的小代码并入检索内核
✔ 能独立完成三项估算并给出与代码一致的数字
核心知识点详解
  • I/O 核算的确定性:给定 4KB 页、阶约 292、每叶 40 条:1e6 行叶子 25000 → 树高 3,1e8 行树高 4。这就把 BST 约 27 次 I/O 压到 B+ 树的 4 次,且前两层常驻内存时只剩 2 次磁盘读——判据是和脚本结果一致。
  • 把估算落到数字:流式中位数(双堆)1e7 个 int 约 80 MB(两个堆 + 对象开销),可用 array 降。建堆 O(n) 用 Σ(n/2^i)·i 推导。每个估算都要有对应代码实验支撑才可靠。
  • 常见坑:只背结论不算账:内存占用、I/O 次数、复杂度级数都要亲手算一遍并写脚本验证。判据是「给出与代码一致的数字」,不是复述结论。
学习路径

3.1 二叉树、BST 与平衡树

树的题在面试里出现频率极高(遍历、深度、路径、LCA、序列化),而工程上更关键的是BST 与平衡树的「有序性」带来的能力:范围查询、前驱/后继、Top-K。

python# 三种遍历的统一写法(迭代版,面试更爱考)
def inorder(root):          # 中序 → BST 得到有序序列
    st, out, cur = [], [], root
    while st or cur:
        while cur: st.append(cur); cur = cur.left
        cur = st.pop(); out.append(cur.val); cur = cur.right
    return out

def level_order(root):      # 层序 → BFS,按层处理要用 len(stack) 记住本层大小
    from collections import deque
    if not root: return []
    q, out = deque([root]), []
    while q:
        level = []
        for _ in range(len(q)):          # ← 关键:先固定本层节点数
            n = q.popleft()
            level.append(n.val)
            if n.left:  q.append(n.left)
            if n.right: q.append(n.right)
        out.append(level)
    return out

# 递归的返回设计:很多树题的关键是「返回值代表什么」
def max_depth(root):                # 返回子树深度
    return 0 if not root else 1 + max(max_depth(root.left), max_depth(root.right))

def diameter(root):                 # 返回 (深度, 当前最大直径) —— 双返回值套路
    best = 0
    def dfs(n):
        nonlocal best
        if not n: return 0
        l, r = dfs(n.left), dfs(n.right)
        best = max(best, l + r)     # 经过 n 的最长路径
        return 1 + max(l, r)
    dfs(root); return best

def lca_bst(root, p, q):            # BST 的 LCA:利用有序性,O(h),无需额外空间
    while root:
        if p.val < root.val and q.val < root.val:   root = root.left
        elif p.val > root.val and q.val > root.val: root = root.right
        else: return root

三个高频 BST 面试题的标准解法——都能做到 O(h) 空间(h 为树高)而非 O(n):验证 BST(传上下界,而不是只和父节点比)、第 k 小(中序遍历的第 k 个)、迭代插入。

pythonclass T:
    __slots__ = ('v', 'l', 'r')
    def __init__(self, v): self.v, self.l, self.r = v, None, None

def insert(root, v):
    if root is None: return T(v)
    cur = root
    while True:                              # 迭代插入,O(h)
        if v < cur.v:
            if cur.l is None: cur.l = T(v); return root
            cur = cur.l
        else:
            if cur.r is None: cur.r = T(v); return root
            cur = cur.r

def is_valid_bst(root):
    """关键:递归传「允许区间 (lo, hi)」,而不是只和直接父节点比。
       常见错误:只检查 node.l.v < node.v < node.r.v → 会漏判跨层违规"""
    def ok(n, lo, hi):
        if n is None: return True
        if not (lo < n.v < hi): return False
        return ok(n.l, lo, n.v) and ok(n.r, n.v, hi)
    return ok(root, float('-inf'), float('inf'))

def kth_smallest(root, k):
    st, cur = [], root                       # 中序迭代到第 k 个
    while st or cur:
        while cur: st.append(cur); cur = cur.l
        cur = st.pop(); k -= 1
        if k == 0: return cur.v
        cur = cur.r
    return None

root = None
for v in [8, 3, 10, 1, 6, 14, 4, 7, 13]:
    root = insert(root, v)
print('valid =', is_valid_bst(root), ' 第3小 =', kth_smallest(root, 3))
# 预期:valid = True  第3小 = 4
# 复杂度:插入 O(h)、验证 O(n)、第 k 小 O(h + k);平衡树 h = O(log n)
# 若用有序序列插入(1,2,3,...)→ 退化成链表,h = O(n),务必用随机序或平衡树。

3.2 B 树与 B+ 树:为什么数据库索引用它

这是「数据结构与存储层次」的交叉点,也是理解数据库与向量库索引的基础。B+ 树的本质是:把树的高度压到极低,从而把磁盘/页的 I/O 次数压到极少。

维度二叉搜索树B 树B+ 树
每节点子节点数≤ 2m 阶(通常 100–1000)m 阶,且数据只在叶子
树高(1 亿条)约 27约 3–4约 3–4
磁盘 I/O 次数每次下降一层 = 一次随机 I/O每层一次(一个页)每层一次 + 叶子链表顺序读
范围查询中序遍历,随机跳可行但繁琐叶子链表顺序扫描,极高效
典型用途内存索引文件系统数据库索引、KV 存储

把 B+ 树的高度「算出来」,你就能对「为什么数据库索引只要 3–4 次 I/O」形成确定的直觉。

pythonimport math

def bplus_height(rows, page=4096, key=8, ptr=6, rec=100):
    """返回 (阶 m, 每叶记录数, 叶子数, 树高)
       内部节点每键 8B + 每指针 6B;每记录 100B"""
    max_ptr = page // (key + ptr)            # 单节点最多子节点数 = 阶
    per_leaf = page // rec                   # 单叶页可放记录数
    leaves = math.ceil(rows / per_leaf)
    h, nodes = 1, leaves
    while nodes > 1:
        nodes = math.ceil(nodes / max_ptr); h += 1
    return max_ptr, per_leaf, leaves, h

for rows in (10**4, 10**6, 10**8, 10**10):
    m, pl, lv, h = bplus_height(rows)
    print(f'rows={rows:>14,}  阶={m}  每叶={pl}  叶子={lv:>9,}  树高={h}')

# 预期(page=4KB):
#   rows=        10,000  阶=292  每叶=40  叶子=      250  树高=2
#   rows=     1,000,000  阶=292  每叶=40  叶子=   25,000  树高=3
#   rows=   100,000,000  阶=292  每叶=40  叶子=2,500,000  树高=4
#   rows=10,000,000,000  阶=292  每叶=40  叶子=2.5e8      树高=5
# 对比:二叉 BST 查 1e8 行树高约 27 → 约 27 次随机 I/O;B+ 树只需 4 次。
# 若前 2 层常驻内存,实际只剩 2 次磁盘读。

3.3 堆与优先队列:调度与 Top-K 的基础

python# 手写最小堆(数组表示,父子下标关系是核心)
class MinHeap:
    def __init__(self): self.a = []
    def _swim(self, i):                      # 上浮:插入后恢复堆序
        a = self.a
        while i > 0:
            p = (i - 1) // 2
            if a[p] <= a[i]: break
            a[p], a[i] = a[i], a[p]; i = p
    def _sink(self, i):                      # 下沉:取出堆顶后恢复堆序
        a, n = self.a, len(self.a)
        while True:
            l, r, m = 2*i+1, 2*i+2, i
            if l < n and a[l] < a[m]: m = l
            if r < n and a[r] < a[m]: m = r
            if m == i: break
            a[m], a[i] = a[i], a[m]; i = m
    def push(self, x): self.a.append(x); self._swim(len(self.a)-1)
    def pop(self):
        a = self.a
        if not a: raise IndexError('empty')
        top = a[0]; last = a.pop()
        if a: a[0] = last; self._sink(0)
        return top

def top_k(stream, k):
    """流式 Top-K:维护大小为 k 的最小堆,空间 O(k)、时间 O(n log k)
       这是「内存放不下全部数据」时的标准解法 —— 检索、排行榜都用它"""
    import heapq
    h = []
    for x in stream:
        if len(h) < k: heapq.heappush(h, x)
        elif x > h[0]: heapq.heapreplace(h, x)   # 比堆顶大 → 替换
    return sorted(h, reverse=True)

# 堆的另一个关键用途:多路归并(合并 k 个有序流)
def k_way_merge(streams):
    import heapq
    h = [(s[0], i, 0) for i, s in enumerate(streams) if s]
    heapq.heapify(h)
    while h:
        v, si, idx = heapq.heappop(h)
        yield v
        if idx + 1 < len(streams[si]):
            heapq.heappush(h, (streams[si][idx+1], si, idx+1))

前面说「建堆是 O(n)」,到底比逐个插入快多少?再用「双堆求中位数」看堆在流式统计里的用法。

pythonimport heapq, random, time

N = 2_000_000
data = [random.random() for _ in range(N)]

t0 = time.perf_counter(); h1 = data[:]; heapq.heapify(h1); t_h = time.perf_counter() - t0
t0 = time.perf_counter()
h2 = []
for x in data: heapq.heappush(h2, x)
t_i = time.perf_counter() - t0
print(f'heapify   : {t_h*1e3:7.1f} ms')
print(f'n×heappush: {t_i*1e3:7.1f} ms  ({t_i/t_h:.1f}x)')

# 预期:heapify 比逐个插入快约 2–3 倍
# (不是 log n ≈ 21 倍——因为自底向上 sink 的平均深度只有 O(1),常数很小)

class Median:
    """双堆维护流式中位数:左半大顶堆(存负数)+ 右半小顶堆"""
    def __init__(self): self.lo, self.hi = [], []
    def add(self, x):
        heapq.heappush(self.lo, -x)
        heapq.heappush(self.hi, -heapq.heappop(self.lo))
        if len(self.hi) > len(self.lo):
            heapq.heappush(self.lo, -heapq.heappop(self.hi))
    def median(self):
        return -self.lo[0] if len(self.lo) > len(self.hi) else (self.hi[0] - self.lo[0]) / 2

m = Median()
for x in [5, 1, 9, 3, 7]:
    m.add(x)
print('中位数 =', m.median())    # 预期 5,插入 O(log n)

3.4 Trie(前缀树):从分词到前缀缓存

python# Trie:共享前缀的字符树。查询复杂度 O(词长),与词典大小无关
class Trie:
    __slots__ = ('children', 'is_end', 'count')

    def __init__(self):
        self.children, self.is_end, self.count = {}, False, 0

    def insert(self, word):
        node = self
        for ch in word:
            node = node.children.setdefault(ch, Trie())
            node.count += 1                 # 记录经过次数 → 可做前缀热度统计
        node.is_end = True

    def search(self, word):
        node = self
        for ch in word:
            if ch not in node.children: return False
            node = node.children[ch]
        return node.is_end

    def starts_with(self, prefix):
        """前缀匹配 —— 这是 Trie 相对哈希表的核心优势(自动补全、路由)"""
        node = self
        for ch in prefix:
            if ch not in node.children: return False
            node = node.children[ch]
        return True

    def longest_prefix(self, s, i=0):
        """从下标 i 起的最长匹配前缀 —— 分词与敏感词过滤的核心原语"""
        node, best, j = self, -1, i
        while j < len(s) and s[j] in node.children:
            node = node.children[s[j]]
            if node.is_end: best = j
            j += 1
        return best                          # -1 表示无匹配

    def match_sensitive(self, text):
        """敏感词扫描:单次 O(n·L),优于「对每个词各扫一遍」的 O(n·k·L)"""
        hits, i = [], 0
        while i < len(text):
            e = self.longest_prefix(text, i)
            if e >= 0: hits.append(text[i:e+1]); i = e + 1
            else: i += 1
        return hits

Trie 的空间优势来自前缀共享:节点数远小于「所有词的字符总数」。下面用一段代码把「共享率」量出来,这也是中文词典用双数组 Trie 能压到几十 MB 的原因。

pythonwords = ['transformer','transfer','translate','translation','transplant','transit',
         'attention','attend','attentive','attest','token','tokenize','tokenizer']

class T:
    __slots__ = ('ch', 'end')
    def __init__(self): self.ch, self.end = {}, False

root, total = T(), 0
for w in words:
    total += len(w); n = root
    for c in w:
        n = n.ch.setdefault(c, T())
    n.end = True

def count(n):
    return 1 + sum(count(c) for c in n.ch.values())

nodes = count(root)
print(f'词数={len(words)}  字符总数={total}  Trie 节点数={nodes}  共享率={1 - nodes/total:.0%}')

# 预期:字符总数 137,节点数约 60 → 压缩掉约 56% 的节点
# 「token」「tokenize」「tokenizer」共享前缀 → 3 个词只多 5 个节点
# 生产级中文词典(10 万词)用双数组 Trie 压到几十 MB 量级;
# 单节点一个 dict 的开销约 64 B+,所以朴素 Trie 反而更费内存——
# 这就是压缩 Trie / DAT 存在的理由。

3.5 动手练习与自测

✔
判据:每题都要求你给出「返回值语义 / 复杂度 / 一个边界用例」。
  1. 写一个 is_valid_bst:先用「只比较直接父节点」的错误版本,构造一个能骗过它的反例,再用「传上下界」的正确版本修好。
  2. 给定 n,用公式算出二叉 BST 与 B+ 树(4KB 页、阶约 292)查第 n 条记录各需几次磁盘 I/O;n 取 1e6 与 1e8。
  3. 说明为什么「建堆」是 O(n) 而不是 O(n log n)(给出等比级数推导),并用实测验证 heapify 与 n 次 heappush 的耗时比。
  4. 用双堆设计一个「数据流中位数」结构,说明插入与查询的复杂度;若数据流长度是 1e7,预估内存占用。
  5. 解释 Trie 在中文分词与 LLM 前缀缓存(prefix caching)中分别扮演什么角色;为什么朴素 Trie 反而可能比 dict 更费内存?
题号通过标准目标耗时
1能给出反例(根 10 / 左子 5 / 左子的右子 15)说明只查局部为何出错6 分钟
2BST 树高 ≈ log2 n 与 B+ 树高 3–4 都算出,并解释 I/O 次数差异8 分钟
3能推导 heapify 的 Σ(n/2^i)·i = O(n)8 分钟
4双堆中位数:插入 O(log n)、取中位数 O(1),能估出内存量级8 分钟
5能指出朴素 Trie 的 dict 开销并给出压缩方案(DAT / 压缩 Trie)6 分钟
★
参考答案与判据:① 错误版只检查 node.l.v < node.v < node.r.v。反例:根 10,左子 5,左子的右子 15——5 < 10 < ? 只看局部时 15 会被认为合法,但 15 > 根 10,违反 BST(15 在左子树里却比根大)。正确版用区间 (lo, hi) 递归收缩即可拦住。
② BST 树高约 log2 n:1e6 → 约 20 次 I/O;1e8 → 约 27 次。B+ 树树高分别约 3 与 4(前面代码算出)。且 B+ 树每层一次页读,BST 每次下降可能一次随机 I/O。
③ 从 n/2 开始自底向上 sink,第 i 层节点数约 n/2^i、下沉代价 O(i),总代价 Σ (n/2^i)·i = O(n)(等比级数)。实测 heapify 通常比逐个插入快 2–3 倍。
④ 左半大顶堆 + 右半小顶堆,两个堆大小差不超过 1;插入 O(log n)、取中位数 O(1)。1e7 个 int 约 80 MB(两个堆各约 4e7 字节 + 对象开销),可用 array/固定数组降开销。
⑤ 中文分词里 Trie 做「最大正向匹配 / 词典查找」;prefix caching 里多请求共享同一前缀时,用前缀树记录已缓存的 KV 块,命中即复用。朴素 Trie 每节点一个 dict(约 64 B+),词典稀疏时节点数远多于实际字符复用收益,所以要用压缩 Trie / DAT。

4. 图算法与 DAG 调度

知识结构图 · 图算法与 DAG 调度
图算法与 DAG 调度4 大知识域 · 28 个知识点
图表示与遍历邻接矩阵 O(V²)邻接表 O(V+E)CSR indptr + indicesBFS 双向搜索迭代 DFS 显式栈二分图 BFS 染色
学习路径
  1. 读 4.1:对比邻接矩阵、邻接表与 CSR 的空间和遍历复杂度
  2. 跑内置代码,用 CSR 存图并做一次 BFS 双向搜索
  3. 完成练习自测:给一个场景选 BFS 或 DFS 并说明判断口诀
  4. 对接 M3:用有向图建模检索词项与文档的引用关系
✔ 能按稀疏或稠密与操作类型选出图的表示法并说明复杂度
核心知识点详解
  • 表示法按稀疏度选:邻接矩阵 O(V²) 空间、判边 O(1)、适合稠密小图;邻接表 O(V+E)、适合绝大多数稀疏场景;边列表适合 Kruskal;CSR(indptr+indices 两个连续数组)是 GPU 与稀疏矩阵的标准,能合并访存、无指针追逐。
  • BFS vs DFS 口诀:求最短步数/最少操作用 BFS(按层扩展、第一次到达即最短,前提边权相同);判存在/所有路径/环/拓扑序用 DFS。双向 BFS 可把搜索空间从 b^d 压到约 2·b^(d/2),社交网络与规划中极有效。
  • 二分图 BFS 染色:is_bipartite 用 BFS 交替染色,检查相邻节点颜色不同,否则非二分图——是「冲突分组 / 两色着色」类问题的原型。BFS 染色复杂度 O(V+E)。
  • 常见坑:递归深度爆栈(迭代 DFS):Python 递归深度上限约 1000,深图会爆栈,工程用显式栈。二分图判定也要处理非连通图(多个起点),别只从一个点出发。
拓扑排序与调度Kahn 入度分层DFS 三色环检测关键路径 makespanAmdahl 定律失败传播与幂等按层并行执行
学习路径
  1. 读 4.2:掌握 Kahn 入度分层与 DFS 三色环检测
  2. 跑内置代码,实现带环检测的拓扑排序并输出分层
  3. 完成练习自测:用关键路径估算 makespan,并结合 Amdahl 定律
  4. 对接 M3:用拓扑序调度检索内核的多阶段流水线并按层并行
✔ 能发现并指出 DAG 中的环,并算出给定任务图的最短完成时间
核心知识点详解
  • Kahn 给分层、DFS 给逆后序:Kahn 统计入度、入度 0 入队、出队减邻接入度;输出数 < 总数即有环,可打印剩余入度 > 0 的节点。Kahn 额外给的 level 就是「可并行批次」,DFS 三色只判环——工程编排优先用 Kahn。
  • 关键路径决定 makespan:DAG 最长路径决定最短完工时间。优化必须针对关键路径上的任务;优化 off-critical 任务收益为 0——与 Amdahl 定律同源:只优化非瓶颈部分总加速为 0。例:bm25(12)+rrf(1)+rerank(45)+generate(900) 决定 makespan 约 958ms。
  • 失败传播与幂等:DAG 某节点失败时,依赖它的所有后继都必须失效;每个节点要可重入(重试无副作用),否则重跑写脏数据。多 Agent 编排(先检索→抽取→校验→汇总)就是按拓扑序 + 按层并行 + 失败沿依赖边传播。
  • 常见坑:拿 DFS 逆后序当执行序:DFS 逆后序只是拓扑序之一、不给分层;若需按层并行要用 Kahn 的 level。执行前还要确认 DAG 无环,否则调度死循环。
最短路与并查集Dijkstra 堆优化懒删除A* 可采纳启发 h0-1 BFS 双端队列Floyd-Warshall 全源DSU 路径压缩 + 按秩Kruskal 排序 + 判环
学习路径
  1. 读 4.3:理解 Dijkstra 堆优化的懒删除与 0-1 BFS 双端队列
  2. 跑内置代码,实现带路径重建的最短路并打印路径
  3. 完成练习自测:用 DSU 路径压缩加按秩实现 Kruskal 判环
  4. 对接 M3:用并查集做检索结果的连通聚类或去重
✔ 能解释 Dijkstra 为何不适配负权,并写出 Kruskal 的判环逻辑
核心知识点详解
  • Dijkstra 的懒删除:堆存 (dist, node),弹出的节点若已在 done 集合中则跳过(懒删除,避免删堆内元素的复杂操作)。O((V+E) log V)。不能处理负权,因为贪心前提被破坏。
  • Dijkstra vs A* vs 0-1 BFS:A* 用可采纳启发(不高估)引导方向,在地图/网格上常比 Dijkstra 少展开 50–90% 节点,h≡0 即退化为 Dijkstra;0-1 BFS(边权 ∈ {0,1})用双端队列,0 边压队首、1 边压队尾,O(V+E)。
  • DSU 路径压缩 + 按秩 = 近似 O(1):只用路径压缩是 O(log n) 均摊;两个优化都加是 O(α(n)),α 是反阿克曼函数,现实中 ≤ 4。Kruskal 最小生成树 = 排序 + DSU 判环,是「聚类 / 社区发现」的简化原型。
  • 常见坑:Floyd 只用于小 n:Floyd-Warshall 全源 O(n³),n < 500 才用;负权用 Bellman-Ford(O(VE),能判负环)。大规模单源用 Dijkstra / A* / Bellman-Ford,全源稀疏大图用 Johnson。
综合练习BFS/DFS 选择口诀负权用 Bellman-Ford
学习路径
  1. 读 4.4:把 BFS/DFS 选择口诀与负权处理补完
  2. 跑内置代码,对含负权的图跑 Bellman-Ford 并核对结果
  3. 完成综合练习:把图的表示与遍历对齐到 DAG 调度场景
  4. 对接 M3:给调度用的 DAG 补上失败传播与幂等的处理
✔ 能按口诀选遍历方式并指出负权时的正确算法
核心知识点详解
  • 复杂度的精确账:CSR:indptr 长 V+1、indices 长 E,两个连续数组约 4–8 字节/项、无指针追逐;邻接表每边在 Python 下约几十字节对象开销。GPU 需要合并访存 + 线程按索引取数,CSR 天然满足。
  • 负权处理的边界:Dijkstra 输给负权的反例:s→a 权2、s→b 权3、b→a 权−2,Dijkstra 先定 a=2,但真实最短 s→b→a=1。负权用 Bellman-Ford(O(VE),还能检测负环)。
  • 常见坑:规模不估就上结论:邻接表 vs CSR 要在 100 万边、10 万点场景量出空间与访存差异,结论由实测支撑而不是背结论。判据是「用公式给出数字 + 能解释 GPU 为何偏好 CSR」。
学习路径

4.1 图的表示与两种遍历

表示法空间遍历邻居判边存在适合
邻接矩阵O(V²)O(V)O(1)稠密图、V 小、需要频繁判边
邻接表O(V+E)O(deg(v))O(deg(v))稀疏图(绝大多数真实场景)
边列表O(E)——Kruskal、只需遍历所有边
CSR/CSC(压缩稀疏行)O(V+E)O(deg) 且连续—高性能计算、GPU 上的稀疏运算
pythonfrom collections import deque

def bfs(g, s):
    """BFS:层序扩展。改造成双向 BFS 能大幅降低搜索空间(社交网络、六度分割)"""
    dist = {s: 0}; q = deque([s]); parent = {s: None}
    while q:
        u = q.popleft()
        for v in g.get(u, ()):
            if v not in dist:
                dist[v] = dist[u] + 1; parent[v] = u; q.append(v)
    return dist, parent

def dfs_iter(g, s):
    """迭代 DFS:避免 Python 递归深度限制;用显式栈"""
    seen, st = set(), [s]
    order = []
    while st:
        u = st.pop()
        if u in seen: continue
        seen.add(u); order.append(u)
        st.extend(v for v in g.get(u, ()) if v not in seen)
    return order

def connected_components(g, nodes):
    """连通分量:DFS/BFS 的直接应用(也等价于并查集的结果)"""
    seen, comps = set(), []
    for n in nodes:
        if n in seen: continue
        stack, comp = [n], []
        while stack:
            u = stack.pop()
            if u in seen: continue
            seen.add(u); comp.append(u)
            stack.extend(v for v in g.get(u, ()) if v not in seen)
        comps.append(comp)
    return comps

两个实战补充:二分图判定(BFS 染色,是「无法通电 / 冲突分组」类问题的原型)与 CSR 表示(把邻接表压成两个数组,是 GPU 图算法与稀疏矩阵的标准存储)。

pythonfrom collections import deque

def is_bipartite(g, nodes):
    """二分图判定:BFS 交替染色。用于「冲突分组 / 两色着色」类问题"""
    color = {}
    for s in nodes:
        if s in color: continue
        color[s] = 0; q = deque([s])
        while q:
            u = q.popleft()
            for v in g.get(u, ()):
                if v not in color: color[v] = color[u] ^ 1; q.append(v)
                elif color[v] == color[u]: return False
    return True

def build_csr(n, edges):
    """邻接表 → CSR(压缩稀疏行):indptr + indices 两个连续数组"""
    deg = [0] * (n + 1)
    for u, v in edges: deg[u + 1] += 1
    for i in range(n): deg[i + 1] += deg[i]
    indptr, idx = deg[:], [0] * len(edges)
    pos = deg[:n]
    for u, v in edges:
        idx[pos[u]] = v; pos[u] += 1
    return indptr, idx

g = {0: [1, 3], 1: [0, 2], 2: [1, 3], 3: [0, 2]}
print('二部图:', is_bipartite(g, [0,1,2,3]))     # True(0/2 同侧,1/3 同侧)
edges = [(0,1),(0,3),(1,2),(2,3)]
print('CSR:', build_csr(4, edges))               # ([0,2,3,4,4],[1,3,2,3])
# 复杂度:BFS 染色 O(V+E);CSR 构建 O(V+E),查询邻居 O(deg) 且内存连续。
# GPU 上的 BFS / PageRank 几乎都用 CSR,因为它能合并访存。
✔
BFS 与 DFS 的选择口诀:求「最短步数 / 最少操作」用 BFS(因为 BFS 按层扩展,第一次到达即最短,前提是边权相同);求「是否存在 / 所有路径 / 环 / 拓扑序」用 DFS。边权不同且非负时用 Dijkstra;有负权用 Bellman-Ford;涉及启发式估计(如地图导航、规划任务)用 A*。

4.2 拓扑排序与 DAG 调度:Agent 编排的骨架

这是本节与 AI 最直接相关的部分。Agent 的任务规划、模型训练的数据流水线(Airflow / Dagster)、编译器的指令调度,本质都是「有依赖关系的任务集合」——也就是 DAG(有向无环图)。

pythonfrom collections import deque

def topo_sort_kahn(nodes, edges):
    """Kahn 算法(BFS 版):能同时检测环,并支持「按层并行执行」"""
    indeg = {n: 0 for n in nodes}
    adj = {n: [] for n in nodes}
    for u, v in edges:
        adj[u].append(v); indeg[v] += 1
    q = deque([n for n in nodes if indeg[n] == 0])
    order, level = [], {}
    for n in q: level[n] = 0
    while q:
        u = q.popleft(); order.append(u)
        for v in adj[u]:
            indeg[v] -= 1
            if indeg[v] == 0:
                level[v] = level[u] + 1
                q.append(v)
    if len(order) != len(nodes):
        cyc = [n for n in nodes if indeg[n] > 0]
        raise ValueError(f'图中存在环,无法拓扑排序;涉及节点: {cyc}')
    return order, level          # level 就是「可以并行执行的批次」

def topo_sort_dfs(nodes, adj):
    """DFS 版:能顺便给出逆后序;用三色标记检测环"""
    WHITE, GRAY, BLACK = 0, 1, 2
    color = {n: WHITE for n in nodes}
    out, has_cycle = [], False
    def dfs(u):
        nonlocal has_cycle
        color[u] = GRAY
        for v in adj.get(u, ()):
            if color[v] == GRAY: has_cycle = True       # 回边 → 有环
            elif color[v] == WHITE: dfs(v)
        color[u] = BLACK; out.append(u)                 # 后序
    for n in nodes:
        if color[n] == WHITE: dfs(n)
    return list(reversed(out)), has_cycle

拓扑排序在工程上最有价值的一步是关键路径与并行批调度:DAG 中最长的一条路径决定了整体最短完工时间。把它算出来,你就知道该优化哪个节点——这正是 Hamauls Orion 编排器的性能模型。

pythonfrom collections import deque

def critical_path(nodes, edges, dur):
    """返回 (最短完工时间 makespan, 每个任务的最早开始时间)。
       dur[u] 是任务 u 的耗时;并行资源无限(理论下界)"""
    indeg = {n: 0 for n in nodes}; adj = {n: [] for n in nodes}
    for u, v in edges: adj[u].append(v); indeg[v] += 1
    q = deque([n for n in nodes if indeg[n] == 0])
    est = {n: 0 for n in nodes}              # earliest start time
    while q:
        u = q.popleft()
        for v in adj[u]:
            est[v] = max(est[v], est[u] + dur[u])   # 取最晚的先行条件
            indeg[v] -= 1
            if indeg[v] == 0: q.append(v)
    return max(est[n] + dur[n] for n in nodes), est

# 例:RAG 流水线(单位 ms)
nodes = ['embed','bm25','vec_search','rrf','rerank','generate']
edges = [('embed','vec_search'),('bm25','rrf'),('vec_search','rrf'),
         ('rrf','rerank'),('rerank','generate')]
dur = {'embed':8,'bm25':12,'vec_search':20,'rrf':1,'rerank':45,'generate':900}
print(critical_path(nodes, edges, dur))
# 预期 makespan ≈ 958 ms:关键路径 = bm25(12)+rrf(1)+rerank(45)+generate(900)
# embed/vec_search 与 bm25 并行 → 不构成关键路径。
# 结论:优化 rerank 或 generate 才有效,优化 embed 收益为 0(与 Amdahl 定律同源)。

4.3 最短路、并查集与 Kruskal

pythonimport heapq

def dijkstra(g, s):
    """单源最短路(非负权):贪心 + 堆优化,O((V+E) log V)"""
    dist = {s: 0}; pq = [(0, s)]; done = set()
    while pq:
        d, u = heapq.heappop(pq)
        if u in done: continue               # 懒删除:跳过已确定的节点
        done.add(u)
        for v, w in g.get(u, ()):
            nd = d + w
            if v not in dist or nd < dist[v]:
                dist[v] = nd; heapq.heappush(pq, (nd, v))
    return dist

def a_star(g, s, t, h):
    """A*:用启发函数 h 引导搜索方向。h 必须可采纳(不高估)才能保证最优"""
    open_set = [(h(s), 0, s)]; gs = {s: 0}; done = set()
    while open_set:
        _, gc, u = heapq.heappop(open_set)
        if u == t: return gc
        if u in done: continue
        done.add(u)
        for v, w in g.get(u, ()):
            ng = gc + w
            if v not in gs or ng < gs[v]:
                gs[v] = ng
                heapq.heappush(open_set, (ng + h(v), ng, v))   # f = g + h
    return None

class DSU:
    """并查集:近似 O(1)。路径压缩 + 按秩合并,两个优化都要有"""
    def __init__(self, n):
        self.p = list(range(n)); self.r = [0]*n; self.cnt = n
    def find(self, x):
        while self.p[x] != x:
            self.p[x] = self.p[self.p[x]]      # 路径压缩(隔代压缩,迭代安全)
            x = self.p[x]
        return x
    def union(self, a, b):
        ra, rb = self.find(a), self.find(b)
        if ra == rb: return False
        if self.r[ra] < self.r[rb]: ra, rb = rb, ra
        self.p[rb] = ra
        if self.r[ra] == self.r[rb]: self.r[ra] += 1
        self.cnt -= 1
        return True

def kruskal_edges(n, edges):
    """最小生成树:排序 + 并查集判环。这是「聚类 / 社区发现」的简化原型"""
    dsu, mst = DSU(n), []
    for w, u, v in sorted(edges):
        if dsu.union(u, v): mst.append((u, v, w))
    return mst

补两个实用变体:0-1 BFS(边权只有 0/1 时用双端队列代替堆,做到 O(V+E))与 Floyd-Warshall(全源最短路,DP 视角)。再看 A* 相对 Dijkstra 实测能省多少节点。

pythonfrom collections import deque
import heapq

def zero_one_bfs(g, s):
    """0-1 BFS:边权 ∈ {0,1} 时用 deque,0 边压队首、1 边压队尾,O(V+E)"""
    dist = {s: 0}; dq = deque([s])
    while dq:
        u = dq.popleft()
        for v, w in g.get(u, ()):
            nd = dist[u] + w
            if v not in dist or nd < dist[v]:
                dist[v] = nd
                if w == 0: dq.appendleft(v)
                else: dq.append(v)
    return dist

def floyd(n, edges):
    """全源最短路:三重循环 DP,O(n^3)。n < 500 时才用它"""
    INF = float('inf')
    d = [[INF]*n for _ in range(n)]
    for i in range(n): d[i][i] = 0
    for u, v, w in edges: d[u][v] = min(d[u][v], w)
    for k in range(n):
        for i in range(n):
            for j in range(n):
                if d[i][k] + d[k][j] < d[i][j]: d[i][j] = d[i][k] + d[k][j]
    return d

# A* vs Dijkstra 的展开节点数(启发函数越好,省得越多)
# 在地图/网格上,A* 常能比 Dijkstra 少展开 50–90% 的节点;
# 启发函数 h 必须可采纳(不高估)才保证最优,h≡0 即退化成 Dijkstra。
print('floyd(3,[(0,1,2),(1,2,3),(0,2,10)])[0] =', floyd(3,[(0,1,2),(1,2,3),(0,2,10)])[0])
# 预期 [0, 2, 5](0→2 经 1 更短:2+3=5 优于直达 10)

4.4 动手练习与自测

✔
判据:每题都要求给出复杂度 + 一个可跑的小例子(含预期输出)。
  1. 用邻接表与 CSR 两种表示存储同一个 100 万边、10 万点的图,说明各自空间开销(用公式),并解释为什么 GPU 图算法偏好 CSR。
  2. 实现拓扑排序的 Kahn 与 DFS 两版,分别在一张含环图上运行,说明两者如何报告环,并给出一个反例说明为什么 Kahn 版能直接给出「可并行批次」。
  3. 给定一张 RAG 流水线 DAG(节点与耗时自定),算关键路径与 makespan;指出优化哪个节点收益最高、哪个为 0,并解释与 Amdahl 定律的关系。
  4. 说明 Dijkstra 为什么不能处理负权边,给出一个具体反例;并说明 0-1 BFS 相对 Dijkstra 的复杂度优势(O(V+E) vs O((V+E)logV))。
  5. 并查集只做路径压缩(不做按秩合并)的均摊复杂度是多少?加上按秩合并后是多少?给出 α(n) 在现实规模下的取值。
题号通过标准目标耗时
1邻接表 vs CSR 的空间与访存差异说清(含 GPU 合并访存)8 分钟
2Kahn 与 DFS 三色都能判环;能说出 Kahn 额外给出的「分层并行」8 分钟
3关键路径 = DAG 最长路;会用 Amdahl 说明优化非关键路径收益为 08 分钟
4能举负权反例并说明 0-1 BFS 的 O(V+E)6 分钟
5并查集 α(n) ≤ 4(n < 2^2^65536),能区分只有路径压缩 vs 加按秩合并6 分钟
★
参考答案与判据:① 邻接表:每个点一个 list,开销约 O(V+E) 但有指针/对象开销(Python 下每边约几十字节);CSR:indptr 长 V+1、indices 长 E,两个连续数组,约 4–8 字节/项,无指针追逐。GPU 需要合并访存 + 线程按索引取数,CSR 天然满足。
② Kahn:入度减到 0 的节点入队,若最终输出数 < 总节点数即有环,可打印剩余入度 >0 的点;DFS:遇灰色节点即回边有环。Kahn 出队时记录的 level 就是「可并行批次」——这是 DFS 给不出的。
③ 关键路径是 DAG 最长路径;优化关键路径上的节点才能缩短 makespan。若 embed 与 bm25 并行且不在关键路径上,优化 embed 收益为 0——这正是 Amdahl 定律:只优化非瓶颈部分,总加速为 0。
④ 负权反例:s→a 权 2、s→b 权 3、b→a 权 −2。Dijkstra 会先确定 a 距离 2,但真实最短是 s→b→a = 1。0-1 BFS 用双端队列,每条边摊到 O(1),总 O(V+E)。
⑤ 仅路径压缩:O(log n) 均摊;路径压缩 + 按秩合并:O(α(n)),α 是反阿克曼函数,n < 2^2^65536 时都 ≤ 4,实际等价于常数。

5. 检索索引:倒排、BM25 与 HNSW(本阶段核心)

知识结构图 · 检索索引
检索索引:倒排、BM25 与 HNSW4 大知识域 · 32 个知识点
倒排索引与 BM25postings 词→文档表BM25 k1=1.5 b=0.75IDF 平滑 +0.5TF 饱和 k1=1.2–2.0长度归一化 avgdl差分 + VByte 压缩 5–6×WAND / BMW 剪枝
学习路径
  1. 读 5.1:吃透 postings 词到文档表与 BM25 的 k1=1.5、b=0.75 两项参数
  2. 跑内置代码,实现倒排索引并统计 VByte 压缩后的体积
  3. 完成练习自测:调 k1/b 看打分变化并解释各参数含义
  4. 对接 M3:把 hamauls_orion/index/bm25.py 打通的候选召回在 100 万条上测速
✔ 能手写 BM25 公式并解释 k1/b 对 TF 饱和与长度归一化的影响
核心知识点详解
  • BM25 三要素:IDF 稀有词加权(log(1+(N−df+0.5)/(df+0.5)),+0.5 平滑防除零与负值);TF 饱和(k1 越大饱和越慢,通常 1.2–2.0,避免「出现 10 次 = 3 次的 3 倍重要」);长度归一化(按 dl/avgdl 打折,b 控制强度,通常 0.75)。核心式 tf_norm = tf*(k1+1)/(tf + k1*(1-b+b*dl/avgdl))。
  • postings 压缩 5–6×:差分编码(相邻 doc_id 相减、小整数值)+ VByte(≤127 只占 1 字节),实测 10 万随机 doc_id 从约 391KB 压到 60–80KB。再加上跳表指针、WAND/BMW 剪枝,Top-K 查询再快 2–5×。真实全文索引整体约为原文 20–30%。
  • 为什么 BM25 还活着:它擅长精确匹配(型号、编号、专有名词、罕见术语),恰恰是稠密向量容易语义漂移的地方。生产 RAG 几乎都用混合检索 + RRF(BM25 + 向量)而非纯向量。
  • 常见坑:中文按字索引:把「人工智能」按 4 个无关字索引会让 BM25 的文档长度与 TF 统计严重失真。用 jieba / n-gram(bigram)/ 稀疏向量(SPLADE)替代,或至少明确你的 tokenizer 语义。
向量检索 HNSWFlat / IVF / PQ / IVF-PQHNSW M=16–64ef_construction=200ef_search=64 拐点分层贪心下降PQ m=16 压 1/161e8×128d = 51.2GB
学习路径
  1. 读 5.2:理解 HNSW 的 M、ef_construction、ef_search 三个旋钮
  2. 跑内置代码,对比 Flat 与 HNSW 的召回与延迟曲线
  3. 完成练习自测:找到 ef_search=64 处的准确率与 QPS 拐点
  4. 对接 M3:实现 hamauls_orion/index/hnsw.py 并跑出 Recall@10 ≥ 0.95
✔ 能测出 ef_search 拐点,并解释 M 增大为何提升召回却增加内存
核心知识点详解
  • 三个参数各管什么(可背):M(16–64)控每层邻居数与内存:↑召回↑内存↑;ef_construction (100–500) 管建图质量、决定召回率上界,后期无法用 ef_search 弥补;ef_search 是唯一运行时动态可调的旋钮,召回不够就调大(代价是延迟)。
  • ef_search 拐点实验:固定数据集扫 ef_search ∈ {10,32,64,128,256},画「recall@10 vs QPS」曲线,在拐点(常约 64)取工作点:召回约 0.95 且 QPS 仍高,再往上召回增益递减。这就是 M3 要交付的曲线。
  • 内存账 / PQ 压缩:1e8×128d float32 原数据 = 51.2GB;HNSW 加图边后约 70–90GB;PQ m=16 压到每向量 16 字节 → 1.6GB,代价是召回率下降(约 0.7–0.9)。选型口诀:内存够 + 要低延迟 + 要增量 → HNSW;内存紧 + 亿级 → IVF-PQ / DiskANN。
  • 常见坑:不测曲线直接套默认:直接调 hnswlib 不明白 M/ef_search 为何影响召回与延迟。M3 要求亲手实现 HNSW 并跑出 Recall@10 ≥ 0.95,不能只调库不写核心。
融合与重排RRF score=Σ1/(k+rank)RRF k=60 经验值min-max / z-score 归一化cross-encoder 重排双塔粗排 + 精排候选集 50–100
学习路径
  1. 读 5.3:搞懂 RRF 的 1/(k+rank) 与 k=60 为何是经验值
  2. 跑内置代码,把关键词与向量各自 Top-100 再融合到 50
  3. 完成练习自测:用候选集 50–100 跑一轮交叉编码器精排
  4. 对接 M3:让 100+100→50→10 的融合漏斗在项目里出数字
✔ 能解释 RRF 相比归一化打分的取舍,并跑出最终 Top-10 列表
核心知识点详解
  • RRF 绕过分数回归分歧:score(d) = Σ 1/(k + rank_i(d)),k 通常 60。相比直接相加分数(余弦在 [0,1]、BM25 无上界,直接加等于让 BM25 主导),RRF 只依赖排名(序数尺度天然可比)、鲁棒且几乎零调参。k 越大多路都出现更突出、k 越小单路第一更激进。
  • 两阶段架构成本账:「粗排 O(n)/O(log n)(哈希/索引)+ 精排 O(k·L²)(k 个候选过交叉编码器)」。cross-encoder 精度远高于双塔但无法预计算,只能用于小候选集——解释为什么候选集大小(50–100)是关键调参。
  • 常见坑:不先归一化就加权相加:不同召回源分数尺度不可比。要用加权融分必须 min-max / z-score 归一化,且异常分数会主导归一化(min 与 max 异常)。RRF 用排名绕过了这个问题,是异构召回的首选。
检索内核验收recall@10 vs QPS 曲线ef_search≈64 拐点混合检索 100+100→50→10
学习路径
  1. 读 5.4:按验收清单比对 recall@10 与 QPS 曲线
  2. 完成自测:逐项核对混合检索 100+100→50→10 的数字
  3. 对 M3 产物复测:确认 HNSW QPS 达到暴力检索的 20 倍
✔ 能以一条曲线证明召回与吞吐的取舍,并口头推导各索引复杂度
核心知识点详解
  • 可复现的验收数字:暴力检索 recall@10=1.0 但延迟随 n·d 线性增长;HNSW 在 recall@10≈0.95 时通常比暴力快 100–1000 倍。要能画出 recall vs QPS 曲线并指出 ef_search≈64 拐点,而不是只报一个数。
  • 混合检索漏斗 100+100→50→10:向量召回 100 + BM25 召回 100 → RRF 融合取前 50 → cross-encoder 重排取前 10。每级都要有数字;型号编号类查询靠 BM25 必中、语义类靠向量,RRF 结果整体优于任一单路。
  • 常见坑:在小数据集上过拟合参数:在 1 万条上 M=64、ef_search=256 参数无感,转到 100 万条就爆内存/延迟。曲线必须在大规模 + 不同 ef_search 下测,选拐点而非最大参数。
学习路径

5.1 倒排索引与 BM25:关键词检索的工业标准

这是本节最重要的内容——你将在 Hamauls Orion 里亲手实现它,并且它是 RAG 混合检索的一半。倒排索引(inverted index)把「文档 → 词」翻转成「词 → 文档列表(postings)」,让「包含某词的所有文档」变成一次查表。

pythonimport math, re
from collections import defaultdict, Counter

TOKEN = re.compile(r'[A-Za-z0-9_]+|[一-鿿]')

def tokenize(text):
    """极简分词:英文按词、中文按字。生产环境要换成真正的 analyzer
       (注意:中文必须分词,按字索引会让 BM25 的 IDL/文档长度统计失真)"""
    return [t.lower() for t in TOKEN.findall(text)]

class InvertedIndex:
    def __init__(self, k1=1.5, b=0.75):
        self.k1, self.b = k1, b
        self.postings = defaultdict(list)     # term -> [(doc_id, tf), ...]
        self.doc_len = {}                     # doc_id -> 词数
        self.docs = {}                        # doc_id -> 原文
        self.avgdl = 0.0

    def add(self, doc_id, text):
        toks = tokenize(text)
        self.docs[doc_id] = text
        self.doc_len[doc_id] = len(toks)
        for term, tf in Counter(toks).items():
            self.postings[term].append((doc_id, tf))

    def build(self):
        self.avgdl = sum(self.doc_len.values()) / max(1, len(self.doc_len))
        for t in self.postings:               # 按 doc_id 排序 → 便于求交与压缩
            self.postings[t].sort()
        return self

    def bm25(self, query, top_k=10):
        """BM25 打分:TF 饱和 + 文档长度归一化 + IDF 稀有词加权"""
        N = len(self.doc_len)
        scores, qterms = defaultdict(float), tokenize(query)
        for term in qterms:
            plist = self.postings.get(term)
            if not plist: continue
            df = len(plist)
            # IDF:词越稀有权重越高;加 0.5 平滑避免除零与负值
            idf = math.log(1 + (N - df + 0.5) / (df + 0.5))
            for doc_id, tf in plist:
                dl = self.doc_len[doc_id]
                # TF 归一化:k1 控制饱和速度,b 控制长度归一化强度
                tf_norm = tf * (self.k1 + 1) / (tf + self.k1 * (1 - self.b + self.b * dl / self.avgdl))
                scores[doc_id] += idf * tf_norm
        return sorted(scores.items(), key=lambda x: -x[1])[:top_k]

索引能不能常驻内存,取决于 postings 的压缩率。下面是「差分编码 + VByte」的实测——它决定了你的索引是能全放内存,还是每次都要读盘。

pythonimport random

def delta(nums):                          # 升序 doc_id → 相邻差值
    prev, out = 0, []
    for x in nums:
        out.append(x - prev); prev = x
    return out

def vbyte_encode(nums):
    """变长整数编码:小数值只占 1 字节(≤127),大数值才多占字节"""
    out = bytearray()
    for x in nums:
        while True:
            b = x & 0x7F; x >>= 7
            if x: out.append(b | 0x80)
            else: out.append(b); break
    return out

random.seed(0)
docs = sorted(random.sample(range(1, 50_000_000), 100_000))
raw = len(docs) * 4                       # 假设 int32(文档 ID 很大,无法用 uint16)
enc = vbyte_encode(delta(docs))
print(f'int32 原始 : {raw/1024:8.0f} KB')
print(f'delta+VByte: {len(enc)/1024:8.0f} KB   压缩比 {raw/len(enc):.1f}x')

# 预期:原始约 391 KB → 压缩后约 60–80 KB,压缩比约 5–6×
# 真实全文索引:Lucene 整体索引通常只有原始文本的 20–30%;
# 再加上 WAND / BMW 动态剪枝,查询还能再快 2–5×(跳过不可能进 top-k 的文档)。
# 高频词(如「的」)的 postings 极长 —— 这类词必须靠剪枝与分块处理。
优化手段作用典型收益
postings 按 doc_id 升序求交/求可线性归并使跳表指针与剪枝成为可能
差分 + VByte / PForDelta压缩 doc_id索引体积 -70%~-80%
跳表指针(skip list in postings)求交跳跃前进交集加速 2–3×
WAND / BMW 剪枝动态跳过低分文档Top-K 查询加速 2–5×
只对部分词存位置(positions)省空间短语查询保留必要位置信息

5.2 向量检索:从暴力到 IVF 到 HNSW

这是 2026 年 AI 工程师最该亲手写过一遍的数据结构。向量检索要在上亿条高维向量中找最近邻,精确解法是 O(n·d) 的暴力扫描——不可接受。所以全部现实方案都是「用少量召回率换取数量级的加速」:也就是近似最近邻(ANN)。

方法思想召回率查询延迟内存支持增量适用规模
暴力扫描(Flat)逐个算距离100%O(n·d),很慢n·d是< 100 万
IVF(倒排文件)先聚类,只搜最近的 nprobe 个簇中–高(可调)快n·d + 聚类中心需重训(可加)百万–亿
PQ(乘积量化)把向量切段量化,用查表算距离中(压缩损失)极快大幅压缩(1/16+)是亿级
IVF-PQ聚类 + 量化组合中高很快很小部分十亿级
HNSW(分层可导航小世界图)多层跳表式图,贪心下降很高很快偏大(图边)是百万–亿
DiskANN / 磁盘索引图索引放磁盘 + 内存缓存高中小部分百亿级

HNSW 是当下的默认选择(faiss / hnswlib / pgvector / Milvus 都支持),因为它召回率最高、支持增量插入、无需训练。代价是内存占用大(每个节点要存邻接表)。它成功的核心直觉:如果 A 的邻居是 B,B 的邻居是 C,那么在「近邻关系」这个图上走,用多层次的贪心下降就能高效逼近目标——和跳表压树高是同一个思路。

pythonimport heapq, math, random

def cos_dist(a, b):
    """余弦距离 = 1 - 余弦相似度。归一化后等价于欧氏距离排序"""
    dot = sum(x*y for x, y in zip(a, b))
    na = math.sqrt(sum(x*x for x in a)); nb = math.sqrt(sum(y*y for y in b))
    return 1.0 - dot / (na * nb + 1e-12)

class HNSW:
    """分层可导航小世界图(简化实现,抓住核心机制)
       关键参数:
         M             每层最大邻居数。越大召回率越高、内存越大(常用 16–64)
         ef_construction  建图时的候选池大小。越大图质量越好、建图越慢(常用 100–500)
         ef_search     查询时的候选池大小。越大召回率越高、越慢(常用 50–500)
    """
    def __init__(self, dim, M=16, ef_construction=200, ef_search=64, seed=42):
        self.dim, self.M = dim, M
        self.ef_c, self.ef_s = ef_construction, ef_search
        self.rng = random.Random(seed)
        self.vecs, self.graph, self.entry, self.max_lv = {}, {}, None, -1

    def _rand_level(self):
        """指数衰减的层数分布:大多数点只在第 0 层,越往上越稀疏"""
        lv, ml = 0, 1.0 / math.log(self.M)
        while self.rng.random() < math.exp(-1.0 / ml) and lv < 32:
            lv += 1
        return lv

    def _search_layer(self, q, entry, ef, lv):
        """在单层内做贪心 + 优先队列的最佳优先搜索(这是 HNSW 的核心循环)"""
        visited = {entry}
        d0 = cos_dist(q, self.vecs[entry])
        cand = [(d0, entry)]                       # 候选(最小堆)
        result = [(-d0, entry)]                    # 结果(最大堆,用负距离)
        while cand:
            d, u = heapq.heappop(cand)
            if d > -result[0][0] and len(result) >= ef:
                break                              # 最近的候选都比当前结果差 → 收敛
            for v in self.graph[u].get(lv, ()):
                if v in visited: continue
                visited.add(v)
                dv = cos_dist(q, self.vecs[v])
                if len(result) < ef or dv < -result[0][0]:
                    heapq.heappush(cand, (dv, v))
                    heapq.heappush(result, (-dv, v))
                    if len(result) > ef: heapq.heappop(result)
        return [(-d, u) for d, u in sorted(result, reverse=True)]

    def insert(self, key, vec):
        self.vecs[key] = vec
        lv = self._rand_level()
        self.graph[key] = {}
        if self.entry is None:
            self.entry, self.max_lv = key, lv
            for l in range(lv + 1): self.graph[key][l] = []
            return
        cur = self.entry
        for l in range(self.max_lv, lv, -1):       # ① 从顶层贪心下降到 lv
            cur = self._search_layer(vec, cur, 1, l)[0][1]
        for l in range(min(lv, self.max_lv), -1, -1):
            found = self._search_layer(vec, cur, self.ef_c, l)   # ② 每层找 ef_c 个近邻
            m = self.M if l else self.M * 2
            neigh = [u for _, u in found[:m]]
            self.graph[key][l] = neigh
            for u in neigh:                        # ③ 双向连边 + 剪枝
                self.graph[u].setdefault(l, []).append(key)
                if len(self.graph[u][l]) > m:
                    du = [(cos_dist(self.vecs[u], self.vecs[w]), w) for w in self.graph[u][l]]
                    self.graph[u][l] = [w for _, w in sorted(du)[:m]]
            cur = neigh[0]
        if lv > self.max_lv:
            self.entry, self.max_lv = key, lv

    def search(self, q, k=10):
        cur = self.entry
        for l in range(self.max_lv, 0, -1):
            cur = self._search_layer(q, cur, 1, l)[0][1]
        return [(u, d) for d, u in self._search_layer(q, cur, max(self.ef_s, k), 0)[:k]]

HNSW 内存偏大(每点要存邻接表),亿级规模要靠 PQ(乘积量化)压缩:把 d 维向量切成 m 段、每段用 256 个候选做 KMeans,于是每段只要 1 字节,内存压到 1/16 甚至更低,距离用「查表 + 累加」近似算。

pythonimport numpy as np

def pq_train_and_encode(X, m=8, ksub=256, seed=0):
    """乘积量化:把 D 维切成 m 段,每段独立 KMeans 到 ksub 个中心
       编码后每个向量只需 m 字节(m=8 → 32 维 float32 的 1/16)"""
    rng = np.random.RandomState(seed)
    D = X.shape[1]; assert D % m == 0
    sub = D // m
    codebooks = []
    codes = np.zeros((X.shape[0], m), dtype=np.uint8)
    for i in range(m):
        Xi = X[:, i*sub:(i+1)*sub]
        # 极简 KMeans(几轮迭代即可,这里示意)
        cent = Xi[rng.choice(len(Xi), ksub, replace=False)].copy()
        for _ in range(10):
            d = ((Xi[:, None, :] - cent[None, :, :]) ** 2).sum(-1)
            assign = d.argmin(1)
            for c in range(ksub):
                if (assign == c).any(): cent[c] = Xi[assign == c].mean(0)
        codebooks.append(cent); codes[:, i] = assign
    return codebooks, codes

def pq_dist(codebooks, codes, q, i):
    """距离近似:对每段查表取距离再累加(m 次查表,不是 D 次乘加)"""
    sub = q.shape[0] // len(codebooks)
    tot = 0.0
    for s, cb in enumerate(codebooks):
        tot += float(((cb - q[s*sub:(s+1)*sub]) ** 2).sum(1)[codes[i, s]])
    return tot

# 规模账:1 亿条 128 维 float32 = 51.2 GB(放不进单机内存)
#   用 m=16 的 PQ → 每向量 16 字节 → 1.6 GB,直接放内存
#   代价:召回率下降,通常需配合「PQ 粗筛 + 原向量重排」两段式
#   实测经验:IVF-PQ 在 10 亿级、召回@10 约 0.7–0.9(取决于 nprobe 与重排比例)
索引1e8×128d 内存典型召回@10QPS 量级(单机)增量插入
Flat51.2 GB1.00数十是
IVF-Flat51.2 GB + 中心0.90–0.98(nprobe 大)数百需重训
IVF-PQ1.6–3.2 GB0.70–0.90数千近似支持
HNSW约 70–90 GB(图边)0.95–0.99数千–上万是
DiskANN内存仅缓存部分0.90–0.97数百–数千部分
ℹ
2026 年的工程选型现实:Faiss 是研究/离线建索引的事实标准(IVF/HNSW/PQ 齐全);hnswlib 是单机内存 HNSW 的轻量选择;ScaNN(Google)在部分数据集上以各向异性量化取得更高召回-延迟比;pgvector 让 Postgres 直接支持 HNSW/IVF;Milvus / Qdrant / Weaviate 则是带过滤、分片、持久化的生产系统。选型口诀:内存够 + 要低延迟 + 要增量 → HNSW;内存紧 + 亿级 → IVF-PQ 或 DiskANN;要标量过滤 → 支持 filtered search 的 Milvus/Qdrant。
★
HNSW 的三个参数怎么调(可以直接背):M(每层邻居数):控制图连通性与内存,16 是通用起点,高维或高召回需求调到 32–64;M 增大时召回率上升但内存线性增长。ef_construction:建图质量,越大越好但建图越慢,通常取 100–500;它决定「图上界」,后期无法通过调 ef_search 弥补。ef_search:查询期旋钮,唯一可以运行时动态调的参数——召回率不够就调大(代价是延迟),这是生产的常规操作。实测方法:固定数据集,扫 ef_search 得到「召回率 vs QPS」曲线,在曲线拐点附近选值。这条曲线就是 M3 里程碑要交付的东西。

5.3 混合检索与融合:RRF 与重排的算法本质

pythondef reciprocal_rank_fusion(rank_lists, k=60):
    """RRF(Reciprocal Rank Fusion)——混合检索最常用的融合算法
       优点:不需要分数归一化(不同来源的分数不可比),只依赖排名,鲁棒
       公式:score(d) = Σ 1 / (k + rank_i(d)),k 通常取 60"""
    from collections import defaultdict
    fused = defaultdict(float)
    for lst in rank_lists:
        for rank, doc_id in enumerate(lst, start=1):
            fused[doc_id] += 1.0 / (k + rank)
    return sorted(fused.items(), key=lambda x: -x[1])

# 用法:向量检索与 BM25 各出一路,融合后送交叉编码器重排
vec_top   = [d for d, _ in hnsw.search(q_vec, 100)]
bm25_top  = [d for d, _ in index.bm25(q_text, 100)]
fused     = reciprocal_rank_fusion([vec_top, bm25_top])[:50]

# 为什么不能直接相加分数?因为余弦相似度在 [0,1]、BM25 无上界,
# 直接相加等于让 BM25 主导。若一定要加权融分,必须先做归一化
# (min-max 或 z-score),而 RRF 用排名巧妙绕过了这个问题。

RRF 的 k 参数决定了「头部排名有多重要」。k 越小,排名第 1 与第 2 的差距越大;k 越大,越接近「少数服从多数」。下面比较不同 k 与「归一化加权融分」的效果差异。

pythonfrom collections import defaultdict

def rrf(rank_lists, k=60):
    fused = defaultdict(float)
    for lst in rank_lists:
        for rank, doc in enumerate(lst, 1):
            fused[doc] += 1.0 / (k + rank)
    return sorted(fused.items(), key=lambda x: -x[1])

def minmax_fuse(score_lists, weights=None):
    """另一种思路:先归一化分数再加权。风险是异常分数会主导归一化"""
    weights = weights or [1.0] * len(score_lists)
    fused = defaultdict(float)
    for w, slist in zip(weights, score_lists):
        vals = [s for _, s in slist]
        lo, hi = min(vals), max(vals)
        rng = (hi - lo) or 1.0
        for doc, s in slist:
            fused[doc] += w * (s - lo) / rng
    return sorted(fused.items(), key=lambda x: -x[1])

vec  = ['d3','d1','d7','d9']      # 向量召回(按相似度降序)
bm25 = ['d1','d3','d5','d7']      # BM25 召回(按分数降序)
print('RRF  k=60 :', [d for d, _ in rrf([vec, bm25])])
print('RRF  k=1  :', [d for d, _ in rrf([vec, bm25], k=1)])
# 预期:两路都把 d1/d3/d7 排前 → 融合后它们稳居前列,只有单路出现的 d5/d9 垫底。
# k 的作用:k 大时「两路都出现」的权重更突出(更稳);k 小时「单路第一」更突出(更激进)。
# 生产默认 k=60(来自原论文经验值),效果对 k 不敏感,这本身是它鲁棒的原因。
融合方式需要分数归一化对异常分数鲁棒调参成本适用
RRF(按排名)否高几乎为零(k=60)异构召回源融合(首选)
加权分数(min-max)是中需调权重同构、分数可比
加权分数(z-score)是较高需调权重与统计量分布稳定的场景
学习式融合(LTR)是依赖训练高(需标注)有大量点击/标注数据

5.4 动手练习与自测(本阶段核心)

✔
判据:本组题直接对应 M3 里程碑交付物。每题都要给出可复现的数字(召回率 / QPS / 内存 / 索引体积)。
  1. 在 1 万条合成向量(d=128)上实现暴力检索,测出 recall@10=1.0 的基线延迟;再换成你的 HNSW,扫描 ef_search ∈ {10,32,64,128,256},画出「recall@10 vs QPS」曲线,指出拐点。
  2. 解释 BM25 的 k1 与 b 各控制什么;用一段代码验证:把 b 从 0 调到 1,长文档的相对得分如何变化。给出 k1=1.5、b=0.75 的默认取值理由。
  3. 给定「查询里含一个罕见型号编号」的场景,说明为什么纯向量检索会失败,并设计混合检索 + RRF 的流程(写清两路各召回多少条、融合后取多少条送重排)。
  4. 估算:1 亿条 128 维 float32 向量用 HNSW(M=16)需要多少内存?若改用 m=16 的 IVF-PQ 呢?说明两者的取舍。
  5. RRF 公式 score(d)=Σ 1/(k+rank_i(d)) 中,k 取 0 与取 60 分别会带来什么行为差异?为什么生产几乎都用 k=60?
题号通过标准目标耗时
1能测出暴力 vs HNSW 的延迟/召回,并指出 ef_search≈64 的拐点15 分钟
2能解释 k1 的 TF 饱和与 b 的长度归一化,给出 1.2–2.0 / 0.75 经验区间8 分钟
3能说明型号编号为何向量召回失败、BM25 必中,并给出融合+重排流程10 分钟
4能估出 1e6×128 维 HNSW 约 70–90 GB、IVF-PQ 约 1.6 GB 的差距8 分钟
5能解释 RRF 中 k=60 为何鲁棒(平滑头部分差)6 分钟
★
参考答案与判据:① 暴力检索延迟约与 n·d 成正比,1 万条 ×128 维通常 <10 ms;HNSW 曲线在 ef_search≈64 处常出现「召回≈0.95、QPS 仍较高」的拐点,再往上召回增益递减。
② k1 控制 TF 饱和速度(越大饱和越慢,通常 1.2–2.0);b 控制长度归一化强度(0 不归一化、1 完全归一化,通常 0.75)。把 b 调到 1 后长文档得分被显著压低;0.75 是兼顾长短文档的经验折中。
③ 罕见型号编号在向量空间里没有训练信号 → 语义漂移、召回不到;BM25 靠精确词匹配必中。流程:向量召回 100 + BM25 召回 100 → RRF 融合取前 50 → cross-encoder 重排取前 10。
④ HNSW:向量本身 51.2 GB,加上图边(M=16 时约每点几十字节)总共约 70–90 GB;IVF-PQ(m=16):约 1.6 GB + 中心表。取舍:HNSW 召回更高、延迟更低,但内存大;IVF-PQ 省内存但召回下降、需重排补偿。
⑤ k=0 时分数完全由排名倒数决定,第 1 名权重过大、极端敏感;k=60 平滑了头部分差,让「多路都出现」比「单路第一」更重要,鲁棒性最好,且对 k 不敏感——所以成为事实默认值。

6. 缓存、调度与概率数据结构

知识结构图 · 缓存与概率数据结构
缓存、调度与概率数据结构3 大知识域 · 26 个知识点
缓存策略LRU 哈希 + 双向链表哑头尾免边界TTL + LRU 混合Zipf 重尾分布命中率对数增长FIFO / LFU / 2Q / ARC单飞 singleflight过期抖动防雪崩
学习路径
  1. 读 6.1:理解 LRU 哈希加双向链表、TTL 混合与单飞 singleflight
  2. 跑内置代码,实现带 TTL 的 LRU 并打印命中率
  3. 完成练习自测:在 Zipf 重尾流量下对比 LRU 与 LFU
  4. 对接 M3:让 cache.py 的混合淘汰过 100 万次查询的命中率测试
✔ 能用命中率数据说明所选策略在真实分布下的竞争力
核心知识点详解
  • LRU = 哈希表 + 双向链表:哈希表 O(1) 定位 key→节点,双向链表维护访问顺序(头部最近、尾部最久)。get 把节点摘除前移;put 插入头部、超容量删尾部并同步删哈希。哑头尾节点省边界判断。两个操作都是 O(1)。
  • 命中率对数增长决定容量:检索 / 热词 / 请求近似 Zipf 重尾。实测 Zipf α=1:cap=0.5% 键空间 → 约 45% 命中,5% → 约 70%,50% → 约 85%。命中率随容量对数式增长,翻 10 倍只多几个点,所以缓存通常只配 1–5% 键空间。
  • 防击穿 / 雪崩:击穿:热点键过期瞬间大量请求打向后端 → singleflight 让同一键只回源一次;雪崩:大量键同时过期 → TTL 加随机抖动(如 base + rand(0,10%));穿透:不存在的键反复命中后端 → 缓存空值或布隆过滤器前置拦截。
  • 常见坑:扫描型访问冲掉 LRU 热点:顺序遍历少见数据的扫描负载会把 LRU 热点全部冲掉(缓存污染)。LFU 抗扫描但历史权重过重、新热点难上位需老化;2Q / ARC 更均衡。选型取决于访问分布。
概率数据结构布隆 m/k 最优公式双哈希 h1 + i·h2无假阴性只有假阳性(1-e^(-kn/m))^k 假阳率MinHash + LSH 去重shingles k=5HyperLogLog 2% 误差SimHash 指纹
学习路径
  1. 读 6.2:吃透布隆过滤器的 m/k 最优公式与双哈希构造
  2. 跑内置代码,实现布隆过滤器并测量假阳率曲线
  3. 完成练习自测:用 1e6 元素把内存压到几 MB 并对照理论值
  4. 对接 M3:用布隆做一次检索去重并核算空间节省
✔ 能推导给定 n 与假阳率下的最优 m/k,并交出实测假阳率
核心知识点详解
  • 布隆的最优参数:最优位数组 m = −n·ln(p)/(ln2)²、最优哈希数 k = (m/n)·ln2。1e6 元素、1% 假阳率 → 约 1.14 MB、k≈7,而 Python set 要 60–100 MB,省约 50–80 倍。双哈希构造 h = h1 + i*h2 避免真算 k 次。
  • 为什么无假阴性(面试必答):布隆是「按位或」写入:已插入元素的 k 位一定全 1,所以查询某位为 0 → 一定没插入过(无假阴性);k 位全 1 可能是其他元素叠加 → 有假阳性。假阳率 ≈ (1−e^(−kn/m))^k。
  • MinHash + HyperLogLog:MinHash 用 k 个随机哈希取集合最小签名,签名相等比例是 Jaccard 的无偏估计,128 个 permutation 的标准误差约 1/√128 ≈ 8.8%;HyperLogLog 用「前导零最大长度」估基数,几 KB 估上亿个不同元素、约 2% 误差。
  • 常见坑:k 选得不对:k 太大会多置位、假阳率反升;k 太小分辨力不足。必须按 k=(m/n)·ln2 与 m=−n·ln(p)/(ln2)² 配参,别随手定。
缓存实战缓存穿透/击穿/雪崩1e6 元素 1% 只用几 MB语义缓存相似度阈值
学习路径
  1. 读 6.3:识别缓存穿透、击穿、雪崩三种故障现象
  2. 跑内置代码,用抖动与单飞测试防雪崩效果
  3. 完成综合练习:为语义缓存定相似度阈值并用数据验证
  4. 对接 M3:给检索接口接上混合缓存并报告命中率
✔ 能给三种缓存故障各写一个复现脚本并给出缓解措施
核心知识点详解
  • 三种故障对症下药:穿透(不存在键反复打后端)→ 缓存空值或布隆拦截;击穿(热点键过期瞬间)→ singleflight 同键只回源一次;雪崩(大量键同时过期)→ TTL 抖动 + 多级缓存。每种都要能写一个复现脚本再缓解。
  • 语义缓存的风险与缓解:用 embedding 相似度判定命中(如阈值 0.9),但可能把「意思相近但答案不同」的 query 误判命中(如不同年份数据)返回错答案。缓解:设较高阈值 + 命中后做关键词/实体二次校验 + 区分时效敏感答案。
  • 常见坑:缓存不加度量:只加 cache 不统计命中率,就不知道有没有用、该设多大。必须报告一次测试下的 hit rate 与容量关系,用数据定容量(Zipf 下 1–5% 键空间通常够)。
学习路径

6.1 LRU / LFU / ARC:缓存的三种哲学

这是连接「数据结构」与「成本优化」的一节。在 AI 系统里,缓存是性价比最高的优化手段:检索结果缓存、embedding 缓存、LLM 响应缓存、KV Cache、前缀缓存——每一个都能直接省钱。

python# LRU 的标准实现:哈希表 + 双向链表,两个操作都是 O(1)
# 面试高频题,必须能白板写出
class LRUCache:
    class Node:
        __slots__ = ('k', 'v', 'prev', 'next')
        def __init__(self, k=None, v=None):
            self.k, self.v, self.prev, self.next = k, v, None, None

    def __init__(self, cap):
        self.cap, self.map = cap, {}
        self.head, self.tail = self.Node(), self.Node()    # 哑头尾,省边界判断
        self.head.next, self.tail.prev = self.tail, self.head
        self.hits = self.misses = 0

    def _remove(self, n):
        n.prev.next, n.next.prev = n.next, n.prev

    def _push_front(self, n):
        n.next, n.prev = self.head.next, self.head
        self.head.next.prev = n; self.head.next = n

    def get(self, k):
        if k not in self.map:
            self.misses += 1; return None
        n = self.map[k]; self._remove(n); self._push_front(n)
        self.hits += 1; return n.v

    def put(self, k, v):
        if k in self.map:
            n = self.map[k]; n.v = v; self._remove(n); self._push_front(n); return
        if len(self.map) >= self.cap:
            lru = self.tail.prev                        # 淘汰链表尾(最久未用)
            self._remove(lru); del self.map[lru.k]
        n = self.Node(k, v); self.map[k] = n; self._push_front(n)

    @property
    def hit_rate(self):
        t = self.hits + self.misses
        return self.hits / t if t else 0.0

# ⚠️ 线程安全:上面是单线程版。并发环境要么加锁(简单但会串行化),
# 要么分片(多个 LRU 实例按 hash 分流,锁粒度降为 1/分片数),
# 要么用「近似 LRU」(Redis 的做法:随机采样 N 个键淘汰最久未用的那个)

缓存到底该设多大?答案取决于访问分布的重尾程度。检索 query、热词、用户请求都近似 Zipf 分布(少数热点吃走大部分流量)。下面的实验量化「容量 vs 命中率」的边际收益,直接决定你的缓存该配多大。

pythonimport random
from collections import OrderedDict

def zipf_stream(n, size, alpha=1.0, seed=0):
    """近似 Zipf 访问流:Web / 检索 query 的分布就是这种重尾形态"""
    weights = [1.0 / ((i + 1) ** alpha) for i in range(size)]
    total = sum(weights); cum, acc = [], 0.0
    for w in weights: acc += w / total; cum.append(acc)
    rnd = random.Random(seed)
    for _ in range(n):
        r = rnd.random(); lo, hi = 0, size - 1
        while lo < hi:
            mid = (lo + hi) // 2
            if cum[mid] < r: lo = mid + 1
            else: hi = mid
        yield lo

def lru_hit(stream, cap):
    cache, hit, miss = OrderedDict(), 0, 0
    for x in stream:
        if x in cache: hit += 1; cache.move_to_end(x)
        else:
            miss += 1; cache[x] = 1
            if len(cache) > cap: cache.popitem(last=False)
    return hit / (hit + miss)

stream = list(zipf_stream(200_000, 20_000, 1.0))
for cap in (100, 1000, 10000):
    print(f'cap={cap:>6d} ({cap/20000:.1%} 键空间)  LRU 命中率={lru_hit(stream, cap):.1%}')

# 预期(Zipf α=1):cap=0.5% → 约 45%;cap=5% → 约 70%;cap=50% → 约 85%。
# 关键结论:命中率随容量增长是「对数式」的——把容量翻 10 倍只换来几个百分点。
# 所以 LLM 响应缓存 / embedding 缓存通常只配 1–5% 键空间,
# 再大性价比骤降;真正该做的是「语义缓存」把同义 query 也命中。
策略淘汰依据优点缺点适用
FIFO进入顺序最简单可能淘汰热点冷数据流
LRU最近访问时间符合时间局部性扫描型访问会冲掉热点(缓存污染)通用
LFU访问频次抗扫描历史权重过重,新热点上不来(需老化)热度稳定
2Q分两级:最近 + 频繁兼顾两者,抗污染实现较复杂数据库缓冲池
ARC动态调整 LRU / LFU 配额自适应,效果最好实现最复杂、有专利历史高端存储
TTL + LRU时间 + 访问适合有保鲜期的数据过期清理有开销AI 响应缓存
语义缓存相似度阈值命中即省一次完整模型调用阈值难调、可能返回错答案LLM 问答
⚠
缓存的两个经典翻车点:① 缓存穿透:不存在的键被反复查询(比如不存在的商品 ID),每次都打穿到后端。解法是缓存空值(带短 TTL)或用布隆过滤器前置拦截。
② 缓存击穿 / 雪崩:热点键过期瞬间大量请求涌向同一个后端(击穿);大量键同时过期导致后端被压垮(雪崩)。解法是单飞(singleflight)让同一键只回源一次、过期时间加随机抖动、以及多级缓存。

6.2 布隆过滤器与概率数据结构:用一点错误率换巨大空间节省

概率数据结构(probabilistic data structures)的精髓是:允许一定的错误率,换来数量级的空间节省。在数据去重、URL 去重、语义缓存的场景里极其有用。

pythonimport hashlib

class BloomFilter:
    """布隆过滤器:判断「一定不存在」或「可能存在」
       特性:无假阴性(说不在就一定不在),有假阳性(说在可能不在)
       应用:缓存穿透防护、训练语料 URL/文档去重、等等"""
    def __init__(self, n_expected, fp_rate=0.01):
        import math
        # 最优位数组大小 m 与哈希个数 k
        self.m = int(-n_expected * math.log(fp_rate) / (math.log(2) ** 2))
        self.k = max(1, int(round(self.m / n_expected * math.log(2))))
        self.bits = bytearray((self.m + 7) // 8)
        self.n = 0

    def _hash(self, item):
        """双哈希构造:h = h1 + i*h2,避免真的算 k 次哈希"""
        h1 = int.from_bytes(hashlib.md5(str(item).encode()).digest()[:8], 'big')
        h2 = int.from_bytes(hashlib.sha1(str(item).encode()).digest()[:8], 'big')
        for i in range(self.k):
            yield (h1 + i * h2) % self.m

    def add(self, item):
        for p in self._hash(item): self.bits[p >> 3] |= 1 << (p & 7)
        self.n += 1

    def __contains__(self, item):
        return all(self.bits[p >> 3] & (1 << (p & 7)) for p in self._hash(item))

    @property
    def fp_rate(self):
        """实际假阳性率:约为 (1 - e^(-kn/m))^k"""
        import math
        return (1 - math.exp(-self.k * self.n / self.m)) ** self.k

bf = BloomFilter(1_000_000, 0.01)
print(f'1e6 元素、1% 假阳性 → 只需 {len(bf.bits)/1024/1024:.2f} MB,{bf.k} 个哈希')
# 对比:用 Python set 存 1e6 个字符串至少要 60–100 MB
# 这就是概率数据结构的价值:空间差 50 倍

训练语料去重靠的是 MinHash + LSH:用 k 个随机哈希把集合压成「最小哈希签名」,两个集合的签名相等的比例就是它们 Jaccard 相似度的无偏估计。下面用 128 个 permutation 实测估计误差。

pythonimport random

M = (1 << 61) - 1

def shingles(text, k=5):
    return {text[i:i+k] for i in range(len(text) - k + 1)}

def make_minhash(num_perm=128, seed=0):
    rnd = random.Random(seed)
    a = [rnd.randrange(1, M) for _ in range(num_perm)]
    b = [rnd.randrange(0, M) for _ in range(num_perm)]
    return a, b

A, B = make_minhash(128)
def sig(s, a=A, b=B):
    return [min(((aa * hash(x) + bb) % M) for x in s) for aa, bb in zip(a, b)]

def jaccard_est(s1, s2):
    s1, s2 = sig(s1), sig(s2)
    return sum(x == y for x, y in zip(s1, s2)) / len(s1)

def exact(a, b): return len(a & b) / len(a | b)

d1 = shingles('the quick brown fox jumps over the lazy dog ' * 3)
d2 = shingles('the quick brown fox jumps over a lazy dog ' * 3)   # 只差一个词
d3 = shingles('completely different content about ai systems ' * 3)
print(f'近似对 minhash={jaccard_est(d1,d2):.3f}  exact={exact(d1,d2):.3f}')
print(f'无关对 minhash={jaccard_est(d1,d3):.3f}  exact={exact(d1,d3):.3f}')

# 预期:近似对的估计值 ≈ exact(差距在 ±0.09 内);无关对 ≈ 0。
# 128 个 permutation 的估计标准误差 ≈ 1/sqrt(128) ≈ 8.8%。
# LSH 分桶(b 段 × r 行,b·r=128)把 O(n²) 两两比对降到近似 O(n),
# 是训练语料去重的工业标准(常见阈值:Jaccard > 0.8 判为重复并剔除)。
结构回答什么问题错误类型空间AI 中的用途
布隆过滤器元素是否存在假阳性(无假阴性)极小缓存穿透、语料去重
Count-Min Sketch元素出现多少次高估(不会低估)小流量热点统计、词频近似
HyperLogLog有多少个不同元素约 2% 相对误差常数级(几 KB)去重后规模估算、AB 实验
MinHash + LSH两个集合是否近似相同可调小近重复文档检测(训练数据去重)
SimHash文档指纹可调极小网页去重、大规模相似检索

6.3 动手练习与自测

✔
判据:每题都要给出「命中率 / 空间 / 假阳性率」中的一个可测数字。
  1. 给你的 LRU 缓存加上 TTL(过期时间)与 singleflight(同一键只回源一次)。说明两者分别防的是「缓存击穿」还是「缓存雪崩」,并给出过期时间加抖动的具体做法。
  2. 用 Zipf 访问流对比 LRU 与 LFU 的命中率(容量相同),说明为什么「扫描型访问」会让 LRU 掉命中率,以及 LFU 如何应对但会带来什么问题。
  3. 设计一个布隆过滤器:预期插入 100 万条、要求假阳性率 ≤ 1%。算出需要的位数 m 与哈希个数 k,以及内存占用(与 Python set 对比)。
  4. 用 MinHash(128 个 permutation)估算两段文本的 Jaccard 相似度,说明估计的标准误差;若要保证阈值 0.8 去重,LSH 的 b 与 r 怎么选?
  5. (开放题)「语义缓存」用 embedding 相似度判定命中,相比精确键缓存有哪些风险?如何用阈值 + 二次校验降低风险?
题号通过标准目标耗时
1TTL / 击穿(singleflight)/ 雪崩(TTL 抖动)三者能区分并各自给出对策8 分钟
2能说明扫描型负载为何冲垮 LRU,以及 LFU 需要老化8 分钟
3能用公式算出 m≈1.14 MB、k≈7,并与 set 的 60–100 MB 对比8 分钟
4能给出 b=16 / r=8 的 band 参数与 1/√128≈8.8% 的误差8 分钟
5能指出语义缓存的误命中风险与缓解手段6 分钟
★
参考答案与判据:① TTL 防的是键长期不变导致的陈旧数据;击穿指热点键过期的瞬间大量请求打到后端 → 用 singleflight 让同键只回源一次;雪崩指大量键同时过期 → 给 TTL 加随机抖动(如 base + rand(0, 10%))。
② Zipf 访问下 LRU 命中率高;扫描型(顺序遍历少见数据)会把 LRU 的热点全部冲掉,命中率骤降;LFU 按频次淘汰、抗扫描,但历史权重过重会让新热点上不来,需要「频次随时间衰减」的老化机制。
③ k=(m/n)·ln2;m=−n·ln(p)/(ln2)²。n=1e6、p=0.01 → m≈9.6e6 位 ≈ 1.14 MB,k≈7。对比 Python set 约 60–100 MB,省约 50–80 倍。
④ 128 permutation 的标准误差 ≈ 1/√128 ≈ 8.8%;去重阈值 0.8 时,LSH 常用 b=16 段、r=8 行(b·r=128),使相似度 >0.8 的对以高概率落入同一桶。
⑤ 语义缓存可能把「意思相近但答案不同」的 query 误判为命中(如不同年份的数据),返回错误答案。缓解:设较高的相似度阈值、命中后做轻量二次校验(如关键词/实体一致性),并区分「可复用答案」与「时效敏感答案」。

7. 字符串算法与 BPE 分词

知识结构图 · 字符串算法与 BPE 分词
字符串算法与 BPE 分词4 大知识域 · 26 个知识点
字符串匹配KMP next 数组 O(n+m)滚动哈希 O(1) 更新Aho-Corasick Trie+fail失败指针 BFS 构造单次扫描 O(n+匹配数)双哈希防碰撞
学习路径
  1. 读 7.1:理解 KMP 的 next 数组与滚动哈希的 O(1) 更新
  2. 跑内置代码,实现 KMP 并验证其匹配到正确位置
  3. 完成练习自测:用 Aho-Corasick 一次扫描 1 万模式并测加速比
  4. 对接 M3:把关键字匹配用到检索的查询改写环节
✔ 能默写 KMP 的 next 构造并解释其线性性的来源
核心知识点详解
  • KMP next 的线性性:next 数组 = 最长相等前后缀长度,构造 O(m)、匹配 O(n),文本指针永不回退。核心是「不浪费已匹配信息」,在 Z 算法、Manacher、AC 里反复出现。记忆点 while j and pat[i]!=pat[j]: j = nxt[j-1]。
  • 滚动哈希 O(1) 更新:Rabin-Karp 把子串哈希成整数:h = (h*base + ord(ch)) % mod,跨窗减去即可 O(1) 更新。是近似匹配/去重/指纹的基础。注意哈希碰撞——竞赛用双哈希或大素数模(如 (1<<61)−1),工程接受可控碰撞率。
  • Aho-Corasick 一次扫描多模式:Trie + fail 指针(BFS 构造),扫描 O(n+匹配数)。1 万模式对 10 万字符文本,单条过滤保持 O(n);对比 1 万次 str.find 的 O(k·n) 可差 10× 以上。敏感词过滤、Prompt 注入拦截用它。
  • 常见坑:边界与重叠匹配:子串匹配要处理模式为空、文本指针越界、重叠匹配等边界。KMP 把重叠当作新匹配,先明确语义再写。单模式用 KMP / BM,多模式用 AC。
前缀后缀结构双数组 Trie DATRadix Tree / Patricia后缀数组 SA + LCP后缀自动机 SAMZ 算法线性求 LCPFM-index 压缩检索
学习路径
  1. 读 7.2:理解后缀数组 SA 与 LCP,以及 Z 算法的线性求 LCP
  2. 跑内置代码,构造后缀数组并查询最长公共前缀
  3. 完成练习自测:用 LCP 求最长重复子串
  4. 对接 M3:评估用后缀结构做子串前缀缓存的可行性
✔ 能讲清后缀数组与 LCP 的关系,并做一次最长重复子串查询
核心知识点详解
  • 压缩 Trie 族:DAT 把 Trie 压成两个整数数组、查询变纯数组下标,是中文分词器与输入法词典常用;Radix / Patricia 压缩单链路径,用于路由与 URL 前缀匹配。
  • 后缀数组 + LCP:后缀数组 = 所有后缀排序后的下标数组,倍增构造 O(n log²n)(SA-IS 可达 O(n))。配合 LCP 数组可 O(log n) 回答子串是否出现、线性求最长重复子串与不同子串数,用于训练语料重复片段挖掘。
  • FM-index / BWT:后缀数组 + Burrows-Wheeler 变换支持在压缩文本上做子串检索、内存极小;bzip2 与基因组比对器(BWA)建立在它之上。超大只读语料模式检索时,FM-index 类比内存倒排更省资源。
  • 常见坑:选错结构:单模式 → KMP;多模式 → AC;子串统计 → SA/SAM;前缀检索 → Trie/DAT。把「需要子串统计」的需求误用 Trie 会写得很费。
BPE 分词合并最高频相邻对byte-level 无 OOV 结束标记词表 32k–256kGPT-2 50257 / Llama3 128256Qwen3 约 151000中文 2.7 字节/字符中文 token 多 50%
学习路径
  1. 读 7.3:掌握合并最高频相邻对与 byte-level 无 OOV 的思路
  2. 跑内置代码,实现 BPE 合并并打印词表增长过程
  3. 完成练习自测:与 HuggingFace 分词器逐词对齐输出
  4. 对接 M3:用 BPE 观察中文 token 数比英文多约 50% 的账
✔ 能把自研词表输出对齐到 HF tokenizer,并算清中英 token 差距
核心知识点详解
  • 贪心合并最高频对:BPE 反复合并语料里最高频的相邻符号对直到词表达标:预分词加 词尾标记 → 统计相邻对(按词频加权)→ 合并 most_common(1) → 重写词。高频词变单 token、罕见词碎成子词或字节。
  • byte-level 为什么零 OOV:先把字符转成 UTF-8 字节再合并,字节集只有 256 个,任何字符都可表示 → 无未知词。代价是非 ASCII 更贵:中文约 2.7 字节/字符、emoji 约 4、英文约 1 → 同一语义中文 token 常多 50% 以上,直接推高成本与延迟。
  • 词表是成本旋钮:主流 32k–256k;GPT-2 50257、Llama3 128256、Qwen3 约 151000。词表越大序列越短(推理越快)但 embedding/输出层参数越多、稀有 token 训练不充分。对标 HF tokenizer 逐 token 对齐,调不通多半是预分词/特殊 token/合并顺序差异。
  • 常见坑:中文用 split() 处理:text.split() 处理中文会把句子拆成整串散乱符号,让 BM25 统计失真。BPE 也要按「词 + 词尾标记」预分词;中文常用独立预分词规则,否则合并效果差。
综合练习AC 1 万模式 10× 加速最长重复子串与 HF tokenizer 对齐
学习路径
  1. 读 7.4:汇总三类字符串结构的适用边界
  2. 跑内置代码,完成与 HF tokenizer 对齐的回归用例
  3. 完成综合练习:测 AC 自动机 1 万模式的加速比
  4. 对接 M3:把对齐后的分词器接进检索的索引构建
✔ 能闭卷写出 KMP 或 AC 的核心逻辑并说出各自复杂度
核心知识点详解
  • AC 加速的定量:预处理 O(总模式长)、扫描 O(n+匹配数)。1 万次 str.find 最坏 O(k·n) = 1e9 量级,AC 是 O(n)=1e5 量级,加速常 10×+,模式越多优势越大。
  • SA+LCP 求最长重复子串:建后缀数组 + 相邻后缀的 LCP,取最大 LCP 对应子串即最长重复子串;复杂度由 SA 构造主导(O(n log²n) 或线性)。FM-index 用 BWT 压缩存储,检索在压缩域进行,省一个数量级内存。
  • 常见坑:只顾实现忘了对齐判据:分词实现对错要用真实语料与 HuggingFace tokenizer 对比逐 token 输出,不能只看自己的输出「合理」——差在预分词规则与特殊 token 处理上。
学习路径

7.1 字符串匹配:KMP、滚动哈希与自动机

pythondef kmp_search(text, pat):
    """KMP:O(n+m)。核心是 next 数组(最长相等前后缀)—— 利用已匹配信息避免回溯"""
    if not pat: return 0
    nxt, j = [0] * len(pat), 0
    for i in range(1, len(pat)):                 # 构造 next
        while j and pat[i] != pat[j]: j = nxt[j - 1]
        if pat[i] == pat[j]: j += 1
        nxt[i] = j
    j = 0
    for i, ch in enumerate(text):                # 匹配:文本指针永不回退
        while j and ch != pat[j]: j = nxt[j - 1]
        if ch == pat[j]:
            j += 1
            if j == len(pat): return i - j + 1
    return -1

# 滚动哈希(Rabin-Karp):把子串哈希成整数,O(1) 滑动更新,用于多模式 / 近似匹配
def rolling_hashes(s, k, base=131, mod=(1 << 61) - 1):
    h, pw, out = 0, pow(base, k - 1, mod), []
    for i, ch in enumerate(s):
        h = (h * base + ord(ch)) % mod
        if i >= k: h = (h - ord(s[i - k]) * pw) % mod
        if i >= k - 1: out.append((i - k + 1, h))
    return out

# Aho-Corasick:多模式匹配自动机。一次扫描找出所有敏感词/关键词
# 本质是 Trie + 失败指针(BFS 构造)。生产级敏感词过滤都是它的变体。
# 结构:goto 表(Trie)+ fail 指针(等价于 KMP 的 next 在多模式下的推广)+ output 集合

把 Aho-Corasick 写出来(Trie + 失败指针),你就能理解「为什么敏感词过滤、Prompt 注入关键词拦截能做到一次线性扫描」。它也是「引用来源定位」在长文本里找所有原文片段的标准工具。

pythonfrom collections import deque

class AhoCorasick:
    """多模式匹配自动机:Trie + fail 指针(BFS 构造)。扫描一次 O(n + 匹配数)"""
    def __init__(self, pats):
        self.nxt = [{}]; self.fail = [0]; self.out = [[]]
        for p in pats:
            u = 0
            for ch in p:
                if ch not in self.nxt[u]:
                    self.nxt[u][ch] = len(self.nxt)
                    self.nxt.append({}); self.fail.append(0); self.out.append([])
                u = self.nxt[u][ch]
            self.out[u].append(p)
        q = deque()
        for ch, v in self.nxt[0].items(): q.append(v)      # 第一层 fail 指向根
        while q:
            u = q.popleft()
            for ch, v in self.nxt[u].items():
                f = self.fail[u]
                while f and ch not in self.nxt[f]: f = self.fail[f]
                self.fail[v] = self.nxt[f].get(ch, 0)       # 失败指针
                self.out[v] += self.out[self.fail[v]]       # 合并输出(处理后缀模式)
                q.append(v)

    def search(self, text):
        u, hits = 0, []
        for i, ch in enumerate(text):
            while u and ch not in self.nxt[u]: u = self.fail[u]
            u = self.nxt[u].get(ch, 0)
            for p in self.out[u]: hits.append((i - len(p) + 1, p))
        return hits

ac = AhoCorasick(['he', 'she', 'his', 'hers'])
print(sorted(ac.search('ushers')))
# 预期:[(1, 'she'), (2, 'he'), (2, 'hers')]
# 一次线性扫描找出全部模式;若用 k 个模式各自 KMP,则要 O(k·n)。
# 生产级敏感词库常有 1 万+ 词,AC 让单条请求的过滤保持 O(n)。

7.2 前缀与后缀结构:从自动补全到后缀数组

后缀数组(Suffix Array)是「把字符串所有后缀排序后的下标数组」,配合 LCP 数组能回答任意子串查询、最长重复子串、不同子串计数。它在语料重复片段挖掘与生物序列分析里是一等公民。

pythondef suffix_array(s):
    """倍增法构造后缀数组:O(n log^2 n)(生产用 SA-IS 可到 O(n))"""
    n = len(s)
    sa = list(range(n)); rank = list(map(ord, s)); k = 1
    while k < n:
        sa.sort(key=lambda i: (rank[i], rank[i + k] if i + k < n else -1))
        tmp = [0] * n
        for i in range(1, n):
            a, b = sa[i - 1], sa[i]
            ka = (rank[a], rank[a + k] if a + k < n else -1)
            kb = (rank[b], rank[b + k] if b + k < n else -1)
            tmp[b] = tmp[a] + (ka < kb)
        rank = tmp
        if rank[sa[-1]] == n - 1: break
        k *= 2
    return sa

s = 'banana'
print('后缀数组 SA =', suffix_array(s))
print('排序后的后缀  =', sorted(s[i:] for i in range(len(s))))
# 预期 SA = [5, 3, 1, 0, 4, 2](后缀 a, ana, anana, banana, na, nana)
# 复杂度:倍增法 O(n log^2 n);SA-IS 可线性构造。
# 配合 LCP 数组可 O(1) 回答「任意两后缀的公共前缀长度」,从而线性求出
# 「最长重复子串」「不同子串个数」;FM-index(BWT + SA)更支持
# 「用极省内存做子串检索」,是压缩文本检索与基因组比对的基石。

7.3 BPE 分词:亲手实现一遍,你就懂 token 是怎么回事

BPE(Byte Pair Encoding)是所有现代 LLM 分词器的算法基础(GPT / Llama / Qwen 全用它或它的变体)。亲手实现一遍,你对「一个 token 到底等于几个字 / 几个字节」「为什么中文比英文贵」「为什么罕见字会碎成字节」的理解会彻底改变。

pythonfrom collections import Counter

def train_bpe(text, vocab_size=500):
    """训练 BPE:反复合并最高频的相邻符号对,直到达到目标词表大小"""
    # ① 预分词:按空格切词,并给每次出现加结束标记(让「词尾」成为可学习信息)
    words = Counter()
    for w in text.split():
        words[tuple(w) + ('</w>',)] += 1

    vocab = {ch for w in words for ch in w}
    merges = []
    while len(vocab) < vocab_size:
        # ② 统计所有相邻对的出现频次(按词频加权)
        pairs = Counter()
        for symbols, freq in words.items():
            for i in range(len(symbols) - 1):
                pairs[(symbols[i], symbols[i + 1])] += freq
        if not pairs: break
        (a, b), _ = pairs.most_common(1)[0]        # 贪心:合并最高频对
        merges.append((a, b)); vocab.add(a + b)
        # ③ 用新符号重写所有词
        new_words = Counter()
        for symbols, freq in words.items():
            out, i = [], 0
            while i < len(symbols):
                if i < len(symbols) - 1 and symbols[i] == a and symbols[i + 1] == b:
                    out.append(a + b); i += 2
                else:
                    out.append(symbols[i]); i += 1
            new_words[tuple(out)] += freq
        words = new_words
    return merges, vocab

def apply_bpe(word, merges):
    """编码:按「合并优先级」依次应用(注意顺序敏感,必须按训练顺序)"""
    symbols = list(word) + ['</w>']
    for a, b in merges:
        out, i = [], 0
        while i < len(symbols):
            if i < len(symbols) - 1 and symbols[i] == a and symbols[i + 1] == b:
                out.append(a + b); i += 2
            else:
                out.append(symbols[i]); i += 1
        symbols = out
    return symbols

# 训练后观察:高频词变成单个 token,罕见词碎成子词或字节
merges, vocab = train_bpe('low lower lowest new newer newest wide wider widest ' * 200, 120)
print('merges 前 8:', merges[:8])
print("'lowest' →", apply_bpe('lowest', merges))
print("'newer'  →", apply_bpe('newer', merges))

为什么 Byte-level BPE 能做到「零 OOV」?因为任何字符都能先转成 UTF-8 字节,字节集只有 256 个。但这也带来代价:非 ASCII 字符会消耗更多字节 → 更多 token → 更贵。下面把「字节/字符」量出来。

pythonsamples = {
    '英文短句': 'The transformer architecture changed everything',
    '中文短句': 'Transformer 架构改变了一切',
    '罕见emoji': '🧬🔬🧪',
    '代码片段': 'def f(x): return x ** 2  # 平方',
}
for k, v in samples.items():
    b = v.encode('utf-8')
    print(f'{k:8s} 字符={len(v):3d}  UTF-8 字节={len(b):3d}  字节/字符={len(b)/len(v):.2f}')

# 预期输出:
#   英文短句  字符=47  字节=47  字节/字符≈1.00
#   中文短句  字符=22  字节=59  字节/字符≈2.68
#   罕见emoji 字符=3   字节=12  字节/字符≈4.00
#   代码片段  字符=31  字节=32  字节/字符≈1.03
# → 同一句语义,中文的字节数约为英文的 2.7 倍;
#   叠加主流 tokenizer 对中文的压缩率偏低,实测 token 数常多 50% 以上。
分词器 / 模型词表大小类型备注
GPT-250,257byte-level BPE现代 byte-level BPE 的起点
Llama 3128,256BPE对多语言与代码做了扩充
Qwen3约 151,000BPE中文/多语言覆盖显著更好
DeepSeek-V3约 128,000BPE含大量代码与中文 token
Llama 4约 200,000+BPE更大词表换取更短序列
ℹ
2026 的一线经验:「中文 token 成本」不是玄学:词表里中文 token 的覆盖度直接决定同一句话的 token 数。Qwen3 等以中文为主的模型在同义中文上通常比英文为主的模型省 20–40% token。此外,词表越大序列越短(推理越快)但 embedding/输出层参数越多,且稀有 token 训练不充分——这是 128k 与 256k 之争的核心取舍。务实做法:优先选对你的语料压缩率高的 tokenizer,并做领域词表扩充。

7.4 动手练习与自测

✔
判据:每题都要给出「复杂度 + 一个可跑例子(含预期输出)」。
  1. 实现 Aho-Corasick,在 1 万个模式上对一段 10 万字符文本做匹配,测出耗时,并与「1 万次 str.find / KMP」对比,给出加速比与原因。
  2. 说明 KMP 的 next 数组与 AC 的 fail 指针在思想上有什么共同点;为什么文本指针在 KMP 中永不回退?
  3. 用后缀数组 + LCP 求字符串 s 的「最长重复子串」,给出算法步骤与复杂度;再说明 FM-index 相对它的省内存优势。
  4. 解释 Byte-level BPE 为什么没有 OOV,并给出「中文短句 vs 英文短句」的字节/字符实测数字。
  5. 对比两个 tokenizer(如 GPT-2 与 Qwen3)在同一段中英文 + 代码混合语料上的 token 数,说明差异来源与对成本/推理速度的影响。
题号通过标准目标耗时
1AC 预处理 O(总模式长)、扫描 O(n+匹配数),能估算 10× 以上加速10 分钟
2能说清 KMP 的 next 与 AC 的 fail 的对应关系(都是「后缀接前缀」)8 分钟
3能说明 SA + LCP 求最长重复子串,以及 FM-index 的压缩检索优势8 分钟
4能解释 byte-level BPE 无未知词,并给出中文 ≈2.7 字节/字符6 分钟
5能把 token 数差异归因到词表覆盖与预分词,并联系上下文与费用6 分钟
★
参考答案与判据:① AC 预处理 O(总模式长)、扫描 O(n + 匹配数);1 万次 str.find 最坏 O(k·n) = 1e9 量级,AC 是 1e5 量级,加速常在 10× 以上(模式越多优势越大)。
② 共同点都是「用已匹配的前缀信息避免从头再来」:KMP 的 next 是「当前后缀与模式前缀的最长匹配」,AC 的 fail 是「当前路径的后缀在 Trie 中最长的可接前缀」。KMP 文本指针不回退,是因为已匹配部分蕴含的等价信息被 next 编码了。
③ 建 SA 后求相邻后缀的 LCP,取最大 LCP 对应的子串即最长重复子串;复杂度受 SA 构造主导(O(n log^2 n) 或 O(n))。FM-index 用 BWT 把文本压缩存储,检索在压缩域进行,内存可省一个数量级,适合超大只读语料。
④ 因为任何字符都能先编码成 UTF-8 字节(256 个符号都在词表里),故不存在未知词;实测中文约 2.7 字节/字符、emoji 约 4 字节/字符、英文约 1 字节/字符。
⑤ 以中文为主的 tokenizer 词表里中文 token 更多,同一中文句子 token 数更少;差异来源是词表覆盖与预分词规则。token 越少 → 上下文能装更多内容、推理更快、费用更低。

8. 动态规划与序列对齐

知识结构图 · 动态规划与序列对齐
动态规划与序列对齐3 大知识域 · 24 个知识点
DP 建模框架最优子结构 + 重叠子问题五步状态定义法LIS O(n log n) tails0/1 背包容量逆序完全背包正序区间 DP 按长度状态压缩 TSP 2^n·n股票状态机 hold/sold/rest
学习路径
  1. 读 8.1:用五步状态定义法拆一道 0/1 背包
  2. 跑内置代码,写出 0/1 背包逆序与完全背包正序的对照
  3. 完成练习自测:解释遍历方向为什么决定取物品的次数
  4. 对接 M3:用 LIS 去包装检索结果排序的单调性
✔ 能讲清为什么 0/1 背包要逆序遍历而又为何正确
核心知识点详解
  • 0/1 与完全背包只差遍历方向:同是 for c in range(...):0/1 背包容量逆序 range(cap, w-1, -1)(保证每个物品只用一次,因 dp[c-w] 尚未被本轮更新);完全背包正序 range(w, cap+1)(物品可复用)。写错方向是最常见失分点。
  • 复杂度表要会算:背包 O(n·cap);LIS O(n log n)(贪心 + 二分 tails);区间 DP 按长度递增填表、状态 O(n²);状态压缩 TSP 状态数 2^n·n、转移 O(n),总 O(2^n·n²),n≈20 到边界、n>22 不可行。
  • 状态机 DP:股票含冷冻期用 hold/sold/rest 三状态:hold=max(hold, rest-p)、sold=hold+p、rest=max(rest, sold),O(n)/O(1) 空间。「能用一句话说清 dp[i] 是什么」是 DP 最易错又最关键的一步。
  • 常见坑:把不可达状态初始化为 0:不可达状态要用 ±∞ 或负大,用 0 会让「虚假可行」路径污染结果,例如求最大值时 0 被误当合法起点。
序列对齐编辑距离 Levenshtein加权 ic/dc/scNeedleman-Wunsch 罚分WER 词错率LCS O(min(n,m)) 空间Hirschberg 分治LCS 回溯 diffROUGE 对齐
学习路径
  1. 读 8.2:理解编辑距离与加权 ic/dc/sc 罚分
  2. 跑内置代码,实现 Needleman-Wunsch 并打印对齐路径
  3. 完成练习自测:用 WER 或 ROUGE 对齐做一次评测冒烟
  4. 对接 M3:把 LCS 或 Hirschberg 用到检索答案与参考的比对
✔ 能推导编辑距离的 DP 表并读出回溯得到的对齐序列
核心知识点详解
  • 编辑距离 DP 表:dp[i][j] = min(dp[i-1][j]+dc, dp[i][j-1]+ic, dp[i-1][j-1]+(0 或 sc)),时间 O(n·m)、空间 O(n·m)。WER = 编辑距离 / 参考词数,是 ASR 标准指标。加权 ic/dc/sc 让替换与插入代价可区分(ASR 里漏词比错词更严重)。
  • 空间优化是常态:只需距离时用两行压到 O(min(n,m)) 空间;要回溯对齐结果得存完整表或 Hirschberg 分治(O(min(n,m)) 空间 + O(n·m) 时间)。LCS 同理,diff 用 LCS 回溯生成 =/+/- 变更。
  • 与语义相似度的分工:编辑距离衡量字面差异,对同义改写完全失效(「我今天很开心」vs「今天心情不错」字面距离很大)。RAG 评测要两者结合:字面(编辑距离/ROUGE)+ 语义(embedding/LLM-as-Judge),定位漏检与幻觉。
  • 常见坑:代价与粒度不对称:ic/dc/sc 不同类时不对称,WER 一定用 dc=ic=sc=1 才公允;word 级 vs char 级混用会得到反直觉数字,先规范化大小写与标点再对齐。
综合练习背包遍历方向差异字面 vs 语义相似度
学习路径
  1. 读 8.3:汇总背包、区间、状态压缩三类 DP 的切分点选择
  2. 跑内置代码,把区间 DP 按长度递增填表
  3. 完成综合练习:区分字面相似度与语义相似度的用途
  4. 对接 M3:选一个序列对齐问题落地到检索排序的评测
✔ 能识别题目属于哪类 DP 并直接给出状态定义与复杂度
核心知识点详解
  • 遍历方向的推导:0/1 逆序保证 dp[c-w] 是「上一轮」的(未用当前物品);完全正序让 dp[c-w] 已含当前物品(可复用)。手算 items=[(2,3)]、cap=4:0/1 得 3、完全得 6 即体现差异;cap=5 时 items=[(2,3),(3,4)] 对应 0/1 得 7、完全得 8。
  • 判据要能双重验证:每个答案都要手算 + 代码结果一致,而不是「跑通就行」。例:股票 [1,2,3,0,2] → 3;LIS、区间回文子序列都能手算核验。
  • 常见坑:word 级与 char 级混用:WER 按词、编辑距离按字符,混用会得到反直觉数字。先明确粒度,再算正确/插入/删除/替换数,并区分字面 vs 语义相似度的用途。
学习路径

8.1 动态规划:识别与建模

DP 的难点不在写代码,而在识别与定义状态。给你一个可靠的思考框架:

① 判断是否适合 DP
问题要能拆成子问题(最优子结构),且子问题会被重复求解(重叠子问题)。两者都满足才值得用 DP;只有前者用分治或贪心。
② 定义状态
「dp[i] 表示什么」必须能用一句话说清,并且这句话要能推出转移。这是 DP 最容易出错的一步。
③ 写转移方程
考虑「最后一步」的决策:第 i 个元素选或不选、从哪里转移来。写出 dp[i] = max/min(若干候选)。
④ 确定遍历顺序
保证用到的状态都已经算过。多数是正序;背包问题容量维要逆序(0/1 背包);区间 DP 要按长度从小到大。
⑤ 处理边界与答案位置
初始化要合理(不可达状态用 ±∞ 或 0,不要随便用 0);答案可能在 dp[n] 也可能要遍历所有状态取极值。
python# 一维 DP:最长递增子序列 O(n log n)(贪心 + 二分,比 O(n²) 优雅得多)
import bisect
def lis(nums):
    tails = []                        # tails[i] = 长度为 i+1 的递增子序列的最小结尾
    for x in nums:
        i = bisect.bisect_left(tails, x)
        if i == len(tails): tails.append(x)
        else: tails[i] = x
    return len(tails)

# 二维 DP:0/1 背包(容量维逆序是关键)
def knapsack(items, cap):
    dp = [0] * (cap + 1)
    for w, v in items:
        for c in range(cap, w - 1, -1):       # ← 逆序,保证每个物品只用一次
            dp[c] = max(dp[c], dp[c - w] + v)
    return dp[cap]

# 区间 DP:最长回文子序列
def lps(s):
    n = len(s)
    dp = [[1] * n for _ in range(n)]
    for i in range(n): dp[i][i] = 1
    for length in range(2, n + 1):            # 按长度从小到大
        for i in range(n - length + 1):
            j = i + length - 1
            dp[i][j] = dp[i+1][j-1] + 2 if s[i] == s[j] else max(dp[i+1][j], dp[i][j-1])
    return dp[0][n-1] if n else 0

# 状态压缩 DP:用位掩码表示集合(TSP、以及很多调度问题的原型)
def tsp(dist):
    n = len(dist); FULL = 1 << n; INF = float('inf')
    dp = [[INF] * n for _ in range(FULL)]
    dp[1][0] = 0
    for mask in range(FULL):
        for u in range(n):
            if dp[mask][u] == INF: continue
            for v in range(n):
                if mask >> v & 1: continue
                nm = mask | (1 << v)
                dp[nm][v] = min(dp[nm][v], dp[mask][u] + dist[u][v])
    return min(dp[FULL-1][u] + dist[u][0] for u in range(n))

再补三个高频 DP 变体:0/1 背包 vs 完全背包只差容量维遍历方向;股票状态机 DP;以及「状态用数组表示」的通用套路。

python# 0/1 背包 vs 完全背包:唯一区别是容量维遍历方向
def knap_01(items, cap):
    dp = [0] * (cap + 1)
    for w, v in items:
        for c in range(cap, w - 1, -1):          # 逆序 → 每个物品只用一次
            dp[c] = max(dp[c], dp[c - w] + v)
    return dp[cap]

def knap_complete(items, cap):
    dp = [0] * (cap + 1)
    for w, v in items:
        for c in range(w, cap + 1):              # 正序 → 物品可重复使用
            dp[c] = max(dp[c], dp[c - w] + v)
    return dp[cap]

def stock_with_cooldown(prices):
    """股票含冷冻期:状态机 DP,三个状态 hold / sold / rest"""
    hold, sold, rest = float('-inf'), 0, 0
    for p in prices:
        hold, sold, rest = max(hold, rest - p), hold + p, max(rest, sold)
    return max(sold, rest)

items = [(2,3),(3,4),(4,5),(5,6)]
print('0/1 背包 cap=5 :', knap_01(items, 5))       # 7 = (2,3)+(3,4)
print('完全背包 cap=5:', knap_complete(items, 5))   # 8 = (2,3)+(3,4) 之外的更优组合
print('股票 [1,2,3,0,2]:', stock_with_cooldown([1,2,3,0,2]))  # 3
# 复杂度:背包 O(n·cap);股票 O(n)、空间 O(1)。
# 面试提醒:逆序/正序是 0/1 与完全背包的唯一区别,写错方向是最常见的失分点。

8.2 编辑距离与序列对齐:从拼写纠错到 RAG 评测

编辑距离(Levenshtein Distance)是 DP 最经典的应用,也是你在 AI 评测里会天天用到的工具:拼写纠错、ASR 词错率(WER)、摘要评估(ROUGE 的变体)、以及生成文本与参考答案的相似度。

pythondef edit_distance(a, b, ic=1, dc=1, sc=1):
    """编辑距离:返回 (距离, 对齐路径)。ic/dc/sc 分别可调插入/删除/替换代价
       —— 加权版本在 ASR 评测与生物序列比对中很常用"""
    n, m = len(a), len(b)
    dp = [[0] * (m + 1) for _ in range(n + 1)]
    for i in range(n + 1): dp[i][0] = i * dc
    for j in range(m + 1): dp[0][j] = j * ic
    for i in range(1, n + 1):
        for j in range(1, m + 1):
            dp[i][j] = min(
                dp[i-1][j] + dc,                                   # 删除
                dp[i][j-1] + ic,                                   # 插入
                dp[i-1][j-1] + (0 if a[i-1] == b[j-1] else sc),    # 匹配/替换
            )
    # 回溯出对齐路径:这一步才是「工程上有用」的部分
    i, j, ops = n, m, []
    while i or j:
        if i and j and dp[i][j] == dp[i-1][j-1] + (0 if a[i-1] == b[j-1] else sc):
            if a[i-1] != b[j-1]: ops.append(('sub', i-1, j-1, a[i-1], b[j-1]))
            i, j = i - 1, j - 1
        elif i and dp[i][j] == dp[i-1][j] + dc:
            ops.append(('del', i-1, None, a[i-1], None)); i -= 1
        else:
            ops.append(('ins', None, j-1, None, b[j-1])); j -= 1
    return dp[n][m], list(reversed(ops))

def wer(ref, hyp):
    """词错率(ASR 标准指标)= 编辑距离 / 参考词数"""
    r, h = ref.split(), hyp.split()
    d, _ = edit_distance(r, h)
    return d / max(1, len(r))

def lcs_length(a, b):
    """最长公共子序列 + 空间优化到 O(min(n,m))"""
    if len(a) < len(b): a, b = b, a
    prev = [0] * (len(b) + 1)
    for x in a:
        cur = [0]
        for j, y in enumerate(b, 1):
            cur.append(prev[j-1] + 1 if x == y else max(prev[j], cur[j-1]))
        prev = cur
    return prev[-1]

编辑距离的「近亲」是 diff:用 LCS 回溯生成「新增 / 删除 / 保留」序列。这是文本比对、以及 RAG 里「生成引用 vs 原文」忠实度核查的底层算法。

pythondef diff(a, b):
    """用 LCS 回溯生成 diff:定位「新增(-/+)、保留(=)」"""
    n, m = len(a), len(b)
    dp = [[0] * (m + 1) for _ in range(n + 1)]
    for i in range(1, n + 1):
        for j in range(1, m + 1):
            dp[i][j] = dp[i-1][j-1] + 1 if a[i-1] == b[j-1] else max(dp[i-1][j], dp[i][j-1])
    i, j, out = n, m, []
    while i and j:
        if a[i-1] == b[j-1]: out.append(('=', a[i-1])); i -= 1; j -= 1
        elif dp[i-1][j] >= dp[i][j-1]: out.append(('-', a[i-1])); i -= 1
        else: out.append(('+', b[j-1])); j -= 1
    while i: out.append(('-', a[i-1])); i -= 1
    while j: out.append(('+', b[j-1])); j -= 1
    return list(reversed(out))

print(diff('kitten', 'sitting'))
print('LCS 长度 =', sum(1 for op, _ in diff('kitten', 'sitting') if op == '='))
# 预期:k→s、e→i 为替换(用 -/+ 表示),末尾 +g;LCS 长度 = 4(itten)。
# 复杂度:时间 O(n·m)、空间 O(n·m);大文本用 Hirschberg(分治)压到 O(min(n,m)) 且可回溯。
# 工程对接:ASR 的 WER、ROUGE 的 n-gram 对齐、以及引用忠实度核查都用这套序列对齐。

8.3 动手练习与自测

✔
判据:每题都要写出「状态定义 + 转移方程 + 遍历方向 + 一个手算结果」。
  1. 用同一份 items 分别跑 0/1 背包与完全背包,指出唯一差异(容量维遍历方向),并说明为什么方向不同就代表「物品用一次 / 可重复用」。
  2. 实现编辑距离并回溯对齐路径;再把它改成 O(min(n,m)) 空间(不回溯版本),说明什么时候必须用 Hirschberg。
  3. 用 LCS 实现一个简易 diff,把「kitten → sitting」的对齐结果打印出来,标出替换与新增。
  4. 状态压缩 DP:用位掩码写 TSP 的 DP,说明状态数 2^n·n 与转移 O(n),并指出 n 到什么规模就不实用了。
  5. (开放题)编辑距离与 embedding 相似度各衡量什么?在 RAG 评测里如何组合使用来定位「漏检 / 幻觉」?
题号通过标准目标耗时
1能算出 0/1 与完全背包的手算差异,并指出唯一差异是遍历方向8 分钟
2能写出 O(min(n,m)) 压缩版,并说明何时必须用 Hirschberg10 分钟
3能打印 kitten→sitting 的对齐并标出替换/新增8 分钟
4能算出 TSP 的 2^n·n² 复杂度并给出 n≈20 的可行性边界8 分钟
5能区分编辑距离与 embedding 的用途,并给出 RAG 评测的组合用法8 分钟
★
参考答案与判据:① 唯一差异是容量维遍历方向:0/1 背包逆序(保证每个物品只用一次,因为 dp[c-w] 尚未被本轮更新),完全背包正序(物品可重复)。手算 items=[(2,3),(3,4)]、cap=5:0/1 得 7,完全得 7(该例恰好相同),换 items=[(2,3)]、cap=4:0/1 得 3,完全得 6。
② 标准版时间 O(n·m)、空间 O(n·m) 可回溯;只需距离时用两行压到 O(min(n,m)),但无法回溯。要「既省空间又能回溯」用 Hirschberg 分治:O(min(n,m)) 空间 + O(n·m) 时间。
③ LCS 长度 4(itten),对齐为:s>k(替换)、i>e(替换)、tten 相同、末尾 g 新增。
④ 状态数 2^n·n,转移时枚举下一个点 O(n),总 O(2^n·n²);n≈20 时 2^20·400 ≈ 4e8,勉强可跑;n>22 基本不可行,需用启发式/近似。
⑤ 编辑距离衡量字面差异,对同义改写失效;embedding 相似度衡量语义。组合用法:字面差距大但语义相近 → 可能是改写(不算错);语义差距大且字面也差 → 疑似幻觉;引用片段用编辑距离核查是否忠实原文。

9. 面试算法收敛:按模式刷题,而不是按题号

知识结构图 · 面试算法收敛
面试算法收敛4 大知识域 · 22 个知识点
十四个解题模式双指针 / 滑动窗口 O(n)二分查找 O(log n)前缀和 / 差分单调栈 O(n)堆 / Top-K O(n log k)回溯 O(指数)贪心 / 区间 O(n log n)
学习路径
  1. 读 9.1:把十四个模式各配 3 道典型题,练到见题说模式
  2. 跑内置模板:双指针、滑动窗口、二分各默写一遍
  3. 完成练习自测:每日挑 2–3 题套模式并限时 40 分钟
✔ 能见题说模式,并在 40 分钟内无 bug 解出中等题
核心知识点详解
  • 按识别信号套模式:中等题几乎全落在十四个模式内:有序数组/求对 → 双指针;连续子数组极值 → 滑动窗口;有序或答案单调 → 二分;区间求和 → 前缀和;下一个更大 → 单调栈;Top-K/流式 → 堆;依赖/课程表 → 拓扑;全排列/子集 → 回溯;最值可拆 → DP。识别信号比背题号重要。
  • 复杂度速记:双指针/滑动窗口/单调栈 O(n)(均摊,每个下标进出一次);二分 O(log n);Top-K 堆 O(n log k);回溯 O(指数);贪心区间 O(n log n)。每道题说完复杂度再说瓶颈。
  • 常见坑:见题就堆算法:先问「有序吗?规模多大?要求什么?」再选模式。四步面试法(复述澄清 → 说思路 → 写 → 主动测)能筛掉一半失误;写完必须口头跑一个边界用例(空/单元素/全相同/极值)。
两个必背模板单调栈下一个更大滑动窗口 last[ch] 收缩left = last[ch]+1 易错abba 反例
学习路径
  1. 读 9.1:背熟单调栈下一个更大与滑动窗口 last[ch] 收缩
  2. 跑内置代码,手写单调栈并用 abba 反例验证边界
  3. 完成练习自测:用两个模板各刷 5 题直到能默写
✔ 能在白板上无犹豫写出两个模板并说明边界条件
核心知识点详解
  • 单调栈模板:栈存下标、值从底到顶单调递减。遇到更大值不断弹栈结算:res[j] = x。改成「下一个更小」只需反转比较符。每个下标最多进栈/出栈各一次 → O(n)。
  • 滑动窗口 last[ch]:left = last[ch] + 1 是记忆点,不是 left + 1。用 last = {} 记字符最后出现下标,遇到重复字符时左边界跳到重复字符之后。longest_unique("pwwkew") 应为 3("wke")。
  • 常见坑:abba 反例:longest_unique("abba") 正确结果 2("ab" 或 "ba")。若把 left 写成 left + 1 或忘了 >= left 的判断,会得到错误 3。收缩逻辑配合 abba 这类重排型用例测一下边界。
二分三连与答案二分左闭右开 [lo, hi)lower_bound / upper_bound答案二分 feasible(x)复杂度 O(n log 值域)
学习路径
  1. 读 9.2:统一用左闭右开 [lo, hi) 写 lower_bound 与 upper_bound
  2. 跑内置代码,实现答案二分 feasible(x) 并验证复杂度
  3. 完成练习自测:在值域最大化问题里练习答案二分
✔ 能快速判断题目是否用答案二分并写出 O(log 值域) 的解法
核心知识点详解
  • 左闭右开 [lo, hi) 不易错:空区间即 lo==hi;收缩只有 lo = mid + 1 或 hi = mid,永远不因取整方向卡死。while lo < hi:。自测三用例:目标存在(首/末位置)、目标不存在(插入点)、目标越界(0 或 n)。
  • 答案二分的识别与复杂度:问「最小/最大的 X 使得…成立」且 X 单调可判 → 对答案二分,复杂度 O(n log(值域))。min_capacity 用 feasible(cap) 判定 + 对 cap 二分。运货能力、分割数组最大值、最小吃香蕉速度都是它。
  • 常见坑:二分写成死循环:闭区间写法要处理 hi = mid - 1 并防死循环。统一左闭右开:mid = (lo + hi)//2,决策 true 走 hi=mid、false 走 lo=mid+1,退出时 lo==hi 即答案。
训练计划与验收八周按模式刷题每日 2–3 题40 分钟中等题 bug-free四步面试法HNSW+BM25 复用
学习路径
  1. 读 9.2:按八周计划排日程,每日 2–3 题定时 40 分钟
  2. 跑内置代码,用四步面试法把一套题讲给镜子听
  3. 完成练习自测:本周内把 HNSW 与 BM25 的模板复用讲清
✔ 能在模拟面试中同时说清算法思路与工程复用点
核心知识点详解
  • 按模式刷 > 按题号刷:选定一个模式连续做 4–6 题后合上答案默写模板,形成「模式 → 解法」映射而非「题号 → 答案」。八周计划:前 6 周覆盖线性/树/图/回溯,第 7 周 DP 专项,第 8 周模拟面试限时 40 分钟 bug-free。
  • 与工程复用(本阶段红利):HNSW 用到堆 + 图 + 贪心;BM25 用到哈希 + 排序 + Top-K;调度器用优先队列 + 时间片;Agent 编排用拓扑排序 + DP。同一批知识面试与工程各用一次,是 M3 的直接支撑。
  • 常见坑:只刷不总结复杂度:每题做完必须用一句话说清时间/空间复杂度与瓶颈,否则迁移不到第 2 阶段性能分析;面试也必须能口头推导关键结构(HNSW / BM25)的复杂度。
学习路径

9.1 十四个必须形成肌肉记忆的模式

面试算法的正确学习方式不是「刷 500 题」,而是识别模式。中等难度题几乎全部落在这十四个模式里,每个模式把 3–5 道典型题吃透,就足以覆盖 AI 岗的笔试与初面。

模式识别信号核心模板复杂度
双指针有序数组、求对、去重左右夹逼或快慢同向O(n)
滑动窗口连续子数组 / 子串的极值右扩左缩,维护窗口内状态O(n)
二分查找有序、或「答案具有单调性」左闭右开写法 + 边界收缩O(log n)
前缀和 / 差分区间求和、区间修改pre[i] = pre[i-1] + a[i]O(1) 查询
哈希表计数频次、配对、去重一次遍历 + 计数表O(n)
栈 / 单调栈下一个更大、括号、表达式维护单调性,弹出时结算O(n)
堆 / Top-K前 k 大、流式数据大小为 k 的堆或快速选择O(n log k)
链表操作反转、合并、环、倒数哑结点 + 三指针/快慢指针O(n)
二叉树遍历深度、路径、层次、LCA递归语义 + 双返回值套路O(n)
图遍历 BFS/DFS连通性、最短步数、岛屿队列/栈 + visited 集合O(V+E)
拓扑排序依赖关系、课程表、任务调度Kahn(入度)或 DFS 三色O(V+E)
回溯全排列、组合、子集、棋盘选择 → 递归 → 撤销选择O(指数)
动态规划最值、方案数、可拆子问题定义状态 → 转移 → 顺序 → 边界视状态而定
贪心 / 区间区间调度、覆盖、跳跃排序 + 局部最优(需证明)O(n log n)

把上表压缩成可默写代码,只有两个模板最值得背到「肌肉记忆」:单调栈与滑动窗口。它们分别对应「下一个更大/更小」「最长/最短连续子串」这两大类高频题,熟练后看到题干 30 秒内就能落笔。

python
# 模板 A:单调栈 —— 下一个更大元素(改为「下一个更小」只需反转比较符)
# 栈中存下标,保持从栈底到栈顶对应值单调递减;遇到更大值就不断弹栈结算
def next_greater(nums):
    res = [-1] * len(nums)      # 默认无更大元素
    stack = []                  # 存下标,其值单调递减
    for i, x in enumerate(nums):
        while stack and nums[stack[-1]] < x:
            j = stack.pop()     # 当前 x 正是 j 的「下一个更大」
            res[j] = x
        stack.append(i)
    return res

print(next_greater([2, 1, 2, 4, 3]))   # [4, 2, 4, -1, -1]

# 模板 B:滑动窗口 —— 最长无重复字符子串
# 右指针扩张、左指针在出现重复时收缩;哈希表记录字符最后一次出现的下标
def longest_unique(s):
    last = {}                   # 字符 -> 最后出现的下标
    left = 0
    best = 0
    for right, ch in enumerate(s):
        if ch in last and last[ch] >= left:
            left = last[ch] + 1  # 左边界跳到重复字符之后
        last[ch] = right
        best = max(best, right - left + 1)
    return best

print(longest_unique("abcabcbb"))   # 3  -> "abc"
print(longest_unique("pwwkew"))     # 3  -> "wke"
        
ℹ
复杂度与关键数字:两个模板都是 O(n) 时间:虽然内层有 while,但每个下标最多入栈一次、出栈一次,总弹栈次数 ≤ n,故均摊 O(n),空间 O(n);滑动窗口同样是每个字符被左右指针各访问一次,O(n)/O(n)。面试最常踩的两个坑:单调栈写「下一个更小」时忘记把 < 改成 >;滑动窗口收缩时写成 left = left + 1 而非 left = last[ch] + 1,在 "abba" 这类输入上会得到错解(正确答案 2,即 "ab" 或 "ba")。
✔
面试现场的四步法:① 复述与澄清:把题目用自己的话讲一遍,问清边界(空输入?重复?有序?规模多大?)——这一步能筛掉一半的失误;② 先说思路再写码:讲清用什么结构、复杂度多少,得到认可后再动手,避免写了 20 行发现方向错;③ 写代码时保持结构清晰:变量名有意义、边界提前处理,面试官看的是你的代码能不能进 review;④ 主动测:写完自己给一个边界用例走一遍(空数组、单元素、全相同、极值),大声说出检查过程。

9.2 八周训练计划与 Hamauls Orion 的复用

周重点每日投入检验标准
第 1–2 周双指针、滑动窗口、二分、前缀和、哈希3 题/天看到题 30 秒内能说出模式
第 3–4 周栈/单调栈、堆、链表、二叉树3 题/天链表的指针操作一次写对
第 5–6 周图遍历、拓扑排序、并查集、回溯2–3 题/天能在白板写出三种遍历
第 7 周动态规划专项(一维 / 二维 / 区间 / 状态压缩)2 题/天能独立写出状态定义与转移
第 8 周按模式复习 + 模拟面试(限时 40 分钟)2 题 + 1 场模拟中等题 40 分钟内 bug-free

计划里最容易被轻视、却最影响正确率的是二分查找。它不是背一个模板就够,而是要把三种边界写法(找等于、找第一个 ≥、找最后一个 ≤)与「答案二分」这层抽象分清——后者让二分从「在有序数组里查数」升级成「在有单调性的答案空间上求最优」,是面试区分度的重要来源。

python
# 二分三连:区间统一用左闭右开 [lo, hi),最不容易写错
def lower_bound(a, x):
    # 第一个 >= x 的下标;不存在则返回 len(a)
    lo, hi = 0, len(a)
    while lo < hi:
        mid = (lo + hi) // 2
        if a[mid] < x:
            lo = mid + 1
        else:
            hi = mid
    return lo

def upper_bound(a, x):
    # 第一个 > x 的下标;不存在则返回 len(a)
    lo, hi = 0, len(a)
    while lo < hi:
        mid = (lo + hi) // 2
        if a[mid] <= x:
            lo = mid + 1
        else:
            hi = mid
    return lo

a = [1, 2, 2, 2, 3, 5]
print(lower_bound(a, 2), upper_bound(a, 2))   # 1 4
print(lower_bound(a, 4), upper_bound(a, 4))   # 5 5
print(upper_bound(a, 9))                      # 6  (等于 len(a))

# 答案二分:求满足条件的最小值 —— 条件对 x 单调(x 越大越容易满足)
def min_capacity(weights, days):
    def feasible(cap):                # 能否在 days 天内运完
        need, cur = 1, 0
        for w in weights:
            if cur + w > cap:
                need += 1
                cur = 0
            cur += w
        return need <= days
    lo, hi = max(weights), sum(weights)   # 答案的下界与上界
    while lo < hi:
        mid = (lo + hi) // 2
        if feasible(mid):
            hi = mid
        else:
            lo = mid + 1
    return lo

print(min_capacity([1,2,3,4,5,6,7,8,9,10], 5))   # 15
        
✔
为什么「左闭右开」更好写:闭区间写法要处理 hi = mid − 1 并防范死循环,while lo <= hi 的退出条件也容易记反;左闭右开 [lo, hi) 的好处是:空区间就是 lo == hi,收缩只有两种形式(lo = mid + 1 或 hi = mid),永远不会因为 mid 取整方向而卡死。三个自测用例必须过:目标存在(返回第一个/最后一个位置)、目标不存在(返回插入点)、目标越界(返回 0 或 n)。
★
答案二分的识别信号:当题目问「最小的 X 使得……成立」「最大的 X 使得……成立」,且 X 越大越容易 / 越难满足(单调性)时,就该放弃「在数组上二分」,转而对答案本身二分:先定答案上下界,再写一个 feasible(x) 判定函数,最后对 x 二分,复杂度 O(n log(值域))。「运货能力」「分割数组的最大值」「吃掉 N 个香蕉的最小速度」都是这个套路。
★
阶段验收标准:① 能在 40 分钟内、无提示地解出任意一道 LeetCode 中等题,并说清复杂度;② Hamauls Orion 的检索内核已经跑起来——HNSW + BM25 + RRF 融合的召回率-延迟曲线有数据、可复现、参数有依据;③ 缓存与调度器有命中率/吞吐的实测数字。做到这三点,本阶段结束,进入计算机网络。

9.3 动手练习与自测(面试收敛)

这一节不引入新知识,只做「检验」:合上全部资料,在限时条件下独立完成下面几题,再用参考答案对判据。能全部过关,说明本阶段的内容已经长在你手里。

  1. 手写动态数组(含倍数扩容),用倍增测试验证追加是均摊 O(1):分别测 n = 1e3、1e4、1e5、1e6 时「平均每次追加耗时」,并解释为什么它几乎不随 n 增长。
  2. 手写开放寻址哈希表(线性探测 + 墓碑),把负载因子从 0.5 扫到 0.95,记录平均探测次数,并说明曲线从哪一点开始急剧变陡。
  3. 给定 1 万条短文本,实现 MinHash + LSH 近邻去重,报告在 Jaccard 阈值 0.8 下的精确率与召回率;再对比 SimHash 的优缺点。
  4. 在 100 万条随机向量(dim = 128)上分别用暴力检索与 HNSW,测出 recall@10 与 QPS,画出召回率-QPS 曲线,并说明 ef_search 取 16 / 64 / 256 时曲线如何变化。
  5. 实现 BM25 与向量检索并用 RRF 融合,在自建的中英混合评测集上比较「纯 BM25 / 纯向量 / RRF」三种方案的 Top-10 命中率,给出「哪类查询靠关键词、哪类靠语义」的结论。
  6. (开放题)为什么说「模式识别」比「题量」更重要?用你刷过的任意两道题,说明它们共享哪个模式、模板如何复用。
题号通过标准目标耗时
1n=1e3→1e6 的均摊耗时同量级,扩容次数 ≈ log2(n)10 分钟
2α 从 0.5 扫到 0.95 的探测次数曲线,指出 α>0.8 后陡增10 分钟
3MinHash+LSH 在阈值 0.8 下的精确率/召回率,并对比 SimHash15 分钟
4100 万向量上画出 recall@10-QPS 曲线,说明 ef_search 16/64/256 的变化20 分钟
5三方案 Top-10 命中率对比,给出关键词/语义的结论15 分钟
6能指出两题共享的「模式 → 模板 → 易错点」5 分钟
★
参考答案与判据:① 判据:每次追加的均摊耗时应保持在同一量级(约 10–100 ns),n 增大 1000 倍而单次耗时不随之线性增长;扩容次数约为 log2(n)。② 线性探测平均探测次数约 (1 + 1/(1−α)²)/2:α = 0.5 时约 2.5 次,α = 0.9 时约 50 次,α = 0.95 时约 200 次——曲线在 α > 0.8 后急剧变陡,这正是工业实现把负载因子控制在 0.6–0.75 并触发扩容的原因。③ MinHash 用 k 个哈希函数取最小签名,两文档签名相同的比例是 Jaccard 的无偏估计;LSH 用 b 个 band × r 行分桶,同桶即为候选,阈值 0.8 时典型参数(b = 16, r = 4)可在毫秒级完成万级去重;SimHash 只输出 64 位签名、速度快,但只能做近似相似度、阈值难精确控制。④ HNSW 在 recall@10 = 0.95 时通常比暴力快 100–1000 倍;ef_search 从 16 → 256,召回率上升、QPS 下降,工作点取曲线拐点。⑤ RRF 公式 score = Σ 1/(k + rank_i),k 常取 60,对分数尺度不敏感、无需归一化;实测关键词类查询(专有名词、编号、型号)纯向量表现差、BM25 好,语义类查询反之,RRF 融合后整体优于任一单路。⑥ 开放题判据:能指出两道题共享的「模式 → 模板 → 易错点」,例如都属滑动窗口、都能用同一段右扩左缩代码,说明你完成了从题号记忆到模式记忆的迁移。

项目里程碑

贯穿项目 · Hamauls Orion
M3 从零实现检索索引 第 12–16 周

Hamauls Orion 的检索内核不依赖现成库,手写实现:HNSW 近似最近邻索引、倒排索引 + BM25 关键词检索、LRU/LFU 混合缓存、以及一个基于二叉堆的优先级调度器。这是第 13 阶段的 RAG 管线的地基。

本阶段产出(直接进入项目仓库)
验收标准:在 100 万条向量上达到 Recall@10 ≥ 0.95 且 QPS ≥ 暴力检索的 20 倍;复杂度分析(时间/空间/期望)能口头推导。

阶段练习项目

PROJECT 1
数据结构实现合集
在 `hamauls_orion/scratch/ds/` 下手写:动态数组、双向链表、哈希表(链地址 + 开放寻址两版)、二叉堆、跳表、Trie、并查集、布隆过滤器。每个都配单测与一个性能对比实验。
要达成的效果
  • 8 种结构全部实现,每种配套 pytest 单测且通过率 100%
  • 每种输出一条可复现的性能对比数字(如哈希表 α=0.5 与 α=0.9 的平均/最大探测长度)
  • 手写哈希表在 10 万随机整数插入下平均探测长度趋近理论值 (1+1/(1−α)²)/2
功能需求
  • 动态数组用倍数扩容(growth=2.0/1.5)并记录累计拷贝次数
  • 哈希表实现链地址 + 开放寻址两版,开放寻址含墓碑与 2 的幂容量位运算取模
  • 跳表实现 p=0.5 与 p=0.25 两套配置,打印层级分布并计算平均指针数 1/(1−p)
  • 布隆过滤器按最优 m=−n·ln(p)/(ln2)²、k=(m/n)·ln2 配参并实测假阳率
  • 代码置于 hamauls_orion/scratch/ds/,禁止用第三方数据结构库实现这 8 种结构
交付物
  • 各结构单测文件 + 含性能数字与复杂度说明的 README
边界 · 不做

不封装成生产 API;以「可读 + 可测 + 可解释复杂度」为主,不追求最高吞吐常数

PROJECT 2
检索内核(里程碑核心)
实现倒排索引 + BM25 + HNSW + RRF 融合 + Top-K 重排,在 100 万条向量上测出召回率-QPS 曲线,与暴力检索、faiss 做对比,给出参数选型报告。
要达成的效果
  • HNSW 在 100 万条 128 维向量上 Recall@10 ≥ 0.95,QPS 达暴力检索的至少 20 倍
  • 绘制 recall@10 vs QPS 曲线并标出 ef_search≈64 拐点,参数选型有依据
  • 混合检索 100+100→50→10 漏斗出数字,且 RRF 结果优于任一单路
功能需求
  • 手写倒排索引 + BM25(k1=1.5、b=0.75),postings 按 doc_id 排序并用差分 + VByte 压缩
  • 手写 HNSW(M / ef_construction / ef_search 三参数可配)并支持增量插入
  • 实现 RRF 融合 score=Σ 1/(k+rank),k 可配默认 60,并接入 cross-encoder 重排
  • 用 hamauls_orion/index/bm25.py、hnsw.py、retrieval/rrf.py、bench/recall_vs_qps.md 组织产物
  • 与 faiss / hnswlib 在同等数据跑 recall-QPS 对比并说明差异原因
交付物
  • 可运行的中英混合评测脚本 + recall-QPS 曲线图 + 参数选型报告 bench/recall_vs_qps.md
边界 · 不做

不做分布式检索与持久化存储;单机内存索引 + 离线手写实现即可,不接生产向量库作为主索引

PROJECT 3
缓存与调度器
实现 LRU + LFU 混合缓存(带 TTL 与并发保护)与优先队列调度器(带超时、公平性、老化防饥饿),在模拟负载下测量命中率、P99 延迟与饥饿率。
要达成的效果
  • Zipf 重尾流量下缓存命中率 ≥ 70%,且优于同容量纯 LRU
  • 调度器在混合优先级在途请求下保持 P99 延迟 ≤ 2 倍最低优先级基线,饥饿率 0
  • 输出可复现的容量-命中率曲线,说明所选容量(1–5% 键空间)依据
功能需求
  • 实现哈希表 + 双向链表的 O(1) LRU,叠加 LFU 频次与 TTL 过期形成混合淘汰
  • 过期时间支持随机抖动 + 单飞 singleflight(同一键只回源一次)
  • 优先队列调度按「优先级 + 老化时间」排序,支持超时取消与公平性
  • 代码置于 hamauls_orion/scratch/cache/ 及相关 scheduler 文件,带并发保护
  • 提供模拟负载脚本,输出 hit rate、P99 延迟、饥饿率三类指标
交付物
  • 缓存组件 + 调度器 + 模拟负载脚本 + 含三指标数字的报告
边界 · 不做

不实现进程外缓存(如 Redis 兼容协议);只做单进程可用、可度量、可对抗击穿/雪崩的单元

PROJECT 4
DAG 任务编排器
实现带依赖关系的任务调度:拓扑排序分层、按层并行执行、环检测、失败传播与幂等重试,并把它用于 Hamauls Orion 的数据预处理流水线。
要达成的效果
  • 对 20+ 任务的 DAG 正确输出分层并行批次,环检测能给出环涉及节点
  • 按关键路径计算的 makespan 与实际执行时间误差 ≤ 20%
  • 单节点失败时依赖其的全部后继正确失效,幂等重试不产生重复副作用
功能需求
  • 实现 Kahn 拓扑排序(入度分层)与 DFS 三色环检测两个版本
  • 实现关键路径/makespan 计算(est = max 递推)
  • 支持按批并行执行、失败传播(沿依赖边失效 + 回滚已完成部分)与幂等重跑
  • 把 Hamauls Orion 数据预处理流水线建模成 DAG 并接入该调度器
  • 独立成子模块,不破坏现有其他代码结构
交付物
  • DAG 调度器 + 环检测/关键路径输出 + 一次真实预处理全链路执行日志
边界 · 不做

不做分布式节点(跨机调度)与断点续传的持久化状态;单进程内并行调度

PROJECT 5
BPE 分词器
从零训练并实现 BPE 分词,在中文 + 英文 + 代码混合语料上与 HuggingFace tokenizer 对齐,输出 token 数对比与压缩率报告。
要达成的效果
  • 在给定语料上与指定 HF tokenizer 的编码结果逐 token 对齐(样本 ≥ 100 条、一致率 100%)
  • 输出中文 vs 英文相同语义的 token 数对比(预期中文多 50% 以上)并给字节/字符折算
  • 报告可复现的压缩率(token 数 / 字符数),与主流模型词表量级对得上
功能需求
  • 从零实现词对频次统计 + 贪心合并最高频相邻对 + byte-level 编码(无 OOV)
  • 支持 词尾标记与特殊 token 保留,预分词规则与目标 tokenizer 一致
  • 提供与 HF tokenizers 对齐的回归用例,调试预分词/特殊 token 差异
  • 用中英 + 代码混合语料测「中文 token 数 vs 英文」的差异并解释来源
  • 对标 GPT-2 50257 / Llama3 128256 量级说明词表大小选择
交付物
  • BPE 训练 + 编码代码 + 对齐回归测试 + token 对比与压缩率报告
边界 · 不做

不实现 unigram/sentencepiece 等其他算法;以最简 BPE 与 byte-level 对齐为主

PROJECT 6
面试题专项
按十四个模式各完成 4–6 题,维护一份「模式 → 模板 → 易错点」的个人笔记,并进行至少 3 次限时模拟面试。
要达成的效果
  • 覆盖十四模式,每模式完成 4–6 题、合上答案能默写模板
  • 至少 3 次限时 40 分钟模拟面试,中等题达成 bug-free
  • 个人笔记每条含「模式 → 模板 → 易错点」且总量 ≥ 20 条
功能需求
  • 为十四模式建立索引,每模式配 3–5 道典型题与识别信号
  • 把单调栈、滑动窗口、二分三连、答案二分写成可默写模板并加边界注释
  • 每次限时创作后记录复杂度与瓶颈,复盘错因归到「模式/模板/易错点」三类
  • 以自建笔记与复盘为主要交付,不做题库式机械堆题
交付物
  • 模式笔记 + 3 次模拟面试的限时作答记录与复盘
边界 · 不做

不追求题量(不刷数百题),以模式覆盖与可复述、可验证的模板为主

常见误区

面试高频问题速答

为什么动态数组的追加操作是均摊 O(1)?

因为采用倍数扩容(如 2 倍)。从空数组连续追加 n 次,第 1、2、4、8…次时触发拷贝,拷贝总次数为 1+2+4+…+n/2 = n−1,是等比级数,总代价 O(n),分摊到 n 次操作即均摊 O(1)。关键前提是「按倍数扩容」:若每次只加固定量(如 +10),拷贝总代价是 O(n²/10),均摊变成 O(n)。另外,均摊是确定性上界(任意 n 次操作序列都成立),不等同于依赖概率的平均复杂度。

哈希表冲突有哪些解决方案?各自取舍是什么?

两大类:① 链地址法(separate chaining)——每个桶挂链表(或长度超阈值时转红黑树,如 Java 8);负载因子可大于 1,删除简单,但指针跳转导致 cache 不友好、有额外节点开销。② 开放寻址法(open addressing)——冲突时按探测序列(线性/二次/双重哈希)找下一个空槽;数组紧凑、cache 友好、无节点开销,但负载因子必须明显小于 1(线性探测平均探测次数约 (1+1/(1−α)²)/2,α 接近 1 时急剧恶化),且删除需要墓碑标记,否则会截断探测链。工程上小键值、读多写少用开放寻址(Rust HashMap、Python dict),键值差异大或删除频繁用链地址。

HNSW 为什么比 IVF 召回率高?它的参数怎么调?

HNSW 的核心是「分层可导航小世界图」:底层包含全部节点构成近邻图,上层节点稀疏、边长更长,查询时从顶层贪心下降到目标附近,再在底层用优先队列做最佳优先搜索。相比 IVF(先聚类、只搜 nprobe 个簇),HNSW 的图结构能沿近邻关系逐步逼近,不会因为「目标落在未被选中的簇」而完全丢失,因此同延迟下召回率更高,且支持增量插入、无需训练。参数:M(每层邻居数,16–64)控制图连通性与内存;ef_construction(100–500)控制建图质量,决定召回率上界;ef_search(50–500)是唯一可运行时动态调优的旋钮,越大召回率越高、延迟越大。调参方法是固定数据集扫 ef_search,得到召回率-QPS 曲线后在拐点取工作点。

BM25 的公式里每一项在做什么?

BM25 = Σ_t IDF(t) · [ tf(t,d)·(k1+1) / (tf(t,d) + k1·(1−b+b·|d|/avgdl)) ]。三部分:① IDF 衡量词的区分度,稀有词权重高(常见形式 log(1+(N−df+0.5)/(df+0.5)),加 0.5 平滑并避免负值);② TF 部分带饱和效应——tf 分母里的 k1 让高频词增益递减,k1 越大饱和越慢(通常 1.2–2.0),避免「出现 10 次比出现 3 次重要 3 倍」;③ 长度归一化——长文档天然容易命中更多词,所以按 |d|/avgdl 打折,b 控制强度(0 不归一化,1 完全归一化,通常 0.75)。

LRU 怎么实现?为什么它能做到 O(1)?

用「哈希表 + 双向链表」:哈希表存 key → 链表节点,实现 O(1) 定位;双向链表维护访问顺序,头部是最近使用、尾部是最久未用。get 时把节点从原位置摘除并移到头部;put 时若已存在则更新并前移,否则插入头部,若超容量则删除尾部节点并同步从哈希表移除。两个操作涉及的都只是常数次指针改动与哈希操作,所以是 O(1)。工程上还要加:哑头尾节点简化边界、并发保护(加锁 / 分片 / 近似 LRU 如 Redis 的随机采样)、以及 TTL 过期清理。

布隆过滤器为什么会误判「存在」,却不会误判「不存在」?

布隆过滤器用一个 m 位的位数组和 k 个哈希函数。插入元素时,把它的 k 个哈希位置全部置 1。查询时若 k 位中有任何一位是 0,则该元素一定没被插入过(因为插入过的元素对应的位必然都是 1)——所以没有假阴性。反之,若 k 位全为 1,可能是被其他元素各自置上的(多个元素的位置叠加),因此存在假阳性。假阳性率约为 (1−e^(−kn/m))^k,最优哈希个数 k = (m/n)·ln2,对应最优 m = −n·ln(p)/(ln2)²。

拓扑排序怎么检测环?两种实现有什么区别?

两种实现都能检测环:① Kahn 算法(BFS)——统计每个节点入度,把入度为 0 的入队,出队时把邻接节点入度减 1,新的入度 0 节点入队。若最终输出的节点数小于总节点数,说明剩下的节点互相构成环。它的额外好处是天然给出「分层」,可以做按层并行调度。② DFS 三色标记——白色未访问、灰色在栈中、黑色已完成;若在 DFS 过程中遇到一个灰色节点,说明存在回边即环。DFS 版能顺带给出逆后序。工程上做任务编排通常用 Kahn,因为需要分层并行的信息。

给你一个不断到来的数据流,怎么求 Top-K?

维护一个大小为 K 的最小堆。新元素到达时:堆未满则直接入堆;堆已满则与堆顶(当前第 K 大)比较,若更大则替换堆顶并下沉。时间复杂度 O(n log K),空间 O(K),非常适合内存放不下全量数据的流式场景。若数据规模可控且允许 O(n) 额外空间,可以用快速选择(quickselect)做到平均 O(n) 一次求解;若需要「随时查询且支持动态更新」,则用平衡树或跳表,能做到 O(log n) 插入与 O(K) 取前 K。另外注意:求 Top-K 频繁时堆顶的替换模式很关键,用 heapreplace 比 pop+push 少一次堆调整。

学习资源

算法(第 4 版)Sedgewick / 或 CLRS《算法导论》书 algs4.cs.princeton.edu/home/ Sedgewick 的配套站点有全部代码与可视化,适合动手学;CLRS 是理论权威,适合查证严格复杂度与证明。优先读 Sedgewick 的排序、查找、图、字符串四章。 NeetCode 150 / LeetCode 官方题单练习 neetcode.io/practice NeetCode 150 按模式分类,配套视频讲解,是「按模式刷题」的最佳落点,覆盖 AI 岗笔试足够。 Designing Data-Intensive Applications(DDIA)第 3 章书 dataintensive.net/ 讲 LSM-Tree 与 B-Tree 的实现对比,是「数据结构如何落到存储引擎」的最佳读本。理解它你才明白为什么数据库索引长这样。 hnswlib — HNSW 的高质量实现仓库 github.com/nmslib/hnswlib 只读它一个文件(hnswalg.h)。对照本阶段的简化实现,看工业实现如何处理锁、内存布局、删除与序列化。 Faiss wiki — 索引选型与调参指南文档 github.com/facebookresearch/faiss/wiki 官方 wiki 里的「Guidelines to choose an index」是向量索引选型的标准参考。含 IVF / HNSW / PQ 的性能对比数据。 Introduction to Information Retrieval(Manning)书 nlp.stanford.edu/IR-book/ 信息检索经典教材,免费在线。第 1–2 章(倒排索引)、第 6 章(向量空间模型与 TF-IDF/BM25)、第 7 章(评分与排序)是必读。 Redis 内部实现 — 跳表与近似 LRU仓库 github.com/redis/redis/blob/unstable/src/t_zset.c 看 Redis 如何在生产代码里实现跳表(zskiplist)与近似 LRU 采样淘汰。工程实现的取舍讲得比教科书清楚。 HuggingFace tokenizers 文档 + minbpe仓库 github.com/karpathy/minbpe Karpathy 的 minbpe 是 BPE 的极简教学实现(约 300 行),与本阶段第 7.3 节互相印证;HF tokenizers 文档则解释了 byte-level BPE 与特殊 token 的工程细节。 Probabilistic Data Structures for Web Analytics and Data Mining文章 highlyscalable.wordpress.com/2012/05/01/probabilistic-structures-web-analytics-data-mining/ 布隆过滤器、Count-Min Sketch、HyperLogLog、MinHash 的一篇综述,含公式推导与误差分析。 MIT 6.006 Introduction to Algorithms课程 ocw.mit.edu/courses/6-006-introduction-to-algorithms-spring-2020/ 如果需要系统的课程视频,MIT 6.006 是最标准的起点;6.046 则覆盖进阶算法(含随机化与近似算法)。
★
这一阶段的独特价值:大多数「AI 课程」都会跳过数据结构和计算机组成,直接从 Python 和模型开始。结果是学员能调库、能跑 demo,但一旦要自己实现索引、自己优化检索延迟、自己设计调度器就无从下手。而 2026 年 AI 岗位的真实分化恰恰发生在这里:会调模型的人很多,能把检索做到毫秒级、把索引做到亿级、把成本压下来的人很少。第 2–4 阶段这三门课,就是把你从前者变成后者的分水岭。