# 实验 12：Top-k 查询——为什么不该为取 10 行排 100 万行

> `ORDER BY title LIMIT 10` 只想要前 10 行，全排序却把 100 万行从头排到尾：本实验对比"排完再截断"与"边扫边维护一个 k 大小的堆"两种做法，量出差距，并找出堆法反过来吃亏的边界。

## 一、实验目标

完成本实验后，你应当能够：

1. 能解释 SQL 中 `ORDER BY … LIMIT k` 的语义，并说明"全排序后截断"（sort-and-cut）与"堆扫描"（queue-based scan / top-k）两种执行策略的区别。
2. 能写出堆法的复杂度：时间 O(n log k)、辅助内存 O(k)，并能与全排序的 O(n log n)、O(n) 做数量级对比。
3. 能用 Python 的 `heapq` 实现"保持当前最小的 k 个元素"的最大堆（max-heap）技巧，并正确处理重复键（ties）。
4. 能设计计时实验（多次运行取中位数），实测两种策略在 n = 1000000、k 取不同值时的耗时，并填入观测表。
5. 能判断在什么条件下堆法反而比全排序慢，并用常数因子与缓存行为解释原因。

## 二、预备知识

### 2.1 查询语义

```sql
SELECT *
FROM books
ORDER BY title
LIMIT 10;
```

含义：把 `books` 表按 `title` 升序排列，返回排最前的 10 行。注意"LIMIT 10 不带 ORDER BY"是另一回事——不指定顺序时返回哪 10 行是不确定的，本实验只讨论带 ORDER BY 的情形。

结果里 10 行之外的 999990 行全部被丢弃。一个自然的追问：**既然 99.999% 的排序结果都要扔掉，为什么还要排完？**

### 2.2 二叉堆：30 秒版

**堆（heap）**是一棵完全二叉树，且每个节点的值不大于（小顶堆，min-heap）其子节点。于是树根永远是全体元素里最小的那个。Python 标准库 `heapq` 实现的是小顶堆，底层就是一个列表，核心操作：

- `heapify(lst)`：把列表原地调整成堆，O(len)。
- `heappush(h, x)` / `heappop(h)`：插入 / 弹出最小元素，O(log len)。
- `heappushpop(h, x)`：先把 x 放进再弹出最小者，一次调整完成，比"先 push 再 pop"更快。

本实验要的是"当前最小的 k 个"，所以堆里要随时能取出**这 k 个里最大的**——那是一个**最大堆（max-heap）**的行为。`heapq` 只有小顶堆，惯用技巧有两条：

1. 键是数字时，把键取负号再入堆。
2. 键是字符串等不可取负的类型时，用一个小包装类把比较方向反过来（见步骤 3）。

### 2.3 两种策略的账

设表中行数为 n，要取前 k 行。

**策略一：sort-and-cut（全排序后截断）**

```text
时间：O(n log n)     ——任何基于比较的排序的下界
内存：O(n)           ——至少要能放下全部行的有序副本
```

n = 10⁶ 时，log₂n ≈ 19.9，比较次数约 n·log₂n ≈ 2×10⁷ 次。

**策略二：queue-based scan（堆扫描）**

先读入前 k 行建一个最大堆；此后每来一行，只和堆顶（当前 k 个中最大者）比较一次：比堆顶小就换入（O(log k)），否则直接丢弃。扫完流即可：

```text
时间：O(n log k)     ——n 次与堆顶的 O(1) 比较，加上不超过 n 次 O(log k) 的堆调整
内存：O(k)           ——任何时刻只保留 k 行
```

k = 10 时，log₂k ≈ 3.3，堆调整次数约 n·log₂k ≈ 3.3×10⁶ 次，比全排序少一个常数级以上的量级；内存从"放下全表"降到"放下 10 行"。

O(k) 内存这一点在数据库里有实际后果：排序若放不进内存就要外排序（external sort），把中间结果写盘、多路归并。PostgreSQL 的 `work_mem` 参数（默认 4 MB）规定了单个排序节点可用的内存上限，超过即转外排。而堆法只要 k 行就能工作，k = 10 时几乎不占内存。

### 2.4 一个重要的反例预告

理论复杂度不是全部。实测会看到：k = 100000（k 接近 n 的十分之一）时，堆法（738.4 ms）反而比全排序（350.6 ms）慢一倍。原因先记两个关键词：**常数因子**与**缓存局部性**，细节在思考题里展开。这提醒我们：算法选择要看参数范围，不能只背复杂度记号。

### 2.5 计时方法约定

比较耗时必须控制变量：

