45 分 Scala 算法训练:进入三套完整模拟题 →

ECE472 / 算法直觉 + 系统落地 + Scala 读码

用很小的状态,看懂很大的数据

这份手册把概率型数据结构和 Scala 实现阅读放在同一条学习线上。前面先弄清每种结构保存什么、怎样更新、怎样查询、怎样估计;最后再把同一套逻辑翻译成考试里可能出现的 Scala 代码。

算法细节默认收起。复习时先看标题自己推一遍,卡住再展开;数值例子、估计器和 MapReduce 落地仍然完整保留。

主线不再按“热门 / 专业 / 冷僻”排列,而是看状态为什么这样设计:过滤、统计摘要、稀有哈希事件、相似性签名和流式抽样。难度不再决定算法放在哪一章。
25种结构与变体
5 家族按核心机制组织
End-to-end状态、更新、查询、输出

先看核心思想,再看算法名字

这里的家族按“摘要为什么能工作”来分,而不是按知名度或考试难度。每个算法只进入一个主家族;它解决的具体问题仍会用 membership、cardinality、frequency、quantile 等标签补充。

Filter 类

把元素压成 bit、计数器或 fingerprint。查询先检查局部状态,快速排除“一定不存在”。

包含结构
Sketch 类

把数据流压成计数矩阵、候选槽位或有序摘要,用固定空间恢复统计量。

包含结构
Log 类

把集合规模映射成稀有哈希模式,再用前导零、寄存器极值或压缩事件反推 distinct count。

包含结构
Similarity 类

把对象变成短签名、最小哈希样本或桶编号,让相似关系转化成短表示上的接近。

包含结构
Sampling 类

在未知长度的数据流中维护固定容量、概率公平的代表性样本。

包含结构

我想解决什么问题?

从题目类型往回找结构,通常比背算法名更省力。下面这张表就是给你做这件事的:先看要回答什么问题,再看该往哪一家结构里找。

问题 优先看的结构 一句话说明
判断某元素是否存在 Bloom Filter / Cuckoo Filter / Quotient Filter 典型的 membership 问题,适合缓存穿透防护、黑名单、预过滤。
估计有多少个不同元素 HyperLogLog / KMV / Theta / CPC 典型的 distinct count 问题,适合 UV、独立访客、去重用户数。
估计某元素出现了多少次 Count-Min Sketch / Count Sketch 典型的频率估计问题,适合热点 key、热门 query、流量计数。
找最热的那些元素 Misra-Gries / Space-Saving / Top-K 你关心的不是所有元素,而是 heavy hitters 和榜单头部。
判断两个集合或对象像不像 MinHash / SimHash / LSH 适合查重、近重复检测、相似内容聚类和大规模候选检索。
从无限数据流抽样 Reservoir Sampling 不知道流会有多长,但想始终保留一个公平样本。
估计 p50 / p95 / p99 t-digest / KLL / GK 典型的 quantile 问题,适合请求时延、响应时间和尾延迟分析。
极小内存计数 Morris Counter 如果你只愿意付出很少的比特去换一个数量级估计。

边界条件先说清楚

概率型结构的价值在于近似、压缩和分布式可操作性,因此它们非常适合监控、分析、缓存、推荐和流式聚合;但它们不适合强一致审计、财务结算、权限判定、法务留痕这类必须返回精确结果的场景。不要把 approximate 当成 sloppy,也不要把 exact 问题错误地下放给近似结构。

Core idea 01 · Membership compression

Filter 类:压缩存在性证据

这一家不保存完整集合,只保存 bit、计数器、fingerprint 或局部布局。查询的重点是能否快速排除“一定不存在”,而不是恢复原始元素。

Core idea 02 · Statistical summaries

Sketch 类:用有限摘要恢复统计量

这一家保存共享计数器、候选槽位或压缩后的排序信息。数据不断到来时只更新小状态,查询再从摘要恢复 frequency、heavy hitters、quantile 或数量级。

Core idea 03 · Rare hash events

Log 类:用稀有哈希模式反推规模

这一家的信号来自极端事件:很长的前导零、桶内极值或高度压缩的 distinct 事件。核心估计器再把这些稀有程度翻译成集合基数。

