深入理解堆与优先队列

news2024/9/22 3:57:08

目录

  • 一、什么是堆?
  • 二、堆的实现
    • 2.1 上滤与下滤
    • 2.2 堆的常用操作
    • 2.3 建堆
  • 三、堆排序
  • 四、优先队列
  • References

一、什么是堆?

(Heap)是一种特殊的完全二叉树,满足性质:除叶节点外每个节点的值都大于等于(或者小于等于)其孩子节点的值(该性质又称「堆序性」)。

堆有两种类型:

  • 大根堆(又称最大堆):堆中每一个节点的值都大于等于其孩子节点的值。所以大根堆的特点是堆顶元素(根节点)是堆中的最大值
  • 小根堆(又称最小堆):堆中每一个节点的值都小于等于其孩子节点的值。所以小根堆的特点是堆顶元素(根节点)是堆中的最小值

下图展示了大根堆与小根堆的区别:

二、堆的实现

堆通常用数组来实现(数组名一般为 h h h,即heap的首字母)。具体来讲,我们 1 1 1 开始,按照层序遍历的顺序给每个节点进行编号,例如,对于上图中的大根堆而言,其编号顺序如下:

每个节点的编号就是该节点在数组中的下标,相应的数组为 h [    ] = { 0 , 10 , 7 , 6 , 4 , 5 , 1 , 2 } h[\;]=\{0,10,7,6,4,5,1,2\} h[]={0,10,7,6,4,5,1,2}(第 0 0 0 个元素是什么不重要)。

按照这种编号方式,不难发现:

  • 根节点的编号一定是 1 1 1
  • 若一个节点的编号为 x x x,则它左子节点(如果有)的编号为 2 x 2x 2x,右子节点(如果有)的编号为 2 x + 1 2x+1 2x+1
  • 若一个节点的编号为 x x x,则它父节点(如果有)的编号为 x / 2 x/2 x/2(这里的除法是整除)。

此外,根据完全二叉树的性质,还可以得到:

  • 若堆中含有 n n n 个元素,则堆的高度为 ⌊ log ⁡ 2 n ⌋ + 1 \lfloor\log_2 n\rfloor+1 log2n+1
  • 若一个节点的编号为 x x x 且满足 x > n / 2 x>n/2 x>n/2(这里的除法是整除),则该节点一定是叶子节点,否则是分支节点。

2.1 上滤与下滤

⚠️ 为统一起见,接下来提到的堆均指小根堆

上滤(又称向上调整)和下滤(又称向下调整)是堆的两种基本操作。

上滤是指将不符合堆序性的某个元素向上调整至合适的位置,下滤是指将不符合堆序性的某个元素向下调整至合适的位置。

先来看下滤操作是如何进行的。设编号为 x x x 的节点不满足堆序性(该节点一定不是叶子节点,否则讨论将变得毫无意义),接下来分两种情况考虑:

  • 编号为 2 x 2x 2x 的节点存在,编号为 2 x + 1 2x+1 2x+1 的节点不存在: 这时候一定成立 h [ x ] > h [ 2 x ] h[x]>h[2x] h[x]>h[2x],此时交换 h [ x ] h[x] h[x] h [ 2 x ] h[2x] h[2x] 即可;
  • 编号为 2 x 2x 2x 的节点和编号为 2 x + 1 2x+1 2x+1 的节点均存在: 这时候 h [ x ] > h [ 2 x ] h[x]>h[2x] h[x]>h[2x] h [ x ] > h [ 2 x + 1 ] h[x]>h[2x+1] h[x]>h[2x+1] 中至少有一个成立。令 y = arg min ⁡ { h [ 2 x ] ,    h [ 2 x + 1 ] } y=\argmin \{h[2x],\;h[2x+1]\} y=argmin{h[2x],h[2x+1]},交换 h [ x ] h[x] h[x] h [ y ] h[y] h[y] 即可。

下滤操作的实现:

void down(int x) {
    while (x <= n / 2) {  // 当x不是叶子节点的时候持续向下调整
        int y = 2 * x;  // 如果x不是叶子节点,则至少存在左子节点
        if (y + 1 <= n && h[y + 1] < h[y]) y++;  // 判断左右子节点哪个更小,并令y等于更小的那个节点的编号
        if (h[y] >= h[x]) break;  // 如果左右子节点中的最小值都要大于等于节点x的值,说明x已经调整完毕
        swap(h[x], h[y]), x = y;  // 否则进行调整
    }
}

