☰
堆排序与TOP-K实战:建堆O(n)、小顶堆筛选与海量分片
2026/10/26 14:19:44 网站建设 项目流程

堆排序和 TOP-K,是我这些年带人、看代码、做评审时出现频率最高的两个话题。有意思的是,很多人能手写出代码,却讲不清建堆为什么是 O(n)、求最大 K 个为什么反而要用小顶堆。这两个问题我在不同场合被问过不下二十次,每次都能看到对方眼神里那种“好像懂又好像哪里不对”的犹豫。这篇就按我自己的理解顺序来拆,从堆这个数据结构的物理本质讲起,把堆排序的每一步动机说透,再把 TOP-K 从单机内存版一路推到海量数据分片版,中间该有的参数计算、代码实现、踩坑记录都不省。

适合阅读的人群我大致分三类:正在啃数据结构教材、需要把课本伪代码变成能跑的程序的学生;工作中要处理排行榜、热门统计、日志分析这类需求的后端;以及准备面试但不想只会背模板的人。下面所有代码以 Python 为主,因为它的可读性最好,关键位置我会标注 C++ 或 Java 里的等价写法和差异。

1. 先说清楚:为什么这两类问题都该用堆来做

在动代码之前,有个认知得先对齐:堆不是一种“更快的数组”,它是一种只维护偏序关系的结构。你付出的是“全局有序”这个能力,换来的是“取最值”这件事的极致便宜。想明白这个交换关系,后面所有设计选择都会变得理所当然。

1.1 堆的物理结构就是一个数组,别被树形图骗了

教材上画堆永远是一棵完全二叉树,这容易让人以为要写节点类和指针。实际上完全二叉树的节点编号是连续的,直接用数组存就行,父子关系靠下标算:下标i的左孩子是2i+1,右孩子是2i+2,父节点是(i-1)//2。这个映射关系是堆能省内存的根本原因,也是它缓存表现比链表好的原因。

我用一个生活化的类比来解释这件事:假设公司有一百号人,按工号排成一列,堆的规则是“每个主管的绩效必须不低于他直接下属”。你不需要知道全公司的完整排名,甚至不需要知道隔壁部门谁高谁低,只要保证这条局部规则处处成立,那么工号 0 那位一定是全公司最高的。这就是堆的全部承诺,代价极低,收益明确。

反过来说,如果你要的是“第三名是谁”“第 50 名和第 51 名差多少”,堆就帮不上忙,你得把整个数组排一遍。这个边界感在选型时非常关键,我见过不少代码明明只需要最值,却顺手写了个全排序,多出来的开销完全没必要。

1.2 堆排序和 TOP-K 的共同内核:都在反复问“现在最大的在哪”

把这两个问题摆在一起讲,不是因为它们常被放在同一章,而是因为它们共享同一个动作模式。堆排序是“反复取走当前最大值,然后让剩下的重新满足堆序”;TOP-K 是“反复拿新元素跟当前这 K 个里最小的比,比它大就替换”。前者在收缩堆,后者在维持一个固定大小的窗口。

这个共性带来的直接好处是:你只要把下沉这一个操作写对,两个问题就都解决了。堆排序用下沉来修复被换走的堆顶,TOP-K 用下沉(或上浮)来修复被替换的堆顶。我在教别人的时候一般会强调,先别急着写两个完整程序,把sift_down写扎实,剩下的都是循环和边界条件的排列组合。

而且这两个场景对“稳定性”要求都很低。堆排序不稳定,TOP-K 在数值相等时返回哪个也无关紧要。如果业务上真的要求相同值保持原顺序,那说明你要的其实不是 TOP-K,而是“按某个键排序后取前 K 个”,这时候老老实实排序更省心。

1.3 三种情况,我建议你绕开堆

不是所有取最值的场景都该上堆,这几点我有过教训。第一,数据量很小的时候,堆的常数开销比一次简单遍历还大,比如在一百个数里找最大值,一个 for 循环就完事了。第二,你要的是“第 K 大”这单个值而不是“前 K 个”,那快速选择类算法的平均表现更好,堆反而绕远路。第三,数据已经基本有序,堆排序的建堆阶段会把这个优势全部打乱,插入排序或者 Timsort 会舒服得多。

还有一个容易忽略的点:如果数据全在内存里且规模可控,Python 的sorted是 C 层实现,常数因子小得惊人,堆排序那点理论优势在实测中经常被抹平。我在某次日志分析里做过对比,对八十万条记录取前一千,直接用sorted反而比手写堆快了一点点,因为数据本身小。最后还是选了堆的版本,理由不是快,是内存占用稳定且可预测。

