学数据结构最恶心的一件事,就是树、二叉树、堆、堆排序这些概念总是一起出现,名字绕来绕去,代码写起来又像递归套递归。很多人背了又忘,原因其实不是笨,而是没把“树 → 完全二叉树 → 堆 → 建堆 → 堆排序”这条线索串起来。这篇文章我就用踩过坑之后总结出来的思路,把这条线完整讲清楚。不搞花架子,直接说清楚堆到底是什么、怎么建、怎么排序,以及在项目和面试里到底怎么用。适合正在学数据结构的学生、准备算法面试的开发者,以及写业务代码想补一补基础的朋友。看完你至少能徒手写对堆排序,并且知道它为什么是这么设计的。
1. 树与二叉树:堆的出身,先搞懂这些
1.1 树的基本概念,为什么堆非要挂在树上讲
很多人一提到堆就背“堆是一种数据结构”,却说不清它跟树是什么关系。实际上,堆就是一种特殊的树,而且是特殊到可以用数组直接存下来的那种树。想搞懂堆,先得把树的地基打好。
树这种结构,本质上是分层组织数据的方式。一个根节点往下面分出子节点,子节点再分出子节点,就像公司组织架构图。节点之间不能有环,这是树和图的本质区别。描述一棵树的时候,你会听到一堆术语:根节点、叶子节点、父节点、子节点、度、深度、高度、层数。不用一个个死记,核心就两个:路径和层级。路径决定了从根到某个节点要经过几个节点,层级决定了这个节点在树里处在第几层。
我自己带新人的时候,发现他们最容易懵的是“深度”和“高度”的区别。这里给个记忆方法:深度是从根往下数,你站在根上往叶子看,越往下越深;高度是从叶子往上数,你站在叶子上往根看,越往上越高。很多教科书规定根节点深度为0,叶子节点高度为0,也有规定从1开始的,不同教材不一样。算法题和面试里这两种口径都出现过,所以看题的时候先确认它的定义,别上来就算。
树在真实场景里无处不在。文件系统是树,目录套目录;HTML的DOM结构是树,标签套标签;数据库的索引也是树,B树和B+树后面我会提到。可以说,只要数据有层级关系、有从属关系,树就是最自然的结构。而堆,就是在这棵树上加了一条“数值大小关系”的约束,让树的形状和数据的大小同时可控。
1.2 二叉树与完全二叉树:识别堆的两个前置概念
树的形态很自由,一个节点可以有任意多个子节点。但节点太多,逻辑上就难处理,所以计算机科学里重点研究的是二叉树:每个节点最多有两个子节点,分别叫左孩子和右孩子。左边和右边是不对称的,即使只有一个子节点,你也得说清楚它在左边还是右边。
二叉树里有两个特殊形态,一个叫满二叉树,一个叫完全二叉树。你去看定义会觉得绕,我用大白话翻译一下:
- 满二叉树:金字塔是完整的,每一层都塞满了节点,最后一层也没有缺角。
- 完全二叉树:从根到倒数第二层都是满的,最后一层的节点全部靠左排列,右边不能有空缺。
两者的关系是:满二叉树一定是完全二叉树,完全二叉树不一定是满二叉树。这个区别在堆这里至关重要,因为堆要求“结构上是完全二叉树”,而不是满二叉树。为什么?因为完全二叉树的节点编号是从上到下、从左到右连续排列的,这个连续性使得它可以平铺成一维数组,不需要任何指针就能还原出树的结构。
我记得当初学到这里最大的困惑就是:二叉树不是用链表存吗,为什么堆能用数组存?现在一句话说清楚:二叉树用链式存储是因为树结构不规整,中间可能缺节点,只能靠指针硬连;完全二叉树没有缺节点,按层序编号之后,任何父子关系都能用下标算术算出来,那还要指针干什么?直接存一个数组,省空间,还快。
1.3 二叉树的遍历:堆用不上,但要记住为什么
二叉树最常见的操作是遍历,就是把所有节点都访问一遍。按照访问根节点的顺序,分成前序(根→左→右)、中序(左→根→右)、后序(左→右→根),再加上按层一层一层扫的层序遍历。前中后序用递归写,非常优美,核心代码就三行:
cpp复制void traverse(TreeNode* node) {
if (!node) return;
// 前序:先访问 node
traverse(node->left);
// 中序:访问完左子树后访问 node
traverse(node->right);
// 后序:访问完左右子树后访问 node
}
堆这里比较特殊,它不需要这套遍历。因为堆用数组存储,你想访问某个节点的左孩子,直接算下标 2 * i + 1 就行了,不需要递归去找。这也就解释了为什么堆的实现都比普通二叉树简单:它的孩子关系是索引算术,不是指针。
这里有第一个容易踩的坑:虽然堆也叫树,但别用递归树的思路去写堆操作。你写堆的下沉(sift down)和上浮(sift up),永远是在数组里循环挪元素,不是递归遍历。一旦你开始用递归写堆操作,多半是把二叉树的习惯带进来了,方向就错了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 堆到底是什么:定义、分类与核心操作
2.1 堆的两个性质,一个管形状,一个管顺序
堆的定义,严谨一点说,必须满足两个性质:第一,它是一棵完全二叉树;第二,任意节点的值大于等于(或小于等于)它的所有子节点的值。
满足“父节点值 ≥ 子节点值”的堆叫大根堆、大顶堆、最大堆,它的堆顶是整个序列的最大值。满足“父节点值 ≤ 子节点值”的叫小根堆、小顶堆、最小堆,堆顶是最小值。这里注意,是父节点跟它的所有子节点比较,不是兄弟之间比较。大根堆里左子树和右子树之间谁大谁小完全没有规定,你只需要保证每个父节点都比自己的孩子大。
用生活化类比:这个关系就像公司里的晋升制度。大根堆是“老板必须比下属职级高”,至于两个平级部门哪个更牛,制度不关心;小根堆则反过来,老板必须比下属职级低。这层约束比二叉搜索树宽松得多,二叉搜索树要求左孩子 < 父节点 < 右孩子,全树严格有序,而堆只要求父子方向上有序。这也是堆比搜索树好维护的原因:你不需要像AVL树、红黑树那样做复杂的旋转来维持严格有序,只需要让大的节点往上游走、小的节点往下沉就行。
2.2 数组存储:下标算术是堆的命脉
堆用数组存的时候,下标规律是所有操作的根基。假设数组从0开始,那么:
- 父节点下标:
parent(i) = (i - 1) / 2 - 左孩子下标:
left(i) = 2 * i + 1 - 右孩子下标:
right(i) = 2 * i + 2
这个规律对任何完全二叉树都成立,你可以拿一张纸画几层节点、写上编号,慢慢验证。我当初是怎么记住的?我画了一个三层的小树,把每个节点的数组下标写在旁边,反复看了几遍发现一切都是 2 倍关系,从此就忘不掉了。
还有一个关键下标:最后一个非叶子节点。叶子节点没有孩子,不需要下沉,所以建堆时从叶子节点的父节点开始处理就行。这个节点的下标是 n / 2 - 1(整数除法)。为什么是这个数?因为最后一个节点下标是 n - 1,它的父节点是 ((n - 1) - 1) / 2 = (n - 2) / 2,整数除法下就等于 n / 2 - 1。举个具体例子,数组长度9,最后一个非叶子节点下标是 9 / 2 - 1 = 3。也就是说下标 0 到 3 都有孩子,下标 4 到 8 全是叶子。
这里我踩过一个坑:把数组长度和节点编号搞混。很多教材喜欢用从1开始的数组描述,因为那样父节点就是 i / 2,左孩子 2i,代码看起来更整齐。但C++和Java的数组天然从0开始,如果你习惯了教材的下标,写代码时忘了减1,就全乱了。我现在的建议是:统一用0基索引,每次写 left = 2*i + 1,写多了就不会错。
2.3 上浮与下沉:堆的自我修复机制
堆只有两个核心操作,一个叫上浮(sift up / bubble up),一个叫下沉(sift down / percolate down)。这两个操作是堆一切功能的发动机,建堆、插入、删除、排序,全部建立在它们上面。
上浮动作用于尾部,路径是往上走的。场景是插入新元素:新元素先放到数组末尾,这时候它可能比自己的父节点大(大根堆),违反了堆序性质,于是它就一直跟父节点比较,比父节点大就交换位置,直到不再比父节点大,或者到达根节点。这个过程像气泡往上冒,所以叫上浮。
下沉的方向相反,作用于头部,路径是往下走的。场景有两种:一是删除堆顶,把数组最后一个元素挪到堆顶,此时堆顶可能比自己的孩子小,需要往下换;二是建堆的时候,从某个非叶子节点开始往下梳理。下沉的逻辑是:当前节点跟它的左孩子、右孩子比较,在孩子里找到大的那个(大根堆),如果当前节点比这个更大的孩子还小,就跟它交换,然后继续对新位置做同样的检查,直到自己比所有孩子都大,或者成为叶子。
这两个操作都不难,难的是边界条件。写下沉的时候,最容易漏判的是右孩子不存在的情况。数组最后一个节点有可能是左孩子,右孩子的下标 2 * i + 2 可能正好等于数组长度,此时越界。正确的检查方式是在循环里先判 left < n,再判 right < n 且 arr[right] > arr[left]。我见过太多人在这里写出数组越界,包括我自己早期的代码。
cpp复制// 大根堆下沉,n 是当前堆的有效长度
void siftDown(vector<int>& arr, int n, int i) {
while (true) {
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) break;
swap(arr[i], arr[largest]);
i = largest;
}
}
这段代码里,left 和 right 先算出来,然后用 left < n 和 right < n 做边界保护,最后比较 arr[largest],一步到位。这种写法比先判断两个孩子在不在、再比较哪个更大要简洁,也不容易漏。
3. 建堆实战:从无序数组到合法堆
3.1 两种建堆思路,效率差了整整一个量级
给你一个乱序数组,怎么把它整理成合法的大根堆?大多数人第一反应是模拟插入:从空堆开始,一个个把元素 append 到末尾,每次做上浮。这个思路没错,但效率很差,最坏情况下是 O(n log n)。
为什么?因为上浮是从叶子一路升到根,最坏需要走完整棵树高度 log n 步。n 个元素都要这么走一遍,总代价自然就是 O(n log n)。虽然这个复杂度在很多场景下已经够用,但它不是最优的。
更聪明的办法是反过来,自下而上做下沉。思路是这样的:叶子节点不需要处理,因为一个没有孩子的节点天然满足堆性质。所以从最后一个非叶子节点开始,从后往前对每个节点执行下沉。你可能会想,这不一样吗,每个节点也都要下沉一次,为什么更快?
关键在于下沉的代价和它所在层高有关。最后一层节点的下沉最多走0步,倒数第二层最多走1步,倒数第三层最多走2步……越往上节点越少,但下沉步数越多。把每层的节点数乘以这层节点的最大下沉深度,加起来会发现总和收敛于 O(n),不是 O(n log n)。这个分解,直观理解就是:大多数节点都集中在树的底部,而底部节点的下沉距离很短;能走很远下沉路径的节点少之又少。于是总工作量被“底部的海量短路径”主导,反而便宜了。
3.2 自下而上建堆的完整流程
以数组 [4, 10, 3, 5, 1, 9, 7, 8] 为例,长度8,最后一个非叶子节点下标是 8 / 2 - 1 = 3。所以从下标3开始,依次处理 3、2、1、0。
第一步,处理下标3,值是5。它只有一个左孩子,下标7,值为8。8 > 5,交换,数组变成 [4, 10, 3, 8, 1, 9, 7, 5]。
第二步,处理下标2,值是3。左孩子下标5,值为9;右孩子下标6,值为7。取较大孩子9,9 > 3,交换,数组变成 [4, 10, 9, 8, 1, 3, 7, 5]。
第三步,处理下标1,值是10。左孩子下标3,值为8;右孩子下标4,值为1。两个孩子都不大于10,不交换。
第四步,处理下标0,值是4。左孩子下标1,值为10;右孩子下标2,值为9。取较大孩子10,10 > 4,交换,数组变成 [10, 4, 9, 8, 1, 3, 7, 5]。但是!交换后下标1的位置放的是4,此时下标1的孩子下标3是8,下标4是1,8 > 4,还得继续下沉。交换,数组变成 [10, 8, 9, 4, 1, 3, 7, 5]。接着看下标3的位置,值是4,左孩子下标7是5,5 > 4,交换,数组变成 [10, 8, 9, 5, 1, 3, 7, 4]。此时下标7是叶子,结束。
建堆完成后,堆顶是10,最大值,没问题。整个过程的关键点在于:下沉不是一次交换就结束的,它可能一路沉到叶子。这个连锁反应,很多人第一次手写会漏。
3.3 时间复杂度为什么是 O(n)
前面说过建堆是 O(n),这里给出一个比较严谨但不复杂的证明思路。把堆看成一棵满的完全二叉树,层高从叶子往根算。设叶子层高度为0,那么高度为 h 的节点数量大约是 n / 2^(h+1),这个节点下沉最多需要 h 步。
总工作量:
code复制T(n) = 1 * n/4 + 2 * n/8 + 3 * n/16 + ...
= n/2 * (1/2 + 2/4 + 3/8 + ...)
括号里的级数收敛于常数2,所以 T(n) = O(n)。更简单的记忆方式:把每个节点的“下沉距离”加起来,最坏求和结果是 n 的量级,不是 n log n。
面试里经常有人被问“建堆是 O(n) 还是 O(n log n)”,标准的回答思路就是解释这个求和。如果你用插入法建堆,那确实是 O(n log n);如果你用自下而上下沉建堆,那就是 O(n)。STL 的 make_heap 用的就是 O(n) 的算法。
这里我提一个实操经验:真正写代码时,不要用递归写下沉,用我上面给的那个 while 循环版本。递归版本虽然好懂,但遇到超大规模数组,递归深度最大能到 log n,一般也还好,但函数调用开销和调试复杂度完全没必要。工程上写堆操作,循环是更稳的选择。
4. 堆排序:一根画笔把数组排明白
4.1 堆排序完整流程与代码
堆排序的思路极其简洁,四句话:
- 把数组建成大根堆。
- 堆顶是最大值,把它和数组最后一个元素交换,此时最大值到了正确位置。
- 将堆的有效长度减1,此时堆里是剩下的 n-1 个元素,但堆顶可能不合法,对堆顶做一次下沉。
- 重复步骤2和3,直到堆有效长度为1。
为什么用大根堆?因为大根堆的堆顶是最大值,把它交换到末尾,数组尾部累积的就是从大到小的序列,左边是未排序的堆,右边是排好序的区域。这样数组整体最终变成升序:每次把当前区间的最大值放到末尾,剩下的还是堆,继续取最大值放到倒数第二……
如果你用小根堆,那堆顶是最小值,交换到末尾之后数组尾部累积的是从小到大,最终得到降序。所以记住:要升序用大根堆,要降序用小根堆。这个细节经常有人记反。
完整代码:
cpp复制#include <vector>
#include <algorithm>
using namespace std;
void siftDown(vector<int>& arr, int n, int i) {
while (true) {
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) break;
swap(arr[i], arr[largest]);
i = largest;
}
}
void heapSort(vector<int>& arr) {
int n = arr.size();
// 建堆:从最后一个非叶子节点开始自下而上
for (int i = n / 2 - 1; i >= 0; --i) {
siftDown(arr, n, i);
}
// 反复取堆顶
for (int i = n - 1; i > 0; --i) {
swap(arr[0], arr[i]);
siftDown(arr, i, 0);
}
}
注意第二个循环里,交换之后传进去的堆大小是 i,也就是说下标大于等于 i 的部分已经排好序,不参与堆调整。这一步我经常看有人写错,传成 n,导致已经排好的区域又被重新整理,排序结果完全不对。传参时想清楚:每次排序的有效区间在 [0, i-1]。
4.2 为什么堆排序不稳定
稳定性是排序算法的一个经典指标,意思是值相同的元素,排序后相对顺序不变。堆排序是典型的不稳定排序,而且这一点经常被面试官拿出来问。
为什么不稳定?因为堆调整的过程中,元素之间会有“跨距离”的交换,而且是从堆顶往末尾扔,中间可能隔着很多相同元素。举个例子:数组 [5a, 5b, 1],其中 5a 和 5b 数值都是5,用下标区分它们。建堆后,5a 和 5b 谁在堆顶取决于它们的位置,假设 5a 成为堆顶,那么堆排序第一步就把 5a 扔到末尾,数组变成 [5b, 1, 5a]。此时 5a 排到了 5b 的后面,相对顺序反了。整个过程中没有任何机制能保证相等元素保持原有次序。
相比之下,归并排序只要合并时“左半边的先放入”,就能保证稳定性;插入排序和冒泡排序天然稳定。堆排序做不到,因为它的交换不是相邻交换,而是相隔很远的父子交换,相等的两个元素可能在堆里处于完全不同的子树,一旦堆顶被扔到末尾,顺序就固定了,再也没机会修正。
4.3 堆排序、快排、归并怎么选
很多人在面试后纠结:堆排序理论上是 O(n log n),空间 O(1),为什么实际场景里大家都更爱用快排?
原因有两个。第一,堆排序的常数大。堆的调整是数组内部跳跃式访问,缓存命中率低,而快排是线性扫描加分区,对CPU缓存友好。第二,堆排序的交换次数比快排多。虽然时间复杂度同为 O(n log n),但堆排序每次下沉都要做好多次比较和交换,整体常数因子明显更大。实测上,数据规模一上来,堆排序通常比快排慢一倍以上。
那堆排序还有什么用?第一,如果你要求严格的 O(1) 额外空间,且手头没有可用的快排实现,堆排序是最稳妥的选择。第二,在外部排序、优先队列、Top K 这类场景里,堆不是用来做全量排序的,而是用来维护一个动态的最大/最小值,那它就比其他算法好用得多。第三,嵌入式等内存受限环境里,堆排序的原地特性非常香。
下表把常见排序做一下对比:
| 排序算法 | 平均时间复杂度 | 最坏时间复杂度 | 额外空间 | 稳定性 |
|---|---|---|---|---|
| 冒泡排序 | O(n²) | O(n²) | O(1) | 稳定 |
| 插入排序 | O(n²) | O(n²) | O(1) | 稳定 |
| 归并排序 | O(n log n) | O(n log n) | O(n) | 稳定 |
| 快速排序 | O(n log n) | O(n²) | O(log n) | 不稳定 |
| 堆排序 | O(n log n) | O(n log n) | O(1) | 不稳定 |
5. 堆的现实应用:排序只是冰山一角
5.1 优先级队列:堆最经典的变身
堆在工程中最重要的身份,不是排序,而是优先级队列。优先级队列的核心需求是:不停地往集合里加入元素,同时能快速取出当前最大或最小的元素。这正好就是堆的看家本领。插入和取最大值的操作都是 O(log n),比每次都排序 O(n log n) 或数组扫描 O(n) 强得多。
C++ 标准库里 priority_queue 默认就是大根堆,底层实现就是堆。Java 的 PriorityQueue 默认是小根堆。如果你不想手写堆,直接用它们就行。比如任务调度系统,每个任务带一个优先级,用一个优先级队列,每次取出优先级最高的任务执行,执行完再插入新任务,整体就是堆在背后工作。
还有一个高频用法:在“动态数据流”里持续取前K大。你维护一个大小为 K 的小根堆,遍历每个元素时,如果元素大于堆顶,就把堆顶替换掉。这样小根堆里永远是当前见过的最大 K 个元素,堆顶就是这 K 个里的最小值。写成代码:
cpp复制vector<int> topK(vector<int>& nums, int k) {
if (k <= 0 || nums.empty()) 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;
}
这里的关键是“小根堆 + 只比堆顶”。如果用大根堆存 Top K,你堆顶是最大的,遇到更大的元素要换,但你还得知道第二大的在哪,处理起来就很别扭。用小根堆,堆顶就是候选K个里最弱的,新元素只要能打败最弱的,就进组,然后重新修炼出新的最弱者,逻辑非常自然。
5.2 流式数据找中位数:两个堆对顶
另一个堆的经典玩法是用两个堆维持数据流的中位数。思路是:用一个大根堆存所有数的较小一半,用一个小根堆存所有数的较大一半。约定大根堆的大小不小于小根堆,且两者大小差最多为1。
插入元素时,先想清楚它该进哪一半:如果它比大根堆堆顶小,进大根堆;否则进小根堆。然后调整两个堆的大小,保持平衡。取中位数的时候,如果两堆大小相等,中位数就是两个堆顶的平均值;如果大根堆多一个,中位数就是大根堆堆顶。
这个方案被很多面试题用作“数据流中位数”的标准解,时间复杂度是每次插入 O(log n)、取中位数 O(1)。直接拿数组排序是 O(n log n),遇到动态流数据根本吃不消。我见过不少系统,需要实时计算用户提交数据的滑动中位数,用的就是这种双堆结构。
5.3 定时器、图算法和其他场景
堆在系统设计里藏得很深。定时器任务通常按过期时间建一个小根堆,堆顶就是最早要到期的任务,每次检查堆顶,到了时间就出堆执行。Redis 的定时器、很多网络框架的时间轮周边,都有堆的身影。
图算法里,Dijkstra 最短路算法如果没有优先队列,每次找当前距离最小的节点都要 O(n) 扫描,有了堆之后能优化到 O(m log n),m 是边数。这是堆对算法效率提升的真实代表。
多路归并问题中,假设有 K 个有序链表要合成一个有序链表,最朴素的办法是每轮从 K 个链表头里选最小,那样是 O(K * n)。用一个小根堆维护 K 个链表的当前头节点,每次出堆一个,再把它的下一个节点入堆,复杂度降到 O(n log K)。K 很大时,这个差距是决定性的。
顺势回应一下热搜里出现的 Redis 数据结构话题。Redis 的有序集合 zset 底层主要用跳表实现,而不是堆。很多人会问:堆取最大最小值是 O(1) 还是 O(log n),跳表优势在哪?关键原因在于跳表支持范围查询,比如按分数区间取出所有成员,这种“找一批而不是只找极值”的需求,堆就无能为力了。所以堆和跳表是不同形状的工具,对应不同的问题。
5.4 搜索热词里的其他“堆”概念别混淆
你可能在搜索里看到过“堆外内存”“编译器的堆空间不足”“栈、堆、队列的定义”这些词。注意,这里的“堆”指的是进程运行时动态内存区域,学名叫堆区,是操作系统和编译器层面的概念;而数据结构里的堆是优先队列的代名词。两者只是英文都叫 heap,本质毫无关系。面试和文档里经常混用这两个词,我见过有人因为这个在技术交流时闹了笑话。内存堆区是无序的,你 new 出来的对象随便在哪;数据结构堆是严格有序的,你必须维护它的堆序。看到“堆外内存”的时候,你可以确定它讲的是内存管理,跟本文的堆没有关系。
6. 面试高频问题与实操避坑
6.1 高频问题速答清单
面试环节里,堆和堆排序的频率极高,基本都是手撕题。这里整理成一张速查表:
| 问题 | 答案要点 |
|---|---|
| 堆排序的时间复杂度 | 建堆 O(n),n次取堆顶每次 O(log n),总 O(n log n) |
| 堆排序的空间复杂度 | O(1),原地排序,不用额外数组 |
| 堆排序稳定吗 | 不稳定,堆调整跨越远距离交换,无法维持相等元素相对顺序 |
| 建堆为什么是 O(n) | 自下而上下沉,各层总工作量求和收敛于线性 |
| 最大堆还是最小堆用来升序 | 最大堆,堆顶最大值交换到末尾,数组尾部累计大值,最终升序 |
| 插入和删除堆顶的复杂度 | 都是 O(log n),插入走长度 log n 的上浮路径,删除后下沉同理 |
| 数组第 K 大怎么求 | 维护大小为 K 的小根堆,扫描一遍,堆顶就是答案 |
| 手写下沉的边界条件 | 左孩子下标小于堆大小,右孩子下标小于堆大小,防止越界 |
| 为什么用数组存堆 | 完全二叉树节点连续,父子关系可用下标算术计算,不用指针 |
6.2 实操踩坑实录
说完高频问题,再讲几个我实际写代码时踩过的坑。第一个是边界问题。n / 2 - 1 这个建堆起点,n 为奇数偶数时都成立,但很多人习惯写 (n - 1) / 2,结果在 n 为偶数时会差1,导致最后一个非叶子节点漏掉,堆建出来不合法。我给的统一写法是 n / 2 - 1,建议直接记住。
第二个坑是 STL 里的 make_heap、push_heap、pop_heap。这三个函数默认都是大根堆行为,很多初学者以为 make_heap 可以生成最小堆,结果 sort 出来的顺序完全不对。要小根堆得自定义比较器,用 greater<int>()。而且注意 pop_heap 会先把堆顶交换到末尾再调整,之后数组末尾就是被弹出的元素,你要真正删除它还得再调用 pop_back。这个组合拳不熟的话,出错率极高,建议在本地先跑几遍验证。
第三个坑和代码优化有关。下沉操作里,如果你每次都先判断左右孩子在不在,再比较大小,逻辑会变得很长。我上面给的写法是先记录 largest = i,然后左孩子如果存在且更大,更新 largest;再检查右孩子,如果存在且更大,再更新 largest。这样避免了 else 分支,而且只用写两次边界检查。实测这种写法不容易漏边界,也方便改成小根堆:只需要把两个 > 改成 <,把变量名 largest 改成 smallest。
第四个坑是堆排序与快速排序混用时长数组的缓存表现。曾经在一次数据量很大的排序任务里,我图省事直接调了堆排序,结果比快排慢了不少。后来我理解了这个道理:排序任务优先选快排,除非有明确的原地和稳定 O(n log n) 最坏复杂度要求。堆排序的强项是取极值和动态维护,不是全量排序。
最后一个建议:笔试手撕代码前,在心里把下沉函数默写三遍,把右边的 right = 2 * i + 2 写好,别漏判 right < n。三遍之后基本就不会犯边界错误了。
我个人在实际操作中的体会是,堆这东西,光看定义看十遍不如手撕一遍。你找一个乱序数组,在纸上一行行模拟建堆的过程,再从堆顶连续取五六个元素出来,动手走完一遍之后,堆序性质、下沉路径、为什么不稳定这些问题就全通了。如果再遇到复杂的 Top K 或数据流中位数问题,心里立刻就有了堆的模型,不会被题目带偏。希望这篇内容能帮你把树、堆、堆排序这条线彻底理顺。