- 每种配置重复运行多次（本实验 5 次），取**中位数**（median），剔除偶发的系统抖动。
- 用 `time.perf_counter()`，它是单调高精度计时器，比 `time.time()` 适合测耗时。
- 排序会修改列表，两种策略都要在数据的**副本**上运行，且副本之间内容一致。

## 三、实验原理

### 3.1 堆法为什么正确

记第 i 行扫过之后，堆中恰好是"目前见过的所有行里 title 最小的 k 个"。归纳论证：

- 初始：读入前 k 行建堆，命题成立。
- 归纳：新行 x 到来。若 x.title ≥ 堆顶（k 个中最大者），x 不可能属于新的前 k 小，丢弃后命题仍成立；若 x.title < 堆顶，则堆顶被淘汰、x 进入，堆中仍是"目前最小的 k 个"，命题成立。

n 行扫完，堆里就是全表最小的 k 个，最后排序这 k 个输出（O(k log k)，可忽略）。堆顶即"当前第 k 小"，这也是数据库执行器实现 Top-k 的标准方式（经典文献里常算作部分排序，partial sort）。

### 3.2 比较次数的粗算

n = 10⁶、k = 10：

```text
全排序比较次数  ≈ n·log₂n = 10⁶ × 19.93 ≈ 2.0 × 10⁷
堆扫描比较次数 ≈ n（与堆顶的 1 次比较） + 命中数 × log₂k
              ≈ 10⁶ + 很小（k/n = 10⁻⁵，绝大多数元素直接被丢弃） ≈ 10⁶
```

即堆法只要约 1/20 的比较量。但注意 Python 里全排序跑在解释器调用的 C 实现上（Timsort），堆扫描的循环却是纯 Python 字节码，每条"比较"的实际开销不同，所以实测加速比（k = 10 时约 17 倍）低于理论比较次数之比。这也是 2.4 节反例的伏笔：当 k 大到堆操作频繁且堆本身超出缓存时，解释器开销叠加缓存不友好，堆法会被 C 实现的排序反超。

### 3.3 k 从小到大：三种区间

参考实测（本机，CPython 3.13 + Windows，n = 1000000，5 次中位数，单位 ms）：

| k | 全排序后截断 | 堆扫描 | 堆/排序 |
| --- | --- | --- | --- |
| 10 | 301.0 | 17.7 | 约 1/17 |
| 100 | 302.6 | 20.5 | 约 1/15 |
| 1000 | 304.3 | 27.6 | 约 1/11 |
| 10000 | 354.5 | 91.5 | 约 1/3.9 |
| 100000 | 350.6 | 738.4 | 约 2.1 倍（堆反超） |

三个区间：

1. **k 很小（≤1000）**：堆的命中率低（随机数据里能挤进前 k 的元素比例约 k/n）、堆小到常驻缓存，堆法大胜，且耗时对 k 不敏感。
2. **k 中等（10³～10⁴）**：命中率与堆深都在涨，差距收窄。
3. **k 接近 n（10⁵ 对 10⁶）**：约 10% 的元素要进堆，堆调整次数 ≈ 0.1n·log₂(10⁵) ≈ 1.7×10⁵ 次 × 17 层；堆本身占数 MB，超出 CPU 缓存后每次调整都是多次缓存未命中；而对面的 Timsort 依然是一条高度优化的 C 代码路径。此消彼长，堆法反而更慢。

配套图件画出了这三段曲线：

![Top-k 两种策略的耗时对比](../figures/fig_topk.png)

### 3.4 数据库里的对应物

真实执行器不会把整行复制来复制去，而是维护 (键, 行标识) 的 k 元素堆；有合适的索引（如在排序列上的 B+ 树）时甚至不用扫全表，直接沿索引顺序读 k 行即可。本实验用纯 Python 模拟的是"无索引、顺序扫描"这一最朴素但最常见的场景。

## 四、实验环境与准备

1. 进入仓库根目录 `explaindb-main`。
2. 安装依赖：

   ```bash
   pip install faker pandas matplotlib jupyter
   ```

3. 打开配套 notebook：

   ```bash
   jupyter notebook notebooks/Top-k.ipynb
   ```

4. notebook 用 40 行小数据演示两种算法并做单元测试；本实验第五章在此基础上把数据量放大到 100 万行做计时。标准库 `heapq`、`statistics` 无需安装。

## 五、实验步骤

### 步骤 1：小数据上理解两种算法（对应 notebook）