2. 堆排序:一次建堆加 n 次下沉,每一步都有讲究

堆排序的骨架只有两句话:先把整个数组调整成大顶堆,然后不断把堆顶换到末尾、堆的有效范围缩小一格、再修复堆顶。听起来简单,但“建堆从哪开始”“为什么是从后往前”“为什么升序要用大顶堆”这几个问题,能筛掉一大半自称会写堆排序的人。

2.1 建堆为什么必须从最后一个非叶子节点倒着走

先说结论:最后一个非叶子节点的下标是n//2 - 1。推导过程很短,最后一个节点下标是n-1,它的父节点是(n-1-1)//2,也就是n//2 - 1。从它开始,依次往前处理到下标 0。

为什么倒着走?因为下沉操作有一个前提:被下沉节点的左右子树必须已经是合法的堆。叶子节点没有孩子,天然满足堆序,所以它们是“免费的”。我们只需要处理有孩子的那些节点,而倒序遍历正好保证处理到某个节点时,它下面所有子树都已经调好了。这个逻辑和动态规划从后往前填表是一个味道。

注意:这里最容易写错的是起始下标。写成n//2会漏掉一个节点,写成n//2+1会多处理一个叶子(不报错但白跑一次),写成n-1虽然结果可能对,但做了一堆无用功。建议直接记住n//2 - 1,n=1时循环不执行,刚好正确。

我在评审时见过一种写法,用while从(n-1)//2开始递减,对某些 n 也对,但边界把握不如直接算n//2 - 1来得干净。这类小地方不值得炫技,写清楚比写巧更重要。

2.2 两种建堆方式的代价差了一个数量级

这是我最想强调的一段,因为它直接决定你会不会把 O(n) 的算法写成 O(n log n)。

方式一,从上往下插:从空堆开始,每次把新元素放到数组末尾,然后向上浮动到合适位置。每个元素最坏浮动到根,代价是当前树高,总代价是 ∑log(i),也就是 O(n log n)。

方式二,从下往上下沉:先把整个数组当成一棵完全二叉树(此时它大概率不满足堆序),然后从最后一个非叶子节点开始倒着下沉。总代价是 O(n)。

为什么差这么多?直觉上解释:方式一中,越靠后的元素越多,而它们恰恰要走最长的路径;方式二里角色反过来了,绝大多数节点在树的底层,它们只需要走很短的距离,真正走长路的只有根附近那极少数几个节点。大多数元素都是“便宜的”,少数元素才“贵”,所以总量摊下来是线性的。

严格一点的推导我放在第 4 章,这里先记住结论:面试或者写代码时,建堆一定用自底向下的下沉式,别用逐个插入。

2.3 排序阶段:升序为什么非得用大顶堆

很多人第一反应会搞混:升序排序,不是应该用小顶堆,每次取最小值放前面吗?逻辑上没错,但工程上不划算。

关键在于堆只能高效操作堆顶。如果用小顶堆,你每次取走最小值之后,得把它放到数组的开头,而数组开头正是堆顶的位置,你没法这么干,除非额外开一个结果数组,空间就从 O(1) 变成 O(n) 了。

用大顶堆就顺了:最大值在堆顶,把它和数组末尾交换,最大值就正好落在了它最终该在的位置(数组最后),然后堆的有效范围减一,对新堆顶做一次下沉。整个过程原地完成,空间 O(1)。代价是每轮下沉 O(log n),总共 n 轮。

心得:这个“堆顶和末尾交换”的技巧,本质上是把堆当成一个可以原地收缩的容器。同样的思路在做原地选择类问题时反复出现,值得单独记一笔。

2.4 完整实现与逐行注释

下面这份实现我在多个场景用过,逻辑没动过,只是把变量名改得更直白了一些。

def sift_down(arr, start, end): """在 arr[start..end] 范围内,把 start 位置的元素下沉到合适位置。 前提:start 的左右子树都已经是合法的大顶堆。 """ root = start while True: child = 2 * root + 1 if child > end: # 越界,说明已经是叶子 break # 挑出两个孩子里更大的那个 if child + 1 <= end and arr[child + 1] > arr[child]: child += 1 if arr[root] < arr[child]: # 违反堆序,交换后继续往下 arr[root], arr[child] = arr[child], arr[root] root = child else: break # 已经满足,提前退出 def heap_sort(arr): n = len(arr) if n < 2: return arr # 阶段一:自底向上建堆,O(n) for i in range(n // 2 - 1, -1, -1): sift_down(arr, i, n - 1) # 阶段二:依次取出堆顶放到末尾,O(n log n) for end in range(n - 1, 0, -1): arr[0], arr[end] = arr[end], arr[0] sift_down(arr, 0, end - 1) return arr