比起下滤操作,上滤操作的实现更为简单(因为往下走有两种选择:左、右子节点,而往上走只有一种选择:父节点)。设编号为 x x x 的节点不满足堆序性(该节点一定不是根节点,否则讨论将变得毫无意义),则一定有 h [ x ] < h [ x / 2 ] h[x]<h[x/2] h[x]<h[x/2],不断交换 h [ x ] h[x] h[x] h [ x / 2 ] h[x/2] h[x/2] 直至 h [ x ] ≥ h [ x / 2 ] h[x]\geq h[x/2] h[x]h[x/2] 即可。

上滤操作的实现:

void up(int x) {
    while (x > 1 && h[x] < h[x / 2]) {  // 当x不是根节点的时候持续向上调整
        swap(h[x], h[x / 2]);
        x /= 2;
    }
}

上滤操作和下滤操作的平均时间复杂度均为 O ( log ⁡ n ) O(\log n) O(logn)

2.2 堆的常用操作

仅用上滤和下滤我们就可以实现堆的常用操作:

操作时间复杂度
获取堆顶元素的值 O ( 1 ) O(1) O(1)
向堆中插入一个元素 O ( log ⁡ n ) O(\log n) O(logn)
删除堆顶元素 O ( log ⁡ n ) O(\log n) O(logn)
删除堆中的任一元素 O ( log ⁡ n ) O(\log n) O(logn)
修改堆中的任一元素 O ( log ⁡ n ) O(\log n) O(logn)

通常,我们需要用两个变量来表示一个堆:一个是上文提到的 h h h 数组,另一个是 i d x idx idx,用来表示当前堆中有多少个元素。

操作一:获取堆顶元素的值

int top() {
    return h[1];
}

操作二:向堆中插入一个元素

向堆中插入元素按照层序遍历的顺序进行,所以新插入的元素一定是叶子节点(编号最大的节点),此时对它进行上滤操作调整至合适的位置即可。

void push(int x) {
    h[++idx] = x, up(x);
}

操作三:删除堆顶元素

做法是用堆中最后一个元素(即编号最大的元素)覆盖掉堆顶元素,然后删除最后一个元素,同时下滤堆顶元素。

void pop() {
    h[1] = h[idx], idx--, down(1);
}

操作四:删除堆中的任一元素

不妨设要删除的元素的编号为 k k k,同样用最后一个元素覆盖掉这个元素,然后删除最后一个元素。此时对于编号为 k k k 的元素而言,要么执行上滤操作,要么执行下滤操作,要么什么都不用执行。简便起见,我们可以直接执行 down(k), up(k),这两个操作至多只有一个会被执行。

void pop(int k) {
    h[k] = h[idx], idx--, down(k), up(k);
}

可以看出 pop(1)pop() 等价。

操作五:修改堆中的任一元素

类似删除堆中的任一元素。

void modify(int k, int x) {
    h[k] = x, down(k), up(k);
}

2.3 建堆

给定一个乱序数组 a a a,我们如何根据它来建堆呢?

如果对于每一个 a [ i ] a[i] a[i],依次调用堆的 push 方法,则总时间复杂度为 O ( n log ⁡ n ) O(n\log n) O(nlogn),有没有更好的方法呢?

考虑将 a a a 赋值给 h h h(事实上一般不会这么做,而是直接输入到 h h h),此时 h h h 所代表的仅仅是完全二叉树,因为 h h h 不一定满足堆序性。对该完全二叉树的每个分支节点进行下滤(因为下滤叶子节点无意义)即可得到堆:

void build() {
    for (int i = idx / 2; i; i--) down(i);
}

下面分析 build 函数的时间复杂度。简便起见,不妨假设堆是满二叉树且含有 n n n 个元素,于是堆的高度为 h ≜ log ⁡ 2 ( n + 1 ) h\triangleq\log_2(n+1) hlog2(n+1)。规定根节点所在的层为第一层,于是最后一层的元素个数为 2 h − 1 2^{h-1} 2h1,倒数第二层的元素个数为 2 h − 2 2^{h-2} 2h2,以此类推。

build 从倒数第二层的节点开始逐个下滤,每个节点的操作次数至多是 1 1 1,因此 build 在倒数第二层的总操作次数为 2 h − 2 ⋅ 1 2^{h-2}\cdot 1 2h21

