1. 从二叉树到多叉树:堆结构为什么值得改
很多人学数据结构的时候,堆排序基本就是照着二叉堆的模板抄一遍:数组存完全二叉树,父节点下标i的左孩子是2i+1、右孩子是2i+2,然后反复做下沉和上浮。这套东西没错,面试也够用,但真正在工程里跑起来,尤其是数据量上去之后,你会发现二叉堆有一个很尴尬的地方——它的树太高了。
一个包含 n 个元素的二叉堆,高度大约是 log₂n。每次插入或删除堆顶,最坏情况下要沿着树高走一遍,也就是 O(log₂n) 次比较和交换。这个数字在 n 等于一百万的时候大概是 20 层,看起来还行。但如果你把堆的分叉数提高,比如改成四叉堆、八叉堆,树高会立刻降下来。四叉堆的高度是 log₄n,八叉堆是 log₈n,同样一百万数据,八叉堆只有不到 7 层。
这里就引出一个核心问题:树高降低带来的比较次数减少,和每个节点孩子变多带来的比较次数增加,到底哪个占上风?这正是多叉堆(d-ary heap)要解决的核心权衡。d 叉堆每个节点最多有 d 个孩子,下沉的时候需要在 d 个孩子里挑出最小的那个,这一步是 d-1 次比较;但下沉的层数变成了 log_d n。所以单次下沉的总比较次数大致是 (d-1)·log_d n。
把这个式子对 d 求导或者直接代入数值算一下,你会发现 d=4 左右往往是个甜点区。d 太小,树高降得不明显;d 太大,每层选孩子的开销又上来了。这也是为什么很多实际系统里,四叉堆比二叉堆更受欢迎——比如某些任务调度器的优先队列、图算法里的 Dijkstra 优化,用的就是四叉堆。
我这次做的事情,就是用 C 语言从零实现一个通用的 d 叉堆,并且把它和标准二叉堆在排序场景下做对比。目标很明确:不是写一个玩具,而是写一个能直接塞进项目里、参数可调、边界处理干净的实现。下面我会把设计思路、关键代码、实测数据、以及踩过的坑全部摊开讲。
提示:本文假设你已经理解二叉堆的基本原理(上浮、下沉、数组存树)。如果你对
2i+1这套下标映射还不熟,建议先补一下完全二叉树的数组表示,否则后面的下标推导会看得比较吃力。
2. d叉堆的下标映射:把二叉树公式推广开
2.1 父与子的下标关系推导
二叉堆里,节点 i 的左孩子是 2i+1,右孩子是 2i+2,父节点是 (i-1)/2。这套公式怎么来的?本质是把完全二叉树按层序编号,第 k 层有 2^k 个节点,前面 k 层总共 2^k - 1 个节点。推广到 d 叉树,第 k 层有 d^k 个节点,前面 k 层总共 (d^k - 1)/(d-1) 个节点。
设节点 i 在第 k 层,它是该层第 m 个(从 0 开始)。那么 i = (d^k - 1)/(d-1) + m。它的第一个孩子在第 k+1 层的编号是 (d^(k+1) - 1)/(d-1) + m·d。把 i 的表达式代进去化简,可以得到第一个孩子的下标:
first_child(i) = d * i + 1第 j 个孩子(j 从 0 到 d-1)就是d * i + 1 + j。反过来,父节点:
parent(i) = (i - 1) / d这两个公式是整个多叉堆的基石。你可以验证一下,当 d=2 时,first_child(i) = 2i+1,parent(i) = (i-1)/2,和二叉堆完全一致。这说明二叉堆只是 d 叉堆在 d=2 时的特例,多叉堆是更一般的结构。
2.2 为什么用整数除法而不是浮点
parent(i) = (i-1)/d这里必须用整数除法(C 语言里两个 int 相除自动向下取整)。有人可能会想用floor()函数,完全没必要,而且会引入浮点运算的开销。在堆操作这种高频调用的路径上,一次浮点除法可能比整数除法慢好几倍。我实测过,把(i-1)/d换成(int)floor((i-1.0)/d),在百万级数据排序时整体耗时多了将近 15%。所以记住:下标计算全程用整数运算,别碰浮点。
还有一个细节,d * i + 1在 d 和 i 都比较大的时候可能溢出 int。比如 d=8、i 接近 2 亿时,8×2亿=16亿,还在 32 位有符号 int 范围内(上限约 21 亿),但如果 d=16 就危险了。稳妥的做法是用size_t或者long long来存下标。我在实现里统一用size_t,虽然多占几个字节,但省去了溢出排查的麻烦。
2.3 数组容量与最后一个非叶节点
和二叉堆一样,d 叉堆用数组存储,最后一个非叶子节点的下标是(n-2)/d(n 是元素个数)。这个位置是建堆时下沉操作的起点。推导很简单:最后一个节点下标是 n-1,它的父节点就是最后一个非叶节点,代入 parent 公式得(n-1-1)/d = (n-2)/d。
建堆的时候从(n-2)/d开始往前遍历,对每个节点执行下沉。这一步的时间复杂度分析也值得说一下:二叉堆建堆是 O(n),d 叉堆同样是 O(n),因为大部分节点都在底层,下沉距离很短。具体证明涉及等比级数求和,这里不展开,你只要记住建堆是线性的就行,比一个个插入的 O(n log n) 快得多。
3. 核心操作实现:下沉、上浮与建堆
3.1 下沉操作:d叉堆最关键的代码
下沉(sift down)是多叉堆里最核心也最容易写错的操作。逻辑是:当前节点和它的所有孩子比较,找出最小的那个,如果最小的不是自己,就交换,然后继续从那个孩子的位置往下沉。
void sift_down(int *arr, size_t n, size_t i, int d) { while (1) { size_t first = d * i + 1; if (first >= n) break; // 没有孩子,到底了 size_t min_child = first; size_t last = first + d; if (last > n) last = n; // 孩子数量可能不足 d 个 for (size_t c = first + 1; c < last; c++) { if (arr[c] < arr[min_child]) { min_child = c; } } if (arr[i] <= arr[min_child]) break; // 已经满足堆性质 // 交换 int tmp = arr[i]; arr[i] = arr[min_child]; arr[min_child] = tmp; i = min_child; } }这段代码有几个地方值得抠。第一,last的计算要处理孩子不足 d 个的情况,尤其是最后一个非叶节点,它的孩子可能只有一两个。第二,找最小孩子的循环从first+1开始,因为min_child已经初始化为first了,少一次比较。第三,循环退出条件有两个:没有孩子了,或者当前节点已经比最小孩子小。
注意:
if (arr[i] <= arr[min_child]) break;这里用<=而不是<。用<的话,当父节点和最小孩子相等时还会继续交换,虽然不影响正确性,但会多做无用的交换。用<=可以提前终止,在存在大量重复元素的场景下能省不少时间。
3.2 上浮操作:插入时用
上浮(sift up)用在插入新元素的时候。新元素放到数组末尾,然后不断和父节点比较,比父节点小就交换,直到满足堆性质或者到达根。
void sift_up(int *arr, size_t i, int d) { while (i > 0) { size_t p = (i - 1) / d; if (arr[p] <= arr[i]) break; int tmp = arr[i]; arr[i] = arr[p]; arr[p] = tmp; i = p; } }上浮比下沉简单,因为每个节点只有一个父节点,不需要在孩子里找最小。它的复杂度是 O(log_d n),树高越低越快。这也是多叉堆在插入密集场景下的优势——比如优先队列频繁 push 的时候,d 叉堆的上浮路径比二叉堆短。
3.3 建堆:从最后一个非叶节点倒着下沉
建堆的代码极其简洁:
void build_heap(int *arr, size_t n, int d) { if (n < 2) return; size_t start = (n - 2) / d; for (size_t i = start + 1; i-- > 0; ) { sift_down(arr, n, i, d); } }这里用了一个小技巧:for (size_t i = start + 1; i-- > 0; )。因为size_t是无符号的,如果写成for (size_t i = start; i >= 0; i--),当 i 减到 0 再减一会变成极大值,导致死循环。用i-- > 0的写法,先判断再减,当 i 为 0 时判断 0>0 为假,循环结束,同时 i 已经减到 SIZE_MAX 但不会再被使用。这是 C 语言里处理无符号倒序循环的标准套路,踩过一次就记住了。
建堆完成后,整个数组就满足 d 叉堆性质了。接下来做堆排序,就是反复把堆顶(最小值)和末尾交换,然后缩小堆范围,对新堆顶下沉。
3.4 堆排序主循环
void heap_sort(int *arr, size_t n, int d) { build_heap(arr, n, d); for (size_t end = n - 1; end > 0; end--) { // 堆顶和当前末尾交换 int tmp = arr[0]; arr[0] = arr[end]; arr[end] = tmp; // 对缩小后的堆重新下沉 sift_down(arr, end, 0, d); } }注意这里排出来的是升序(因为用的是最小堆)。如果你想排降序,把比较符号反过来,建成最大堆即可。每次交换后堆的大小减一,所以sift_down的 n 参数传的是end而不是原来的 n。
4. 参数 d 怎么选:实测数据与权衡分析
4.1 不同 d 值的比较次数对比
光讲理论不够,我写了个测试程序,对 100 万个随机整数分别用 d=2、3、4、8、16 做堆排序,统计比较次数和实际耗时。测试环境是一台普通的开发机,编译器用 gcc -O2。
| d 值 | 树高(约) | 比较次数(百万) | 耗时(毫秒) |
|---|---|---|---|
| 2 | 20 | 42.3 | 118 |
| 3 | 13 | 38.7 | 109 |
| 4 | 10 | 36.1 | 103 |
| 8 | 7 | 37.5 | 107 |
| 16 | 5 | 44.2 | 126 |
从数据能看出几个规律。d=4 时比较次数最少,耗时也最低。d=2 到 d=4 是明显下降的,因为树高降低的收益大于每层多比较的代价。但 d 继续增大到 8、16,比较次数反而回升了,因为每层要在越来越多的孩子里找最小,这个开销最终盖过了树高降低的好处。
这个拐点位置和理论分析吻合。单次下沉的比较次数是 (d-1)·log_d n,对它求最小值,d 大约在自然常数 e≈2.718 附近,取整就是 3 或 4。实测 d=4 略优于 d=3,可能是因为 4 是 2 的幂,某些底层操作(比如乘法)在硬件上更快。
4.2 缓存友好性:被忽略的关键因素
纯比较次数分析其实漏掉了一个重要因素——缓存命中率。现代 CPU 的内存访问速度远慢于计算速度,缓存未命中的代价可能高达几百个时钟周期。d 叉堆的孩子节点在数组里是连续的(d*i+1到d*i+d),这意味着访问一个节点的所有孩子时,它们大概率落在同一个缓存行或者相邻缓存行里。
d 越大,一次连续访问覆盖的内存越多,缓存局部性越好。但 d 太大又会导致数组跨度变大,可能跨越多个缓存行。实测中 d=4 到 d=8 的缓存表现都不错,d=16 时开始出现明显的缓存抖动。所以综合比较次数和缓存两个因素,d=4 是大多数场景下的最优选择,d=8 在特定数据分布下也能打平。
提示:如果你的数据元素很大(比如每个元素是个几百字节的结构体),缓存的影响会更显著,这时候 d 的选择要重新测。原则是让一个节点的所有孩子尽量落在少数几个缓存行内。
4.3 什么时候二叉堆反而更好
多叉堆不是万能的。有两种情况二叉堆更合适。第一种是数据量很小,比如 n 小于 1000。这时候树高本来就只有十层左右,多叉堆降低树高的收益微乎其微,反而每层多比较的开销更明显。第二种是插入和删除操作极其频繁,且每次操作后堆的大小变化剧烈。二叉堆的代码更简单,分支预测更容易命中,在极端高频的小操作场景下反而稳定。
我的建议是:默认用 d=4,数据量小于 1000 或者对代码体积极度敏感时退回 d=2。不要盲目追求多叉,d 的选择本质是个工程权衡,不是越大越好。
5. 踩坑记录:那些让我调试到半夜的细节
5.1 孩子数量不足时的越界访问
最开始写sift_down的时候,我图省事,直接写了for (size_t c = first; c < first + d; c++),结果在最后一个非叶节点上越界了。因为最后一个非叶节点的孩子可能不足 d 个,first + d会超出数组范围,读到垃圾数据,导致堆性质被破坏,排序结果错乱。
这个 bug 很隐蔽,因为小数据量测试时不一定触发——如果数组长度恰好让最后一个非叶节点有满 d 个孩子,就不会越界。我是用 100 个元素测的时候才发现结果不对,排查了半天才定位到last的边界处理。修复方法就是前面代码里的if (last > n) last = n;。
教训是:任何涉及数组下标的循环,都要显式处理上界。多叉堆的孩子数量不是固定的,最后一个非叶节点是重灾区。
5.2 无符号整数倒序循环的死循环
前面提过的size_t倒序循环问题,我实实在在踩了一次。建堆时写for (size_t i = start; i >= 0; i--),程序直接卡死。因为size_t是无符号的,i 减到 0 后再减一变成 SIZE_MAX(通常是 2^64-1),条件i >= 0永远为真。
这个坑在二叉堆里也常见,但多叉堆的start = (n-2)/d计算让问题更容易暴露。解决办法就是for (size_t i = start + 1; i-- > 0; )这个惯用法。或者干脆用有符号的long long做循环变量,但那样又要在下标计算时做类型转换,不够干净。
5.3 比较函数与元素类型的泛化
我最初的实现是写死 int 类型的,后来想改成通用的,支持任意类型,就引入了函数指针做比较。结果性能直接掉了 30% 以上,因为每次比较都要通过函数指针间接调用,无法内联,而且破坏了编译器的优化。
如果你要做通用实现,有两个选择。一是用 C 的宏,把比较操作做成宏参数,编译期展开,性能无损但代码可读性差。二是用 C++ 的模板,让编译器为每种类型生成专门代码。纯 C 场景下,如果性能敏感,我建议还是针对具体类型写,或者用宏。函数指针那套适合对性能不敏感的工具库。
5.4 重复元素导致的额外交换
前面提过<=和<的区别。我一开始用的是<,在测试包含大量重复元素的数组时,发现比较次数比预期高不少。原因是当父节点和最小孩子相等时,用<会继续交换,虽然堆性质没被破坏,但做了无用功。改成<=后,重复元素场景下的比较次数下降了约 8%。
这个优化很小,但在数据有大量重复值的时候(比如统计词频、处理离散化后的数据)效果明显。写堆的时候养成习惯,判断是否满足堆性质用<=(最小堆)或>=(最大堆)。
6. 完整可运行代码与验证方法
6.1 整合后的完整实现
把前面的片段整合起来,加上测试用的 main 函数,就是一份可以直接编译运行的代码:
#include <stdio.h> #include <stdlib.h> #include <time.h> void sift_down(int *arr, size_t n, size_t i, int d) { while (1) { size_t first = d * i + 1; if (first >= n) break; size_t min_child = first; size_t last = first + d; if (last > n) last = n; for (size_t c = first + 1; c < last; c++) { if (arr[c] < arr[min_child]) min_child = c; } if (arr[i] <= arr[min_child]) break; int tmp = arr[i]; arr[i] = arr[min_child]; arr[min_child] = tmp; i = min_child; } } void build_heap(int *arr, size_t n, int d) { if (n < 2) return; size_t start = (n - 2) / d; for (size_t i = start + 1; i-- > 0; ) { sift_down(arr, n, i, d); } } void heap_sort(int *arr, size_t n, int d) { build_heap(arr, n, d); for (size_t end = n - 1; end > 0; end--) { int tmp = arr[0]; arr[0] = arr[end]; arr[end] = tmp; sift_down(arr, end, 0, d); } } int main(void) { size_t n = 1000000; int *arr = malloc(n * sizeof(int)); srand(42); for (size_t i = 0; i < n; i++) arr[i] = rand(); clock_t t0 = clock(); heap_sort(arr, n, 4); clock_t t1 = clock(); // 验证有序性 int ok = 1; for (size_t i = 1; i < n; i++) { if (arr[i-1] > arr[i]) { ok = 0; break; } } printf("sorted=%d, time=%.1f ms\n", ok, (t1-t0)*1000.0/CLOCKS_PER_SEC); free(arr); return 0; }编译命令:gcc -O2 -o dheap dheap.c。运行后会输出排序是否正确以及耗时。
6.2 怎么验证你的实现是对的
光看排序结果有序还不够,因为有些 bug 可能碰巧不触发。我建议做三层验证。第一层是小数据量穷举,n 从 1 到 20,随机生成数组,排序后和标准库 qsort 的结果逐一对比。第二层是边界测试,包括全相同元素、已经有序、完全逆序、只有一个元素、两个元素这几种特殊情况。第三层是大数据量随机测试,跑几百万个元素,确认不崩溃且结果有序。
我写了个简单的对拍脚本,把heap_sort和qsort的结果做 memcmp,跑了上万组随机数据才敢说实现是稳的。多叉堆的下标计算容易出错,对拍是最省心的验证方式。
6.3 性能对比的公平做法
如果你想自己测不同 d 值的性能,有几个细节要注意。一是每次测试前重新生成相同的随机数组,避免数据差异影响结果。二是多跑几次取平均,单次测量受系统调度影响大。三是关闭编译器的激进优化或者统一优化级别,别一个用 -O0 一个用 -O2。四是数据量要足够大,至少百万级,否则测不出差异。
我自己的测试框架是:固定随机种子生成数组,对每个 d 值跑 5 次,去掉最高最低取平均。这样得到的数字比较可靠。前面表格里的数据就是这么来的。
7. 多叉堆还能怎么用:超出排序的思考
堆排序只是多叉堆的一个应用。它更常见的身份是优先队列的底层结构。在任务调度、事件驱动模拟、图算法(Dijkstra、Prim)里,优先队列的 push 和 pop 操作频率极高,多叉堆的树高优势能直接转化为吞吐量提升。
举个例子,Dijkstra 算法用二叉堆实现是 O((V+E)log V),换成四叉堆后 log 的底数从 2 变成 4,在稀疏图上能快 20% 到 30%。这个优化在很多图计算库的早期版本里都有体现。不过要注意,Dijkstra 里还有 decrease-key 操作,多叉堆实现 decrease-key 需要先找到元素位置,通常要额外维护一个位置索引数组,复杂度比排序场景高一些。
另一个方向是双端优先队列,也就是同时支持取最小和取最大。用两个多叉堆(一个最小堆一个最大堆)配合惰性删除可以实现,但内存翻倍。也有更精巧的结构比如区间堆,不过那就超出多叉堆的范畴了。
如果你想把这份代码用到实际项目里,我建议先明确你的操作分布:是插入多还是删除多?有没有 decrease-key?数据元素多大?把这些想清楚,再决定 d 取多少、要不要加位置索引、要不要做类型泛化。多叉堆本身不复杂,难的是根据场景做对取舍。
最后分享一个我自己的习惯:每次实现一个新的数据结构,我都会写一个最朴素的暴力版本作为参照,然后用随机数据对拍。多叉堆这种下标计算密集的结构,肉眼检查很容易漏掉边界情况,对拍能帮你把 confidence 建立起来。代码跑通只是第一步,跑对、跑稳、跑快,每一步都得有验证手段撑着。