逻辑之美(5)_优先队列、二叉堆和堆排序

二叉堆其实就是一棵堆有序的二叉树

开篇

本篇文章主要讲什么

此文是排序算法系列文章的倒数第三篇,因此本文的主要意图还是讲排序算法,这次我们一起聊聊堆排序

在正式开始之前,我们先要花些篇幅聊两种很重要的基础数据结构——优先队列二叉堆

正文

优先队列(PriorityQueue)

有时我们需要处理一组有序数据时,并不需要它们整体有序。设想这样一种情况,对于一组数据,每次我们都只处理其中键值最大的元素,我们可能会把这组数据的规模扩大,往里面放更多新元素,但仍是每次挑键值最大的元素来处理。对于这种情况我们当然可以使整组数据一直保持整体有序,但这不是必要的——我们只需保证第一个元素始终是键值最大那个就行。

这就需要引入了一种新的数据结构——优先队列,它应该高效地实现两种基本操作,访问最大元素和插入元素。

一种优先队列的经典实现方式就是使用二叉堆这种更低级点的数据结构。

二叉堆(BinaryHeap)

接着来详细了解下二叉堆(下简称堆)这种数据结构。

看到这个名字你是不是想到了二叉树?堆其实就是一棵堆有序的二叉树(二叉树这种更低级的数据结构非本文重点,此处略过不表,还请读者自行复习二叉树相关知识)。

何为堆有序?

当一颗二叉树的每个结点其键值都大于等于它的两个子结点时,我们称其是堆有序的。

也即在一颗堆有序的二叉树中,每个结点的键值都小于等于它的父结点。很容易确定,根结点是一棵堆有序的二叉树中键值最大的结点。

这是一个堆:

             11
         /        \
       9           10
   /     \      /     \
   5      6     7      8
  / \    / \
 1   2   3   4

具体我们如何在程序中表示一个堆呢?用数组即可。

就是将堆(二叉树)的结点按层级顺序依次放入数组中。为了方便后面算法的表示,我们不使用数组的第一个位置,即从数组下标为 1 的位置开始存储一个堆。把上面的堆放入数组中我们可以得到:

[-1,11, 9, 10, 5, 6, 7, 8, 1, 2, 3, 4]//把上面的二叉堆按层级顺序放入数组中,注意我们没使用数组第一个位置,我们把没使用到的数组位置置值为-1表示

这样一个数组。

这样表示的堆有几条重要性质:

  1. 位置(也即下标) n 的结点其父结点的位置为 n/2
  2. 位置(也即下标) n 的结点其两个子结点的位置为 2n 或 2n + 1(如果两个子结点都存在的话)
  3. 一颗大小为 N 的完全二叉树其高度为(lg N)

1 和 2 让我们可以不使用指针,仅通过计算数组的索引在树中上下移动,3 保证了我们实现的基本操作其算法仅具有对数级别的时间复杂度。

下面我们用 Java 代码来实现一个基于二叉堆的自然数优先队列:

/**
 *  基于二叉堆实现的正整数优先队列
 *  注意下面注释的表述可粗略认为优先队列等于二叉堆等于二叉树
 */

public class IntPQ {
    private int[] heap;//用于存放整个二叉堆(值)的数组,不使用数组第一个位置,即 heap[0] 我们永远也用不到

    private int size = 0;//堆当前的体积大小,二叉堆存储于数组 heap 的[1-size] 中,heap[0] 无用

    /**
     * <p>构造函数</p>
     * @param size:需要构造的优先队列的初始大小
     */
    public IntPQ(int size) {
        heap = new int[size + 1];
    }

    /**
     * <p>交换数组中两个位置的值</p>
     * @param i 待交换值的位置
     * @param j 待交换值的位置
     */
    private void exch(int i, int j){
        if (i < 1 || i >= heap.length || j < 1 || j >= heap.length){
            return;
        }
        int mid = heap[i];
        heap[i] = heap[j];
        heap[j] = mid;
    }

    /**
     *
     * @return 优先队列是否是空的
     */
    public boolean isEmpty(){
        return size == 0;
    }

    /**
     *
     * @return 优先队列当前大小
     */
    public int size(){
        return size;
    }