对于倒数第三层的节点,每个节点的操作次数至多是 2 2 2,因此 build 在倒数第二层的总操作次数为 2 h − 3 ⋅ 2 2^{h-3}\cdot 2 2h32

不断进行下去可得到 build 的总操作次数:

S = 2 h − 2 ⋅ 1 + 2 h − 3 ⋅ 2 + 2 h − 4 ⋅ 3 + ⋯ + 2 0 ⋅ ( h − 1 ) = ∑ i = 1 h − 1 i ⋅ 2 h − i − 1 \begin{aligned} S&=2^{h-2}\cdot 1+2^{h-3}\cdot 2+2^{h-4}\cdot 3+\cdots + 2^0\cdot (h-1)\\ &=\sum_{i=1}^{h-1}i\cdot 2^{h-i-1} \end{aligned} S=2h21+2h32+2h43++20(h1)=i=1h1i2hi1

经过简单计算可得:

S = 2 S − S = ∑ i = 1 h − 1 i ⋅ 2 h − i − ∑ i = 1 h − 1 i ⋅ 2 h − i − 1 = ∑ i = 1 h − 2 2 h − i + 1 + 2 h + 1 − ( h − 1 ) = 2 h + 2 − h − 7 = O ( 2 h ) = O ( n ) \begin{aligned} S&=2S-S=\sum_{i=1}^{h-1}i\cdot 2^{h-i}-\sum_{i=1}^{h-1}i\cdot 2^{h-i-1} \\ &=\sum_{i=1}^{h-2}2^{h-i+1}+2^{h+1}-(h-1) \\ &=2^{h+2}-h-7\\ &=O(2^h)=O(n) \end{aligned} S=2SS=i=1h1i2hii=1h1i2hi1=i=1h22hi+1+2h+1(h1)=2h+2h7=O(2h)=O(n)

故建堆的时间复杂度为 O ( n ) O(n) O(n)

三、堆排序

堆排序实际上就是先根据乱序序列建堆,然后将根节点与编号最大的节点进行交换(注意是交换而不是覆盖),同时下滤根节点。再将根节点与编号第二大的节点进行交换,同时下滤根节点,以此类推。

堆排序结束后,对堆进行层序遍历即可得到排序后的序列。

注意到如果初始时建立的是小根堆,则排序结束后会得到降序序列;如果初始时建立的是大根堆,则排序后会得到升序序列。

这里给出一个堆排序的模板:

#include <iostream>

using namespace std;

const int N = 1e5 + 10;

int n;  // 堆中的元素数量
int h[N];  // 用于存储堆的数组

// 大根堆的下滤操作
void down(int x) {
    while (x <= n / 2) {
        int y = 2 * x;
        if (y + 1 <= n && h[y + 1] > h[y]) y++;
        if (h[y] <= h[x]) break;
        swap(h[x], h[y]), x = y;
    }
}

int main() {
    cin >> n;

    for (int i = 1; i <= n; i++) cin >> h[i];  // 读入乱序序列

    for (int i = n / 2; i; i--) down(i);  // 建立大根堆

    int t = n;  // 循环结束后n的值会变为0,所以需要先提前保存一下方便后续输出
    while (n) {
        swap(h[1], h[n]), n--, down(1);
    }

    for (int i = 1; i <= t; i++) cout << h[i] << ' ';  // 输出升序序列

    return 0;
}

容易看出堆排序的时间复杂度是 O ( n log ⁡ n ) O(n\log n) O(nlogn),空间复杂度是 O ( 1 ) O(1) O(1)

四、优先队列

所谓优先队列,就是指定队列中元素的优先级,优先级越大越优先出队,而普通队列则是按照进队的先后顺序出队,可以看成进队越早越优先。

STL中的优先队列实际上就是大根堆,元素越大越优先出队。本节主要讲解STL中的优先队列的用法。

使用优先队列需要先包含头文件:

#include <queue>

创建一个优先队列(大根堆):

priority_queue<int> q;

如果要创建一个小根堆,则可以这样声明:

priority_queue<int, vector<int>, greater<int>> q;

优先队列的常用操作:

操作描述
q.top()返回队头元素
q.pop()弹出队头元素
q.push(x)向队列中插入元素
q.empty()判断队列是否为空
q.size()返回队列的大小

References

