C++:【数据结构】模拟堆

堆是一种很重要的数据结构,这篇文章主要介绍一下数组实现的堆以及基本的一些操作。

一、背景知识

1、堆

一般说到堆时,我们所指的都是二叉堆。它是一棵被完全填满的二叉树(完全二叉树),意思就是他是一个很完整的二叉树,除了最底层,每个节点都被完全填满。可能有些抽象,我们举个例子:
在这里插入图片描述
然后呢,还有一个很重要的发现,我们按层数来给节点排个序:
在这里插入图片描述
通过观察我们可以发现一个重要的规律
节点 k 的左儿子2k ,右儿子是 2k + 1 。节点 k 的父节点是 k / 2 (向下取整)。有了这些规律,我们就可以用一个数组来建树了:
在这里插入图片描述
根据上面的规律,我们不难用数组建出这样的一棵树。举个例子,C 的下标是 3 ,那么它的儿子是 6 和 7 ,也就是 F 和 G ,符合我们的完全二叉树。别忘了,这篇文章是介绍堆的,而堆就是一棵完全二叉树,所以上面的过程也就成功的完成了建堆
注意:堆数组下标为0的位置是不存储数据的。因为 0 * 2 还是0,不符合这个规律,所以堆数组是从 1 开始存储的,并且 1 是整个堆的根节点。
至于为什么要用数组来模拟堆呢,好处就是,因为操作不需要指针,所以效率高,操作简单呀。但是也有一点不足,就是这个数组的大小需要预先设置好,但是影响不大。

2、大根堆

一个堆,就是一堆数,也没什么技术含量。所以我们得加点料,让它稍微有点技术含量。由此,我们来介绍大根堆和小根堆。

所谓的大根堆,就是根节点是整个堆树的最大值,而对于每个子树,子树的根节点也是子树的最大值。说白了就是,在一个节点和它的两个儿子里面,那个节点是三者的最大值,并且所有节点都满足这个性质。

3、小根堆

小根堆,顾名思义就是大根堆的反例,也就是根节点最小值,和大根堆完全类似的定义。

二、实现

1、up和down操作

这里我们拿小根堆来举例,明白了小根堆,大根堆也就没有什么困难了。

首先我们有一堆数,这些数没有任何规律,我们要做的就是把它们建成一个小根堆。我们可以先把这些数存入数组,然后把数组改造成小根堆;我们也可以一个数一个数地读入,在读入的过程中就把数排好。那么怎么实现这个改造的操作呢,首先介绍两个基本操作:上浮和下沉
我们知道,小根堆里面,某个节点下面的数一定是大于这个节点的。所以如果出现下面的数比上面的数小的情况,那么我们就要把那个小的数上浮,这样才能保持小根堆的性质嘛。实现起来比较简单,因为每个节点只有一个父亲节点,所以我们只需要把它和它的父亲比较一下,如果它比它父亲小,那么就把当前节点和它的父节点交换一下,直到它小于当前的父节点,或者到达根节点的时候停止。我们用代码来实现一下这个操作:

//h数组是堆数组,因为堆的英文单词是 heap ,下同
//传入堆数组某一个数的下标
void up(int u)
{
    //u / 2 是为了判断一下是否到达了根节点,h[u]是当前节点,h[u / 2]是当前节点的父亲节点
    //我们要判断一下当前节点是不是比它的父亲节点小,如果比父节点小,那就要交换一下
    while (u / 2 && h[u] < h[u / 2])
    {
        //swap是C++里交换两个数的值的函数,如果没有,也可以自己实现一下交换操作
        swap(h[u], h[u / 2]);
        //交换完以后,这里是满足了,但是不确定更上面的性质有没有被破坏。
        //所以我们要再往上面走一层,去看看上面的那一层需不需要操作
        //而向上走一层的操作就是让当前被操作的节点找到它的父节点,然后去操作父节点
        u /= 2;
    }
}

相反的,某个节点上面的数一定是小于这个节点的。如果出现上面的数大于当前节点的情况,那就要将这个数下沉到合适的位置,这样才能保持小根堆的性质。下沉操作比上浮操作稍微繁琐一点,因为一个数有两个子节点,所以要判断一下向左边还是右边下沉。不过也好理解,为了保持某一结点一定比它的两个儿子小,我们只需要找到此节点和它的两个儿子中的最小值,然后用这个最小值替换掉此节点就好。替换之后呢还没完,万一你把替换后的那一侧的性质破坏了怎么办,不急,我们只需要再下沉一下被替换那一侧的数就好了,直到下沉操作进行到某一个节点,不用操作就已经是满足小根堆性质的时候,就不需要再下沉了。我们用代码来实现一下:

//传入的是堆数组的下标
void down(int u)
{
    //用一个临时变量 t 来存储三点中的最小值
    int t = u;
    //判断哪个是最小的(u和它的两个儿子)
    if (u * 2 <= cnt && h[u * 2] < h[t]) t = u * 2;
    if (u * 2 + 1 <= cnt && h[u * 2 + 1] < h[t]) t = u * 2 + 1;
    //如果发现确实儿子节点有比较小的,把最小的那个交换上来
    //如果u本来就是最小的,那这一步就不用进行了
    if (u != t)
    {
        swap(h[u], h[t]);
        //换下去以后,更下面的堆的性质可能被破坏,这时候我们再去看看下面还满不满足性质
        down(t);
    }
}

大根堆的操作和这个类似,交给你们自己去实现了。
有了这连个操作,我们就能解决堆中所有的基本操作了。

2、建堆

有了 up 和 down 操作,我们建堆就很容易了。假如先给出一个乱序的数组,我们只需要从最底层到根节点(也就是数组从最后一个元素到下标为 1 的元素) 全部down一遍就好,注意是从下至上的down。为什么不是从上到下的 down 呢?因为 down 操作后,可以保证的是被操作的那个节点下面的所有节点都是满足性质的,所以从最底层开始,依次向上,到根节点的时候,可以保证根节点以下都是满足堆序性的。但是如果从上至下,假设进行到了中间某个节点,对它进行了 down 操作,那么它下面的所有节点是满足堆序性了,但是不能保证它上面的节点也满足堆序性。所以要从下至上的down。我们还是拿例子来说:
现在有一个数组是这样,然后它对应的堆是这样:
在这里插入图片描述
我们对它进行从下至上的down

    for (int i = n; i; i --) down(i);
    for (int i = 1; i <= n; i ++ ) cout << h[i] << ' ';