几个细节值得单独点出来。sift_down的参数是闭区间[start, end],这个end是堆的有效边界,不是数组边界,所以在排序阶段它会不断收缩。选孩子的时候用了严格大于arr[child + 1] > arr[child],相等时不动,这会影响相同值元素的最终次序,也就是不稳定性的来源之一。循环里else: break那行很关键,一旦当前节点已经比孩子大,就说明整条路径都不需要再调整了,提前退出能省不少比较。

2.5 堆排序的三个先天短板

第一是不稳定。给个具体例子就很清楚了:数组[5, 4a, 4b, 3],建堆后是[5, 4a, 4b, 3];第一轮把 5 换到末尾,堆区变成[3, 4a, 4b],下沉后是[4a, 3, 4b];第二轮把 4a 换到堆区末尾,得到[4b, 3, 4a, 5]。最终 4a 和 4b 的相对次序反了。原因就是交换堆顶和末尾这个动作跨越了任意远的距离。

第二是缓存不友好。下沉过程是i → 2i+1这样的跳跃访问,跨度大,CPU 预取基本帮不上忙。快速排序虽然也是递归跳跃,但在划分阶段是顺序扫描,局部性好得多。这也是为什么在实际基准测试里,堆排序往往跑不过同复杂度的快排。

第三是常数偏大。建堆虽然只有 O(n),但每个非叶子节点都要做比较和可能的交换,实际指令数不少。数据规模不大时,这点开销能被简单排序的简洁性反超。

3. TOP-K:求最大 K 个,偏偏要用小顶堆

这一节的核心就是一个反直觉的结论,我在面试中听到过太多错误的答案。先说结论:求最大的 K 个元素,用小顶堆;求最小的 K 个元素,用大顶堆。跟直觉完全相反,但逻辑非常干净。

3.1 为什么维护“最大的 K 个”要用小顶堆

想象你手里有一个只能装 K 个球的漏斗。你要做的事情是:决定新来的球要不要留下。如果新球比漏斗里最小的那个还小,那它肯定进不了最终的前 K,直接扔掉;如果比最小的大,那就把最小的挤出去,把它塞进来。

所以你需要随时知道“当前这 K 个里最小的谁”——这正好是小顶堆的堆顶。堆顶天然就是这个集合里的最小值,一次比较 O(1),替换后修复堆 O(log k)。整个流程顺畅得不像话。

反过来想,如果求最大 K 个却用大顶堆,堆顶是当前 K 个里的最大值,你拿新元素跟最大值比,比完了也不知道该不该替换,因为你不清楚这个集合里最弱的是谁。要么把整个堆遍历一遍,要么换结构。这就是很多人把逻辑写反之后代码能跑但结果不对的根本原因。

提示:这个结论记不住的话,可以用一句话概括——“用一个卡的住门的下限来筛选”。求最大的 K 个,门槛就是这 K 个里的最小值;求最小的 K 个,门槛就是这 K 个里的最大值。

3.2 K 值怎么定,内存先算清楚

TOP-K 真正的价值不在时间复杂度,在空间。举个例子,假设你要从十亿条访问日志里找出访问次数最多的 1000 个接口。每条记录按 200 字节算,全量加载需要接近 200GB,普通机器直接歇菜。而维护一个大小为 1000 的小顶堆,哪怕每条记录带一个长字符串,占用也就几百 KB 级别,完全无压力。

这里有个取舍需要说明:K 越小,堆操作的代价越低,但每次替换的概率也越高;K 越大,堆越占内存,但新元素“游进去”的机会多,比较后命中的比例下降。实测中 K 在千级别到万级别时,O(n log k)里的log k大概是 10 到 14,跟log n的差距其实不大。所以选堆做 TOP-K,第一动机永远是内存和可流式处理,不是理论复杂度。

如果 K 已经接近数据总量的三分之一甚至更多,我就会考虑直接排序或者快速选择。堆的优势区间是“K 远小于 n”,这个“远小于”我个人的经验阈值大概是 K < n/10,超出之后收益就不明显了。

