手写堆(Heap)

本文深入探讨了堆排序算法,重点讲解了如何构建大根堆和小根堆,以及核心的上调整(up)和下调整(down)操作。此外,还介绍了在C++中实现这些操作的代码。堆排序的时间复杂度为O(n log n),优于某些O(n)算法。文章进一步讨论了在模拟堆中如何维护插入顺序的映射和反映射,以及如何使用heap_swap操作来处理结点交换。通过实例展示了堆排序、插入、删除和修改元素的操作流程。
摘要由CSDN通过智能技术生成


前言

堆有个常见的应用就是堆排序(HeapSort),将数据排序是次要的,这里主要是理解这一数据结构以及它的几个相关操作。

注*: 堆还有其他应用如各语言的STL或API中优先队列(priority_queue),都是用的大根堆或小根堆实现的。

首先,堆是一个完全二叉树,用来表示一个完整的数据集合,每个结点就是一个数据,为了降低操作的复杂度,利用数组heap[]来存取堆,其根结点为heap[1]
对于heap[n],它的左结点为heap[2 * n],右结点为heap[2 * n + 1]

一般都是对堆的根结点感兴趣,由此引申两个概念——大根堆和小根堆。
大根堆是指对于堆中任意一个结点,它的父结点的值一定大于该结点以及该结点的兄弟结点,因此大根堆的根结点一定是整个集合中值最大的,小根堆的定义反之亦然。

为了构建和维护堆,有以下两个核心操作:

up(x)表示向上调整结点x以保证堆符合性质,down(x)同理表示向下调整结点x以保证堆符合性质。
其中,这两个操作的复杂度与堆的高度是成正比的,而由完全二叉树的性质得知n节点的树的高度为logn,因此复杂度都是 O(logn) 的,这也是为什么堆会比一些 O(n) 算法来维护集合争宠的原因。

比如下面的堆中,结点16向上调整前为:

在这里插入图片描述

调整后:

在这里插入图片描述


小根堆为例,一般的操作为如下5个:

在这里插入图片描述

值得注意的是,“删除xx”这一操作其实本质上是 “值的覆盖”,因为在数组中搬动大量的元素会非常麻烦,所以一般都是将堆的尾部元素heap[size]覆盖掉要抹去的结点,然后再①向上或下调整、②size --,整个集合大小减1。这样就变相删除了


堆排序

输入一个长度为 n 的整数数列,从小到大输出前 m 小的数。

输入格式
第一行包含整数 n 和 m。

第二行包含 n 个整数,表示整数数列。

输出格式
共一行,包含 m 个整数,表示整数数列中前 m 小的数。

数据范围
1≤m≤n≤105,
1≤数列中元素≤109

输入样例:

5 3
4 5 1 3 2

输出样例:

1 2 3

建堆(着重理解)

一开始读入数据时只会for ~ i然后顺序存入heap[]里,此时不构成堆,所以需要再调整元素一遍使得变成一个堆。
一种思路是利用down操作,要知道down操作是向下调整子根堆,如果从最后一个结点heap[size]开始一直往前down()操作,直到根heap[i]为止,这样整个堆就建起来了。

分析复杂度可知,n个结点,每个结点down一遍,复杂度就是 O(n * logn),较高!
但仔细一想,似乎叶子结点没有down的必要啊,因为它没有子结点了,而它们的父结点down时必定会将其考虑至其中。
因为完全二叉树的性质:总结点为n —> 叶子结点的个数约为n / 2个,所以从第一个叶子结点的父结点开始,那么对应代码如下:

for (i = n / 2; i >= 1; i--)	down(i);

再来分析复杂度会怎样呢,这里有个推导公式:

在这里插入图片描述

可以惊奇地发现,复杂度降了近一个数量级!


C++代码

#include <iostream>
#include <algorithm>
using namespace std;

#define size Size
const int N = 1e5 + 10;

/**
 * 小根堆
 */

int n, m, size;
int heap[N];

void down(int x){       //递归向下调整子结点
    int t = x;      //t:存三个中最小的结点
    if(2 * x <= size && heap[t] > heap[2 * x])      t = 2 * x;		//比较左结点
    if(2 * x + 1<= size && heap[t] > heap[2 * x + 1])      t = 2 * x + 1;	//比较右结点
    
    //如果t == x那么这个子堆往下的所有子堆都调整好,退出
    
    if(t != x){
        swap(heap[x], heap[t]);
        down(t);
    }
}

