排序算法:堆排序

        数组、链表都是一维的数据结构,相对来说比较容易理解,而堆是二维的数据结构,对抽象思维的要求更高,所以许多程序员「谈堆色变」。但堆又是数据结构进阶必经的一步,我们不妨静下心来,将其梳理清楚。

堆:符合以下两个条件之一的完全二叉树:

  • 根节点的值 ≥ 子节点的值,这样的堆被称之为最大堆,或大顶堆;
  • 根节点的值 ≤ 子节点的值,这样的堆被称之为最小堆,或小顶堆。

       为了有一个轻松的开场,我们先来看一个程序员的段子放松一下:

你有哪些用计算机技能解决生活问题的经历?

我认识一个大牛,他不喜欢洗袜子,又不喜欢袜子的臭味。于是他买了很多样式一样的袜子,把这些袜子放在地上,根据臭的程度,摆一个二叉堆。每天早上,他 pop 两只最“香”的袜子,穿上;晚上回到家,把袜子脱下来,push 到堆里。某一天,top 的袜子超过他的耐臭能力,全扔掉,买新的。

        如果我们将袜子 「臭的程度」 量化,这位大牛每天做的事情就是构建一个大顶堆,然后将堆顶的袜子取出来。再调整剩下的袜子,构建出一个新的大顶堆,再次取出堆顶的袜子。这个过程使用的就是堆排序的思想,它是由 J. W. J. Williams 在1964 年发明的。

堆排序过程如下:

  • 用数列构建出一个大顶堆,取出堆顶的数字;
  • 调整剩余的数字,构建出新的大顶堆,再次取出堆顶的数字;
  • 循环往复,完成整个排序。

整体的思路就是这么简单,我们需要解决的问题有两个:

  • 如何用数列构建出一个大顶堆;
  • 取出堆顶的数字后,如何将剩余的数字调整成新的大顶堆。

构建大顶堆 & 调整堆

构建大顶堆有两种方式:

  • 方案一:从 0 开始,将每个数字依次插入堆中,一边插入,一边调整堆的结构,使其满足大顶堆的要求;
  • 方案二:将整个数列的初始状态视作一棵完全二叉树,自底向上调整树的结构,使其满足大顶堆的要求。

 在介绍堆排序具体实现之前,我们先要了解完全二叉树的几个性质。将根节点的下标视为 0,则完全二叉树有如下性质:

  • 对于完全二叉树中的第 i 个数,它的左子节点下标:left = 2i + 1
  • 对于完全二叉树中的第 i 个数,它的右子节点下标:right = left + 1
  • 对于有 n 个元素的完全二叉树(n≥2),它的最后一个非叶子结点的下标:n/2 - 1

堆排序代码如下:

