[路飞]前端算法——数据结构篇(三、树): 大顶堆与小顶堆

274 阅读7分钟

最大堆,又称大根堆(大顶堆)是指根结点(亦称为堆顶)的关键字是堆里所有结点关键字中最大者,属于二叉堆的两种形式之一。

前言

前端算法系列是我对算法学习的一个记录, 主要从常见算法数据结构算法思维常用技巧几个方面剖析学习算法知识, 通过LeetCode平台实现刻意练习, 通过掘金和B站的输出来实践费曼学习法, 我会在后续不断更新优质内容并同步更新到掘金、B站和Github, 以记录学习算法的完整过程, 欢迎大家多多交流点赞收藏, 让我们共同进步, daydayup👊

目录地址:目录篇

相关代码地址: Github

相关视频地址: 哔哩哔哩-百日算法系列

一、完全二叉树

24091d7df12b2084df48358c0cdcf501.jpeg

1、定义

假设树的高度为 n + 1, 只有第 n + 1 层的右侧是缺少节点的, n + 1 层左侧的节点是连续的

2、特性

以下的性质建议在纸上画一下或者推算一下, 推算过程这里并不做赘述

(1)、完全二叉树高度

h = logn

(2)、映射在数组中的下标

假设将二叉树以前序遍历(根, 左, 右)的顺序映射到数组中, 且数组的下标从 1 开始

当前节点的下标: i
当前节点的左子树下标: 2i
当前节点的右子树下标: 2i + 1

(3)、叶子节点与非叶子节点的下标

如果二叉树的数据总量为 n, 且下标从 1 开始, 那么

非叶子节点下标: [1, n/2]
叶子节点的下标: (n/2, n]

⚠️ 注意这里如果 n/2 是小数的话, 我们需要向下取整, 另外 n/2 是非叶子节点

二、什么是堆

所谓的堆就是满足根节点一定大于(小于)左右子节点的完全二叉树

当我们完全二叉树中的节点满足根节点大于左右两个字节点时, 这就是一个大顶堆, 与之对应的便是小顶堆.

三、堆的实现

实现之前, 我们有了上面完全二叉树的性质之后, 我们就可以使用一个数组来表示一个堆(二叉树), 因为数组是连续的, 而完全二叉树也是连续的, 所以可以使用下标来表示二叉树中对应的节点. 我们把这种通过计算得到对应节点的表示方式叫做计算式

1、堆的建立

堆的创建方法有两种, 一种是通过循环插入的方法建堆, 还有一种是通过调整的方式建堆.

(1)、插入建堆

时间复杂度: O(nlogn)
空间复杂度: O(n)

创建一个新的数组, 循环遍历原数组, 依次向堆中插入数据

下图是插入建堆以及堆排序的动态过程, 先循环插入, 建堆完成之后再循环删除进行排序

图片来源: 网络 d6f0432b6f53a5488f33466753f63222.gif

(2)、原地建堆

时间复杂度: O(n/2logn)
空间复杂度: O(1)

关于时间复杂度精确算法(了解)

精确计算方式为:

T += 每层的节点个数 * 每层的树高
S1=1h+21(h1)+22(h2)+...+2k(hk)+...+2h11S1 = 1*h+2^1*(h-1)+2^2*(h-2)+...+2^k*(h-k)+...+2^{h-1}*1
S1=2h+1h2S1 = 2^{h+1}-h-2
h=logn(树高)h = logn(树高)
T=O(n)T = O(n)

思路

因为对于叶子节点而言, 它本身已经是叶子节点了, 已经不能再执行下沉操作,所以我们可以省略对它的下沉操作, 只对非叶子节点进行下沉操作, 从第 n/2 个开始, 依次调整

下图是原地建堆以及堆排序的动态过程, 通过对非叶子节点的下沉操作调整堆, 再通过逻辑删除进行堆排序

图片来源: 网络 934b7eadab81812eb0442c0c9f11ae74 (1).gif

2、堆的插入

时间复杂度: O(logn)

思路

1、找好位置:

为了方便操作, 我们把堆的末尾, 也就是数组的末尾作为理想的插入位置, 因为在这里插入并不会破坏完全二叉树的性质, 最后一行的左侧是连续的

2、调整姿势:

在末尾插入虽然并没有破坏完全二叉树的性质, 但显然它破坏了堆的性质, 此时新插入的数据与其根节点的关系是不确定的, 因为我们需要对堆进行调整, 调整的主要逻辑就是将子节点与其父节点进行比较, 然后通过交换位置的方式让较大的那个向上调整, 也就是上浮操作

