最大堆,又称大根堆(大顶堆)是指根结点(亦称为堆顶)的关键字是堆里所有结点关键字中最大者,属于二叉堆的两种形式之一。
前言
前端算法系列是我对算法学习的一个记录, 主要从常见算法、数据结构、算法思维、常用技巧几个方面剖析学习算法知识, 通过LeetCode平台实现刻意练习, 通过掘金和B站的输出来实践费曼学习法, 我会在后续不断更新优质内容并同步更新到掘金、B站和Github, 以记录学习算法的完整过程, 欢迎大家多多交流、点赞、收藏, 让我们共同进步, daydayup👊
目录地址:目录篇
相关代码地址: Github
相关视频地址: 哔哩哔哩-百日算法系列
一、完全二叉树
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)
创建一个新的数组, 循环遍历原数组, 依次向堆中插入数据
下图是插入建堆以及堆排序的动态过程, 先循环插入, 建堆完成之后再循环删除进行排序
图片来源: 网络
(2)、原地建堆
时间复杂度: O(n/2logn)
空间复杂度: O(1)
关于时间复杂度精确算法(了解)
精确计算方式为:
T += 每层的节点个数 * 每层的树高
思路
因为对于叶子节点而言, 它本身已经是叶子节点了, 已经不能再执行下沉操作,所以我们可以省略对它的下沉操作, 只对非叶子节点进行下沉操作, 从第 n/2 个开始, 依次调整
下图是原地建堆以及堆排序的动态过程, 通过对非叶子节点的下沉操作调整堆, 再通过逻辑删除进行堆排序
图片来源: 网络
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(最坏)
利用堆顶的数据是所有数据中最小(大)的值, 我们将堆顶的元素删除, 然后对堆进行调整, 重复当前操作就可以获取一个有序的数组.
另外, 因为我们可以用数组来表示堆, 而被删除的节点我们可以只将其逻辑删除, 也就是利用双指针的思路, 限定数组中的一段位置表示堆, 而另外一段位置则可以保存我们已经有序的数据.
3、TOPk
比较经典问题之一就是求数组中第K大元素或前 k 大元素的问题