一、二叉树的顺序结构
普通的二叉树是不适合用数组来存储的,因为可能会存在大量的空间浪费。而完全二叉树更适合使用顺序结构存储。现实中我们通常把堆(一种二叉树)使用顺序结构的数组来存储,需要注意的是这里的堆和操作系统虚拟进程地址空间中的堆是两回事,一个是数据结构,一个是操作系统中管理内存的一块区域分段
二、堆的概念及结构
如果有一个关键码的集合K ={k0,k1,k2,…,kn-1},把它的所有元素按照完全二叉树的顺序存储方式存储在一个一维数组中,并满足:Ki <= K2*i+1且 Ki <= K2*i+2 (Ki >= K2*i+1且 Ki >= K2*i+2) i =0, 1, 2…则称为小堆(或大堆)。将根节点最大的堆叫做最大堆或大根堆,根节点最小的堆叫做最小堆或小根堆
堆分为大堆和小堆
- 小堆要求:任意一个父亲结点<=孩子结点
- 大堆要求:任意一个父亲结点>=孩子结点
堆的性质:
- 大堆中某个节点的值总是不大于其父节点的值
- 小堆中某个节点的值总是不小于其父节点的值
- 堆总是一棵完全二叉树
三、堆的实现
堆分为大堆或小堆,无论是向上或向下调整算法,会根据大小堆的需求去修改部分的代码,其实就是修改大于小于号的问题。以下代码部分是根据建小堆来走,如果需要建大堆可以修改直接的大于小于号。
堆总是一颗完全二叉树,对于搭建完全二叉树的结构,一般采用数组作为存储结构,而完全二叉树作为逻辑结构。
父子节点间下标规律关系
leftchild = parent * 2 + 1;
rightchild = paretn * 2 +2;
parent = (child - 1) / 2;
(不区分左右孩子)
3.1 堆向下调整算法
堆向下调整(Heapify Down)
是一个修复堆性质的过程,而不是用于初始化或完全建立堆数据结构的过程。使用向下调整算法的前提是需要左右子树必须是一个堆才能进行调正,如果左右子树不是一个堆,我们将不采取使用向下调整算法,而是采用向上调整算法。
堆向下调整算法只用于根节点不满某种条件时,使用向下调正算法进行调整,至于使用向下调整算法不能达到我们的预期,比如现在建小堆,从根节点和根左右节点调整,由于左右子树不是一个小堆,无法保证此时的根就是最小的值,可能在某个子树中,左右子树话没有进行调整。除此之外删除节点也适合向下调整算法。
void AdjustDown(HPDataType *a,int size,int parent) { int child=parent * 2 + 1; while(child<size)//空树或者只有一个结点 { //假设左孩子小,如果右孩子小,就更新下(左右孩子相差1)选择较小的孩子 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; } } }
3.2 向上调整算法
在堆数据结构中,堆向上调整(Heapify Up)
是一种用于保持堆的性质的操作,通常适用于最后一个元素出现问题或者插入新元素的时候使用.
void AdJustUp(HPDataType *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;//不用交换,直接退出 } } }
这里的孩子节点不用需要判断左右孩子,肯定是先插入左孩子,在插入右孩子,如果如果是插入右孩子,就不必要考虑左孩子,此时左孩子的值是符合大于(小于)等于父亲的情况.
无论是向下调整算法还是向下调整算法,目的都是使得保持堆的性质,在判断语句中得以体现。想要更好地理解这个两个算法,搞清楚谁是需要被处理的节点,循环条件是什么?
3.3 处理完全二叉树不是堆情况
把它构建成一个堆。根节点左右子树不是堆,这里我们从倒数的第一个非叶子节点的子树使用向上调整算法开始调整,一直调整到根节点的树,就可以调整成堆。
3.4 堆的插入
随机插入一个数值到数值的尾上,再进行向上调整算法直到满足堆
void HeapPush(HP *php,HPDataType x) { assert(php); if(php->size==php->capacity) { int newcapacity=php->capacity==0?4:php->capacity*2; HPDataType*tmp=(HPDataType*)realloc(php->a,newcapacity*sizeof(HPDataTyped)); if(tmp==NULL) { perror("realloc fail!!!"); return 1; } php->a=tmp; php->capacity=newcapacity; } php->a[php->size]=x; php->size++; //重头戏--向上调整形成一个堆,这里的size代表的是下一个元素,所以-1 AdjustUp(php->a,php->size-1); }
3.5 堆的删除
关于堆的删除,我们一般默认规定删除堆顶也是就是根节点,至于删除尾部数据意义不大,尾部数据没有特别的地方,既不是最大(小)的数据意义不大。
3.5.1 挪移数据覆盖删除
挪移数据覆盖会导致堆发生严重BUG,整棵树的父子关系全乱,也就是需要维持大小关系乱了(我拿你当兄弟,你却像当我爹)
3.5.2 首尾交换再删除
对于堆的删除,我们采用另外一种方法,首尾交换再删除,左右子树依旧是堆,同时关系也没有乱,并且删除堆顶数据通过尾删再向下调整代价很低
void HeapPop(HP *php) { assert(php); assert(php->size>0)//没有数值,不需要删除 Swap(&php->a[0],&php->a[php->size-1]);//size是指向下一个 php->size--; AdjustDown(php->a,php->size,0) }
四、堆的应用
4.1 堆排序
堆排序(HeapSort)
移除位在第一个数据的根节点,并做最大堆调整的递归运算建堆(本质:模拟堆插入的过程建堆)。上面对于堆的调整不是叫做堆排序,堆排序是对数组元素进行操作的
堆排序即是运用堆的思想进行排序,总共分为两个步骤:建堆和利用堆删除思想进行排序
1.建堆 (后面有解释)
- 升序:建大堆
- 降序:建小堆
2.利用堆删除思想来进行排序
建堆和堆删除中都用到了向下调整,因此掌握了向下调整,就可以完成堆排序。
该过程解析:这里是需要升序,根据结论需要建大堆。可以这样子理解升序建大堆目的,我们配合物理结构数组和逻辑结构二叉树去看待这个问题,如果我们需要升序,意味着数组最后一个元素是最大值,那么大堆可以保证堆顶元素是最大值,再利用堆删除的思想,将堆顶元素和尾元素交换,那么可以保证最大值在尾,而且由于是大堆,尾元素互会通过向下调整算法使得堆顶元素为次大的值,这个时候最后一个元素不用去动他,倒数第二个位置跟次大堆顶元素交换,这样子就完成了堆排序。
4.1.1 如果升序建小堆
如果升序建小堆1 2 2 6 5 8 4 9,当我们把最小值1选出来后,接下来需要找次小值。最小值1的位置是不动,剩下的数不能看成堆,关系乱了,只能重新建堆,找出次小值,但是代价很大
4.1.2 向上或向下调整建堆
这里为了快速地使用堆排序,这里可以直接通过向上或向下调整算法直接建堆。不止可以使用向上调整建堆,也可以使用向下调整建堆(使用向下调整建堆,需要保证左右为堆),对此不能从整体入手,可以一步步向上。从倒数的第一个非叶子,也就是最后一个结点的父亲,不断的向上而向下调整。向下调整建堆相较于向上调整建堆有很多优势,在建堆的时间复杂度分析中,可以看出,这里关于这方面会单独拿出来分析。对此这需要掌握堆向下调整算法即可
这里不要跟上面堆的插入混淆,这里数组元素已经确定,而堆的插入元素在不断地更新,如果使用向下调整意味着从新插入界节点重新向上调整,向上调整只需要对新插入节点进行移动即可
//升序 void HeapSort(int *a,int n) //O(N*logN) //for(int i=0;i<n;i++) //{ // AdjustUp(a,i); // } //O(N) for(int i=(n-1-1)/2;i>=0;--i) { AdjustDown(a,n,i);//从倒数的第一个非叶子,也就是最后一个结点的父亲 } int end=n-1;//下标--同时调整后,最后一个元素不再改动 //O(N*logN) while(end>0)//利用堆删除思想进行排序 { Swap(&a[0],&a[end]); AdjustDown(a,end,0);//要清楚为什么要向下调整 --end; }
4.1.3向下向上调整建堆时间复杂度
过程解析:无论是向上还是向下调整建堆,建堆的累积调整次数等于每一层节点个数*向上(下)调正次数之和。主要是利用高中数学中错位相减法计算出求和通式。由于一般不会得知树的高度去求时间复杂度,而是通过节点个数去求时间复杂度,这里需要利用树的高度与节点个数的关系式,进行替换即可。满二叉树:2^h-1 =N 可得 h =log(N+1)
这里建堆主要就是受到每一个节点个数*向上(下)调正次数,对于向下调整建堆多节点*少调整、少节点*多调整
,而向上调整建堆多节点*多调整、少节点*少调整
导致时间复杂度差异。
4.2 TOP-K问题
即求数据结合中前K个最大的元素或者最小的元素,一般情况下数据量都比较大。比如:专业前10名、世界500强、富豪榜、游戏中前100的活跃玩家等。
对于Top-K问题,能想到的最简单直接的方式就是排序,但是:如果数据量非常大,排序就不太可取了(可能数据都不能一下子全部加载到内存中,内存不足的问题)。最佳的方式就是用堆来解决,基本思路如下
用数据集合中前K个元素来建堆
- 前k个最大的元素,则建小堆
- 前k个最小的元素,则建大堆
用剩余的N-K个元素依次与堆顶元素来比较,不满足则替换堆顶元素 ,将剩余N-K个元素依次与堆顶元素比完之后,堆中剩余的K个元素就是所求的前K个最小或者最大的元素 ,时间复杂度O(N*logK)
解析过程:这里思路跟堆排序大差不差,主要就是利用堆顶的特性。如果需要找出最大值,那么在小堆(都是很大的值)中堆顶就相当于门槛,至少需要被最大中的最小要大才有资格进来,然后重新筛选出来新的最小值当保安。
测试代码(自取)
void TestTopk() { int n = 10000; int* a = (int*)malloc(sizeof(int)*n); srand(time(0)); for (size_t i = 0; i < n; ++i) { a[i] = rand() % 1000000; } a[5] = 1000000 + 1; a[1231] = 1000000 + 2; a[531] = 1000000 + 3; a[5121] = 1000000 + 4; a[115] = 1000000 + 5; a[2335] = 1000000 + 6; a[9999] = 1000000 + 7; a[76] = 1000000 + 8; a[423] = 1000000 + 9; a[3144] = 1000000 + 10; }