堆是一种满足特定条件的完全二叉树,其中大顶堆 中任意节点的值大于等于其子节点的值 ,小顶堆 中任意节点的值小于等于其子节点的值 。
其具有一下特点:
最底层节点靠左填充,其他层的节点都被填满
我们将二叉树的根节点称为“堆顶”,将底层最靠右的节点称为“堆底”
对于大顶堆(小顶堆),堆顶元素(根节点)的值是最大(最小)的
堆的常用操作
堆通常用于实现优先队列,大顶堆相当于元素按从大到小的顺序出队的优先队列。从使用角度来看,我们可以将“优先队列”和“堆”看作等价的数据结构。
C未直接提供heap类,C++代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 priority_queue<int ,vector<int >,greater<int >>minHeap; priority_queue<int ,vector<int >,less<int >maxHeap>; maxHeap.push (1 ); maxHeap.push (3 ); maxHeap.push (2 ); maxHeap.push (5 ); maxHeap.push (4 ); int peek = maxHeap.top (); maxHeap.pop (); maxHeap.pop (); maxHeap.pop (); maxHeap.pop (); maxHeap.pop (); int size=maxHeap.size ();bool isEmpty=maxHeap.empty ();vector<int > input{1 ,2 ,3 ,4 ,5 }; priority_queue<int ,vector<int >,greater<int >>minHeap (input.begin (),input.end ());
堆的实现
堆的存储和表示
当使用数组表示二叉树时,元素代表节点值,索引代表节点在二叉树中的位置。节点指针通过索引映射公式来实现。给定索引i i i ,其左子节点的索引为2 i + 1 2i+1 2 i + 1 ,右子节点的索引为2 i + 2 2i+2 2 i + 2 ,父节点的索引为( i − 1 ) / 2 (i-1)/2 ( i − 1 ) /2 (向下整除)。当索引越界时,表示空节点或节点不存在。
代码实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 int left (MaxHeap *maxHeap,int i) { return 2 *i+1 ; } int right (MaxHeap *maxHeap,int i) { return 2 *i+2 ; } int parent (MaxHeap *maxHeap,int i) { return (i-1 )/2 ; }
访问堆顶元素
堆顶元素即为二叉树的根节点,也就是列表的首个元素:
1 2 3 int peek (MazHeap*maxHeap) { return maxHeap->data[0 ]; }
元素入堆
我们首先将其添加到堆底。添加之后,由于 val 可能大于堆中其他元素,堆的成立条件可能已被破坏,因此需要修复从插入节点到根节点的路径上的各个节点,这个操作被称为堆化 (从底至顶 )。
设节点总数为n n n ,则树的高度为O ( log n ) O(\log n) O ( log n ) ,故堆化操作即元素入堆操作的时间复杂度为O ( log n ) O(\log n) O ( log n ) 。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 void push (MaxHeap*maxHeap,int val) { if (maxHeap->size==MAX_SIZE){ printf ("heap is full" ); return ; } maxHeap->data[maxHeap->size]=val; maxHeap->size++; siftUp(maxHeap,maxHeap->size-1 ); } void siftUp (MazHeap*maxHeap,int i) { while (true ){ int p=parent(maxHeap,i); if (p<0 || maxHeap->data[i]<=maxHeap->data[p]){ break ; } swap(maxHeap,i,p); i=p; } }
堆顶元素出堆
直接从列表中删除首元素,那么二叉树中所有节点的索引都会发生变化,这将使得后续使用堆化进行修复变得困难。为了尽量减少元素索引的变动,我们采用以下操作步骤。
交换堆顶元素与堆底元素(交换根节点与最右叶节点)
交换完成后,将堆底从列表中删除(注意,由于已经交换,因此实际上删除的是原来的堆顶元素)
从根节点开始,从顶至底 执行堆化
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 int pop (MaxHeap*maxHeap) { if (isEmpty(maxHeap)){ printf ("heap is empty" ); return INT_MAX; } swap(maxHeap,0 ,size(maxHeap)-1 ); int val=maxHeap->data[maxHeap->size-1 ]; maxHeap->size--; siftDown(maxHeap,0 ); return val; } void siftDown (MaxHeap*maxHeap,int i) { while (true ){ int l=left(maxHeap,i); int r=right(maxHeap,i); int max=i; if (l<size(maxHeap)&&maxHeap->data[l]>maxHeap->data[max]){ max=l; } if (r<size(maxHeap)&&maxHeap->data[r]>maxHeap->data[max]){ max=r; } if (max==i){ break ; } swap(maxHeap,i,max); i+max; } }
堆的应用
堆通常作为实现优先队列的首选数据结构,其入队和出队操作的时间复杂度均为O ( log n ) O(\log n) O ( log n ) ,建堆操作为O ( n ) O(n) O ( n ) 。
给定一组数据,我们可以用它们建立一个堆,然后不断地执行元素出堆操作,从而得到有序数据。然而,我们通常会使用一种更优雅的方式实现堆排序,详见“堆排序”章节。
获取最大的k k k 个元素:例如选择热度前 10 的新闻作为微博热搜,选取销量前 10 的商品等。
建堆操作
借助入堆操作实现
首先创建一个空堆,然后遍历列表,依次对每个元素执行“入堆操作”,即先将元素添加至堆的尾部,再对该元素执行“从底至顶”堆化。
每当一个元素入堆,堆的长度就加一。由于节点是从顶到底依次被添加进二叉树的,因此堆是“自上而下”构建的。
设元素数量为n n n ,每个元素的入堆操作使用O ( log n ) O(\log n) O ( log n ) 时间,因此该建堆方法的时间复杂度为O ( n log n ) O(n\log n) O ( n log n ) 。
通过遍历堆化实现
这种方法分为两步:
将列表所有元素原封不动地添加到堆中,此时堆的性质尚未得到满足。
倒序遍历堆(层序遍历的倒序),依次对每个非叶节点执行“从顶至底堆化”。
每当堆化一个节点后,以该节点为根节点的子树就形成一个合法的子堆。
由于叶节点没有子节点,因此它们天然就是合法的子堆,无须堆化。如以下代码所示,最后一个非叶节点是最后一个节点的父节点,我们从它开始倒序遍历并执行堆化:
1 2 3 4 5 6 7 8 9 10 11 MaxHeap*newMaxHeap (int nums[],int size) { MaxHeap*maxHeap=(MaxHeap*)malloc (sizeof (MaxHeap)); maxHeap->size=size; memcpy (maxHeap->data,nums,size*sizeof (int )); for (int i=parent(maxHeap,size-1 );i>=0 ;i--){ siftDown(mapHeap,i); } return maxHeap; }
输入列表并建堆的时间复杂度为O ( n ) O(n) O ( n ) ,证明过程见原书。
Top-k问题
法一:遍历选择
通过k轮遍历,分别在每轮中提取第当前轮大的元素。
当k < < n k<<n k << n 时,时间复杂度为O ( n k ) O(nk) O ( nk ) ,当k k k 和n n n 比较接近时其复杂度接近O ( n ) O(n) O ( n ) ,很耗时。
法二:排序
对数组进行排序,提取前k k k 个元素即可。但是该方法“超额”完成了任务,空间复杂度为O ( n log n ) O(n\log n) O ( n log n )
法三:堆
初始化一个小顶堆,其堆顶元素最小。
先将数组的前 k k k 个元素依次入堆。
从第 k + 1 k+1 k + 1 个元素开始,若当前元素大于堆顶元素,则将堆顶元素出堆,并将当前元素入堆。
遍历完成后,堆中保存的就是最大的 k k k 个元素。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 void pushMinHeap (MaxHeap *maxHeap, int val) { push(maxHeap, -val); } int popMinHeap (MaxHeap *maxHeap) { return -pop(maxHeap); } int peekMinHeap (MaxHeap *maxHeap) { return -peek(maxHeap); } int *getMinHeap (MaxHeap *maxHeap) { int *res = (int *)malloc (maxHeap->size * sizeof (int )); for (int i = 0 ; i < maxHeap->size; i++) { res[i] = -maxHeap->data[i]; } return res; } int *getMinHeap (MaxHeap *maxHeap) { int *res = (int *)malloc (maxHeap->size * sizeof (int )); for (int i = 0 ; i < maxHeap->size; i++) { res[i] = -maxHeap->data[i]; } return res; } int *topKHeap (int *nums, int sizeNums, int k) { int *empty = (int *)malloc (0 ); MaxHeap *maxHeap = newMaxHeap(empty, 0 ); for (int i = 0 ; i < k; i++) { pushMinHeap(maxHeap, nums[i]); } for (int i = k; i < sizeNums; i++) { if (nums[i] > peekMinHeap(maxHeap)) { popMinHeap(maxHeap); pushMinHeap(maxHeap, nums[i]); } } int *res = getMinHeap(maxHeap); delMaxHeap(maxHeap); return res; }
注:本文是作者阅读《hello算法》一书的读书笔记,内容会与原书有重合,特此声明。