妙用二叉树后序位置:归并排序

一句话总结

堆排序是从 二叉堆结构 衍生出来的排序算法,复杂度为 O(NlogN)。堆排序主要分两步,第一步是在待排序数组上原地创建二叉堆(Heapify),然后进行原地排序(Sort)。

前文 二叉堆基础 介绍过二叉堆结构, 二叉堆实现优先级队列 利用二叉堆结构实现了一个 SimpleMinPQ 优先级队列,插入队列的元素会按照从小到大的顺序取出。

本文将介绍堆排序算法,它是基于二叉堆性质衍生出来的一种全新排序算法,非常优雅和高效。

首先,我要复述一下二叉堆实现优先级队列的几个关键原理,如果你有任何不理解的地方,务必回去复习前文,否则无法理解堆排序。

1、二叉堆(优先级队列)底层是用数组实现的,但是逻辑上是一棵完全二叉树,主要依靠 swim, sink 方法来维护堆的性质。

2、优先级队列可以分为小顶堆和大顶堆,小顶堆会将整个堆中最小的元素维护在堆顶,大顶堆会将整个堆中最大的元素维护在堆顶。

3、优先级队列插入元素时,首先把元素追加到二叉堆底部,然后调用 swim 方法把该元素上浮到合适的位置,时间复杂度是 O(logN)。

4、优先级队列删除堆顶元素时,首先把堆底的最后一个元素交换到堆顶作为新的堆顶元素,然后调用 sink 方法把这个新的堆顶元素下沉到合适的位置,时间复杂度是 O(logN)。

那么最简单的堆排序算法思路就是直接利用优先级队列,把所有元素塞到优先级队列里面,然后再取出来,不就完成排序了吗?

// 直接利用优先级队列对数组从小到大排序
func sort(nums []int) {
    // 创建一个从小到大排序元素的小顶堆
    pq := NewSimpleMinPQ(len(nums))

    // 先把所有元素插入到优先级队列中
    for _, num := range nums {
        // push 操作会自动构建二叉堆,时间复杂度为 O(logN)
        pq.Push(num)
    }

    // 再把所有元素取出来,就是从小到大排序的结果
    for i := 0; i < len(nums); i++ {
        // pop 操作从堆顶弹出二叉堆堆中最小的元素,时间复杂度为 O(logN)
        nums[i] = pq.Pop()
    }
}

因为优先级队列的 push, pop 方法的复杂度都是 O(logN),所以整个排序的时间复杂度是 O(NlogN),其中 N 是输入数组的长度。

这个思路可以得到正确的排序结果,但空间复杂度是 O(N),因为我们创建的这个优先级队列是一个额外的数据结构,它的底层使用了一个数组来存储元素。

所以,堆排序要解决的问题是,不要使用额外的辅助空间,直接在原数组上进行 sink, swim 操作,在 O(NlogN) 的时间内完成排序。

堆排序的两个关键步骤

1、原地建堆(Heapify):直接把待排序数组原地变成一个二叉堆。

2、排序(Sort):将元素不断地从堆中取出,最终得到有序的结果。

你不妨自己思考几分钟,对比一下优先级队列增删元素的过程,其实利用 swim, sink 方法原地实现这两步并不难,应该可以独立思考出来。

在具体讲解堆排序代码实现之前,我先把二叉堆的 swim, sink 方法和配套的工具函数写出来,因为后文我会带你逐步优化堆排序的代码,就不重复实现这些函数了。

这些函数就是从 二叉堆实现优先级队列 中的优先级队列实现里抠出来的,把数组作为函数参数传入,其他的逻辑完全一样:

// 小顶堆的上浮操作,时间复杂度是树高 O(logN)
func minHeapSwim(heap []int, node int) {
    for node > 0 && heap[parent(node)] > heap[node] {
        swap(heap, parent(node), node)
        node = parent(node)
    }
}