[1] https://oi-wiki.org/ds/heap/
[2] https://zh.cppreference.com/w/cpp/container/priority_queue
[3] https://zh.wikipedia.org/wiki/%E5%A0%86%E7%A9%8D
[4] https://www.acwing.com/activity/content/punch_the_clock/11/

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/188605.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

【Window 入侵排查】

Window 入侵排查1、文件的排查1.1 开机启动有无异常文件启动1.2 对系统敏感文件路径的查看1.3 查看Recent1.4 查看文件时间1.5 webshell 文件排查2、进程、端口排查2.1 查看进程2.2 进程排查2.3 使用powershell 进行查询2.4 使用WMIC 命令进行排查3、检查启动项、计划任务、服务…

全志V85X系列芯片PCB设计需要注意些什么?

全志V85X &#xff08;包括V853、V853S、V851S、V851SE等&#xff09;是一颗面向智能视觉领域推出的新一代高性能、低功耗的处理器SOC&#xff0c;可广泛用于智能门锁、智能考勤门禁、网络摄像头、行车记录仪、智能台灯等智能化升级相关行业。V85X 集成ARM Cortex-A7和RISC-V E…

一个跨平台执行外部命令的C#开源库

更多开源项目请查看&#xff1a;一个专注推荐.Net开源项目的榜单 对于我们程序员来说&#xff0c;在日常开发项目中&#xff0c;调用外部的命令是非常常见的&#xff0c;比如调用批处理命令、调用其他应用&#xff0c;这里面就涉及到进程的通讯、管理、启动、取消等一些操作&am…

Spring Native打包本地镜像,无需通过Graal的maven插件buildtools

简介 在文章《GraalVM和Spring Native尝鲜&#xff0c;一步步让Springboot启动飞起来&#xff0c;66ms完成启动》中&#xff0c;我们介绍了如何使用Spring Native和buildtools插件&#xff0c;打包出本地镜像&#xff0c;也打包成Docker镜像。本文探索一下&#xff0c;如果不通…

一文细说Linux Out Of Memory机制

有时候我们会发现系统中某个进程会突然挂掉&#xff0c;通过查看系统日志发现是由于 OOM机制 导致进程被杀掉。 今天我们就来介绍一下什么是 OOM机制 以及怎么防止进程因为 OOM机制 而被杀掉。 什么是OOM机制 OOM 是 Out Of Memory 的缩写&#xff0c;中文意思是内存不足。而…

【CLYZ集训】人人人数【数学】

思路&#xff1a; 先转转转&#xff0c;把答案变成求每种数的出现次数都小于i的方案书除以Cnm−1mC_{n m - 1}^{m}Cnm−1m​ 对于每个1到m中的数&#xff0c;设每个数的出现次数为xi&#xff0c;则所有x加起来要等于m&#xff0c;且都小于i。 容斥&#xff0c;设其中k个不小于…

目标检测算法——YOLOV8——算法详解

一、主要贡献 主要的创新点&#xff1a;其实到了YOLOV5 基本创新点就不太多了&#xff0c;主要就是大家互相排列组合复用不同的网络模块、损失函数和样本匹配策略。 Yolo v8 主要涉及到&#xff1a;backbone 使用C2f模块&#xff0c;检测头使用了anchor-free Decoupled-head&a…

HTB_Unified_log4j_jndi注入mongodb修改用户hash

文章目录信息收集漏洞复现漏洞验证漏洞利用提权信息收集 nmap -sV -v 这次扫描时间很长&#xff0c;因为默认只扫 1000 个常用端口&#xff0c;如果扫到大端口就会自动扫描全端口&#xff0c;可以自行加速 22/tcp open ssh OpenSSH 8.2p1 Ubuntu 4ubuntu0.3 (…

Markdown语法-从基础到进阶

时代在进步&#xff0c;越来越多的人和软件开始使用Markdown进行文字编辑&#xff0c;其编辑方便性让很多人爱不释手。但是&#xff0c;不可避免的问题是&#xff0c;在编辑的时候&#xff0c;经常会需要去google,毕竟&#xff0c;习惯了office的可视化操作符号&#xff0c;很多…

在成都Java培训班学习五个多月有用吗?

不知道“有用”的标准是什么&#xff0c;是能入行上岗工作&#xff0c;还是想只通过几个月的培训一跃成为资深开发攻城狮&#xff1f;这里不得不给大家泼瓢冷水&#xff0c;短期培训能让你对口上岗工作就很不错了&#xff1b;想要成为技术大佬&#xff1f;大学里面四年都没能让…