Core idea 04 · Short signatures

Similarity 类:让相似对象在短表示里靠近

这一家先把集合或对象变成短签名、最小哈希样本或桶编号,再用签名相等比例、Hamming 距离、阈值样本或同桶关系回答相似性与集合问题。

Core idea 05 · Representative samples

Sampling 类:固定空间保留代表性

这一家不试图直接恢复全集,而是让数据流中的每个元素拥有公平入选机会,从未知长度的流中持续维护固定大小样本。

Redis / PostgreSQL / Apache DataSketches 里有什么?

前面三章讲“算法是什么”,这一节讲“系统里怎么用”。Redis 更像现成工具箱,Postgres 更像扩展生态,Apache DataSketches 则是一整套 sketch 实现库。

Redis

Redis 在这件事上很像工程工具箱,因为它把常见 sketch 直接做成了接口。HyperLogLog 对应 PFADDPFCOUNTPFMERGE,拿来估 distinct count 很顺手;Bloom Filter 回答“可能存在吗”;Cuckoo Filter 多了删除;Count-Min Sketch 估频率,但结果会偏高;t-digest 处理 percentile 和 quantile;Top-K 维护当前最热的元素。

从教学角度看,Redis 的价值在于它把抽象算法翻译成了非常直接的系统接口,因此你能很快感受到这些结构为什么适合线上服务:它们占用空间小、更新快、能容忍少量误差,而且非常适合被部署在高吞吐场景里。

PostgreSQL / Postgres

Postgres core 并不像 Redis 那样直接内置一整组 sketch,但 extension 生态已经把几类最有价值的结构补了出来。官方 contrib 里的 bloom 扩展提供 Bloom filter index access method,本质上是用 signature 来快速排除不匹配 tuple;postgresql-hllhll 扩展提供 HyperLogLog 类型,用于 approximate count distinct,并且支持 merge;tdigest 扩展则服务于 percentile、quantile 和 trimmed mean 这类近似统计需求。

因此,如果你在数据库系统课程里看这些结构,Postgres 给你的启发不是“所有 sketch 都已经内置”,而是“关系数据库也会通过扩展机制吸纳这些近似结构,用它们处理原生 SQL 聚合不擅长的大规模统计问题”。

Apache DataSketches

Apache DataSketches 不是单个算法,而是一整套围绕 sketch 思想构建的库。它覆盖的范围远比 HLL 更广,包括 distinct count 的 Theta / HLL / CPC,quantile 的 KLL,frequent items 的热点项摘要,以及 sampling 和 set operations 等一系列能力,因此它更适合被理解成“近似分析工具箱”而不是“某一个去重算法的实现”。

从学习路径上说,DataSketches 的意义在于把你从单点算法拉向一个完整体系:你不再只是在问“有没有 Bloom 或 HLL”,而是在问“对于 distinct、quantile、frequent items、sampling、set operations 这些不同问题,哪种 sketch 最适合当前系统”。

大对比表

如果你复习时更喜欢横向看差异,这张表最有效。它不替代正文,但能帮你迅速对齐“核心家族、问题类型、误差形式、删除能力、可合并性和典型落地场景”。

数据结构 核心家族 回答的问题 返回结果 误差形式 删除 合并 典型系统 最适合场景

学习路线

如果你打算把这些结构真正学成体系,而不是只背几个名字,下面这条路线会比较稳。它先建立 membership 和 cardinality 的直觉,再进入频率、相似性与 quantile,最后再回到系统落地。

建议顺序

  1. Bloom Filter
  2. HyperLogLog
  3. Count-Min Sketch
  4. MinHash / SimHash
  5. t-digest / KLL
  6. Misra-Gries / Space-Saving
  7. Redis / Postgres / DataSketches 工程落地

面试速记版

  • Bloom:有没有,允许 false positive,不会 false negative。
  • HLL:多少个不同元素,依赖前导零和寄存器聚合,误差约为 1.04 / sqrt(m)。
  • CMS:某元素大概出现几次,查询取 min,结果可能高估。
  • MinHash:两个集合像不像,本质上估计 Jaccard 相似度。
  • t-digest / KLL:不用保存所有值,也能估计 p99。
  • Misra-Gries / Space-Saving:谁最热,抓 heavy hitters。

