基础数据结构 - 二叉堆

目录

二叉堆

二叉堆是一种支持插入、删除、查询最值的数据结构。他其实是一颗满足 “堆性质” 的完全二叉树,树上每个节点带有一个权值。

堆性质

若树中的任意节点的权值都小于等于其父节点的权值,则称二叉树满足 “大根堆性质” 。反之,则称二叉树满足 “小根堆性质” 。

根据这些描述,就有了它的实现代码了,首先它是一颗完全二叉树,故可以使用数组来存这颗树。

定义二叉堆

int heap[N], n;

插入操作:

void up(int p) {
    while(p > 1) {
        if(heap[p] > heap[p >> 1]) {
            swap(heap[p], heap[p >> 1]);
            p >>= 1;
        }
        else break;
    }
}

void insert(int x) {
    heap[++n] = x;
    up(n);
}

弹出对顶操作

void down(int p) {
    int s = p << 1;
    while(s <= n) {
        if(s < n && heap[s] < heap[s + 1]) s++;
        if(heap[s] > heap[p]) {
            swap(heap[s], heap[p]);
            p = s, s = p << 1;
        } else break;
    }
}

void pop() {
    heap[1] = heap[n--];
    down(1);
}

移除元素操作

void remove() {
	heap[k] = heap[n--];
	up(k), down(k);
}

通常我们可以使用 STL 里的优先队列,不过它并没有提供删除元素的功能。

【例题】超市

超市里有 N N N 件商品,每件商品都有利润 p i p_i pi 和过期时间 d i d_i di,每天只能卖一件商品,过期商品不能再卖。

求合理安排每天卖的商品的情况下,可以得到的最大收益是多少。

数据范围
0 ≤ N ≤ 10000 , 1 ≤ p i , d i ≤ 10000 0≤N≤10000, 1≤p_i,d_i≤10000 0N10000,1pi,di10000

分析:

显然一个直白的贪心策略:当前若当前是第 t t t 天,那么一定是选择所有不过期的物品中,利润前 t t t 大的商品。

显然这个 t t t 是不知道的,它是由所有商品的过期时间决定的,故有了下面的算法:

  1. 对于商品的过期时间排序,建立一个小根堆,然后扫描每个商品,小根堆维护的是可选商品集合。

  2. 若当前商品过期时间 t t t 等于堆中元素个数,说明目前可选商品中要想添加它,必须放弃一个商品。

    • 若当前商品价值大于对顶,则替换对顶。
    • 反之,就没必要选择。
  3. 若大于,则直接插入堆就行了。

最终堆中的元素就是选择方案了。

代码如下:

#include <bits/stdc++.h>
using namespace std;
const int N = 100005;
int n;

int main()
{

    while(cin >> n) {
        priority_queue<int, vector<int>, greater<int> > heap;
        vector<pair<int, int>> a(n);
        for(auto &it : a) {
            cin >> it.second >> it.first ;
        }
        
        sort(a.begin(), a.end());
        
        for(auto it : a) {
            int d = it.first, p = it.second;
            if(d == heap.size() && p > heap.top()) {
                heap.pop();
                heap.push(p);
            } else if(d > heap.size()) {
                heap.push(p);
            }
        }
        
        int res = 0;
        
        while(!heap.empty()) {
            res += heap.top();
            heap.pop();
        }
        
        cout << res << endl;
        
    }
    
    return 0;
}

【例题】序列

给定 m m m 个序列,每个包含 n n n 个非负整数。

现在我们可以从每个序列中选择一个数字以形成具有 m m m 个整数的序列。

很明显,我们一共可以得到 n m n^m nm 个这种序列,然后我们可以计算每个序列中的数字之和,并得到 n m n^m nm 个值。

现在请你求出这些序列和之中最小的 n n n 个值。

数据范围
0 < m ≤ 1000 , 0 < n ≤ 2000 0<m≤1000, 0<n≤2000 0<m1000,0<n2000

分析:

首先确认暴力怎么选:对于 这 m m m 个序列排序,那么最小的肯定是选择每个序列的最小元素,那么第二小的肯定将这里面选择一个数替换称第二大的数,使得总数与前一个差值最小,那么在确定这第二大方案时,直接确认就十分难做。

