数据结构与算法之堆排序

1、基本思想

堆排序是一种树形选择排序,是对直接选择排序的改进。

首先,我们来看看什么是堆(heap):

  • 堆中某个节点的值总是不大于或不小于其父节点的值;
  • 堆总是一棵完全二叉树(Complete Binary Tree)。

完全二叉树是由满二叉树(Full Binary Tree)而引出来的。

除最后一层无任何子节点外,每一层上的所有结点都有两个子结点的二叉树称为满二叉树

如果除最后一层外,每一层上的节点数均达到最大值;在最后一层上只缺少右边的若干结点,这样的二叉树被称为完全二叉树

1.png

一棵完全二叉树,如果某个节点的值总是不大于其父节点的值,则根节点的关键字是所有节点关键字中最小的,称为小根堆(小顶堆);如果某个节点的值总是不小于其父节点的值,则根节点的关键字是所有节点关键字中最大的,称为大根堆(大顶堆)。

从根节点开始,按照每层从左到右的顺序对堆的节点进行编号:

2.png

可以发现,如果某个节点的编号为i,则它的子节点的编号分别为:2i、2i+1。据此,推出堆的数学定义:
具有n个元素的序列(k1,k2,...,kn),当且仅当满足

3.png

时称之为堆。

需要注意的是,堆只对父子节点做了约束,并没有对兄弟节点做任何约束,左子节点与右子节点没有必然的大小关系

如果用数组存储堆中的数据,逻辑结构与存储结构如下:

4.png

初始时把要排序的n个数看作是一棵顺序存储的完全二叉树,调整它们的存储顺序,使之成为一个堆,将堆顶元素输出,得到n 个元素中最小(最大)的元素,这时堆的根节点的数最小(或者最大)。然后对前面(n-1)个元素重新调整使之成为堆,输出堆顶元素,得到n 个元素中次小(或次大)的元素。依次类推,直到只有两个节点的堆,并对它们作交换,最后得到有n个节点的有序序列。这个过程就称为堆排序。

2、堆的构建

写代码之前,我们要解决一个问题:如何将一个不是堆的完全二叉树调整为堆。

例如我们要将这样一个无序序列:

49,38,65,97,76,13,27,49

建成堆,将它直接映射成二叉树,结果如下图的(a):

5.png

(a)是一个完全二叉树,但不是堆。我们将它调整为小顶堆。

堆有一个性质是:堆的每个子树也是堆

调整的核心思想就是让树的每棵子树都成为堆,以某节点与它的左子节点、右子节点为操作单位,将三者中最小的元素置于子树的根上。

(a)中最后一个元素是49,在树中的序号为8,对应的数组下标则为7,它的父节点对应的数组下标为3(如果一个元素对应的存储数组的下标为i,则它的父节点对应的存储数组的下标为(i-1)/2),49小于97,所以两者交换位置。

此时,以第三层元素为根节点的所有子树都已是堆了,下一步继续调整以第二层元素为根节点的子树。

先调整以65为根的子树,再调整以38为根的子树(满足堆的要求,实际上不用调整)。

然后调整以第一层元素为根的子树,即以49为根,以38为左子节点,以13为右子节点的子树,交换13与49的位置。
一旦交换位置,就有可能影响本来已经是堆的子树。13与49交换位置之后,破坏了右子树,将焦点转移到49上面来,继续调整以它为根节点的子树。如果此次调整又影响了下一层的子树,继续调整,直至叶子节点。

以上就是由数组建堆的过程。

3、实例

堆建好之后开始排序,堆顶就是最小值,取出放入数组中的最后一个位置,将堆底(数组中的最后一个元素)放入堆顶。这一操作会破坏堆,需要将前n-1个元素调整成堆。

然后再取出堆顶,放入数组的倒数第二个位置,堆底(数组中的倒数第二个元素)放入堆顶,再将前n-2个元素调整成堆。

按照上面的思路循环操作,最终就会将数组中的元素按降序的顺序排列完毕。