共同思想与家族分类

把这些算法放回同一张图里看,会更清楚。它们不是一堆散名字,而是几条很稳定的思路:有的负责排除,有的负责压缩统计,有的做相似性签名,有的做随机抽样。复习时先认题型,再认这类结构保存什么状态,会比硬背名字顺得多。

第一层:先分问题

  1. Membership:某个元素来没来过?
  2. Cardinality:一共有多少个不同元素?
  3. Frequency:某个 key 大概出现了多少次?
  4. Similarity:两个对象像不像?
  5. Sampling:怎么从流里保留代表性样本?

第二层:再看共同骨架

  • 先把原始对象映射成 hash、位、计数器、寄存器或样本。
  • 再用很少的状态保存“足够回答问题的信息”。
  • 最后用一个估计器把摘要翻译回答案。
  • 所以这类算法本质上都是“压缩 + 概率保证 + 快速更新”。
Filter 类:核心思想是“先排除不可能,再接受少量误判”

Filter 家族最典型的代表是 Bloom Filter、Counting Bloom、Stable Bloom、Cuckoo Filter 和 Quotient Filter。它们共同关心的不是“完整保存集合”,而是“用更少空间快速判断某个元素是否值得继续查”。

共同目标

尽快回答 membership,减少后续昂贵查询。

共同代价

通常接受 false positive,但标准 Bloom 不接受 false negative。

共同做法

把元素压成 bit / fingerprint / local layout,再做局部检查。

可以把它们理解成“先挡掉明显不在集合里的东西”。这就是为什么 filter 类常被放在缓存穿透防护、黑名单、索引预过滤和流式去重的前面。

展开看:这一类为什么能省空间?

因为它们不保存原始元素,只保存被 hash 后的很少状态。Bloom 保存的是被点亮的 bit,Cuckoo/Quotient 保存的是 fingerprint 或局部布局信息,所以空间从“存对象”降成了“存指纹/状态”。

Sketch 类:核心思想是“用摘要回答统计问题”

Sketch 不是单个算法,而是一类“把流压缩成统计摘要”的方法。HyperLogLog、Count-Min Sketch、Count Sketch、KLL、GK、t-digest、Theta、KMV 都属于这个大类,只是分别服务不同统计量。

子类代表结构回答的问题摘要长什么样
Log / CardinalityFM、LogLog、HLL不同元素有多少bucket + 前导零 / register
FrequencyCMS、Count Sketch、Misra-Gries某个 key 多热多行计数器 / 候选表
QuantileGK、KLL、t-digestp50 / p95 / p99压缩后的排序摘要
Set / SimilarityKMV、Theta、MinHash集合像不像最小哈希值 / 采样签名

这一类的共同点很朴素:不存全量数据,只存足够恢复统计量的摘要。因为题目允许近似,所以这条路走得通,而且误差通常还能写清楚。

Log 类:核心思想是“让极少的 bit 暗示数量级”

所谓 log 类,通常指 Flajolet-Martin、LogLog、SuperLogLog、HyperLogLog 这条线。它们的共同点不是名字里真的都写了 log,而是它们都利用了“前导零长度”和“指数型稀有事件”来编码规模。

直觉上,哈希越均匀,出现很长前导零就越罕见;但如果你已经看到很多元素,那就更有机会撞到很长的前导零。所以“最长前导零有多长”就变成了规模的信号。

共同思想

把“大数”映射成“稀有模式”。

为什么叫 log

数量级变化能被很少状态感知,估计器常是指数或对数风格。

核心信号

前导零越长,说明更像见过更多 distinct 元素。

这条线本质上是在用少量哈希现象反推集合规模。HLL 只是把这件事做得更稳,也更适合工程实现。

Similarity 类:核心思想是“把相似性变成签名上的接近”

MinHash、SimHash 和 LSH 这条线关心的是“像不像”。它们的共同套路是:先把对象变成更短的 signature 或 bucket,然后在这个更小的表示上做比较或召回。

MinHash

更像集合交并,估计 Jaccard。

SimHash

