☰
堆排序实验报告避坑:heapify边界、复杂度与测试分层
2026/10/28 20:54:46 网站建设 项目流程

简介:这份资源是一份面向计算机专业学生与算法初学者、求职笔试备考者的堆排序算法实验报告文档,围绕算法原理与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
1i=3左子 25 小于 50,不动50, 20, 90, 50, 30, 40, 60, 25
2i=2右子 60 大于左子 40,90 仍更大,不动50, 20, 90, 50, 30, 40, 60, 25
3i=1左子 50 大于 20,交换;下沉后 20 与 25 再交换50, 50, 90, 25, 30, 40, 60, 20
4i=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相等元素比较时是否死循环
单元素7buildHeap里n/2-1 = -1,循环不进入
空数组长度 0heapSort的 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]。这一行字看起来多余,但它能挡掉我八成的越界和漏元素问题。堆排序里那五个坑,回头看有四个都源于区间约定没定死。

这份实验报告的价值不在于它记录了一个能跑的排序,而在于它把“为什么这么写”和“写错了会怎样”都留了痕迹。流程图负责说清顺序,代码负责说清边界,复杂度分析负责说清代价,三块咬合上,才算一份能拿得出手的东西。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询