「数据结构」 | 二叉堆

点赞关注,不再迷路,你的支持对我意义重大!

🔥 Hi,我是丑丑。本文「数据结构 & 算法」| 导读 —— 登高博见 已收录,这里有 Android 进阶成长路线笔记 & 博客,欢迎跟着彭丑丑一起成长。(联系方式在 GitHub)

前言

  • 今天,我们来讨论一个非常实用的数据结构——二叉堆(Binary Heap,简称:堆),它最主要的应用场景有 堆排序 & 优先队列 & Top K & 最大索引堆。与堆相关算法题目基本属于中等难度,在算法面试中也比较常见,建议应试者着重学习;
  • 在这篇文章里,我将梳理堆的 基本知识 & 常考题型。如果能帮上忙,请务必点赞加关注,这真的对我非常重要。

目录


1. 基本概念

  • 逻辑结构

二叉堆(Binary Heap) 是一种特殊的完全二叉树,即:每个节点都大于等于(或者小于等于)子节点。需要注意的是,兄弟节点的相对大小是不重要的。

具体来说,如果每个节点都大于等于子节点,这种堆称为 大顶堆 / 最大堆;如果每个节点都小于等于子节点,这种堆称为 小顶堆 / 最小堆

  • 物理结构

树可以采用数组 & 链表两种存储方式,对于二叉堆(完全二叉树)来说,数组存储方式是空间利用率最高的存储方式。因此通常来说,二叉堆是基于数组的数据结构。


2. 堆的基本操作

这一节,我们先来讨论堆的三个基本操作 —— 上浮 & 下沉 & 建堆,这三个操作的目的其实都是堆化(Heapify)。建堆的作用是把一组数据转换为满足堆性质的数据结构,而上浮 和 下沉的作用是在堆结构变化之后,适当地调整节点使其重新满足堆的性质 。

提示: 为了专注于讨论二叉堆的相关内容,在这一节里我们不考虑泛型,同时以大顶堆为例子。

2.1 上浮(添加元素)

往堆里添加元素时,需要执行上浮操作,具体步骤如下:

  • 1、新元素放在数组末尾(注意:是有效数据的末尾,而不是数组物理区域的末尾);
  • 2、与父节点比较,如果不满足堆的性质,则交换父子节点
  • 3、重复步骤 2,直到满足堆的性质

提示: 从数组末尾开始上浮,数组交换和一定的次数最少。

引用自 https://time.geekbang.org/column/article/69913 —— 王争 著

结合代码可能更容易理解:

根节点存储在第 [0] 位

public class Heap {

    private Integer[] data;
    private int count;

    添加元素
    private void insert(Integer x) {
        int i = count;
        data[count++] = x;
        siftup(i);
    }

    上浮操作
    private void siftup(int k) {
        while (k > 0 && data[(k - 1) / 2] < data[k]) {
            swap(data, (k - 1) / 2, k);
            k /= 2;
        }
    }
}

这段代码存在着一些不必要的赋值操作,可以优化,优化后目标元素只会赋值一次到最终位置:

参考 JDK 1.5 java.util.PriorityQueue.java

添加元素
private void insert(Integerx) {
    int i = count;
    data[count++] = x;
    siftup(i, x);
}

上浮操作
private void siftup(int k, Integer x) {
    while (k > 0) {
        int parent = (k - 1) >>> 1;
        Integer parentE = data[parent];
        if (parentE >= x)
            break;
        data[k] = parentE;
        k = parent;
    }
    data[k] = x;
}

2.2 下沉(取出元素)

往堆里取出元素时,需要执行下沉操作,具体步骤如下:

  • 1、取出堆顶元素,即数组第 1 个元素(索引为 0 或 1,取决于具体实现)
  • 2、数组最后一个元素放到数组 1 个元素的位置
  • 3、与子节点比较,如果不满足堆的性质,则交换父节点与子节点中最小的那个
  • 4、重复步骤 3,直到满足堆的性质

提示: 从数组头部开始下沉,数组交换和一定的次数最少,同时能够避免出现 数组空洞

引用自 https://time.geekbang.org/column/article/69913 —— 王争 著

另外,需要注意到叶子节点是没有子节点的,不需要执行下沉操作,可以提前结束。(根节点存储在第 [0] 位时,叶子节点下标为\frac{count} {2} 到 count-1,根节点存储在第 [1] 位时,叶子节点下标为 \frac{count}{2} + 1 到 count)。

结合代码可能更容易理解:

根节点存储在第 [0] 位

public class Heap {

    private Integer[] data;
    private int count;

    取出堆顶元素
    int poll() {
        int k = --count;
        int result = data[0];
        int x = data[k];
        data[k] = null;
        if (k != 0) {
            siftdownV2(0);
        }
        return result;
    }
    
