堆与堆排序
这里的堆,不是内存管理中说的堆栈的堆,前者的堆,准确的说是二叉堆,是一种类似于完全二叉树的数据结构;后者的堆是一种类似于链表的数据结构。
完全二叉树
堆的结构性质
二叉堆在逻辑结构上是一颗完全二叉树。
对于一个有 N 个节点的完全二叉树,我们可以为每个节点指定一个索引,其实就是按照层序遍历的方式,如下图。
对于一个有 N 个节点的完全二叉树,索引值和元素是一一对应的,我们可以先用一个数组表示,而不需要指针,索引值就是数组的下标,数组元素的值就是节点的关键字。
如图,完全二叉树和数组的对应关系。
有一个规律:对于数组任一位置 i 上的元素,其左儿子在 2 i + 1 2i+1 2i+1 位置上,右儿子在 2 i + 2 2i+2 2i+2 位置上,父亲则在 ( i − 1 ) / 2 (i-1)/2 (i−1)/2 位置上。
以节点 D 为例,D的下标是 3,
- B 是它的父节点,B 的下标是 1 = ( 3 − 1 ) / 2 1 = (3 - 1) / 2 1=(3−1)/2,图中黑色的线。
- H 是它的左孩子,H 的下标是 7 = 2 ∗ 3 + 1 7 = 2 * 3 + 1 7=2∗3+1,图中蓝色的线。
- I 是它的右孩子,I 的下标是 8 = 2 ∗ 3 + 2 8 = 2 * 3 + 2 8=2∗3+2,图中红色的线。
堆序性质
二叉堆一般分为两种:最大堆和最小堆。
-
最大堆:也叫做大根堆。每一个节点的关键字,都大于或等于它的孩子的值。
-
最小堆:也叫做小跟堆,每一个节点的关键字,都小于或等于它的孩子的值。
**堆序特性:**以大根堆为例,在任何从跟到某个叶子的路径上,键值的序列是递减的(如果有相同的键值存在,则是非递增的)。但是,键值之间不存在从左到右的次序,也就是说,同一层节点之间,不存在任何关系,更一般的来说,也就是同一节点的左右子树,之间不存在任何关系。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-5mrDLHgR-1662559297273)(C:\Users\lenovo\AppData\Roaming\Typora\typora-user-images\1644755459996.png)]
- 一颗有 n 个节点的完全二叉树。它的高度等于 ( l o g 2 n ) + 1 (log_2 n)+1 (log2n)+1;
- 堆的根总是包含了堆的最大元素。
- 堆的一个节点,及该节点的子孙,也是一个堆。
- 可以用数组来实现堆,方法是从上到下,从左到右记录堆的元素。
- 在上述方法中,父母节点的键,将会占据前 n / 2 n/2 n/2 个位置,叶子节点的键将会占据后 ( n + 1 ) / 2 (n+1)/2 (n+1)/2 个位置。
**最后一个父母节点:**一个堆有 N 个元素,判断一个节点是不是父母节点,可以判断有没有孩子,如果有孩子,那么 2 i + 1 2i+1 2i+1 一定小于等于 N-1,如果 2 i + 1 2i+1 2i+1 大于 N-1,那么它一定是叶子节点,在它之后的节点,也一定是叶子节点。
所以,得不等式, 2 i + 1 > N − 1 2i+1>N-1 2i+1>N−1,取 i 的最小值,就是第一个叶子节点的位置,i 的最小值就是, i = N / 2 i=N/2 i=N/2,最后一个父母节点的值也就是 i = N / 2 − 1 i=N/2-1 i=N/2−1。
构造堆
方法一:自底向上构造堆
构造大根堆:假设有一颗完全二叉树。
- 从最后一个父节点开始,检查是否满足父母优势,如果不满足,就将父母节点和子节点中最大值进行交换,然后再检查在新的位置上,是否满足父母优势。循环这个过程,直到这个节点满足父母优势。(对叶子节点来说,父母优势自动满足)
- 在对当前这个父节点为根的子树,完成堆化之后,再对前一个父节点(就是数组中此节点的前一个节点)按同样的操作完成堆化。直到对树的根完成这个操作,堆构造完毕。
上述方法称为:下滤。示意图和代码,见附录。
方法二:自顶向下构造堆
通过把新的键连续插入事先构造好的堆,来构造一个堆(效率较低),可以称为自顶向下构造堆。
- 首先,将一个键值为 K 的新节点附加在最后一个叶子节点的后面。(也就是数组最后)
- 然后,拿 K 和它父母的键作比较,如果 K 小于等于它的父母,算法停止。否则交换这两个键,并把 K 和它的新父母作比较。
- 重复 2,一直到 K 不大于它的父母,或者 K 成为树根为止。
上述方法称为上滤,示意图,代码,见附录。
堆排序的基本思想:
- 为给定的序列创建一个堆。
- 交换堆的第一个元素
a[0]
和最后一个元素和a[n - 1]
。 - 堆的大小减 1(–n)。如果
1 == n
,算法停止,否则对 a[0] 进行下滤。 - 重复 2~3 步。
对大跟堆,堆的最顶端元素总是最大的,所以如上述方法,交换第一个元素和最后一个元素,然后检查树根的父母优势,采用上述的下滤函数重新构造出一个堆。
关键点在于如何删除最大键,示意图和代码,见附录。
在每次删除最大元素之后,堆的规模减小了 1,因此,位于最后的那个空间,可以用来存放,刚刚删去的最大值。
附录:
一:自底向上构造堆+下滤
递归+交换:
示意图:
有 10 个键: 4, 1, 3, 2, 16, 9, 10, 14, 8, 7
对应的完全二叉树:
依照上面公式,最后一个父母节点位置:10 / 2 = 5,所以我们从 5 号节点开始对这个完全二叉树进行堆化。
示意图:
代码:
// 对于给定的位置 i 节点
#define LEFT(i) (2*i+1)
#define RIGHT(i) (2*i+2)
#define PARENT(i) ((i-1)/2)
// 下滤函数
void percolate_down_recursive(int arr[], int t)
{
int left = LEFT(t);
int right = RIGHT(t);
int max = t; //都是索引,不是值
if(left <= NUM){ //索引t左孩子存在
max = arr[left] > arr[max] ? left : max;
}
if(right <= NUM){ //索引t右孩子存在
max = arr[right] > arr[max] ? right : max;
}
if(max != t){
swap(arr + t, arr + max); // 交换函数
percolate_down_recursive(arr, max);
}
}
非递归+空穴:
非递归+交换也可以。
示意图:
这里以 1 号节点为例。实际对树进行堆化时,还是按照自底向上的方法,从最后一个父节点开始,采用下滤策略。
代码:
void percolate_down_no_swap(int arr[], int t)
{
int key = arr[t]; //要处理的键值,一个根节点
int max_idx = -1; //比较大的索引
int heap_ok = 0; //父母优势不满足标志,初始值不满足
while(!heap_ok && LEFT(t) <= NUM){ //有孩子必定先有左孩子
max_idx = LEFT(t); //假设左孩子键值大
if(LEFT(t) < NUM){ //存在右孩子
if(arr[LEFT(t)] < arr[RIGHT(t)]){ //找出最大值
max_idx = RIGHT(t);
}
}
if(key >= arr[max_idx]){ //父母优势满足,跳出
heap_ok = 1;
}
else{
arr[t] = arr[max_idx];
t = max_idx; //下一轮,孩子的子树 父母优势判断,
}
}
arr[t] = key;
}
一:自顶向下构造堆+上滤
依然使用4, 1, 3, 2, 16, 9, 10, 14, 8, 7
这列键 。
示意图:
代码:
void insert(element_t x, priority_queue max_heap)
{
if( is_full(max_heap) ){
printf("priority queue is full, insert failed\n");
return;
}
// 把新节点附加在当前堆的最后一个叶子后面
max_heap->elements[ ++max_heap->size ] = x;
int cur = max_heap->size; // cur 指向新的节点
#ifdef MAX_HEAP_SENTINEL
while( x > max_heap->elements[ cur/2 ] ) // 把新节点的键值和它的父母比较
#else
// 当 cur == 1 时,说明新节点已经被交换到了树根的位置,此时应该跳出循环
while( (cur != 1) && (x > max_heap->elements[ cur/2 ]) )
#endif
{
// 交换新节点和它的父母
swap(max_heap->elements + cur/2, max_heap->elements + cur);
cur /= 2; // cur继续指向新节点
}
}
// swap操作比较费时,上面的函数可以优化一下
void insert_no_swap(element_t x, priority_queue max_heap)
{
if( is_full(max_heap) ){
printf("priority queue is full, insert failed\n");
return;
}
// 在当前堆的最后一个叶子后面创建一个空穴,用cur指向空穴
int cur = ++max_heap->size;
#ifdef MAX_HEAP_SENTINEL
while( x > max_heap->elements[ cur/2 ] ) // 新节点的键值和空穴的父母作比较
#else
while( (cur != 1) && (x > max_heap->elements[ cur/2 ]) )
#endif
{
// 父母向下移动一层,填补空穴,原父母的位置成为新的空穴
max_heap->elements[cur] = max_heap->elements[cur/2];
cur /= 2;
}
max_heap->elements[cur] = x; //把新节点填入空穴
}
堆排序:
删除最大键:
假设依据上面的方法,构造好了一个大根堆。对应数组是: [16,14,10,8,7,3,9,1,4,2]
。
示意图:
代码:
element_t delete_max(priority_queue max_heap)
{
if( is_empty( max_heap ) ){
printf("priority queue is empty, return [0]\n");
return max_heap->elements[0];
}
if( max_heap->size == 1){
max_heap->size = 0;
return max_heap->elements[1];
}
else{
element_t max_elmt = max_heap->elements[1];
// 得到最后一个元素,并且堆的大小减一
element_t last_elmt = max_heap->elements[max_heap->size--];
// 把最后一个元素移动到树根的位置
max_heap->elements[1] = last_elmt;
// 调整树根
percolate_down_no_swap(max_heap->elements,max_heap->size ,1);
return max_elmt;
}
}
完整代码:
#include <stdio.h>
#define LEFT(i) (2*i+1)
#define RIGHT(i) (2*i+2)
#define PARENT(i) ((i-1)/2)
#define LAST_PARENT(n) (((n)/2) - 1) // yang
#define ELMT_NUM 10
#define DUMMY_POS -1
#define DUMMY_TOKEN '\0'
typedef int element_t;
void print_array_debug(int a[], int len, int pos, char token)
{
for(int i=0; i<len; ++i)
{
if( i == pos )
{
printf("%c %d ", token, a[i]); //打印元素值和记号
}
else
{
printf("%3d ",a[i]); //正常打印
}
}
printf("\n\n");
}
//交换*a和*b
void swap(int* a, int* b)
{
int temp = *a;
*a = *b;
*b = temp;
}
// 下滤函数(递归解法)
// 假定以 LEFT(t) 和 RIGHT(t) 为根的子树都已经是大根堆
// 调整以 t 为根的子树,使之成为大根堆。
// 节点位置为 [0], [1], [2], ..., [n-1]
void percolate_down_recursive(int a[], int n, int t)
{
int left = LEFT(t);
int right = RIGHT(t);
int max = t; //假设当前节点的键值最大
if(left < n) // 说明t有左孩子
{
max = a[left] > a[max] ? left : max;
}
if(right < n) // 说明t有右孩子
{
max = a[right] > a[max] ? right : max;
}
if(max != t)
{
swap(a + max, a + t); // 交换t和它的某个孩子,即t被换到了max位置
percolate_down_recursive(a, n, max); // 递归,继续考察t
}
}
// 自底向上建堆,下滤法
void build_max_heap(element_t a[], int n)
{
int i;
// 从最后一个父母节点开始下滤,一直到根节点
//for(i = PARENT(n); i >= 0; --i) // yang
for(i = LAST_PARENT(n); i >= 0; --i)
{
percolate_down_recursive(a, n, i);
}
}
//把最大元素和堆末尾的元素交换位置,堆的规模减1,再下滤根节点
void delete_max_to_end(int heap[], int heap_size)
{
if(heap_size == 2) // 当剩下2个节点的时候,交换后不用下滤
{
swap( heap + 0, heap + 1 );
}
else if(heap_size > 2)
{
swap( heap + 0, heap + heap_size - 1 );
percolate_down_recursive(heap, heap_size-1, 0);
}
return;
}
void heap_sort(int a[], int length)
{
build_max_heap(a,length);
#ifdef PRINT_PROCEDURE
printf("build the max heap:\n");
print_array_debug(a,ELMT_NUM, DUMMY_POS, DUMMY_TOKEN);
#endif
for(int size=length; size>=2; --size)
{
delete_max_to_end(a,size);
#ifdef PRINT_PROCEDURE
print_array_debug(a, ELMT_NUM, size-1, '|');
#endif
}
}
int main(void)
{
int a[ELMT_NUM]={4,1,3,2,16,9,10,14,8,7}; //10个
printf("the array to be sorted:\n ");
print_array_debug(a,ELMT_NUM, DUMMY_POS, DUMMY_TOKEN);
heap_sort(a,ELMT_NUM);
printf("sort finished:\n ");
print_array_debug(a,ELMT_NUM, DUMMY_POS, DUMMY_TOKEN);
}