int main(){
    cin >> n >> m;
    size = n;
    for(int i = 1;i <= n;i ++)      cin >> heap[i];
    
    for(int i = n / 2; i ;i --)     down(i);        //建堆
    
    while(m --){
        cout << heap[1] << " ";
        heap[1] = heap[size --];    //删除根
        down(1);        //维护堆
    }
    
    return 0;
}

模拟堆

维护一个集合,初始时集合为空,支持如下几种操作:

I x,插入一个数 x;
PM,输出当前集合中的最小值;
DM,删除当前集合中的最小值(数据保证此时的最小值唯一);
D k,删除第 k 个插入的数;
C k x,修改第 k 个插入的数,将其变为 x;
现在要进行 N 次操作,对于所有第 2 个操作,输出当前集合的最小值。

输入格式
第一行包含整数 N。

接下来 N 行,每行包含一个操作指令,操作指令为 I x,PM,DM,D k 或 C k x 中的一种。

输出格式
对于每个输出指令 PM,输出一个结果,表示当前集合中的最小值。

每个结果占一行。

数据范围
1≤N≤105
−109≤x≤109
数据保证合法。

输入样例:

8
I -10
PM
I -10
D 1
C 2 8
I 6
PM
DM

输出样例:

-10
6

映射和反映射

由于此时出现了一个新概念 “堆中插入的第k个数”,而heap[x]仅仅能映射出x的结点值,而如何通过x找到它是第几个被插入的点以及又如何通过次序k找到第k个插入点的结点下标呢?

所以就引入了两个映射数组:

正映射:ph,第k个数中的k映射—>结点下标i,即ph[k] = i
反映射:hp,结点下标i —> k,即hp[i] = k

由于phhp是实时记录的,所以在结点交换时不再是单纯的swap(heap[a], heap[b])了。
而是要使对应的映射跟着结点下标a、b变动。
所以新的swap操作:

void heap_swap(int a, int b){
    //a, b是两个结点的下标
    swap(heap[a], heap[b]);
    swap(hp[a], hp[b]);
    swap(ph[hp[a]], ph[hp[b]]);
}

有关于映射—反映射和其它几样操作的详细说明还可以参照这篇Acwing的文章:
👉如何理解模拟堆中的heap_swap,hp[N], ph[N]?


C++代码

#include <iostream>
#include <algorithm>
#include<string.h>
#define size Size

using namespace std;

const int N = 1e5 + 10;

char op[3];
int n, x, k, cnt = 0, size = 0, heap[N];
int ph[N], hp[N];        //ph:正映射,第k个数中的k映射—>结点下标i;  hp:反映射,结点下标i —> k

void heap_swap(int a, int b){       //由于引入两个映射,结点交换时会跟着变,所以专门封装一系列的swap操作
    //a, b是两个结点的下标
    
    swap(heap[a], heap[b]);
    swap(hp[a], hp[b]);
    swap(ph[hp[a]], ph[hp[b]]);
}

void down(int x){       //思路一样直接copy堆排序
    int t = x; 
    if(2 * x <= size && heap[t] > heap[2 * x])      t = 2 * x;
    if(2 * x + 1<= size && heap[t] > heap[2 * x + 1])      t = 2 * x + 1;

    if(t != x){
        heap_swap(t, x);
        down(t);
    }
}

void up(int x){
    while(x / 2 && heap[x / 2] > heap[x]){           //父结点存在且大于该结点就交换
        heap_swap(x / 2, x);
        x >>= 1;
    }
}

int main(){
    cin >> n;
    int i;
    while(n --){
        scanf("%s", op);
        
        if(!strcmp(op, "I")){       //尾部插入
            cin >> x;       //此时cnt也即是堆的 “size”
            size ++;        //结点下标
            cnt ++;           //第k个数
            ph[cnt] = size, hp[size] = cnt;
            heap[ph[cnt]] = x;
            up(ph[cnt]);
        }
        
        else if(!strcmp(op, "PM"))      cout << heap[1] << endl;
        
        else if(!strcmp(op, "DM")){     //用尾部交换成根结点后size-- 代以删除
            heap_swap(1, size);
            size --;
            down(1);
        }
         
        else if(!strcmp(op, "D")){
            cin >> k;
            i = ph[k];      //k对应的结点下标
            heap_swap(i, size);
            size --;
            up(i), down(i);     //up和down只会执行其中一个
        }
        
        else{
            cin >> k >> x;
            i = ph[k];
            heap[i] = x;
            up(i), down(i);
        }
    }
    
    return 0;
}
  • 1
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值