一般说的堆就是指二叉堆。
堆往往可以认为是所谓的"完全二叉树",什么是完全二叉树?
可以找到很多描述完全二叉树的文章, 但最最重要的如下的性质:
1、完全二叉树用数组描述而不是链表,数组的第0个节点废弃,每个节点的索引从1-N
2、如果一个节点i有父节点,那么父节点的索引是i/2
3、节点的左孩子的节点索引是i*2,右孩子节点的索引是i*2+1
4、节点最大索引是N,如果节点i,i > N/2,那么节点i肯定是叶子节点即无子节点,反之必然至少有一个子节点
5、节点最大索引是N,如果节点i,i < (N - 1)/2,那么必然有两个子节点,所以只有一个子节点的父节点,只可能存在于节点N/2
由上简言之,任何一个可比较的长度大于1的数组,都能形成一个二叉堆;每个节点都可以根据自己在数组里的下标,找到自己的父节点和子节点的下标;可以根据数组长度判断到哪些是有子节点的父节点,哪些是叶子节点。
二叉堆还有自己另外的特点:堆顶是最大/小的元素。即所谓的最大堆/最小堆,这是一个非常重要的特点,有不少实用解决问题方式根源于此。
下面结合实例和代码一起描述
一、二叉堆的构造:
首先是头文件minhep
#include <vector>
template<typename T> class minheap {
std::vector<T> data;
int parent(int i) {
return i/2;
}
int left(int i) {
return i * 2;
}
int right(int i) {
return i * 2 + 1;
}
void swap (int i, int j) {
T tmp = data[i];
data[i] = data[j];
data[j] = tmp;
}
public:
minheap (const std::vector<T> indata);
minheap (T *indata, int size, T firstdata);
~minheap(){data.clear();}
void adjust(int i, int size);
void sort();
void show();
T gettop();
void settop(T newtop);
};
然后是类方法定义minheap_funcs.h:
首先看下构造函数
#include "minheap.h"
#include <iostream>
template<class T> minheap<T>::minheap (T *indata, int size, T first) {
data.clear();
data.push_back(first);
for (int i = 0; i < size; i++) {
data.push_back(indata[i]);
}
//为什么从size/2开始? 因为这是二叉堆的特点, size/2的节点是最后一个拥有孩子节点的父节点
//子节点有必要adjust吗? 没有
//为什么要从size/2到1去adjust, 而不是从1到size/2呢? 这是为了保证堆顶是最大/小. 从最低一级的父节点就保证是最大/小, 这样一直往上直到堆顶, 最终保证堆顶是所有元素的最大/小
for (int i = size/2; i >= 1; i--) {
adjust(i, size);
}
}
二叉堆构造时,首先将全部数据加入数组,然后从最后一个父节点开始执行执行adjust,adjust是什么意思?如下:
//如果节点i的左节点l和右节点r, 存在且比节点i大/小(代码里目前是大), 那么就需要交换并继续找可能存在的更大/小的子节点, 保证每一级子树中, 父节点都是最大/小
//显然平均时间复杂度在logN
template<class T> void minheap<T>::adjust (int i, int size) {
int l = left(i), r = right(i), big_or_small = i;
if (i <= size/2) {
if (l <= size && data[l] > data[big_or_small]) {
big_or_small = l;
}
if (r <= size && data[r] > data[big_or_small]) {
big_or_small = r;
}
if (big_or_small != i) {
swap(i, big_or_small);
adjust(big_or_small, size);
}
}
}
在adjust,对节点i,判断其左子节点l和右子节点r(如果存在的话),是否比节点i更大/小,如果确实更大/小,那么执行数据交换,并继续对交换后的左/右子节点,继续判断他们的子节点是否还存在比它更大/小的。简言之:adjust保证节点i是比它的左/右子节点都大/小。
那么在构造函数里,从最后一个父节点执行adjust,就是为了保证最低一级的各个父节点,都比其子节点大/小,这样一级一级往上,就能保证每个父节点也都是比其子节点大/小,最终保证堆顶节点是最大/小的。
如下图:
如输入元素为:[3,1,2,5,4,6],则原始如图:
size = 6,size/2 = 3,所以从第3个节点即值为2节点开始,执行adjust:
1、索引3节点值为2,,左节点值为6更大,执行2和6的值交换,递归进入索引6(此时已经值为2),不属于父节点返回
2、索引2节点值为1,左节点值为5更大,执行1和5的值交换,递归进入索引5(此时已经值为1),不属于父节点返回
3、索引1节点值为3,右节点值为6更大,执行3和6的值交换,递归进入索引3(此时已经值为3),左节点值为2比它小,返回
则一步步的二叉堆演变为:
堆顶节点的值为6,是最大的值,最大堆。把代码中adjust函数里的data[l]和data[r]的和data[big_or_small]的判断条件由‘>’改为‘<’,就能发现堆顶节点值为1,即最小堆了。
1、一定由图仔细感受二叉堆的创建,它保证了树的每个节点都比左子节点和右子节点都大/小,也就保证了堆顶节点是最大/小的节点
2、对于任意一个子树,长度如果为n,那么高度为logn,即adjust的时间复杂度为logn。对于长度为N高度为logN的整个二叉堆,做一次从顶到底的adjust,时间复杂度为logN。
3、二叉堆创建的时间复杂度在O(N):最后一层父节点的个数是2^(logN - 1)个(建议一定亲自画图设想),倒数第二层的父节点个数为2^(logN - 2).....另一方面,最后一层父节点本身最多需要执行一次adjust,倒数第二层父节点需要执行最多2次adjust......最终的时间复杂度:1 * (2^(logN - 1)) + 2 * (2^(logN - 2)) + ... + logN * (1) = 2N - 2 - logN => O(N)
3、数组现在变为了[6, 5, 3, 1, 4, 2],最大节点值为6,位于第一个索引处。但要观察到,数组并未排序,确切说,每个子树都比其左右节点大,但是同级的不同子树节点并未排序;这就引出了所谓的堆排序
4、但是对于二叉堆,堆排序还不是最重要的,堆的很多实用功能并不需要进行堆排序;这就引出了下面的二叉堆的重调堆顶
二、重调堆顶
//一个二叉堆是保证堆顶节点是最大/小的, 直接更换堆顶节点, 就要通过adjust重新保证堆顶是最大/小
template<class T> void minheap<T>::settop (T headdata) {
data[1] = headdata;
adjust(1, data.size() - 1);
}
比如执行settop(2.5),替换堆顶6为2.5(纯为举例暂不考虑浮点数),重调堆顶如下图:
依然通过执行一次adjust,保证各个子树都是父节点比左右子节点都大/小。显然调整堆顶的时间复杂度是logN。
换句话说,任意一条数据在二叉堆的数组里想判断是不是最大,仅需最多logN的时间就可以判断,而且完全不需要排序。这是一个重要的性质,所谓海量数据的前topk、中位数等问题均通过此解决。
三、堆排序:
堆排序一样是根据上面的堆顶调整而来,简单说就是:
1、用每个数据替换一次堆顶,然后执行adjust,获取到当前堆的最大/小节点
2、把这样的节点从后往前排列
1和2总结就是:
第一步:当前堆的最大/小就是堆顶节点,把最后一个节点N和堆顶节点做值交换,这样最大/小节点已经在索引N了,然后对[1, N-1]的堆进行adjust,获取新的堆顶
第二步:获取到新的堆顶,再和N-1索引的节点做值交换,即N-1节点是倒数第二的最大/小,然后再对[1, N-2]的堆进行adjust,再获取新的堆顶
.......
第m步:...............获取新的堆顶,和第N-m节点值交换,再对[1, N-m]的堆进行adjust,再获取新的堆顶
.......
第N步:只剩下堆顶节点了,堆排序结束
如下图:
第一步:2和5值交换,最大值5已在堆尾
第二步:剩余部分做adjust,新堆顶是4
第三步:2和4值交换,第二大值4已在倒数第二堆尾
第四步:剩余部分做adjust,新堆顶是3
第五步:2和3交换,第三大值3已在倒数第三堆尾
第六步:剩余部分做adjust,新堆顶是2.5
第七步:1和2.5交换,第四大值2.5已在倒数第四堆尾
第八步:剩余部分做adjust,新堆顶是2
第九步:1和2交换,第5大值2已在倒数第五堆尾
第10步,剩余部分只剩下1了,就是他自己了,不用再动了。
堆排序结局:[1,2,2,5,3,4,5]
可以清晰的发现,堆排序的时间复杂度,不论平均复杂度还是最差复杂度均 = (N-1) * logN => NlogN
四、二叉堆实用举例:
1、topk问题:
在N个数之中(N很大),找到最大/小的前k个:
如果用二叉堆,思路如下,
1、默认将N个数之中的前k个,创建为一个二叉堆(时间复杂度O(k)),自然堆顶是这k个数中最大/小的;这里假设是找最小的k个数,那么要创建最大堆
2、对于后面的(N-k)个数,如果比最大堆的堆顶还要大,那么就不用关注了,因为这个数肯定不是N个数中最小的k个的,如果比堆顶小,那么直接用它替换堆顶再做adjust(即settop方法),时间复杂度最大为(N-k) * logk
3、最终这个堆里的数就是N个数之中最小的k个数,整体复杂度为O(k) + (N-k) * logk => Nlog(k)
当然topk问题其实还有一些更好方式。
下面是一个对1亿个随机数找到最小的前100个的测试程序,运行可以只需几秒,相对排序后再找topk,确实还是挺快的:
#include "heap_func.h"
#include <stdlib.h>
using namespace std;
void init (std::vector<int> &testdata, int top, std::vector<int> &heap) {
srand((int)time(0));
for (int i = 0; i < 10240 * 10240; i++) {
int data = rand();
testdata.push_back(data);
if (i < top) {
heap.push_back(data);
}
}
}
void output(const std::vector<int> testdata) {
for (size_t i = 0; i < testdata.size(); i++) {
std::cout << testdata[i];
if (i != testdata.size() - 1) {
std::cout << ",";
}
}
std::cout << std::endl;
}
int main () {
int top = 100;
std::vector<int> testdata, topheap;
init(testdata, top, topheap);
heap<int> minhp(topheap, false);
for (int i = top; i < testdata.size(); i++) {
int curdata = testdata[i];
int topdata = minhp.gettop();
if (curdata > topdata) {
continue;
} else {
minhp.settop(curdata);
}
}
minhp.show();
return 0;
}
2、中位数问题:
在N个数中找到中位数。
核心思想是,通过遍历数组,把数据分别放入两个二叉堆中,一个最小堆一个最大堆,然后要保证:
1、最小堆和最大堆的成员个数差不超过1
2、最小堆的堆顶肯定比最大堆的堆顶大
如果满足这两个条件,那么显而易见,中位数就出现在最大堆的堆顶/最小堆的堆顶
首先,增加heap类的动态/删除增加成员的方法add和push:
template<class T> void heap<T>::add (T adddata) {
data.push_back(adddata);
++heapsize;
for (int i = heapsize/2; i >= 1; i--) {
adjust(i, heapsize);
}
}
template<class T> void heap<T>::pop () {
data[1] = data[heapsize];
--heapsize;
data.pop_back();
adjust(1, heapsize);
}
对于增加成员,就是追加到数组的末尾,然后重新调整各子树的堆顶,注意,新增成员不仅影响的是堆顶,而是影响所有子树,所以必须像创建二叉堆一样,从最后一个父节点开始adjust;
对于删除成员,实际就是删除堆顶,具体方式是将末尾成员赋值到堆顶,然后释放末尾成员,再调整堆顶即可。
接下来是寻找中位数的代码逻辑:
#include "heap_funcs.h"
//获取数组的中位数. 这是一个非常有意思的利用二叉堆的一个操作:
//核心思想是, 通过遍历数组, 把数据分别放入两个二叉堆中, 一个最小堆一个最大堆
//这样很显然最大堆的堆顶/最小堆的堆顶中的一个肯定是中位数
//主要注意下面代码注释里的一些细节
int find_median (int *testdata, int size) {
heap<int> small(0, true), big(0, false);
for (int i = 0; i < size; i++) {
int smallsize = small.getsize(), bigsize = big.getsize(), data = testdata[i];
//先避免两个堆有空的情况
if (bigsize == 0) {
big.add(data);
} else if (smallsize == 0) {
small.add(data);
} else {
//两个堆都不为空时, 注意我们求的是中位数, 所以最大堆和最小堆的长度要同步, 长度差不能超过1
//最大堆长度更长时:
//1 除非数据比最小堆的堆顶还要大: 那么把最小堆的堆顶插入到最大堆, 同时最小堆用新数据更新堆顶
// 否则直接就插入到最大堆
//同理, 最小堆长度更长时:
//1 除非数据比最大堆的堆顶还要小: 那么把最大堆的堆顶插入到最小堆, 同时最大堆用新数据更新堆顶
// 否则直接就插入到最大堆
int bigtop = big.gettop(), smalltop = small.gettop();
if (bigsize > smallsize) {
if (data >= smalltop) {
small.add(data);
} else {
if (data >= bigtop) {
small.add(data);
} else {
small.add(bigtop);
big.settop(data);
}
}
} else if (bigsize < smallsize) {
if (data <= bigtop) {
big.add(data);
} else {
if (data <= smalltop) {
big.add(data);
} else {
big.add(smalltop);
small.settop(data);
}
}
} else {
if (data > smalltop) {
small.add(data);
} else {
big.add(data);
}
}
}
}
return (big.getsize() > small.getsize())?big.gettop():small.gettop();
}
int main () {
int testdata[11] = {131, 23, 2, 13, 140, 15, 6, 27, 118, 19, 22};
std::cout << "zhongweishu is " << find_median(testdata, sizeof(testdata)/sizeof(testdata[0])) << std::endl;
return 0;
}
精华已在代码注释之中。