    下沉操作
    void siftdown(int k) {
        int half = count / 2;
        while (k < half) {
            int minPos = k;
            if (data[k * 2 + 1] < data[k]) minPos = k * 2 + 1;
            if (data[k * 2 + 2] < count && data[k * 2 + 2] < data[k]) minPos = k * 2 + 2;
            if (minPos == k)
                break;
            swap(data, k, minPos);
            k = minPos;
        }
    }
}

这段代码存在着一些不必要的赋值操作,可以优化,优化后目标元素只会赋值一次到最终位置:
参考 JDK 1.5 java.util.PriorityQueue.java

取出堆顶元素
private int poll() {
    int k = --count;
    int result = data[0];
    int x = data[k];
    data[k] = null;
    if (k != 0) {
        siftdown(0, x);
    }
    return result;
}

下沉操作
private void siftdown(int k, int x) {
    注意:叶子节点没有子节点,不需要下沉
    int half = count >>> 1;
    while (k < half) {
    int child = (k << 1) + 1; // assume left child is least
    Integer childE = data[child];
    int right = child + 1;
    if (right < count && childE > data[right])
        childE = data[child = right];
        if (x < childE)
            break;
        data[k] = childE;
        k = child;
    }
    data[k] = x;
}

2.3 建堆

前面讲的上浮和下沉操作的前提是数组本身是堆化的,那么这一节我们就来讨论 建堆 这一操作。

建堆可以分为 原地建堆 & 非原地建堆,原地建堆指的是将一个数组原地堆化的过程,而非原地建堆指的是输入数据一个个添加到数组中的过程。可以观察到, 非原地建堆其实就是不断添加元素执行下沉操作的过程,等同于 第 2.1 节 上浮操作,所以我们主要是分析原地建堆。

原地建堆可以用两种方法实现,分别为 从下往上堆化 & 从上往下堆化

  • 从下往上堆化

这种方法先将下标为 0 的第一个元素视为大小为 1 的堆,随后将下标从1到count -1的元素依次执行上浮操作。这个过程也相当于不断向这个初始大小为 1 的堆里添加元素。

  • 从上往下堆化

这种方法先将叶子节点视为大小为 1 的堆,随后将下标从\frac{count}{2}-1递减到0的节点执行下沉操作。

2.4 复杂度分析

  • 时间复杂度

    • 上浮 & 下沉:沿着树根节点到叶子节点的路径进行比较和交换。而一个包含 n 个节点的二叉树,树的高度为 lgn,所以时间复杂度为O(lgn)
    • 建堆:建堆的时间复杂度是O(n),推导过程可以看参考资料,这个结论还是比较重要的。
  • 空间复杂度
    堆化的过程中只是用了常量级变量,因此空间复杂度为O(1)


3. 堆的应用 —— 堆排序

堆排序(Heap Sort) 指的是借助二叉堆实现的原地排序算法,它是一种时间复杂度为 O(nlgn)的不稳定的排序算法,快速排序有几分相似之处,后文我也会分析它们之间的区别。

总的来说,堆排序的过程可以分为 建堆 & 排序 两个步骤:

3.1 建堆

建堆的过程在 第 2.3 节讨论过了,假设我们要进行递增排序的话,我们就需要建立一个大顶堆(每个节点都大于等于子节点)。

特别提示: 完成建堆后,数据处于 堆有序 状态,但不是 有序 状态,堆有序其实只是指数据满足堆的性质(每个节点都大于等于或小于等于子节点)。

3.2 排序

建立大顶堆后,现在我们来进行排序操作,具体操作如下:

  • 1、堆顶元素,交换到数组末尾位置,此时,最大的元素已经完成排序
  • 2、执行下沉操作,将数组前 n - 1 个数据重新堆化
  • 3、重复步骤 1 和 步骤 2,直到堆的大小为 1

整个过程相当于执行 n 次 取出堆顶元素的操作,直到最后堆的大小为 1 时,数组完成排序。

引用自 https://time.geekbang.org/column/article/69913 —— 王争 著

3.3 复杂度分析

  • 时间复杂度

下沉操作的时间复杂度是O(lgn),总共执行 n 次,因此总体的时间复杂度为O(nlgn)

  • 空间复杂度

堆排序是原地排序,建堆和排序的过程中只是用了常量级变量,因此总体的空间复杂度为O(1)

3.4 堆排序与快速排序对比

前面提到了堆排序和快速排序的相似之处,那么两者有何不同的?

  • 数据访问方式不同

快速排序是从数组下标依次递增访问数据,而堆排序是跳着访问的,后者更不利于命中 CPU 缓存行。

  • 数据交换次数不同

