我这一生如履薄冰,你说我能学会堆排序吗

198 阅读2分钟

完全二叉树

什么是完全二叉树

  • 满二叉树:一个二叉树,如果每层的节点数都达到最大值,在这个二叉树就是满二叉树。即如果二叉树的层数为kk,节点总数是2k12^k - 1,则它就是满二叉树

    image-20231210171251246
  • 完全二叉树:对于深度为kk的、有nn个节点的二叉树,当且仅当每一个节点编号都与深度为kk的满二叉树中节点编号一一对应时称之为完全二叉树。

    • 满二叉树也是一种特殊的完全二叉树
完全二叉树

完全二叉树的性质

假设节点编号为i(根节点编号为0

  • i0i \ne 0,则父节点编号:(i - 1) / 2
  • 左孩子编号:2 * i + 1
  • 右孩子编号:2 * i + 2
  • 完全二叉树的第n层(n不是最后一层)有2n12^{n-1}个节点
  • 节点数量为N的完全二叉树的高度为logNlog N(向上取整)

完全二叉树的表示方法

传统树结构

class TreeNode {
    int val;
    TreeNode left;
    TreeNode right;
}

数组表示

使用arr[i] 代表完全二叉树中编号为i的节点的值

例如:

int[] heap = new int[] {0, 1, 2, 3, 4, 5, 6};

这个数组可以表示如下的这棵完全二叉树

image-20231210171251246

堆的概念

堆在本质上是一种特殊的完全二叉树,它分为小根堆和大根堆

  • 小根堆:完全二叉树中每一棵子树的根节点都是子树中最小的元素,即每个父节点的值都小于左孩子和右孩子的值。
  • 大根堆:完全二叉树中每一棵子树的根节点都是子树中最大的元素,即每个父节点的值都大于左孩子和右孩子的值

例如:下面的这棵树是大根堆

image-20231210174659236

下面的这棵树是小根堆

image-20231210174425977

下面的这棵树不是堆结构

image-20231210174745398

堆结构中的两个重要流程

以下的所有流程均以大根堆为例,即父节点的值>左右子节点的值

从下向上的调整:heapInsert

流程描述

heapInsert是指针对堆结构中的某个节点,不断地和自己的父节点比较,如果子节点的值大于父节点的值,则和父节点交换位置,直到不再大于父节点,或者自己已经是整个二叉树的根节点为止。

简单地说,heapInsert就是节点“上浮”直到符合堆结构要求的过程。

举个例子,对于这样的一个数组:[9, 7, 5, 4, 5, 2, 3, 1],它代表的是这样的一个堆结构。

image-20231211141236163

如果在数组的最后面追加一个元素8,即数组变成了[9, 7, 5, 4, 5, 2, 3, 1, 8],此时显然是不符合大根堆要求的,那么如何才能调整这个堆结构,使得它重新变成一个大根堆呢。

image-20231211143006337

设置一个指针i指向数组8位置的8,将它和父节点比较,父节点在数组中的index(8 - 1) / 2 = 3,所以将8位置的83位置的4比较,8 > 4,所以将两个元素互换位置,数组变成了[9, 7, 5, 8, 5, 2, 3, 1, 4],对应的完全二叉树结构变成了下面这个样子。

image-20231211150012572

此时,3位置的8继续和自己的父节点比较,父节点的index(3 - 1) / 2 = 1,所以3位置的81位置的7做比较,8 > 7,所以和父节点交换位置,数组变成了[9, 8, 5, 7, 5, 2, 3, 1, 4],对应的完全二叉树结构变成了如下所示的样子。

image-20231211150957652

再将1位置的8和父节点,也就是0位置的9做比较,8 < 9,所以已经不能再继续上浮了,此时终止整个流程,整个完全二叉树结构已经符合大根堆的要求。

代码

/**
 * 从i位置向上看,不断的和自己的父节点pk,直到无法比父节点更大,或自己已经是整棵树的根节点为止
 */
public void heapInsert(int[] arr, int i) {
    while (arr[i] > arr[(i - 1) / 2]) {
        DUtils.swap(arr, i, (i - 1) / 2);
        i = (i - 1) / 2;
    }
}

这段代码中有一个点需要特殊说明一下,如果i = 0,也就是说要判断的节点已经来到了整棵树的根节点,那么(i - 1) / 2 = 0,在while循环的判断条件那里就是arr[0]arr[0]做比较, 一定不满足循环条件,就一定会跳出循环。

时间复杂度分析

从流程上看,heapInsert就是节点的不断上浮的过程。已知节点数量为N的完全二叉树高度是logNlog N,所以这个比较和上浮的过程最多会执行logNlog N次,所以heapInsert的时间复杂度是O(logN)O(logN)

从上向下的调整:heapify

流程描述

上面提到的heapInsert是从下到上的调整,在堆结构的调整中还存在一种从上到下的调整。举个例子,对于这样的一个数组:[9, 8, 5, 7, 5, 2, 3, 1, 4],它对应的是如下所示的这样一个大根堆结构。

image-20231211153339745

此时,如果由于某些原因,堆顶的9变成了1,显然大根堆结构又被破坏了。此时需要将堆顶元素1与自己的左右孩子对比,如果节点的值比左右孩子都大则终止流程,否则将节点和左右孩子中的较大者交换位置,不断地下沉,直到结构再次符合大根堆的要求,这个过程我们称之为heapify

image-20231211154538624

以上面的数组为例,设置一个指针指向0位置的1,首先去计算节点左右孩子在数组中的index,左孩子的index等于0 * 2 + 1 = 1,右孩子的index0 * 2 + 2 = 2,这两个位置都没有超过堆的范围,然后再将arr[0]arr[1]arr[2]做对比,发现arr[1] = 8,是三个节点中的最大值,所以将0位置的11位置的8交换位置,数组变成了[8, 1, 5, 7, 5, 2, 3, 1, 4],对应的堆结构调整如下。

image-20231211155449279

此时再将1位置的1和它的两个子节点做比较。左孩子的index1 * 2 + 1 = 3,右孩子的index1 * 2 + 2 = 4arr[1]arr[3]arr[4]三个元素中最大的是3位置的7,所以将arr[1]arr[3]交换位置,数组变成了[8, 7, 5, 1, 5, 2, 3, 1, 4],对应的堆结构调整如下。

image-20231211155930679

现在再将3位置的1和它的两个孩子,也就是7位置的18位置的4做比较,发现三个元素中最大的是8位置的4,所以将3位置的18位置的4交换位置,数组变成了[8, 7, 5, 4, 5, 2, 3, 1, 1],对应的堆结构调整如下。

image-20231211160400769

此时的i指向8位置的1,计算它的左右子节点的index,分别是2 * 8 + 1 = 172 * 8 + 2 = 18,发现都已经超过了堆的范围,所以说明它不再有左右子节点,终止流程,二叉树结构已经符合大根堆的要求。

代码

/**
 * 从arr[i]开始不断下沉,直到满足大根堆的标准
 * 如何下沉:不断和左右孩子中较大的元素交换位置
 */
private void heapify(int[] arr, int i, int heapSize) {
    while (2 * i + 1 < heapSize) {
        // 有左孩子
        int swapIndex = i;
        if (arr[2 * i + 1] > arr[swapIndex]) {
            swapIndex = 2 * i + 1;
        }

        if (2 * i + 2 < heapSize && arr[2 * i + 2] > arr[swapIndex]) {
            swapIndex = 2 * i + 2;
        }

        if (swapIndex == i) {
            break;
        }

        DUtils.swap(arr, i, swapIndex);
        i = swapIndex;
    }
}

时间复杂度分析

从流程上可以看出来,heapify是一个节点的不断下沉的过程,和前面类似,已知完全二叉树的高度是logNlogN,那么这个比较和交换的过程最多也就执行logNlogN次,所以heapify的时间复杂度也是O(logN)O(logN)

堆排序

堆排序的流程

所谓堆排序,就是将一个无序数组通过堆结构变成有序的状态。堆排序一共有两个步骤:建堆和排序。我们下面的这个数组为例:[3, 5, 2, 7, 5, 4, 6]

建堆

首先设置一个变量heapSize,含义是arr[0...heapSize-1]是堆结构中的元素。初始状态heapSize = 0,意味着堆中没有任何元素。

heapSize++,现在heapSize的值是1,意味着堆中只有一个元素,就是arr[0],现在是符合大根堆结构的要求的。

image-20231211210350566

再让heapSize++,意味着堆中目前有两个元素。此时的完全二叉树是不符合堆结构的要求的,所以需要执行heapInsert,将1位置的50位置的3交换位置,数组变成[5, 3, 2, 7, 5, 4, 6],调整后的堆结构如下。

image-20231211205823055

heapSize+1变成3,即将数组2位置的2也插入堆结构,再执行heapInsert2位置的2比父节点0位置的5小,所以直接结束流程,此时数组依然为[5, 3, 2, 7, 5, 4, 6],堆结构如下。

image-20231211210410821

heapSize+1变成4,即将数组3位置的7插入堆结构,再执行heapInsert3位置的7首先和父节点1位置的3做比较,7 > 3所以交换位置,再和父节点0位置的5做比较,交换位置。此时数组变成[7, 5, 2, 3, 5, 4, 6],堆结构如下。

image-20231211210500548

heapSize继续+1变成5,把4位置 5插入堆结构后执行heapInsert4位置的5找到自己的父节点,也就是1位置的5发现不能上浮,所以流程结束,数组不变,此时的堆结构如下。

image-20231211210920030

heapSize1后变成6,把数组5位置的4插入堆结构后执行heapInsert5位置的4比自己的父节点,也就是2位置的2大,所以交换位置,再找父节点找到了0位置的7,已经不能再上浮了,所以数组变成了[7, 5, 4, 3, 5, 2, 6],堆结构如下。

image-20231211211417081

最后heapSize+1变成7,把数组6位置的6插入堆结构再执行heapInsert6先和父节点2位置的4比较后交换位置,再喝0位置的7做比较,然后终止流程,数组变成[7, 5, 6, 3, 5, 2, 4],堆结构如下。

image-20231211212423812

至此,数组中的所有元素都已经插入到堆结构中了,建堆过程到此结束。

排序

到目前为止我们已经将数组中的所有元素建成了一个大根堆,也就是说堆顶元素就是堆中最大的元素。我们将堆顶元素arr[0]与堆范围内的最后一个元素arr[6]交换位置,数组变成了[4, 5, 6, 3, 5, 2, 7]。随后将heapSize--,意味着切断了6位置的7和整个堆结构的联系。此时的堆结构变成了如下的样子。

image-20231211213619575

此时的堆结构是不符合大根堆要求的,所以我们针对换到堆顶的4执行heapify,让它不断下沉,直到整个结构符合大根堆要求。

image-20231211213844509

本次heapify结束之后,数组变成了[6, 5, 4, 3, 5, 2, 7]

此时再将堆顶元素6和堆中最后一个元素5位置的2交换位置,数组变成[2, 5, 4, 3, 5, 6, 7]heapSize--,然后针对堆顶元素执行heapify,数组变成[5, 5, 4, 3, 2]

image-20231211214339251

再将堆顶元素5和堆中最后一个元素2交换位置, 数组变成[2, 5, 4, 3, 5, 6, 7]heapSize--,再针对堆顶元素执行heapify,数组变成[5, 3, 4, 2, 5, 6, 7]

image-20231211215034022

堆顶的5和堆中最后一个元素2再交换位置,数组变成[2, 3, 4, 5, 5, 6, 7]heapSize--,再针对堆顶元素heapify,数组变成[4, 3, 2, 5, 5, 6, 7]

image-20231211215647576

堆顶的4和堆中最后的2再交换位置,数组变成[2, 3, 4, 5, 5, 6, 7]heapSize--,再针对堆顶的2执行heapify,数组变成[3, 2, 4, 5, 5, 6, 7]

image-20231211215930211

最后再将堆顶的3和堆中最后的2交换位置,heapSize--,再执行heapify,此时堆中已经只剩下一个元素了,堆排序的整个流程就可以终止了,数组变成[2, 3, 4, 5, 5, 6, 7],有序状态。

代码

堆排序的代码如下所示

public static void heapSort(int[] arr) {
    if (arr == null || arr.length < 2) {
        return;
    }

    // 1. 建堆
    // heapSize: i+1
    for (int i=0; i<arr.length; i++) {
        heapInsert(arr, i);
    }

    int heapSize = arr.length;
    while (heapSize > 0) {
        DUtils.swap(arr, 0, --heapSize);
        heapify(arr, 0, heapSize);
    }
}

时间复杂度分析

分析一下上述流程的时间复杂度。上述流程首先分别在大小为1, 2, 3...N的堆上执行heapInsert,上面分析过heapInsert的时间复杂度上O(logN),所以建堆的时间花费就是log1+log2+log3+...+logN=log1×2×3×...×N=logN!log1 + log2 + log3 + ... + logN = log 1\times2\times3\times...\times N = log N!

根据数学推导可以得知,当N趋近于无穷大时,logN!logN!logNNlogN^N是同阶无穷大,所以建堆的时间复杂度就是O(NlogN)O(NlogN)

同样的,排序的过程也是分别在大小为N, N-1, ..., 2, 1的堆上执行heapifyheapify的时间复杂度也是O(logN),所以排序过程上时间复杂度也是O(NlogN)O(NlogN)

时间复杂度优化

在上述的两个步骤中,排序的时间复杂度已经没办法再优化了,但建堆还可以再做一步优化。还是以这个数组为例:[3, 5, 2, 7, 5, 4, 6],它可以表示如下这棵完全二叉树。

image-20231212103027826

如何将这棵完全二叉树调整成大根堆呢?我们从最后一个节点开始,逐个节点进行heapify。所以首先针对二叉树的最下层节点从右侧向左,也就是6457调用heapify。由于这几个节点已经是叶子节点了没办法再下沉了,所以可以直接退出流程。

image-20231212103435902

然后再针对树的倒数第二层从右向左调用heapify。首先是2位置的2,将它和6位置的6交换位置。

image-20231212104046721

再将1位置的53位置的7交换位置。数组变成了[3, 7, 6, 5, 5, 4, 2],对应的堆结构调整如下。

image-20231212104125614

最后再针对0位置的3调用heapify0位置的3首先和1位置的7交换位置,再和3位置的5交换位置,数组变成了7, 5, 5, 3, 5, 4, 2],对应的堆结构调整如下。