所以我们将问题缩小,如果 m = 2 m =2 m=2 怎么确认?

所以现在有两个序列 A A A B B B,将他们排序,最小的肯定是 A [ 1 ] + B [ 1 ] A[1] + B[1] A[1]+B[1] 的方案,次小就是 min ⁡ ( A [ 1 ] + B [ 2 ] , A [ 2 ] + B [ 1 ] ) \min(A[1] + B[2],A[2] + B[1]) min(A[1]+B[2],A[2]+B[1]),假设次小就是 A [ 1 ] + B [ 2 ] A[1] + B[2] A[1]+B[2] ,那么第 3 3 3 小就是 min ⁡ ( A [ 1 ] + B [ 3 ] , A [ 2 ] + B [ 1 ] , A [ 2 ] + B [ 2 ] ) \min(A[1] + B[3],A[2] + B[1],A[2]+B[2]) min(A[1]+B[3],A[2]+B[1],A[2]+B[2]),那么也就是说,如果已经确认 A [ i ] + A [ j ] A[i]+A[j] A[i]+A[j] 是第 k k k 小后,那么参与第 k + 1 k+1 k+1 小的方案一定会添加上 A [ i + 1 ] + B [ j ] A[i+1]+B[j] A[i+1]+B[j] A [ i ] + B [ j + 1 ] A[i] + B[j+1] A[i]+B[j+1] ,因为 A [ 1 ] + B [ 2 ] A[1]+B[2] A[1]+B[2] A [ 2 ] + B [ 1 ] A[2] +B[1] A[2]+B[1] 都会推出 A [ 2 ] + B [ 2 ] A[2]+B[2] A[2]+B[2] ,所以一定要处理这种重复问题,因此规定,如果 A [ i ] + B [ j ] A[i] + B[j] A[i]+B[j] i + 1 i+1 i+1 则不能下一个不能推出 j + 1 j+1 j+1

这样我们推出了序列 1 1 1 和序列 2 2 2 的前 n n n 小后的新序列,拿这个新序列和序列 3 3 3 在形成新的序列,这样问题就问不断缩小, m − 1 m - 1 m1 次后的新序列就是答案了。

代码如下:

#include <bits/stdc++.h>
using namespace std;
const int N = 2010, M = 1010;

int nums[M][N];
int a[N], b[N], c[N];

struct Node {
    int l, r;
    bool flag;
    
    bool operator < (const Node &W) const {
        return a[l] + b[r] > a[W.l] + b[W.r];
    }
};


int main()
{
    int t;
    cin >> t;
    while(t -- ){
        int m, n;
        cin >> m >> n;
        priority_queue<Node> heap;
        
        for(int i = 0; i < m; ++i) {
            for(int j = 0; j < n; ++j) {
                scanf("%d", &nums[i][j]);
            }
            sort(nums[i], nums[i] + n);
        }
        
        if(m == 1) {
            for(int i = 0; i < n; ++i)
                printf("%d ", nums[0][i]);
            putchar('\n');
            continue;
        }
        
        memcpy(a, nums[0], sizeof nums[0]);
        memcpy(b, nums[1], sizeof nums[1]);
        
        heap.push({0,0,false});
        
        for(int i = 0; i < n; ++i){
            auto it = heap.top();
            heap.pop();
            c[i] = a[it.l] + b[it.r];
            heap.push({it.l, it.r + 1, true});
            if(!it.flag) {
                heap.push({it.l + 1, it.r, false});
            } 
        }
        
        int idx = 2;
        while(idx < m) {
            memcpy(a, c, sizeof c);
            memcpy(b, nums[idx], sizeof nums[idx]);
            while(!heap.empty()) heap.pop();
            heap.push({0,0,false});
        
            for(int i = 0; i < n; ++i){
                auto it = heap.top();
                heap.pop();
                c[i] = a[it.l] + b[it.r];
                heap.push({it.l, it.r + 1, true});
                if(!it.flag) {
                    heap.push({it.l + 1, it.r, false});
                } 
            }
            idx++;
        }
        
        for(int i = 0; i < n; ++i)
            printf("%d ", c[i]);
        putchar('\n');
        
    }
    return 0;
}

【例题】数据备份

你在一家 I T IT IT 公司为大型写字楼或办公楼的计算机数据做备份。

