堆 (Heap)
1. 定义 : 堆(Heap)是计算机科学中一类特殊的数据结构的统称。堆通常是一个可以被看做一棵完全二叉树的数组对象。 故通常我们用完全二叉树来维护一个一维数组
2. 分类 : 按照堆的特点可以把堆分为大顶堆和小顶堆
----------大顶堆:每个结点的值都大于或等于其左右孩子结点的值
----------小顶堆:每个结点的值都小于或等于其左右孩子结点的值
3. 完全二叉树维护的一维数组的特点
再开始具体讲解堆之前,我们先来了解一下完全二叉树的特点,及其用它来维护的一维数据有什么特点,首先完全二叉树的特点就是除了最后一层外,单看 1 ~ n - 1层这就是一颗满二叉树.对于满二叉树, 如果有n层,那么其节点总数为
2
n
−
1
2 ^ {n} - 1
2n−1; 其中第k层的节点数为
2
k
−
1
2 ^ {k - 1}
2k−1. 若将节点的总数 N / 2 即 得到 2 ^ (n - 1) - 1;这里的结果进行了下取整, 这个为倒数第二层的最右边的那个节点。对于完全二叉树来说,
⌊
N
/
2
⌋
\lfloor N /2 \rfloor
⌊N/2⌋得到的是最后一个非叶子节点的序号,具体推导 :
(1) 具体例子 + 图示推导
(2) 公式推导
-
对于非空二叉树来说其叶子结点(度为0的结点)等于度为2的结点树加1, 即 n 0 n_0 n0 = n 2 n_2 n2 + 1 ;这个证明比较简单, 因为对于树来说每一个分支都对应一个结点(除了根结点外),故二叉树的结点总数N可以表示为 N = B + 1 (B为二叉树的分支总数, +1表示加上根结点),然后每一个分支都是由度为1或度为2的结点分出来的,故B= 2 n 2 n_2 n2 + n 1 n_1 n1, 所以我们可以得到一个等式 :
n 1 n_1 n1 + n 2 n_2 n2 + n 0 n_0 n0 = 2 n 2 n_2 n2 + n 1 n_1 n1 + 1; 整理得到 : n 0 n_0 n0 = n 2 n_2 n2 + 1 -
对于完全二叉树来说, 若其结点总数为N, 则其由度为0和度为1还有度为2的结点组成, 其中由于是完全二叉树故其度为1的结点最多为1, 故我们分为两种情况:
(1) 只含一个度为1的结点的情况: N = n 2 n_2 n2 + n 1 n_1 n1 + n 0 n_0 n0 = n 2 n_2 n2 + n 0 n_0 n0 + 1; 由于存在 n0 = n2 + 1; 所以 N = 2 n 0 2n_0 2n0 ; 所以N个结点中有 N / 2是叶子结点,所以可以推出第 N / 2是最后一个非叶子结点
(2) 度为1的结点为0个的情况, 此时二叉树结点总数 N = n 2 n_2 n2 + n 0 n_0 n0, 即N = 2 n 0 2n_0 2n0 - 1; 即N个结点中, 有 n 0 n_0 n0个叶子结点, n 2 n_2 n2 = n 0 n_0 n0 - 1; 故第 n 0 n_0 n0 - 1个结点为最后一个非叶子结点 , 因为N为奇数, 故 ⌊ N / 2 ⌋ \lfloor N /2 \rfloor ⌊N/2⌋ = ⌊ n 0 − 1 / 2 ⌋ \lfloor n_0 -1/2 \rfloor ⌊n0−1/2⌋ = n 0 n_0 n0 - 1;
在完全二叉树中我们发现关系就是 如果父节点的序号为 x, 那么其左孩子节点若存在其序号为 2 * x, 右孩子存在则其序号为 2 * x + 1; 那么一个节点的父节点则为 x / 2; 故根据这个序号特点,我们将序号作为数组的下标,将一颗完全二叉树用一个一维数组存储。
4. 堆的基本操作
由于堆是一个完全二叉树, 且其每个结点的值都小于或等于其左右孩子结点的值(这里以小顶堆为例子).故我们需要一些操作使得我们除了叶子节点之外都满足结点的值都小于或等于其左右孩子结点的值,故为了实现这个我们对于节点有两个基本的操作,上浮up和下沉down.
#include<iostream>
using namespace std;
constexpr int N = 1e+5 + 10;
int n,m,h[N], s;
// h[N] 一维数组用于存储完全二叉树上的节点的值,其下标判断各个节点之间的关系
// 例如 h[x] 的 左孩子节点的值为 h[2 * x] . 右孩子节点的值为 h[2 * x + 1], 其父节点为 h[x / 2];
// 假定都存在, 2 * x <= 节点总数 && 2 * x + 1 <= 节点总数 && 父节点 x / 2 >= 1
// s 用来表示当前堆中的节点的总数
1.下沉操作down
对于一个节点,我们判断其节点值与左右节点的值相比较,如果当前节点的值不满足都小于或等于其左右孩子结点的值, 那么我们将节点与左右节点中较小的那个进行交换操作,由于进行了交换,故当前这棵子树中满足了小顶堆的特点,但由于交换了位置,故需要判断被交换位置后的子树位置此时的节点与其新左右孩子的节点值是否满足小顶堆,故这里我们就需要递归.
// 代码down操作实现
auto down(int x) -> int
{
int t = x;
if(2 * x <= n && h[t] > h[2 * x]) t = 2 * x; // 与左孩子节点值比较
if(2 * x + 1 <= n && h[t] > h[2 * x + 1]) t = 2 * x + 1; // 与右孩子节点值比较
if(t != x) swap(h[t], h[x]), down(t); // 若t != x,说明此时节点并不不符合堆,故与其中较小的孩子进行交互
// 交换完后,此时被交互到孩子位置的节点与其新左右孩子的节点值是否满足小顶堆,故这里我们就需要递归.
}
2.上浮操作up
对于一个节点我们与其父节点进行比较,如果当前的父节点比其节点值大,则不满足小顶堆的条件,故此时这两个节点需要进行交互,交换完毕后的成为新的父节点,此时由于位置变动,其需要继续与原本父节点的父节点进行比较,直到都满足堆条件为止,同样这里可以用递归或者while循环来实现。
// 代码up操作的实现
auto up(int x)
{
while(x / 2 && h[x / 2] > h[x]) // 若此时父节点存在,且父节点值大于自己,不满足条件需要进行交换
{
swap(h[x / 2] , h[x]);
x /= 2;
}
}
// 递归写法
void up(int x)
{
if(x / 2 && h[x / 2] > h[x])
swap(h[x / 2], h[x]), up(x / 2);
}
3.由down操作和up操作完成堆中数据的操作
对于堆的数据进行操作,均可由这两种基本操作组合来完成
(1) 插入一个数 : 即增加一个叶子节点,由于其没有孩子,故只能上浮操作,来将这个新插入的值调整到合适的位置,使得整个堆都符合小顶堆的条件。 增加一个叶子节点的操作为h[++s] = x; 然后再进行上浮 up(s); s为当前堆中节点的总数
(2) 找出堆中数据的最小值,根据小顶堆的特点我们知道,其根节点即为最值点,故h[1];(下标从1开始) 即为要找的答案,对于大顶堆来说,这个就是最大值了。
(3) 删除一个元素: 此时我们只需让需要删除的元素与最后一个叶子节点进行交换,交换后我们进行上浮和下沉, 由于这两种操作只有其中一种会被执行,故我们在代码实现的时候并不需要在这里作判断执行哪一个,直接写两个就ok了,反正只会执行满足条件的那一个. 故其代码实现为
// 设要交换的位置为 x
swap(h[x], h[s]); // 交换要删除的元素和最后一个叶子节点的位置
--s; // 表示删除了一个元素,因为如果对于堆,直接删堆中元素操作复杂,但在一维数组中直接删除最后一个元素很方便
down(x), up(x); //执行其中一个操作,具体的操作由此时被交换叶子节点的值与新父子节点和孩子节点值的关系决定
// 如果是删除堆顶元素
swap(h[1], h[s--]);
down(1); // 此时只有下沉操作
(4) 修改其中一个元素的值 : 直接修改其位置上的值,然后由于其节点值发生了改变,故需要判断此时堆是否满足条件,故进行上浮和下沉,当然两种操作只会执行其中的一种.
C++STL中的堆结构priority_queue
基本用法
(1) 函数原型
优先队列有三个参数,其声明形式为:
priority_queue< type, container, function>;
这三个参数,后面两个可以省略,第一个不可以。其中:
- 参1 type:数据类型;
- 参2 container:实现优先队列的底层容器,必须是可随机访问的容器,例如vector、deque,而不能使用list;
- 参3 function:元素之间的比较方式;在STL中,默认情况下(不加后面两个参数)是以vector为容器,以 operator< 为比较方式,所以在只使用第一个参数时,优先队列默认是一个最大堆,每次输出的堆顶元素是此时堆中的最大元素。
(2)成员函数
top(); // 访问队头元素
empty(); // 队列是否为空
size(); // 返回队列内元素个数
push(); // 插入元素到队尾 (并排序)
emplace(); // 原地构造一个元素并插入队列
pop(); // 弹出队头元素
(3) 构造大顶堆和小顶堆
在C++中优先队列 priority_queue就是使用堆结构来存储数据的,默认是大顶堆, 以存储int类型数据为例子
#include<queue>
priority_queue<int> q; // 默认大顶堆等价于 priority_queue<int, vector<int>, less<int> > q;
priority_queue<int, vector<int>, greater<int>> q;
为什么大顶堆默认使用的是less?
这里就不搬源码上来了,一个简单的结论就是在优先队列的比较中,__comp是比较的当前结点和其孩子结点,若当前结点小于孩子结点返回真,则孩子结点进行上浮操作,这就是大顶堆,父结点要求大于孩子结点的特点。 故传入greater时,要求的是父亲结点 > 孩子结点时才为真然后进行向上调整,故这就实现了小顶堆。
(3) 自定义数据类型构造堆
对于自定义数据类型如
#include<iostream>
#include<functional>
#include<queue>
using namespace std;
class W
{
public:
W(string w, int cnt):w(w),cnt(cnt){}
string w;
int cnt;
// 1.重载小于号
bool operator <(const W&o2) const
{
return this->cnt > o2.cnt;
}
};
class W2
{
public:
W2(string w, int cnt):w(w),cnt(cnt){}
string w;
int cnt;
};
// 2.仿函数
class Comp
{
public:
bool operator()(W&o1, W&o2)
{
return o1.cnt < o2.cnt;
}
};
auto main() -> int
{
priority_queue<W> q;
priority_queue<W2> q2;
W o1("hello", 123);
W2 o1("hello", 123);
q.push(o1);
q2.push(o2); // 报错,调用push加入元素后,没有相应的规则比较W2类型的元素.
/*正确写法
priority_queue<W2, vector<W2>, Comp> q2;
q2.push(o2);
*/
return 0;
}
这里由于是自定义数据类型,故我们需要提供一个自定义元素之间的比较方式,这里有两种方式,(1) 直接在我们的自定义数据类型中重载operator<, 即重载小于号,因为默认的priority_queue就是按照less来比较两个元素. (2)传入参3,即我们构造一个访函数. 然后自定义数据类型的比较关系到我们建立的到底是大顶堆还是小顶堆, 例如我们想构造一个W类型的小顶堆, 以其成员变量cnt作为比较依据,而根据priority_queue的比较规则是当前结点与孩子结点比较结果为真时,让孩子结点上移, 由于此时我们想要建立的是小顶堆, 故根据规则孩子结点上移应该是当前结点大于孩子结点时为真,即arg1 > arg2时为真。 故我们重载函数中写为 return this->cnt > o2.cnt;
堆的应用
所以现在我们知道了堆的数据结构可以用 O ( 1 ) O(1) O(1)的时间复杂度帮我们找到一组序列中最大的一个元素(大顶堆) / 或一个最小的元素(小顶堆), 且将一个元素插入到堆中其时间复杂度为 O ( l o g k ) O(logk) O(logk), k为堆中元素的数量。 故其应用场合可以用于需要快速找出最小数或者最大数的场合。
题目链接: 剑指 Offer 40. 最小的k个数
题目描述
输入整数数组 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]
示例 2:
输入:arr = [0,1,2,1], k = 1
输出:[0]
数据范围
0
⩽
\leqslant
⩽ k
⩽
\leqslant
⩽ arr.length
⩽
\leqslant
⩽ 10000
0
⩽
\leqslant
⩽ arr[i]
⩽
\leqslant
⩽ 10000
算法1
(排序然后找出前k个数) 时间复杂度 O ( n l o g n ) O(nlogn) O(nlogn)
C++ 代码
class Solution {
public:
vector<int> getLeastNumbers_Solution(vector<int> arr, int k)
{
vector<int> res;
sort(arr.begin(), arr.end());
for(int i = 0; i < k; ++i) res.push_back(input[i]);
return res;
}
};
算法2
(大顶堆, 随时维护保证堆中只有k个元素) 时间复杂度 O ( n l o g k ) O(nlogk) O(nlogk)
思路分析
往堆中插入一个元素的时间复杂度是 O ( l o g k ) O(logk) O(logk), 而一共有n个元素要插入, 故时间复杂度为 O ( n l o g k ) O(nlogk) O(nlogk) ,每次插入后我们都检查一下堆的元素数量是否大于k,若大于k则将堆顶元素弹出,因为我们使用的是大顶堆,而我们想要找到的是最小的k个数,由于此时堆顶是最大元素且堆的元素个数大于k,显然堆顶元素肯定比第k个元素大, 故直接弹出堆顶没毛病。
代码实现
class Solution {
public:
vector<int> getLeastNumbers(vector<int>& arr, int k)
{
vector<int> res;
priority_queue<int> heap;
for(auto val : arr)
{
heap.push(val);
if(heap.size() > k) heap.pop();
}
while(heap.size()) res.push_back(heap.top()), heap.pop();
return res;
}
};