// 小顶堆的下沉操作,时间复杂度是树高 O(logN)
func minHeapSink(heap []int, node, size int) {
    for left(node) < size || right(node) < size {
        // 比较自己和左右子节点,看看谁最小
        min := node
        if left(node) < size && heap[left(node)] < heap[min] {
            min = left(node)
        }
        if right(node) < size && heap[right(node)] < heap[min] {
            min = right(node)
        }
        if min == node {
            break
        }
        // 如果左右子节点中有比自己小的,就交换
        swap(heap, node, min)
        node = min
    }
}

// 大顶堆的上浮操作
func maxHeapSwim(heap []int, node int) {
    for node > 0 && heap[parent(node)] < heap[node] {
        swap(heap, parent(node), node)
        node = parent(node)
    }
}

// 大顶堆的下沉操作
func maxHeapSink(heap []int, node, size int) {
    for left(node) < size || right(node) < size {
        // 小顶堆和大顶堆的唯一区别就在这里,比较逻辑相反
        // 比较自己和左右子节点,看看谁最大
        max := node
        if left(node) < size && heap[left(node)] > heap[max] {
            max = left(node)
        }
        if right(node) < size && heap[right(node)] > heap[max] {
            max = right(node)
        }
        if max == node {
            break
        }
        swap(heap, node, max)
        node = max
    }
}

// 父节点的索引
func parent(node int) int {
    return (node - 1) / 2
}

// 左子节点的索引
func left(node int) int {
    return node*2 + 1
}

// 右子节点的索引
func right(node int) int {
    return node*2 + 2
}

// 交换数组中两个元素的位置
func swap(heap []int, i, j int) {
    heap[i], heap[j] = heap[j], heap[i]
}

简单直接的堆排序实现

只要你彻底理解了优先级队列的 push, pop 操作,那么就能比较容易得想到下面这个原地堆排序的实现:

// 将输入的数组元素从小到大排序
func sort(nums []int) {
    // 第一步,原地建堆,注意这里创建的是大顶堆
    // 只要从左往右对每个元素调用 swim 方法,就可以原地建堆
    for i := 0; i < len(nums); i++ {
        maxHeapSwim(nums, i)
    }

    // 第二步,排序
    // 现在整个数组已经是一个大顶了,直接模拟删除堆顶元素的过程即可
    heapSize := len(nums)
    for heapSize > 0 {
        // 从堆顶删除元素,放到堆的后面
        swap(nums, 0, heapSize-1)
        heapSize--
        // 恢复堆的性质
        maxHeapSink(nums, 0, heapSize)
        // 现在 nums[0..heapSize) 是一个大顶堆,nums[heapSize..) 是有序元素
    }
}

这里面一个关键点是要用大顶堆来完成 nums 从小到大的排序,因为从堆顶删除的元素是从后往前填到 nums 数组中的,最终 nums 中的元素是从小到大排序的。

如果你用小顶堆的话,最终 nums 中的元素是从大到小排序的,还需要再翻转一下数组,没有大顶堆的效率高。

类比优先级队列的操作
这个实现思路相当于完全模拟了优先级队列的 push, pop 操作,类似下面的逻辑:

```java
void sort(int[] nums) {
    // 第一步,创建大顶堆
    SimpleMaxPQ pq = new SimpleMaxPQ();
    for (int num : nums) {
        pq.push(num);
    }

    // 第二步,排序
    int heapSize = pq.size();
    while (heapSize > 0) {
        // 因为这是大顶堆,所以 pop 出来的元素是从大到小的
        nums[heapSize - 1] = pq.pop();
        heapSize--;
    }
    // 最终 nums 数组就是从小到大排序的结果
}
```

如果还是不理解,建议回去复习一下优先级队列的实现。

我们来分析一下上述代码的时间复杂度,假设 nums 的元素个数为 N:

第一步建堆的过程中,swim 方法的时间复杂度是 O(logN),算法对每个元素调用一次 swim 方法,所以总时间复杂度是 O(NlogN)。 第二步排序的过程中,每次 sink 方法的时间复杂度是 O(logN),算法对每个元素调用一次 sink 方法,所以总时间复杂度是 O(NlogN)。 综上,整个堆排序的时间复杂度是 2NlogN,用 Big O 表示就是 O(NlogN)。与 快速排序、 归并排序 是一个级别的排序算法。