```python
from dataclasses import dataclass
from functools import total_ordering
import heapq, random

@total_ordering
@dataclass(eq=False)
class Book:
    author: str
    title: str

    def __eq__(self, other):
        return self.title == other.title

    def __lt__(self, other):
        # 故意反转比较方向：让 heapq 的小顶堆表现出"最大堆"的行为，
        # 堆顶 heap[0] 恒为堆内 title 最大的行。
        return self.title > other.title

def make_books(n, seed=42):
    rng = random.Random(seed)
    words = ["alpha", "bravo", "charlie", "delta", "echo", "fox", "golf", "hotel"]
    return [Book(rng.choice(words) + str(rng.randrange(10**6)),
                 rng.choice(words) + str(rng.randrange(10**6)))
            for _ in range(n)]

data = make_books(8)
for b in sorted(data, key=lambda b: b.title)[:3]:
    print(b.title)
```

### 步骤 2：实现策略一"全排序后截断"

```python
def sort_and_cut(data, k=10):
    books = data.copy()               # 不改动原数据
    k = min(len(data), k)
    books.sort(key=lambda b: b.title) # O(n log n)
    return books[:k]                  # 只留前 k 个
```

### 步骤 3：实现策略二"堆扫描"

```python
def queue_based_scan(data, k=10):
    """顺序扫描，堆中始终保持目前见过的 title 最小的 k 行。
    时间 O(n log k)，辅助内存 O(k)。"""
    k = min(len(data), k)
    result = data[:k].copy()
    heapq.heapify(result)             # O(k) 建堆；Book.__lt__ 已反转，堆顶是最大 title

    for book in data[k:]:
        if book.title < result[0].title:   # 先和堆顶比一次，O(1)
            heapq.heappushpop(result, book)  # 换入并保持堆性质，O(log k)

    result.sort(key=lambda b: b.title)     # 最后只排 k 个
    return result
```

两个细节：

- `book.title < result[0].title` 用的是真实顺序（不是反转后的），所以判断语义是"比当前第 k 小还小"。
- 有重复 title（ties）时，"哪一个同键行入选"不确定，但 title 的多重集合一定一致。若要求结果可复现，可在比较键后追加一个次级键（如行号）。

### 步骤 4：单元测试确认两法等价

```python
import unittest

class TestTopK(unittest.TestCase):
    def test_results_match(self):
        for trial in range(10):
            data = make_books(1000, seed=trial)
            self.assertListEqual(
                [b.title for b in sort_and_cut(data, 10)],
                [b.title for b in queue_based_scan(data, 10)],
            )

    def test_k_larger_than_n(self):
        data = make_books(5, seed=99)
        self.assertEqual(len(queue_based_scan(data, 10)), 5)

unittest.main(argv=["ignored", "-v"], defaultTest="TestTopK", exit=False)
```

### 步骤 5：放大到 100 万行并计时

```python
from time import perf_counter
from statistics import median

N = 1_000_000
print("造数据……")
data = make_books(N)                  # 一次性造好，两种策略共用同一份

def bench(fn, repeat=5):
    times = []
    for _ in range(repeat):
        t0 = perf_counter()
        fn()
        times.append((perf_counter() - t0) * 1000)
    return median(times)

print(f"{'k':>8} {'全排序(ms)':>12} {'堆扫描(ms)':>12}")
for k in [10, 100, 1000, 10_000, 100_000]:
    t_sort = bench(lambda: sort_and_cut(data, k))
    t_heap = bench(lambda: queue_based_scan(data, k))
    print(f"{k:>8} {t_sort:>12.1f} {t_heap:>12.1f}")
```

预期结果与 3.3 节参考实测同方向：k ≤ 1000 时堆法快一个数量级，k = 100000 时堆法反超。绝对数字随机器不同而变化，请以自己的输出为准。

### 步骤 6：画耗时曲线

```python
import matplotlib.pyplot as plt

ks = [10, 100, 1000, 10_000, 100_000]
ts = [bench(lambda: sort_and_cut(data, k)) for k in ks]
th = [bench(lambda: queue_based_scan(data, k)) for k in ks]

fig, ax = plt.subplots(figsize=(7, 4))
ax.plot(ks, ts, "o-", label="sort and cut")
ax.plot(ks, th, "s-", label="queue-based scan")
ax.set_xscale("log"); ax.set_yscale("log")
ax.set_xlabel("k"); ax.set_ylabel("耗时 (ms)")
ax.set_title(f"Top-k 两种策略（n = {N:,}）")
ax.legend(); ax.grid(True, which="both", ls=":", alpha=0.5)
fig.tight_layout()
plt.show()
```

对照仓库 `教学产出/figures/fig_topk.png` 检查两条曲线的相对形状：小 k 时分离，大 k 时交叉。

