跳到主内容

一文搞懂堆与 Top-K:优先队列、多路归并与海量数据选 Top-K 的完整套路 ​

「找出最大的 K 个」「最热门的 K 个」「最相似的 K 条」——Top-K 大概是工程里出镜率最高的算法需求:热搜榜单每分钟从亿级行为里挑前五十,推荐系统在候选池里召回最相似的几百条,日志平台要提取耗时最高的 K 条请求做告警。面试里它同样是必考题,因为它能在二十分钟内同时考察候选人对堆、快排划分与复杂度权衡的综合理解。

有意思的是,这道题的标准答案往往和多数人的第一直觉相反:求「最大的 K 个」,用的是小顶堆而不是大顶堆。方向一旦选反,整个解法的复杂度优势就荡然无存。这篇文章从问题背景、方案对比讲到可运行实现与海量数据场景,把 Top-K 的完整套路一次理清。

问题背景:为什么「排序取前 K」不是好答案 ​

最直觉的做法是把全部数据排序,再切出前 K 个。数据量小的时候这确实是最省事的方案;但一旦数据上量,它有两个致命问题。

第一是内存装不下。十亿条日志记录、几十 GB 的原始数据,不可能整块加载进内存再排序,「先排序再取前 K」这条路直接被堵死。第二是算力浪费。排序的代价是 O(n log n),可我们只关心 K 个元素——当 K 是 10 而 n 是十亿时,为剩下的 n−K 个元素维持全序关系毫无必要,这部分开销占了绝对大头。Top-K 问题的本质是:只维护「够进前 K 的资格线」,其余元素扫一眼就该扔。

这两个约束决定了答案的形态:内存上必须只占 O(K) 级别的空间,时间上必须做到「每个元素只被处理一次,且处理代价与 n 无关」。堆正好同时满足这两条。

方案对比与思路推导:三种方案的边界 ​

先把可选方案摆在一起看:

维度全排序后切片小顶堆维护快速选择(partition)
时间复杂度O(n log n)O(n log k)平均 O(n)
额外空间O(n)O(k)O(1)~O(n),视实现
结果形态完全有序堆内元素即答案前段无序,需再排 K 个
数据流适用不可行唯一可行不可行(需全部驻留)
海量数据需外部排序单遍扫描即可需多趟或全量内存

为什么求最大的 K 个要用小顶堆? 推理的关键是搞清楚「资格线」放在哪里。维护最大的 K 个,本质是维护一条准入门槛:新元素只要比「当前 K 个里最小的那个」大,就有资格进榜。小顶堆的堆顶恰好就是当前 K 个候选里的最小值——新元素与堆顶比一次,就能决定进榜还是淘汰;进榜时用堆替换操作完成调整,代价 log k。反过来,如果用大顶堆,堆顶是 K 个里的最大值,它对「新元素有没有资格」毫无判断力,判断一次要扫遍整个堆,等于退化成暴力。

复杂度推导。 n 个元素每个最多触发一次 O(log k) 的堆调整,总计 O(n log k)。对比排序的 O(n log n):当 k 远小于 n 时(比如 k=100、n=10^8),log k ≈ 7 而 log n ≈ 27,加上常数与缓存友好度,堆方案优势是数量级的。空间上堆里始终只有 k 个元素,O(k),这正是它能跑在数据流上的原因。

多路归并是同一套思维的延伸。 合并 k 个有序列表(或 k 个有序文件)时,任何时刻的「下一个输出」只可能是各路当前头部之一——把 k 个头部放进一个小顶堆,每次弹出全局最小,再从该路补一个进来,堆的大小始终是 k。

实操代码:数据流 Top-K 与多路归并 ​

先看数据流场景的 Top-K,Python 的 heapq 是标准小顶堆:

python
import heapq

def top_k_largest(nums: list[int], k: int) -> list[int]:
    """返回最大的 k 个数(升序输出)。内存只占 O(k),适合数据流。"""
    if k <= 0:
        return []
    heap: list[int] = []                 # 小顶堆:堆顶是当前 k 个候选中的最小值
    for x in nums:
        if len(heap) < k:                # 堆未满,先填满
            heapq.heappush(heap, x)
        elif x > heap[0]:                # 比当前门槛大,替换堆顶并下沉
            heapq.heapreplace(heap, x)   # 弹堆顶并入堆,一步完成,比 pop+push 快
    # 此时堆内就是全局最大的 k 个,逐个弹出即有序
    return [heapq.heappop(heap) for _ in range(len(heap))]

再看 k 路归并——外部排序、合并多个有序日志文件的通用底座:

python
import heapq

def merge_k_sorted(lists: list[list[int]]) -> list[int]:
    """k 路归并:任意时刻堆中只保存 k 个候选(各路的当前头部)。"""
    # 三元组 (值, 路编号, 路内下标):路编号保证值相同时比较不报错且结果稳定
    heap = [(lst[0], i, 0) for i, lst in enumerate(lists) if lst]
    heapq.heapify(heap)                  # 建堆 O(k)
    result: list[int] = []
    while heap:
        val, li, idx = heapq.heappop(heap)   # 取全局最小 O(log k)
        result.append(val)
        nxt = idx + 1
        if nxt < len(lists[li]):             # 该路还有余量,补给堆
            heapq.heappush(heap, (lists[li][nxt], li, nxt))
    return result

两段代码的复杂度:Top-K 为 O(n log k)、空间 O(k);k 路归并处理总数 N 个元素为 O(N log k)。海量数据工程落地上有两个标准套路:其一,外部排序 + 多路归并——数据分块排序后落盘,把上面 merge_k_sorted 里的列表换成逐文件的迭代器即可,堆中依旧只有 k 个头部;其二,哈希分片——按 key 哈希把相同元素固定落到同一分片文件,各分片独立求 Top-K 后汇总再取一次,适合「统计每类元素频次再选 Top-K」的场景。而数据只有一次遍历机会(典型如流水线日志)时,堆方案几乎是唯一选择:边读边维护资格线,扫描结束答案就在堆里。

避坑总结 ​

  1. 堆方向选反是第一大坑。求最大 K 个用小顶堆,求最小 K 个用大顶堆;Python heapq 只有小顶堆,求最小 K 个要么存负数,要么直接用 heappushpop/nsmallest 这些现成接口。
  2. heapreplace 与「先 pop 后 push」不等价。前者一次下沉完成调整且堆大小恒不超限;后者两次调整、瞬时元素数可能多一,高频调用时性能差距明显。
  3. 多路归并必须过滤空列表。建堆时没把空列表筛掉,取 lst[0] 直接越界;同理补位前必须检查下标是否越界。
  4. 自定义对象进堆要提供可比的 key。对象没有自然序时比较会抛异常,惯用做法是塞入 (key, 序号, 对象) 这样的元组,用序号兜底保证同 key 情况下比较不落到对象本身。
  5. 快速选择不是万能替代。它平均 O(n) 看似更优,但最坏 O(n²)(pivot 挑选需随机化)、会打乱原数组、结果无序,且不支持数据流。「一次性批量、内存够、只要前 K 个无序」才轮到它;有流式或海量约束时,堆 + 归并是更稳的工程解。

写在最后 ​

Top-K 的所有变体——榜单、召回、归并、告警——背后都是同一个模型:用一条 O(k) 的资格线代替全量排序。堆只是这条资格线最高效的载体。理解了「小顶堆堆顶即门槛」这层直觉,再遇到「前 K 高频」「第 K 大」「数据流中位数」这类题,都能沿同一条思路推出来,而不是背一个又一个孤立模板。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。