3.3 手写版本和调库版本

生产代码我一般直接用heapq,它的实现是经过大量验证的 C 加速版本。但手写一遍有助于理解每一步在干什么。

import heapq def top_k_largest(nums, k): if k <= 0 or not nums: return [] heap = [] # 小顶堆,里面放当前的候选 for x in nums: if len(heap) < k: heapq.heappush(heap, x) # 还没装满,直接塞 elif x > heap[0]: # 比门槛大,才替换 heapq.heapreplace(heap, x) return sorted(heap, reverse=True) # 堆本身无序,要排一下才好看

手写堆版本的话,核心就是sift_up(用于插入)和sift_down(用于替换堆顶后修复),这两段写出来和堆排序那份是共通的。区别在于 TOP-K 的堆大小固定为 K,所以下标边界一直是k-1,不需要收缩。

有个细节容易被忽略:heapq是标准小顶堆,如果要处理“按某个字段比较”的场景(比如根据访问次数取 Top-K 接口名),常见做法是往堆里塞元组(count, name)。这里有个坑,当count相同时 Python 会去比较第二个元素name,如果name是不可比较的对象(比如自定义类),就会直接抛异常。解决办法是塞(count, 自增序号, name),或者用一个包装类只实现__lt__。

3.4 海量数据:分片求 TOP-K 再合并

单机内存放不下的时候,走分片。思路是:把所有数据按某种规则切成若干份,每份单独求出 Top-K,得到 m 个大小为 K 的候选集,再对这 m×K 个元素求一次 Top-K。

正确性我想强调一下,因为有人会担心“全局 Top-K 会不会被分片切散”。不会。假设某个元素进了全局前 K,那么在它所在的那个分片里,它一定也进了前 K(因为同分片里比它大的最多也就 K-1 个)。所以每个分片只要保留自己的前 K,就不会漏掉任何一种全局可能。

def top_k_sharded(chunks, k): """chunks 是一个可迭代对象,每次产出一批数据(比如按行读文件)""" heap = [] for chunk in chunks: local = top_k_largest(chunk, k) # 分片内先筛一遍 for x in local: if len(heap) < k: heapq.heappush(heap, x) elif x > heap[0]: heapq.heapreplace(heap, x) return sorted(heap, reverse=True)

分片数量怎么定?我一般让每片控制在内存能承受的 1/3 左右,留出余量。太多了会导致中间候选集膨胀(m×K),二次合并反而变慢;太少了单批处理会撑爆内存。

3.5 数据流场景:堆本身就是答案

实时排行榜、热门搜索词、接口调用 Top-N 监控,这类场景的数据是持续到来的,你没法“先收完再算”。这时候堆就不只是手段,而是唯一合理的结构——维护一个固定大小的堆,来一条处理一条,任何时刻取出来都是当前的前 K。

from collections import Counter def top_k_frequent(stream, k): freq = Counter() heap = [] for item in stream: freq[item] += 1 cnt = freq[item] # 简化演示:实际中用 (cnt, item) 元组或包装类维护堆 if len(heap) < k: heapq.heappush(heap, (cnt, item)) elif cnt > heap[0][0]: heapq.heapreplace(heap, (cnt, item)) return heap

注意这里的“频率统计 + Top-K”是经典组合,先用哈希表把计数攒起来,再用堆取前 K。哈希表本身也有内存上限,超大规模下需要用分片哈希或者 Sketch 类近似结构,不过那已经超出这篇的范围了,知道有这么个延伸方向就行。

4. 复杂度推导与参数计算实录

理论部分如果不落到具体数字上,很容易变成背结论。这一章我把建堆和 TOP-K 的复杂度各自推一遍,再给几个能直接算的参数对比。

4.1 建堆 O(n) 的推导,用等比数列收尾

设完全二叉树的高度为 h,根在第 0 层,第 k 层有 2^k 个节点,这些节点最多需要向下移动 h-k 层。总代价是:

T(n) = Σ(k=0 到 h) 2^k × (h - k)

令 j = h - k,则 k = h - j,代入得:

T(n) = Σ(j=0 到 h) 2^(h-j) × j = 2^h × Σ(j=0 到 h) j / 2^j

后面那个求和 Σ j/2^j 是收敛的,j 从 0 到无穷时的极限是 2。所以:

T(n) ≤ 2^h × 2 = 2^(h+1)

