堆排序与Top-K:从堆结构到优先队列的极值处理思维
2026/9/7 20:06:45 网站建设 项目流程

堆排序在“八大排序”里一直是个很特别的存在。你说它难吧,代码模板背下来也就十几行;你说它简单吧,很多人学完只记住了“建堆、交换、再调整”这个流程,换个Top-K场景就不会用了。这篇我想换一条思路来讲:不把堆排序当成一个孤立的排序算法,而是从“堆结构”本身出发,把堆排序和Top-K问题串成一条线。你会发现,这两件事本质上用的是同一套机制——堆顶的极值、上滤下滤的调整、以及“用空间换时间”的取舍。无论你是准备面试、刷LeetCode,还是工作中要处理“取前100个最大订单”“维护热搜榜Top-K”这类需求,这篇文章都能给你一套可以落地的思路。

1. 堆结构到底是什么:数组里藏着一棵“逻辑树”

很多人一听到“堆”就觉得要画二叉树、写指针,其实堆的逻辑模型确实是树,但物理存储上就是一个数组。这种“逻辑是树、物理是数组”的设计,是堆一切优秀特性的根源。

1.1 堆的数学定义:完全二叉树 + 父大于子

堆首先是一棵完全二叉树,再叠加一个大小关系约束。完全二叉树意味着每一层都是满的,最后一层从左到右填充,这保证了树的高度稳定在 log₂(n) 量级,也保证了数组存储时不会浪费空间。

大小关系约束有两种:

  • 大根堆(Max Heap):每个节点的值 ≥ 孩子的值,堆顶是全局最大值。
  • 小根堆(Min Heap):每个节点的值 ≤ 孩子的值,堆顶是全局最小值。

我用一个特别朴素的例子帮我妈解释什么是堆:你开了一家小卖部,货架最显眼的位置永远放着最热销的商品。不管新进货还是卖了货,你都会立刻把当前最好卖的东西放到第一层。堆干的就是这件事——它保证“此刻你永远知道仓库里最值钱的是哪件”,代价是每次变动后要花点时间重新整理一下货架。

数组下标关系是:节点 i 的左孩子是 2i+1,右孩子是 2i+2,父节点是 (i-1)/2。以 0 为起点的下标体系在代码里最常见,千万注意别跟 1 为起点的写法混了,这是堆排序里最高频的bug来源。

1.2 堆的两个核心动作:上滤与下滤

堆的所有操作,本质上只做两件事:把节点往上挪(上滤,sift up),或者把节点往下沉(下滤,sift down)。

上滤用于插入场景。新元素先扔到数组末尾,然后一路和父节点比较,如果违反堆序就交换,直到找到合适位置。因为完全二叉树高度是 log₂(n),所以插入的最坏时间复杂度是 O(log n)。

下滤用于删除堆顶或调整场景。删除堆顶时,把数组最后一个元素放到堆顶,然后和左右孩子中较大(大根堆)/较小(小根堆)的那个比较,如果违反堆序就交换,一路下沉到正确位置。

这两个动作的时间复杂度都是 O(log n)。可以说,堆的所有功能——无论是排序、Top-K、优先级队列、还是中位数维护——都是这两个动作的不同组合。搞懂了上滤和下滤,堆的场景题就是换皮不换里。

注意:下滤和上滤不是对称的。上滤只需要跟一个父节点比,下滤却要同时比较两个孩子再决定跟谁换。所以下滤的代码更要注意边界条件,左孩子、右孩子是否越界要判断清楚。

1.3 为什么说“极值优先”是堆的灵魂

堆结构和数组、链表相比,最大的特点是:获取极值只需要 O(1) 时间,也就是直接读heap[0]。删除极值、插入新元素则是 O(log n)。

这个“极值即堆顶”的特性,让堆天然擅长两类事:一类是“每次都要取当前最大/最小”的调度问题,另一类是“只关心前几名,不关心全局顺序”的选择问题。