更像向量方向,估计 Hamming 接近。

LSH

更像候选生成机制,把相近对象分到一起。

这类方法通常不直接比原对象,而是先造一个更短的签名或桶编号。这个短表示越接近,原对象大概率也越接近。

Sampling 类:核心思想是“保留代表性,而不是保留全部”

Reservoir Sampling 是最典型的流式抽样方法。它不问“这个元素有多重要”,而问“怎样让每个元素在最后都拥有公平入选概率”。

共同目标

从未知长度的数据流里保留固定大小样本。

共同做法

新元素按概率进入样本池,进入后随机替换旧样本。

共同价值

让离线分析看到代表性数据,而不是偏见样本。

Sampling 类保留的是代表性,不是全集。它关注的是“样本够不够像原流”,而不是“统计量是否已经完整恢复”。

Implementation Reading

第四章:把算法翻译成 Scala

考试里的实现题通常不是在考你能不能凭空写出 Scala,而是在看你能不能从字段、更新函数和估计器里认出算法。前面三章回答“结构怎么工作”,这一章专门练“代码里的哪一行对应哪一步”。

先找四件事,再看语法

拿到一段 sketch 实现,先不要从第一行逐字翻译。先扫字段和方法名,把它放进下面四个格子里。

状态

保存的是位数组、寄存器数组、二维计数矩阵、最小哈希样本,还是压缩摘要点?

更新

新元素进来后,是点亮 bit、计数加一、寄存器取 max,还是插入后再压缩?

查询

最后是检查若干槽位、取多行的 min、代入估计器,还是按 rank 找 quantile?

合并

两份局部状态之间,是按位 OR、逐项相加、逐寄存器取 max,还是合并后重压缩?

这四个问题都能回答时,算法已经读懂了大半。Scala 只是把同一套状态转移写成代码。

第 1 步:拆掉最常见的 Scala 外壳

课堂实现常用的语法很集中:classvalvardefArrayforif,再加少量 mapforeachsum

class HyperLogLog(val p: Int) {
  val m: Int = 1 << p
  val registers: Array[Int] = Array.fill(m)(0)

  def add(x: String): Unit = {
    // 更新状态,不返回结果
  }

  def estimate(): Double = {
    // 读取状态,返回估计值
    0.0
  }
}
class

定义一种数据结构。构造参数 p 通常决定精度或空间。

val

名字不再绑定到别的对象,但数组里的内容仍然可以更新。

var

变量本身会变,常见于循环下标和中间计算。

def ...: Unit

没有返回值的更新函数,通常负责改变 sketch 状态。

第 2 步:从容器认出状态

val bits = Array.fill(16)(false)
val registers = Array.fill(8)(0)
val table = Array.ofDim[Int](3, 10)

case class Entry(value: Double, g: Int, delta: Int)
Scala 写法按算法语言翻译常见结构
Array.fill(n)(false)长度为 n 的位状态Bloom Filter
Array.fill(n)(0)一排寄存器或计数器HLL、Counting Bloom
Array.ofDim[Int](d, w)d × w 的计数矩阵CMS、Count Sketch
case class Entry(...)一条带多个字段的摘要记录GK、t-digest
Seq / Vector / Set少量样本、摘要点或签名KLL、MinHash、Theta

第 3 步:把位运算看成算法信号

HLL、LogLog、Bloom、CMS 的代码里经常出现位运算。它们通常不是为了炫技,而是在拆 hash、选桶或数前导零。

1 << p

计算 2^p,HLL 里常用来得到桶数 m

x >>> k

无符号右移,常用来取 hash 高位作为 bucket id。

x & mask

只保留 mask 指定的位,常用来取槽位或 fingerprint。

numberOfLeadingZeros

计算前导零长度,是 LogLog/HLL 家族的核心统计信号。

val h = hash64(x)
val bucket = (h >>> (64 - p)).toInt
val w = (h << p) | (1L << (p - 1))
val rho = java.lang.Long.numberOfLeadingZeros(w) + 1
  1. 元素先被哈希成 64 位整数 h
  2. p 位被取出,变成桶号 bucket
  3. 剩余位移到前面,交给 numberOfLeadingZeros
  4. rho 是这次更新观察到的前导零长度,接下来通常会和寄存器做 max

