章节十四:乱序中的“指挥家”:堆排序奥义 - (堆排序 / Heap Sort)

各位老铁,阿扩又来啦!

前面我们聊了各种数据结构和算法,从基础的排序查找,到复杂的图算法、动态规划,再到巧妙的Trie树和布隆过滤器。今天,我们要再次回到排序算法的舞台,但这次的主角,可不是简单的“冒泡”或“选择”,而是一位在乱序中能高效组织、精准定位的“指挥家”——堆排序 (Heap Sort)

你可能听说过快速排序、归并排序,它们都是 O(N log N) 级别的排序算法。堆排序也同样拥有这个优秀的性能,而且它还是一种原地排序算法(不需要额外的大量空间),这在内存受限的场景下显得尤为珍贵。

那么,这位“指挥家”是如何在混乱的数据中,一步步建立秩序,最终奏响有序的乐章呢?来,跟着阿扩,一起揭开堆排序的奥秘!


章节十四:乱序中的“指挥家”:堆排序奥义 - (堆排序 / Heap Sort)

堆排序:基于“堆”这种特殊树形结构的排序

堆排序是一种基于堆 (Heap) 这种特殊数据结构的排序算法。在深入堆排序之前,我们得先搞清楚什么是“堆”。

堆 (Heap) 是一种特殊的完全二叉树 (Complete Binary Tree)。它满足以下两个条件之一:

  1. 大顶堆 (Max-Heap): 任何一个父节点的值都大于或等于其子节点的值。这意味着,堆中最大的元素总是在根节点。
  2. 小顶堆 (Min-Heap): 任何一个父节点的值都小于或等于其子节点的值。这意味着,堆中最小的元素总是在根节点。

通常,堆会用数组来表示。对于一个数组 arr

  • 父节点 i 的左子节点是 2*i + 1
  • 父节点 i 的右子节点是 2*i + 2
  • 子节点 j 的父节点是 (j - 1) / 2 (向下取整)。
数组表示
大顶堆 (Max-Heap) 示例
75
85
60
70
90
80
100
80
100
90
70
60
85
75

堆排序的整个过程可以分为两大步:

第一步:建堆 (Heapify)
  • 将一个无序的数组构建成一个大顶堆(或小顶堆)。
  • 这一步的目的是让数组中最大的元素(如果是大顶堆)浮到数组的第一个位置(即堆的根)。
  • 通常从最后一个非叶子节点开始,向上逐个调整,确保每个子树都满足堆的性质。
第二步:排序 (Sort)
  • 重复以下操作 N-1 次:
    1. 将堆顶元素(最大值)与堆的最后一个元素交换。
    2. 将交换后的最后一个元素从堆中“移除”(逻辑上,因为它已经是有序部分的元素了)。
    3. 对剩余的 N-1 个元素重新调整堆,使其再次满足堆的性质(即,将新的堆顶元素下沉到正确的位置)。

通过不断地将最大元素“提取”出来并放到数组的末尾,最终整个数组就会变得有序。

堆排序的“指挥”过程:图解

假设我们有一个数组 arr = [4, 10, 3, 5, 1, 2],我们要进行升序排序(使用大顶堆)。

第一步:建堆

从最后一个非叶子节点开始(索引 (N/2)-1 = (6/2)-1 = 2,即元素 3)。

  • 调整以 3 为根的子树: [4, 10, 3, 5, 1, 2] -> [4, 10, 3, 5, 1, 2] (3比子节点5小,交换) -> [4, 10, 5, 3, 1, 2]
  • 调整以 10 为根的子树: [4, 10, 5, 3, 1, 2] -> [4, 10, 5, 3, 1, 2] (10比子节点3,1大,不变)
  • 调整以 4 为根的子树: [4, 10, 5, 3, 1, 2] -> [10, 4, 5, 3, 1, 2] (4比子节点10小,交换) -> [10, 5, 4, 3, 1, 2] (4比子节点3,1大,不变)

最终建堆完成:[10, 5, 4, 3, 1, 2]

建堆过程 (以大顶堆为例)
初始数组
从最后一个非叶子节点 (索引2, 值3) 开始调整
调整后: [4, 10, 5, 3, 1, 2]
调整以索引1 (值10) 为根的子树
调整后: [4, 10, 5, 3, 1, 2] (不变)
调整以索引0 (值4) 为根的子树
调整后: [10, 5, 4, 3, 1, 2]
2
1
5
3
10
4

第二步:排序

