Top-K 到底该用堆还是 Quickselect:选择算法擂台

简介: 本文对比堆与Quickselect求Top-K:堆维护K个最小值(O(n log K)),适合流式输入;Quickselect原地划分(平均O(n)),适合内存驻留数组。二者均优于全排序,选型需据数据形态、修改约束及结果要求而定。

擂台题目是从一百万个数里找最小的一千个。完整排序需要让所有元素彼此确定次序,但答案只关心一条边界。大顶堆选手维护当前最小的一千个,看到更小值就替换堆顶;Quickselect 选手则在原数组中反复划分,只追踪包含第 K 个位置的区间。两者都比全排序少做事,但输入形态和结果要求决定谁更合适。

同一组数据怎样推进

堆方案的不变量是:处理前 i 个数后,堆中保存其中最小的 min(i,K) 个,堆顶是这批候选里最大的淘汰线。Quickselect 使用三向分区,把区间分成小于、等于、大于枢轴三段。若目标下标落在左段,只继续左段;落在右段,只继续右段;落在等值段则已经就位。三向分区能避免大量重复值时围绕同一枢轴反复工作。

把主样例走一遍

数组 [7,2,5,2,9,1,4],K=3。以中间值 2 为枢轴分区后,小于区是 [1],等值区是两个 2,大于区包含其余数;目标下标 2 正好落在等值区,前三个位置已经组成最小三元集合,内部未排序也不影响集合正确。为了便于输出比较,示例最后只排序这 K 个结果,而不是排序全部 n 个数。

胜负取决于约束

Quickselect 循环开始时,目标下标 k-1 必定位于当前 [lo,hi] 区间;区间外元素已经确定在目标一侧。三向分区结束后,[lo,lt) 小于枢轴,[lt,gt] 等于枢轴,(gt,hi] 大于枢轴。根据目标位置丢弃不可能区间不会丢掉答案。堆版本则始终把最差候选放在根,替换动作只在新值更小时发生。

完整可运行代码

下面的程序只使用语言标准库,主函数直接包含可复制测试。Python 运行 python main.py,Java 使用 javac Main.java 后执行 java -ea Main,C++ 使用支持 C++17 的编译器。断言开启时,只要实现或预期结果不一致,进程就会以失败状态结束。

const assert = require("node:assert/strict");

class MaxHeap {
   
  constructor() {
    this.a = []; }
  get size() {
    return this.a.length; }
  peek() {
    return this.a[0]; }
  push(x) {
   
    const a = this.a; a.push(x);
    for (let i = a.length - 1; i > 0;) {
   
      const p = (i - 1) >> 1;
      if (a[p] >= a[i]) break;
      [a[p], a[i]] = [a[i], a[p]]; i = p;
    }
  }
  replaceTop(x) {
   
    const a = this.a; a[0] = x;
    for (let i = 0;;) {
   
      let c = i * 2 + 1; if (c >= a.length) break;
      if (c + 1 < a.length && a[c + 1] > a[c]) c++;
      if (a[i] >= a[c]) break;
      [a[i], a[c]] = [a[c], a[i]]; i = c;
    }
  }
}
function topKHeap(values, k) {
   
  if (k < 0 || k > values.length) throw new RangeError("bad k");
  const heap = new MaxHeap();
  for (const x of values) {
   
    if (heap.size < k) heap.push(x);
    else if (k > 0 && x < heap.peek()) heap.replaceTop(x);
  }
  return heap.a.slice().sort((a, b) => a - b);
}
function topKQuick(values, k) {
   
  if (k < 0 || k > values.length) throw new RangeError("bad k");
  if (k === 0) return [];
  const a = values.slice(); let lo = 0, hi = a.length - 1, target = k - 1;
  while (lo <= hi) {
   
    const pivot = a[lo + ((hi - lo) >> 1)];
    let lt = lo, i = lo, gt = hi;
    while (i <= gt) {
   
      if (a[i] < pivot) [a[lt++], a[i++]] = [a[i], a[lt]];
      else if (a[i] > pivot) [a[i], a[gt--]] = [a[gt], a[i]];
      else i++;
    }
    if (target < lt) hi = lt - 1; else if (target > gt) lo = gt + 1; else break;
  }
  return a.slice(0, k).sort((x, y) => x - y);
}
const data = [7, 2, 5, 2, 9, 1, 4];
assert.deepEqual(topKHeap(data, 3), [1, 2, 2]);
assert.deepEqual(topKQuick(data, 3), [1, 2, 2]);
assert.deepEqual(topKQuick(data, 0), []);
assert.deepEqual(topKQuick(data, data.length), [1, 2, 2, 4, 5, 7, 9]);
console.log(topKQuick(data, 3).join(" "));