而完全二叉树n和h的关系是2^h ≤ n < 2^(h+1),所以2^(h+1) < 2n,即T(n) < 2n。

这就完了,总代价严格小于 2n 次操作,是货真价实的 O(n)。这个推导的关键在于:层数越深节点越多,但每个节点移动的步数越少,两者相乘之后被 2 的幂次压住了。相比之下,逐个插入建堆的代价是每层都要付出,没有这种抵消,所以才是 O(n log n)。

4.2 排序阶段为什么是 O(n log n),顺手算个具体数

建堆完成后,每次把堆顶(当前最大值)换到末尾,堆的有效规模减一,然后对新堆顶下沉。第 i 轮(i 从 1 开始)堆的大小是n-i+1,下沉最多走log2(n-i+1)层。总代价:

Σ(i=1 到 n-1) log2(n-i+1) ≈ log2(n!) ≈ n log2(n) - 1.44n

严格来说log2(n!)通过斯特林公式约等于n log2 n - 1.44n,所以是 O(n log n) 量级,同时也说明系数接近 1,没有隐藏的大常数。

拿具体数字感受一下:n = 10^7,log2(10^7) ≈ 23.25,总比较次数大约 2.3 亿次。这个量级在现代 CPU 上单线程跑完大概需要十几秒,主要还是受制于缓存不友好的随机跳跃访问。

4.3 TOP-K 的 O(n log k) 是怎么来的

扫描 n 个元素,每个元素做一次和堆顶的 O(1) 比较。只有比堆顶大(或小,取决于求 Top-K 大还是小)的时候才触发替换,替换后修复堆的代价是 O(log k)。

所以严格的最坏情况是:如果数据是升序来的,每个新元素都比堆顶大,那每次都要替换,总共 n 次O(log k),就是O(n log k)。但如果数据是随机分布的,实际替换次数远小于 n,均值大概是k × ln(n/k)量级,实际跑起来会比最坏情况快很多。

对比一下全排序的O(n log n):n = 10^8,k = 10^4 时,log2(n) ≈ 26.6,log2(k) ≈ 13.3,理论差距约 2 倍。但如果考虑到堆排序要额外做建堆和 n 次堆顶交换,而 TOP-K 只是单向扫描加偶尔替换,实测差距通常会更大一些。当然,最大的差距还是在内存上。

4.4 几种方案横向对比,选型时直接看表

方案时间复杂度空间能否流式稳定性适用场景
堆排序O(n log n)O(1)否不稳定内存严格受限、对稳定性无要求
快速排序平均 O(n log n)O(log n)否不稳定通用排序,实际最快
归并排序O(n log n)O(n)是稳定需要稳定或外排序
小顶堆 TOP-KO(n log k)O(k)是无关K 远小于 n,内存受限
快速选择平均 O(n)O(1)否不稳定单机内存足够,只要前 K 无序
全排序后截取O(n log n)O(n)否看排序算法n 不大、代码越短越好

快速选择值得单独说一句。它的思路是借用快排的划分过程,每次划分后只递归包含目标位置的那一半,平均复杂度是 O(n)。但它有 O(n²) 的最坏情况(比如每次划分都极不均衡),工程上一般用随机化或三点取中来缓解,C++ 的nth_element用的是一种叫 introselect 的混合策略。如果你的数据全在内存且只需要“前 K 个是谁”而不要求有序,快速选择确实更快,我做过对比,千万级数据下比堆版本快接近一倍。

不过快速选择有两个硬伤:一是必须能随机访问全部数据,流式场景用不了;二是它会打乱原始数组的顺序,如果原数组还要留着用,就得先复制一份,空间优势也没了。

5. 常见问题与排查技巧实录

这一章全是我和身边人真实踩过的坑,按现象归类整理,遇到问题可以直接对着查。

5.1 TOP-K 结果不对,先查这三个地方

第一个怀疑对象是堆的类型搞反了。求最大 K 个用了大顶堆,症状是结果里混进了一堆中间值,而且越到后面越离谱。判断方法很简单:随便造一组数据[1, 2, 3, 4, 5],取 Top 3,正确结果应该是[5, 4, 3],如果跑出来是[1, 3, 5]之类,基本就是堆类型错了。

第二个是替换条件写成了>=或者写反了方向。边界条件上,x > heap[0]和x >= heap[0]在数值上结果相同(因为等于的时候换不换都一样),但如果堆里存的是带序号的元组,>=会导致相同值频繁替换,白白增加操作次数。更糟的是写成x < heap[0],那结果会完全颠倒。