上述算法直接操作原始数组,没有使用额外的辅助空间,所以空间复杂度是 O(1)。

你可以把这段代码拿去力扣第 912 题「排序数组」提交,它可以轻松通过所有测试用例。

利用逆向思维进行优化

能写出上面的堆排序代码已经不错了,但是这个解法其实还可以再优化,优化的点在于建堆的过程。

说实话这个优化点不太容易想到,我直接讲了,请你仔细思考理解。

首先,请你忘记二叉堆底层的数组实现,我们只看二叉堆的逻辑结构,即完全二叉树

在我们的解法中,对每个元素依次调用 maxHeapSwim 方法建堆,相当于把数组中的第一个元素作为堆顶(完全二叉树的根节点),然后不断把新元素追加到堆底(完全二叉树的最底层最右侧),调用 maxHeapSwim 方法让新元素上浮到合适的位置。

那么在这个过程中,其实你是没有任何优化空间的,因为每个新元素都必须加到堆里,且必须进行上浮操作,才能维护堆的性质。

想要找到优化空间,需要利用二叉堆的一个性质:对于一个二叉堆来说,其左右子堆(子树)也是一个二叉堆

就好比一棵二叉树的根节点,它的左右子树也是一棵二叉树。

如果你给我一个二叉树节点和两棵二叉树,那么我可以把这个二叉树节点作为根节点,两棵二叉树作为根节点的左右子树,这样就能构建出一棵新的二叉树。

二叉堆本质上是二叉树,所以也是类似的:

如果给我两个二叉堆,和一个二叉堆节点,那么我可以把这个节点作为堆顶(根节点),两个二叉堆作为左右子堆(子树),构建出一棵新的二叉堆(二叉树),对吧?

但是有个问题,构建出的这个新二叉堆,它的左右子堆肯定都符合堆的性质,但这个新的根节点的值可能不符合堆的性质,怎么办?

简单啊,sink 方法不就是专门针对这种情况的吗?只要对根节点调用一次 sink 方法,就能让这个新的二叉堆符合堆的性质了。

这就是堆排序算法的优化思路的核心,先看代码,注意建堆的部分:

// 将输入的数组元素从小到大排序
func sort(nums []int) {
    // 第一步,原地建堆,注意这里创建的是大顶堆
    // 从最后一个非叶子节点开始,依次下沉,合并二叉堆
    n := len(nums)
    for i := n / 2 - 1; i >= 0; i-- {
        maxHeapSink(nums, i, n)
    }

    // 合并完成,现在整个数组已经是一个大顶堆

    // 第二步,排序,和刚才的代码一样
    heapSize := n
    for heapSize > 0 {
        // 从堆顶删除元素,放到堆的后面
        swap(nums, 0, heapSize-1)
        heapSize--
        // 恢复堆的性质
        maxHeapSink(nums, 0, heapSize)
        // 现在 nums[0..heapSize) 是一个大顶堆,nums[heapSize..) 是有序元素
    }
}

每个单独的叶子节点都是符合堆的性质的,所以上述代码从最后一个非叶子节点开始,依次调用 maxHeapSink 方法,合并所有的子堆,最终整个数组就是一个大顶堆了。

n / 2 - 1 是最后一个非叶子节点的索引,这个值的运算也比较精妙,需要你熟练掌握 完全二叉树的性质,我计划专门开一个章节把各种二叉树的性质及推导进行总结,所以这里先不展开,你有兴趣的话可以先自己思考一下。

显然,这个优化后的堆排序算法在建堆时效率更高,因为只需要对一半的元素调用 sink 方法,总的操作次数大概是 1/2∗NlogN。虽然用 Big O 表示法还是 O(NlogN),但实际执行的操作次数肯定会比对每个元素调用 swim 方法要少。

二叉堆结构,用数组抽象二叉树,简单的 swim, sink 方法,都能玩出这么多花活,算法还是挺有意思的吧?

堆排序的稳定性

堆排序是一种不稳定的排序算法,因为二叉堆本质上是把数组结构抽象成了二叉树结构,在二叉树逻辑结构上的元素交换操作映射回数组上,无法顾及相同元素的相对位置。