排序属于前者——每次取堆顶最大元素,依次放到末尾,就排好了;Top-K属于后者——维护一个大小为K的堆,堆顶就是第K名的门槛。你会发现,两者用的是同一个堆结构,只是“操作方式”略有不同:堆排序把堆当“提取器”,Top-K把堆当“门槛”。

2. 堆排序的完整实现:从建堆到有序数组

堆排序整体可以拆成三大步:建堆、重复提取堆顶、得到有序序列。其中建堆的细节决定了算法的时间复杂度,很多人背代码但不理解为什么从 n/2 - 1 开始,这一节我讲透。

2.1 建堆(Heapify):为什么从最后一个非叶子节点往前遍历

堆排序可以把数组原地变成大根堆,不需要额外的空间。建堆的经典做法是 Floyd 算法:

从最后一个非叶子节点开始,依次往前做下滤。最后一个非叶子节点的下标是 n/2 - 1(0-based)。为什么是这个位置?因为叶子节点本身没有孩子,一定满足堆序,不需要调整。从最后一个非叶子节点开始,等于从最底层“有孩子”的节点开始逐层向上整理。

void siftDown(vector<int>& arr, int i, int n) { int largest = i; // 记录当前节点、左孩子、右孩子中最大值的下标 int left = 2 * i + 1; int right = 2 * i + 2; if (left < n && arr[left] > arr[largest]) largest = left; if (right < n && arr[right] > arr[largest]) largest = right; if (largest != i) { swap(arr[i], arr[largest]); siftDown(arr, largest, n); // 继续向下调整,防止破坏子树堆序 } } void buildMaxHeap(vector<int>& arr) { int n = arr.size(); for (int i = n / 2 - 1; i >= 0; --i) { siftDown(arr, i, n); } }

这里为什么用递归下滤而不是循环?递归写法语义更清晰,但工程上建议改成迭代,避免大数据量时递归栈过深。二者逻辑一样,面试时写递归更快,笔试时手写迭代更稳。

建堆的时间复杂度是 O(n),不是很多人直觉上的 O(n log n)。直觉解释是:建堆时,处于低层的节点数量多但下沉路径短,处于高层的节点数量少但下沉路径长,加权求和后收敛为线性。严格推导需要用到“树高求和”的级数,结论是 T(n) = O(n)。相比之下,如果一个一个地插入建堆,复杂度是 O(n log n)。所以面试时被问“建堆时间复杂度”,答 O(n) 才是正解。

2.2 排序过程:把堆顶“摘”下来,把末尾“顶”上去

建堆完成后,堆顶就是全局最大值。排序的思路很直接:把堆顶和数组末尾交换,最大值就落在了最终位置,然后把堆的有效范围缩小1,对新堆顶做一次下滤。重复 n-1 次,数组就从小到大了(大根堆配合“从后往前填”的结果)。

void heapSort(vector<int>& arr) { int n = arr.size(); buildMaxHeap(arr); // 第一步:原地建堆 for (int i = n - 1; i > 0; --i) { swap(arr[0], arr[i]); // 堆顶最大值放到位置i siftDown(arr, 0, i); // 恢复前i个元素的堆序 } }

注意这里siftDown的第三个参数是i,表示当前堆的有效长度是i,注意不要写成n。如果你用循环实现下滤,循环条件要写2 * i + 1 < heapSize,而不是2 * i + 1 < n。这个边界错误几乎每个人都踩过。

排序阶段,每次取堆顶 O(1),但每次需要下滤 O(log n),共 n-1 次,所以排序阶段 O(n log n)。整体堆排序的时间复杂度:最好、最坏、平均都是 O(n log n),空间复杂度 O(1),属于原地排序。

2.3 稳定吗?不稳定,原因很直观

堆排序是不稳定排序。稳定性的定义是:相等元素的相对顺序排序后保持不变。堆排序的不稳定来源于“远距离交换”——堆顶和堆尾的元素可能隔着很长的距离,交换时会把相同值的相对位置打乱。比如数组 [5a, 3, 5b],大根堆建好后,5a 和 5b 的先后关系可能就变了,因为堆调整时不是只做相邻交换。

实际场景如果要保留原始顺序,比如按成绩排序还要保持同分同学按学号顺序,堆排序就不适用。工程里用的排序更多是快排(不稳定)和归并(稳定),这跟语言标准库的实现策略也有关:C++ 的std::sort通常走快排,std::stable_sort走归并。

我自己的习惯是:纯理论题、Top-K题、需要手写小规模排序时用堆排序、堆结构;涉及业务数据顺序保真时,宁愿多用一点空间走归并。堆排序的“O(1)空间 + O(n log n)时间”听起来很美,但实际性能因为局部性差常常跑不过快排,这部分我在后面第5节会展开讲。

3. Top-K 问题:把“排序”换成“选择”

Top-K 问题的经典描述是:找出一组数据里最大(或最小)的K个元素。它和排序的区别在于,Top-K 不关心全局顺序,只关心“前K名”。这就让“全排序”变成了一种杀鸡用牛刀的做法。堆在这里的优势非常明显:时间 O(n log K),空间 O(K),而且天然支持数据流。

3.1 Top-K 三大经典问法

Top-K 常见的问法有三种:

  • 最大K个元素(Top K largest)
  • 最小K个元素(Top K smallest)
  • 第K大元素(Kth largest),是前两种的特例:可以先用堆维护大小为K的“门槛”,然后取堆顶。

实际业务中这三种都有大量应用:电商平台要展示销量最高的100个商品(最大K个);系统日志要找出最频繁出现的50条错误(这里会先转成频次统计,再按频次取最大K个);推荐系统要过滤掉热度最低的200条内容(最小K个)。这些场景的共同点是:K 远小于 n,并且数据量可能很大,甚至大到无法一次性装入内存。

3.2 找最大K个:维护一个大小为K的小根堆

求最大K个元素,用最短的思路是:维护一个小根堆,大小限制为K。遍历数组时,如果堆还没满就直接入堆;堆满了,就与堆顶比较,如果当前元素比堆顶大,说明堆顶那个“排行榜最后一名”该被挤掉了,弹出堆顶、把新元素放进去。

vector<int> topKLargest(const vector<int>& nums, int k) { if (k <= 0) return {}; priority_queue<int, vector<int>, greater<int>> minHeap; // 小根堆 for (int x : nums) { if (minHeap.size() < k) { minHeap.push(x); } else if (x > minHeap.top()) { minHeap.pop(); minHeap.push(x); } } vector<int> res; while (!minHeap.empty()) { res.push_back(minHeap.top()); minHeap.pop(); } return res; }

这里的关键点是:小根堆的堆顶永远是当前K个候选里最小的那个,也就是“第K名”。新元素只要比第K名大,就说明有资格进入前K名,顶掉第K名后重新调整。遍历完所有元素,堆里留下的正好就是前K大。

建议画个例子手推一遍,比如数组 [3,2,1,5,6,4],K=3。你会发现,每次更新堆顶,都是把“候选人名单”的最后一名换掉,这个过程非常直观。我用这个例子给不少同学讲过,几乎一遍就能听懂。

复杂度上,每步最多 O(log K) 一次堆调整,所以总体 O(n log K)。如果K很小,比如K=10,这个算法几乎就是 O(n),比全排序的 O(n log n) 快一个量级,这也是它适合海量数据的原因。

3.3 第K大元素:堆解法 vs 快速选择(Quick Select)

第K大是 Top-K 的一种特例,常见解法有两个路线:堆和快速选择。

堆解法先建一个大小为K的小根堆,过程同上,最后返回堆顶即可,复杂度 O(n log K)。优点是稳定,不会退化,而且非常好写,很少出错。

快速选择基于快排的 partition 思想:每次确定一个 pivot 的最终位置,如果这个位置正好是第K大,直接返回;否则递归地去某一侧继续找。平均时间复杂度 O(n),但最坏是 O(n²),比如输入已经有序且每次 partition 都选到最差的 pivot。

我自己的建议是:面试里被问到“第K大”,先答快速选择体现你懂复杂度优化,但马上补一句“不过快速选择有退化风险,我会用堆作为稳定方案”。这会让面试官觉得你有复杂度敏感度,又有工程意识。笔试写题、生产环境,我几乎都用堆,除非面试官明确要求 O(n) 平均复杂度。

3.4 数据流和分布式场景:堆的优势进一步放大

堆解法还有一个独特优势:它天然适合数据流。数据流意味着你不知道总数据量有多大,甚至不知道什么时候结束。全排序必须先拿到全部数据,快速选择虽然能处理静态数组,但也要把数组完整读入内存。堆只需要维护K个元素的内存,就能一直跑到数据流结束。

举个例子:你写一个日志监控系统,每秒钟产生几千条日志,想实时统计当前出现频次最高的10个错误码。对每条日志,先用哈希表累计频次,再把这个频次更新到堆里。不管是持续跑1小时还是1个月,内存占用都是恒定的 O(K)。

分布式场景也很类似:10台机器各存了一部分数据,想求全量Top-K。标准的做法是每台机器各自用堆求出局部Top-K,然后把每台机器的局部结果汇总到一台机器上,再对这 10×K 个元素求一次全局Top-K。这里局部Top-K之所以可以用堆,就是因为它能把每台机器的数据压缩到K个“最有希望的候选者”,大大减少网络传输量。

4. 工程中的堆:优先级队列、中位数与图算法

堆结构在工程里很少以“堆排序”的名字出现,而是以“优先队列(Priority Queue)”这个更抽象的身份立足。很多开发者天天在用,但未必意识到底层就是堆。这一节我结合自己实际接触过的场景,挑几个最典型的讲讲。

4.1 线程池的阻塞队列,为什么经常用优先队列

线程池的任务队列,通常有两种选择:普通 FIFO 队列和优先队列。FIFO 适合任务之间没有优先级差异的场景,但真实系统里总有“紧急任务要插队”的需求。比如一个外卖派单系统,普通订单可以排队,但VIP用户投诉订单必须优先处理。

如果直接用数组实现插队,平均复杂度 O(n);如果用一个基于堆的优先队列,插入和取出的复杂度都是 O(log n)。C++ 里对应std::priority_queue,Java 里对应PriorityQueue,Python 里是heapq

我第一次用优先队列做任务调度时犯过一个错:直接用默认的std::priority_queue<Task>,但Task自定义类型没有重载<,编译报错。解决办法是提供比较器,注意C++里的比较器写法容易把方向搞反——想取“优先级最高”的任务,比较器写法跟你想的常相反。这个在后面常见问题里再展开。

4.2 数据流中位数:一边一个大根堆,一边一个小根堆

求一个不停增长的数据流的中位数,也是堆结构的经典应用。思路是维护两个堆:

  • 大根堆,存放“较小的一半”元素,堆顶是这一半的最大值;
  • 小根堆,存放“较大的一半”元素,堆顶是这一半的最小值。

只要保证两堆元素个数之差不超过1,那么中位数要么是某个堆顶,要么是两个堆顶的平均值。插入时,先根据大小关系决定放进哪个堆,然后通过调整维持两个堆的大小平衡。插入 O(log n),取中位数 O(1)。

这题我面试时遇到过好几次,代码不长,但很考验对堆类型和大小平衡的理解。我第一次写,脑子一热把新元素直接塞进大根堆,导致两堆严重失衡,中位数算错。后来养成了固定流程:先塞进大根堆,再把大根堆的堆顶移到小根堆,最后如果大根堆比小根堆多出超过1个元素再做一次平衡。这样写看起来多几步,但逻辑清晰不易错。

4.3 Dijkstra 和 Prim 算法里的堆优化

图算法里 Dijkstra 最短路、Prim 最小生成树,核心瓶颈都是“每次从未确定集合里取距离最小的点”。如果每次都用线性扫描,复杂度 O(V²);如果用普通数组维护距离,堆优化可以把时间复杂度压到 O(E log V)。

Dijkstra 的标准做法是:用优先队列存 (距离, 节点) 对,每次弹出距离最小的节点,如果旧信息已经过期就跳过。这里的“过期”判断很多人一开始不太理解:堆里可能同一个节点被更新过多次,所以要从堆里弹出节点时,发现它记录的距离已经大于当前已知最短距离,就说明这一条是旧信息,直接丢弃。

堆在处理这类“动态取最小值”的问题里几乎是标准答案。理解了这一层,你就会发现:堆并不是排序算法的附庸,它在调度、搜索、流式计算里无处不在。

5. 常见问题、调试技巧与选型建议

这一节把我在实际写代码和帮别人 review 时遇到的高频问题集中整理一下,尤其是那些“看代码逻辑明明对,但一跑就错”的坑。

5.1 堆排序高频 Bug 清单

先看代码怎么写会踩坑:

具体表现排查思路
下滤时忘记判断孩子越界数组访问越界,或排序结果为随机值确认left < heapSizeright < heapSize都判断
0-based 和 1-based 混用建堆用(n-2)/2,下滤用2*i+1,组合起来出错统一一套下标公式,推荐0-based
大根堆小根堆方向写反排出来的序是反的,Top-K 结果刚好是“最差K个”拿 [3,1,2] 这种小数组手跑一遍
排序循环里堆大小没缩小每次下滤都对全数组,已排好的元素又被打乱确认heapSize在每轮交换后递减
递归下滤栈溢出数据规模较大时崩溃改成迭代下滤

调试堆排序我最常用的方法:在siftDown开头加一行打印当前i和数组状态,然后用一个长度为 5~8 的小数组跑一遍,观察每一轮调整是否符合预期。堆的调试最怕“逻辑看着对”,所以尽量使用小样本手工推导。

5.2 Top-K 的边界与特殊输入

Top-K 代码不难,但边界条件非常容易在面试时被追问:

场景处理方法
K = 0直接返回空集合,很多实现忽略这个导致除零或越界
K >= n等价于全量排序或全量收集,直接返回全部元素
数组为空返回空集合
大量重复元素堆解法天然支持,重复值不会影响正确性
无限数据流维护固定大小K的堆,内存恒为 O(K)

我面试时写 Top-K,一定会在函数开头把k <= 0k >= n两个分支写清楚。这两个分支不是代码难点,但能体现你考虑问题是否全面,是个加分项。

5.3 堆排序 vs 快排 vs 归并,到底什么时候用谁

维度堆排序快速排序归并排序
平均时间复杂度O(n log n)O(n log n)O(n log n)
最坏时间复杂度O(n log n)O(n²)O(n log n)
空间复杂度O(1)O(log n)(递归栈)O(n)
稳定性不稳定不稳定稳定
缓存局部性中等

为什么 C++ 的std::sort不用堆排序?因为堆排序虽然复杂度稳定,但它的访问模式是“跳着访问”的,数组下标从堆顶跳到堆尾,缓存命中率很差。现代 CPU 上,快排实际跑得比堆排序快不少。快排的退化问题可以通过三数取中、随机 pivot 等优化手段压制,所以在通用库中快排更受欢迎。

那堆排序到底什么时候用?我个人经验是这几个场景:

  • 需要手写且要求最坏情况下仍 O(n log n) 的算法题;
  • 必须 O(1) 额外空间的排序需求;
  • 作为实现优先队列的基础结构,用在各种 Top-K、调度类场景。

如果你只是在业务代码里给一个列表排序,直接调用语言标准库就好,没必要手写堆排序。堆排序价值的正确打开方式,是搞懂那套“极值维护机制”,然后把它迁移到 Top-K、优先级队列、中位数等场景里。

最后再分享一个我自己的小习惯:遇到任何要求“Top K”“最大/最小第K个”“每次取极值”的题目,先不要急着排序。先问自己一句,真的需要全部有序吗?如果不需要,堆往往就是比排序更优的答案。这套从堆结构到排序、再到选择问题的思路,我用了很多年,每次都能让我少写好几行代码,也少踩好几个坑。

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

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

立即咨询