前言
基于二叉树,普通的二叉树不适合用数组来存储,因为它不符合人们脑中对二叉树的结构认知,可能会浪费大量的空间。而完全二叉树很适合使用顺序结构来存储,因为它不存在有右子树确没有左子树的情况。现实中,我们把用顺序存储的二叉树结构,称为堆数据结构。
- 如上图所示,普通二叉树顺序存储会浪费大量空间。
堆
概念
如果有一个关键码的集合 K = { k 0 , k 1 , k 2 , . . . , k n − 1 } K=\{k_0, k_1, k_2, ..., k_{n-1}\} K={k0,k1,k2,...,kn−1},把它的所有元素按完全二叉树的顺序存储方式存储在一个一维数组中,并满足: K i ≤ K 2 i + 1 K_i\leq K_{2i+1} Ki≤K2i+1且 K i ≤ K 2 i + 2 K_i\leq K_{2i+2} Ki≤K2i+2( K i ≥ K 2 i + 1 K_i\geq K_{2i+1} Ki≥K2i+1且 K i ≥ K 2 i + 2 K_i\geq K_{2i+2} Ki≥K2i+2) i = 0 , 1 , 2 , . . . i=0,1,2,... i=0,1,2,...则称为小堆(大堆)。将根结点最大的堆叫做最大堆或大根堆,根结点最小的堆叫最小堆或小根堆。
性质
- 堆中某个结点的值总是不大于或不小于父结点的值。
- 堆总是一棵完全二叉树。
堆的实现
- 给定一个数组,把它看成一个完全二叉树。
int arr[] = {32,16,18,22,55,19,65,47,33,71};
- 如图所示,红色框内已经是小堆,只有32不满足小堆条件,因此需要进行向下调整。
堆的向下调整算法
- 向下调整只需要将不满足条件的元素,依次向下比较,调整位置,最终构成堆即可,前提是左右子树必须是一个堆。1
堆的创建
-
堆的结构归根结底还是一种顺序结构,因此这里构建堆和顺序表相同,就不再多赘述了。
-
代码实现:
-
typedef int HDataType; typedef struct Heap { HDataType* a; int size; int capacity; }Heap;
-
-
初始化堆结构代码实现:
-
void HeapInit(Heap* ph) { assert(ph); ph->a = NULL; ph->size = 0; ph->capacity = 0; }
-
-
如上所述,堆的向下调整算法的前提是左右子树必须是一个堆,但数组是无序的,如何保证左右子树,就需要从倒数第一个非叶子结点开始调整。
-
代码如下:
-
int a[] = {5,8,7,3,9,2}; int i = 0; for(i = (n - 1 - 1) / 2; i >= 0; i--) { AdjustDown(a, n, i); } void AdjustDown(HDataType* a, int size, int parent) { int child = parent * 2 + 1; while(child < size) { if(child + 1 < size && a[child+1] < a[child]) { child++; } if(a[child] < a[parent]) { Swap(&a[child], &a[parent]); parent = child; child = parent * 2 + 1; } else { break; } } }
-
时间复杂度
- 如图所示,假设树的高度是 h h h, 2 0 2^0 20个数据元素需要移动 h − 1 h-1 h−1次, 2 1 2^1 21个数据元素需要移动 h − 2 h-2 h−2次,以此类推,最后 2 h − 2 2^{h-2} 2h−2个数据元素需要移动1次。
- 以此得出:
T ( n ) = 2 0 ∗ ( h − 1 ) + 2 1 ∗ ( h − 2 ) + 2 2 ∗ ( h − 3 ) + . . . + 2 h − 3 ∗ 2 + 2 h − 2 ∗ 1 T(n)=2^0*(h-1)+2^1*(h-2)+2^2*(h-3)+...+2^{h-3}*2+2^{h-2}*1 T(n)=20∗(h−1)+21∗(h−2)+22∗(h−3)+...+2h−3∗2+2h−2∗1
使用错位相减法:
2 T ( n ) = 2 1 ∗ ( h − 1 ) + 2 2 ∗ ( h − 2 ) + 2 3 ∗ ( h − 3 ) + . . . + 2 h − 2 ∗ 2 + 2 h − 1 ∗ 1 2T(n)=2^1*(h-1)+2^2*(h-2)+2^3*(h-3)+...+2^{h-2}*2+2^{h-1}*1 2T(n)=21∗(h−1)+22∗(h−2)+23∗(h−3)+...+2h−2∗2+2h−1∗1
T ( n ) = − ( h − 1 ) + 2 1 + 2 2 + 2 3 + . . . + 2 h − 2 + 2 h − 1 T(n)=-(h-1)+2^1+2^2+2^3+...+2^{h-2}+2^{h-1} T(n)=−(h−1)+21+22+23+...+2h−2+2h−1
T ( n ) = 2 0 + 2 1 + 2 2 + 2 3 + . . . + 2 h − 2 + 2 h − 1 − h T(n)=2^0+2^1+2^2+2^3+...+2^{h-2}+2^{h-1}-h T(n)=20+21+22+23+...+2h−2+2h−1−h
T ( n ) = 2 h − 1 − h T(n)=2^h-1-h T(n)=2h−1−h
已知数的高度 h = l o g 2 ( n + 1 ) h=log_2 (n+1) h=log2(n+1),所以 T ( n ) = n − l o g 2 ( n + 1 ) ≈ n T(n)=n-log_2 (n+1)\approx n T(n)=n−log2(n+1)≈n。
- 因此建堆的时间复杂度是O(N)。
堆插入数据元素
- 由于堆结构和顺序表相同,因此在插入数据元素前,需要进行空间判定,即
ph->size == ph->capacity
表示空间已满,需要扩容。 - 数据元素依次向后插入,在不满足小堆的条件进行向上调整。
-
依次向上调整,最后构建出小堆
-
代码实现:
-
void HeapPush(Heap* ph, HDataType x) { assert(ph); if(ph->size == ph->capacity) { int newCapacity = ph->capacity == 0 ? 4 : 2 * ph->capacity; HDataType* tmp = (HDataType*)realloc(ph->a, newCapacity*sizeof(HDataType)); // 扩容 if(tmp == NULL) { perror("realloc"); return; } ph->a = tmp; ph->capacity = newCapacity; } ph->a[ph->size] = x; ph->size++; AdjustUp(ph->a, ph->size - 1); // 向上调整 } void AdjustUp(HDataType* a, int child) { int parent = (child - 1) / 2; // 计算出双亲结点 while(child > 0) { if(a[child] < a[parent]) { Swap(&a[child], &a[parent]); // 不满足小堆要求进行交换 child = parent; parent = (child - 1) / 2; } else { break; } } }
-
堆删除数据元素
- 堆的删除次序是要删除头部数据元素,若只是将头部指针指向下一个元素,则不一定会满足小堆(大堆)条件,因此采用先将头部数据元素和最后一个数据元素交换,删除最后数据元素,在向下调整成小堆(大堆)。
-
代码实现:
-
void HeapPop(Heap* ph) { assert(ph); if(ph->size == 0) { printf("堆里没有数据,无法删除\n"); return; } Swap(&(ph->a[0]), &(ph->a[ph->size - 1])); // 交换元素 ph->size--; // 删除最后一个元素 AdjustDown(ph->a, ph->size, 0); // 向下调整 } void AdjustDown(HDataType* a, int size, int parent) { int child = parent * 2 + 1; while(child < size) { if(child + 1 < size && a[child+1] < a[child]) { child++; } if(a[child] < a[parent]) { Swap(&a[child], &a[parent]); parent = child; child = parent * 2 + 1; } else { break; } } }
-
堆排序
- 利用堆的思想进行排序,需要两个步骤:
- 建堆。
- 升序:建大堆。
- 降序:建小堆。
- 利用删除的思想来排序。
- 建堆。
建堆的时间复杂度分析
- 上述可知,向下调整建堆的时间复杂度是O(N)。
- 向上调整的时间复杂度通过计算:
T ( n ) = 2 1 ∗ 1 + 2 2 ∗ 2 + 2 3 ∗ 3 + . . . + 2 h − 2 ∗ ( h − 2 ) + 2 h − 1 ∗ ( h − 1 ) T(n)=2^1*1+2^2*2+2^3*3+...+2^{h-2}*(h-2)+2^{h-1}*(h-1) T(n)=21∗1+22∗2+23∗3+...+2h−2∗(h−2)+2h−1∗(h−1)
使用错位相减法:
2 T ( n ) = 2 2 ∗ 1 + 2 3 ∗ 2 + 2 4 ∗ 3 + . . . + 2 h − 1 ∗ ( h − 2 ) + 2 h ∗ ( h − 1 ) 2T(n)=2^2*1+2^3*2+2^4*3+...+2^{h-1}*(h-2)+2^h*(h-1) 2T(n)=22∗1+23∗2+24∗3+...+2h−1∗(h−2)+2h∗(h−1)
T ( n ) = − ( 2 1 + 2 2 + 2 3 + . . . + 2 h ) + 2 h ∗ h = ( n + 1 ) ( l o g 2 ( n + 1 ) − 1 ) ≈ n l o g 2 n T(n)=-(2^1+2^2+2^3+...+2^h)+2^h*h=(n+1)(log_2 (n+1)-1)\approx nlog_2 n T(n)=−(21+22+23+...+2h)+2h∗h=(n+1)(log2(n+1)−1)≈nlog2n
因此,向上调整建堆的时间复杂度是O(N*logN)。
- 得出结论:建堆最好采用向下调整算法建堆。
排序
-
如上面动图一次堆排序所示,要想排升序,首先要建立一个大堆。
-
建堆代码实现:
-
int a[] = {33,16,18,47,55,22,65,19,32,71}; int i = 0; for(i = (n - 1 - 1) / 2; i >= 0; i--) { AdjustDown(a, n, i); // 从最后一个非叶子结点开始向下调整 } void AdjustDown(HDataType* a, int size, int parent) { int child = parent * 2 + 1; while(child < size) { if(child + 1 < size && a[child+1] > a[child]) { child++; } if(a[child] > a[parent]) { Swap(&a[child], &a[parent]); parent = child; child = parent * 2 + 1; } else { break; } } }
-
-
交换最后一个和第一个数据元素,再向下调整,排序好最后一个元素,依次排序即可达到升序结果。
-
代码实现:
-
int end = sizeof(a)/sizeof(a[0] - 1; while(end > 0) { Swap(&a[0], &a[end]); AdjustDown(a, end, 0); end--; }
-
TopK问题
我们经常可以在各种软件上看到前K个备受好评的选项,那么在数据量无限大的情况下,如何进行排序来选出K个最大的数呢?
- 根据上面的堆排序我们可知,想要降序需要建立小堆。但若数据量非常大,将全部数据建堆是不现实的,我们可以通过对数据前K个数据建小堆,在把N-K个数据和第一个数据比较,若比它大,便赋值再向下调整。
-
代码实现:
-
void TopK(int* a, int n, int k) { int* kHeap = (int*)malloc(sizeof(int)*k); assert(kHeap); int i = 0; for(i = 0; i < k; i++) { kHeap[i] = a[i]; } for(i = (k - 1 - 1) / 2; i >= 0; i--) { AdjustDown(kHeap, k, i); } int j = 0; for(j = k; j < n; j++) { if(a[j] > kHeap[0]) { kHeap[0] = a[j]; AdjustDown(kHeap, k, 0); } } }
-
本文以小堆为例进行解析。 ↩︎