简介:这份资源是一份面向计算机专业学生与算法初学者、求职笔试备考者的堆排序算法实验报告文档,围绕算法原理与Java实现展开,可用于课程实验提交参考或算法巩固复习。压缩包内仅含1个doc文件,整体约63KB,内容以文字报告为主体,配套流程图说明、可直接运行的Java关键代码(建堆、调整堆、堆排序与校验方法)以及复杂度分析,并记录实验硬件与软件环境和实验心得体会。文档对最好、最坏、平均三种情况的时间复杂度做了逐项讨论,指出建堆为O(n)、重建堆为O(nlog₂n),总体为O(nlog₂n),空间复杂度仅需一个暂存单元,同时提示堆排序不稳定、不适合小规模序列等注意点。目前已有3949人学习下载,适合希望对照完整报告格式、代码实现与复杂度推导快速理清堆排序脉络的读者参考。
1. 从一次实验报告翻车说起:堆排序算法能跑通,不代表你真的看懂了堆
第一次把堆排序算法交上去的时候,我的代码是能跑的,测试也过了,但答辩被问了一句“你建的是大顶堆还是小顶堆”,我指着注释说小顶堆,指着代码说不出话。那份实验报告里,流程图、关键代码、复杂度分析三块都齐,唯独中间那层因果关系是空的:为什么建堆要从 n/2-1 倒着走,heapify 的第三个参数到底代表什么,为什么最好情况也躲不掉 O(n log n)。后来我把这份东西重新拆了一遍,把每一个边界条件都和流程图上的框对齐,才算真正弄明白。这篇写给正在交算法实验、或者想把排序吃透的人——新手可以照着代码和用例一步步走,熟手能看到我在哪几处翻过车、判断依据是什么。
2. 堆排序的流程设计:流程图上的每个框都对应一行边界条件
很多人画堆排序的流程图,画出来是“建堆 → 取最大值 → 调整堆 → 重复”四个框,箭头一画,看着挺完整,但拿这张图去写代码,一样会卡在i到底从几开始、j到底包不包括。流程图的价值不在于把步骤画全,而在于每个框背后那个“循环变量从哪来、到哪去”是明确的。我后来重画这张图,会在每个框下面标一行伪代码级别的边界说明:建堆的循环变量初值是n/2-1、终值是 0、步长 -1;取最大值那一步的循环变量初值是n-1、终值是 1;调整堆时有效范围是[0, i-1]。这三行注释写不出来,流程图就是装饰。
下面这张表是我现在画这类图时固定会先列的,把数组下标当成完全二叉树的节点编号,一切下标运算都能从这里推出来,不用死记。
| 位置 | 下标表达式 | 说明 |
|---|---|---|
| 根节点 | 0 | 堆顶,全堆最大(大顶堆) |
| 节点 i 的左子 | 2i + 1 | 超过数组上界即无左子 |
| 节点 i 的右子 | 2i + 2 | 超过数组上界即无右子 |
| 节点 i 的父 | ⌊(i-1)/2⌋ | 用于自底向上回溯 |
| 最后一个非叶节点 | ⌊n/2⌋ - 1 | 建堆循环的起点 |
有了这张表,建堆为什么从n/2-1开始就不是一句“书上这么写的”,而是能自己推出来的结论。
2.1 建堆为什么从 n/2-1 开始倒着走
完全二叉树里,下标大于n/2-1的节点全是叶子。叶子没有子节点,天然满足“父不小于子”这个堆性质,不需要调整。所以建堆真正要处理的只有0到n/2-1这一段非叶节点,而且必须从后往前处理。倒序的理由是不变量:当处理到下标i时,它的左右子树(下标2i+1、2i+2)都已经各自是合法堆了,这时候对i做一次下沉,整棵以i为根的子树就变成堆。
如果反过来从 0 开始往下走,处理根节点时它的子树还是一团乱,下沉到一半就无处可去,只能反复重扫,复杂度直接退化。这个顺序不是优化技巧,是算法成立的前提。我见过有人在报告里写“从根节点开始逐个调整”,流程图看着没问题,代码跑小数据也对,一到 8 个元素以上就开始出错,原因就在这里。
拿实验里那组数据{50, 20, 90, 50, 30, 40, 60, 25}走一遍建堆,n=8,起点是8/2-1 = 3。把每一步的中间态列出来,比看十遍代码都管用:
| 步骤 | 处理的 i | 比较与交换 | 处理后的数组 |
|---|---|---|---|
| 初始 | - | - | 50, 20, 90, 50, 30, 40, 60, 25 |
| 1 | i=3 | 左子 25 小于 50,不动 | 50, 20, 90, 50, 30, 40, 60, 25 |
| 2 | i=2 | 右子 60 大于左子 40,90 仍更大,不动 | 50, 20, 90, 50, 30, 40, 60, 25 |
| 3 | i=1 | 左子 50 大于 20,交换;下沉后 20 与 25 再交换 | 50, 50, 90, 25, 30, 40, 60, 20 |
| 4 | i=0 | 右子 90 最大,交换;90 到位后 50 与 60 再交换 | 90, 50, 60, 25, 30, 40, 50, 20 |
第 4 步是最容易看漏的:根节点和右子交换之后,被换下去的那个 50 到了下标 2 的位置,而它的新子树可能又不满足堆性质,所以要继续往下沉,直到 50 落到下标 6 才停。这段“交换之后还要接着沉”的递归,就是后面heapify里那行递归调用的由来。流程图如果只画一个“交换”的框,不画这个回边,实现的时候十有八九会漏。
2.2 交换堆顶与末尾:循环为什么是 i > 0 而不是 i >= 0
排序阶段的逻辑是:堆顶是当前有效区最大值,把它和有效区最后一个元素交换,最大值就落到了最终位置;然后有效区长度减一,对新的堆顶做一次下沉,恢复堆性质。循环变量i从n-1递减到 1。
终止条件写i > 0而不是i >= 0,原因是当i=1时,有效区只剩a[0]和a[1]两个元素,交换之后a[1]就位,a[0]自然就是剩下的最小值,不需要再处理。如果写成i >= 0,i=0那轮会拿a[0]和自己交换,然后调用heapify(a, 0, -1),此时left = 1 > -1直接返回,结果上不会错,但多了一次无意义调用。更麻烦的是,这种“多跑一轮”的写法一旦和别的边界条件混用,很容易在下沉函数里算出负下标,变成越界。
第二个要盯住的是下沉的上界。第i轮交换完成后,有效区是[0, i-1],所以调用的是heapify(a, 0, i-1)。这里传错成n-1是最典型的翻车方式:已经排好序的最大值会被重新卷回堆里,表现为“前面基本有序,末尾几个元素乱掉”。这个 bug 在小数据上偶尔能蒙混过关,数据一大必现,而且报错信息是“结果不是升序”,完全不指向真正的原因。
我一般会把排序阶段的前两轮也手算一遍,和代码输出对照。接着上面的堆{90, 50, 60, 25, 30, 40, 50, 20}:第一轮swap(0,7)把 90 送到末尾,数组变成{20, 50, 60, 25, 30, 40, 50, 90},然后对[0,6]下沉,20 一路换到下标 6,得到{60, 50, 50, 25, 30, 40, 20, 90}。第二轮把 60 换到下标 6,再对[0,5]下沉。手算两轮之后,i和j的关系就再也不会搞混了。
3. 关键代码拆解:Java 实现里哪几行是骨架,哪几行是护栏
这份实验报告用的是 Java,代码分成五个方法:heapSort是入口,buildHeap建堆,heapify下沉,swap交换,另外还有isHeap和isSorted两个校验方法。很多人看这段代码会觉得“不就是个递归下沉”,但真要自己默写,卡住的地方往往不是递归本身,而是参数的含义。下面按调用链拆开讲。
3.1 heapify 的三个参数:i 是根,j 是有效区的最后一个下标
heapify(int[] a, int i, int j)是整个实现的基石,两个下标参数的含义必须在动手前就定死:i是待调整子树的根,j是当前有效堆的最后一个元素下标,闭区间。这个约定一旦定下来,后面所有判断都围着它转。
public void heapify(int[] a, int i, int j) { int left = i * 2 + 1; int right = i * 2 + 2; // 左子已经越界,说明 i 是叶子,天然是堆,直接返回 if (left > j) { return; } int large = left; // 先假设左子最大 // 右子存在且比左子大,最大者换成右子 if (right <= j && a[left] < a[right]) { large = right; } // 根比最大子小,交换,并沿着被换下去的那个位置继续下沉 if (a[i] < a[large]) { swap(a, i, large); heapify(a, large, j); // 注意:上界仍然是 j,不能换成 a.length - 1 } }参数说明:a是待排序数组;i是本轮下沉的起点,调用方保证0 <= i <= j;j是有效区右边界,闭区间,堆排序过程中它会随着已排序元素增多而不断左移。
这段代码有三个细节值得单独说。第一,left > j用的是大于而不是大于等于,因为j是闭区间,left == j时这个左子是存在的、需要参与比较。第二,right <= j这个判断不能省,右子可能不存在,漏掉就是越界。第三,递归调用传的是large和j,不是large和a.length-1——传错上界,已经排好的元素会被重新拉进堆,前面所有轮次的工作全部作废。
用递归写下沉,代码短、好懂,但它有一个隐性成本:递归深度等于树高,也就是 O(log n)。对常规数据量完全没问题,但如果哪天要处理上千万级别的数据,把递归改成 while 循环会更稳,避免栈深度带来的风险。这是个取舍,不是对错。
3.2 buildHeap 的起点与 heapSort 的循环骨架
建堆和排序这两个方法加起来不到十五行,但每一行都是边界。
public void buildHeap(int[] a) { int n = a.length; // 从最后一个非叶节点开始,倒序下沉到根 for (int i = n / 2 - 1; i >= 0; --i) { heapify(a, i, n - 1); // 建堆阶段有效区是整段数组 } } public void heapSort(int[] a) { buildHeap(a); // 建堆完成后 a[0] 是全局最大值 for (int i = a.length - 1; i > 0; i--) { swap(a, 0, i); // 最大值换到有效区末尾,i 及其之后有序 heapify(a, 0, i - 1); // 有效区缩到 [0, i-1],对新堆顶下沉 } }buildHeap的循环变量i初值是n/2-1,步长 -1,边界i >= 0,三个值缺一不可。heapSort里heapify传的是i-1,这是整段代码里最容易写错的一个字符——写成i就相当于把刚换到末尾的最大值又算进堆里,表现为“末尾若干个元素不参与排序”。
顺带提一句swap,三个赋值语句没什么可讲的,但它是“原地排序”这个结论的全部依据:除了这一个临时变量,整个算法没有申请任何和 n 相关的额外空间。复杂度分析里那句“空间复杂度 O(1)”,说的就是它。
3.3 用 isHeap 和 isSorted 做分层校验
一份能拿得出手的实验报告,测试不能只写一个“排完之后是有序的”。原因是:断言失败的时候你只知道结果错了,不知道错在建堆还是错在排序。分两层校验,问题范围立刻缩小一半。
// 校验以 i 为根的子树是否满足大顶堆性质 public boolean isHeap(int[] a, int i) { int n = a.length; if (i >= n) { return true; } int left = 2 * i + 1; int right = 2 * i + 2; // 父节点必须不小于左右子,否则不是大顶堆 if (left < n && a[left] > a[i]) { return false; } if (right < n && a[right] > a[i]) { return false; } // 左右子树也要分别是堆 return isHeap(a, left) && isHeap(a, right); } // 校验整体是否升序 public boolean isSorted(int[] ary) { for (int i = 0; i < ary.length - 1; i++) { if (ary[i] > ary[i + 1]) { return false; } } return true; }isHeap的判断方向要注意:a[left] > a[i]就返回 false,意味着父节点必须大于等于子节点,这是大顶堆的校验。这一段和摘要描述里写的“小顶堆”是冲突的,后面避坑那一节会专门说这件事。
分层的用法是这样:先用实验报告里那组图示数据{16, 14, 10, 8, 7, 9, 3, 2, 4, 1}单独测isHeap,确认建堆逻辑正确;再用{50, 20, 90, 50, 30, 40, 60, 25}测完整排序。如果第一个测试挂,问题在buildHeap或heapify;如果第一个过、第二个挂,问题在heapSort的循环边界。这个分法比盯着一个 assert 猜要高效得多。
4. 复杂度分析:为什么最好的情况也躲不开 O(n log n)
复杂度这一段是实验报告里最容易写成背书的部分。“建堆 O(n),重建堆 O(n log n),总复杂度 O(n log n),最好最坏平均都一样”——这四句话背下来只要十秒,但答辩时被追问一句“建堆为什么是 O(n) 不是 O(n log n)”,很多人就答不上来了。这一段我按自己能讲清楚的方式重新推了一遍。
4.1 建堆是 O(n) 而不是 O(n log n):一次求和自己就能算
直觉上容易这么想:一共 n/2 个非叶节点,每个下沉最多走 log n 层,乘起来不就是 O(n log n)?这个算法错在“每个节点都走满树高”这个假设上。实际上绝大多数节点都在树的底部,它们能下沉的高度很小,只有根节点才有机会走满 log n 层。
严格算法是数每个高度的节点有多少个。高度为 h 的节点最多有 ⌈n/2^(h+1)⌉ 个,每个最多下沉 h 层,总代价是 Σ h·n/2^(h+1),这个级数收敛到常数,所以总量是 O(n)。
拿 n=16 具体算一遍更直观:最底层 8 个叶子节点完全不动;倒数第二层 4 个节点各最多沉 1 层,共 4 次;再往上一层 2 个节点各最多沉 2 层,共 4 次;根节点最多沉 3 层,共 3 次。加起来 11 次,而 n log n = 16 × 4 = 64。差距这么大,就是因为“沉得动的节点少,能沉得深的节点更少”。
这个推导值得在报告里写出来,因为它是后面那句“堆排序对原始序列不敏感”的基础:建堆的代价和输入是否有序基本无关,只和树形有关。
4.2 重建堆:n-1 次取顶,每次 O(log n)
排序阶段要做 n-1 轮,第 i 轮的有效区大小是 i,对应的完全二叉树高度是 ⌊log₂i⌋ + 1。每轮下沉沿着一条从根到叶的路径走,最多比较两次、交换一次,代价是 O(log i)。累加起来是 Σ log i(i 从 2 到 n),量级 O(n log n)。
所以总复杂度 = O(n) + O(n log n) = O(n log n),前面的 O(n) 被后面的项吃掉了。
4.3 最好、最坏、平均为什么一致,以及“不稳定”到底意味着什么
三种情况复杂度相同,根本原因是堆排序的工作量由堆的形状决定,而不是由数据的初始顺序决定。不管输入是已经升序、完全逆序还是随机排列,建堆都是把那 n/2 个非叶节点挨个沉一遍,排序阶段都是 n-1 轮、每轮一次下沉。没有哪个分支会因为“数据已经有序”而提前退出。
这一点和插入排序正好相反:插入排序遇到已经有序的数组,内层 while 一次都不进,最好情况退化成 O(n)。堆排序拿不到这个便宜,但也因此不会像快排那样在特定输入下退化到 O(n²)——快排的最坏情况是真实存在的,堆排序没有这个问题。这是选型时一个很实际的判断依据:如果场景对最坏延迟敏感,堆排序的确定性比快排的平均性能更值钱。
空间上,整个算法只用了swap里那一个临时变量,O(1)。稳定性上,堆排序是不稳定的。原因在于交换是跳跃式的:元素从堆顶直接跳到有效区末尾,中间跨越了大量元素,相等元素的相对次序很容易被打乱。举个最小例子,数组{3a, 1, 3b}(a、b 只用来区分两个相等的 3):建堆后仍然是{3a, 1, 3b},第一轮把3a换到末尾得到{3b, 1, 3a},第二轮再把3b换到下标 1,最终结果是{1, 3b, 3a}——原来在前面的 3a 跑到了后面。稳定排序不会有这种现象。
实验报告里的测试数组{50, 20, 90, 50, 30, 40, 60, 25}恰好含两个 50,如果想验证不稳定,只要在排序前后记录两个 50 的原始下标就能观察出来。这个点写进报告,比抄一遍“堆排序不稳定”有说服力得多。
4.4 什么时候该用堆排序,什么时候不该
结论要落到具体的适用边界上,不能停在“O(n log n) 很优秀”。
该用的情况:数据量大、内存紧张(原地排序,O(1) 额外空间)、对最坏情况有硬性要求(不能接受偶发的 O(n²))、只需要前 K 个最大或最小值(维护一个大小为 K 的堆,复杂度 O(n log K),这才是堆真正的主场)。
不该用的情况:数据量很小。建堆阶段那 n/2 次下沉的比较开销在小数据上占比很高,n 小于十几的时候,直接插入排序往往更快。另外堆排序的访问模式是跳跃的,缓存局部性差,同样复杂度下实测往往跑不过归并排序,这一点在数据量大到超出缓存时尤其明显。
提示:如果只是要取 Top K,不要对整个数组做堆排序再截取,直接维护一个大小为 K 的堆即可,复杂度从 O(n log n) 降到 O(n log K),K 远小于 n 时差距非常明显。
5. 踩坑与排查:堆排序从“能跑”到“结果对”之间隔了五道坎
这段是我自己踩过、也见过别人踩的。每一条都按“现象 → 原因 → 解决”写,照着对一遍,能省掉大半天。
5.1 五条血泪踩坑记录
第一坑:文档说小顶堆,代码建的是大顶堆。现象是报告正文写着“建立小顶堆”,但程序跑出来是升序,答辩时被追问就露馅。原因是判断方向搞反了:isHeap里a[left] > a[i]就返回 false,意思是父必须大于等于子,这是大顶堆;而要得到升序结果,必须用大顶堆把最大值反复送到末尾,用小顶堆排出来是降序。解决方式二选一:要么把文档统一改成大顶堆,要么把比较符全部反过来并接受降序输出。关键是全文口径一致,别正文一套、代码一套、注释再一套。
第二坑:heapify 的上界传错。现象是排序结果“前面一大段有序,末尾几个元素乱掉”,而且数据量越大越明显。原因是heapSort里调用heapify(a, 0, i)而不是heapify(a, 0, i-1),把刚换到末尾的最大值又算进了有效区,下一轮下沉时它被重新拉回堆里搅了一遍。解决就是把上界改成i-1,并且检查递归调用里传的是不是同一个上界。
第三坑:闭区间和开区间混用。现象是ArrayIndexOutOfBoundsException,或者最后一个元素永远排不对。原因是这套代码用的是闭区间约定(j是最后一个有效下标),判空写的是left > j;如果某处按“长度”的思路写成left >= j,那么left == j这个合法的左子就被跳过了。解决方式是开工前先写下约定,然后全文所有判断都按这一个约定推一遍,不要凭手感改。
第四坑:递归下沉时上界跟着数组长度走。现象是排序过程中断,或者结果正确但多做了大量无用功。原因是heapify(a, large, a.length - 1)这种写法,把已经就位的元素重新纳入调整范围。解决是递归调用必须传当前的j,让有效区的边界一路传下去。
第五坑:测试只断言最终有序。现象是测试失败只报一个assertTrue失败,完全不知道错在哪一层。原因是没有中间态断言。解决办法是把测试拆成两段:先用固定数据测isHeap,再测isSorted。前者挂了说明建堆有问题,后者挂了说明排序循环有问题,定位范围立刻从“整个算法”缩到“某几行”。
5.2 一组能揪出 bug 的测试数据该怎么设计
实验报告里只用了两组数据,一组验证堆性质,一组验证排序结果。这个覆盖度不够,尤其是边界情况基本没碰。我现在会固定准备这几组:
| 用例类型 | 输入 | 想验证什么 |
|---|---|---|
| 常规乱序 | 50, 20, 90, 50, 30, 40, 60, 25 | 基本正确性,含重复元素 |
| 已升序 | 1, 2, 3, 4, 5, 6, 7, 8 | 建堆阶段的交换是否被跳过 |
| 完全逆序 | 8, 7, 6, 5, 4, 3, 2, 1 | 下沉路径是否走满 |
| 全部相等 | 5, 5, 5, 5, 5 | 相等元素比较时是否死循环 |
| 单元素 | 7 | buildHeap里n/2-1 = -1,循环不进入 |
| 空数组 | 长度 0 | heapSort的 for 循环不进入,不应抛异常 |
其中“全部相等”这组特别值得跑。因为heapify里的交换条件是严格小于(a[i] < a[large]),相等时不交换,所以全等数组在建堆阶段一次交换都不会发生,排序阶段每轮交换堆顶和末尾后,下沉也立即停止,最终结果仍然正确。这个行为能验证你的比较符用的是<还是<=——如果用<=,全等数组会陷入无意义的反复交换。
注意:空数组和单元素数组能通过,不代表逻辑正确,只是恰好没踩到边界。真正能暴露问题的是“已升序”和“完全逆序”这两组,因为它们的下沉路径长度差异最大。
5.3 出错时先看这三个地方
真跑出错误结果,别急着通读代码,按这个顺序排查效率最高。
第一,打印建堆完成后的数组,手工验证它是不是合法大顶堆。合法的话,问题一定在排序循环;不合法的话,问题在建堆,直接去看n/2-1那个起点和heapify的判空条件。
第二,如果建堆正确但排序结果不对,在第一轮和第二轮循环结束时各打印一次数组,看有效区是不是在正确缩小。如果某一轮结束后末尾的元素又被换走了,说明上界传错。
第三,如果结果对但偶发异常,重点查right <= j这个判断有没有漏,以及递归调用的上界有没有写错。这两个地方出错的表现都是“大部分情况正常,特定输入崩溃”,最难靠肉眼发现。
6. 把这份实验报告改成可复用的模板:三个检查点决定成败
把上面这些捋完之后,我对这份报告做了一个改动:不再按“流程图、代码、复杂度”三段并列地写,而是让后面每一段都为前一段提供证据。流程图里的每个框,在代码里都能找到对应的一到三行;代码里的每个边界判断,在复杂度分析里都能找到“为什么这里不能省”的理由。这样写出来的东西,答辩时随便指一行都能讲下去。
具体落到操作上,我给自己定了三个检查点,交之前必须过一遍。
第一个检查点:口径统一。大顶堆还是小顶堆、闭区间还是开区间、升序还是降序,这三件事在正文、注释、测试用例里必须完全一致。我见过太多报告栽在这上面——代码是对的,文字描述是反的,评委只看文字就判了不及格。检查方法很简单,把“小顶堆”“大顶堆”“升序”“降序”这几个词在全文搜一遍,看有没有互相矛盾的地方。
第二个检查点:每个复杂度结论都有推导。写“建堆 O(n)”的时候,后面必须跟一句“因为大部分节点靠近树的底部,能下沉的高度很小,总和收敛”,而不是只写结论。写“不稳定”的时候,后面必须跟一个具体的反例数组。这一步花不了十分钟,但它决定了这段内容是“背下来的”还是“想明白的”。
第三个检查点:测试分层。至少两组数据、两个断言,一组验证中间态(isHeap),一组验证最终态(isSorted)。边界数据至少覆盖空数组、单元素、全等、已升序四种。这一步做完,代码的正确性基本可以自己证明,不用等运行结果告诉你。
| 检查点 | 具体动作 | 不做的后果 |
|---|---|---|
| 口径统一 | 全文搜索“大顶堆/小顶堆”“升序/降序” | 文字与代码矛盾,直接被判错 |
| 推导完整 | 每个复杂度结论后补一句原因 | 被追问就答不上来 |
| 测试分层 | 中间态断言 + 边界用例 | 出错时定位不到具体位置 |
最后说一个我后来养成的习惯。凡是涉及下标区间的算法——堆排序、快排的分区、二分的边界——我做的第一件事不是写代码,而是在纸上写一行字:“本实现使用闭区间[l, r]”。然后把所有判断都按这一行推一遍:判空是l > r,长度是r - l + 1,递归是[l, mid]和[mid+1, r]。这一行字看起来多余,但它能挡掉我八成的越界和漏元素问题。堆排序里那五个坑,回头看有四个都源于区间约定没定死。
这份实验报告的价值不在于它记录了一个能跑的排序,而在于它把“为什么这么写”和“写错了会怎样”都留了痕迹。流程图负责说清顺序,代码负责说清边界,复杂度分析负责说清代价,三块咬合上,才算一份能拿得出手的东西。希望帮到你。
本文还有配套的精品资源,点击获取