2023年南京Java培训机构排行榜上线,犹豫的小伙伴们看过来!

2022年&#xff0c;JRebel发布了《2022年Java发展趋势和分析》&#xff0c;它通过调研问卷的方式总结的报告&#xff0c;涉及了不同国家、不同岗位、不同公司规模、不同行业&#xff0c;相对来说&#xff0c;该调查报告是有一定参考意义的。数据显示&#xff0c;Java这一语言在…

使用Chisel搭建Systolic Array

最近听到非常多人吹Chisel&#xff0c;为了方便快速做算法实现&#xff0c;就去尝试学了下&#xff0c;发现确实很香&#xff0c;有种相见恨晚的感觉。今天是使用Chisel搭建个脉动阵列&#xff08;Systolic Array, SA&#xff09;[1]&#xff0c;脉动阵列是神经网络中最基础也是…

用递归玩转简单二叉树

前言&#xff1a; 数据结构学到二叉树&#xff0c;就进入到了有难度的部分了&#xff0c;但难度对应着重要性&#xff0c;其重要性也不言而喻了。这节我会介绍用C语言实现递归方法的二叉树的一些重要基本功能&#xff0c;在二叉树中又属于基础知识&#xff0c;有需要的各位必须…

下载CleanMyMac X有什么好处?最新版本有哪些新功能

CleanMyMac X 是一款先进的、集所有功能于一身的实用系统清理工具&#xff0c;它能帮助保持您的Mac保持清洁。只需两个简单的点击&#xff0c;就可以删除无用的文件&#xff0c;以节省您宝贵的磁盘空间。CleanMyMac X可以流畅地与系统性能相结合&#xff0c;清洁不需要的语言、…

EasyCVR新增角色分配分组功能的使用及注意事项

我们在此前的文章中分享过关于EasyCVR分组功能的更新&#xff0c;具体可以查看这篇文章&#xff1a;AI云边端EasyCVR平台新功能解析&#xff1a;支持为角色选择多级分组。今天我们来为大家介绍一下&#xff0c;新功能在配置时需要注意的事项。1、首先我们先简单回顾一下老版本的…

【Js】语法糖之数组解构和拆包表达式

文章目录数组结构拆包表达式来源数组结构 在ES5中&#xff1a;如果计划从数组中提取特定元素&#xff0c;就需使用元素的索引&#xff0c;并将其保存到变量之中。 在ES6中&#xff1a;新增数组解构功能&#xff0c;以简化获取数组中数据的过程。 数组解构采用了数组字面量的…

【SpringCloud复习巩固】Sentinel

sentinel 链接&#xff1a;https://pan.baidu.com/s/1lLJKBSDJNJgW5Lbru6NYrA 提取码&#xff1a;ut3g 目录 一.初识Sentinel 1.1雪崩问题及其解决方案 1.2认识Sentinel 1.3安装Sentinel控制台 1.4微服务整合sentinel 二.限流规则 2.1簇点链路 2.2流控规则 2.3流控效果…

从0~1实现 单体或微服务下 实现订单未支付超时取消功能 方案(2)-rocketmq 延迟队列方案 完整设计和源码

从0~1实现 单体或微服务下 订单未支付超时取消功能 方案&#xff08;1&#xff09;-java delayquene 注册中心(zookeeper/nacos)高可用方案从0~1实现 单体或微服务下 订单未支付超时取消功能 方案&#xff08;2&#xff09;-rocketmq 延迟队列方案 场景说明 我们日常接触的电…

IronPDF for .NET 2023.1 Crack

关于 .NET 的 IronPDF 创建、编辑和导出 PDF 文档。 IronPDF for .NET 允许开发人员在 C#、F# 和 VB.Net for .NET Core 和 .NET Framework 中轻松创建 PDF 文档。您可以选择简单的 HTML&#xff0c;或合并 CSS、图像和 JavaScript。IronPDF 呈现紧跟谷歌浏览器。 IronPDF 功能…

eclipse新手快捷键

1. ctrlshiftr&#xff1a;打开资源 这组Eclipse快捷键可以让你打开你的工作区中任何一个文件&#xff0c;而你只需要按下文件名或mask名中的前几个字母&#xff0c;比如applic*.xml。美中不足的是这组快捷键并非在所有视图下都能用。 2. ctrlo&#xff1a;快速outline 列出…