堆
堆是一棵树,其每个节点都有一个键值,且每个节点的键值都大于等于/小于等于其父亲的键值。以大根堆为例,每个节点的键值都小于等于其父亲的键值,所以堆顶就是最大值。堆还支持插入操作,删除堆顶操作。
操作 | 时间复杂度 |
---|---|
取最值 | O ( 1 ) O(1) O(1) |
插入 | O ( l o g 2 n ) O(log_2n) O(log2n) |
删除堆顶 | O ( l o g 2 n ) O(log_2n) O(log2n) |
堆的结构
最常见的堆的结构就是二叉堆,二叉堆就是完全二叉树。完全二叉树,从根往下数,除了最下层外都是全满(都有两个子节点),而最下层所有叶结点都向左边靠拢填满。构造一颗完全二叉树就是从上到下,从左往右的放置节点。
下图就是一个完全二叉树,每个节点旁边的数字就是这个节点的编号,内部就是节点的数据。
观察节点的编号,发现可以很好的用数组进行模拟,假设某个节点的编号为 x
, 那么他的两个孩子的编号就是 2*x
, 2*x+1
.
堆的插入
现在有一个集合S={2, 5, 8, 9, 10, 13},对这个集合建大根堆,然后再插入一个数字 16.
在插入之前堆的结构是这个样子的,一共有6个节点,根节点的编号是1。可以看到每个节点孩子的值都小于等于当前节点的值。所以根节点就是最大值。
当我们插入 16 的时候,新增一个编号为 7 的节点, 也就是 3 号节点的右儿子。16 就是 7 号节点的值。
把新增的数放在了最后的位置,不一定满足大根堆的性质,所以需要调整结构,既然儿子的值大于其父亲的值,那么让儿子造反就行了, 把儿子和父亲换一下,这样儿子的值就会小于父亲的值。也就是把3号节点的值和7号节点的值换一下。如果调整一次还不满足大根堆的性质,那么就接着调整,直到满足大根堆的性质或者该节点变成了根节点。
最终的结构如上图所示,一共调整了两次,就满足了大根堆的性质,堆顶就是最大值的位置。
由于完全二叉树的性质,树的高度最多就是 l o g 2 n log_2n log2n 层,所以调整的次数最多也就是 l o g 2 n log_2n log2n 次。
总结一下:
构建大根堆的过程就是把每一个要加入的数放在最后的位置,然后调整堆的结构,直到满足大根堆的性质。构建小根堆也是一样的过程,只是改一下大于小于的符号。
堆的删除
删除堆顶的步骤:
1、把堆顶和堆的最后一个节点交换,堆的最后一个节点变成了堆顶,堆顶在最后一个节点上,然后删除最后一个节点,就相当于删除了堆顶。
2、从堆顶开始,如果堆顶小于堆顶的两个儿子中最大的那一个,那么堆顶就和大的儿子交换。一直交换到满足大根堆的性质。
删除堆顶也就是重复的操作,和插入一样,最多交换 l o g 2 n log_2n log2n 次。
下面画图来演示一下。一开始堆是这个样子的。
经过步骤 1 变成了下图。
很明显堆顶小于他的右儿子,所以节点1,3要交换. 交换之后3号节点还是小于6号节点,所以节点 3,6还要交换。
最终的结构如上图所示,堆顶的值变成了13.
/*
head 为模拟大根堆的数组
sz 为堆里面元素的个数。
*/
void push(int x){ // 插入一个数 x
heap[++sz] = x; // 把新增加的数放到堆的最后。
int now = sz; // 当前节点为最后一个节点。
while(now > 1 && heap[now] > heap[now / 2]){ // 然后不断的和 其父亲毕竟,如果大于父亲的值,就要交换。
swap(heap[now], heap[now / 2]);
now /= 2; // 交换之后,当前节点变为父亲的编号。
}
}
void erase(){
swap(heap[1], heap[sz]); // 交换 堆顶 和 堆最后节点的值。
sz--; // 堆元素个数减少
int now = 1; // 当前节点为堆顶。
while(now * 2 <= sz){ // 判断当前节点是不是还有儿子。
int tmp = now * 2;
if (tmp + 1 <= n && heap[tmp+1] > heap[tmp]) tmp++; // tmp 是两个儿子中最大值的编号。
if (heap[tmp] <= heap[now]) break; // 如果满足了大根堆的性质,那么就break 退出。
swap(heap[now], heap[tmp]); // 交换 当前节点 和 儿子节点。
now = tmp; // 当前节点变成儿子节点的编号。
}
}
堆的两种操作已经介绍完了,最重要的是了解堆的思想。如果想要使用堆排序,那么就需要把所有的元素都加入到堆里面,然后每次取堆顶,删除堆顶。
平常我们不用写堆的代码,就像我们也不写快排的代码一样。堆和快排一样,C++ 和 Java 都自己实现了,我们只需要调用接口会用就可以了。我们要知其然也要知其所以然,了解底层到底是什么样的一个结构。了解底层结构之后,也方便自己长期记忆。
C++ 堆的使用方法
priority_queue<int> que1; // 定义一个大根堆
for (int i = 0; i <= 10; ++i)
que1.push(i); // 向大根堆里面添加元素
printf("%d \n",que1.top()); // 输出来大根堆的堆顶。
que1.pop(); // 删除大根堆的堆顶。
priority_queue<int, vector<int>, greater<int>>que2; // 定义一个小根堆
for (int i = 0; i <= 10; ++i)
que2.push(i); // 向小根堆里面添加元素
printf("%d \n",que2.top()); // 输出来小根堆的堆顶。
que2.pop(); // 删除小根堆的堆顶。
例题
输入整数数组 arr ,找出其中最小的 k 个数。例如,输入4、5、1、6、2、7、3、8这8个数字,则最小的4个数字是1、2、3、4。
示例 1:
输入:arr = [3,2,1], k = 2
输出:[1,2] 或者 [2,1]
这是一个简单题,把前 k 小的输出来,那么我们直接排序一遍,然后把答案输出来就可以了。
今天的专题是堆, 所以要用堆来做。
题目要求前 k 小, 所以我们要建一个小根堆, 然后把所有的数都放到堆里面。这个时候堆顶就是最小的。剩下的操作就是取堆顶,然巴删除堆顶,操作k次就好了。
c++ 和 Java 都有接口,我们学会使用接口就行,具体看c++的实现代码。
class Solution {
public:
vector<int> getLeastNumbers(vector<int>& arr, int k) {
vector<int>ans; // 答案
priority_queue<int, vector<int>, greater<int>>que; // 声名一个小根堆。
for (auto it: arr)
que.push(it); // 把所有的数都放到小根堆里面。
for (int i = 0; i < k; ++i){ // k 次遍历, 去堆顶, 删除堆顶。
ans.push_back(que.top());
que.pop();
}
return ans;
}
};