基于比较的排序

基于比较的排序

优先队列的原理

优先队列(Priority Queue) 是一种抽象数据结构,它支持以最高(或最低)优先级为先进行元素的插入和取出操作。
在优先队列中,每次检索得到的都是当前权重最大(或最小)的元素。

为了实现优先队列,最直接的思路是维护一个内部元素有序的数组:每次取数就是第一个数,存数时将元素放入合适位置,并调整序列。

堆(Heap)结构是实现优先队列的首选。

数组模拟堆实现的优先队列

堆结构与取存原理

堆(Heap)本质上是一个完全二叉树结构: (当然也可以是多叉树,但没有必要) 图1:大根堆的二叉树结构图示

这里用“大根堆”为例,从图中可以看到,每一个节点都比它的子节点更大。 既然“优先队列”可以理解为一种特殊的“队列”,那么我们先用堆实现这个队列的出和入:

取数

从优先队列中取数,显然堆顶的数就是要的那个最大者。 但是将这个数取出后还不能结束,因为需要维护堆的性质。
为了维护堆性质,一般通过将末尾的元素放到堆顶,然后将其不断与左右儿子进行替换,直到他比两个儿子都大或儿子不存在为止,称为“下滤”。

存数

与取数类似,重要的是维护堆的性质。将新数放到最后(上图中的第一个黑色节点中)后,将这个数进行“上浮”。

数组模拟堆

为了实现堆,其实不需要真的写一个二叉树,用数组就可以做到。 图2:左边是堆的数组表示,右边是其对应的二叉树 如上图,左边是堆的数组表示,右边是其对应的二叉树。

数组下标从1开始,任意一个下标i,其左右儿子下标刚好就是"i*2"和“i*2+1”。这样在后续代码实现时,代码写起来就会简单很多。

建堆

用数组模拟堆还有一个好处,就是可以“原地建堆”。 对于一个元素随机的数组,只需要O(n)的时间复杂度就可以完成随机数组向堆的转化。

具体做法为“从后向前”遍历,对于每一个非叶子节点,就将其进行“下滤”,这样以它为根的子树就变成一个小堆。往前遍历即可。

优先队列的算法实现

练习:堆操作

在进行代码实现之前,做一个问题练习: 对于一个随机队列"[3, 32, 6, 43, 5, 8, 0, 9]",经过一轮反向扫描下滤建大根堆操作,得到的堆的序列是什么? 将答案告知AI教师。

任务:城市事件优先调度

现在,让我们通过编程实践来掌握堆排序的实现。假设我们需要对城市中发生的一系列事件按照紧急程度(以数值大小表示优先级)排序,从而依次处理最高优先级的事件。这相当于将一组数字按从大到小排序的过程,与堆排序的机制完全一致。



题目:实现堆排序

请你实现一个堆排序算法 heap_sort(arr),将传入的数组利用堆排序方法排序(从大到小)。
你可以通过实现max_heapify和build_max_heap等函数来完成这一任务。
完成编码后,我们将对算法的性能进行测试,比较不同规模输入下堆排序运行时间的增长情况。

代码框架

请在下方代码编辑区完成 max_heapify、build_max_heap 和 heap_sort 函数的实现。

import random
import time

def max_heapify(arr, n, i):
    """
    维护最大堆性质:假设结点 i 的左右子树已经是最大堆,
    调整结点 i 使以其为根的子树成为最大堆
    参数:
        arr: 存储堆的数组
        n:   堆的有效大小(长度)
        i:   需要下滤调整的节点索引
    """
    # TODO: 在此处实现 "下滤" 操作,将 arr[i] 下沉到正确位置
    

def build_max_heap(arr):
    """
    将无序数组原地建成最大堆,从后往前进行下滤
    参数:
        arr: 待调整的数组
    """
    # TODO: 调用 max_heapify 将 arr 调整为堆
    

