您的位置:首页 > 其它

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

2017-08-27 10:53 225 查看

写在前面

本文是对《算法》第四版“优先队列”章节做的笔记,在学习并理解了书本“优先队列”这一节后,回过头来看这些知识点,虽然内容多,但不算很难,然而想要彻底地把握这一节的所有内容,也并不容易。为什么呢,大概是由于如下几点:

首先,为了体现堆排序的优点,书本拿它和传统的排序算法进行比较,这就要求你要熟悉传统排序算法(比如选择、插入、归并、快排),至少应该知道时间复杂度。

其次,虽然堆排序的存储结构是数组,但是逻辑结构是二叉树,或者可以说是完全二叉树,在计算它的各项特性、算法时间复杂度的时候,又会牵扯到二叉树的相关数学性质,这又要求你要有二叉树相关知识的基础。

最后,“优先队列”这一节的脉络有点类似书本第一章里关于“动态连通性”一节那样,有这么一个问题提出->分析->得出API->具体数据结构分析选型->对所选择的数据结构性能进行分析的系列过程。所以这一节的内容、思维跨度还是比较大的。

说了那么多,现在我们开始准备学习堆排序与优先队列吧。

文章脉络

本文脉络大体上按照书本的来,再根据笔者自己的一点想法,最终具体的脉络如下:

堆排序和优先队列有什么关系

提出问题(一个真实生活里的问题)

传统的解决思路(不用优先队列来解决问题)

新的解决思路(使用优先队列解决问题的思路)

对问题进行数学抽象

根据数学抽象得出API

初级实现的优先队列

二叉堆及其相关概念、算法

二叉堆实现优先队列

用二叉堆实现的堆排序

现在正式进入主题。

堆排序和优先队列的关系

问:文章标题是堆排序与优先队列,那么它们两者有什么关系呢?

答:其实它们两者没有什么必然联系。首先优先队列是一类抽象数据类型,堆排序是具体排序方式,实现一类抽象数据类型,有不同数据结构和算法可以实现,不一定非要用堆排序。其次堆排序只是一类排序方式,不是专门用来实现优先队列的。

问:为什么将堆排序和优先队列放在一起?

答:想要实现优先队列容易,但是想要同时做到节省空间(空间复杂度低)、高效(时间复杂度低),比较好的实现方式是二叉堆,而在二叉堆的基础上,可以延伸出一类新的排序方式——堆排序。这就是堆排序和优先队列的联系。

好了,现在我们从一个实际的问题开始,一点点延伸出优先队列、二叉堆、堆排序。

提出问题

如果现在银行需要收集一整天的交易数据,然后得出其中金额最大的10笔交易,该怎么做?

传统解决思路及其算法分析

很简单,把全部交易数据读进数组里,随便选一个排序方式(冒泡、选择、插入、归并、快排),按照递减顺序(严格地讲应该叫做非递增序,为了避免绕口,后文都用递减/递增顺序,不用非递增/非递减)排序,然后输出前面10个元素,完事。假设我们选择快排实现,那么根据快排的性质,算法性能如下:

空间复杂度:全部都要读取进内存,不用计算,就是O(N)。

时间复杂度:根据快排的性质,时间复杂度是O(NlogN)。

可以看出,不论是时间复杂度还是空间复杂度,都是可以接受的,既然传统排序方式的性能就可以接受,那我们为什么还要学习新的方式?现在我们来给问题加一些限制:

首先银行一天的交易量是很大的,我就只要10笔交易,你把全部(N笔交易)存起来了,其中有(N-10)的空间占用是多余的。其次如果现在在年尾,我要看下这一整年所有交易里面金额最大的几笔交易,难道你将整一年所有的交易全都读到内存里?如果我给这个需求前面加上一个限制:“待输入的数据不能全都暂存到内存里”,这就直接否定了传统的解决方式,因为传统的排序方式必须将数据全部存到内存里。然后我们面临的问题就是:有N个待处理的数据,这些数据不能同时全部存到内存里(N很大,比如说趋于无穷,反正内存装不下),而且这里面只有M个数据才是我们需要的,如何在同时兼顾时间复杂度、空间复杂度的情况下解决该问题。这就需要新的解决方式。