然而数据备份的工作是枯燥乏味的,因此你想设计一个系统让不同的办公楼彼此之间互相备份,而你则坐在家中尽享计算机游戏的乐趣。

已知办公楼都位于同一条街上,你决定给这些办公楼配对(两个一组)。

每一对办公楼可以通过在这两个建筑物之间铺设网络电缆使得它们可以互相备份。

然而,网络电缆的费用很高。

当地电信公司仅能为你提供 K K K 条网络电缆,这意味着你仅能为 K K K 对办公楼(总计 2 K 2K 2K 个办公楼)安排备份。

任意一个办公楼都属于唯一的配对组(换句话说,这 2 K 2K 2K 个办公楼一定是相异的)。

此外,电信公司需按网络电缆的长度(公里数)收费。

因而,你需要选择这 K K K 对办公楼使得电缆的总长度尽可能短。

换句话说,你需要选择这 K K K 对办公楼,使得每一对办公楼之间的距离之和(总距离)尽可能小。

下面给出一个示例,假定你有 5 5 5 个客户,其办公楼都在一条街上,如下图所示。

5 5 5 个办公楼分别位于距离大街起点 1 k m , 3 k m , 4 k m , 6 k m 1km,3km,4km,6km 1km,3km,4km,6km 12 k m 12km 12km 处。

电信公司仅为你提供 K = 2 K=2 K=2 条电缆。

在这里插入图片描述

上例中最好的配对方案是将第 1 1 1 个和第 2 2 2 个办公楼相连,第 3 3 3 个和第 4 4 4 个办公楼相连。

这样可按要求使用 K = 2 K=2 K=2 条电缆。

1 1 1 条电缆的长度是 3 k m − 1 k m = 2 k m 3km−1km=2km 3km1km=2km,第 2 2 2 条电缆的长度是 6 k m − 4 k m = 2 k m 6km−4km=2km 6km4km=2km

这种配对方案需要总长 4 k m 4km 4km 的网络电缆,满足距离之和最小的要求。

数据范围
2 ≤ n ≤ 100000 , 1 ≤ K ≤ n / 2 , 0 ≤ s ≤ 1000000000 2≤n≤100000 , \\ 1≤K≤n/2, \\ 0≤s≤1000000000 2n100000,1Kn/2,0s1000000000

分析:

把题意提炼一下:给定长度为 n n n 的序列 A A A ,现在通过 A A A 构造一个序列 D D D ,其中 D i = A i + 1 − A [ i ] , i ∈ [ 1 , n − 1 ] D_i=A_{i+1}-A[i],i\in[1,n-1] Di=Ai+1A[i],i[1,n1] 。现在从序列 D D D 中选出 K K K 个互不相邻的元素的和最小。

如果 K = 1 K=1 K=1 ,则肯定是最小的 D i D_i Di 就行。
如果 K = 2 K=2 K=2,那么就两种情况:

  1. 选择最小值 D i D_i Di ,以及除了 D i − 1 D_{i-1} Di1 D i D_i Di D i + 1 D_{i+1} Di+1 之外其他数中的最小值。
  2. 选最小值 D i D_i Di 的左右两个数 D i − 1 D_{i-1} Di1 D i + 1 D_{i+1} Di+1

证明方法就是反证法:对于方案一很容易,对于方案二,如不是相邻的两个数,必然可以将其中一个数替换成 D i D_i Di 更优。

那么如果 K = 3 K=3 K=3

K = 2 K=2 K=2 所选的数选这其中一个进行贪心。依次类推。

所以我们可以构建一个集合表示所选数,然后从 K = 1 K = 1 K=1 依次递推到 K = k K = k K=k ,最后堆中就是答案了,而方案而需要选择相邻元素,故序列 D D D 使用链表储存。而每次我们肯定是尽力选择小的方案,所以这个集合用小根堆。所以有了下面算法:

  1. 取出堆顶,把权值加到答案中。设堆顶储存了它在链表的位置 p p p ,其权值为 D ( p ) D(p) D(p)
  2. 在链表中删除 p p p p − > p r e v p->prev p>prev p − > n e x t p->next p>next 。在同样的位置添加一个新节点其权值为方案二,即 L ( p − > p r e v ) + L ( p − > n e x t ) − L ( p ) L(p->prev) + L(p->next) - L(p) L(p>prev)+L(p>next)L(p) 。堆中也要删除对应得 p p p 得前驱节点和后驱节点,然后把新节点 p p p 插入堆中。

