算法(23):heapsort-8.3一种先构建再排序的排序方法
堆排序的物理本质只有两阶段先原地建堆再反复摘取最大值放到数组末尾。Page 30堆排序基本计划物理内容堆排序做两件事建堆把无序数组变成一个最大堆根节点最大。排序反复把根节点最大值移到数组末尾把剩下的元素重新堆化。关键物理事实这两个阶段都在同一个数组里原地进行。你不需要aux辅助数组。Page 31–32堆排序的可视化概览这两页展示了堆排序的宏观过程第一阶段Page 31数组初始是乱序的S O R T E X A M P L E。经过“自底向上”的堆构造从N/2往下sink变成堆有序X T S P L R A M O E E。第二阶段Page 32反复将最大值根与末尾交换然后缩小堆范围再对新的根做sink直到所有元素被排到数组末尾。Page 33建堆的物理动作——自底向上代码片段javafor (int k N/2; k 1; k--) sink(a, k, N);物理原因数组的后半部分N/21到N都是叶子节点。叶子节点单独看就是一个合法的堆大小为1根就是自己。从第一个非叶子节点N/2开始向根节点方向k--依次执行sink每个节点执行完后以该节点为根的子树就满足了堆序。一路扫到根节点1整棵树就变成了堆。为什么建堆只需要O(N)而不是O(N log N)因为大部分节点叶子不执行任何操作只有从中间往上走的节点才执行sink且越往上节点数越少总交换次数被限制在2N以内。Page 34排序阶段的物理动作代码片段javawhile (N 1) { exch(a, 1, N--); sink(a, 1, N); }物理步骤当前根节点a[1]是剩余元素中的最大值。把它和当前堆的最后一个元素a[N]交换最大值去到了它的最终位置。堆的大小减 1N--这个最大值不再参与后续操作。新换到根节点的元素原本在末尾可能很小执行sink(1)让它下沉到正确位置恢复堆序。重复直到堆中只剩 1 个元素它自然是最小值。Page 35Java 实现框架与索引转换代码结构与二叉堆基本一致唯一需要注意的是索引转换PPT 里的堆逻辑使用 1-based 索引根在 1子节点在2k和2k1。Java 数组从 0 开始。所以sink内部计算要改为左子节点2*k 1右子节点2*k 2父节点(k - 1) / 2在exch和less中索引参数都以 0 为基准传递。代码中N表示数组长度当你需要操作堆时堆的大小是N完全覆盖数组。Page 36堆排序轨迹表这是一张详细的轨迹表展示每一轮sink后的数组内容索引从 0 或 1 开始取决于视角。你不需要逐行读数字只需要观察建堆阶段最大值X逐步移动到根a[1]。排序阶段根与末尾交换后X被固定在数组最右侧且不再参与后续堆操作。堆的大小逐渐缩小N递减。Page 37可视轨迹可视化图与 Page 36 类似只是用棒状图表示非文本表格。无新物理内容。Page 38数学分析与意义复杂度结论建堆最多2N次比较和交换。整个排序最多2N log N次比较和交换。物理意义时间保证了最坏情况O(N log N)与归并排序相同。空间原地排序与快速排序相同但快速排序最坏是平方级。工程局限PPT 明确列出内循环比快排长快排只是简单比较堆排序涉及数组跳跃索引计算。缓存利用差在排序阶段你频繁访问根索引1和末尾索引N然后在sink里跳转到2k和2k1。这些地址不连续CPU 预取失效。快排的partition是在连续地址上单向移动缓存命中率远高于堆排序。不稳定与归并排序相反。Page 39排序算法总结表这一页是整章的压缩总结表堆排序heap一行是否原地是。是否稳定否。最坏/平均/最佳都是N log N严格保证。备注N log N保证原地排序。堆排序的物理本质一句话总结它在同一块连续内存里把数组先组织成完全二叉树再通过反复交换根与末尾把最大元素逐次放到数组后端。它兼顾了最坏情况N log N和原地排序但因为访问地址不连续常数因子比快排大实际速度慢于快排。它存在的价值是“理论保证 空间节省”在你需要严格最坏情况保证且内存紧张的场景下它可能是唯一的选择。一、heap constructionQp33页需要详解。为什么是自底向上构建又为什么总交换次数被限制在2N以内我需要听你讲解自底向上能够保证这个堆秩序完满并且比自上向底更有效的原因。以及我还想知道为什么总交换次数是2n以内。按理说有N个节点则作为母节点就有floor[log2 N]层那么就可能做2^floor[log2 N]-1次交换最多。A你点到了堆排序里最核心、也最容易误解的地方。你的观察是对的——你不能把“每层最多交换次数”简单相乘因为大部分节点根本不会跑到最底层。. 为什么建堆必须“自底向上”物理原因下沉sink操作成立的前提是——左右子树必须是堆。当你对节点k执行sink时你是在假设节点k的左子树根在2k和右子树根在2k1已经各自满足了堆序父节点 ≥ 子节点。如果你从顶部根开始往下做左右子树全是乱的sink走几步就停了因为下面一团糟它不知道“该跟谁换”才能恢复整棵树的秩序。如果你从底部开始往上做从N/2开始最后一个有子节点的节点它的子节点全是叶子天然是堆。然后k--每次都确保当前节点下面的所有子树都已经满足了堆序。一路做到根节点整棵树就完成了堆化。所以“自底向上”的本质是从“已知有序的小结构”逐步向上合并而不是从顶部强行冲刷。2. 为什么总交换次数被限制在2N以内以及你的计算公式哪里出了问题你的想法是“母节点有floor(log2 N)层每层最多交换...次算出来好像不止2N。”我们来做这个求和的正确写法。考虑一棵完全二叉树我们把树按照高度Height分层叶子节点高度 0数量约N/2—— 它们不做任何交换。高度 1父节点是叶子每个最多下沉 1 次数量约N/4。高度 2每个最多下沉 2 次数量约N/8。……高度h根最多下沉h次数量为 1。总交换次数 S 的精确表达式忽略常数项这个求和的值是多少你可以把它拆开看第 1 项d1约N/4第 2 项d2约N/8 * 2 N/4第 3 项d3约N/16 * 3 ~N/5.3……你会发现当d增大时N/2^{d1}迅速衰减远远抵消了d的线性增长。这个级数的总和收敛于N - log N - 1对于完全二叉树永远小于N。你看到的2N是 PPT 给出的保守上界考虑非完全二叉树和取整误差实际交换次数通常在N左右。你原来的计算误区在于你假设每个母节点都能交换到它的最大深度log N但实际上只有靠近根的那几个节点才有机会下沉很远。绝大多数节点底层的大量节点下沉深度只有 1 或 2。你把最大深度乘以节点数量就是把每个节点的“潜力”当成了“必然发生的成本”而sink在每一层都会停下来因为下沉过程中可能已经遇到比它大的父节点了。总结帮你焊死这个结论自底向上从N/2到1执行sink利用“子树已经有序”的前提逐层往上堆化。为什么是2N而不是N log N下沉的总次数等于“所有非叶子节点的高度”之和。在完全二叉树中大部分节点的高度极小求和结果收敛于NPPT 为了保证通用性写了2N。所以你的直觉“每层都可能做很多次交换”在物理上成立极少数节点确实会下沉到底但数学求和已经把所有层加起来了结果是线性而非线性的。你刚才的公式错在“把每一层都当成了全量节点下沉到底”而实际上只有少数高层节点能下沉到最深处。Q我好像知道了如果这个堆结构原本就是乱序我们又要自上向下的执行sink的话每次执行完就只能保证当次执行的堆是有序的也就是说假如先从节点1向节点23执行了然后再从节点2向节点45执行那么就是先保证堆123有效再保证堆245有效但是保证堆245有效的时候却不能保证堆123有效了因为堆245的置换可能会导致节点2的值再次比节点1大。最后就是只有叶子节点和它的母节点是有效的对吗A正确。你举的例子先沉节点1再沉节点2直接暴露了问题的本质沉完节点1后以节点1为根的堆1-2-3暂时有效。沉节点2时节点2的值可能被其子节点4或5中的较大者替换。替换后节点2的值可能变得比节点1还大。此时堆序被破坏1 2而节点1是根无法自动调整。你为了修复节点2反而破坏了节点1。所以你说“最后只有叶子节点和它的母节点是有效的”——这句话在物理上完全成立。经过一轮自上而下的扫描只有最底层的父子关系被固定因为底层没有更深的节点来干扰它们而高层的关系依然混乱。实际上如果你真的按for (k 1; k N/2; k) sink(k)执行最终的数组很可能依然不是堆因为节点1和节点2的关系无法保证。为什么自底向上不会出现这个问题自底向上的执行顺序是k N/2, N/2-1, ..., 1。当你处理节点2时它的子树4、5已经是堆因为它们是叶子或已经被处理过。节点2下沉后节点4或5可能会升上来但节点1还没有被处理所以它破坏了节点1也无所谓。当你最后处理节点1时节点2和节点3的子堆已经彻底稳定不会再被后续操作改变。此时节点1做一次下沉就能把整棵树固定。你刚才的推理已经完整地解释了“为什么自上而下不行”。而你总结的那个现象只有叶子与母节点有效恰好是自上而下策略的必然后果——因为它没有为高层提供“后盾”。Q但自上向下为什么还是不行呢如果我们自上向下多次的话不就可以处理掉这个堆了吗例如说按照我刚刚举的例子自上向下一次那么最底下的堆有效。然后自上向下第二次可能出现的是刚刚的节点二置换上来的数比节点一大或者比节点一小。如果比节点一小那么sink就会结束内部不一定完全有序如果比节点一大sink继续但被操作的还是节点2的子孙节点。那么无论如何节点3那边就被忽略了。但如果自上向下对每个节点都操作多轮呢似乎最后是可以的但代价已经远超自底向上了。A多次自上而下确实可以完成建堆而且如果你一直执行到没有交换发生它最终确实会得到一个合法的堆。但这是一种不同的算法通常称为“反复下沉直到稳定”它的代价不是O(N)而是O(N log N)最坏情况下甚至接近O(N²)。这就是为什么工业实现和教科书都选择自底向上而不是“多轮自上而下”。1. 为什么“多轮自上而下”最终有效但代价极高你推演的逻辑完全成立第一轮操作后底层局部有序第二轮操作后高层得到修正多轮之后整棵树最终会稳定下来。这就像一种松弛迭代法类似于冒泡排序在树形结构上的推广。但代价为什么高因为自上而下的一轮扫描中存在大量重复且无效的工作。看一个简单场景最小的元素在根节点。在第一轮中当你执行sink(1)时这个最小元素会一直被交换到叶子节点路径长度为log N。这很好。但紧接着当你执行sink(2)时你可能会把某些元素向上推导致根节点再次变得很小。在第二轮中你又得处理根节点那个最小元素可能又被推下去。同一个元素可能在多轮中被反复推到底部因为它不断地被其他操作“顶”上来。而自底向上时这个最小元素如果出现在根节点它根本不会被处理因为建堆阶段关心的是“把大的元素推上来”而不是“把小的元素推下去”。只有到排序阶段最小元素才会被移到末尾。量化结果每一轮自上而下的扫描最坏情况下需要O(N log N)次比较因为许多节点会下沉到很深处。如果你需要log N轮才能稳定总代价就是O(N log² N)。对于N100万log² N ≈ 400而log N ≈ 20差距是20倍。这种代价在工业级规模下是完全不可接受的。而自底向上一次完成代价是O(N)没有任何后续轮次。2. 自底向上为什么一次就够而不用多轮因为自底向上在接触父节点之前已经把所有子堆完全锁死了。当处理节点 3 时它的子树6、7已经是完美堆且不会再被任何后续操作除了直接的父节点交换破坏。当处理节点 2 时节点 4、5 的堆已经确定即使节点 2 下沉时与它们交换交换后节点 2 的某个子堆比如 4会暂时变乱但sink(2)会在那个子堆内部继续下沉而不会回头去破坏节点 3 的堆。因为sink的路径是单向向下的从 2 到 4 到 8...它永远不会横向触及节点 3。而在自上而下的多轮过程中当你在第二轮再次sink(1)时它会沿着一条路径向下移动。此时它可能会交换到节点 2然后继续向下到节点 4但它也可能与节点 3 的值进行比较虽然通常不会交换但比较的成本依然存在。多轮操作会导致大量跨层的重复比较这些比较在建堆过程中毫无意义。3. 用你的例子验证“节点3被忽略”的现象你提到“无论如何节点3那边就被忽略了”。在自底向上的方案中节点3确实会在第二轮被处理因为循环从N/2向下会经过节点3。在处理节点3时它的子堆6、7已经是完美的所以节点3的sink一次就能正确修复且修复后不会再被破坏。但在自上而下的多轮方案中节点3在第一轮被处理时子堆6、7可能还是乱的所以sink(3)可能什么也做不了或者做了一些无效交换。到第二轮虽然子堆6、7可能好了但节点3已经不会再被重新处理了除非再做第二轮。这就是“被忽略”的物理含义——它没有被安排在“子堆已经稳定”之后执行而是在子堆稳定之前就被执行了从而错过了最佳执行时机。4. 结论帮你锁死这个认知你完全理解了“多轮自上而下”为什么能收敛以及为什么它不符合实际工程要求。工业界选择自底向上正是因为它只需要一轮且这一轮的工作量是线性的。任何其他方案多轮自上而下都会引入额外的时间开销而且这个开销不是常数因子而是对数或者平方级别的。二、sortdownQ来看sortdown它讲的应该是如何从一个最大优先堆构建最小优先堆每次把最大的挪到数组的末尾但是不删除它而后对挪上去的那个小数进行一次sink由于除了堆123其他都是有序的所以只要单一的自上向下sink就可以保证整个堆有序。而后多次重复就可以构建出一个最小优先堆因为每次都把剩余堆中最大的元素放到了最倒数的那个位置。A描述在物理动作上完全准确只有最后一句话的目标定义需要微调。你观察到的“最终数组从左到右是升序这正好满足最小堆的性质”是对的。但堆排序的主要目标是原地升序排序而不是“主动构建一个最小堆数据结构”。最终数组恰好满足最小堆性质是“每次把最大值放到末尾”这个操作的必然结果不是算法刻意追求的目标。关于你描述中的三处物理事实确认“每次把最大的挪到数组的末尾但是不删除它”—— 正确。N--只是缩小堆的逻辑边界最大值仍然留在数组里只是不再参与后续的堆操作。“对挪上去的那个小数进行一次sink由于除了根以外其他子树都是有序的只要单一的自上向下sink就可以保证整个堆有序”—— 正确。交换前的整个堆是合法的最大堆除了根节点被替换成了一个小值以外所有子树仍然各自满足堆序。一次sink(1)只需沿着一条路径向下修复就能让整棵树恢复堆序。“多次重复就可以构建出一个最小优先堆”—— 这个说法本身在结果上是对的最终升序数组确实满足最小堆性质但它不是算法设定好的目标。堆排序的定位一直是先建最大堆再通过反复交换最大值到末尾获得升序数组。那么为什么说最终数组“恰好”是一个最小堆最小堆的性质是父节点 ≤ 两个子节点。对于升序数组任意a[k]都小于它右边的所有元素所以它的左子节点2k1和右子节点2k2一定更大。因此升序数组天然满足最小堆的定义。所以你的观察没有错只是它的出现方式是被动的你一直在构建升序数组而升序数组恰好是最小堆的一种特殊形态。如果你想要一个“明确的最小优先堆”作为最终产物你完全可以直接使用这个升序数组但它已经不再是堆排序原本要强调的输出形态了。Q那么我们已经做了一个自底向上堆构建为什么还要做sortdown呢A因为堆构建和下沉排序解决的是两个不同的问题。堆构建只负责把数组变成“堆有序”保证a[parent] a[child]。但堆有序 ≠ 完全升序。在最大堆中根节点是最大值左子树和右子树之间没有大小关系——左子树里的某个值完全可能大于右子树根节点。所以你完成了堆构建只是拿到了一个“最大值在顶部的树状结构”并没有拿到一个从左到右递增的数组。而sortdown的目标是把堆有序结构“摊平”成一个完全升序的数组。物理上sortdown具体在做什么提取最大值根节点a[1]是剩余元素中的最大值。你把它交换到数组的末尾a[N]。隔离最大值此时末尾元素已经是正确排序的最终位置不再参与后续堆操作N--。恢复堆序新换到根节点的元素原本在末尾通常很小执行sink(1)让剩余元素重新满足堆序。重复下一次循环中新的根节点又是剩余元素中的最大值再次交换到当前末尾。最终效果数组从左到右索引 1 → N是升序排列的。为什么不能只用堆构建来完成排序堆构建只保证父子但不保证左右。它只给了你一个允许快速提取最大值的结构但没有给你一个连续的升序序列。你需要sortdown这个“提取-放末尾”的循环把最大值逐个剥离并固定在数组后端。剥离的过程本质上是在做“就地提取”而堆构建只是为这个提取过程准备了一个高效的数据结构。一句话堆构建是“建立最大值快速提取通道”sortdown是“通过这个通道逐个提取最大值并放到数组末尾最终填满一个有序数组”。一个是工具一个是使用工具完成的目标。你要是问如果只想得到一个最大堆不做排序那确实不需要sortdown。三、时间复杂度分析Q来看这里这里的前提中有一条是在堆构建的时候交换和比较次数会小于等于2n次但我觉得好像不太对。例如我们现在假设一个最底层全部满员的堆每有两个节点代表两次比较和一次可能的交换那么我们对最底层也就是第n层进行对比交换的话大约有N/2个节点将被操作这里获得的比较次数是N/2交换次数是N/4假设全交换对每层套用相同的逻辑我们不需要把第一层排除其外因为第一层不会影响数量级的计算我们将获得两个等比数列第一个等比数列是124一直到n/4交换数列第二个等比数列是124一直到N/4、N/2比较数列将两个数列的和相加我最后求得的答案是n-2认为这个是比较次数和交换次数之和的最坏情况。那么就不应该说交换和比较次数将小于等于2n次而是直接小于等于n次。A这里堆构建的交换模型是错误的。自底向上看不能把堆看做相互孤立而是高层堆的构建与底层堆会产生联系。Q那么我的问题现在变成了为什么比较次数的等比数列中依旧是n/4加上2n/83n/16等假使我们的堆构建进行的是全比较和全交换那么对于自底向上第一个底层的比较次数是n/2次进行到倒数第二层的比较其构建的是倒数第二层与倒数第三层之间的堆关系。那么先进行一个倒数第二层与倒数第三层之间堆构建的比较就是N/4次在进行倒数第二层与倒数第一层之间的下沉堆构建比较则是n/2次。那么这个数列将会变成n/2n/2n/4n/2n/4n/8等。A你的这个推演在逻辑上完全自洽而且你精准地描述了“如果高层节点下沉时会连带重新检查所有底层叶子”的假想场景。但物理事实是高层节点下沉时并不会重新检查所有 n/2n/2 个叶子。它只沿着它下沉的那一条路径走只触及路径上的那几个叶子。1. 你的模型全局重扫 vs. 实际物理单路径你的模型相当于“逐层多轮扫描”处理到倒数第三层时你认为“下层堆”需要被全局重建n/2n/2 次比较因为某个高层节点沉了下来。这实际上描述的是每一轮都重新检查整层所有节点的多遍算法它确实会导致 n/2n/4…n/2n/4… 的累加最终是 O(nlog⁡n)O(nlogn)。实际sink的物理当高层节点比如根下沉时它从根开始走到左子节点或右子节点选择一个然后继续往下走。它不会去检查这一层的其他兄弟节点。对于 n/2n/2 个叶子一个高层节点下沉只可能替换掉一个叶子节点路径最底端的那一个。其他 n/2−1n/2−1 个叶子完全没有被触及它们的状态在之前的自底向上构建中已经被锁死了不需要重新检查。2. 为什么你觉得“下层堆会被重新构建”因为你把“高层节点下沉时路径最底端替换了一个叶子”等价为了“整个下层堆被破坏需要重建”。实际上当路径底端的叶子被替换时只有那个叶子所在的子树局部被破坏了。而那个子树的父节点高度 1在sink过程中会立即对路径上的节点做调整然后停止。其余的所有叶子不在路径上的毫发无损。所以自底向上构建中每个节点恰好被处理一次且只沿一条路径向下因此总比较次数是线性的~2N而不是逐层累计的 n/2n/4…n/2n/4…。3. 你的级数 n/2n/4…n/2n/4… 对应的是什么它对应的是一种“多轮全局扫描”策略第一轮处理所有高度 1 的节点。第二轮再次处理所有高度 1 和高度 2 的节点因为高层下沉可能破坏了底层的堆序。第三轮再次处理所有高度 1、2、3 的节点……直到稳定。这种策略确实能完成建堆但它的总比较次数是 O(Nlog⁡N)O(NlogN)而不是 O(N)O(N)。你的直觉并没有错只是你把这个“多轮”的成本套用在了“单轮自底向上”的算法上所以觉得不对劲。自底向上之所以是~2N正是因为它不走回头路——每个节点只执行一次sink且不重新检查已经稳定了的兄弟子树。A确实如此那么在倒二倒三层之间进行完构建再回到倒一层构建的时候构建的规模数将是倒一层和倒二层的一半。而如果再往上看你将看到的是三个等量规模的复制与叠加也就是说无论在多高层在该层开始一个sink往后传递的比较与交换都只是单链路传递而非全局扫描。