目录

一、任务目标

二、主要内容

1. 什么是堆?

2. 堆的存储:用数组表示

3. 堆的基本操作

3.1 插入元素(上浮)

3.2 删除堆顶元素(下沉)

3.3 建堆

4. 堆的应用:优先队列

5. 堆排序

6. Java 实现最小堆

6.1 最小堆类定义

6.2 测试最小堆

7. 堆排序实现

8. 总结


一、任务目标

1. 理解最小堆、最大堆的结构。

2. 实现堆的插入、删除(上浮/下沉)。

3. 掌握堆的应用(优先队列)。

4.使用 JAVA,实现最小堆,完成堆排序。

二、主要内容

在计算机科学中,堆(Heap) 是一种非常重要的数据结构,它常用于实现优先队列,并在堆排序、Top K 问题、任务调度等场景中发挥着关键作用。本文将带你从最基础的概念开始,逐步深入理解堆的结构与操作,并使用 Java 亲手实现一个最小堆,最后完成堆排序。

1. 什么是堆?

堆是一种特殊的完全二叉树,它满足以下两个条件:

  1. 完全二叉树:除了最后一层,其他层的节点都是满的,且最后一层的节点尽可能靠左排列。

  2. 堆序性质:对于最小堆,任意父节点的值小于等于其子节点的值;对于最大堆,任意父节点的值大于等于其子节点的值。

下图是一个最小堆的示例(数字代表节点的值):

        5
       / \
      8   9
     / \
    12  11

可以看到,每个父节点都小于它的子节点,根节点(5)是整个堆中最小的元素。最大堆则相反,根节点最大。


2. 堆的存储:用数组表示

虽然堆是一棵树,但我们通常用数组来存储它,因为完全二叉树可以用数组紧凑表示。对于一个索引从 0 开始的数组,节点下标 i 与其父节点、左右子节点的关系如下:

  • 父节点下标:(i - 1) / 2

  • 左子节点下标:2 * i + 1

  • 右子节点下标:2 * i + 2

例如,上面最小堆的数组表示为:[5, 8, 9, 12, 11]。下标 0 是根节点 5,下标 1 是 8,下标 2 是 9,下标 3 是 12,下标 4 是 11。验证一下:下标 1 的父节点是 (1-1)/2 = 0,确实指向 5;下标 1 的左子节点是 2*1+1 = 3,指向 12,右子节点是 4,指向 11,符合树的结构。

这种存储方式既节省空间,又方便通过下标快速访问父子节点。

3. 堆的基本操作

堆的核心操作是插入和删除堆顶元素,它们都需要通过上浮和下沉来维持堆序性质。

3.1 插入元素(上浮)

向堆中插入一个新元素时,我们先将它放在数组的末尾(保证完全二叉树结构),然后让它上浮到合适的位置。

上浮过程:比较当前节点与父节点的值,如果违反堆序(在最小堆中,当前节点小于父节点),则交换它们,然后继续向上比较,直到满足条件或到达根节点。

示例:向上面最小堆插入元素 3。

  • 初始堆:[5, 8, 9, 12, 11],末尾添加 3 后:[5, 8, 9, 12, 11, 3]。

  • 3 的下标是 5,父节点下标 (5-1)/2 = 2,值为 9。3 < 9,交换 → [5, 8, 3, 12, 11, 9]。

  • 现在 3 在位置 2,父节点下标 (2-1)/2 = 0,值为 5。3 < 5,交换 → [3, 8, 5, 12, 11, 9]。

  • 到达根节点,上浮结束。最终堆顶是最小值 3。

3.2 删除堆顶元素(下沉)

删除堆顶元素(即弹出最小值)时,我们先将数组最后一个元素移到堆顶,然后让这个元素下沉到合适位置。

下沉过程:比较当前节点与左右子节点中较小的那个(最小堆),如果当前节点大于那个较小的子节点,则交换,然后继续向下比较,直到满足条件或到达叶子节点。

示例:从上面最终堆 [3, 8, 5, 12, 11, 9] 删除堆顶。

  • 将最后一个元素 9 移到堆顶:[9, 8, 5, 12, 11](数组长度减 1)。

  • 当前节点 9(下标 0),左子 8(下标 1),右子 5(下标 2)。较小的是 5(因为 5 < 8),9 > 5,交换 → [5, 8, 9, 12, 11]。

  • 现在 9 在位置 2,左子下标 5(已超出),下沉结束。最终堆为 [5, 8, 9, 12, 11]。

注意:删除操作通常只删除堆顶元素,因为堆主要用来快速获取最值。如果要删除任意元素,可以先用该元素与堆顶交换,再删除堆顶,但一般不常用。

3.3 建堆

