1.堆
堆是一种完全二叉树,现实中通常使用顺序结构的数组来存储堆,此处的堆是一种数据结构(完全二叉树),而不是虚拟进程空间中的那个堆(是操作系统用来管理内存的一个区域分段)
如果有一个关键码的集合K = {k0,k1, k2,…,kn-1},把它的所有元素按完全二叉树的顺序存储方式存储在一个一维数组中,并满足:Ki <= K2i+1 且 Ki<= K2i+2 (Ki >= K2i+1 且 Ki >= K2i+2) i = 0,1,2…,则称为小堆(或大堆)。将根节点最大的堆叫做最大堆或大根堆,根节点最小的堆叫做最小堆或小根堆。
堆的性质:
- 堆中某个节点的值总是不大于或不小于其父节点的值;
- 堆总是一棵完全二叉树。
小根堆
大根堆
2. 堆的实现
现在我们先给出一个数组,在逻辑上将其看作是一颗完全二叉树,用它作为一个向下调整算法和向上调整算法的示例
int a[ ] = {27,15,19,18,28,34,65,49,25,37}
2.1向下调整算法
首先,向下调整算法有一个大的前提:左右子树必须是一个堆(大堆或小堆),才能进行调整。
该算法的核心思想就是:选出左右孩子节点中较小的那一个,跟父亲节点进行交换,将小的向上浮,大的向下沉
初始情况
选出较小的孩子节点:15,然后跟27做交换
然后,重复以上步骤,直至27交换到小于它的孩子节点的时候或是叶子节点的时候
这就是一次向下调整算法的整个过程
算法实现(小堆)
//小堆的向下调整算法
//arr为传入的完全二叉树,arrSzie为树的大小,root为传入的根节点(需要进行调整的节点)
void AdjustDown(int* arr, int arrSize,int root)
{
int parent = root;
//默认左右孩子中较小的节点为左孩子,由二叉树的性质可知,右孩子的位置就为child + 1
int child = parent * 2 + 1;
while(child < arrSize)
{
//首先得到左右孩子中较小的那一个
if(child + 1 < arrSize && arr[child + 1 ] < arr[child])
{
++child;
}
//其次,比较较小孩子节点和父亲节点的大小,若小于,则交换,并且更新两个节点位置,若大于或等于,则break;
if(arr[child] < arr[parent])
{
int tmp = arr[child];
arr[child] = arr[parent];
arr[parent] = tmp;
parent = child;
child = parent * 2 + 1;
}
else
{
break;
}
}
}
若是实现大堆的向下调整算法,则只需变换两个符号即可
if(child + 1 < arrSize && arr[child + 1 ] > arr[child])
if(arr[child] > arr[parent])
注:
- 向下调整算法是大多是用来建堆的情况
- 由于树的高度为log2N,向下调整算法的最坏情况是从根节点开始一直调整到叶子节点,总共需要走log2N次,因此向下调整算法的时间复杂度为 O(log2N)
2.2 向上调整算法
向上调整算法多用作堆插入的情况,当要在堆中插入一个数时,为了不打乱堆(二叉树)的结构,往往都是将其插入堆的最后一个节点处,但此时堆就不再是原来的小堆(大堆)了,需要对其进行调整。和向下调整算法(向下交换)一样,向上调整算法是将插入的值向上交换;找到插入节点的父亲节点,比较两个节点的值,如果小于,则进行交换,如果大于或等于则说明已经排好顺序(这里说的是小堆),也是"小的向上浮,大的向下沉"。
初始情况:首先将要插入的节点放在小根堆的末尾
将插入的节点和其对应的父亲节点做比较,如果小于 (2 < 28),则交换两个节点,如果大于或等于,则表明已经排好顺序
然后,重复以上步骤,直到它成为根节点或者大于等于父亲节点时,则停止
这就是一次向上调整算法的过程
算法实现(小堆)
//小堆的向上调整算法
//arr为传入的完全二叉树,arrSzie为树的大小,child为传入的孩子节点(需要进行调整的节点)
void AdjustUp(int* arr ,int arrSize ,int child)
{
int parent = (child - 1) / 2;
//当child等于0时,表明已经为根节点,证明整个已经排好序了,则退出
//这里不能使用parent >= 0来做循环结束的标准,因为当child = 0时,parent = (child - 1) / 2 = -1 / 2 = 0。
while(child > 0)
{
if(arr[child] < arr[parent])
{
int tmp = arr[child];
arr[child] = arr[parent];
arr[parent] = tmp;
child = parent;
parent = (child - 1) / 2;
}
else
{
break;
}
}
}
若是实现大堆的向上调整算法,只需变换一个符号即可
arr[child] > arr[parent]
2.3 建堆算法
下面我们给出一个数组,这个数组逻辑上可以看做一颗完全二叉树,但是还不是一个堆,现在我们通过算法,把它构建成一个大根堆。
int a[ ] = {1,5,3,8,7,6}
由于根的左右子树并不是(大根)堆,因此就不能使用向下调整算法,不满足该算法的前提,那既然从根节点开始使用向下调整算法不行,我们可以先考虑叶子节点,叶子节点由于再无孩子节点,因此向下调整算法可以使用,但如此做是没有意义的,于是我们可以沿着这个思路,转而去看倒数第一个非叶子节点,发现倒数的非叶子节点,满足使用向下使用算法的前提条件,于是我们可以从底向上推,从倒数第一个非叶子节点开始,一直逆推到根节点,每次都使用向下调整算法进行调整,最终即可得到一个堆。
(文字看起来繁琐?直接来看图)
初始情况
首先,对倒数第一个非叶子节点使用向下调整算法
然后对倒数第二个非叶子节点使用向下调整算法
最后,一直到根节点,再使用一次向下调整算法,即可得到最终的大根堆
算法实现
int n = sizeof(arr) / sizeof(int);
for(int i = ((n - 1) - 1) / 2 ; i >= 0; i--)
{
AdjustDown(arr,n,i);
}
3.堆排序
下面我们将上面建好的大根堆进行相应的堆排序,堆排序为升序(由小到大)
算法(升序)的核心思想是:每次将建好的大堆的最后一个叶子节点和根节点进行互换,然后执行一次向下调整算法调整堆的结构,再将指向堆最后位置的指针减1(相当于将最大的数放在最后一个,并且排除它),下面来说说原因,首先大根堆是所有父亲节点均大于其对应的孩子节点,而我们要求的是升序的堆,大根堆的根节点肯定是堆里最大的数,因此,我们可以通过将根节点和最后一个叶子节点互换,并将最后一个叶子节点的指针减1,即可保存最大的值,但是互换过去的叶子节点破坏了原来大根堆的结构,因此我们就需要用向下调整算法来恢复结构以便于选出次大的数。
注:
- 堆排序若是排升序,则要建大堆
- 堆排序若是排降序,则要建小堆
(文字太多不想看?直接看图解)
初始情况
① 首先将根节点和最后一个叶子节点进行互换,再将 end - -,表明保存下来最大的数
② 再进行向下调整算法,恢复其作为大根堆的结构
③然后重复①②过程,直至end指针等于0的时候,表明已经排序完毕
算法实现(升序)
//升序
void HeapSort(int* arr, int n)
{
//升序要首先建立大堆
//建堆的时间复杂度为o(N)
for(int i = ((n - 1) - 1) / 2 ; i >= 0; i--)
{
AdjustDown(arr,n,i);
}
//每次选出剩余数中最大的数,并保存到每次最后的节点
int end = n - 1;
while(end > 0)
{
int tmp = arr[end];
arr[end] = arr[0];
arr[0] = tmp;
//选出次小的数
AdjustDown(arr,end,0);
--end;
}
}
4.堆排序和建堆的时间复杂度
4.1 建堆的时间复杂度
首先,我们假设存在一个满堆,那么数组的长度为n,堆的高度为h,第k层节点的个数为2k,那么已知的关系有:
- n = 2h+1 - 1
- h = log2(n + 1) - 1
注:这里的根节点是从0开始的
由建堆算法可知,数组中每个非叶子节点都要进行一次向下调整算法,在向下调整算法中交换的次数相当于从该节点到叶子节点的高度,那么每一层中所有节点交换的次数为该层节点的个数乘以该节点到叶子节点的高度,比如,第一层的交换次数就是20 * h,那么对其进行累计求和,即可得到总的交换次数S(n)
即:
S(n) = 20 * h + 21 * (h-1) + 22 * (h - 2) + …… + 2h-2 * 2 +2h-1 * 1+ 2h * 0
观察一下S(n)的结构,不难发现它是由等比数列乘以等差数列再求和的情况,我们可以使用错位相减法来求出S(n).
化简一下,记为①
① S(n) = 20 * h + 21 * (h-1) + 22 * (h - 2) + …… + 2h-2 * 2 +2h-1 * 1
对 ① 等于号两边均乘以2,记为②,即:
② 2S(n) = 21 * h + 22 * (h-1) + 23 * (h - 2) + …… + 2h-1 * 2 +2h
用②式减去①式,即可得:
S(n) = -h + 21 + 22 + 23 + … + 2h-1 + 2h
利用等比数列求和公式:
本题中,a1 = 2,q = 2
则可以得到
S(n) = 2h+1 - (h + 2)
又因为已知条件
n = 2h+1 - 1
h = log2(n + 1) - 1
结合可得
S(n) = (n + 1) - (log2(n + 1) - 1 + 2)
化简后为:
S(n) = n - log2(n + 1)
至此,可得该建堆算法的时间复杂度为O(N).
更为详细得可以看这个,这是知乎上一名大佬的解法,我就是根据他的回答来写的
4.2 堆排序的时间复杂度
根据堆排序的算法实现可得,我们已知建堆的时间复杂度为O(N),而在排序过程中,由于要每次选出剩余数中最大的数,并保存到每次最后的节点,并要再执行一次向下调整算法,总共需要进行N次,而向下调整算法的时间复杂度为O(log2N),进行N次就是O(N*log2N),所以最终的时间复杂度为O(N + N * log2N),即为O(N*log2N)。