算法竞赛进阶指南 基本数据结构 0x17 二叉堆

二叉堆是一种支持插入、删除、查询最值的数据结构。它其实是一棵满足“堆性质“的完全二叉树(完全二叉树:叶子节点都在最后两层,且在最后一层集中于左侧的二叉树),树上的每个节点带有一个权值。若树中的任意一个节点的权值都小于等于其父节点的权值,则称该二叉树满足”大根堆性质”。若树中的任意一个节点的权值都大于等于其父节点的权值,则称该二叉树满足“小根堆性质”。

满足“大根堆性质”的完全二叉树就是“大根堆”,而满足“小根堆性质”的完全二叉树就是“小根堆”,二者都是二叉堆的形态之一。

根据完全二叉树的性质,我们可以采用层次序列存储方式,直接用一个数组来保存二叉堆。层次序列存储方式,就是逐层从左到右为树中的节点依次编号,把此编号作为节点在数组中存储的位置(下标)。在这种存储方式中,父节点编号等于子节点编号除以2,左子节点编号等于父节点编号乘2,右子节点编号等于父节点编号乘2加1。(下标从1开始)

我们以大根堆为例探讨堆支持的几种常见操作的实现。

Insert:
Insert(val)操作向二叉堆中插入一个带有权值val的新节点。我们把这个新节点直接放在存储二叉堆的数组末尾,然后通过交换的方式向上调整,直至满足堆性质。其时间复杂度为堆的深度,即 O ( l o g N ) O(logN) O(logN)

GetTop:
GetTop操作返回二叉堆的堆顶权值,即最大值heap[1],复杂度为O(1)

Extract:
Extract操作把堆顶从二叉堆中移除。我们把堆顶heap[1]与存储在数组末尾的节点heap[n]交换,然后移除数组末尾节点(令n减小1),最后把堆顶通过交换的方式向下调整,直至满足堆性质。其时间复杂度为堆的深度,即 O ( l o g N ) O(logN) O(logN)

Remove:
Remove§操作把存储在数组下标p位置的节点从二叉堆中删除。与Extract相类似,我们先把heap[p]与heap[n]交换,然后令n减小1。

C++STL中的priority_queue(优先队列)为实现了一个大根堆,支持push(Insert)、top(GetTop) 、pop(Extract)操作,不支持Remove操作

1、AcWing 145. 超市

题意 :

  • 超市里有 N 件商品,每件商品都有利润 pi 和过期时间 di,每天只能卖一件商品,过期商品不能再卖。
  • 求合理安排每天卖的商品的情况下,可以得到的最大收益是多少。

思路 :

  • 容易想到一个贪心策略:在最优解中,对于每个时间(天数)t,应该在保证不卖出过期商品的前提下,尽量卖出利润前t大的商品。因此,我们可以依次考虑每个商品,动态维护一个满足上述性质的方案。
  • 详细地说,我们把商品按照过期时间排序,建议一个初始为空的小根堆(节点权值为商品利润),然后扫描每个商品:
    1、若当前商品的过期时间(天数)t等于当前堆中的商品个数,则说明在目前方案下,前t天已经安排了t个商品卖出。此时,若当前商品的利润大于堆顶权值(即已经安排的t个商品中的最低利润),则替换掉堆顶(用当前商品替换掉原方案中利润最低的商品)。
    2、若当前商品的过期时间(天数)大于当前堆中的商品个数,直接把该商品插入堆。
  • 最终,堆里的所有商品就是我们需要卖出的商品,它们的利润之和就是答案
  • 该算法的时间复杂度为 O ( N l o g N ) O(NlogN) O(NlogN)
#include <iostream>
#include <queue>
#include <algorithm>
using namespace std;
typedef pair<int, int> PII;
const int N = 1e4 + 10;

int n;
PII a[N];

int main() {
    while (cin >> n) {
        for (int i = 0; i < n; ++ i) scanf("%d%d", &a[i].second, &a[i].first);
        sort(a, a + n);
        priority_queue<int, vector<int>, greater<int>> heap;
        for (int i = 0; i < n; ++ i) {
            heap.push(a[i].second);
            if (a[i].first < heap.size()) heap.pop();
        }
        int sum = 0;
        while (heap.size()) {
            sum += heap.top();
            heap.pop();
        }
        printf("%d\n", sum);
    }
}

2、AcWing 146. 序列

题意 :

  • 给定 m 个序列,每个包含 n 个非负整数。
  • 现在我们可以从每个序列中选择一个数字以形成具有 m 个整数的序列。
  • 很明显,我们一共可以得到 n m n^m nm个这种序列,然后我们可以计算每个序列中的数字之和,并得到 n m n^m nm 个值。
  • 现在请你求出这些序列和之中最小的 n 个值。