如果给我们一个无序数组,如何将其构建成堆?有两种方法:

  1. 自上而下上浮:从空堆开始,依次插入每个元素(调用上浮)。时间复杂度 O(n log n)。

  2. 自下而上下沉(Floyd 算法):从最后一个非叶子节点开始,依次对每个节点执行下沉操作。时间复杂度 O(n),更高效。

最后一个非叶子节点的下标为 n/2 - 1(整数除法)。为什么?因为最后一个节点的下标是 n-1,其父节点即为最后一个非叶子节点。

示例:将数组 [4, 10, 3, 5, 1] 构建成最小堆。

  • n=5,最后一个非叶子节点下标 = 5/2 -1 = 1(值为 10)。

  • 从下标 1 开始下沉:

    • 节点 10(下标1),左子5(下标3),右子1(下标4)。较小的是 1,10 > 1,交换 → [4, 1, 3, 5, 10]。

  • 然后下标 0 下沉:

    • 节点 4(下标0),左子1(下标1),右子3(下标2)。较小的是 1,4 > 1,交换 → [1, 4, 3, 5, 10]。

    • 4 现在在位置1,左子5(下标3),右子10(下标4)。较小的是 5,4 < 5,停止。

  • 建堆完成:[1, 4, 3, 5, 10]。

4. 堆的应用:优先队列

优先队列是一种数据结构,它允许我们以任意顺序插入元素,但每次弹出的都是当前优先级最高(最小或最大)的元素。堆是实现优先队列的最常见方式,因为插入和删除堆顶都是 O(log n) 的时间复杂度。

Java 标准库中的 PriorityQueue 就是基于堆实现的。默认是最小堆,也可以通过传入比较器实现最大堆。

5. 堆排序

堆排序是利用堆进行排序的算法,时间复杂度 O(n log n),空间复杂度 O(1)(就地排序)。它的步骤如下(以升序排序为例,使用最大堆):

  1. 将无序数组构建成最大堆。

  2. 将堆顶元素(最大值)与数组末尾元素交换,此时最大值已就位。

  3. 将堆的大小减 1(排除已排序的末尾元素),然后对新的堆顶执行下沉,重新调整成最大堆。

  4. 重复步骤 2-3,直到堆的大小为 1。

如果使用最小堆,可以排序成降序。但通常升序用最大堆,降序用最小堆。

示例:对数组 [4, 10, 3, 5, 1] 进行升序堆排序。

    1. 建最大堆:[10, 5, 3, 4, 1]

    2. 交换 10 和 1:[1, 5, 3, 4, 10],堆调整前 4 个:下沉 1 → [5, 4, 3, 1, 10]

    3. 交换 5 和 1:[1, 4, 3, 5, 10],堆调整前 3 个:下沉 1 → [4, 1, 3, 5, 10](1 与 4 交换后,1 在位置 1,子节点 3 和 ... 实际上此时前 3 个是 [4,1,3],下沉 1 时,与子节点 3 比较,1<3 所以不用动?等等,这里需要仔细)
      为避免混淆,我们直接用代码实现。

6. Java 实现最小堆

我们将实现一个简化版的最小堆,支持以下功能:

  • offer(int val):插入元素

  • poll():删除并返回堆顶

  • peek():查看堆顶

  • size():返回堆大小

  • isEmpty():判空

为了通用性,也可以使用泛型,但这里我们用 int 类型,便于理解。

6.1 最小堆类定义

import java.util.Arrays;
import java.util.NoSuchElementException;

public class MinHeap {
    private int[] array;      // 存储堆元素的数组
    private int size;         // 当前堆中元素个数
    private static final int DEFAULT_CAPACITY = 10;

    public MinHeap() {
        this(DEFAULT_CAPACITY);
    }

    public MinHeap(int capacity) {
        array = new int[capacity];
        size = 0;
    }

    /**
     * 通过一个无序数组构建最小堆(建堆操作)
     */
    public MinHeap(int[] data) {
        // 将数组复制到堆数组中,并扩容
        size = data.length;
        array = Arrays.copyOf(data, Math.max(size, DEFAULT_CAPACITY));
        // 从最后一个非叶子节点开始下沉
        for (int i = (size / 2 - 1); i >= 0; i--) {
            sink(i);
        }
    }

    // 工具方法:获取父节点、左子、右子下标
    private int parent(int i) { return (i - 1) / 2; }
    private int leftChild(int i) { return 2 * i + 1; }
    private int rightChild(int i) { return 2 * i + 2; }

    // 上浮
    private void swim(int k) {
        while (k > 0 && array[parent(k)] > array[k]) {
            swap(k, parent(k));
            k = parent(k);
        }
    }

    // 下沉
    private void sink(int k) {
        while (leftChild(k) < size) { // 至少有一个左子
            int j = leftChild(k);
            // 如果右子存在且小于左子,则指向右子
            if (j + 1 < size && array[j + 1] < array[j]) {
                j = rightChild(k);
            }
            // 如果当前节点小于等于较小子节点,停止下沉
            if (array[k] <= array[j]) {
                break;
            }
            swap(k, j);
            k = j;
        }
    }