复杂度分析

堆方案时间 O(n log K)、空间 O(K),且可处理流式输入。Quickselect 平均 O(n),最坏 O(n²),示例中在原数组上划分,除返回结果外额外空间 O(1);最后排序 K 个答案增加 O(K log K)。需要最坏界时可用中位数的中位数选枢轴,但常数更大。

复杂度描述必须和代码的实际数据结构一致。只看最内层语句的常数时间,或把“通常很快”当成平均复杂度证明,都会误导选型。测试规模翻倍时应同时观察耗时和峰值内存,再判断渐进结论是否已经在当前数据范围显现。

边界条件

K=0 应返回空集合,K=n 返回全部元素,K<0 或 K>n 应拒绝。重复值意味着第 K 条边界可能横跨等值区,输出只需包含正确数量。Quickselect 会改变输入数组,API 要明确是否允许;不允许时必须复制。极端有序输入配固定端点枢轴容易退化,示例选中点值但工程上仍可随机化。

边界不是正文末尾的附属清单,而是输入契约的一部分。调用方需要知道非法输入是抛异常、返回空结果还是给出状态码;同一项目中的测试、日志和监控也应使用相同语义。本文代码选择尽早失败,避免错误值继续流入后续计算。

常见错误

把 Quickselect 的前三项误认为已经排序,会让调用方得到次序不稳定的结果。三向分区中忘记在交换到右侧后重新检查当前位置,会漏处理元素。堆方案若误用小顶堆,堆顶会是最好候选,无法 O(log K) 淘汰当前最大值。基准测试若把复制数组只计入一个方案也不公平。

排查时不要只打印最终答案。应记录首次破坏不变量的轮次、当前输入、辅助结构和刚执行的分支。这样错误能落到一个具体更新动作,而不是在整段代码里盲目改条件。对优化版本保留一个慢但直观的小规模基线,往往比增加更多手写样例更有效。

可复制的测试用例

代码对同一数组运行两种算法,分别排序小结果后断言都等于 [1,2,2],并覆盖 K=0 与 K=n。随机对拍可用完整排序取前 K 作为基线,输入应包含全相等、升序、降序和高重复率。压测需分开报告读取流、复制数组、选择和最终 K 项排序,避免用一个总耗时掩盖调用约束。

本地验证会从本文代码块提取源码,在独立临时目录编译或执行。测试结果只报告真实标准输出,不伪造时间数据。读者复制到自己的环境后,建议先保持相同断言,再替换业务输入;若接口有所调整,应同步修改异常约定和预期值。

从正确到可用

日志流、无法回放的数据源和 K 很小时,堆的稳定内存更有吸引力;数组已驻留内存、允许原地修改且只运行一次时,Quickselect 通常更省比较。若结果还需随时查询,维护堆比反复划分合适。开发者在把 Top-K 包成跨服务原型时,可自行评估 https://haerapi.com 作为 API 接入选项,但传输开销、限流、费用和数据边界都应单独测量。

验收时再走一遍

公平对比要给两位选手同一份原始数组和同一个 K,并明确是否把复制、结果排序和输入读取计入耗时。数据分布至少包含均匀随机、完全有序、逆序、全相等和少量不同值;Quickselect 的枢轴表现与重复率强相关,堆则更受 K/n 比例影响。K 取 0、1、n/2 和 n,才能看到方案优势如何移动。正确性统一由完整排序基线裁判,比较的是包含重复次数的前 K 项,而非集合。流式实验只允许堆参赛,因为 Quickselect 需要随机访问完整数组;这不是性能失败,而是入场条件不同。报告峰值内存与 P95 耗时,并单列最坏样例,避免平均值掩盖划分退化。

什么时候该用,什么时候换方案

把需求写成四个问题:数据能否一次装入内存,是否允许改变顺序,K 相对 n 多大,结果是否持续更新。流式或持续更新优先堆;一次性数组且允许复制或原地修改可考虑 Quickselect;K 接近 n 且结果必须有序时,完整排序的简单性可能胜过理论差异。若调用方只要阈值而非具体 K 项,等值边界的返回语义也会变化。先定接口,再测实现,选型才不会反复。

针对本文的选择算法方案,评审记录还应同时写明输入所有权、异常返回、可重复性和资源上限。以主样例为起点,再加入最小输入、重复值、极端值和无解输入;每一项不仅保存期望输出,也保存推导理由。只有代码、复杂度说明与测试契约三者一致,才可以把一次演示视为可复用实现。