思路 :

  • 先来考虑当M = 2时的简化问题,即从2个序列中任取一个数相加构成的 N 2 N^2 N2个和中求出前N小的和。设这两个序列为A和B,把它们分别排序。
  • 可以发现,最小的和一定是A[1] + B[1],次小和是min(A[1] + B[2], A[2] + B[1])。假设次小和是A[2] + B[1],那么第3小和就是A[1] + B[2],A[2] + B[2],A[3] + B[1]这三者之一。也就是说,当确定A[i] + B[j]为第k小和后,A[i + 1] + B[j]与A[i] + B[j + 1]就加入了第k + 1小和的备选答案集合。读者可以类比右两个指针分别指向A[i]和B[j],把其中一个指针向后移动一位,就可能产生下一个和。
  • 需要注意的是,A[1] + B[2]和A[2] + B[1]都能产生A[2] + B[2]这个备选答案。为了避免重复,我们在一开始将所有A[1] + B[i]都放入堆中,此后双指针i和j中,只让A中的指针往后移动
  • 我们建立一个小根堆(因为优先队列可以快速找到最值并删除最值),一开始将所有A[1] + B[i]插入堆中;从堆中取n次最值,每次取出后即为A和B两个序列产生的第i小的和,假设取出的值是A[i] + B[j],那么我们下一个放入堆中的值是A[i + 1] + B[j],即A[i] + B[j] - A[i] + A[i + 1],因此,我们优先队列中存的值为PII形式,其中second表示A的下标
#include <iostream>
#include <algorithm>
#include <queue>
#include <cstring>
using namespace std;
typedef pair<int, int> PII;
const int N = 2e3 + 10;

int m, n;
int a[N], b[N], c[N];

void work() {
    priority_queue<PII, vector<PII>, greater<PII>> heap;
    for (int i = 0; i < n; ++ i) heap.push({a[0] + b[i], 0});
    for (int i = 0; i < n; ++ i) {
        PII t = heap.top();
        heap.pop();
        c[i] = t.first;
        heap.push({t.first - a[t.second] + a[t.second + 1], t.second + 1});
    }
    memcpy(a, c, n * sizeof(int));
}

int main() {
    int _; scanf("%d", &_);
    while (_ -- ) {
        scanf("%d%d", &m, &n);
        for (int i = 0; i < n; ++ i) scanf("%d", &a[i]);
        sort(a, a + n);
        for (int i = 1; i < m; ++ i) {
            for (int j = 0; j < n; ++ j) scanf("%d", &b[j]);
            sort(b, b + n);
            work();
        }
        for (int i = 0; i < n; ++ i) printf("%d ", a[i]);
        puts("");
    }
}

3、(Skip)数据备份

二、Huffman树

考虑这样一个问题:构造一棵包含n个叶子节点的k叉树,其中第i个叶子节点带有权值w_i,要求最小化 ∑ w i ∗ l i \sum w_i * l_i wili,其中l_i表示第i个叶子节点到根节点的距离。该问题的解被称为k叉Huffman树(哈夫曼树)。

为了最小化 ∑ w i ∗ l i \sum w_i * l_i wili,应该让权值大的叶子节点的深度尽量小。当k = 2时,我们很容易想到用下面这个贪心算法来求出二叉Huffman树。
1、建立一个小根堆,插入这n个叶子节点的权值。
2、从堆中取出最小的两个权值w_1和w_2,令ans += w_1 + w_2
3、建立一个权值为w_1 + w_2的树节点p,令p成为权值为w_1和w_2的树节点的父亲。
4、在堆中插入权值w_1 + w_2
5、重复第2~4步,直至堆的大小为1

最后,由所有新建的p与原来的叶子节点构成的树就是Huffman树,变量ans就是 ∑ w i ∗ l i \sum w_i * l_i wili的最小值。

对于k (k > 2)叉Huffman树的求解,直观的想法是在上述贪心算法的基础上,改为每次从堆中取出最小的k个权值。然而,仔细思考可以发现,如果在执行最后一轮循环时,堆的大小在2~k - 1之间(不足以取出k个),那么整个Huffman树的根的子节点个数就小于k。这显然不是最优解——我们任意取Huffman树中一个深度最大的节点,把它改为树根的子节点,就会使 ∑ w i ∗ l i \sum w_i * l_i wili变小。

因此,我们应该在执行上述贪心算法之前,补加一些额外的权值为0的叶子节点,使叶子节点的个数n满足 (n - 1) mod (k - 1) = 0。也就是说,我们让子节点不足k的情况发生在最底层,而不是根节点处。在(n - 1) mod (k - 1) = 0时,执行“每次从堆中取出最小的k个权值”的贪心算法就是正确的。

在这里插入图片描述

1、AcWing 148. 合并果子

题意 :

  • 达达决定把所有的果子合成一堆。
  • 每一次合并,达达可以把两堆果子合并到一起,消耗的体力等于两堆果子的重量之和。
  • 达达在合并果子时总共消耗的体力等于每次合并所耗体力之和。
  • 因为还要花大力气把这些果子搬回家,所以达达在合并果子时要尽可能地节省体力。
  • 1≤n≤10000,
#include <iostream>
#include <queue>
using namespace std;

int n;

int main() {
    scanf("%d", &n);
    int ans = 0;
    priority_queue<int, vector<int>, greater<int>> heap;
    for (int i = 0, x; i < n; ++ i) {
        scanf("%d", &x);
        heap.push(x);
    }
    while (heap.size() != 1) {
        int a = heap.top(); heap.pop();
        int b = heap.top(); heap.pop();
        ans += a + b;
        heap.push(a + b);
    }
    printf("%d", ans);
}

2、(Skip)荷马史诗

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值