一步步地分析排序——优先队列和堆排序

断了今生、忘了曾经 提交于 2020-01-15 03:55:00

本文框架

思维导图

定义和使用场景

优先队列是一个抽象数据类型,和栈、队列类似,它们都是抽象数据类型,相当于一个Java类,有自己的属性,并对外提供API。在了解它有什么API之前,先来看看优先队列的使用场景。

优先队列适用于需要对集合不断地执行插入元素、删除最大(或最小)元素的场景。这个场景大体可以分为两类:
第一类是业务实际情况需要,比如CPU的任务调度,待执行的任务是一个集合,每启动一个新程序就是在向集合里面插入元素。当前程序执行完后,就要从集合里面取出下一个优先级最高的程序。不断地有程序被启动和被执行,就像不断地对集合执行插入、删除最大元素的操作。

第二类场景是“从N个元素里获取最大的M个元素,N很大,不能一次性全部读进内存”,比如从银行成百上千万条交易记录里面找到金额最大的10笔交易;或者从全国的手机号码里面找到使用年限最长的10个号码。对于第二类场景,问题本身并不需要不断地对集合进行插入、删除操作。如果内存没有限制的话,你可以一次性将数据全部装进集合,然后随便选择一个排序算法对集合进行降序排列,接着输出最前面的10个元素。但是由于待处理的数据量过大(相对内存而言),不能使用排序算法解决该类问题,以银行交易记录为例子,你可以用优先队列通过如下步骤解决:

  1. 创建一个容量为11的集合
  2. 向集合里插入一笔交易记录,如果插入后集合的元素达到11个,删除金额最小的一笔交易(需要注意的是,如果要获取最大的M个元素,在删除时删除的是最小的;而如果获取最小的M个元素,在删除时删除的是最大的,是反过来的
  3. 循环执行步骤2,直到所有交易记录遍历完毕
  4. 逐一输出集合里的所有交易记录,此时输出的便是成百上千万条交易记录里面金额最大的10笔交易,而且输出的数据是升序的

整个流程如下图所示,为了绘图方便,这里取了比较小的值,N为9,M为3:
优先队列使用示例
此时的集合就像是一个过滤器,如果新插入的元素比集合里面所有的元素都小,在下一次删除元素时,这个元素会被删除,就像它从来没出现过。如果新插入的元素比集合里原有的任一元素大,在下一次删除元素时,集合里面最小的元素会被“排挤”出去。

不论是哪一类场景,都是在不断地(不一定是轮流进行,没有必然的规律)对集合进行插入、删除最大(或最小)的元素,这就是优先队列的典型应用。

抽象API

通过前文的使用场景可以看到,主要的操作就是插入和删除最小的元素,因此优先队列的API可以这样定义:

// 插入元素
void insert(int a);

// 删除最大元素
int deleteMin();

以上是实现一个优先队列必要的API,当然如果你有需要,可以定义其它的API。

优先队列使用示例

以下代码示例了如何使用优先队列解决“从N个元素里面获取最大的M个元素”的问题,代码来自《算法》第四版中文版,删减了一些不需要的东西。

public class TopM{
    publi static void main(String[] args){
        int M = Integer.parseInt(args[0]);
        // 注意了,需要(M+1)个空间
        MinPQ<Transaction> pq = new MinPQ<Transaction>(M+1);
        
        // 循环地向优先队列里插入元素
        while(StdIn.hasNextLine()){
            pq.insert(new Transaction(StdIn.readLine()));
            // 当优先队列里已经有(M+1)个元素,删除最小的那个
            if(pq.size() > M){
                pa.deleteMin();
            }
        }// 循环结束,最大的M个元素都在优先队列里面
        
        // 输出优先队列里的元素
        while(!pq.isEmpty()){
            StdOut.println(pq.deleteMin());
        }
    }
}

计算API的调用次数

由于我们还没谈到具体如何实现这两个API,所以暂时无法计算时间复杂度,但是我们可以计算从N个元素里面获取最大的M个元素时,插入和删除操作执行的次数。整个过程可以分为如下阶段:

  1. 遍历待处理的元素:就是让待处理的元素都被集合处理一次,这一阶段还可以进一步分为两个阶段:
    1. 装满集合:集合里的元素从0个到M个,这一阶段共调用insert()M次。
    2. 过滤元素:集合元素达到M个后,每插入一个新元素,就要从集合里面删除最大的元素,该部分调用insert()和deleteMin()各(N-M)次。
  2. 输出集合里的元素:这一阶段其实就是输出结果,共调用deleteMin()M次,集合元素从M个到0个。

这是为我们后续计算具体实现方案的时间复杂度作铺垫。

传统方式实现优先队列

优先队列最简单的实现方案就是使用有序或无序的数组(或链表),以下列举每个方案实现上述API的逻辑:

实现方案 insert() deleteMin()
有序数组 相当于执行插入排序的一次插入,元素按照降序排列 删除数组尾部的元素
无序数组 向数组尾部插入一个元素 相当于执行选择排序的一次选择,找到最小的元素后,将它和数组尾部的元素互换位置,此时最小元素在数组尾部,直接删除尾部元素即可
有序链表 相当于执行插入排序的一次插入,元素按照升序排列 删除表头元素
无序链表 向链表头部插入一个元素 相当于执行选择排序的一次选择,找到最小的元素之后直接删除即可

接着列举每个方案的时间复杂度(假设元素总数为N,需要获取最大的M个元素,这里的N是远大于M的):

实现方案 一次insert() 一次deleteMin() 从N个元素里面获取最小的M个元素的总时间复杂度
有序数组 O(M) O(1) M² + (N-M)*M + (N-M) + M = O(NM)
无序数组 O(1) O(M) M + (N-M) + (N-M)*M + M² = O(NM)
有序链表 O(M) O(1) M² + (N-M)*M + (N-M) + M = O(NM)
无序链表 O(1) O(M) M + (N-M) + (N-M)*M + M² = O(NM)

小结:

  • 可以看出,使用数组还是链表差异不大,主要是有序和无序的差异。
  • 进行一次插入或删除操作的时间复杂度和集合的大小(即M)成线性关系,解决整个问题的全部时间复杂度和待处理元素数量N和集合大小M的乘积即(NM)成线性关系。

在真实的工程应用里,单次操作的时间复杂度为线性是不可以接受的,单次操作一般都要求对数关系的时间复杂度,于是有了接下来要谈的实现方案。

二叉堆的定义

声明:为了在后续二叉堆的介绍里面,更符合直觉和习惯,前文我们分析的都是实现deleteMin(),从现在起按照实现deleteMax()来进行分析

二叉堆是一个存储在数组里的堆有序的完全二叉树,第一个元素存放在数组下标1的位置。这句话有几个关键词:堆有序的完全二叉树、存在数组里、下标1。他们分别从顺序、结构、存储方式对二叉堆进行了约束。

顺序性:堆有序的二叉树,是指二叉树里的每个结点都大于等于它的两个子结点。比如一个由1、2、3三个元素构成的二叉树,如果要符合堆有序,根结点必须是3,至于1和2谁左谁右,没有关系。注意它和二叉查找树的差异,如果是两层的二叉查找树的话,三个元素只有一个摆放方式:2是根结点,1是左子结点,3是右子结点。然而堆有序的二叉树只要求父结点大于等于两个子结点,对两个子结点的大小关系没有约束。下图示例了二叉堆和二叉查找树的区别:
二叉堆和二叉查找树的区别举例
结构性:二叉堆是一棵完全二叉树,也就是在堆有序的二叉树的前提下,加上完全二叉树的约束。

存储方式:不论一棵二叉树是否是完全二叉树,用链式结构存储都有一个问题:从父结点找子结点容易,从子结点找父结点不方便(除非在每个结点添加指向其父结点的指针)。而由于完全二叉树的特殊性,可以直接使用数组存放,父子结点之间不用任何指针,它们之间有算术上的关系,可以通过算术关系找到任意结点的父、子结点。那么为什么要在下标1呢?根据完全二叉树的性质,如果第一个元素在下标0上,则第k个元素的两个子元素为2k+1和2k+2,父元素为k/2(当k为奇数)或((k/2)-1)(当k为偶数),即父元素有两类情况。但如果第一个元素在下标1上,则第k个元素的两个子元素为2k和2k+1,父元素为k/2,即父元素只有一类情况。也就是为了方便计算。

二叉堆的特性

除了上述提到的父子结点的算术关系,我们还可以列举几个二叉堆的特性,加深我们对二叉堆的理解。

  • 大顶堆、小顶堆:如果一个二叉堆,每个结点都大于或等于它的两个子结点,这个二叉堆称为大顶堆;反之,如果一个二叉堆,每个结点都小于或等于它的两个子结点,这个二叉堆称为小顶堆。
  • 对于大顶堆,从任一结点随便选一条路径往下走到叶子结点,可以得到一个降序序列;反之,从任意叶子结点往上走到根结点,可以得到一个升序序列。
  • 一个降序排列的数组其实就是一个大顶堆,一个升序排列的数组就是一个小顶堆。

恢复堆的有序性——上浮

既然二叉堆作为一个集合(数组即集合),那么在对集合进行增删改的时候,可能会破坏二叉堆原有的顺序性。比如直接将某个结点的值修改为一个比它的父结点更大的值;向数组尾部插入一个新结点,新结点的值比它的父结点大;直接修改某个结点的值让它比其中一个子结点的值小。修改集合的方式有很多,但是造成的结果可以归为两类:某个结点变大或变小。那么当二叉堆的顺序被破坏后,如何恢复堆的有序性?

如果某个结点的值变得比它的父结点更大,对于以该结点为根结点的子堆,显然还是符合二叉堆的特性的,只是该结点和它的父结点、祖先结点的位置关系不对,此时只要将该结点和其父结点互换位置即可。接下来面临同样的问题,如果该结点仍然比它新的父结点更大该怎么办,自然是继续互换它们的位置,直到遇到比它大的父结点,或者该结点最终变成了根结点。整个过程就像该结点沿着路经在往上爬,或者说是我们将该结点浮上去了,这个操作称为“上浮”,上浮操作可以在我们向二叉堆插入新元素后,恢复二叉堆的有序性。下图示例了上浮操作的过程:
上浮操作示例图

上浮操作示例代码

以下是上浮操作示例代码:

/*
 * 方法说明:“上浮”算法要实现的,是对位置k的结点执行“上浮”操作,将其“上浮”到合适的位置。
 * 对引用到的两个方法说明如下:
 * less(int p, int q)方法,如果下标为p的元素小于下标为q的元素,则返回true,否则返回false。
 * exchange(int p, int q)方法,互换数组里面下标为p和q的两个元素。
 */
private void swim(int k){
    // 循环判定条件:只要k还没到达根结点,且k的子结点比他还小,k就要继续往上浮
    while( k>1 && less(k/2,k) ){
        exchange(k/2, k);
        k = k/2;
    }
}

时间复杂度分析

我们以执行“比较操作”的次数表示时间复杂度,根据完全二叉树的性质,如果根结点算作第一层的话,位置k的结点在⌊log2k⌋ + 1层。最坏的情况是在数组尾部插入新元素并且要上浮到根结点,如果结点总数为N,则要爬⌊log2N⌋层,每爬一层之前都要比较一次,爬到根结点时不用再对两个结点进行比较了,所以比较次数为⌊log2N⌋,即时间复杂度为O(logN)。

恢复堆的有序性——下沉

如果某个结点的值变得比它的其中一个子结点小(也有可能比它的两个子结点都小),对于该结点往上的所有结点,显然还是符合二叉堆的特性,只是该结点和它的子结点、子孙结点的位置不对。此时只要将该结点和它的两个子结点里较大的那个互换位置即可。接下来面临同样的问题,如果该结点的两个新子结点里仍然有比该结点更大的怎么办,自然是继续和它的两个子结点里较大的那个互换位置,直到该结点比它的两个子结点都大,或者该结点变成了叶子结点。整个过程就像该结点沿着路经往下滑,或者说是我们将该结点沉下去了,这个操作称为“下沉”。下图示例了下沉操作的过程:
下沉操作示例图

下沉操作示例代码

以下是下沉操作示例代码:

/*
 * 方法说明:“下沉”算法要实现的,是对位置k的结点执行“下沉”操作,将其“下沉”到合适的位置。
 * 对引用到的两个方法说明如下:
 * less(int p, int q)方法,如果下标为p的元素小于下标为q的元素,则返回true,否则返回false。
 * exchange(int p, int q)方法,互换数组里面下标为p和q的两个元素
 */
private void sink(int k){
    // 循环判断条件,位置k是否还有子结点
    while(2*k<=N){
        int j = 2*k; // j指向k的第一个子结点
        // 如果位置k的元素有两个子结点,且第二个子结点大于第一个,将j指向第二个子结点
        if( j<N && less(j, j+1) ) {
            j++;
        }
        // 当代码走到这里,不论k有一个还是两个子结点,j已经指向最大的那个子结点
        // 如果结点k大于它所有的子结点,结束
        if(less(j, k)) {
            break;
        }
        // 否则,将k和其子结点位置交换
        exchange(k, j);
        k = j;
    }
}

时间复杂度分析

我们仍以执行“比较操作”的次数表示时间复杂度,根据完全二叉树的性质,如果根结点算作第一层的话,位置k的结点在⌊log2k⌋ + 1层。最坏的情况是从根结点下沉到树的底层,如果结点总数为N,则要下沉⌊log2N⌋层。每下沉一层都要比较两次,所以比较次数为2*⌊log2N⌋次,即时间复杂度为O(log2N)。

二叉堆实现优先队列API

其实当我们介绍了如何使用上浮和下沉操作恢复二叉堆的有序性后,你大概就知道该怎么使用二叉堆来实现优先队列了。对于insert(),只要将元素插入到数组尾部,然后对该元素执行上浮操作即可。对于deleteMax(),只要删除二叉堆的根结点,就能输出集合里最小的元素,然后,将数组尾部的元素移到根结点,对根结点执行下沉操作即可恢复堆的有序性。

示例代码

以下代码展示了二叉堆实现优先队列的insert()和deleteMax()方法:

// 向优先队列插入一个元素
public void insert(Key v){
    pq[++N] = v; // 向数组末尾插入一个元素
    swim(N);     // 将刚插入的元素上浮到正确位置
}

// 从优先队列删除最大元素
public Key deleteMax(){
    Key max = pq[1];    // 保存堆顶的元素
    exchange(1, N--);   // 堆尾元素移到堆顶
    pq[N+1] = null;     // 删除堆尾的元素
    sink(1);            // 将堆顶元素下沉到正确位置
    return max;
}

时间复杂度分析

前文已经分析过单次insert()或deleteMax()的时间复杂度,均是O(logN),现在来分析解决整个“从N个元素里面获取最小的M个元素”问题的时间复杂度,注意现在分析的是deleteMax()所以是获取最小的M个元素。我们按照前文计算优先队列API调用次数时分成两个阶段来计算,先引入一个算式方便我们计算:
当N = 2k-1时(这个约束其实就是要求N的值刚好是满二叉树的结点数量),有⌊log21⌋ + ⌊log22⌋ + … + ⌊log2N⌋ = 0 + 1 + 1 + 2 + 2 + 2 + 2 + 3 + 3 +3 +3 +3 +3 +3 + 3 + 4…这个算式其实是有规律的,当N刚好对应满二叉树的结点数量时,值为 (N+1)*log2(N+1)-2N。
这个算式是计算时间复杂度的一部分,表达的是一个增长趋势,对于一棵完全二叉树,底层元素个数是1个和完全铺满的情况,计算差异可能很大,但是底层不论有几个元素,耗时不会超过铺满的情况。

最优情况是输入是降序的:

  1. 遍历待处理的元素:
    1. 装满集合:数组从0个元素增加到M个元素,每插入一个元素,只需进行一次比较,时间复杂度为M。
    2. 过滤元素:在含有M个元素的二叉堆里交替执行insert()和deleteMax(),注意下沉操作需要进行两次比较,时间复杂度为(N-M) + (N-M)2log2M。
  2. 输出集合里的元素:调用deleteMax(),数组元素从M个缩减为0个,删除第一个元素之后,是在(M-1)个元素里执行下沉,所以执行下沉操作的总次数为2*(⌊log2(M-1)⌋ + ⌊log2(M-2)⌋ + … + ⌊log21⌋) ,取(M-1)刚好是满二叉树的情况,使用前面的算式,得到(2Mlog2M) - 4(M-1)。

求和之后得到N + 2Nlog2M - 4M + 4 ≈ O(Nlog2M)

最坏的情况是输入序列是升序的:

  1. 遍历待处理的元素:
    1. 装满集合:数组从0个元素增加到M个元素,从第二个元素开始(第一个元素没有可上浮的),每插入一个元素,都要从数组末尾上浮到根结点,时间复杂度为⌊log21⌋ + ⌊log22⌋ + ⌊log23⌋ + … + ⌊log2(M-1)⌋ = (Mlog2M) - 2(M-1)。
    2. 过滤元素:在含有M个元素的二叉堆里交替执行insert()和deleteMax(),时间复杂度为(N-M)log2M + (N-M)2log2M。
  2. 输出集合里的元素:调用deleteMax(),数组元素从M个缩减为0个,删除第一个元素之后,是在(M-1)个元素里执行下沉,所以时间复杂度为2*(⌊log2(M-1)⌋ + ⌊log2(M-2)⌋ + … + ⌊log21⌋) = (2Mlog2M) - 4(M-1)。

求和之后得到3Nlog2M - 6M + 6 ≈ O(Nlog2M)。

堆排序

如果对一个大顶堆连续执行删除根结点的操作,就会输出一个降序序列,相当于排序的效果,堆排序就是基于二叉堆实现的排序。思路也很简单,首先用N个元素构造一个二叉堆,接着连续删除根结点,为了实现原地排序的功能,删除的时候不要直接输出元素,而是将根结点和数组尾部元素互换位置,这样就不需要使用而外的数组。

构造堆

最直观的方法自然是从第一个待处理的元素开始遍历数组,类似调用N次insert()向优先队列里插入N个元素那样。这个方式可以称为上浮方式构造堆,它的逻辑和前文的基本一致,就不多说了。

一个更高效的方法是下沉方式构造堆,我们知道下沉操作可以在二叉堆的某个结点变得比它的子结点小时恢复堆的有序性,而如果我们将这个变小的结点看作它的左右两个子堆的根结点,这个操作就像合并两个子堆,如下图示:
合并两个子堆示例一
特别的,如果对三个随机排列的元素的根结点执行下沉操作,就像将两个大小为一的子堆合并成一个二叉堆,如下图示:
合并两个子堆示例二
如果从第一个非叶子结点开始,自右向左,自下而上地对每个结点执行下沉操作,就像不断地在合并子堆,子堆的大小从1开始递增,整个过程有点类似归并排序的自底向上的归并过程,如下图示:
下沉建堆
以上就是下沉方式构造堆的过程,由于叶子结点是大小为1的堆,所以从第一个不是叶子的结点开始下沉,也就是从大小为3的堆开始下沉,由于过程是自下而上的,所以每一个新处理的结点,它的两个子堆一定都是有序的,对新结点进行下沉即可合并两个子堆,直到遇到根结点,建堆完毕。当元素数量相同时,树的高度也相同,上浮建堆和下沉建堆只是方向不同,它们走的高度都是一样的,为什么下沉就更高效呢?因为结点在树的各层不是均匀分布的,这导致了以根结点作为终点(上浮)来建堆和以树的底部作为终点(下沉)来建堆的效率差异,如下图示:
下沉建堆和上浮建堆的比较
越是接近树的底部,结点数量越多,反之,越是接近根结点,结点的数量越少。如果以根结点作为终点,意味着结点数量最多的那一层,走的路径也是最长的,结点数量第二多的那一层,走的路径是第二长的。如果以树的底部作为终点,则结点数量最多的那一层,走的路径长为0,结点数量第二多的那一层,走的路径长为1。上浮建堆和下沉建堆是相反的,这是效率差异的原因。以15个结点作为例子进行计算,上浮建堆的路径总长度为10 + 21 + 32 +43 = 34,下沉建堆的路径总长度为80 + 41 + 22 + 13 = 11。这是从计算的角度说明。

下沉建堆时间复杂度分析——证明下沉建堆能在线性时间内完成
我们以下图所示由满二叉树构成的二叉堆为例进行计算和说明:
下沉建堆时间复杂度分析
计算由2k+1-1个元素构成的数组执行下沉建堆的时间复杂度,可以涵盖由2k至2k+1-1个元素构成的数组执行下沉建堆的时间上界。例如当k取4,计算31个元素构成的数组执行下沉建堆的时间复杂度,可以涵盖由16至31个元素构成的数组执行下沉建堆的时间上界。对于时间复杂度的计算,得到上界即可,满二叉树比较有规律,方便计算。

以上图为例,对31个元素构成的升序排列的数组进行下沉建堆,构建大顶堆,
在高度为0那层,有16个元素,每个元素需要下沉0层;
在高度为1那层,有8个元素,每个元素需要下沉1层;
在高度为2那层,有4个元素,每个元素需要下沉2层;
在高度为3那层,有2个元素,每个元素需要下沉3层;
在高度为4那层,有1个元素,每个元素需要下沉4层;
这个算式其实是有规律的,在高度为h那层,有2H-h个元素,每个元素需要下沉h层,则建堆需要下沉的总次数为:
1 * 2H-1 + 2 * 2H-2 + 3 * 2H-3 + … + H*20,这是一个等差等比数列相乘求和,用错位相减法即可化简,最终结果为:
2 * 2H+1 - H - 2,再根据H和N的关系,得到最终算式:
N - log2(N+1),由于下沉一层需要比较两次,所以比较的次数为2N - 2log2(N+1),这个算式的值显然小于2N,所以时间复杂度为O(N),即下沉建堆可以在线性时间复杂度完成。
重新提一下计算的前提是数量N构成满二叉树,这个算式仅在N = 2k+1-1的时候得到的值是刚好相等的,其它情况相当于是计算出了耗时上界。

下沉排序

下沉排序的思路类似实现优先队列的deleteMax(),但不像deleteMax()那样把根结点删了就行了,为了实现原地排序(也就是空间复杂度为常数),在删除第一个结点的时候,将根结点和倒数第一个元素互换位置,接着对新的根结点进行下沉操作。然后继续将根结点和数组倒数第二个元素互换位置,再对新的根结点进行下沉操作,以此类推。这样就能实现原地排序,最终得到一个升序数组。时间复杂度的计算和前面“输出集合里的元素”一致:
2 * (⌊log2(N-1)⌋ + ⌊log2(N-2)⌋ + … + ⌊log21⌋) = (2Nlog2N) - 4(N-1) = O(Nlog2N)。

将建堆和下沉排序的时间复杂度加起来,这个算式不好化简,但是我们可以只将它们的上界加起来,即2N和2Nlog2N,则通过下沉建堆的方式实现堆排序需要不超过(2N + 2Nlog2N)次的比较(N+Nlog2N)次的互换(比较次数的一半)。

堆排序示例代码

以下是堆排序代码实例:

public static void heapSort(int[] a){
    int N = a.length;
    
    // 下沉建堆
    for(int k = N/2; k>=1; k--){
        sink(a, k, N);
    }
    
    // 下沉排序
    while(N > 1){
        exchange(a, 1, N--);
        sink(a, 1, N);
    }
}

全文总结

本文介绍了优先队列的定义及典型使用场景,接着由使用场景引出了相应API的定义,并列举了两类实现优先队列API的方式——数组(链表)和二叉堆。接着对由二叉堆实现优先队列API和堆排序的思路进行了详细的说明,并详细计算了相应的时间复杂度。

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!