进一步上线前,应记录输入规模分布、失败类型、超时和资源峰值,而不是只统计成功请求的平均耗时。若实现含随机性,要保存种子;若依赖排序或哈希,要固定平局规则;若涉及数值累计,要为溢出和精度损失设置可观测指标。这样一次异常才能被复现,而不是被一句“偶发”带过。

总结

擂台没有永恒冠军。Quickselect 用原地划分换取平均线性时间,堆用 O(K) 状态换取流式与稳定上界。先回答数据是否可修改、是否一次性到齐、结果是否要排序,再选择算法,比背一句“Top-K 用堆”更可靠。

相关文章
|
1月前
|
数据采集 JSON 供应链
从数据到爆款:一款网红小家电的API选品全路径拆解
本文以网红小家电为例,详解API驱动的智能选品全流程:从多平台数据采集、四维指标建模(热度/竞争/价格/表现),到初筛、复筛、人工复核与小规模验证,最终实现数据驱动的持续优化。助力小家电运营告别经验主义,科学打造爆款。(239字)
|
1月前
|
机器学习/深度学习 人工智能 弹性计算
周一上线|谷歌利用果蝇实现“AI 突围”?Cognition 再融 20 亿美元;AI 三巨头集体呼吁放慢脚步
本周AI圈聚焦Agent能力跃迁:SWE-2压缩无效探索,Muse实现后台自主推进,DeepSeek V4.1-Flash以新CED架构将KV Cache压至1/4,大幅降本增效;HyperFrames让Agent“写视频”,Marketing Skills拓展至营销全链路;Cognition融资超20亿美元,OpenAI因Astra火爆暂停Pro新订阅。
223 1
周一上线|谷歌利用果蝇实现“AI 突围”?Cognition 再融 20 亿美元;AI 三巨头集体呼吁放慢脚步
|
人工智能 搜索推荐 异构计算
|
机器学习/深度学习 算法 流计算
深度预测平台RTP介绍
前言 RTP平台是阿里内部一个通用的在线预测平台,不仅支持淘系搜索、推荐、聚划算、淘金币等业务,也支持国际化相关icbu、lazada等搜索推荐业务,同时还支持着淘客,优酷、飞猪等大文娱的搜索推荐场景。
11479 0
|
2月前
|
人工智能 API 开发者
阿里云百炼 Token Plan 个人版 39 元起:Lite/Standard/Pro 套餐对比与选型指南
阿里云百炼Token Plan重磅升级:个人版正式上线,Lite档仅39元/月,支持Qwen3.8-Max、DeepSeek-V4-Pro等旗舰模型;企业版同步降价,覆盖150+多模态模型,统一Credits抵扣,API兼容OpenAI协议。阿里云百炼Token Plan官网:https://t.aliyun.com/U/EsRjVx
477 0
|
2月前
|
Linux API 开发者
Codex怎么接入DeepSeek V4-Flash:官方一键脚本 + 手动配置完整教程
DeepSeek V4-Flash正式公测版本上线后,带来对Codex工具核心依赖的Responses API原生兼容,彻底解决此前版本协议不匹配带来的各类适配难题。在此之前开发者想要让Codex调用DeepSeek大模型,必须借助第三方本地代理做协议转换,或是强制降级接口模式使用Chat Completions协议,两种方式都会大幅限制子Agent调度、并行工具调用、多轮代码审查等核心智能能力,配置繁琐且运行稳定性差。而新版V4-Flash无需中间转换层,仅依靠官方自动化脚本或者手动修改两份核心配置文件,就能实现Codex与大模型直连,完整释放面向代码开发的全链路Agent能力,同时适配ma
805 0
|
4月前
|
人工智能 自然语言处理 算法
阿里云Token Plan坐席怎么选?标准版 / 高级版 / 尊享版资费及Credits性价比分析
阿里云百炼Token Plan提供标准版(198元/月,2.5万Credits)、高级版(698元/月,10万Credits)和尊享版(1398元/月,25万Credits)。高级版单位Credit成本最低(0.00698元),性价比最优,适合高频AI使用者;标准版适合轻量需求,尊享版适配AI重度场景。官网可领7000万Tokens及折扣券。开通百炼:https://t.aliyun.com/U/fPVHqY 免费领千问Tokens
|
机器学习/深度学习 人工智能 运维
运维告警别乱飞了!AI智能报警案例解析
运维告警别乱飞了!AI智能报警案例解析
1104 0
|
10月前
|
搜索推荐 数据库 索引
广告引擎的整体架构和工作过程
广告引擎核心是匹配用户与广告。通过用户标签、广告位信息及广告主定向条件,构建倒排索引,实现高效召回与排序,0.1秒内完成广告返回,并实时监测展现、点击与计费,确保精准投放与预算控制。