### 步骤 7：验证堆的内存优势（可选）

```python
import sys

k = 10
# 堆法的工作集：k 行引用 + 堆列表
print("堆法保留行数:", k)
# 全排序的工作集：整表副本
print("全排序副本行数:", len(data))
# 用 sys.getsizeof 粗估列表结构本身（不含元素）：
print("k 元素列表 ≈", sys.getsizeof([None] * k), "字节；",
      "n 元素列表 ≈", sys.getsizeof([None] * N), "字节")
```

列表结构本身的差距已达数量级；真实数据库里行更宽，全排序往往还要落到磁盘外排（参考 PostgreSQL `work_mem` 默认 4 MB 的设定）。

## 六、观测记录表

n 固定为 1000000，每格填 5 次运行的中位数。

**表 1：耗时（ms）**

| k | 全排序后截断 | 堆扫描 | 堆/排序 加速比 |
| --- | --- | --- | --- |
| 10 |  |  |  |
| 100 |  |  |  |
| 1000 |  |  |  |
| 10000 |  |  |  |
| 100000 |  |  |  |

**表 2：交叉点探测（自己加测几组 k，找出堆法反超的位置）**

| k | 全排序 (ms) | 堆扫描 (ms) | 谁更快 |
| --- | --- | --- | --- |
|  |  |  |  |
|  |  |  |  |
|  |  |  |  |

**表 3：n 的影响（固定 k = 10）**

| n | 全排序 (ms) | 堆扫描 (ms) |
| --- | --- | --- |
| 100000 |  |  |
| 500000 |  |  |
| 1000000 |  |  |

## 七、思考题

1. 用大 O 记号比较两种策略的时间与内存，并代入 n = 10⁶、k = 10 算出比较次数的量级差。
2. `queue_based_scan` 里先做一次 `book.title < result[0].title` 的比较，而不是直接 `heappushpop`。这次比较省掉了什么？估计它最多能省多少次堆调整。
3. 为什么 k = 100000 时堆法反而比全排序慢？至少给出两个原因，并说明为什么"O(n log k) < O(n log n)"此时不再有指导意义。
4. 重复 title（ties）会让两种策略结果不同吗？`sort_and_cut` 与 `queue_based_scan` 各自怎么处理同键行？如何让结果完全可复现？
5. 如果表有 1 亿行、单机内存装不下，两种策略各会遇到什么问题？堆法的 O(k) 内存优势此时体现在哪里？外排序为什么要写盘？
6. `LIMIT 10` 去掉 `ORDER BY` 后，数据库还需要排序吗？返回的 10 行有保证吗？
7. 若在 `title` 列上建有 B+ 树索引，执行器可以怎样不扫全表就回答这个查询？什么情况下（查询条件变化后）索引方案会失效？
8. 把数据改成"已按 title 基本有序"（例如 90% 的行局部有序），Timsort 的表现会怎么变？堆扫描呢？这对计时实验的设计有什么提醒？

## 八、思考题答案与解析