    /**
     * 注意下面两个方法极为重要,即二叉堆结点的跃迁和降级操作,
     * 什么意思呢?
     * 首先我们要知道数据结构的树和现实世界中的树有点不一样,数据结构的树树根在上树叶在下,现实世界反之,我们这里讨论数据结构的树
     * 所谓跃迁操作,就是这个树下面的某个结点的键值比它的父结点要大,那它肯定要跃迁到它父结点上面才能使整棵树堆有序
     * 反之,就是所谓的降级操作
     * 就是堆中某个结点不在它该在的位置,打破了二叉树的堆有序时,我们将其归位让二叉树重新堆有序的操作
     * 这两个方法是 insert 和 delMax 方法的基础*/

    /**
     * <p>二叉堆中指定位置结点的跃迁操作,如果此结点的值比它的父结点大,就和父结点交换位置,如此往复直至树的根部</p>
     * @param i 待跃迁结点位置
     */
    private void up(int i){
        while (i > 1 && heap[i] > heap[i/2]){//需要上浮的结点位置在根结点下面且值大于它的父结点
            //交换 i 和 i/2 两个位置的值
            exch(i, i/2);
            i /= 2;
        }
    }

    /**
     * <p>二叉堆中指定位置结点的降级操作,如果此结点的值比它的两个自结点中较大那个小,就和此子结点交换位置,如此往复直至树的叶子结点那层</p>
     * @param i 待跃迁结点位置
     */
    private void down(int i){
        while (i * 2 <= size){
            int j = i * 2;
            if (j < size && heap[j] < heap[j + 1]){
                j += 1;
            }
            if (heap[i] < heap[j]){
                //交换 i 和 j 两个位置的值
                exch(i, j);
                i = j;
            }else {
                break;
            }
        }
    }

    /**
     * <p>结点插入操作,注意这里我偷懒没写给数组扩容的逻辑,因这不是重点略过没写,读者可自行改进
     * 此方法具有对数级别的平均时间复杂度</p>
     * @param value 待插入的结点
     */
    public void insert(int value){
        //将结点插入当前二叉树的最后面,同时使当前二叉树的大小加一
        heap[++ size] = value;
        //新插入的结点可能会破坏二叉树的堆有序,然后对其执行跃迁操作,以让其归入正确位置,确保二叉树的堆有序
        up(size);
    }

    /**
     * <p>删除并返回队列中值最大的那个元素(其实就是当前树根),也就是树根结点,此方法具有对数级别的平均时间复杂度</p>
     * @return
     */
    public int delMax(){
        int max = heap[1];//从二叉树的根结点得到键值最大的元素

        //将根结点的最后一个叶子结点交换位置,并将树的大小减一,
        // 此时我们相当于把根结点删除了,但新的根结点键值不一定是最大的,
        // 所以我们需要降级归位,使二叉树重新堆有序
        exch(1, size --);
        down(1);//根结点降级归位,使二叉树重新堆有序
        return max;
    }
}

堆排序

经过上面那么多铺垫,一种新的排序算法已呼之欲出。

是滴,利用二叉堆这种数据结构,我们可以实现一种新的排序算法——堆排序。

终于说到堆排序了!你可以暂缓几分钟,接着我们来好好聊聊什么是堆排序,准备好~

堆排序的思路是这样的,利用优先队列(基于二叉堆)的两个基本操作(插入元素和删除最大元素),我们可以写出对数组原地排序的更高效算法,其最坏时间复杂度仅为线性对数级别的(n log n)。具体算法实现共分两步,构造堆和销毁堆。

  1. 构造堆:即先对数组进行原地调整,让整个数组堆化。基于堆元素的跃迁和降级操作,你或许会有多种思路来将一个数组堆化,这里我们使用一种最经典高效的方法来将一个数组堆化。即使用下沉操作遍历树中的所有非叶子结点,递归地给数组构造出堆的秩序。为什么要这样来构造堆?因为这是对数组操作最少,最节省成本的堆构造方式。这个问题其实很有意思,值得好好思考。之所以只遍历所有非叶子结点,是因为我们可以跳过所有大小为 1 的子堆,因为大小为 1 的子堆(子树)已经是一个堆了(已经堆有序了),而如果一个结点的两个子结点已经是堆了,那在此结点上调用降级操作可将它们变成一个整体的堆,就是如此,递归地给数组建立起堆的秩序。这里有个问题,堆中的非叶子结点分布在数组中的什么位置区间?如果现在的数组规模为 n ,那答案就是从堆第一个结点的位置到数组下标 n/2,即[1, n/2],这点很容易自证。如果用来给数组原地排序的话,那堆在数组中存放时就不能跳过第一个位置了,这样当前堆所有非叶子结点所在区间就是[0, n/2 - 1]。
  2. 销毁堆:将数组构建出堆秩序后,我们已经得到了一个堆。再进一步,使数组达到整体有序,接下来要做的就是一个结点一个结点地销毁掉整个堆。你应该很容易想到,所用方法正是递归地删除当前二叉堆的树根,将其跟最后一个结点交换位置,然后堆的规模减一,此时新的根结点可能会破坏堆有序状态,我们对其进行降级操作使新的规模缩减的堆重新变成堆有序,如此递归,直至堆的规模缩减为一,此时整个数组达到整体有序,排序完成。