public static void heapSort(int[] arr) {
    // 构建初始大顶堆
    buildMaxHeap(arr);
    for (int i = arr.length - 1; i > 0; i--) {
        // 将最大值交换到数组最后
        swap(arr, 0, i);
        // 调整剩余数组,使其满足大顶堆
        maxHeapify(arr, 0, i);
    }
}
// 构建初始大顶堆
private static void buildMaxHeap(int[] arr) {
    // 从最后一个非叶子结点开始调整大顶堆,最后一个非叶子结点的下标就是 arr.length / 2-1
    for (int i = arr.length / 2 - 1; i >= 0; i--) {
        maxHeapify(arr, i, arr.length);
    }
}
// 调整大顶堆,第三个参数表示剩余未排序的数字的数量,也就是剩余堆的大小
private static void maxHeapify(int[] arr, int i, int heapSize) {
    // 左子结点下标
    int l = 2 * i + 1;
    // 右子结点下标
    int r = l + 1;
    // 记录根结点、左子树结点、右子树结点三者中的最大值下标
    int largest = i;
    // 与左子树结点比较
    if (l < heapSize && arr[l] > arr[largest]) {
        largest = l;
    }
    // 与右子树结点比较
    if (r < heapSize && arr[r] > arr[largest]) {
        largest = r;
    }
    if (largest != i) {
        // 将最大值交换为根结点
        swap(arr, i, largest);
        // 再次调整交换数字后的大顶堆
        maxHeapify(arr, largest, heapSize);
    }
}
private static void swap(int[] arr, int i, int j) {
    int temp = arr[i];
    arr[i] = arr[j];
    arr[j] = temp;
}

        堆排序的第一步就是构建大顶堆,对应代码中的 buildMaxHeap 函数。我们将数组视作一颗完全二叉树,从它的最后一个非叶子结点开始,调整此结点和其左右子树,使这三个数字构成一个大顶堆。

        调整过程由 maxHeapify 函数处理, maxHeapify 函数记录了最大值的下标,根结点和其左右子树结点在经过比较之后,将最大值交换到根结点位置。这样,这三个数字就构成了一个大顶堆。

        需要注意的是,如果根结点和左右子树结点任何一个数字发生了交换,则还需要保证调整后的子树仍然是大顶堆,所以子树会执行一个递归的调整过程。

        这里的递归比较难理解,我们打个比方:构建大顶堆的过程就是一堆数字比赛谁更大。比赛过程分为初赛、复赛、决赛,每场比赛都是三人参加。但不是所有人都会参加初赛,只有叶子结点和第一批非叶子结点会进行三人组初赛。初赛的冠军站到三人组的根结点位置,然后继续参加后面的复赛。

        而有的人生来就在上层,比如李小胖,它出生在数列的第一个位置上,是二叉树的根结点,当其他结点进行初赛、复赛时,它就静静躺在根结点的位置等一场决赛。

        当王大强和张壮壮,经历了重重比拼来到了李小胖的左右子树结点位置。他们三个人开始决赛。王大强和张壮壮是靠实打实的实力打上来的,他们已经确认过自己是小组最强。而李小胖之前一直躺在这里等决赛。如果李小胖赢了他们两个,说明李小胖是所有小组里最强的,毋庸置疑,他可以继续坐在冠军宝座。

        但李小胖如果输给了其中任何一个人,比如输给了王大强。王大强会和张壮壮对决,选出本次构建大顶堆的冠军。但李小胖能够坐享其成获得第三名吗?生活中或许会有这样的黑幕,但程序不会欺骗我们。李小胖跌落神坛之后,就要从王大强的打拼路线回去,继续向下比较,找到自己真正实力所在的真实位置。这就是 maxHeapify 中会继续递归调用 maxHeapify 的原因。

        当构建出大顶堆之后,就要把冠军交换到数列最后,深藏功与名。来到冠军宝座的新人又要和李小胖一样,开始向下比较,找到自己的真实位置,使得剩下的 n−1 个数字构建成新的大顶堆。这就是 heapSort 方法的 for 循环中,调用 maxHeapify 的原因。

        变量 heapSize 用来记录还剩下多少个数字没有排序完成,每当交换了一个堆顶的数字,heapSize 就会减 1。在 maxHeapify 方法中,使用 heapSize 来限制剩下的选手,不要和已经躺在数组最后,当过冠军的人比较,免得被暴揍。

        这就是堆排序的思想。学习时我们采用的是最简单的代码实现,在熟练掌握了之后我们就可以加一些小技巧以获得更高的效率。比如我们知道计算机采用二进制来存储数据,数字左移一位表示乘以 2,右移一位表示除以 2。所以堆排序代码中的arr.length / 2 - 1 可以修改为 (arr.length >> 1) - 1,左子结点下标2 * i + 1可以修改为(i << 1) + 1。需要注意的是,位运算符的优先级比加减运算的优先级低,所以必须给位运算过程加上括号。

注:在有的文章中,作者将堆的根节点下标视为 1,这样做的好处是使得第 i 个结点的左子结点下标为 2i,右子结点下标为 2i + 1,与 2i + 1 和 2i + 2 相比,计算量会少一点,本文未采取这种实现,但两种实现思路的核心思想都是一致的。

分析可知,堆排序是不稳定的排序算法。

 

时间复杂度 & 空间复杂度

        堆排序分为两个阶段:初始化建堆(buildMaxHeap)和重建堆(maxHeapify,直译为大顶堆化)。所以时间复杂度要从这两个方面分析。

        根据数学运算可以推导出初始化建堆的时间复杂度为 O(n),重建堆的时间复杂度为 O(nlogn),所以堆排序总的时间复杂度为 O(nlogn)。推导过程较为复杂,故不再给出证明过程。

        堆排序的空间复杂度为 O(1),只需要常数级的临时变量。

        堆排序是一个优秀的排序算法,但是在实际应用中,快速排序的性能一般会优于堆排序。

你可能感兴趣的:(算法相关,排序算法,算法,数据结构)