现在数组是 [10, 5, 4, 3, 1, 2]

  1. 第一次:
    • 交换堆顶 10 和末尾 2[2, 5, 4, 3, 1, 10]
    • 10 视为已排序部分。
    • [2, 5, 4, 3, 1] 重新调整堆:[5, 3, 4, 2, 1]
  2. 第二次:
    • 交换堆顶 5 和末尾 1[1, 3, 4, 2, 5, 10]
    • 5 视为已排序部分。
    • [1, 3, 4, 2] 重新调整堆:[4, 3, 1, 2]
  3. 第三次:
    • 交换堆顶 4 和末尾 2[2, 3, 1, 4, 5, 10]
    • 4 视为已排序部分。
    • [2, 3, 1] 重新调整堆:[3, 2, 1]
  4. 第四次:
    • 交换堆顶 3 和末尾 1[1, 2, 3, 4, 5, 10]
    • 3 视为已排序部分。
    • [1, 2] 重新调整堆:[2, 1]
  5. 第五次:
    • 交换堆顶 2 和末尾 1[1, 2, 3, 4, 5, 10]
    • 2 视为已排序部分。
    • [1] 重新调整堆:[1] (只剩一个元素,自然有序)

最终,数组变为 [1, 2, 3, 4, 5, 10],排序完成!

Show Me The Code! (Java & Python 双语教学)

堆排序的核心是 heapify 函数,它负责将一个子树调整为堆。

Java 版本:

import java.util.Arrays;

public class HeapSort {

    /**
     * 堆排序主函数
     * @param arr 待排序数组
     */
    public static void heapSort(int[] arr) {
        int n = arr.length;

        // 1. 建堆 (Build heap)
        // 从最后一个非叶子节点开始向上调整,确保每个子树都满足大顶堆性质
        for (int i = n / 2 - 1; i >= 0; i--) {
            heapify(arr, n, i);
        }

        // 2. 排序 (Extract elements from heap)
        // 每次将堆顶元素(最大值)与当前堆的最后一个元素交换,然后重新调整堆
        for (int i = n - 1; i > 0; i--) {
            // 将当前堆顶元素(最大值)与堆的最后一个元素交换
            swap(arr, 0, i);
            // 对剩余的 i 个元素重新调整堆(因为最大值已经放到正确位置)
            heapify(arr, i, 0);
        }
    }

    /**
     * 调整函数:将以 rootIndex 为根的子树调整为大顶堆
     * @param arr 数组
     * @param heapSize 当前堆的大小(有效元素的数量)
     * @param rootIndex 当前子树的根节点索引
     */
    private static void heapify(int[] arr, int heapSize, int rootIndex) {
        int largest = rootIndex; // 假设根节点是最大的
        int leftChild = 2 * rootIndex + 1; // 左子节点索引
        int rightChild = 2 * rootIndex + 2; // 右子节点索引

        // 如果左子节点在堆范围内且比当前最大值大
        if (leftChild < heapSize && arr[leftChild] > arr[largest]) {
            largest = leftChild;
        }

        // 如果右子节点在堆范围内且比当前最大值大
        if (rightChild < heapSize && arr[rightChild] > arr[largest]) {
            largest = rightChild;
        }

        // 如果最大值不是根节点,则交换并递归调整受影响的子树
        if (largest != rootIndex) {
            swap(arr, rootIndex, largest);
            heapify(arr, heapSize, largest); // 递归调整被交换的子树
        }
    }

    // 交换数组中两个元素的值
    private static void swap(int[] arr, int i, int j) {
        int temp = arr[i];
        arr[j] = arr[i];
        arr[i] = temp;
    }

    public static void main(String[] args) {
        int[] arr1 = {4, 10, 3, 5, 1, 2};
        System.out.println("Original array: " + Arrays.toString(arr1));
        heapSort(arr1);
        System.out.println("Sorted array: " + Arrays.toString(arr1)); // Expected: [1, 2, 3, 4, 5, 10]

        int[] arr2 = {12, 11, 13, 5, 6, 7};
        System.out.println("Original array: " + Arrays.toString(arr2));
        heapSort(arr2);
        System.out.println("Sorted array: " + Arrays.toString(arr2)); // Expected: [5, 6, 7, 11, 12, 13]

        int[] arr3 = {1};
        System.out.println("Original array: " + Arrays.toString(arr3));
        heapSort(arr3);
        System.out.println("Sorted array: " + Arrays.toString(arr3)); // Expected: [1]

        int[] arr4 = {};
        System.out.println("Original array: " + Arrays.toString(arr4));
        heapSort(arr4);
        System.out.println("Sorted array: " + Arrays.toString(arr4)); // Expected: []
    }
}

Python 版本:

from typing import List