新的解决思路

来看下怎么解决空间限制:如果我先读取11笔交易,然后将其中最小的1笔删了,然后继续读取下一笔交易,每次读取一笔交易,都将这11笔交易里面最小的一笔删了,在读取完所有交易之后,我手里剩下的就是金额最大的10笔交易。(注意一下,如果你要最大的M笔交易,你就要保存(M+1)笔交易,如果你只保存M笔的话,如果你最先读取的M笔交易刚好就是最大的M笔,那么在你第一次删除的时候,就删除了第M大金额的交易了)。这个就是新的思路。

数学抽象

根据我们得出的新的解决思路,用简单的数学抽象(集合)就能描述这个问题:



如上图所示:

首先我有一个集合,起初它里面一个元素都没装。

然后我要面对N个输入,其实就是N个元素,N本身趋于无穷大,或者是一个集合不可能全部装的下的数量。当遍历完这N个元素之后,集合里面要剩下这N个元素里面值最大的M个元素。

先往集合里面装(M+1)个元素,然后拿走最小的1个,接下来每往集合里装一个元素,都要拿走已有的(M+1)个元素里面最小的1个。

全部元素遍历完之后,集合里面剩下的元素,就是问题的解。

得出API

仔细看看数学抽象的内容,你会发现其实主要就是两个操作,一个是向集合里面插入元素,一个是取出集合里面最小的那个元素,根据这个就得出API:

void insert(int v);  // 向集合里插入一个元素
int deleteMin();     // 删除集合里面最小的那个元素,并将该元素返回


这两个API,其实就是优先队列的核心API,来看看优先队列的定义:

优先队列是一种抽象数据类型,它表示了一组值和对这些值的操作,优先队列最重要的操作就是删除最大(或者最小)元素和插入元素。——《算法》第四版

事实上,根据已有数学抽象和API,我们可以得到使用优先队列解决该问题的大致流程的代码,这里直接套用书本的代码:

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个元素都在集合里面
// 其实代码执行到这里,已经完成任务了,那么下面这几行代码是干什么的呢?通过一轮入栈,一轮出栈,颠倒元素的顺序,就能按照递减序输出集合里面的元素。所以其实只是为了颠倒元素输出顺序。
Stack<Transaction> stack = new Stack<Transaction>();
while(!pq.isEmpty()){
stack.push(pq.deleteMin());
}
for(Transaction t:stack){
StdOut.println(t);
}
}
}


通过这段代码我们可以得到优先队列执行过程中insert()、deleteMin()方法的调用以及调用次数,它分为两个阶段:

当集合里面元素个数小于(M+1)时,共计调用M次insert()方法插入元素。

从第(M+1)个元素开始,对于每一个元素,均要调用一次insert()方法和一次deleteMin()方法,一共有(N-M)个元素是这样被处理的。

以上这两点是我们后续对优先队列进行时间复杂度分析的依据。另外上面的这段代码insert()方法和deleteMin()方法,没有给出具体实现,这很正常,我们都还没有选择数据结构,哪里来的具体实现,我们接下来就要讨论这两个方法的具体实现。

初级实现的优先队列及其算法分析

实现方式

所谓的实现优先队列,其实就是选择合适的数据结构实现insert()、deleteMin()两个方法而已,有四种很简单的实现优先队列的方式:

无序数组

有序数组

无序链表

有序链表

这里笔者不打算逐一分析,毕竟这些初级实现方式不是本文重点,我们就挑有序数组作为代表,分析、计算一下它的复杂度。首先描述一下有序数组如何实现insert()方法和deleteMin()方法:

对于insert()方法,每次插入一个元素,就类似于插入排序的过程,在数组末尾插入元素,并将它放到合适位置,使得数组里的元素保持递减顺序,如果你还不了解插入排序,可以看下笔者之前关于插入排序的简单介绍: 一步步地分析排序——插入排序

对于deleteMin()方法,就是直接在数组末尾删除元素。

接下来计算一下空间、时间复杂度。

空间复杂度

其实不论使用上述四种方式里的哪种,空间复杂度都是(M+1)其实就是O(M),直接由我们的数学抽象就能得到(数学抽象约定了集合里面只会装M+1个元素),不需要什么计算。

