真实面经题目 · 原创解析
TopK,如果k很小,n很大,用什么方法?
当 k 很小、n 很大时,TopK 的标准精确解通常是维护大小为 k 的堆,而不是全排序。求最大 TopK 用小顶堆,求最小 TopK 用大顶堆,复杂度 O(n log k)、空间 O(k)。如果数据可放入内存且允许原地修改,可以补充 quickselect;如果值域小、数据海量或允许近似,还要分别考虑计数桶、分片合并和近似频率算法。
真实面经题目 · 原创解析
当 k 很小、n 很大时,TopK 的标准精确解通常是维护大小为 k 的堆,而不是全排序。求最大 TopK 用小顶堆,求最小 TopK 用大顶堆,复杂度 O(n log k)、空间 O(k)。如果数据可放入内存且允许原地修改,可以补充 quickselect;如果值域小、数据海量或允许近似,还要分别考虑计数桶、分片合并和近似频率算法。
我会先问清楚是求最大的 k 个还是最小的 k 个,是否要求结果有序,数据是否能放入内存,是否是流式输入,是否允许近似。一般 k 很小、n 很大,不建议全排序,因为全排序 O(n log n) 会计算大量无关顺序。精确求最大 TopK 时,维护一个大小为 k 的小顶堆:先放前 k 个元素,之后每个新元素和堆顶比较,如果更大就替换堆顶并调整;堆顶就是当前 TopK 的门槛。这样整体 O(n log k)、空间 O(k)。如果求最小 TopK,则用大顶堆。若数据在内存数组里且可原地修改,可以用 quickselect 平均 O(n) 找第 k 大分界,但最坏会退化且结果无序。海量数据先按文件块或机器分片做局部 TopK,再合并成全局 TopK;值域小可用计数或桶;如果问的是高频 TopK 且允许误差,可以提 Count-Min Sketch、Space-Saving 等近似算法。
TopK 不是固定答案,先确认最大还是最小、是否需要排序输出、重复值如何处理、数据在内存还是磁盘、是否流式、是否允许误差。k 很小、n 很大意味着目标是过滤少量候选,而不是获得全部元素的全序关系。这个边界决定了堆、quickselect、桶或分布式方案哪个更合适。
全排序会把 n 个元素全部排成有序序列,复杂度通常是 O(n log n),但 TopK 只关心前 k 个元素,剩下 n-k 个元素之间的顺序没有价值。当 k 远小于 n 时,全排序为大量无关顺序付费。面试中要说明 TopK 是选择问题,不是排序问题,除非题目额外要求全部有序输出。
求最大的 k 个元素时,维护大小为 k 的小顶堆。堆里保存当前扫描过数据中的前 k 大,堆顶是这 k 个候选里最小的那个,也就是进入 TopK 的门槛。新元素不大于堆顶就丢弃;更大就替换堆顶并下沉调整。堆大小始终是 k,特别适合流式和内存受限场景。
如果题目改成求最小的 k 个元素,思路完全对称:维护大小为 k 的大顶堆,堆顶是当前 k 个最小候选中最大的那个。新元素更小就替换堆顶,否则丢弃。正确表达是堆顶代表最容易被淘汰的候选,所以最大 TopK 用小顶堆,最小 TopK 用大顶堆。
堆方案扫描所有元素一次,前 k 个建堆 O(k),之后每个元素最多触发一次 O(log k) 调整,整体可写成 O(n log k)、空间 O(k)。当 k 很小时,log k 很小,内存占用稳定,不需要保存全部数据。这也是日志流、搜索候选、榜单筛选和在线流式数据里最稳健的方案。
如果数据都在内存数组里,且允许原地修改,quickselect 是重要补充。它借鉴快排 partition,每次只递归包含第 k 大位置的一侧,不需要把两侧都排好,平均 O(n)、空间接近 O(1)。但最坏可能 O(n^2),工程上要随机 pivot 或更稳 pivot 策略,而且结果内部不一定有序。
如果元素是整数且取值范围不大,计数数组或桶可能比堆更快。先统计每个值出现次数,再从高到低或低到高累计直到取够 k 个,复杂度约 O(n+R),R 是值域大小。限制是值域必须可控;值域巨大、稀疏或非离散数据时,桶会浪费内存。
当 n 大到单机内存放不下时,要先分片。把大文件切成多个块,每块用大小为 k 的堆求局部 TopK,最后汇总所有局部 TopK,再做一次全局 TopK。每片最多贡献 k 个候选,合并阶段数据量从 n 降到 m*k,是单机外部处理和 MapReduce 的通用思路。
function topK(nums, k) {
const heap = [];
for (const num of nums) {
if (heap.length < k) {
heappush(heap, num);
} else if (num > heap[0]) {
heapreplace(heap, num);
}
}
return heap.sort((a, b) => b - a);
} 堆里只保留当前最有希望成为答案的 k 个元素。求最大 TopK 时,最应该被淘汰的是这 k 个候选里最小的元素,所以让它位于堆顶最方便。新元素只要大于小顶堆堆顶,就能挤进当前 TopK;如果用大顶堆,堆顶是最大值,反而不能快速判断淘汰对象。
数据是流式、无法全部放入内存或 k 很小时,优先大小为 k 的堆,因为空间 O(k),可以边读边处理。数据已经在内存数组里、允许原地修改且更关注平均性能时,可以选 quickselect。但 quickselect 结果无序,最坏会退化,需要随机 pivot 等策略降低风险。
先用大小为 k 的小顶堆求出最大的 k 个候选,然后只对这 k 个元素排序。总复杂度是 O(n log k + k log k),通常仍明显优于对 n 个元素全排序。也可以不断弹堆再反转,本质仍是只处理 k 个候选的顺序。
把数据按文件块或机器分片,每个分片在内存中用堆求局部 TopK,然后把局部结果汇总,再用同样方法求全局 TopK。理由是某个元素如果连所在分片的局部 TopK 都进不了,就不可能成为全局 TopK,可以安全丢弃。
当元素是整数、值域较小或能合理分桶时,计数和桶可以把比较问题转成统计问题,复杂度接近 O(n)。但如果值域巨大、稀疏或者元素不是离散整数,桶会占用大量无效内存,反而不如堆稳健。
近似算法更适合海量流里的高频 TopK,例如日志关键词、商品点击热点。Count-Min Sketch、Space-Saving、Misra-Gries 用较小内存估计高频项,但会有误差或需要二次校验,不适合必须精确返回数值前 k 大的场景。