目录
一、前言
堆排序,顾名思义利用堆的结构特性对一组数据进行排序。
那么什么是堆呢?
二叉树的实现分为两种,顺序结构实现二叉树或者是链式结构实现二叉树。使用顺序结构也就是数组来储存一定的数据实现二叉树结构就叫堆。所以堆就是二叉树,而且是一种特殊的二叉树,在具备普通二叉树性质的同时,还得具备完全二叉树的特点。
堆的底层结构是数组。
堆排序分为两种,大根堆和小根堆:
①大根堆(大堆):每个结点的值都大于或等于其左右孩子结点的值
②小根堆(小堆):每个结点的值都小于或等于其左右孩子结点的值
注意!!!并不要求结点的左右孩子的值的大小关系
[本文章统一以小根堆为背景来介绍]
我们知道在堆的基本操作中,出堆一般都是按照一定的顺序。大堆出堆是升序,小堆出堆是降序。那么借助这一特点,我们就可以利用出堆的这个思想来给一组数据进行排序。
二、分析
首先,因为堆的底层结构是数组,那么待排序列也可以存在数组里面,方便后续操作。
但是不能一直都原封不动的在数组里面放着,毕竟我们是要借助堆的结构特性去排序的。因此啊这个时候,我们就要先将这个数组里面数据的排放结构进行调整(如下图),使其符合一个堆的结构。
这里我们用到的是向上调整算法建堆的。
2.1 向上调整算法
那么啥是向上调整算法呢?
基本思路:将元素插入到堆的末尾。插入之后如果堆的结构特性发生改变(即不满足大根堆or小根堆),那么就找到该结点的父节点,交换孩子结点和父亲结点。以此类推,顺着往上的来进行调整堆的结构。[看不懂的可以根据上图来理解]
void AdjustUp(HPDataType* arr, int child)
{
int parent = (child - 1) / 2; //先由孩子找父母
while (child > 0)//child只需要走到根节点的位置,因为根节点没有父节点不需要交换,也就不用在向上调整了
{
//这是建小堆
if (arr[child] < arr[parent])
{
Swap(&arr[parent], &arr[child]);
child = parent;//重复步骤
parent = (child - 1) / 2;
}
else
{
break;//直至满足堆的性质
}
}
}
void Swap(int* x, int* y)
{
int tmp = *x;
*x = *y;
*y = tmp;
}
刚刚在前言中,我们有提到是利用出堆的思想(出堆是按照顺序的)去进行最终的排序。因为我们建立的是小堆,所以出堆的时候,是降序。
那怎么出堆呢?
出堆就是将堆顶元素和末尾元素,进行交换。然后再删除末尾元素。
因为堆顶永远都是这个堆所包含的数据中最大or最小的,因此出堆才按照一定的顺序。
就拿我们建的这个小堆来说,堆顶是最小的元素2。将2出堆后,根据堆的结构特性,接下来的堆顶一定是次小的3,依次类推,最终出堆的顺序就是2,3,5,6,7。
但是请注意哈!我们这里只是借用出堆的思想去排序,而不是真正的出堆。一开始待排序列在数组中存储,那么最终排好的序列也应该在数组中存储,即7,6,5,3,2。
堆顶元素和末尾元素交换的本质就是,下标进行了变换。堆顶2和末尾6交换,堆顶的2这个时候就存储在数组的末尾位置arr[4]。
但是啊,我们下一次交换时,还是要用到末尾位置的,难道继续用arr[4]进行交换吗?
虽然说我们排序的时候只是借用出堆思想,而不真正出堆,可是如果又把末尾位置的2给调到堆顶arr[0]上,显然就是不合理的。
所以真正的做法就是,每次交换完,就将下标-1,假装之前的末尾元素已经没有了。那么下一次进行交换的时候末尾元素就会变成arr[3]
可以发现,每次交换完,也需要调整堆的结构。
上面建堆的时候,是用向上调整算法。这里调整堆,我们用向下调整算法。
2.2 向下调整算法
注意!!!向下调整算法有一个前提,左右子树必须是一个堆,才能进行调整
基本思路:给一个结点(父节点),找到该结点的孩子,比较两个结点的大小,判断时候需要交换,依次往下,直至符合堆结构特性。如果给的是堆顶元素,那么就从堆顶元素向下调整。
void AdjustDown(HPDataType* arr, int parent, int n)
{//向下调整算法,要传一个父节点,从这个父节点开始向下
int child = parent * 2 + 1;//找到左孩子
while (child < n)//理解这一步是很重要的,当孩子到叶子结点时就可以跳出循环了
{
//这是建小堆
//找左右孩子中最小的
if (child + 1 < n && arr[child] > arr[child + 1])
{
child++;
}
if (arr[child] < arr[parent])
{
Swap(&arr[child], &arr[parent]);
parent = child;//重复步骤
child = parent * 2 + 1;
}
else
{
break;
}
}
}
void Swap(int* x, int* y)
{
int tmp = *x;
*x = *y;
*y = tmp;
}
之前的第一步调整细节如下:
从堆顶arr[0]开始向下调整,可知它的孩子是arr[1]和arr[2]。
先比对arr[1]和arr[2]的大小,选出最小的(因为这里建的是小根堆)和arr[0]进行比较交换
交换完成后,发现堆已经满足特性,故不需要再向下调整。否则重复上述步骤,直至调整完成。
到此,堆排序的逻辑思路已讲清楚,接下来就是代码实现。
三、代码实现
堆排序代码:
//堆排序(利用堆的结构特点,对数据进行排序)
// 想要升序就建大堆,想要降序就建小堆
// 这里是降序
void HeapSort(int* arr, int n)//参数要传一个数组,还有数组里面的元素个数
{
//向上调整算法建堆
for (int i = 0; i < n; i++)
{
AdjustUp(arr, i);
}
//上面步骤结束之后初步建立了一个符合二叉树结构中的堆结构,但是不一定排好了顺序
//利用出堆的思想(因为出堆是按照一定顺序的)
//循环将堆顶元素跟最后位置的元素进行交换,最后一个元素会变化,下标每次都会减少1
int end = n - 1;//end为末尾元素
while (end > 0)
{
Swap(&arr[0], &arr[end]);//这里的交换是针对数组而言,调整顺序,真正进行排序
AdjustDown(arr, 0, end);//从根节点开始向下调整堆结构
end--;
}
}
测试堆排序的代码:
int main()
{
//测试:堆排序
int arr[] = { 17,20,10,13,19 };
HeapSort(arr, 5);
for (int i = 0; i < 5; i++) //打印排好的数组
{
printf("%d ", arr[i]);
}
printf("\n");
return 0;
}
四、改进
由上面可知,我们建堆用的是向上调整算法,后续调整堆用的是向下调整算法。
这里是我为了既能介绍到向上调整算法又能介绍到向下调整算法,而特意做的安排。
那么在实际实现当中,为了提高效率,我们可以都使用向下调整算法。
如果利用向下调整算法建堆,代码如下:
//向下调整算法建堆
for (int i = (n - 1 - 1) / 2; i > =0; --i)
{
AdjustDown(arr, i, n);//这里的i就是父节点
}
图解析:
结合向下算法建堆的代码和AdjustDown代码共同理解
那为什么向下调整算法能够提高效率呢?又为什么不采用向上调整算法呢?接下来,我们探究一下向下调整算法和向上调整算法的区别
五、分析时间复杂度
5.1 向上调整算法
向上调整,顾名思义是由下到上进行调整。
也就是每次我们插入一个新结点的时候,都要向上看看,是否符合的堆的结构,如果不符合,那就向上移动。
堆的第k层,最多有2^(k-1)个结点
分析向上调整算法的移动:越往下,结点越多,移动的层数也越多
第一层时 | 满的情况下,有1个根结点,没有更往上的一层,不需要进行调整 |
第二层时 | 满的情况下,有2个结点,最差往上调整1层 |
第三层时 | 满的情况下,有4个结点,最差往上调整2层 |
…… | …… (最差指的是最差情况下) |
第n层时 | 满的情况下,有2^(n-1)个结点,最差往上调整n-1层 |
则
每层移动步数 = 每层的结点数 * 每层往上移动的步数
将每层移动步数相加得到总移动步数,再进行数学代换,把得到的式子,简化一下,最终得到向上调整算法的时间复杂度: O(n*log2 n)
5.2 向下调整算法
向下调整,顾名思义有上到下进行调整。
①出堆里应用:通常是从堆顶向下调整,使其符合堆结构。
②建堆里应用:给一个父节点,找到其孩子结点,向下调整。
分析向下调整算法的移动:越往下,结点越多,但移动的层数越少
第一层时 | 满的情况下,有1个根结点,最差从根结点就得交换,往下n-1层 |
第二层时 | 满的情况下,有2个结点,最差往下调整n-2层 |
第三层时 | 满的情况下,有4个结点,最差往下调整n-3层 |
…… | …… |
第n层时 | 满的情况下,有2^(n-1)个结点,到达叶子结点,无需往下调整 |
则
每层移动步数 = 每层的结点数 * 每层往下移动的步数
将每层移动步数相加得到总移动步数,再进行数学代换,把得到的式子,简化一下,最终得到向下调整算法的时间复杂度: O(n)
由此,我们发现向下调整算法的时间复杂度是小于向上调整算法的。
故使用向下调整算法效率会更高。