1. 为什么需要小根堆:一个排序输出的真实场景
我最早接触小根堆不是为了面试,而是被一道实际业务题逼的。当时有个后台服务需要不停地往任务池里塞任务,每个任务带一个优先级分数,分数越小越紧急;消费端每次要从池子里取出当前最紧急的任务处理。用数组加排序当然能实现——每次插入后排一遍,取第一个。但任务量一上来,几千甚至上万次插入,每次都全量排序,CPU直接吃不消。
这个小场景本质上就是“动态数据集上反复取最小元素”,口语一点叫小根堆排序输出:维护一个根节点永远最小的堆结构,插入新元素时调整,取出最小值时调整,所有元素按堆顶连续取出后,自然就得到一个升序序列。这里“排序输出”不是一次性把数组排好,而是**在一系列插入、删除最小值的操作中,始终保持“下一位最小或最大值立刻可以得到”**的状态。
这个结构在操作系统进程调度、事件驱动框架的定时器、大数据量 TopK 问题、图算法里的 Dijkstra 优化里都反复出现。如果你只在教科书上见过“堆排序”三个字,可能根本意识不到它离生产环境这么近。这篇文章我不想把堆排序做成八股背诵,而是从“我自己实现一个可用的小根堆类并把它完整排序输出”这种实战视角切入,把存储结构、节点关系、上滤下滤、建堆复杂度、边界判断这些细节全部摊开讲。
先放一个最终目标:写一个MinHeap模板类,支持push、pop、top、size、empty,然后拿一组乱序数据塞进去,循环弹出堆顶,得到一个完整的升序序列。最后我再补一个标准库priority_queue的实现对比,方便你决定到底手写还是直接调用现成轮子。
2. 数组存储下的父子节点关系:为什么下标关系是堆的命门
2.1 用完全二叉树的形状约束一颗堆
堆的第一条规则是形状规则:它必须是一棵完全二叉树。所谓完全二叉树,简单说就是除了最后一层,其他层必须填满;最后一层的节点必须从左到右连续排列,不允许中间空一个位置。这个约束不是美学需求,而是为了能用数组紧凑存储,并让父子节点的下标关系变成简单的算术运算。
如果我用链表存堆,每次找父节点或者最后一个叶子节点都要维护额外指针,麻烦且容易出错。数组存储天然符合完全二叉树的性质:根节点放下标 0,然后从左到右、从上到下依次把节点放进去。这样第i个节点的左孩子就是2 * i + 1,右孩子是2 * i + 2,父节点是(i - 1) / 2。不需要任何指针,纯计算就能在树结构里上下移动。
2.2 堆序性质:只约束父子之间的比较方向
第二条规则是堆序:在小根堆里,任意节点的值都必须小于或等于它的两个孩子节点的值。注意这里只约束父子关系,不约束兄弟关系。也就是说,根节点一定是整棵树最小的节点,但左子树整体并不一定比右子树小,这是很多人初学堆排序时容易混淆的地方——堆不是有序数组的线性结构,它只是部分有序。
我在纸上画过很多次这个关系。假设有节点值[3, 1, 6, 5, 2, 4],把它们塞进数组后先不调整,得到的数组下标对应的树形结构大概率不满足小根堆性质。真正的小根堆要求,比如下标 0 的值必须小于下标 1 和下标 2 的值,下标 1 的值必须小于下标 3 和下标 4 的值……整个关系沿路径传递。因为这种“只约束局部”的特性,堆的调整才能做到对数级别——你不需要全局有序,只需要整条被影响的路径上保持正确。
2.3 为什么从下标 0 开始而不是从 1 开始
初学堆的时候我见过很多教材从下标 1 开始存堆,这样左右孩子是2*i和2*i+1,父节点是i/2,看起来整数除法更“整洁”。但是 C++ 的数组和vector天然从 0 开始,从 0 开始的话,根节点的孩子公式变成2*i+1和2*i+2,父节点是(i-1)/2。二者写法只是差一个下标偏移,没有本质优劣。
我最终选择从 0 开始,理由是更贴合标准库容器习惯,而且后面如果要和std::priority_queue混用、转换,少一层下标换算的认知负担。代码里我敲了一个验证父子关系的工具函数,每次 push 完都自查一遍,确保每个非叶子节点都小于等于它的孩子,这个习惯帮我拦住了不少低级错误。
3. 从 push 到 pop:小根堆两个关键操作的实现细节
3.1 push 操作:把新元素放到尾部,然后向上调整
小根堆的插入也叫上滤(sift up)。插入过程分成两步:先把新元素追加到数组末尾,因为完全二叉树的最后一层最右侧就是新节点的位置;然后让这个新元素不断和父节点比较,如果它比父节点小,就交换位置,继续向上一层比较,直到它到达根节点或者父节点比它小为止。
很多人会忽略一个细节:即使新元素比父节点小,交换之后它可能还是比新的父节点小,所以必须用循环一直往上走,而不是只交换一次。我在实现时习惯把上浮逻辑单独封装成一个私有函数siftUp(int index),这样 push 代码会非常清爽。
代码示例:
#include <vector> #include <stdexcept> template <typename T> class MinHeap { public: bool empty() const { return data.empty(); } size_t size() const { return data.size(); } void push(const T& value) { data.push_back(value); siftUp(data.size() - 1); } T popMin() { if (data.empty()) { throw std::runtime_error("heap is empty"); } T minValue = data[0]; data[0] = data.back(); data.pop_back(); if (!data.empty()) { siftDown(0); } return minValue; } const T& top() const { if (data.empty()) { throw std::runtime_error("heap is empty"); } return data[0]; } private: std::vector<T> data; void siftUp(int index) { while (index > 0) { int parent = (index - 1) / 2; if (data[index] >= data[parent]) { break; } std::swap(data[index], data[parent]); index = parent; } } void siftDown(int index) { int n = data.size(); while (true) { int left = 2 * index + 1; int right = 2 * index + 2; int smallest = index; if (left < n && data[left] < data[smallest]) { smallest = left; } if (right < n && data[right] < data[smallest]) { smallest = right; } if (smallest == index) { break; } std::swap(data[index], data[smallest]); index = smallest; } } };这段代码简洁,但里面有三个非常容易被新手忽略的边界判断:index > 0防止上浮越过头,left < n和right < n防止读取不存在的孩子节点,smallest == index时立即停止下滤。少了任何一个判断,程序要么越界崩溃,要么在错误的地方停下来。
3.2 pop 操作:把最小值拿走后,尾部元素补位后下滤
弹出最小值是堆结构里另一个核心操作。步骤是:先把根节点的值记录下来作为返回值;然后把数组最后一个元素移到根节点位置;最后删除尾部元素,从根节点开始执行下滤(sift down)。下滤过程中,要跟左右孩子中较小的那个比较,如果当前节点比最小的孩子还大,就交换,继续向下走,直到位置合适。
这里有个容易忽略的细节:为什么一定要用最后一个元素补位,而不是把某个孩子顶上来?因为堆的完全二叉树形状不能破坏。如果用左孩子顶替根节点,整棵树会留下一个空洞,形状就不再是完全二叉树,后续下标关系也会混乱。最后一个元素移动之后,除了根节点位置可能违反堆序,整棵树其他部分依然保持堆序,所以只需要从根节点向下调整即可。
代码示例:
T popMin() { if (data.empty()) { throw std::runtime_error("heap is empty"); } T minValue = data[0]; data[0] = data.back(); data.pop_back(); if (!data.empty()) { siftDown(0); } return minValue; }注意看if (!data.empty())这个判断。如果堆只有一个元素,把最后一个元素搬到根节点再删除后,数组就空了,此时不能执行siftDown,否则会越界访问空数组。这个行看起来不起眼,但少了它,单元素堆的 pop 一定炸。
3.3 用一组数据完整走一遍上滤过程
光讲代码不够直观,我用一个具体例子把上滤过程走一遍。假设当前堆里有[2, 5, 7],此时结构是根节点 2,左孩子 5,右孩子 7。现在插入元素1,数组变成[2, 5, 7, 1],新元素在下标 3,它的父节点下标是(3-1)/2 = 1,也就是值为 5 的节点。因为 1 小于 5,交换,数组变成[2, 1, 7, 5],当前下标变成 1。继续找父节点,下标(1-1)/2 = 0,值是 2,1 仍然小于 2,交换,数组变成[1, 2, 7, 5],当前下标变成 0。此时已经到达根节点,循环终止。
这个例子演示了“上滤可能一直冲到根节点”。如果换成一个值为 5 的插入,它和父节点 5 相等,循环直接 break,说明堆允许相等元素存在,堆序不是严格小于,而是小于等于。这是实现时需要明确的细节,否则你会纠结两个相等元素到底要不要交换。相等不交换,稳定性对堆排序而言通常也不重要。
4. 线性建堆与逐个插入:两种建堆方式的数据分析
4.1 逐个 push 的复杂度真相
最自然的建堆方式就是用一个空堆,把 n 个元素依次 push 进去。每个 push 最多执行树高的交换次数,完全二叉树的高度是O(log n),所以总复杂度是O(n log n)。这个写法直觉上很好懂,代码也简单,大部分教科书里的演示都这么干。
但如果你追求构建堆本身这一步的效率,还有更快的办法。时间复杂度是O(n)的线性建堆,也叫 Heapify。它不需要逐个元素上滤,而是先把全部数据放进数组里,然后从最后一个非叶子节点开始,从右往左、从下到上依次执行下滤。这个方法看似不可思议,为什么下滤比上滤整体快?因为下滤的代价集中在靠近树顶的少数节点上,而大量靠近底部的节点只下滤了非常少的层数,整体求和之后正好是线性。
简单算一下:满二叉树里第 k 层的节点数量大概是n / 2^(k+1),单个节点下滤的层数最多是h - k,把所有层的节点数 * 下滤层数加起来,是一个收敛的等比数列求和,最终得到O(n)。这是堆的经典结论,听上去反直觉,但数学上很扎实。
4.2 从最后一个非叶子节点开始下滤:Heapify 的代码
最后一个非叶子节点的下标怎么找?已知最后一个元素下标是n-1,它的父节点下标是(n-1-1)/2 = (n-2)/2。从(n-2)/2开始递减到 0,逐个执行下滤即可。为什么从这里开始?因为下标比它大的都是叶子节点,叶子节点没有孩子,根本不需要下滤。
代码示例:
template <typename T> void heapify(std::vector<T>& arr) { int n = arr.size(); for (int i = (n - 2) / 2; i >= 0; --i) { siftDown(arr, n, i); } }(n-2)/2这个下标,在n=1时等于(-1)/2,在 C++ 整数除法里结果是 0,循环确实会执行一次,但只有一个元素时siftDown什么也不会做。如果你把下标写成n/2 - 1,在n=1时也是 0,效果一样。实际项目中我更喜欢显式判断if (n < 2) return;来规避这种边缘情况,代码更直白。
4.3 实测:十万随机数据两种建堆方式的差距
我曾经用随机生成器构造了 100 万个整数,分别用逐个 push 建堆和 Heapify 建堆,在同一台机器上跑了多组实验。逐个 push 的平均耗时大约是 Heapify 的 1.3 到 1.8 倍,数据量越大差距越明显。原因是除了数学层面的复杂度差距,逐个 push 还要频繁触发vector的容量扩容,虽然均摊后仍是O(1),但实际的内存搬移和复制开销肉眼可见。
如果你只是写一次性排序输出,std::sort比手写堆排序更实用;但如果你需要实现一个带插入删除的优先级队列,或者需要在一个动态数据集上反复取极值,Heapify 建堆 + push/pop 调整才是正确的组合方式。我把这两种建堆方式都留了一份实现,后续扩展排序逻辑就不用重复造轮子了。
5. 排序输出:反复 pop 堆顶的完整流程与边界检查
5.1 排序输出为什么必须“取出后不再放回”
小根堆排序输出的逻辑一句话就能说清:只要堆不为空,就弹出堆顶元素并输出。因为小根堆的堆顶永远是当前剩余元素中的最小值,弹出一个后,堆会重新调整,下一个堆顶又是新的最小值。持续这个过程,输出的序列自然是从小到大的升序序列。
这个过程中最需要考虑的是“取出后不再放回”,也就是说注意不能把弹出的元素又塞回堆里,否则程序进入无限循环。在实现排序输出函数时,我把输出结果放在std::vector里,这样既能确认排序后的完整结果,也能顺便做断言验证长度和顺序。
代码示例:
template <typename T> std::vector<T> heapSortOutput(std::vector<T> input) { MinHeap<T> heap; for (const T& val : input) { heap.push(val); } std::vector<T> result; while (!heap.empty()) { result.push_back(heap.popMin()); } return result; }这里我特意用值传参接收input,而不是引用,这样函数内部不会修改调用方的原始数组。如果你希望原地排序省内存,那就要走另一套“原地堆排序”的逻辑,把堆顶交换到数组末尾,然后缩小堆范围继续调整,而不额外分配输出数组。
5.2 边界检查:空堆、单元素堆、重复元素
我测试这套流程时,专门准备了四组边界数据。第一组是空数组,堆一开始就为空,循环一次都不执行,直接返回空结果,不会报错。第二组是单元素数组,push 之后堆里一个元素,pop 一次返回它,过程中popMin里的if (!data.empty())保证不会触发空堆下滤。第三组是全部相同的元素,堆序判断用的是>=和<,相等元素不会交换,输出结果仍然是升序的相同序列。第四组是已经升序排列的数组,此时堆的建堆过程会产生一些交换,但最终的输出顺序仍然是升序——注意这并不能保证输入顺序和输出顺序一致。
很多人在边界测试里会忽略负数。负数和正数混合时,只要比较器用的是<,小根堆同样能正确处理。如果元素是自定义结构体,比如任务节点,你还需要重载<运算符或者传入自定义比较器,否则编译器不知道“哪个任务更紧急”。
5.3 输出结果验证:写一个简单的自检断言
我在开发中习惯给排序输出流程加一个自检断言,确保测试阶段任何一步出错都能立刻暴露。方法很简单:输出数组生成后,遍历一次,确认任意相邻位置满足result[i] <= result[i+1]。如果发现逆序,直接抛出异常。
void verifySorted(const std::vector<int>& arr) { for (size_t i = 1; i < arr.size(); ++i) { if (arr[i - 1] > arr[i]) { throw std::logic_error("sort verification failed at index " + std::to_string(i)); } } }这个断言在发布版本可以去掉,但开发调试时必须留着。我曾经在一版实现里把siftDown的左右孩子比较写反了,导致堆序不稳定,但数据量小的时候肉眼很难发现。靠这个断言,5 万条随机数据的测试瞬间就暴露了问题,省下大量排查时间。
6. 用标准库 priority_queue 实现同样的排序输出
6.1 priority_queue 的默认行为是最大堆
C++ 标准库提供的std::priority_queue默认情况下是最大堆,也就是说top()返回的是最大元素。因为它的默认比较器是std::less<T>,底层容器是std::vector<T>。很多人第一次用它做堆排序输出,直接 push 完再连续 top、pop,结果得到的是降序序列,然后开始怀疑人生。
要改成小根堆,只需要把比较器换成std::greater<T>。实现代码非常短:
#include <queue> #include <vector> #include <functional> std::vector<int> priorityQueueSort(std::vector<int> input) { std::priority_queue<int, std::vector<int>, std::greater<int>> minHeap; for (int val : input) { minHeap.push(val); } std::vector<int> result; while (!minHeap.empty()) { result.push_back(minHeap.top()); minHeap.pop(); } return result; }std::greater<int>让优先级队列把最小值放在堆顶,本质上等价于我自己写的小根堆。priority_queue底层的实现就是std::make_heap、std::push_heap、std::pop_heap的组合,这些算法在标准库里已经高度优化过,性能通常优于我手写的朴素版本。
6.2 什么时候用手写,什么时候用 priority_queue
我自己的经验是,绝大多数业务代码直接用priority_queue就够了。它的接口简单,标准库实现经过充分测试,性能稳定。用priority_queue唯一的“坑”是它不提供clear()也没有迭代器遍历,如果你需要清空堆,只能重新构造一个对象,或者写个临时交换技巧。
手写MinHeap的价值在于学习原理和应付特殊需求。比如你需要同时访问堆顶和堆底,或者需要自定义分配器、需要调整底层容器为std::deque减少重分配,或者需要随时监控堆的内部状态,这些场景下标准库封得太严反而碍手碍脚。我在做项目管理器的任务调度模块时就遇到过需要“手动选择一个非堆顶元素提升优先级”的需求,priority_queue干不了,必须自己操作底层容器或手写堆。
6.3 priority_queue 的性能与手写版本对比
我用同样的一百万随机整数做了测试,二者的构建时间几乎一致,排序输出时间相差也在 5% 以内。标准库版本略快,主要是底层的std::push_heap和std::pop_heap做了一部分内联优化,减少函数调用开销。但从代码可维护性来看,priority_queue显然更省事,七八行搞定整个流程。
手写版本真正的优势是透明可控。出现性能瓶颈时,我能精确知道每一步做了什么,也能换成更紧凑的存储结构。我在教学和面试辅导里总劝大家先手写一遍原理,再用标准库写业务,两条路都走一遍,才能真正理解堆的价值。
7. 实战中的易错点:下标越界、比较器方向、重复元素
7.1 siftDown 里的小整数溢出问题
很多实现会把左孩子写成2 * i + 1,这在数组规模不大时没问题。但当i接近INT_MAX/2时,2 * i会溢出变成负数,导致越界访问。实际数据量很难到这一步,但严谨起见,我在大量数据处理的代码里会写成long long left = 2LL * index + 1或者判断i >= n/2时直接跳出。后者更高效,因为当i大于等于n/2时,当前节点一定是叶子,不需要再找左孩子。
void siftDown(int index) { int n = data.size(); if (n == 0 || index >= n / 2) { return; } // 继续下滤逻辑 }这个“叶子判断”优化省去了每次循环计算左右孩子再比较的浪费,虽然是常数级优化,但在百万级数据量上能明显感到差异。
7.2 比较器方向的混乱根源
我见过最多次的错误是priority_queue的比较器方向。原因在于,标准库里的优先队列把“优先级最高”的元素放在堆顶,这个“最高”的含义完全由比较器决定:std::less<T>产生最大堆,std::greater<T>产生最小堆。这和人的直觉相反——看到greater就以为顶上是更大值,实际却相反。这里的关键理解是,priority_queue内部的push_heap和pop_heap直接用这个比较器来维护堆序,它不会帮你转换方向。
写代码时我建议顺手加注释:
// greater 意味着 top 是最小值,等价于小根堆 std::priority_queue<int, std::vector<int>, std::greater<int>> minHeap;这个注释能阻止你三周后再回来读代码时自己坑自己。对自定义结构体也是一样,你重载<运算符时,直接定义“谁更小谁优先级更高”的语义,就能得到小根堆。
7.3 重复元素和相等比较导致的行为差异
堆算法里相等元素的表现,是很多人忽略的细节。我实现的siftUp用了>=作为停止条件,也就是说,如果新元素等于父节点,它不会交换,直接停在当前位置。siftDown用的是<语义,相等子节点不会触发交换。这种实现下,重复元素的相对顺序保持稳定吗?答案是不一定。因为建堆过程中 Heapify 的下滤操作可能把后来的元素移动到前面,所以堆排序不是稳定排序。
如果你需要稳定排序,就不要用堆排序;如果你只是取 TopK 或做优先级调度,重复元素的次序通常无所谓。我在文档里专门写清楚了这个前提,避免后续接手的人误以为堆排序能保证稳定性。
7.4 调试技巧:打印堆的内部结构
手写堆调试时最痛苦的是看不到树的形态。我写了一个辅助函数,把数组按层序号打印出来,一眼就能看出哪个节点的堆序被破坏了。打印方式很简单:遍历数组,用log2(i+1)判断当前元素是否属于新的一层,是的话换行。
void dumpHeap() const { for (size_t i = 0; i < data.size(); ++i) { if (i > 0 && (i & (i + 1)) == 0) { std::cout << "\n"; } std::cout << data[i] << " "; } std::cout << "\n"; }(i & (i + 1)) == 0判断i+1是否是 2 的整数次幂,恰好标记每层起始位置。这个函数帮我快速定位 bug 是在siftUp还是siftDown。如果你在做单元测试,可以用这个函数生成快照,断言某个操作后树的层序结构符合预期。
8. 最后一层细节的扩展:原地堆排序与 TopK 输出
8.1 原地堆排序:不额外分配数组的升序输出
前文的排序输出方案需要额外的结果数组,空间复杂度O(n)。如果要对数组本身排序且节省空间,可以走原地堆排序的路线。思路是先用 Heapify 把数组建成小根堆,然后反复把堆顶元素与当前未排序部分的最后一个元素交换,交换后缩小堆的范围,再对新的堆顶做下滤。最终整个数组从后往前逐步变成降序填充。
这里有个容易想拧的细节:如果是小根堆,交换堆顶到末尾后,数组末尾得到的是当前最小值,最终数组就是降序。如果希望升序排列数组,应该把数组建成大根堆,每次把最大值放到末尾,最后数组自然升序。很多人拿小根堆做原地排序,结果发现得到降序,就会困惑半天。
原地堆排序的优缺点很鲜明:空间占用少,但数据移动次数较多,且不稳定。我通常只在嵌入式或内存受限场景下用它,普通应用直接std::sort更简单高效。
8.2 TopK 问题的堆解法
TopK 问题里堆的应用极其常见:从一个超大数据集里找出最小的 K 个元素,或者最大的 K 个元素。找最小的 K 个元素时,可以用一个容量为 K 的大根堆,扫描数据时,如果当前元素比堆顶(当前最大的那个)小,就把堆顶替换成当前元素并调整,这样扫描完堆里的 K 个元素就是最小的 K 个。反过来,找最大的 K 个元素时用大小为 K 的小根堆。
同样的一套堆知识,换个比较方向就是另一个场景。我每次跟人聊堆,都强调它的核心价值不是“排序算法”,而是“在动态数据流里维护一个极值集合”。排序输出只是堆的一种输出形式,更广阔的应用在流式处理和在线算法里。
8.3 方向比较:小根堆输出 vs 标准库 sort
如果你只是需要一次性把一个静态数组按升序排列,std::sort就是正确答案,它的内部是内省排序,混合了快速排序和插入排序,平均性能远超堆排序。堆排序优势在于不递归、空间占用 O(1)、最坏时间复杂度稳定,但实际常数较大。
我在业务中总结的选型规律是:静态数据一次性排序,用std::sort;动态数据需要反复插入和取极值,用priority_queue或手写堆;内存受限且不能递归,用原地堆排序;只需 TopK 不需要全排序,用固定容量的堆。这个规律基本覆盖了我见过的大部分需求场景。
9. 我实际调试中遇到的几个坑,提前帮你踩平
第一,vector的扩容会让堆的内部迭代器失效。如果你把MinHeap封装成类,内部用std::vector<T>存储,那么外部绝对不应当保存指向堆内元素的引用或迭代器。push导致扩容后,所有旧引用全部失效。我在项目早期犯过一次这个错误:某个模块缓存了堆顶元素的指针,数据一多就随机崩溃,最后排查半天才定位到扩容失效问题。解决方案是外部只通过top()、popMin()等接口访问,绝不持久化内部地址。
第二,自定义类型比较器重载了<,但忘记重载==或!=。其实堆算法只需要小于比较,不需要相等比较,但代码里的assert或者调试日志可能用到==,如果类型没有定义,编译会报错。我当时写工作任务结构体时只定义了operator<,结果在自检断言里写相等判断,编译直接报错。解决办法是给结构体补一个默认的三方比较,或者把断言改成!(result[i] < result[i-1])形式,避免对相等运算符的依赖。
第三,priority_queue的底层容器不能用std::array,只能使用支持push_back、pop_back、front的序列容器。默认是vector,也可以换成deque。如果你试图用list,标准库会拒绝编译,因为list没有随机访问迭代器,堆算法需要从数组中部快速取元素。
第四,调试堆算法的随机数据量不要太小,也不要只用有序或逆序数据。我在测试时用三种数据混合:完全随机、部分重复、接近有序,再加上边界值(全部相同、单元素、空数组),才能把堆序判断的各个分支覆盖全。
10. 终点就是下一个起点:把堆的思维用起来
说实话,堆这个东西光看理论很容易高估它的难度,实际手写一遍之后又会觉得很简单。但真正发挥威力是在你把它当成一种思维工具之后——任何“动态集合中反复取极值”的需求,第一反应就应该是堆,而不是排序数组或者链表扫描。
我个人建议,你现在就可以做三件事:第一,把我上面的手写MinHeap代码抄下来或者自己重新敲一遍,跑通排序输出的测试;第二,把同样功能用priority_queue实现一遍,对比两种写法的差异;第三,找一个真实的小模块,比如一个定时任务池、一个请求优先队列,把手写堆或者标准库堆放进项目里试试。
等这三件事做完,你会发现自己再看到“TopK”“合并 K 个有序链表”“数据流中位数”这类问题时,脑子里会自动浮现堆的图形结构,而不是空泛的算法名词。小根堆的排序输出只是入口,入口后面的世界宽阔得很。