步骤

  • 将数据插入到堆尾
  • 上浮操作
    • 将插入的节点与其父节点比较大小, 如果插入节点大就替换位置, 向上浮
    • 如果上浮之后, 重复判断其父节点, 如果还大就继续上浮
    • 最多上浮 logn 次, 它就到了根节点, 结束

3、堆的删除

时间复杂度: O(logn)

思路

对于删除操作, 为了不破坏完全二叉树的性质, 我们选择将待删除的节点删除之后, 由堆的末尾元素顶替其位置, 接着我们讲替换后的节点与其子节点进行比较, 如果更小则向下调整, 也就是下沉操作

步骤

  • 将堆尾的节点与待删除的节点交换
  • 下沉操作
    • 将该节点与两个子节点比较, 若子节点更大, 则向下交换
    • 重复上述操作, 直到其没有子节点, 结束

4、上代码

const maxn = 100

// 笔记:
// 将这两个变量放在这里, 可以在模块化导出之后
// 防止外界通过实例化对象直接修改该变量而导致意外的错误
let cnt = 0, data = []

// 交换函数
const temp = (a, b) => [data[a], data[b]] = [data[b], data[a]]

/**
* @name 大顶堆
* @desc 根节点比左右大, 初始根节点下标: 0, 
*/
function Heep (nums) {
    cnt = 0, data = []
    this.init(nums)
}

// 插入建堆
Heep.prototype.init = function (nums) {
    data = new Array(nums.length || maxn)

    for(let i=0; i<nums.length; i++) {
        this.push(nums[i])
    }
}

// 插入堆尾-把元素塞到屁股后面再提上来
Heep.prototype.push = function (item) {
    if (this.isFull()) return false
    data[cnt++] = item
    // 上浮操作(插入时)
    this.up(cnt - 1)

    // 上浮操作(插入时)
    // let i = null, idx = cnt - 1
    // const f = (idx) => Math.floor((idx - 1) / 2)
    // while(data[idx] > data[i = f(idx)]) {
    //     temp(i, idx)
    //     idx = i
    // }
}

// 上浮操作(插入时)
Heep.prototype.up = function (i) {
    if (!this.isHas(i)) return
    const f = Math.floor((i - 1) / 2)
    if (data[i] > data[f]) {
        temp(i, f)
        this.up(f)
    }
}

// 删除堆顶-把堆尾的薅上来再踢下去
Heep.prototype.pop = function () {
    if (this.isEmpty()) return false
    temp(0, --cnt)
    // 下沉操作(删除时)
    this.down(0)
    return data[cnt]
}



// 下沉操作(删除时)
Heep.prototype.down = function (i) {
    let max = i, l = i * 2 + 1, r = i * 2 + 2
    if (!this.isHas(l)) return
    if (data[l] > data[max]) max = l
    if (this.isHas(r) && data[r] > data[max]) max = r
    if (max !== i) {
        temp(max, i)
        this.down(max)
    }
}

// 堆顶
Heep.prototype.top = function () {
    return data[0]
}

// 堆尾
Heep.prototype.tail = function () {
    return data[cnt - 1]
}

// 长度
Heep.prototype.size = function () {
    return cnt
}

// 判有
Heep.prototype.isHas = function (idx) {
    return idx < cnt
}

// 判无
Heep.prototype.isEmpty = function () {
    return cnt === 0
}

// 判满
Heep.prototype.isFull = function () {
    return cnt === data.length
}

四、堆的应用

1、优先队列

堆是实现优先队列的其中一种方式

定义

  • 尾部可以插入
  • 头部可以弹出
  • 每次出队权值(最大/最小的元素)
  • 通过数组实现, 在逻辑上可以看成一个堆

2、堆排序

时间复杂度: O(nlogn)

我们要对n个数据依次进行弹出与调整, 每次调整的时间复杂度为logn(最多就是树高), 所以就是 nlogn(最坏)

利用堆顶的数据是所有数据中最小(大)的值, 我们将堆顶的元素删除, 然后对堆进行调整, 重复当前操作就可以获取一个有序的数组.

另外, 因为我们可以用数组来表示堆, 而被删除的节点我们可以只将其逻辑删除, 也就是利用双指针的思路, 限定数组中的一段位置表示堆, 而另外一段位置则可以保存我们已经有序的数据.

src=http___img2020.cnblogs.com_blog_1531324_202007_1531324-20200723170730928-135292175.gif&refer=http___img2020.cnblogs.gif

3、TOPk

比较经典问题之一就是求数组中第K大元素或前 k 大元素的问题

215. 数组中的第K个最大元素