image-20231212104550796

至此,建堆流程结束。

分析一下这个流程的时间复杂度。假设堆中一共有n个节点,那么堆的最后一层,大约有n/2个节点的heapify完全不需要调整,大约有n/4个节点的heapify最多调整一层,n/8个节点的heapify最多只需要调整两层,...。所以整个流程的时间花费(假设为S(n)S(n)就可以表示为

S(n)=0×n2+1×n4+2×n8+...S(n) = 0 \times \frac {n} {2} + 1 \times \frac {n} {4} + 2 \times \frac {n} {8} + ...

将上述公式两侧 ×2\times 2可得

2S(n)=n2+2×n4+3×n8+...2S(n) = \frac {n} {2} + 2 \times \frac {n} {4} + 3 \times \frac {n} {8} + ...

公式2和公式1错位相减后可以得到

S(n)=n2+n4+n8+...S(n) = \frac {n} {2} + \frac {n} {4} + \frac {n} {8} + ...

根据等比数列的求和公式S(n)=a1(1qn)1qS(n) = \frac {a_1(1 - q^n)} {1-q},上述公式可以写成

S(n)=n2(1(12)n)112=nn2nS(n) = \frac {\frac {n} {2}(1-(\frac {1} {2})^n)} {1-\frac {1} {2}} = n - \frac {n} {2^n}

nn趋近于无穷大时,n2n\frac {n} {2^n}一定是趋近于0的,所以上述公式的时间复杂度就是O(n)O(n)

堆的应用

leetcode 23.合并K个升序链表

题目描述

给你一个链表数组,每个链表都已经按升序排列。

请你将所有链表合并到一个升序链表中,返回合并后的链表。

示例 1:

输入:lists = [[1,4,5],[1,3,4],[2,6]] 输出:[1,1,2,3,4,4,5,6] 解释:链表数组如下: [ 1->4->5, 1->3->4, 2->6 ] 将它们合并到一个有序链表中得到。 1->1->2->3->4->4->5->6

示例 2:

输入:lists = [] 输出:[]

示例 3:

输入:lists = [[]] 输出:[]

解法1

【解法流程】

k条链表中的所有节点存入一个集合,然后调用系统排序算法排序后重新拼接成一个新的链表

【复杂度分析】

假设k条链表中共有n个节点

  • 时间复杂度

主要的时间花费在排序上,时间复杂度为O(NlogN)

  • 空间复杂度

需要一个集合存储n个节点,所以空间复杂度O(n)

解法2

【解法流程】

准备一个大小为k的小根堆,将k条链表的头节点放入堆中,然后弹出堆顶元素,此时弹出的堆顶元素一定是k条链表中最小的元素,拼到结果链表中。再将弹出元素的下一个节点放入堆中,弹出堆顶元素,循环往复直到堆中元素为空。

【代码】

public ListNode mergeKLists(ListNode[] lists) {

        PriorityQueue<ListNode> queue = new PriorityQueue<>(Comparator.comparingInt(n -> n.val));

        for (ListNode node : lists) {
            if (node != null) {
                queue.offer(node);
            }
        }

        ListNode head = new ListNode(0);
        ListNode cur = head;

        while (!queue.isEmpty()) {
            ListNode poll = queue.poll();
            cur.next = poll;
            cur = poll;
            if (poll.next != null) {
                queue.offer(poll.next);
            }
        }

        return head.next;
    }

【复杂度分析】

  • 时间复杂度

n个节点分别入堆出堆,在堆中调整的时间复杂度是O(logK),整体流程的时间复杂度是O(nlogK)

  • 空间复杂度

整体流程借用了一个大小为k的堆,所以空间复杂度O(k)