重复 K K K 次就是答案了。

代码如下:

#include <bits/stdc++.h>
using namespace std;
const int N = 200005;
int n, K;
int e[N], l[N], r[N], idx;
struct Node {
    int p;
    bool operator < (const Node &W) const {
        return e[p] > e[W.p];
    }
};
void init() {
    r[0] = 1, l[1] = 0;
    idx = 2;
}

void insert(int p, int x) {
    e[idx] = x;
    l[idx] = p, r[idx] = r[p];
    l[r[p]] = idx, r[p] = idx++;
}

void remove(int p) {
    l[r[p]] = l[p];
    r[l[p]] = r[p];
}



int main()
{
    init();
    scanf("%d%d", &n, &K);
    vector<int> a(n);
    priority_queue<Node> heap;
    
    for(int i = 0; i < n; ++i) {
        scanf("%d", &a[i]);
    }
    
    for(int i = 0, x; i < n - 1; ++i) {
        x = (a[i + 1] - a[i]);
        heap.push({idx});
        insert(0, x);
    }
    
    int res = 0;
    while(K--) {
        auto it = heap.top();
        heap.pop();
        res += e[it.id];
        
        int p = it.id;
        int temp = e[l[p]] + e[r[p]] - e[p]; 
        int q = l[l[p]];
        
        remove(l[p]);
        remove(p);
        remove(p);
        
        heap.push({idx});
        insert(q, temp);
    }
    
    cout << res ;
    
    return 0;
}

Huffman 树

k k k 叉树最小权重问题

构造一颗包含 n n n 个叶子节点的 k k k 叉树,其中第 i i i 个叶子节点带有权值 w i w_i wi,要求最小化 ∑ w i × l i \sum w_i\times l_i wi×li ,其中 l i l_i li 表示 第 i i i 个叶子节点到根节点的距离。

其解法就是 k k k H u f f m a n Huffman Huffman 树。

k = 2 k=2 k=2 时,使用堆就可以解决了:

  1. 建立一个小根堆,插入 n n n 个叶子节点的权值。
  2. 从堆中取出最小的两个权值 w 1 w_1 w1 w 2 w_2 w2 ,令 a n s + = w 1 + w 2 ans += w_1 + w_2 ans+=w1+w2
  3. 建立一个权值为 w 1 + w 2 w_1 + w_2 w1+w2 的树节点 p p p p p p w 1 w_1 w1 w 2 w_2 w2 的父亲节点。
  4. 堆中插入 w 1 + w 2 w_1 + w_2 w1+w2
  5. 重复 2 ∼ 4 2\thicksim 4 24 ,直到堆大小为 1 1 1

最后 a n s ans ans 就是最小值了。

k > 2 k>2 k>2 时,需要满足 n n n ( n − 1 )   m o d   ( k − 1 ) = 0 (n-1)~mod~(k-1) = 0 (n1) mod (k1)=0 ,这样才满足 k k k H u f f m a n Huffman Huffman 树的要求。当 n n n 不满足的时候,我们可以添加 0 0 0 元素使得 n n n 满足要求。

【例题】合并果子

在一个果园里,达达已经将所有的果子打了下来,而且按果子的不同种类分成了不同的堆。

达达决定把所有的果子合成一堆。

每一次合并,达达可以把两堆果子合并到一起,消耗的体力等于两堆果子的重量之和。

可以看出,所有的果子经过 n − 1 n−1 n1 次合并之后,就只剩下一堆了。

达达在合并果子时总共消耗的体力等于每次合并所耗体力之和。

因为还要花大力气把这些果子搬回家,所以达达在合并果子时要尽可能地节省体力。

假定每个果子重量都为 1 1 1,并且已知果子的种类数和每种果子的数目,你的任务是设计出合并的次序方案,使达达耗费的体力最少,并输出这个最小的体力耗费值。

例如有 3 3 3 种果子,数目依次为 1 , 2 , 9 1,2,9 129

可以先将 1 1 1 2 2 2 堆合并,新堆数目为 3 3 3,耗费体力为 3 3 3

