1. 引言

如果一个排序算法在对多个具有相同键值的记录排序后,仍能保持它们在原始数组中的相对顺序,那么该排序算法就是稳定的。

稳定的排序算法在许多重要场景中非常关键,例如基数排序(Radix Sort)就依赖于排序的稳定性。

本文将通过一个反例说明:堆排序(Heapsort)虽然是一个高效算法(时间复杂度为 O(n log n)),但它并不稳定。

2. 理解排序稳定性

我们以一个包含多个元组的数组为例:

[(72, bob), (89, jim), (77, alice), (90, tom), (63, sam), (55, jill), (89, emma)]

每个元组可以理解为(成绩,姓名)。我们按“成绩”进行排序:

✅ 稳定排序结果应为:

[(55, jill), (63, sam), (72, bob), (77, alice), (89, jim), (89, emma), (90, tom)]

⚠️ 注意:虽然 (89, emma)(89, jim) 按字母顺序可能更自然地排成 (89, emma) 在前,但排序仅依据“成绩”这个键,不考虑“姓名”。

因此,在稳定排序中,jim 应该排在 emma 前面,因为它们在原始数组中就是这个顺序。

3. 堆排序简介

堆排序是一种基于堆结构的比较排序算法,其核心步骤如下:

  • 将输入数组构造成一个最大堆(Max-Heap)
  • 重复以下操作 n 次:
    1. 将堆顶元素与堆的最后一个元素交换
    2. 对剩余堆进行 sift-down(下滤) 操作,恢复堆性质

最终数组将被排序为升序。

堆排序不需要额外的二叉树结构,所有操作都在原数组上完成。

4. 堆排序为何不稳定?

我们继续使用前面的元组数组作为例子,验证堆排序是否保持稳定性。

输入数组为:

A = [(72, bob), (89, jim), (77, alice), (90, tom), (63, sam), (55, jill), (89, emma)]

数组索引为 1~7(从 1 开始)。

4.1. 构建最大堆

我们构建最大堆后,堆顶元素为 (90, tom)

heapsort intermediate state 1

将其与最后一个元素 (89, emma) 交换,此时 (90, tom) 被放到最终位置:

heapsort intermediate state 2

4.2. 使用 sift-down 修复堆

此时堆结构被破坏,(55, jill) 的键小于其子节点。我们使用 sift-down 操作将其修复:

heapsort intermediate state 3

修复后,堆顶为 (89, jim)。再次交换堆顶和最后一个元素:

heapsort intermediate state 4

4.3. 继续堆修复过程

继续对剩下的堆进行 sift-down 操作:

heapsort intermediate state 5

将堆顶 (89, emma) 与最后一个元素 (55, jill) 交换:

heapsort intermediate state 6

此时,我们已经可以观察到最终排序结果的一部分:

[(...), ..., (89, emma), (89, jim), (90, tom)]

⚠️ 问题出现了:在原始数组中,jim 出现在 emma 前面;但在排序结果中,emma 却排在了前面。

这说明堆排序破坏了原始的相对顺序,因此:

堆排序是不稳定的排序算法。

5. 总结

堆排序是一种高效的排序算法,具有 O(n log n) 的时间复杂度。然而,它并不保证相同键值元素在排序后的相对位置不变,因此不具备稳定性。

要判断排序算法是否稳定,不能仅看数值排序结果,而应从记录的角度出发,考虑多个字段中仅按一个字段排序时的行为。

在实际开发中,如果你需要保持记录顺序,应避免使用堆排序,而选择归并排序等稳定排序算法。


原始标题:Why Isn’t Heapsort Stable?