1.堆的概念及结构
如果有一个关键码的集合,把它的所有元素按完全二叉树的顺序存储方式存储在一个一维数组中,并满足:
且
则称为小堆(或大堆)。将根节点最大的堆叫做最大堆或者大根堆,根节点最小的堆叫做最小堆或小根堆。
堆的性质:1、堆中某个节点的值总是不大于或不小于其父节点的值;2、堆总是一棵完全二叉树。
2.堆的分类
堆主要分为两种类型:大根堆(最大堆)(Max Heap)和小根堆(最小堆)(Min Heap)。在大根堆中,父节点的值总是大于或等于其子节点的值,因此堆顶元素是整个堆中的最大值。相反,在小根堆中,父节点的值总是小于或等于其子节点的值,堆顶元素是整个堆中的最小值。
堆通常使用数组来实现,数组中的每个元素对应堆中的一个节点。由于堆是完全二叉树,所以可以使用数组的下标关系来模拟树中父节点和子节点之间的关系。这种实现方式使得堆在插入、删除和查找最大(或最小)元素等操作中具有高效的性能。
3.父节点和子节点的下标关系
对于给定的下标为 i
的结点,其父结点、左子结点和右子结点的下标可以通过以下关系式计算:
- 下标i元素的父结点下标:
(i - 1) / 2
(使用整数除法) - 下标i元素的左子结点下标:
2 * i + 1
- 下标i元素的右子结点下标:
2 * i + 2
4.堆的实现
4.1 堆的向下调整算法(重点掌握)
现在我们给出一个数组,逻辑上看做一棵完全二叉树。通过从根节点开始的向下调整算法可以把它调整 成一个小堆。向下调整算法有一个前提:1、若想将其调整为小堆,根节点的左右子树必须都为小堆;2、若想将其调整为大堆,根节点的左右子树必须都为大堆。就根节点不满足。
向下调整算法的基本思想:1、从根节点处开始,选出左右子结点中值较小的子节点;2、让小的子结点与其父节点进行比较。我所说的这个是小堆的调整,大堆的话刚好相反。
(1)若左右子节点中较小的子结点比父节点小,则让该子结点与其父节点的位置进行交换。并将原来较小的子结点的位置当作父节点继续向下进行调整,直到调整到叶子节点为止。
(2)如左右子结点中较小的子节点比父节点大,则就不需要处理了,调整完成,整个树就已经是小根堆了。
比如:int array[] = {27,15,19,18,28,34,65,49,25,37};以27为根的左右子树,都满足小堆的性质,只有根节点不满足,因此只需要将根节点往下调整到合适的位置即可形成堆。
void Swap(HPDataType* p1, HPDataType* p2)
{
HPDataType tmp = *p1;
*p1 = *p2;
*p2 = tmp;
}
void AdjustDown(HPDataType* a, int n, int parent)
{
//默认左孩子节点较小
int child = parent * 2 + 1;
while (child < n)
{
//找出左右孩子中小的那一个
//如果右孩子小于左孩子
//这里child<n但是不能保证child+1<n
//child+1<n是为了防止出现越界情况的发生
if (child + 1 < n && a[child] > a[child + 1])
{
//将较小的子节点改为右孩子节点
++child;
}
//如果孩子节点小于双亲节点则交换
if (a[child] < a[parent])
{
Swap(&a[child], &a[parent]);
parent = child;
child = parent * 2 + 1;
}
else
{
//当小的孩子节点大于双亲节点,则就不需要往下调整了
break;
}
}
}
当调用AdjustDown()这个函数时,需要传入:1、传入指向堆元素数组的指针(例如,如果有一个int类型的堆数组heap,则传入heap)。2、传入堆数组中当前元素的数量(即堆的大小)。3、需要向下调整的节点的索引(对于根节点来说通常是0)。
4.2 堆的向上调整算法
对于上图中的小根堆,当我们在当前的小根堆的最后一个空缺位置插入一个新的节点,这个新节点要影响的并不是整棵左子树,也不是右子树,而是它的父亲节点和祖先节点,因为对于堆来说,就是需要去比较它的父亲节点和孩子节点之间的关系。
对于上图中的小根堆,此时在末尾插入一个100,该节点比其父亲节点56还要大,则该节点当然也比其祖先节点要大。因此,我们无需再往上进行比较。
我们再看第二个小根堆,将标红的目标节点与其父节点进行比较,若目标节点的值比其父节点的值小,则交换目标节点与其父节点的位置,并将原目标节点的父节点当作新的目标节点继续进行向上调整。如目标节点的值比其父节点的值大,则停止向上调整,此时该树已经是小根堆了。
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;
}
}
}
为了正确调用AdjustUp()函数,需要传入堆的数组(指针),当前需要调整的节点的索引(通常是指新插入元素的索引)。
5.堆的数据结构各算法接口实现
5.1 结构体的定义及声明
我们首先介绍结构体的定义和声明,如下代码所示:
typedef struct Heap
{
HPDataType* _a;
int _size;
int _capacity;
}Heap;
5.2 堆的初始化
这里的堆初始化是提供一个数组,使用向下调整算法将该数组调整成堆。
//提供一个数组a,并把该数组调整成一个堆
void HeapInit(Heap* php, HPDataType* a, int n)
{
//1、开辟一个空间并将数组a中的数据拷贝到该空间中--使用memcpy函数
php->_a = (HPDataType*)malloc(sizeof(HPDataType) * n);
memcpy(php->_a, a, sizeof(HPDataType) * n);
php->_size = n;
php->_capacity = n;
//2、使用向下调整算法构建堆
for (int i = (n - 1 - 1) / 2; i >= 0; --i)
{
AdjustDown(php->_a, php->_size, i);
}
}
5.3 堆的销毁
//注意:这里指针php指向的结构体Heap不需要我们去free,
//因为这个结构体不是malloc函数创建出来的。
void HeapDestroy(Heap* php)
{
assert(php);
free(php->_a);
php->_a = NULL;
php->_size = php->_capacity = 0;
}
5.4 向堆中插入数据
上面我们已经构建了一个基本堆,现在往堆中插入数据。对于数组我们都会去写一段数组扩容的逻辑,除了扩容逻辑之外,再底部还有一个向上调整算法,我们在插入新的元素之后要始终保持原先的堆是一个大根堆或者小根堆,所以要去进行一个向上调整。
//插入数据,借助向上调整算法来完成插入数据的操作
void HeapPush(Heap* php, HPDataType val)
{
assert(php);
//如果数组的大小和容量相等,则需要进行扩容
if (php->_size == php->_size)
{
php->_capacity *= 2;
HPDataType*tmp = (HPDataType*)realloc(php->_a, sizeof(HPDataType) * php->_capacity);
php->_a = tmp;
}
php->_a[php->_size++] = val;
AdjustUp(php->_a, php->_size - 1);
}
5.5 删除堆顶数据
删除堆顶元素的前提是不能破坏堆原有的结构,将堆顶的数据与堆中最后一个数据进行交换,将堆的大小减小即--size删除最后一个数据,也就是原堆顶数据,再进行一次堆的向下调整操作。
//删除堆顶的数据--不能破环堆原有的结构
//将堆顶的数据与最后一个数据进行交换,--size删除最后一个数据,即原堆顶数据
//再进行一次向下调整操作
void HeapPop(Heap* php)
{
assert(php);
assert(php->_size > 0);
Swap(&php->_a[0], &php->_a[php->_size - 1]);
php->_size--;
AdjustDown(php->_a, php->_size, 0);
}
6.两种调整算法的时间复杂度刨析
6.1 向下调整算法的时间复杂度刨析
在本文的第4部分我们介绍了两种堆的调整算法,分别是向上调整算法和向下调整算法,在实现接口HeapPush和HeapPop时又用到了这两种算法,本文的核心就是围绕这两个调整算法来的。下面我们就分析这两种算法的时间复杂度。
因为堆是完全二叉树,而满二叉树也是完全二叉树,此处为了简化使用满二叉树来证明。(时间复杂度看的是近似值,多几个节点不影响最终结果)
由上图可知,
第1层:2^0个节点,需要向下移动h-1层;
第2层:2^1个节点,需要向下移动h-2层;
第3层:2^2个节点,需要向下移动h-3层;
...
第h-1层:2^(h-2)个节点,需要向下移动1层。
建堆的调用次数用T(N)表示:(从最后一个非叶子节点<也就是倒数第二层>开始,最极端的情况下:倒数第二层每个节点最多能向下调整1次;倒数第三层每个节点最多能向下调整2次;倒数第四层每个节点最多向下调整3次;依次类推。
向下调整建堆总的调整次数为:每层节点个数*极端情况下每个节点调整的次数;
即T(N)=2^(h-2) *1+2^(h-3)*2+...+2^1*(h-2)+2^0(h-1) (1)
在公式(1)的左右两边同时乘以2,得到公式(2);
2T(N)=2*2^(h-2)*1+2*2^(h-3)*2+...+2*2^1*(h-2)+2*2^0(h-1) (2)
公式(2)-(1)得:T(N)=2^h-1-h
计算时间复杂度:若规定根节点的层数是1,则深度为h的二叉树的最大节点数是2^h-1,则有h=log(N+1)。则有T(N)=N-log(N+1);即时间复杂度为O(N)。
6.2 向上调整算法的时间复杂度刨析
由上图可以得到向上调整建堆总的调整次数为:每层节点个数*极端情况下每个节点向上调整的次数;
T(N)=2^(h-1) *(h-1)+2^(h-2)*(h-2)+...2^2*2+2^1*1 (1)
则:2T(N)=2*2^(h-1)*(h-1)+2*2^(h-2)*(h-2)+...+2*2^2*2+2*2^1*1 (2)
则可得向上调整算法的时间复杂度为:NlogN。
很明显,向下调整算法更优一些,因为向下调整随着堆的层数增加结点数也会变多,可是结点越多调整得就越少,因为在一些大型数据处理场合我们会使用向下调整算法。
7.堆的实际应用
7.1 堆排序
下面我们介绍一种基于堆的排序算法--堆排序。
如果要排升序,是建大堆还是建小堆来进行排序?
首先建小堆可以实现升序,但是效率得不到保证。如果选择建小堆的话,每次建堆选出最小的数据(即栈顶元素),找出这个最小值后,后面剩余的节点还需要继续建小堆来找出次小的数据,即选出一个数据需要建一次小堆。每次向下调整建堆的时间复杂度是O(N),那么选完N个数据的时间复杂度是O(N^2)。
则可以考虑建大堆来实现升序,第一次建大根堆,堆顶元素为最大值,将该最大值与堆底末梢数据调换位置(此时建立大根堆的时间复杂度为O(N)),并同时将堆的size--,即除了最后一个叶子节点(原堆顶最大节点),将剩余的其他节点组成一棵二叉树,并使用向下调整算法对该树进行调堆建立大根树,找出次大的节点,由于此时该堆的左右子树都是大堆,只有根节点不满足,因此此时使用向下调整算法建立大根堆的时间复杂度是O(logN)。
时间复杂度总结:第一次建大根堆选出最小的数(时间复杂度为O(N)),后面需要使用向下调整算法选出次小以及后续的数据,一次向下调整算法的时间复杂度是logN,则N个数据需要被调整,则整体的时间复杂度是NlogN。总的时间复杂度为O(N+NlogN),取影响大的那个就是O(NlogN),这就是堆排序的时间复杂度。
//堆排序:
//给定一个数组a,对该数组进行堆排序,n为数组中元素的个数
//排降序建小堆
void HeapSort(int* a, int n)
{
//1.建堆--利用向下调整算法建堆
//从最后一个非叶子结点开始调整建堆,
//即从最后一个叶子节点的父节点开始调整建堆
for (int i = (n - 1 - 1) / 2; i >= 0; --i)
{
AdjustDown(a, n, i);
}
int end = n - 1;
while (end>0)
{
Swap(&a[0], &a[end]);
//再继续选次小的数
AdjustDown(a, end, 0);
--end;
}
}
7.2 Top-K问题
7.2.1 Top-K问题的定义及思想
(1)Top-K问题的定义
求出一组数据中前K个最大的元素或者最小的元素,一般情况下数据量都比较大。比如:专业前10名、世界500强、富豪榜等。我们以求n个数据中前K个最大的元素为例进行说明:(假设n=10000、K=10)。
(2)解决Top-K问题的思路
①排序法(不推荐使用)
对于Top-K问题,能想到的最简单直接的方法就是排序,我们可以把这个10000个数据排成降序,然后逐个取前10个数就是最大的10个数。即使用最快的排序算法,时间复杂度也是O(N*logN),空间复杂度是O(1)(直接在数组内对数据进行排序操作,所以空间复杂度是O(1),如果在排序的过程中另外新开辟了包含N个数据的空间,空间复杂度是O(N))。此外,如果数据量特别大可能数据都不能一下子全部加载到内存中。
②堆函数操作法(不推荐使用)
建立包含N个数据的大根堆(此时堆顶元素就是就是数组中的最大值),接着就可以不断的弹出当前堆顶元素,同时不断更新堆顶以保证当前堆一直是大根堆,这样我们所弹出的K个元素就是数组中前K个最大的元素。
时间复杂度:O(N+K*logK)(建大根堆的时间复杂度O(N)+从堆顶向下调整一次的高度是logN,一共需要调整K次,即需要Pop K次O(K*logK))。由于是直接在数组中建堆,则空间复杂度为:O(1)
当N非常大时,N远大于K ,比如100亿个数里面找出最大的前10个,上面的两种方法均不适用,即100亿个整数是放在磁盘中,也就是文件中。
③最优的解决思路
1. 用数据集合中的前K个元素来建堆
如果要找出前K个最大的元素,则建小堆;如果要找出前K个最小的元素,则建大堆。
2. 用剩余的N-K个元素依次与堆顶元素进行比较,满足要求则替换堆顶元素
假设找出前K个最大的元素,则建小根堆。并将剩余N-K个元素依次与堆顶元素进行比较,如果比堆顶数据大就替换堆顶。将剩余N-K个元素依次与堆顶元素比完之后,堆中剩余的K个元素就是所求的前K个最大的元素。
用前K个数建立一个K个数的小堆(前K个数是随机的,我们只管建小堆),然后剩下的N-K个依次遍历 和堆顶最小的数据比较,如果比堆顶的数据大,就把大的数赋给堆顶,再向下调整,最后堆里面的K个数就是最大的K个 ,因为小堆的堆顶一定是最小的数,只要随便拿个数比他大就交换他俩,大的那个数进入堆后再建小堆,大的数就沉在最下面,所以最后堆里面一定是K个最大的数。
复杂度:
时间复杂度:建K个数据的小根堆的时间复杂度为:O(K),剩下的N-K个数和堆顶比较,比堆顶大就放进堆顶,并向下调整一次,向下调整一次最坏交换h次,h=logK;最坏情况是建完小根堆后,里面正好全是最小的数,则剩下N-K个数都比堆顶大,都要放进堆顶并向下调整,所以是O((N-K)*logK),加起来就是O(K+(N-K)*logK)。
空间复杂度:O(K)。
代码如下:
//1、交换函数
void Swap(HPDataType* p1, HPDataType* p2)
{
HPDataType tmp = *p1;
*p1 = *p2;
*p2 = tmp;
}
//2、向下调整算法,将二叉树调整成大堆形式
void AdjustDown(HPDataType* a, int n, int root)
{
//将根节点赋值给双亲节点
int parent = root;
//默认新定义的孩子是左孩子
int child = parent * 2 + 1;
while (child < n)
{
//找出左右孩子中大的那一个
//如果右孩子大于左孩子
if (child + 1 < n && a[child] < a[child + 1])
{
++child;
}
//如果孩子节点小于双亲节点则交换
if (a[child] > a[parent])
{
Swap(&a[child], &a[parent]);
parent = child;
child = parent * 2 + 1;
}
else
{
//当小的孩子节点大于双亲节点,则就不需要往下调整了
break;
}
}
}
//Top-K问题的代码实现
//3、Top-K问题--返回数组中前K个最小的值
int* GetLeastNumbers(int* arr, int arrSize, int k)
{
//开辟一块空间用于存放数组中前K个最小的值,并返回该数组
int* retArr = (int*)malloc(sizeof(int) * k);
//建包含K个数据的大堆
//首先将arr数组中前K个数据放入到数组retArr中
for (int i = 0; i < k; ++i)
{
retArr[i] = arr[i];
}
//建大根堆
for (int i = (k - 1 - 1) / 2; i >= 0; --i)
{
AdjustDown(retArr, k, i);
}
//除去前K个数,从第K+1个数开始与堆顶的数据比较
//如果比堆顶的数据小,则进堆,并进行一次向下调整,重新建大根堆
for (int j = k; j < arrSize; ++j)
{
if (arr[j] < retArr[0])
{
retArr[0] = arr[j];
AdjustDown(retArr, k, 0);
}
}
return retArr;
}
堆的完整代码:堆的向下/向上调整算法、堆排序、Top-K问题完整代码