堆排序进行堆化时,可能会改变数据原本的相对顺序,将原本相对有序的数组变得更加无序。这反而增加了逆序度,增加了执行交换操作的次数。

考虑到这两个因素,我们不难理解为什么 JDK 的 java.util.Arrays.sort()会选择使用快速排序,而不是堆排序了。

当然了,堆排序也不是完全没有价值,有一种场景堆排序就 “秒杀” 快速排序。那就是只需要取得前 k 个有序的数据时,即 Top k 问题,使用堆排序(或者称为大小为 k 优先队列),时间复杂度仅为O(nlgk)


4. 堆的应用 —— 优先队列

与优先对列相似的有一种数据结构:队列,虽然它们的名称很相似,但是本质上区别是很大的:

数据结构 描述
队列 先进先出(FIFO),出队顺序由时间顺序决定
优先队列 出队顺序与入队顺序无关,而由优先级顺序决定

4.1 优先队列的实现

狭义上,优先队列指的是基于二叉堆实现的数据结构。而广义上,凡是能够实现按优先级顺序出队的数据结构都可以称为优先队列(例如 Android 领域熟知的 MessageQueue)。

提示: 当你看到优先队列这个词的时候,如果没有特殊上下文语境,指的就是基于堆实现的优先队列。

一般来说,优先队列有以下三种实现:

底层实现 入队 出队 举例
普通数组 O(1) O(n)(扫描整个数组选择最高优先级) /
顺序数组 O(n)(入队时维护顺序,下标靠前优先级越高) O(1)(取出数组下标为 0 的元素) Android MessageQueue
O(lgn) O(lgn) Java PriorityQueue

可以观察到,基于堆的优先队列平衡了入队与出队的时间复杂度。

4.2 优先队列的优点

使用优先队列可以实现 高性能的定时器

假设我们有一个定时器 / 消息器,里面存储是等待执行的定时任务,最笨的方法是每隔一小段时间扫描整个任务列表,筛选出到达执行时间的任务。这样做有两大弊端:

  • 1、无效轮询:任务的执行时间可能还差很久,前面的扫描都是无效的;

  • 2、扫描耗时:如果任务列表非常庞大,一趟扫描会非常耗时。

而如果使用优先队列,则可以规避这两个弊端,即不需要轮询,也不需要扫描整个任务列表。

需要做的是维护定时任务列表的执行优先顺序,每次从优先队列中取出队首的任务。然后计算该任务执行时间与当前时间的差值,把这个差值作为等待时间,等待这个时间之后再回来执行任务(如果等待过程中对任务列表进行操作,则需要提前唤醒)。

Android 领域的小伙伴应该对 MessageQueue 优先队列有深刻理解。虽然它并不是一个基于堆的优先队列,但是思路是一致的:如果当前时间还未到达队首消息的执行时间,那么当前线程等待,而不是轮询判断。Android 领域的小伙伴可以看看之前我写的这篇文章:《Android | 面试必问的 Handler,你确定不看看?》


5. 堆的应用 —— Top K 问题

5.1 题目描述

输入整数数组 arr ,找出其中最小的 k 个数。例如,输入4、5、1、6、2、7、3、8这8个数字,则最小的4个数字是1、2、3、4。

Top K 问题是一个超高频的面试算法问题,难度属于中等,它的解法有很多个,最笨的方法是先将整个数组执行快速排序,再返回排序后前 k 个数,时间复杂度为O(nlgn)。在这里我们主要讲使用二叉堆的解法。

5.2 解法步骤

步骤分解如下:

  • 1、将数组前 k 个数添加到堆,建立一个大小为 k 的大顶堆;
  • 2、依次遍历数组后续的数,如果该数比堆顶的数更小,则将堆顶元素弹出,而该数添加到堆中;
  • 3、重复步骤 2,直到所有数据处理完毕,最终堆中元素就是最小的 k 个数。

可以发现基于二叉堆的思路是 使用大顶堆维护数据的最小的 k 个数,每次将一个数与堆顶元素比较。如果该数小于堆顶元素,说明堆顶元素不是最小 k 小个数,应当从堆里弹出,而该数添加到堆里。

5.3 复杂度分析

  • 时间复杂度

建堆的时间复杂度是O(k),而取堆顶元素和插入元素的时间复杂度为O(lgk),因此总的时间复杂度为O(nlnk),优于快速排序O(nlgn)

  • 空间复杂度

维护了一个大小为 k 的二叉堆,空间复杂度为O(k)


6. 最大索引堆

Editting...


参考资料


创作不易,你的「三连」是丑丑最大的动力,我们下次见!

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

推荐阅读更多精彩内容