第三个是最后忘了排序直接返回堆数组。堆的内部结构只保证堆顶是最小值,其余部分的顺序是乱的。我见过有人直接把heap当结果返回给前端,页面上显示的排行榜顺序诡异,排查了半天才发现问题在这里。

5.2 堆排序看起来对,其实只是赶巧了

有一种情况很迷惑:小数据量测试全过,一上生产就出错。常见原因是下沉函数里的边界判断写成了child < end而不是child <= end。在偶数个元素、某些特定堆形态下,最后一次下沉恰好不需要访问边界元素,测试就蒙混过关了。

我的做法是固定用几组“刁钻”数据做回归:全相同元素、严格升序、严格降序、只有两个元素、只有一个元素、奇数个和偶数个各一组。尤其是全相同元素这一组,能同时验证比较符号的方向和提前退出的逻辑。

另一个隐蔽问题:在 Python 里arr[i], arr[j] = arr[j], arr[i]是安全的元组交换,但在 C/C++ 里手动写的交换如果不注意,或者在 Java 里对同一个下标做交换,可能引入难以发现的错误。Java 的数组元素访问本身没有别名问题,但要小心sift_down里root和child更新顺序颠倒。

5.3 性能不达预期,通常是这几个原因

跑得比预期慢,第一件事是确认建堆用的是下沉式还是插入式。插入式建堆在小数据上感觉不出来,到了千万级就会明显拖后腿,因为多了一整个数量级的操作。

第二件事是看有没有做“提前退出”。下沉循环里如果当前节点已经满足堆序,必须立刻 break,不能继续往下白跑。这个优化能省下大量无意义比较,尤其是在数据接近有序的时候。

第三件事比较隐蔽:在 TOP-K 场景里,如果 K 设置得过大(比如接近数据量的三分之一),替换操作会变得非常频繁,每次替换都是 O(log k),实际总代价可能反超直接排序。我的做法是先估一下 K/n 的比例,超过 0.1 就换方案。

5.4 常见问题速查表

现象最可能的原因排查动作
排序结果局部有序整体乱建堆起点错误检查是否为n//2 - 1
结果完全倒序升序用了小顶堆改为大顶堆,或反向输出
TOP-K 结果数量不足 K数据总量小于 K 但没做保护加k = min(k, len(nums))
TOP-K 结果里有不该出现的值替换比较符号写反用[5,1,3,2,4]取 Top2 验证
大数据量下内存暴涨堆里存了完整记录而非引用堆中只存键和标识,落盘取详情
排序后相同元素顺序变了堆排序本身不稳定改用归并,或附加原始下标参与比较
运行时间忽长忽短数据分布导致的替换次数波动先统计替换次数,再决定是否换方案

6. 我在实际取舍中攒下的几条经验

写到这里,代码和推导都齐了,最后说几个不太适合放进教科书、但确实影响决策的点。

关于堆排序的定位,我现在基本把它当成“内存极度紧张时的兜底方案”。它原地、O(1) 额外空间、最坏情况也稳定在 O(n log n),这三点在嵌入式或者超大数组场景里是硬优势。但如果空间不是瓶颈,我会优先用库里的排序,理由前面提过,常数因子和缓存友好性的差距很难用理论弥补。

关于 TOP-K,我现在的默认路径是:数据能全进内存且只算一次,优先快速选择;数据流式到来或者 K 很小,用小顶堆;数据大到一个机器装不下,分片加堆。这三条覆盖了我遇到过的绝大多数需求。跨机器分布式求 Top-K 的思路也是一样的,只是把“分片”换成了“分节点”,每个节点算本地 Top-K 再汇总,合并阶段的候选集大小是节点数 × K。

还有一个边界条件清单,我每次写这类代码都会过一遍:K 小于等于 0、K 大于数组长度、数组为空、数组只有一个元素、数组元素全部相等、数组里含有无法直接比较的类型。这几条看着琐碎,但每一条我都在真实代码里见过对应的线上问题。

最后分享一个小技巧。调试堆相关代码时,与其加一堆打印,不如写一个校验函数is_valid_heap(arr),遍历一遍确认每个父节点都不小于(或大于)它的孩子。这个函数能把“结果错了但不知道错在哪”直接缩小到“建堆阶段就错了”还是“排序阶段错了”,排查效率提升非常明显。这套校验我到现在还留在自己的工具库里,每次写堆都会顺手带上。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询