时间复杂度

根据我们前面得到的“优先队列执行过程中insert()、deleteMin()方法的调用以及调用次数”,加上有序数组实现insert()、deleteMin()方法的操作,就能得到时间复杂度的计算过程。由于计算时间复杂度都是以最劣情况下的输入为标准的,那就约定元素是按照递增序输入的,过程如下:

对于前面M个元素,相当于对M个元素执行插入排序,耗时:M²。

从第(M+1)个元素开始,每个元素都要从数组末尾移动到数组开头,交换次数为M次,共有(N-M)个元素需要完成从数组末尾移动到开头,耗时:(N-M)*M(在数组末尾删除元素时间为O(1),不参与计算)。

对以上两个算式求和:M²+(N-M)*M = NM。

对比传统解决思路

让我们做一下对比吧,书本里面有一张表,大概是这样:

示例时间复杂度空间复杂度
传统的解决方式(不用优先队列)NlogNN
初级实现的优先队列NMM
基于堆实现的优先队列NlogMM
第三行我们接下来会讲,先来看看前面两行:

1. 如果M很大的话(尤其是M趋向于N),初级实现的优先队列似乎还大不如传统的排序方式,所以优先队列也不可以滥用。

2. 当我们需要使用优先队列的时候,一般都是面对着N很大(趋于无穷,或者至少不能全部读进内存),M比较小的情况,此时不论在时间还是空间上,都是优先队列表现比较好。

从传统的实现方式到优先队列的实现,我们已经对空间复杂度进行了优化,接下来看看怎么优化时间复杂度,来进入我们的下一个主题——二叉堆实现优先队列。

二叉堆及其相关概念

堆有序以及二叉堆

先来看看堆有序的概念:

当一棵二叉树的每个结点都大于等于他的两个子结点时,它被称为堆有序。——《算法》第四版

再来看看二叉堆的概念:

二叉堆是一组能够用堆有序的完全二叉树排序的元素,并在数组中按照层级存储(不使用数组的第一个位置)。——《算法》第四版

其实简单点来说:二叉堆就是将一棵堆有序的完全二叉树,按照层级顺序存放到数组里,另外有一点很重要,不使用数组的第一个位置,即下标0是不使用的,这在牵扯到计算问题的时候,非常重要。二叉堆具备两个特性:

结构性:在逻辑上,它是一棵完全二叉树。

堆序性:二叉树里的每个结点都大于等于(或小于等于)它的(一个或两个)子结点。

大顶堆和小顶堆

在堆有序的性质里,如果二叉树里的每个结点都大于等于它的子结点,那么这个二叉堆就叫大顶堆,如果每个结点都小于等于它的子结点,就叫做小顶堆。大顶堆对应实现deleteMax()方法,小顶堆对应实现deleteMin()方法。

现在来张图片感性地认识一下二叉堆:



A认为是最小的,Z认为是最大的,按照这样的前提,这张图片就是一张大顶堆的逻辑结构展示图,注意了这只是逻辑结构,二叉堆的存储结构是个数组。虽然我们是基于完全二叉树的性质来讨论二叉堆的算法特性,但是你还是应该知道,我们处理的实际上是个数组

结点间数学关系

按照我们前面说的,二叉堆的第一个元素,存放在数组下标为1的位置,则二叉堆的结点间有如下数学关系:

在一个二叉堆里,位置k的结点的父结点位置为(k/2),计算结果向下取整。

位置k的结点的两个子结点的位置为(2k)、(2k+1)。

以上的数学关系是按照层级顺序存储的完全二叉树的特性,不是二叉堆专有的的特性,至于这两个特性怎么来的,本文就不论述了,有兴趣的可以自己查阅二叉树的相关资料。。

二叉堆的相关算法

堆有序化的概念

二叉堆的主要算法包括上浮算法、下沉算法,它们都是为了实现堆有序化而设计的。那么什么是堆有序化,这就要从二叉堆实现优先队列的insert()方法和deleteMax()方法说起了(这里我们还要用前面那张用字母表示二叉堆的图,所以对应大顶堆,实现deleteMax()方法)

insert()方法