接着,将新堆与原先的第三堆合并,又得到新的堆,数目为 12 12 12,耗费体力为 12 12 12

所以达达总共耗费体力 = 3 + 12 = 15 =3+12=15 =3+12=15

可以证明 15 15 15 为最小的体力耗费值。

分析:

这是经典的 H u f f m a n Huffman Huffman 树用法,其合成的过程用树来表示,那么就是 2 2 2 叉树最小权重问题 。

代码如下:

#include <bits/stdc++.h>
using namespace std;

int main()
{
    int n;
    scanf("%d", &n);

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

    for(int i = 0, x; i < n; ++ i) {
        scanf("%d", &x);
        heap.push(x);
    }

    int res = 0;

    while(heap.size() > 1) {
        int it1 = heap.top();
        heap.pop();

        int it2 = heap.top();
        heap.pop();

        res += it2 + it1;

        heap.push(it1 + it2);
    }

    cout << res ;

    return 0;
}

【例题】荷马史诗

追逐影子的人,自己就是影子。 ——荷马

达达最近迷上了文学。

她喜欢在一个慵懒的午后,细细地品上一杯卡布奇诺,静静地阅读她爱不释手的《荷马史诗》。

但是由《奥德赛》和《伊利亚特》组成的鸿篇巨制《荷马史诗》实在是太长了,达达想通过一种编码方式使得它变得短一些。

一部《荷马史诗》中有 n n n 种不同的单词,从 1 1 1 n n n 进行编号。其中第 i i i 种单词出现的总次数为 w i w_i wi

达达想要用 k k k 进制串 s i s_i si 来替换第 i i i 种单词,使得其满足如下要求:

对于任意的 1 ≤ i , j ≤ n , i ≠ j 1≤i,j≤n,i≠j 1i,jni=j,都有: s i s_i si 不是 s j s_j sj 的前缀。

现在达达想要知道,如何选择 s i s_i si,才能使替换以后得到的新的《荷马史诗》长度最小。

在确保总长度最小的情况下,达达还想知道最长的 s i s_i si 的最短长度是多少?

一个字符串被称为 k k k 进制字符串,当且仅当它的每个字符是 0 0 0 k − 1 k−1 k1 之间(包括 0 0 0 k − 1 k−1 k1)的整数。

字符串 S t r 1 Str1 Str1 被称为字符串 S t r 2 Str2 Str2 的前缀,当且仅当:存在 1 ≤ t ≤ m 1≤t≤m 1tm,使得 S t r 1 = S t r 2 [ 1.. t ] Str1=Str2[1..t] Str1=Str2[1..t]

其中, m m m 是字符串 S t r 2 的 长 度 , Str2 的长度, Str2Str2[1…t]$ 表示 S t r 2 Str2 Str2 的前 t t t 个字符组成的字符串。

注意:请使用 64 64 64 位整数进行输入输出、储存和计算。

分析:

当我们使用一颗树来表示这个过程时,它其实就是 k k k 叉树最小权重问题,不过除了最小权值,它还要求这颗树的是最小高度,因此我们在向上合成节点选择时,除了大小权值,还有深度的权值,在大小相同时,优先合成深度小的。

代码如下:

#include <bits/stdc++.h>
using namespace std;
typedef long long LL;
const int N = 100010;

LL n, k;

int son[N][11], cnt[N], idx;

struct Node {
    LL v;
    int dep;

    bool operator < (const Node &W) const {
        if(v == W.v) return dep > W.dep;
        return v > W.v;
    }
};


int main()
{
    scanf("%lld%lld", &n, &k);

    priority_queue<Node> que;

    for(int i = 0; i < n; ++i) {
        LL w;
        scanf("%lld", &w);
        que.push({w, 1});
    }

    while((que.size() - 1) % (k - 1)) {
        que.push({0, 1});
    }

    LL res = 0;
    while(que.size() > 1) {

        LL sum = 0;
        int dep = 0;
        for(int i = 0; i < k; ++i) {
            auto it = que.top();
            que.pop();

            sum += it.v;

            dep = max(dep, it.dep);
        }

        que.push({sum, dep + 1});
        res += sum;
    }



    cout << res << endl;
    cout << que.top().dep - 1 ;

    return 0;
}
  • 5
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值