OK 捋完整体逻辑我们来撸一下代码:

/**
     * <p>堆排序的 Java 实现</p>
     * @param array: 待排序数组,我们采用原地排序
     */
    public static void sortHeap(int[] array){
        //第一步,构建堆
        int start = array.length/2 - 1;//构建堆的开始遍历下标,因为数组是从下标 0 开始存放堆的,所以减一,此下标在遍历中递减
        int bound = array.length - 1;//待操作子堆最后一个下标,也可看做当前堆长度,不过此长度从 0 开始计
        for (; start >= 0; start --){
            down(array, start, bound);
        }
        //第二步,销毁堆,这个过程跟选择排序有点类似
        while (bound > 0){
            exch(array, 0, bound);
            down(array, 0, -- bound);
        }
    }

    /**
     * <p>交换数组中两个位置的值</p>
     * @param i 待交换值的位置
     * @param j 待交换值的位置
     */
    private static void exch(int[] array, int i, int j){
        if (i < 0 || i >= array.length || j < 0 || j >= array.length){
            return;
        }
        int mid = array[i];
        array[i] = array[j];
        array[j] = mid;
    }

    /**
     * <p>调整后的堆结点降级操作</p>
     * @param heap 待操作数组,存放堆
     * @param index 待降级操作的结点位置
     * @param bound 待操作结点所在子堆(此子堆根结点目前不在应在位置,我们对其根结点执行降级操作使其归位)的最后一个结点位置(下标)
     */
    private static void down(int[] heap, int index, int bound){
        //注意对比原始的 down 方法中操作下标的地方,这里多了很多 + 1 操作,原因就是我们从数组中第一个位置开始存放堆
        while (index * 2 + 1 <= bound){
            int j = index * 2 + 1;
            if (j < bound && heap[j] < heap[j + 1]){
                j += 1;
            }
            if (heap[index] < heap[j]){
                //交换 i 和 j 两个位置的值
                exch(heap, index, j);
                index = j;
            }else {
                break;
            }
        }
    }

代码主要看 sortHeap 方法。

我的天花这么多篇幅终于聊完本文主角堆排序了!

总结

堆排序的效率比 冒泡排序、选择排序、插入排序和希尔排序都要高,其最差时间复杂度仅为线性对数级别的O(n log n)。

尾巴

关于优先队列这种数据结构,它的应用场景可不止排序,本系列后续文章会再单独聊聊这种数据结构,你会看到更多优先队列大显身手的应用场景,敬请期待。

下篇,我们聊聊归并排序。

完。

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

推荐阅读更多精彩内容

  • 一些概念 数据结构就是研究数据的逻辑结构和物理结构以及它们之间相互关系,并对这种结构定义相应的运算,而且确保经过这...
    Winterfell_Z阅读 5,531评论 0 13
  • 数据结构与算法--优先队列和堆排序 在某些数据处理的例子中,总数据量太大,无法排序(甚至无法全部装进内存)。例如,...
    sunhaiyu阅读 998评论 0 2
  • 本文的目标是要做出优先队列和堆排序两个Demo。 完全二叉树 优先队列 堆排序 完全二叉树 完全二叉树的定义是建立...
    囧书阅读 4,867评论 13 48
  • 1 初级排序算法 排序算法关注的主要是重新排列数组元素,其中每个元素都有一个主键。排序算法是将所有元素主键按某种方...
    深度沉迷学习阅读 1,335评论 0 1
  • 0.目录 1.优先队列ADT 2.几种实现 3.二叉堆 4.d-堆 5.左式堆 6.斜堆 7.二项队列 8.斐波那...
    王侦阅读 2,909评论 1 2