得到了
在这里插入图片描述
现在我们把原乱序堆从上至下down一遍(错误做法

    for (int i = 1; i <= n; i ++) down(i);
    for (int i = 1; i <= n; i ++ ) cout << h[i] << ' ';

得到了
在这里插入图片描述
可以看出两次操作得到的结果不一样。我们把它放到树里看:
在这里插入图片描述
我们可以看到,从上至下得到的堆是满足堆序性的,但是从下至上得到的堆就不满足了。
我们拿最直观的一个例子来说:错误做法里,5 号位置的 2 是由 10 号位置的 35 换上去的,也就是说本来是这样,然后down了以下 5 号位置:
在这里插入图片描述
但是呢,down 5 号位置只能满足以 5 节点为根节点的子树的所有节点满足堆序性,但是无法保证 5 上面的堆序性了。所以一定要注意从下往上的down!!
对于 up 操作也是同理,进行从上往下的up就可以完成建堆了。逻辑同 down 是一样的。

但是这样建堆未免有些麻烦,down的时候我们发现,对本来在叶子节点(也就是没有儿子的节点)进行down后,没啥用,本来就在最下面了,没有地方让它下沉了。所以我们可以简化以下,只 down 非叶子节点的节点。也就是说,省去 down 叶子节点,只从最后一个非叶子节点开始 down 到第一个节点。那么如何去找最后一个非叶子节点呢?这也好说,最后一个叶子节点是 n ,那最后一个非叶子节点就是它的父节点嘛,也就是 n / 2 。于是我们有了简化的建堆方式:

    for (int i = n / 2; i; i -- ) down(i);

可以证明,全 down 一遍的时间复杂度是 O(NlogN) 的,简化版的时间复杂度是 O(N) 的。具体怎么得来的这里就不多赘述,毕竟重点不在于时间复杂度上。

3、访问最小值

访问最小值的操作最简单了,因为是小根堆,我们只要看看堆数组一号位(也就是堆的根节点)是几就行了。最简单,我们就最先说。

4、插入一个数

我们想插入一个数,只需要在原堆数组中加一个数(相当于多加了一个节点),当然这个数在数组的最后(也就是堆的最底层),所以我们只需要把它 up 一下就好了。另外我们还需要一个变量来表示当前用到了数组中的哪个位置,就叫 cnt 吧:

scanf("%d", &x);
h[ ++ cnt] = x;
up[cnt];

5、删除最小值

我们想删掉最小值,只需要用原来堆里的随便一个数把根节点覆盖一下,然后再 down 一下那个数就好了。为了方便,我们选最后一个数来覆盖根节点,再把计数器 cnt 减一下,正好抹掉了最后一个元素,而最后一个元素这时已经到了最上面,我们再down一下就好。

swap(h[cnt], h[1]);
cnt -- ;
down(1);

6、修改任意一个值

假设我们要修改堆数组中下标是 k 的元素,直接修改就行。但是这时候我们可能破坏了堆序性,但是也问题不大,我们只需要分情况把被修改的元素 down 一下或 up 一下就可以了。但是分情况太麻烦了,我们直接 down 一下,再 up 一下,虽然写了两次操作,但是这两个里面只有一个会执行,这样就省去了我们判断的麻烦。

scanf("%d", &x);
h[k] = x;
down(k);
up(k);

7、删除任意一个值

想删除堆数组中下标是 k 的元素,和删除最小值差不多,只要用最后一个元素把我们想删除的值覆盖就行了。但是有一点不同,覆盖最小值(根节点)后,因为在堆顶,所以我们只要 down 就行,但是任意一个数有可能在中间,所以我们要根据实际情况看看是 down 一下还是 up 一下。上面说了,懒得判断,所以直接全进行一次。

swap[h[k], h[cnt]);
cnt -- ;
down(k);
up(k);

到这里我们的小根堆操作就介绍完了,只要理解了小根堆,大根堆照葫芦画瓢就可以写出来。

三、例题

我们用两道例题来实际操作一下。

例题1

acwing.堆排序

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

输入格式
第一行包含整数 n 和 m。
第二行包含 n 个整数,表示整数数列。

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

数据范围
1≤m≤n≤105
1≤数列中元素≤109
输入样例

5 3
4 5 1 3 2

输出样例

1 2 3

这道题明显可以用其他的排序来做,但是为了练习一下堆的使用嘛,我们就用小根堆来实现一下。把上面所说的组合拼凑一下就差不多解完了,我们只要想想怎么依次输出最小值就可以。其实也好说,在没有什么别的要求的情况下,我们只要输出堆顶元素就是最小值,然后把输出的这个堆顶元素删掉,再维护一下堆序性,以此类推,依次输出堆顶元素。
题解:

#include <iostream>
#include <cstdio>

using namespace std;

const int N = 100010;

//cnt用来记录当前的堆数组用到了哪个位置
int h[N], cnt;

void down(int u)
{
    int t = u;
    if (u * 2 <= cnt && h[u * 2] < h[t]) t = u * 2;
    if (u * 2 + 1 <= cnt && h[u * 2 + 1] < h[t]) t = u * 2 + 1;
    if (u != t)
    {
        swap(h[u], h[t]);
        down(t);
    }
}

int main()
{
    int n, m;
    cin >> n >> m;
    for (int i = 1; i <= n; i ++ ) scanf("%d", &h[i]);
    cnt = n;
    
    for (int i = n / 2; i; i -- ) down(i);
    
    while (m -- )
    {
        printf("%d ", h[1]);
        h[1] = h[cnt -- ];
        down(1);
    }
    
    puts("");
    
    return 0;
}

因为这道题比较简单,用不到up操作,所以就没写up。

例题2

acwing.模拟堆

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

  1. I x,插入一个数 x;
  2. PM,输出当前集合中的最小值;
  3. DM,删除当前集合中的最小值(数据保证此时的最小值唯一);
  4. D k,删除第 k 个插入的数;
  5. 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 个插入的数。我们在建堆的时候,数据之间换来换去,插入顺序已经很混乱了,所以这就是这道题最难的一点。当然这种维护数据插入顺序的堆比较少见,所以学有余力的同学可以看一下。
因为我们需要维护数据插入的顺序,所以我们需要额外开个数组来存储插入顺序。具体来说是这样的:ph[N] 数组下标为 i 的元素为 j ,表示第 i 个插入的数在堆数组中下标为 j
当然这还不够,我们要能找到第 k 个插入的元素是几,同时也要找到堆数组中某个元素是第几个插入的。我们再开一个数组:hp[N] 数组下标为 i 的元素为 j ,表示堆数组中下标为 i 的元素是第 j 个插入的
我们可以发现,其实这俩就是一个对应的关系。比如 ph[i] 的值为 j ,那么 hp[j] 的值就是 i 。

除此之外,我们还需要一个新的swap操作。因为之前的 swap 操作就是把堆数组中两个数的值简单交换一下。但是这道题目里面,因为要维护数据插入顺序,所以我们当然不能简单的交换一下,因为交换以后要考虑 hp 数组和 ph 数组是怎么变化的,因为插入顺序不能乱。
那么我们要怎么高级地交换一下呢?首先我们先传入两个数 a b ,表示待交换的堆数组的下标。因为我们给出的是下标,所以我们首先要通过下标找到它是第几个插入的数,即 hp[a] 和 hp[b] ,这时候我们要交换一下 ph 数组中这两个数,即交换一下 ph[hp[a]] 和 ph[hp[b]] 。
有点抽象,这是什么意思呢,就是说假如我们想交换一下堆数组中下标是 a 和 b 的数,其中下标是 a 的数是第 i 个插入的,下标是 b 的数是第 j 个插入的。我们画图来说明一下,假设 p[a] 是1,p[b] 是2。
在这里插入图片描述
这时候如果我们直接交换一下 p[a] 和 p[b] 的值,那么就会:
在这里插入图片描述
这样就乱套了,所以肯定是不能直接交换的。我们要做的是什么呢,我们要做的是把 ph 数组的对应关系也交换一下。那么我们就要把 ph[i] 和 ph[j] 交换一下。但是条件给出的是 a 和 b,别急,我们还有 hp 数组,回想一下上面写到的,hp[a] = i ,hp[b] = j ,所以这时候我们要交换的就是ph[hp[a]] 和 ph[hp[b]] 了。交换以后,对应关系就成立了。别忘了 hp[a] 和 hp[b] 的值也要交换一下,因为 h ph hp 这三者是一一对应的嘛,交换其中一个,另外两个也要交换。
所以我们就得到了我们的交换堆数组中两个数(高级版):

void heap_swap(int a, int b)
{
    swap(ph[hp[a]], ph[hp[b]]);
    swap(hp[a], hp[b]);
    swap(h[a], h[b]);
}

有了这个交换操作,我们只需要把上面讲过的低级swap换成我们的高级heap_swap就成了。稍加修饰,给出本题题解:

#include <iostream>
#include <algorithm>
#include <cstring>

using namespace std;

const int N = 100010;

//cnt是用来存储当前堆中有多少元素的(也就是堆数组用到了哪个下标)
//m是用来存储现在是第几个插入的数
//所以cnt可以增加也可以减少,但m只会增加不会减少
int h[N], ph[N], hp[N], cnt, m;

void heap_swap(int a, int b)
{
    swap(ph[hp[a]], ph[hp[b]]);
    swap(hp[a], hp[b]);
    swap(h[a], h[b]);
}

void down(int u)
{
    int t = u;
    if (u * 2 <= cnt && h[u * 2] < h[t]) t = u * 2;
    if (u * 2 + 1 <= cnt && h[u * 2 + 1] < h[t]) t = u * 2 + 1;
    if (u != t)
    {
        heap_swap(u, t);
        down(t);
    }
}

void up(int u)
{
    while (u / 2 && h[u] < h[u / 2])
    {
        heap_swap(u, u / 2);
        u >>= 1;
    }
}

int main()
{
    int n;
    cin >> n;
    
    while (n -- )
    {
        char op[5];
        int k, x;
        scanf("%s", op);
        
        //注意这里一开始堆是空的,所以不需要建堆操作。
        if (!strcmp(op, "I"))
        {
            scanf("%d", &x);
            cnt ++ ;
            m ++ ;
            ph[m] = cnt, hp[cnt] = m;
            h[cnt] = x;
            up(cnt);
        }
        else if (!strcmp(op, "PM")) printf("%d\n", h[1]);
        else if (!strcmp(op, "DM"))
        {
            heap_swap(1, cnt);
            cnt -- ;
            down(1);
        }
        else if (!strcmp(op, "D"))
        {
            scanf("%d", &k);
            k = ph[k];
            heap_swap(k, cnt);
            cnt -- ;
            up(k);
            down(k);
        }
        else
        {
            scanf("%d%d", &k, &x);
            k = ph[k];
            h[k] = x;
            up(k);
            down(k);
        }
    }
    
    return 0;
}
  • 8
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值