如果想要升序排列,利用大顶堆进行类似的操作即可。下面的java实现就是使用大顶堆完成的。

    // 堆排序
    public void heapSort() {

        buildHeap();
        System.out.println("建堆:");
        printTree(array.length);

        int lastIndex = array.length - 1;
        while (lastIndex > 0) {
            swap(0, lastIndex); 
            // 取出堆顶元素,将堆底放入堆顶。其实就是交换下标为0与lastIndex的数据
            if (--lastIndex == 0)
                break; // 只有一个元素时就不用调整堆了,排序结束
            adjustHeap(0, lastIndex); // 调整堆

            System.out.println("调整堆:");
            printTree(lastIndex + 1);
        }

    }

    /**
     * 
     * - 用数组中的元素建堆
     */
    private void buildHeap() {
        int lastIndex = array.length - 1;
        for (int i = (lastIndex - 1) / 2; i >= 0; i--) { 
            // (lastIndex-1)/2就是最后一个元素的根节点的下标,依次调整每棵子树
            adjustHeap(i, lastIndex); // 调整以下标i的元素为根的子树
            // System.out.println("调整以下标"+i+"的元素为根的子树:");
            // printTree(lastIndex+1);

        }
    }

    /**
     * 
     * - 调整以下标是rootIndex的元素为根的子树
     * 
     * - @param rootIndex 根的下标
     * 
     * - @param lastIndex 堆中最后一个元素的下标
     */
    private void adjustHeap(int rootIndex, int lastIndex) {

        int biggerIndex = rootIndex;
        int leftChildIndex = 2 * rootIndex + 1;
        int rightChildIndex = 2 * rootIndex + 2;

        if (rightChildIndex <= lastIndex) { // 存在右子节点,则必存在左子节点

            if (array[rootIndex] < array[leftChildIndex] || array[rootIndex] < array[rightChildIndex]) { // 子节点中存在比根更大的元素
                biggerIndex = array[leftChildIndex] < array[rightChildIndex] ? rightChildIndex : leftChildIndex;
            }

        } else if (leftChildIndex <= lastIndex) { // 只存在左子节点

            if (array[leftChildIndex] > array[rootIndex]) { // 左子节点更大
                biggerIndex = leftChildIndex;
            }
        }

        if (biggerIndex != rootIndex) { // 找到了比根更大的子节点

            swap(rootIndex, biggerIndex);

            // 交换位置后可能会破坏子树,将焦点转向交换了位置的子节点,调整以它为根的子树
            adjustHeap(biggerIndex, lastIndex);
        }
    }

    /**
     * 
     * - 将数组按照完全二叉树的形式打印出来
     */
    private void printTree(int len) {

        // int len = array.length;
        int layers = (int) Math.floor(Math.log((double) len) / Math.log((double) 2)) + 1; // 树的层数
        int maxWidth = (int) Math.pow(2, layers) - 1; // 树的最大宽度
        int endSpacing = maxWidth;
        int spacing;
        int numberOfThisLayer;
        for (int i = 1; i <= layers; i++) { // 从第一层开始,逐层打印
            endSpacing = endSpacing / 2; // 每层打印之前需要打印的空格数
            spacing = 2 * endSpacing + 1; // 元素之间应该打印的空格数
            numberOfThisLayer = (int) Math.pow(2, i - 1); // 该层要打印的元素总数

            int j;
            for (j = 0; j < endSpacing; j++) {
                System.out.print("  ");
            }

            int beginIndex = (int) Math.pow(2, i - 1) - 1; // 该层第一个元素对应的数组下标
            for (j = 1; j <= numberOfThisLayer; j++) {
                System.out.print(array[beginIndex++] + " ");
                for (int k = 0; k < spacing; k++) { // 打印元素之间的空格
                    System.out.print("  ");
                }
                if (beginIndex == len) { // 已打印到最后一个元素
                    break;
                }
            }

            System.out.println();
        }
        System.out.println();
    }

用一下代码测试:

    int [] a = {7,1,9,2,5,10,6,4,3,8};
    Sort sort = new Sort(a);
    
    System.out.println("未排序时:");
    sort.display();
    System.out.println();
    
    sort.heapSort();
    System.out.println("排序完成:");
    sort.display();

打印结果如下:

6.png

4、算法分析

它的运行时间主要是消耗在初始构建堆和在重建堆时的反复筛选上。

在构建堆的过程中,因为我们是完全二叉树从最下层最右边的非终端结点开始构建,将它与其孩子进行比较和若有必要的互换,对于每个非终端结点来说,其实最多进行两次比较和互换操作,因此整个构建堆的时间复杂度为O(n)。

在正式排序时,第i次取堆顶记录重建堆需要用O(logi)的时间(完全二叉树的某个结点到根结点的距离为log2i+1),并且需要取n-1次堆顶记录,因此,重建堆的时间复杂度为O(nlogn)。

所以总体来说,堆排序的时间复杂度为O(nlogn)。由于堆排序对原始记录的排序状态并不敏感,因此它无论是最好、最坏和平均时间复杂度均为O(nlogn)。这在性能上显然要远远好过于冒泡、简单选择、直接插入的O(n2)的时间复杂度了。

空间复杂度上,它只有一个用来交换的暂存单元,也非常的不错。不过由于记录的比较与交换是跳跃式进行,因此堆排序是一种不稳定的排序方法。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 219,188评论 6 508
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,464评论 3 395
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 165,562评论 0 356
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,893评论 1 295
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,917评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,708评论 1 305
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,430评论 3 420
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,342评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,801评论 1 317
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,976评论 3 337
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,115评论 1 351
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,804评论 5 346
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,458评论 3 331
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 32,008评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,135评论 1 272
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,365评论 3 373
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 45,055评论 2 355

推荐阅读更多精彩内容