def heap_sort(arr):
    """
    利用堆排序算法排序数组(降序)
    参数:
        arr: 待排序数组
    返回:
        排序后的数组(从大到小)
    """
    # TODO: 完成堆排序的实现
    n = len(arr)
    # 1. 原地建堆
    build_max_heap(arr)
    # 2. 依次将当前堆顶(最大值)交换到数组末尾,并缩小堆的范围,然后下滤
    

#性能测试:对比不同规模输入的堆排序用时
def measure(sort_func, data):
    start = time.perf_counter_ns()
    sort_func(data.copy())
    end = time.perf_counter_ns()
    return (end - start) / 10**6  # 毫秒

sizes = [1000, 5000, 10000]
print("堆排序性能测试:")
for n in sizes:
    data = [random.randint(0, 1000000) for _ in range(n)]
    t = measure(heap_sort, data)
    print(f"数据规模 n={n}: 排序耗时 {t:.2f} ms")
实验结果分析

请完成并运行上述代码,观察不同输入规模下算法的执行时间。理论上,堆排序的时间复杂度为O(n \log n),当输入规模增大时,运行时间应呈现近似线性乘以对数的增长趋势。具体来说,若将输入规模扩大10倍,运行时间将增加约10 \times \log_2(10) \approx 10 \times 3.3 \approx 33倍左右。
相比之下,简单的O(n^2)排序算法在相同扩大量级下耗时会增加约100倍。通过与之前插入排序实验的对比,你会发现堆排序对规模扩大的响应增长显著缓慢得多。
这印证了堆排序的效率优势:在最坏情况和平均情况下它都能维持O(n \log n)的性能,不会出现如快速排序在极端情况下退化为O(n^2)的尴尬局面。
此外,堆排序是一种原地排序(只需要常数级别的额外空间),这也是相对于归并排序的一个优势。综合来看,利用优先队列实现的堆排序在效率和空间上都表现出色,是一种成熟可靠的排序方法。

比较排序的决策树模型

前面的内容介绍了多种基于元素比较的排序算法(比较排序),包括快速排序、堆排序等。接下来,我们讨论一个重要的理论结果:
在比较模型下,任意排序算法的最优时间复杂度下界为Ω(n \log n)。 这个结论意味着,无论设计何种巧妙的比较排序算法,都无法突破这一定义上的效率极限。证明这一点的经典工具是决策树模型。

决策树是描述比较排序过程的一种抽象模型。 在排序过程中,每进行一次比较(例如“A[i] \le A[j]?”)就相当于根据结果(二叉决策:是/否)将可能的输入情况划分到两个分支。
整个排序算法的运行过程可以被看作是在这样一棵决策树上从根节点走向某个叶节点的过程。决策树的每个叶节点对应一种可能的输入集合及其确定的输出顺序。
当有n个待排序元素时,假设它们两两各不相同,则可能的输入排列情况共有n!种(所有元素的全排列)。为了正确地将每种输入排列映射到唯一的输出(即排好序的有序序列),排序算法的决策树必须至少具备n!个叶节点——每个叶子对应一种输入排列的判别结果。

对于一棵二叉决策树,若包含L个叶节点,其高度h满足L \le 2^h,因此h \ge \lceil \log_2 L \rceil。在排序问题中,L最少取n!,因此最优情况下决策树高度也满足:
$h_{\min} \geq \lceil \log_2(n!) \rceil.$ 利用对数运算的性质,可以估计\log_2(n!)的数量级。根据斯特林公式近似,n!大约为(n/e)^n的数量级,那么:
$\log_2(n!) \approx \log_2\left((n/e)^n\sqrt{2\pi n}\right) = n\log_2 n - n\log_2 e + O(\log n).$
可以看出,当n较大时,\log_2(n!) = Θ(n \log n)。这意味着决策树的高度下界h_{\min} = Ω(n \log n)。换言之,任何基于比较的排序算法在最理想情况下也需要执行与n \log n同数量级的比较操作。
例如,对于$ n=3$的简单情况,3! = 6,满足2^2 < 6 < 2^3,因此判定3个元素的任意排列需要至少3次比较。这与我们已知的事实相符:对三个无任何特殊性质的数进行排序,最少需要3次比较才能确定它们的正确顺序。