二叉树和堆

堆(Heap)是计算机科学中一类特殊的数据结构,通常是一棵完全二叉树(每个结点只有不超过两个子结点,只有最下边一层从右开始不缺少或连续缺少一段结点,其它层都是满的的树形结构)。

堆的性质:

  • 某个结点的值总是不大于或不小于其父节点的值
  • 通常是个完全二叉树

将根结点最大的堆叫做大根堆,根结点最小的堆叫做小根堆。常见的堆有二叉堆、斐波那契堆等。

左是小根堆,右是大根堆:

二叉树和堆

堆的定义如下:

\(n\) 个元素的序列 \(\{A_1,A_2,A_i,\cdots,A_n\}\) 当且精当,满足如下条件时,称之为堆:

\[A_i\le A_{2i},A_i\le A_{2i+1} \]

\[A_i\ge A_{2i}, A_i \ge A_{2i+1}(i=1,2,3,4,5,\cdots,\frac{n}{2}) \]

其实这就是把一个结点 \(i\) 的左儿子放在 \(2i\) 的位置,右儿子放在 \(2i+1\) 的位置,然后满足堆的性质。平常储存堆的时候我们也用这种方式储存。


堆的操作

a. 插入:向堆中插入一个新的元素。在数组末尾插入新节点。然后自下而上调整子节点和父节点,如果不满足堆的性质那就调换,使得当前子树满足二叉堆的性质。时间复杂度为 \(\mathcal{O}(\log n)\)。

void push(int a[], int i, int& n) { //i 为插入的值,n 为插入之前堆的大小
	n++; //调整大小
    a[n] = i;
    int p = n;
    while (p > 1 && a[p / 2] > a[p]) {
        swap(a[p / 2], a[p]);
        p /= 2;
    }
    return;
}

b. 弹出:删除堆顶元素。先把堆存储的最后一个结点放在堆顶处,然后自下而上调整父节点和子结点,时间复杂度为 \(\mathcal{O}(\log n)\)。

int pop(int a[], int& n) {
    int res = a[1]; //记录堆顶元素
    a[1] = a[n]; //交换堆顶元素和堆尾元素
    n--; //调整大小
    int p = 1, t;
    while (p * 2 <= n) {
        if (p * 2 + 1 > n || a[p * 2] <= a[p * 2 + 1]) { //找到左右孩子最小者
			t = p * 2;           
        } else {
            t = p * 2 + 1;
        }
        if (a[p] > a[t]) { //不满足堆的性质
            swap(a[p], a[t]);
        } else {
            break;
        }
 	}
    return res;
}

c) 删除:使该元素与堆尾元素交换,调整堆容量,再由原堆尾元素的当前位置自顶向下调整。时间复杂度为 \(\mathcal{O}(\log n)\)。

大根堆

下面序列中符合大根堆的定义的有:

  • 7,5,6,2,4,1,5

    每个结点都不小于子结点的值

  • 7,7,5,7,7,5,5

    每个结点都不小于子结点的值

  • 7,7,6,8,2,5,3

    7和8的关系不符合堆的性质

  • 1,2,3,4,5,6,7

    小根堆


优先队列

在队列中,元素从队尾进入,队首删除、相比队列,优先队列里的元素增加了优先级的属性,优先级高的元素先被删除。优先队列内部是用堆实现的。

优先队列的使用和队列基本上没有区别。

#include<queue>
#include<iostream>
using namespace std;
int main() {
    priority_queue<int> q; //定义一个储存 int 类型的优先队列
    q.push(1); //入队
    q.push(2);
    q.push(3);
    while (!q.empty()) {
        cout << q.top() << endl;
        q.pop();
    }
    return 0;
}

输出为:

3
2
1

优先队列会默认把较大的数值放在队首,它就是大根堆,想要把较小的值放入前面的话,需要如下的方式定义:

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

含义是:这个优先队列里装int,这个优先队列内部用vector存储,比较方式是greater,在使用优先队列的时候用greater的方式比较会把较小的元素放在队首

或者,我们也可以使用优先级重载小于号来实现。

struct node {
    int dist, loc;
    node() { }
    bool operator < (const node & a) const {
        return dist > a.dist;
    }
};

priority_queue <node> Q;

上述代码起到优先级重载的作用,我们仍然可以进行toppop等便捷的操作。


例题:

输入 \(n\) 个数字,要求从大到小进行排序,必须使用优先队列来实现。

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

priority_queue<int> pq;

int main() {
    int n;
    cin >> n;
    for (int i = 1;i <= n;i++) {
        int t;
        cin >> t;
        pq.push(t);
    }
    while (!pq.empty()) {
        cout << pq.top() << endl;
        pq.pop();
    }
}

合并果子

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

每一次合并,多多可以把两堆果子合并到一起,消耗的体力等于两堆果子的重量之和。可以看出,所有的果子经过 \(n-1\) 次合并之后,就只剩下一堆了。多多在合并果子时总共消耗的体力等于每次合并所耗体力之和。