1. 全排序：时间 O(n log n)、内存 O(n)；堆扫描：时间 O(n log k)、内存 O(k)。n = 10⁶、k = 10 时，log₂n ≈ 19.9、log₂k ≈ 3.32：全排序约 2.0×10⁷ 次比较；堆扫描主体是 n 次与堆顶的比较（10⁶ 次）加上极少数命中的 O(log k) 调整（命中比例约 k/n = 10⁻⁵），量级约 1.0×10⁶ 次，差约 20 倍。实测加速约 17 倍，与理论接近但略低——Python 解释器开销摊平了部分差距。
2. `heappushpop` 无论是否需要都会做一次"放入再弹出"的堆调整（O(log k)）；先比较堆顶，可在"新元素不如堆顶"时用 O(1) 直接跳过。随机数据下 k/n 的元素能通过比较（k = 10 时约十万分之一），即约 (1 − k/n)·n ≈ 10⁶ 次堆调整被这次比较省掉。这是堆法实测曲线平坦（对 k 不敏感之外）的重要原因之一。
3. 原因一：命中率从 10⁻⁵ 升到约 10%，约 10⁵ 次真实的堆调整，每次 O(log₂10⁵) ≈ 17 层；原因二：10 万元素加包装对象的堆有数 MB，远超 L2 缓存（参考量级 7 ns 一级延迟的层次结构里，缓存未命中代价成倍放大），每次调整沿着 17 层指针跳来跳去；原因三：对面的 Timsort 是 C 实现、顺序访问内存、还能利用运行中发现的局部有序。结论：复杂度记号隐藏了常数与访存模式，k/n 超过百分之几就该重新实测。
4. 同键行在 k 的边界上"谁进谁出"不确定：`sort_and_cut` 由排序算法的稳定性与初始顺序决定（Timsort 稳定，取原顺序靠前的），`queue_based_scan` 由堆内部结构决定，可能取到不同的具体行，但两法返回的 title 序列一致（测试断言正是按 title 比较）。要完全可复现，给比较键追加唯一次级键：`key=lambda b: (b.title, b.row_id)`，两法用同一键。
5. 全排序必须物化全部行（或至少全部排序键），装不下就外排序：分批排序写入临时文件，再多路归并，I/O 次数随数据量增长；堆扫描从头到尾只维护 k 行工作集，1 亿行流式扫过即可，内存依旧 O(k)。这是流式 / 管道执行器偏爱堆法的根本原因。外排序写盘是因为归并需要随机（或多路顺序）访问中间结果，内存放不下就只能落到文件（PostgreSQL 的 `work_mem` 默认 4 MB，超限即走这条路）。
6. 不需要排序。没有 ORDER BY 时 SQL 不承诺任何顺序，执行器可以任取 10 行（常表现为按扫描顺序返回）。同样的"LIMIT"语义在不同前提下成本天差地别——这正是本实验标题的另一半。
7. 在 title 的 B+ 树索引上从最左叶子开始顺序读，读完 10 行即停：成本 O(log n + k)，与 n 的大小基本无关。失效情形：查询带了不能走该索引的谓词（如 `WHERE author = …` 且 author 无索引），或需要按别的列过滤后再取前 k，此时仍需扫描候选集做 Top-k。
8. Timsort 会显著变快：它专门检测已有的升序/降序段（run），基本有序的数据接近 O(n)。堆扫描几乎不变：它做的是流式比较，不利用全局有序性。提醒：计时实验必须用"乱序随机"数据测最坏情形；若无意中生成了有序数据，会把全排序测得虚快，得到错误结论。步骤 1 用随机词表造数据正是为此。

## 九、扩展任务（选做）

1. **标准库对照。** 用 `heapq.nsmallest(k, data, key=lambda b: b.title)` 重做计时，与手写堆法比较。提示：`nsmallest` 的关键路径由 C 加速，手写纯 Python 堆大概率更慢；讨论"教学实现"与"工程实现"的差距。
2. **找交叉点。** 在 k ∈ [10⁴, 10⁵] 区间内二分探测"堆法开始慢于排序"的 k*，画出 k*/n 的比值。换几组随机种子重复，看比值是否稳定。提示：每测一个 k 都要重新计时两种策略，数据可复用。
3. **带过滤的 Top-k。** 给 Book 加一个 `year` 字段，实现 `WHERE year >= 2000 ORDER BY title LIMIT 10` 的堆扫描版本（不满足条件的行直接跳过）。讨论：过滤选择性很高（只剩几百行）时，先过滤再全排序是否反而更好？写代码验证你的判断。

## 十、常见错误与排查

| 现象 | 原因 | 处理 |
| --- | --- | --- |
| 两种策略返回结果不同 | 比较键不一致：一处用 `title`，另一处用对象默认比较 | 两法统一用 `key=lambda b: b.title`；`Book` 的 `__lt__` 反转只服务于堆 |
| `heapq` 报 `unorderable types` 或堆顶不是预期元素 | 忘了 `@total_ordering` 与 `__eq__`，或比较方向没反转 | 对照步骤 1 检查 `Book` 定义；堆顶应为"堆内最大 title" |
| 计时数字每次相差数倍 | 单次计时受 GC、系统调度影响 | 重复 5 次以上取中位数；必要时 `gc.disable()` 包住计时段 |
| 堆法在大 k 时测试断言失败 | `heappushpop` 的语义理解反了（它先 push 后 pop，不是先 pop） | 阅读官方文档确认语义；或拆成 `heappush` + `heappop` 验证 |
| 造 100 万行数据卡住 | 在循环里逐条调 `Faker` | 用步骤 1 的 `random` 词表造数据；Faker 只用于 notebook 小数据演示 |
| 曲线与参考图形状不同 | 纵轴未取对数，或 k 取点太稀疏 | `ax.set_xscale("log")`、`ax.set_yscale("log")`，并在 10⁴～10⁵ 之间加密取点 |
| 全排序把原数据顺序改掉了 | 忘了 `data.copy()`，计时循环互相污染 | 两个策略都必须在副本上运行（见步骤 2、3 的 copy） |
