堆排序这东西,跟平时我们学的那些死记硬背的算法彻底不一样。它不像快速排序那样把数据切分得明明白白,也不像归并排序那样非得等到两边都排稳了再合并,它的核心逻辑就一句话:为了快,得牺牲一点点顺序。

说白了,就是让数据“乱一点”,但保证那个最大值(要么最小值)总在堆顶,然后把它捧出来,再让剩下的数据重新找个平衡点。

这就好比你看一个歪歪扭扭的树,你一眼就能看到最高的树是哪位,然后你把它摘下来放在最上面,剩下的树枝还得再比个子高低,重新站成个架子。 在讲堆排序如何算准不准之前,咱们先拿个具体的例子看看。假设你要排一个数组,里面混杂着 10 个数字,比如 5, 9, 2, 8, 1, 4, 7, 3, 6, 0。堆排序的第一步,就是把这些数字塞进一个彻底二叉堆里。

这时候你不用管数组原来的顺序,你只关心这 10 个数能不能组成一个“大根堆”。在这个例子中,堆顶就是 9,这玩意儿就是最大的,哪位也不信。接下来就是循环了,从最终一个非叶子节点启动往下跑。

这时候你会发现,有些节点它的子节点比它大,有些又小,它得赶紧把小那个子节点调过来当父节点。

这个过程有点像你在整理房间,你发现床底下藏了个比被子还大的玩偶,你得赶紧把它拿出来放在最显眼的位置。一旦你重新建立了这个堆,那堆顶的 9 依然是老大,剩下的 8, 7, 6 紧随其后,前面的数字也在逐步缩小。

这时候,堆的大小是 n,非叶子节点的数量大约是 n/2。 接下来才是真正启动挖堆的时候。当堆只有一个元素时,排序终止,直接回。

要是堆里还剩两个元素,那就直接排好,出于两个元素本身就是一种有序的对子。但只要堆里还有三个或以上元素,就得执行“提升”操作。你从最终一个非叶子节点启动往上退,这时候你会发现,有些父节点别看比子节点小,但它得赶紧把子节点推上去,重新归位。

这一轮一上来,堆顶是不是又变了?不是,那个被挖出来的 9 还是那个 9,但这次它是在堆的“根”位置,并且是刚刚确认了身份的最大值。

这就像是你从树里把老大摘下来,把他叫到前台来,这时候前台的叶子节点就都变得更矮了,但大家头顶的树冠(堆顶)依然稳稳当当地站着。 这时候,算法重复之前的步骤,从 n/2 的位置启动,一层层往上。每一次往上走,只要发现子节点比自己大,就执行 swap。

这时候你可能会认定有点怪,往上走,本来应当变小啊?不对,这里有个误区。

实际上我们不是在调整堆顶,而是在调整“被挖出来的那个最大值”这个元素本身。想象一下,你是要把一个最大的球拿出来放在地毯中间,你不得不从它上面拿走一个次大的球,把它放在它旁边。

这时候,原本在它下面的次大球,目前务必找一个新的位置,它上面的球务必探出头来。

这个过程会一直持续,直到碰到叶子节点。

这时候整个数组的状态是:最上面那个你刚刚挖出来的 9 是老大,下面这一堆次之,最底下的新叶子节点也是老大。 算法持续往下走,启动把刚刚那个老大(9)处理掉。你目前要拿 9 和它下面两个儿子比。假设它下面有个子节点 8,有个子节点 7,那 9 就得把 8 和 7 俩都推到下面去。

这时候你就相当于把老大放到了一个中间位置,然后剩下的两个子节点负责填补空缺。

要是兄弟俩都是 6,那随意放哪个都行,反正都比 9 小。

关键是,在这个过程中,你起码移动了三次元素。 当堆里只剩下最终一个元素时,你就把 9 和它剩下的那个儿子换一下位置,这一步相当于把 9 和它原来的位置搞个“亲热拥抱”,别看位置变了,但值没变,反正都是最大的。

最终,整个数组就变成有序的了,从大到小,要么从小到大,取决于你是建大堆还是小堆。 那这个时候,要是要用代码来算比较次数,你会如何算?咱们不妨用数学公式来说一说。设数组长度为 n。建堆的时候,你需求遍历非叶子节点。非叶子节点的数量大约是 n/2。每个节点顶多要和它的两个孩子比较一次。

故此建堆的过程,比较次数大约是 n (n/2) / 3 = n^2/6。

这局部代码跑起来有点慢,主要是为了把“歪歪扭扭的树”变成“挺拔的树”。 然后挖堆的过程也一样。

每次从堆顶挖出来一个数,剩下的堆大小减 1。但这时候比较次数变了。当你处理最上面的一个数时,你比较的是它的两个孩子,也就是 2 次比较。处理下一个数时,出于左右子树都不整个,一般只需求 1 次比较就能拍板哪个子节点需求去。处理中间那些节点时,比较次数大约是 1 次。处理到最底下的叶子节点时,别看没比较子节点,但整个堆被“撑开”了,消耗了 n-1 次的空间移动(这里说的空间移动包含比较害得的位移)。

故此挖堆过程比较次数大约是 n - 1。 把建堆和挖堆加起来,总的比较次数大约是 n^2/6 + n - 1。当 n 特别大时,n 这一项就远小于 n^2/6 了,故此总复杂度是 O(n^2)。

这就解释了为啥堆排序在数据量挺大时,性能会急剧下降。

要是 n 是 1 万,那建堆就要跑 10^7 次比较,挖堆也就几万次,加起来差不多了。 这个公式实际上挺有意思的。

要是非要追求极致,那肯定得做大量优化。

比方说,建堆的时候,每个节点只和还不如最小的子节点比较,这样能够省下 n/2 次比较

这时候复杂度变成 n^2/3。再比如,挖堆的时候,大量情况下孩子只有一个,那就只比较 1 次,这样复杂度就变成 n^2/2。但即便如此,理论分析还是得假设每个节点都要比较所有可能的子节点,要不就你能证明在某些特定数据分布下,比如所有元素都相同,要么都是有序的,那样比较次数就能降到 O(n log n)。 总而言之,堆排序这事儿,就是靠一个“牺牲顺序换取空间换工夫”要么“牺牲工夫换取确定性”的办法。它不是魔法,只是一个严谨的数学过程。

每次比较,都是一个小小的博弈,要么保留顺序持续往下跑,要么拉倒顺序把最大 (或最小) 值提上来。

这就好比你在整理一堆乱码,你务必不断地把最显眼的挑出来,然后下面的再挑,一层层剥离,直到最终剩下的才是真正的有序。

这个过程别看慢,但一旦数据量挺大,它的优势就体现出来了:稳定性极佳,并且不需求额外的辅助空间。