因为还要花大力气把这些果子搬回家,所以多多在合并果子时要尽可能地节省体力。假定每个果子重量都为 \(1\) ,并且已知果子的种类 数和每种果子的数目,你的任务是设计出合并的次序方案,使多多耗费的体力最少,并输出这个最小的体力耗费值。

解析:

每次进行操作的时候,应当选择最少的两堆果子进行合并,直到最后只剩下一堆果子的时候会最优。那就可以使用小根堆进行实现。每次的操作就是量词取出堆顶,然后把他们的和加入答案。最后剩下一堆果子就是答案。

关于其正确性,可以这样来证明。把合并的路径画下来成为一棵树,发现消耗的总体力数就是这一堆的果子数乘上它到根的路径长度。想要这个乘积的和最小,一定是每次合并两堆当前最小的果子。为什么是对的,考虑交换两棵子树,结果要不然跟之前同构,要不然一定是把一权值较大的放到了更深的位置,而权值更小的到了更浅的位置,那答案肯定不会减。按照这样的合并方式画出的树叫做哈夫曼树。

#include <queue>
#include <iostream>
using namespace std;
priority_queue<int, vector<int>, greater<int> > pq;
int main() {
    int n;
    cin >> n;
    for (int i = 0;i < n;i++) {
        int t;
        cin >> t;
        pq.push(t);
    }
    int ans = 0;
    while (pq.size() > 1) {
        int n1 = pq.top();
        pq.pop();
        int n2 = pq.top();
        pq.pop();
        pq.push(n1 + n2);
        ans += n1 + n2;
    }
    cout << ans << endl;
}

n 个最小和

给出两个长度为 \(n\) 的数组 \(A,B\)。在 \(A,B\) 中任意取出一个数字并将这两个数字相加,可以得到 \(n^2\) 个和,求这些和中最小的 \(n\) 个。

我们可以借助优先队列来解决这题。首先把 \(A,B\) 按照从小到大的顺序排序。观察下面这个表格。

表1:\(A_1+B_1\le A_1+B_2\le A_1 + B_3\le\cdots\le A_1+B_n\)

表2:\(A_2+B_1\le A_2+B_2\le A_2 + B_3\le\cdots\le A_2+B_n\)

表3:\(A_3+B_1\le A_3+B_2\le A_3 + B_3\le\cdots\le A_3+B_n\)

\(\cdots\)

表n:\(A_n+B_1\le A_n+B_2\le A_n + B_3\le\cdots\le A_n+B_n\)

我们使用一个结构 \((sum,a,b)\) 来表示一个和值。

我们建立一个优先队列 \(q\),队列中 \(sum\) 越小优先级越高。初始的时候将 \((A[i]+B[1],i,1)\) 入队、然后在队列中取出结构 \((sum,a,b)\),然后把结构 \((A[a],B[b+1],a,b+1)\) 入队。这样重复 \(n\) 次取出的结构就是最小的 \(n\) 个和。任何时候,优先队列最多只会有 \(n\) 个元素,所以时间复杂度为 \(\mathcal{O}(n\log n)\)。

#include<iostream>
#include<queue>
#include<vector>
#include<algorithm>
using namespace std;
struct node {
    int sum, a, b;
    node(){}
    node(int ssum, int aa, int bb) {
        sum = ssum, a = aa, b = bb;
    }
    bool operator < (const node& rhs) const {
        if (sum != rhs.sum) return sum > rhs.sum;
        else return a > rhs.a;
    }
};
int a[1010], b[1010];
priority_queue<node> pq;

int main() {
    int n;
    cin >> n;
    for (int i = 1;i <= n;i++) cin >> a[i];
    for (int i = 1;i <= n;i++) cin >> b[i];
    sort(a + 1, a + n + 1);
    sort(b + 1, b + n + 1);
    for (int i = 1;i <= n;i++) {
        pq.push(node(a[i] + b[1], i, 1));
    }
    for (int i = 1;i <= n;i++) {
        node t = pq.top();
        pq.pop();
        cout << t.sum << ' ';
        if (t.b + 1 <= n) {
            pq.push(node(t.a) + b[t.b + 1], t.a, t.b + 1);
        }
    }
    return 0;
}

映射二叉堆

具有映射功能的堆称为双向映射堆。堆又名二叉堆,所以也常常称其为映射二叉堆。映射二叉堆相比普通的堆,核心功能是支持元素的快速查找,可以在 \(\mathcal{O}(\log n)\) 的时间复杂度内找到索引为 \(id\) 的元素,并进行后续的修改或者删除等操作。

映射二叉堆与普通堆的不同之处是它不存储数值,而是存储数据对应的索引(数组下标)。当需要比较父子结点的大小时,我们需要对两个索引对应的关键字进行比较;当需要交换父子结点时,我们要交换堆中父子结点的索引。在堆的外部还需要存储一个从索引到堆中元素的反向映射,用来在堆中检索指定索引的元素,进行后续的修改或删除操作。

上一篇:239. 滑动窗口最大值


下一篇:Apollo-预测*