def heap_sort(arr: List[int]):
    n = len(arr)

    # 1. 建堆 (Build heap)
    # 从最后一个非叶子节点开始向上调整,确保每个子树都满足大顶堆性质
    # 最后一个非叶子节点的索引是 n // 2 - 1
    for i in range(n // 2 - 1, -1, -1):
        _heapify(arr, n, i)

    # 2. 排序 (Extract elements from heap)
    # 每次将堆顶元素(最大值)与当前堆的最后一个元素交换,然后重新调整堆
    for i in range(n - 1, 0, -1):
        # 将当前堆顶元素(最大值)与堆的最后一个元素交换
        arr[i], arr[0] = arr[0], arr[i]
        # 对剩余的 i 个元素重新调整堆(因为最大值已经放到正确位置)
        _heapify(arr, i, 0)

def _heapify(arr: List[int], heap_size: int, root_index: int):
    """
    调整函数:将以 root_index 为根的子树调整为大顶堆
    :param arr: 数组
    :param heap_size: 当前堆的大小(有效元素的数量)
    :param root_index: 当前子树的根节点索引
    """
    largest = root_index # 假设根节点是最大的
    left_child = 2 * root_index + 1 # 左子节点索引
    right_child = 2 * root_index + 2 # 右子节点索引

    # 如果左子节点在堆范围内且比当前最大值大
    if left_child < heap_size and arr[left_child] > arr[largest]:
        largest = left_child

    # 如果右子节点在堆范围内且比当前最大值大
    if right_child < heap_size and arr[right_child] > arr[largest]:
        largest = right_child

    # 如果最大值不是根节点,则交换并递归调整受影响的子树
    if largest != root_index:
        arr[root_index], arr[largest] = arr[largest], arr[root_index] # 交换
        _heapify(arr, heap_size, largest) # 递归调整被交换的子树

if __name__ == "__main__":
    arr1 = [4, 10, 3, 5, 1, 2]
    print(f"Original array: {arr1}")
    heap_sort(arr1)
    print(f"Sorted array: {arr1}") # Expected: [1, 2, 3, 4, 5, 10]

    arr2 = [12, 11, 13, 5, 6, 7]
    print(f"Original array: {arr2}")
    heap_sort(arr2)
    print(f"Sorted array: {arr2}") # Expected: [5, 6, 7, 11, 12, 13]

    arr3 = [1]
    print(f"Original array: {arr3}")
    heap_sort(arr3)
    print(f"Sorted array: {arr3}") # Expected: [1]

    arr4 = []
    print(f"Original array: {arr4}")
    heap_sort(arr4)
    print(f"Sorted array: {arr4}") # Expected: []
⚡️ 性能剖析
  • 时间复杂度:O(N log N)

    • 建堆: 这一步的时间复杂度是 O(N)。虽然看起来是循环 N/2 次,每次 heapify 是 O(log N),但实际上,由于大部分节点都在底层,它们的 heapify 操作深度很小,所以总和是 O(N)。
    • 排序: 这一步需要执行 N-1 次循环,每次循环都进行一次交换和一次 heapify 操作。heapify 的时间复杂度是 O(log N)。所以总和是 O(N log N)。
    • 综合起来,堆排序的时间复杂度是 O(N log N)。
  • 空间复杂度:O(1)

    • 堆排序是原地排序算法,它只需要常数级别的额外空间来存储临时变量。这是它相对于归并排序(O(N) 额外空间)的一个显著优势。
  • 稳定性:不稳定

    • 堆排序是不稳定的排序算法。在交换堆顶元素和末尾元素时,可能会改变相同值元素的相对顺序。例如,[10, 10', 5],如果 105 交换,10' 的相对位置就变了。
阿扩小结:划重点!

堆排序,这位乱序中的“指挥家”,凭借其独特的“堆”结构,实现了高效排序:

  1. 核心思想: 利用堆的性质(大顶堆根最大,小顶堆根最小),将最大/最小元素不断“提取”出来放到正确位置。
  2. 两大步骤:
    • 建堆 (Heapify): O(N) 时间将无序数组构建成堆。
    • 排序: O(N log N) 时间将堆顶元素逐个取出并放到数组末尾。
  3. 性能:
    • 时间复杂度:O(N log N),与快速排序、归并排序同级别。
    • 空间复杂度:O(1),原地排序,非常节省内存。
    • 稳定性:不稳定
  4. 应用:
    • Top K 问题: 找出数组中最大/最小的 K 个元素,使用堆(特别是优先队列)是最高效的方法。
    • 系统调度: 优先级队列的底层实现通常就是堆。

堆排序虽然不如快速排序那样“快”(平均情况),但它稳定的 O(N log N) 性能和 O(1) 的空间复杂度,使其在某些特定场景下成为非常优秀的选择。


老铁们,今天的“乱序指挥家”——堆排序,是不是让你对排序算法有了更全面的认识?如果觉得阿扩讲得还行,点赞、收藏、转发就是对我最大的鼓励!

算法专栏的硬核内容到这里就告一段落了。在最后一章,我们将进行一场面试通关“最终形态”:算法思维大串讲与实战模拟。阿扩会带大家回顾整个专栏的知识体系,并分享如何在面试中灵活运用这些算法思维,祝你斩获心仪的Offer!我们下期不见不散!

你可能感兴趣的:(常用算法详解,算法)