深入浅出数据结构C语言版(19)——堆排序

  在介绍优先队列的博文中,我们提到了数据结构二叉堆,并且说明了二叉堆的一个特殊用途——排序,同时给出了其时间复杂度O(N*logN)。这个时间界是目前我们看到最好的(使用Sedgewick序列的希尔排序时间复杂度为O(N4/3),下图为两者函数图像对比,但是注意,这并不是希尔排序与堆排序的对比,只是两个大O阶函数的对比)。这篇博文,我们就是要细化用二叉堆进行排序的想法,实现堆排序。

       

  在介绍优先队列的博文中(http://www.cnblogs.com/mm93/p/7481782.html)所提到的用二叉堆排序的想法可以简单地用如下代码表示:

void HeapSort(int *src,int size)
{
        //BuildHeap即根据所给数组建立一个二叉堆并返回
    struct BinaryHeap *h = BuildHeap(a, size);

        //有了二叉堆后,只需不断DeleteMax得到根结点,然后输出到目标数组即可
        //此循环结束后,src数组中就有了从小到大的顺序
    for (int i = size-1;i >=0 ;--i)
    {
        src[i] = DeleteMax(h);
    }
}

  虽然介绍优先队列的博文中没有BuildHeap和DeleteRoot函数,但学会了二叉堆的话,这两个函数不难写出,BuildHeap其实就是Initialize函数与Insert函数的结合,而DeleteMax也和Dequeue思路相同,即删除并返回堆的根,前提是建立的堆满足任一结点均大于其孩子,即Max型堆,与介绍二叉堆时实现的Min型堆恰好相反。

  至此,堆排序的实现就算是完成了,但是不难发现上述实现方法有一个缺陷,就是原数组src占用了空间N,建立的堆h又占用了空间N,也就是说该实现耗费的空间是插入排序、希尔排序的两倍。那么是否存在解决这个空间问题的办法呢?答案是有,解决的办法就是:直接将原数组src改成一个二叉堆,而后每次DeleteMax,将所得原堆根放置在原堆尾,size次DeleteMax后src就会变为从小到大的顺序(执行DeleteMax后,原堆尾对于堆来说就是“废弃”的,可以用于存储“删掉的根”。希望最后顺序为有小到大是我们将DeleteMin改为DeleteMax的原因,如果需要从大到小的顺序,则应为DeleteMin)

  上述解决办法中最难的一环可能就是“将src改为一个二叉堆”。BuildHeap的实现简单,只需要建立一个足够大的空堆,而后不断将数据Insert即可,而Insert的思路就是“将新元素从堆尾开始进行上滤”。那么Insert的这个思路是否可以用于将数组直接改造为二叉堆呢?比如先让src[size-1]上滤,然后src[size-2]上滤……答案是不行!因为Insert的上滤前提是向“已存在的堆”插入数据,“已存在的堆”要么为空,要么处处符合堆的要求。而src数组是“一片乱”的,这个想法是不行的。

  阐述正确做法之前,我们先要明确一个不容易注意到的点:在优先队列中,堆可以舍弃掉数组[0]的位置,这样可以使编程更加方便,即任一数组[i]的孩子就是数组[i*2]和数组[i*2+1],而数组[i]的父亲则是数组[i/2]。但是如果是将src直接改造为二叉堆,则不能舍弃src[0],因为我们认为src应是满的数组。因此,将src改造为二叉堆后,任一src[i]的孩子应为src[i*2+1]与src[i*2+2],而src[i]的父亲则应为src[(i-1)/2]

  接下来我们说说将src直接改造为二叉堆的方法:令i=(size-1)/2,即src[i]为src[size-1]的父亲,然后令src[i]下滤,src[i]下滤结束后,令i--,重复此过程直至i=-1。

  上述方法之所以可行,是因为按i初始值为(size-1)/2,而后i--的顺序执行下滤的话,每个以src[i]为根的堆都只有src[i]是不符合堆要求的,此时只需要让src[i]下滤即可,根本思路与DeleteRoot的下滤是一样的。

  这个方法实现起来非常简单:

//cur即当前进行下滤的元素的下标,FilterDown即下滤之意
void FilterDown(int *src, int cur, unsigned int size)
{
    //先暂存下滤元素的值,避免实际交换
    int temp = src[cur];
    unsigned int child;

    //child初始值为src[cur]左孩子下标
    for (child = cur * 2 + 1;child < size;child = child * 2 + 1)
    {
        //若src[cur]存在右孩子,且右孩子比左孩子大,则令child为右孩子下标,即令child为src[cur]更大的孩子的下标
        if (child < size - 1 && src[child] < src[child + 1])
            child++;
        //比较下滤元素与src[child],若小于,则令src[child]上移,否则下滤结束
        if (temp < src[child])
            src[(child - 1) / 2] = src[child];
        else
            break;
    }
    //下滤结束后的child对应的父亲即下滤元素应处的位置
    src[(child - 1) / 2] = temp;
}

void TransformToHeap(int *src, unsigned int size)
{
    for (int i = (size - 1) / 2;i >= 0;--i)
        FilterDown(src, i, size);
}

  解决了最难的改造二叉堆后,堆排序的剩余操作也就不难实现了:

void HeapSort(int *src, unsigned int size)
{
    TransformToHeap(src, size);

    //不断地将堆的根与堆的尾(最后一个叶子)交换,交换后新的堆根为原堆尾,令新堆根下滤。
    //此操作与堆的DeleteRoot本质相同,只是将所得原堆根放在了原堆尾处,从而利用了废弃空间
    for (int oldTail = size - 1;oldTail > 0;--oldTail)
    {
        int temp = src[0];
        src[0] = src[oldTail];
        src[oldTail] = temp;
        FilterDown(src, 0, oldTail - 1);
    }
}

  至此,堆排序算是改善好了。接下来要讨论的问题就是,为什么堆排序时间复杂度那么好,却不如快速排序?(快速排序最坏情况为O(N2),平均为θ(N*logN))

  这个问题很难解答,因为随着DeleteMax操作,堆的内部结构一直是不稳定的。但我们可以分成三个方面来试着解释一下。

  第一,我们要明白大O阶只是一个简写的时间界,即使是1000000000*N*logN+100000000000,我们依然是写作O(N*logN),因此两个同为O(N*logN)的算法并不意味着两者时间上会很接近。套用到堆排序与快速排序中,就是堆排序虽然也是O(N*logN),但是其常数项比快速排序的平均界θ(N*logN)要大得多,大多少,我不知道╮(╯_╰)╭。

  第二,从计算机的底层来说,CPU与内存之间存在缓存,缓存一般存储着最近访问的数据所在的数据块,假设来说,因为我们访问了内存中的src[100],所以CPU将src[80]到src[120]都放入了缓存,这之后如果我们访问src[80]到src[120]之间的数据就会很快,因为它们在缓存之中。但是,堆排序中相邻操作所访问的数据“距离太远了”,比如我们访问了src[100]后要访问其孩子进行比较,则我们需要访问src[201]或src[202],而它们很可能不在缓存中,因此对它们的访问会比访问缓存中的数据更慢,并且我们访问其孩子后,并不一定会与父结点进行交换,如果是这样,那此次访问就可以说是“花了大代价确定了这件事不需要做”。而在快速排序中相邻的两次访问一般是相邻的,进行远距离访问时都是需要进行交换操作的时候,也就是说快速排序可以比堆排序更好的利用CPU缓存

  第三,在堆排序中,DeleteMax函数的无效比较与无效交换比例很高,怎么说呢?因为我们在拿走原堆根后,是拿原堆尾到根处,然后进行下滤的,但是直观的说,原堆尾作为“堆中较小元素”,其比原堆根的孩子要大的概率是很低的,也就是说原堆尾拿到根处几乎不用比就知道要下滤,然而我们还是得进行比较、交换。从这个角度来说,将原堆尾拿到根处下滤是做了很多无效工作的,但这又是不得不为之的,因为我们必须得保持堆的完全二叉树性质。也就是说,为了保持堆的特性,我们做了不少额外的操作。

  关于第三点,我们可以看看介绍优先队列的博文中关于堆删除操作的例子,不难看出,将原堆尾元素31从根处进行下滤,最后其还是下滤到了原有深度:

  

  最后,对大小为10000,元素随机的数组进行模拟测试显示,快速排序执行的交换操作次数比堆排序要少很多很多:

    

  

  不过,虽然我们将堆排序“狠狠地”批判了一番,其时间界依然是不错的,毕竟最坏情况也就是O(N*logN),当然,这个最坏情况恐怕也是个平均情况(注意,这一点并没有被证明),因为在实际使用中,面对大量数据时堆排序往往是远不如快速排序的。此外,据称堆排序的实际效果甚至不如使用Sedgewick序列的希尔排序。基于上述种种原因,一般来说,我们还是按照介绍希尔排序的博文中所说的:将插入排序作为“初级排序”,希尔排序作为“中级排序”,快速排序作为“高级排序”。

  那么,作为“高级排序”的快速排序究竟是怎样的呢?我们下一篇博文将会介绍。

时间: 2024-10-27 10:54:18

深入浅出数据结构C语言版(19)——堆排序的相关文章

深入浅出数据结构C语言版(8)——后缀表达式、栈与四则运算计算器

在深入浅出数据结构(7)的末尾,我们提到了栈可以用于实现计算器,并且我们给出了存储表达式的数据结构(结构体及该结构体组成的数组),如下: //SIZE用于多个场合,如栈的大小.表达式数组的大小 #define SIZE 1000 //表达式的单个元素所使用的结构体 typedef struct elem { int num = 0; //若元素存储操作数则num为该操作数 char oper = '='; //若元素存储操作符则oper为该操作符 bool IsNum = false; //用于

深入浅出数据结构C语言版(4)——表与链表

在我们谈论本文具体内容之前,我们首先要说明一些事情.在现实生活中我们所说的"表"往往是二维的,比如课程表,就有行和列,成绩表也是有行和列.但是在数据结构,或者说我们本文讨论的范围内,我们所说的"表"是一维的,即所有"元素"都是前后排列的.就我个人而言,这样的"表"用"队列"来形容比较恰当.但是,数据结构中"队列"这个名词是被一种特殊的"表"给占用了的,所以我们没法再用

深入浅出数据结构C语言版(9)——多重表(广义表)

在深入浅出数据结构系列前面的文章中,我们一直在讨论的表其实是"线性表",其形式如下: 由a1,a2,a3,--a(n-1)个元素组成的序列,其中每一个元素ai(0<i<n)都是一个"原子","原子"的意思就是说元素本身是一个个体,所有元素都是相同的结构. 但是在我们常见的某些应用,比如Excel的表格中,我们发现表并不一定是线性表,Excel中的表就明显是二维的结构 那么在数据结构中,我们会使用这种广义上的表吗?答案是会,我们也会.或

深入浅出数据结构C语言版(5)——链表的操作

上一次我们从什么是表一直讲到了链表该怎么实现的想法上:http://www.cnblogs.com/mm93/p/6574912.html 而这一次我们就要实现所说的承诺,即实现链表应有的操作(至于游标数组--我决定还是给它单独写个博文比较好~). 那么,我们的过程应该是怎么样的呢?首先当然是分析需要什么操作,然后再逐一思考该如何实现,最后再以代码的形式写出来. 不难发现,我们希望链表能支持的(基础,可以由此延伸)操作就是: 1.给出第n个元素 2.在第n个元素的后面插入一个元素(包含在最后一个

深入浅出数据结构C语言版(15)——优先队列(堆)

在普通队列中,元素出队的顺序是由元素入队时间决定的,也就是谁先入队,谁先出队.但是有时候我们希望有这样的一个队列:谁先入队不重要,重要的是谁的"优先级高",优先级越高越先出队.这样的数据结构我们称之为优先队列(priority queue),其常用于一些特殊应用,比如操作系统控制进程的调度程序. 那么,优先队列该如何实现呢?我们可以很快给出三种解决方案. 1.使用链表,插入操作选择直接插入到表头,时间复杂度为O(1),出队操作则遍历整个表,找到优先级最高者,返回并删除该结点,时间复杂度

深入浅出数据结构C语言版(12)——从二分查找到二叉树

在很多有关数据结构和算法的书籍或文章中,作者往往是介绍完了什么是树后就直入主题的谈什么是二叉树balabala的.但我今天决定不按这个套路来.我个人觉得,一个东西或者说一种技术存在总该有一定的道理,不是能解决某个问题,就是能改善解决某个问题的效率.如果能够先了解到存在的问题以及已存在的解决办法的不足,那么学习新的知识就更容易接受,也更容易理解. 万幸的是,二叉树的讲解是可以按照上述顺序来进行的.那么,今天在我们讨论二叉树之前,我们先来讨论一种情形.一种操作:假设现在有一个数组,数组中的数据按照某

深入浅出数据结构C语言版(14)——散列表

我们知道,由于二叉树的特性(完美情况下每次比较可以排除一半数据),对其进行查找算是比较快的了,时间复杂度为O(logN).但是,是否存在支持时间复杂度为常数级别的查找的数据结构呢?答案是存在,那就是散列表(hash table,又叫哈希表).散列表可以支持O(1)的插入,理想情况下可以支持O(1)的查找与删除. 散列表的基本思想很简单: 1.设计一个散列函数,其输入为数据的关键字,输出为散列值n(正整数),不同数据关键字必得出不同散列值n(即要求散列函数符合单射条件) 2.创建一个数组HashT

深入浅出数据结构C语言版(22)——排序决策树与桶式排序

在(17)中我们对排序算法进行了简单的分析,并得出了两个结论: 1.只进行相邻元素交换的排序算法时间复杂度为O(N2) 2.要想时间复杂度低于O(N2),算法必须进行远距离的元素交换 而今天,我们将对排序算法进行进一步的分析,这一次的分析将针对"使用比较进行排序"的排序算法,到目前为止我们所讨论过的所有排序算法都在此范畴内.所谓"使用比较进行排序",就是指这个算法实现排序靠的就是让元素互相比较,比如插入排序的元素与前一个元素比较,若反序则交换位置,再比如快速排序小于

深入浅出数据结构C语言版(17)——有关排序算法的分析

这一篇博文我们将讨论一些与排序算法有关的定理,这些定理将解释插入排序博文中提出的疑问(为什么冒泡排序与插入排序总是执行同样数量的交换操作,而选择排序不一定),同时为讲述高级排序算法做铺垫(高级排序为什么会更快). 在讨论相关定理之前,我们必须先掌握一个与顺序有关的概念:逆序数. 所谓逆序数,就是"逆序组合的个数",假设我们希望的顺序为从小到大(反之同理): 设有元素互异数列X0,X1,X2--Xn-1,(元素互异即数列中任取两数均不相等)从中任取两数作为组合(Xa,Xb),若a<