如下图所示



假设原来有一个二叉堆,如左边所示,共有10个元素(不包括X),这是一个符合堆有序性的二叉堆。现在在堆的结尾新插入一个X,导致二叉堆新的状态不符合堆有序性,那么该如何处理?处理方式就是图中红色箭头所示,将新的元素一直“上浮”,直到遇到比它更大的父结点,否则,新元素就成为新的根结点,上浮算法结束后,堆的状态如右图所示。

deleteMax()方法

如下图所示



假设原来有一个二叉堆,如左边所示,现在调用deleteMax()方法,根据二叉堆的性质,应该删除掉的就是根结点元素,那么剩下的元素该怎么办呢?如中间那幅图所示,先将最后面的元素,直接移动到根结点,然后再逐层下沉,注意每次下沉的时候,都必须和它的两个子结点里面较大的那个替换位置,直到遇到它的两个子结点都比它小,或者它已经成为了叶子结点(没有子结点)为止。左图所示的二叉堆,调用deleteMax()方法删除一个元素后,处理结果如右图所示。

好了,经过以上两幅图的讲解,已经理清楚上浮、下沉算法的过程了,我们可以开始学习代码和算法分析了。

上浮——由下至上的堆有序化

代码

/*方法说明:“上浮”算法要实现的,是对位置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;
}
}


时间复杂度分析

观察代码可以看出,less(int p, int q)方法执行次数肯定不少于 exchange(int p, int q)方法,所以我们以对数组里的元素进行“比较操作”的次数表示算法的耗时。

循环判断条件每成立一次,新插入的元素就会往上浮一层,即less(int p, int q)方法每成立一次,元素就会往上浮一层,即less(int p, int q)方法被调用的次数,或者等于元素向上浮的次数,或者等于元素往上浮的次数+1。

假设新插入的元素位置为k,根据完全二叉树的性质,位置k的元素距离根结点的路径长为lgk(以2为底k的对数,路径的概念请查阅二叉树相关性质),最劣的情况是新插入的元素比原来所有的元素都大,则新元素需要上浮lgk层,假设插入前原来已有的元素个数为N个,如果元素N和元素k在同一层,则lgk == lgN,否则,lgk == (lgN)+1。

结论:向一个已经含有N个元素的优先队列里面,插入一个元素,最糟糕的情况下面,需要执行lgN+1次比较,如下图所示为最劣情况:



下沉——由上至下的堆有序化

代码

/*方法说明:“下沉”算法要实现的,是对位置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;
}
}


时间复杂度分析

类似上浮操作,通过代码可以看出,less(int p, int q)方法执行次数肯定 不少于 exchange(int p, int q)方法执行次数,所以我们还是以对数组里的元素进行“比较操作”的次数表示算法的耗时。

循环每完整执行一次,元素就会被下沉一层,比较操作就会被执行1至2次(其实只要某个位置k有两个子结点,比较操作就会被执行两次,只有当位置k只有一个子结点,才存在执行一次比较操作的情况)。

假设二叉堆原有元素个数N,最劣的情况是,从根结点开始,将根结点元素下沉至堆底(即下沉lgN层),而且路径上所有的结点,都有两个子结点(即每一层需要进行两次比较)。

结论:从一个已经含有N个元素的优先队列里面,删除最大的元素后,进行下沉操作需要进行(2lgN)次比较操作。如下图所示:



二叉堆实现优先队列及其算法分析

二叉堆实现优先队列

文章到了这里,我们已经知道了二叉堆的相关概念、算法,插入元素的流程、删除元素的流程,单次插入、删除元素的时间复杂度。现在我们来看一下二叉堆实现优先队列的算法。其实就是直接用了书本上的,但是只写了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;
}


如果你已经理解了“上浮”、“下沉”的过程,上面的这两个函数应该都不难读懂,前面我们已经讨论了单次插入、删除元素的时间复杂度,现在是时候来看一下使用二叉堆实现“从N个元素里面找到最大M个元素”这个完整过程的时间复杂度。注意一下,如果你要找到最大的M个元素,就要构建小顶堆 ,也就是实现deleteMin()方法,如果你要找到最小的M个元素,就要构造大顶堆,就要实现deleteMax()方法,现在我们重新回到文章开头最大M个元素的问题,所以对应小顶堆,实现deleteMin()方法

最优情况的算法分析

再次强调,以下讨论是基于小顶堆的,最优的情况就是输入刚好是递增序的,结合我们前面得出的“优先队列执行过程中insert()、deleteMin()方法的调用以及调用次数”,可以这样子计算:

对于前面的M次insert()方法,每一次插入,都不需要进行任何上浮操作(因为对于每一个新元素,它前面的元素一定都比它小)。每个元素都只是和它的父元素比较了一下,发现父元素比较小,然后insert()方法就结束了,那么一共是M次比较。

对于后面(N-M)次插入和删除,insert()方法仍然是没有进行任何的上浮操作,那就是(N-M)次比较操作,而对于deleteMin()方法,在删除了根结点后,它会将数组末尾的元素放到根结点,然后进行下沉操作。由于数组末尾的元素一定是数组里面所有元素最大的,所以它一定下沉到最后一层,由于deleteMin()方法会被调用(N-M)次,则调用deleteMin()方法所造成的比较总次数是(N-M)*2lgM。所以第二阶段发生比较的总次数是:(N-M)+(N-M)*2lgM。

前面两个阶段求和,我们就得到结果了:N+2*(N-M)*lgM = N+2NlgM-2MlgM < N+2NlgM = O(NlgM)。

最劣情况的算法分析

以下讨论是基于小顶堆的,重要的事情说三遍,最劣的情况是输入刚好是递减序的(恩,你没看错,如果最早来的M个元素刚好就是我们要的最大的M个元素,反而对应了算法最劣的情况),结合我们前面概括的优先队列的执行过程,可以这样子计算:

对于前面的M次insert()方法,每一次插入,都需要将它上浮到根结点(因为新输入的元素是最小的)。这里直接用单次插入的(lgN+1)这个结论来计算,不好化简,我们用另一个方法计算,如图:



规律基本就出来了,那么时间复杂度(比较总次数)的算式是:

1*2^1+2*2^2+3*2^3+4*2^4+….+i*2^i = ( (i-1)*2^(i+1) )+2

这是一个等差等比数列相乘求和公式,再根据二叉树的两个性质:由M个元素,共i层(根结点算作第0层)构成的满二叉树,M = (2^(i+1))-1,i = lgM(结果向下取整),化简之后的结果是:2MlgM-M+1

对于后面的(N-M)个元素,每个元素都要进行lgM次的比较(将它浮动到根结点),然后再进行2lgM次的比较(浮动到根结点之后又把它删掉)。合计3*(N-M)*lgM次比较。

上面两个阶段求和,结果就是:3NlgM-MlgM-M+1 <= 3NlgM = O(NlgM)。

关于计算结果的说明

强调一下,计算思路应该是没错的,O(NlgM)的结果看着也和书本对的上,不过化简之前的最初结果就不知道是不是完全正确,笔者数学功底有限,读者千万不要拿着这个当做标准答案

堆排序

至此,关于针对优先队列提出的需求、分析、数学抽象、API、不同解决方式间的比较已经基本结束了,现在我们来看下另一个主题——基于二叉堆实现的堆排序。

堆排序的过程说明

类似于优先队列的执行过程分为两个阶段(输入M个元素构造二叉堆,以及后面对(N-M)个元素进行插入和删除,重新复述一遍当做复习),通过二叉堆实现堆排序,整个过程也分为两个阶段:

堆的构造

下沉排序

下面我们将详细讨论各个阶段的具体内容。

堆的构造

如果你真的理解了二叉堆实现的优先队列,在这个基础上延伸到堆排序,其实是很容易的。我们之前一直说的都是用二叉堆实现“从N个元素里面找到最大的M个元素”,这样就能得到一个由M个元素组成的二叉堆。现在稍微改一改这句话,改成“从N个元素里面找到最大的N个元素”,我们就能得到一个由N个元素组成的二叉堆,这个就是堆排序的第一阶段——“堆的构造”的阶段。

堆的构造有两个方式:

上浮构造:通过swim()方法从左往右扫描数组,已扫描过的地方就会形成一个二叉堆,这个过程和前面分析的使用二叉堆实现优先队列时,将元素一个个地插入二叉堆的过程是完全一样,除了N和M的个数不同,其它地方真的是完全一样。我们这里就不重复去分析了,主要的是看一下另一个方式。

下沉构造:在分析优先队列的时候,由于我们假设了N是无穷大的,所以不可以全部装入数组里,这迫使我们必须从左到右一个个地读入元素。然而现在我们分析的是排序,既然要全部排序自然是可以将N个元素全部装到数组里面,此时我们可以不用从左到右一个个地读取元素。通过sink()方法从数组最后一个元素的父结点开始,从右往左扫描数组,对每一个扫描过的结点执行下沉操作。咋一看似乎挺奇怪的,来看个图片就很清晰了,假设现在要通过下沉操作构造一个根结点是最小元素的二叉堆:



其实也挺简单的,就是从二叉堆最后一个元素的父元素开始(即第N/2个元素),对每一个元素执行下沉操作,同时下标每次左移一位(减一),这样就能通过下沉操作实现自下而上的堆有序化。

过程已经理清楚了,现在我们来算一下下沉构造的时间复杂度,我们考虑数量N刚好构成一棵满二叉树,同时最劣的情况是,每次执行下沉操作,被选中的那个元素都会下沉到堆底(如果我们要建立的是小顶堆,输入的数组是递减序的,就刚好对应了这种情况),看图:



根据规律“叶子结点高度算作0,对于高为h的树,在高为k的那一层,有2^(h-k)个结点,每个结点需要下沉k层”,现在我们可以列算式了:

1*2^(h-1)+2*2^(h-2)+3*2^(h-3)+….+(h-1)*2^1+h*2^0 = -h-2+2^(h+1)

由于高为h的满二叉树结点数量N = -1+2^(h+1),我们带进去化简一下,可以得到结果是:

N-h-1 < N = O(N)

这是发生互换的次数,比较的次数最多是互换次数的两倍,所以比较的次数是:

2N = O(N)。

所以我们的结论是:通过下沉操作自下而上地构造堆,需要进行不超过2N次比较以及N次互换

两个构造方式对比:上浮操作构造堆的时间复杂度等价于前面分析二叉堆实现优先队列的第一阶段,只要将M换成N,我们就能得到结果:O(NlogN),好了结果出来了,上浮操作构造堆的时间复杂度是O(NlgN),下沉操作构造堆的时间复杂度是O(N),所以下沉操作构造堆是更优秀的方式

下沉排序

下沉排序:如果对已经构造好的二叉堆连续调用N次deleteMin()方法,并且将deleteMin()方法每次返回的元素输出,输出的结果就是一个递增排序的序列。但是事情还没结束,因为合理的做法不应该是将堆有序的数组里的元素逐个输出,而是将堆有序的数组原地变成一个线性有序的数组,这就是“下沉排序”的阶段,我们来看一张图就很清晰了。



简单地讲就是不断将根结点元素和堆末尾的元素互换位置,互换之后二叉堆计数N减一,然后对新换上来的根结点进行下沉操作。看图“对应数组”中红色部分,它们会一点点从数组末尾开始形成一个递减序列。最终得到一个按照递减序排序的数组。

至此,堆排序的过程已经说清楚了。

总结

本文从一个实际问题开始,经历了从提出问题、分析问题、使用数学集合抽象问题、得出API、传统解决思路、优先队列解决思路这一系列的过程,完成了从传统排序到优先队列,再到二叉堆,最后延伸到堆排序的逐步过渡。

关于时间复杂度的计算,本文记录了书本对于单次插入、删除操作的复杂度计算,同时笔者根据自己的思考理解,拓展了全部元素完整插入、删除操作的复杂度计算过程。部分计算由于书本只有结果,没有过程,所以笔者的思路也没有可以参考的答案,请读者不要作为标准答案,仅做参考。

《算法》这本书优先队列这一章节真的是非常值得学习以及仔细思考的,因为涉及了基础排序算法,二叉树相关性质,还有对于同一问题不同解决方式间的思考对比,对书本前面知识点的总结以及思维的提高非常有好处。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
相关文章推荐