第 4 步:完整读一遍 HyperLogLog

这段代码把前面 HLL 章节的四个环节全部写出来了。不要只看懂某一行,要能从输入 x 一直跟到最后的 distinct-count 估计。

class HyperLogLog(val p: Int) {
  val m: Int = 1 << p
  val registers: Array[Int] = Array.fill(m)(0)

  def add(x: String): Unit = {
    val h = hash64(x)
    val j = (h >>> (64 - p)).toInt
    val w = h << p
    val r = java.lang.Long.numberOfLeadingZeros(w) + 1
    registers(j) = math.max(registers(j), r)
  }

  def merge(other: HyperLogLog): Unit = {
    for (i <- registers.indices) {
      registers(i) = math.max(registers(i), other.registers(i))
    }
  }

  def estimate(): Double = {
    val z = registers.map(r => math.pow(2.0, -r)).sum
    0.7213 / (1 + 1.079 / m) * m * m / z
  }
}
状态:registers 保存了什么?

数组长度是 m = 2^p。每个位置对应一个桶,只保存这个桶见过的最大前导零长度。它不保存原始元素,也不保存全部 hash。

更新:一个新元素改了哪里?

h 被拆成桶号 j 和剩余位。剩余位产生 r,最后只有 registers(j) 可能变化,而且更新规则是 max(old, r),不是加一。

合并:为什么逐寄存器取 max

机器 A 和机器 B 在同一个桶里分别见过最大值 4 和 7,全局数据里该桶的最大值自然就是 7。所以 mapper 本地建 HLL,reducer 逐位置取 max,就能得到全局状态。

查询:估计器最终算了什么?

map(r => 2^{-r}).sum 先计算 z = Σ2^{-M[j]},下一行再计算 α_m · m² / z。因此这不是简单平均寄存器,而是把所有桶的证据放进 HLL 的调和平均形式估计器,输出 distinct count。

第 5 步:用动词认出其他结构

结构状态更新时找什么查询时找什么merge 时找什么
Bloom Filter位数组bits(pos) = true对应位是否全为 1按位 OR
Count-Min Sketch二维计数矩阵table(r)(c) += 1多行计数取 min矩阵逐项相加
MinHash签名数组每个位置取 min相同位置所占比例逐位置取更小值
KLL / GK摘要点或分层缓冲insert + compress按累计权重找 rank合并后继续压缩
// 逐位置合并 HLL
for (i <- registers.indices) {
  registers(i) = math.max(registers(i), other.registers(i))
}

// 对每个哈希函数更新 Bloom
hashes.foreach { h =>
  val pos = (h(x) % m).toInt
  bits(pos) = true
}

// CMS 查询与 MinHash 比较
val frequency = counters.min
val similarity = signature.zip(otherSig)
  .count { case (a, b) => a == b }.toDouble / signature.length

这里的 foreach 就是循环,zip 是把两个数组按位置配对,count 是统计满足条件的位置数。把高阶函数翻译回朴素循环,不会影响你理解算法。

Bloom

预过滤:先排除一定不存在的 key,少做后面的昂贵查询。

HLL

去重计数:各节点维护小寄存器数组,最后低成本 merge。

CMS

流式频率:用固定大小矩阵估计热门 key,接受保守高估。

KLL / t-digest

延迟分位数:节点先做摘要,汇总后查询 p95、p99。

考试时按这六步读

  1. 圈字段:找出数组、矩阵、样本或摘要点,这些就是持久状态。
  2. 圈更新函数:add / update / insert 收到一个元素后改了哪些状态。
  3. 圈查询函数:query / estimate / contains 怎样把状态翻译成答案。
  4. 检查 merge:判断它是 OR、加法、maxmin,还是合并后重建。
  5. 解释位运算:优先往桶号、mask、前导零、fingerprint 上翻译,不要停在语法名称。
  6. 收尾一句:说明它维护什么摘要、允许什么误差、为什么适合流式或分布式场景。
目标不是从零默写 Scala,而是看到实现后能沿着“状态 → 更新 → 查询 → 输出”把算法完整复原。