    private void swap(int i, int j) {
        int temp = array[i];
        array[i] = array[j];
        array[j] = temp;
    }

    // 插入元素
    public void offer(int val) {
        // 如果数组满了,扩容
        if (size == array.length) {
            array = Arrays.copyOf(array, array.length * 2);
        }
        array[size] = val;
        swim(size);
        size++;
    }

    // 删除并返回堆顶
    public int poll() {
        if (size == 0) {
            throw new NoSuchElementException("堆为空");
        }
        int min = array[0];
        array[0] = array[size - 1];
        size--;
        sink(0);
        return min;
    }

    // 查看堆顶
    public int peek() {
        if (size == 0) {
            throw new NoSuchElementException("堆为空");
        }
        return array[0];
    }

    public int size() {
        return size;
    }

    public boolean isEmpty() {
        return size == 0;
    }

    // 打印堆(用于测试)
    public void print() {
        System.out.print("堆内容: ");
        for (int i = 0; i < size; i++) {
            System.out.print(array[i] + " ");
        }
        System.out.println();
    }
}

6.2 测试最小堆

public class MinHeapTest {
    public static void main(String[] args) {
        // 测试插入
        MinHeap heap = new MinHeap();
        heap.offer(5);
        heap.offer(8);
        heap.offer(9);
        heap.offer(12);
        heap.offer(11);
        heap.offer(3);
        heap.print(); // 预期: 3 8 5 12 11 9 (顺序可能因实现细节不同,但堆性质成立)

        // 测试删除
        System.out.println("poll: " + heap.poll()); // 3
        heap.print(); // 应满足最小堆

        // 测试从数组建堆
        int[] data = {4, 10, 3, 5, 1};
        MinHeap heap2 = new MinHeap(data);
        heap2.print(); // 预期: 1 4 3 5 10

        // 依次弹出所有元素
        while (!heap2.isEmpty()) {
            System.out.print(heap2.poll() + " ");
        }
        // 输出应为升序: 1 3 4 5 10
    }
}

7. 堆排序实现

利用上面的 MinHeap,我们可以轻松实现堆排序:先建堆,然后反复弹出堆顶。这样得到的就是升序序列。

public class HeapSort {
    /**
     * 堆排序(升序),直接修改原数组
     * 思路:使用最小堆,反复弹出堆顶放入新数组,但为了原地排序,通常使用最大堆。
     * 这里为了演示,我们使用最小堆,但需要额外空间。下面给出原地最大堆版本。
     */
    public static void sortAscending(int[] arr) {
        // 构建最大堆
        int n = arr.length;
        // 从最后一个非叶子节点开始下沉,构建最大堆
        for (int i = n / 2 - 1; i >= 0; i--) {
            sinkMax(arr, i, n);
        }
        // 依次将堆顶交换到末尾,并调整堆
        for (int i = n - 1; i > 0; i--) {
            swap(arr, 0, i);          // 堆顶与末尾交换
            sinkMax(arr, 0, i);        // 调整前 i 个元素为最大堆
        }
    }

    // 最大堆下沉(针对数组的一部分)
    private static void sinkMax(int[] arr, int k, int length) {
        while (2 * k + 1 < length) {
            int j = 2 * k + 1; // 左子
            if (j + 1 < length && arr[j + 1] > arr[j]) {
                j = 2 * k + 2; // 右子更大
            }
            if (arr[k] >= arr[j]) {
                break;
            }
            swap(arr, k, j);
            k = j;
        }
    }

    private static void swap(int[] arr, int i, int j) {
        int temp = arr[i];
        arr[i] = arr[j];
        arr[j] = temp;
    }

    public static void main(String[] args) {
        int[] arr = {4, 10, 3, 5, 1};
        System.out.print("排序前: ");
        for (int n : arr) System.out.print(n + " ");
        System.out.println();

        sortAscending(arr);

        System.out.print("排序后: ");
        for (int n : arr) System.out.print(n + " ");
        System.out.println();
    }
}

运行结果:

排序前: 4 10 3 5 1 
排序后: 1 3 4 5 10 

8. 总结

  • 堆是一种完全二叉树,具有堆序性质,常用数组存储。

  • 最小堆:父节点 ≤ 子节点;最大堆:父节点 ≥ 子节点。

  • 插入:末尾添加,然后上浮。

  • 删除堆顶:末尾元素替换堆顶,然后下沉。

  • 建堆:从最后一个非叶子节点开始下沉,时间复杂度 O(n)。

  • 优先队列是堆的典型应用。

  • 堆排序:利用最大堆进行原地升序排序,步骤为建堆 → 交换堆顶与末尾 → 调整剩余堆

更多推荐