逐步砍掉树杈的堆排序

前言

在实现堆排序之前,我们先来看看常见的数据结构,在网上我看到了一个特别全的版本:数组,栈,链表,队列,树,堆,图,散列表,本着鸡蛋里挑骨头的态度,我们来看看数组和链表,这两个到底算不算数据结构,貌似它们应该算是线性表这个结构,它们更应该被称作是一个实现结构的元素,比如通过数组和链表可以实现线性表、队列、栈,二叉树等等,可是看看数据结构的定义是计算机存储、组织数据的方式,貌似它们又算是数据结构,反正这个概念模模糊糊,不太清楚,要按我的理解常见结构应该只有线性表、栈、队列、树、图,其他的像堆其实是一种树,散列表很多的内部实现也是树。

好了,数据结构的事情也放一边,今天的目的是排序,主角是堆,那么究竟什么是堆呢?它的形状和山一样,只不过比山要“便宜”,比如土堆、煤堆、垃圾堆,这和金山、银山、绿水青山是没法比的,但是形状相似,只是小一点而已,上边小下边大,尖尖的,从侧面看就是第一个三角形。堆排序中的堆也是这种形状,上边窄下边宽呈现出一个三角形,其本质是一颗完全二叉树,一个n层的二叉树只有最后一层叶子节点可能不完整,但是都靠左排列,最多只有一个单叶子节点,如果说到这里你根本不知道什么是完全二叉树,甚至对树结构都是一头雾水,那么请去补补课,查询一下相关的定义就明白了。

一棵树要想被称为堆结构,光满足完全二叉树还不够,其中的元素也有要求,如果每个父节点都大于等于左右子节点被称为大根堆,如果每个父节点都小于等于左右子节点被称为小根堆,这样我们就知道在堆这个结构中,堆的顶端不是最大的值就是最小的值。

堆顶元素恰恰是堆排序的关键,试想一个有大根堆,我们把堆顶的数据拿下来放到一旁,把剩下的元素再调整成一个大根堆,然后再把堆顶数据拿下来放在刚才拿出那个元素的前面,再调整剩下的元素,反复这样操作,最后拿出来的这些元素就构成了一个有序序列,也就达到了排序的目的。

在进行升序排列时常使用大根堆,降序排列时常使用小根堆,这个知识点不是绝对的,也不需要记忆,只是这样操作更加方便,当你理解了堆排序的流程之后,很自然就能明白这样的用意,并且到那时候你完全可以反着操作来提升自己,不过效果和可读性可能会差一点。

堆排序

树结构可以用数组表示出来,特别是完全二叉树用数组表示起来更加方便,从上往下,从左往右依次把数据放入数组,我们就把一颗完全二叉树塞进了一维数组里,本来打算一个图都不画的,但是突然良心发现了,不能对你们太残忍,还是画一个吧,下面这个图展示了完全二叉树与数组的对应关系,这可是本文中唯一的一个图了,可要珍惜一下,把这个图弄明白,之后我们就只操作数组,不再画树了,因为我太懒了。

1
2
3
4
5
6
7
8
9
10
11
graph TB
2-->3;
2-->9;
3-->4;
3-->7;
9-->12;
9-->6;
4-->1;
4-->11;
7-->5;
7-->8;
idx_0 idx_1 idx_2 idx_3 idx_4 idx_5 idx_6 idx_7 idx_8 idx_9 idx_10
2 3 9 4 7 12 6 1 11 5 8

这个用数组表示的完全二叉树有一个性质,这个性质是我编的,你之前可能没有听过,那就是从后面删除n个节点之后,剩下的元素还是一颗完全二叉树,这一点隐含在堆排序的整个过程中,并且二叉树的父节点都排列在数组前面,叶子节点都排在数组后边,父节点和子节点对应的索引满足一定的关系:

  • 假设父节点索引是i,左节点索引=2*i+1
  • 假设父节点索引是i,右节点索引=2*i+2
  • 假设子节点索引是i,父节点的索引=(int)((i-1)/2)

明白了上面的关系,先简单描述一下堆排序的整个过程,操作的数据源就是这个数组,长度n=11,先将整个数组表示的完全二叉树调整成一个大根堆,这时树的根节点也就是数组的第一个元素就是最大值,把它和数组的最后一个元素交换,之后不再管最后这个数据,相当于把这个树杈砍掉了,根据上段提到的性质,砍掉最后一个叶子节点的二叉树仍然是一颗完全二叉树,调整数据使得前n-1个节点再次成为一个大根堆,继续把根节点索引为0的元素,也就是这个次最大值,与倒数第二个元素交换,之后也放弃倒数第二个元素了,相当于再次砍掉了这棵二叉树的一个树杈,如此反复操作,当“砍”到根节点时,整个数组也就从小到大排好序了。

排序过程

通过上面描述可能还是不太明白堆排序的过程,接下来可以通过一个例子来实际操作一次,看看数组是怎样在不断调整大根堆的过程中慢慢变成有序的,数组的初始状态是:

idx_0 idx_1 idx_2 idx_3 idx_4 idx_5 idx_6 idx_7 idx_8 idx_9 idx_10
2 3 9 4 7 12 6 1 11 5 8
  1. 先将数组代表的完全二叉树调整成一个大根堆

    首先需要确认的是这个调整应该从下往上调整,先看最下边的子树,调整父节点和子节点的数据,使得父节点数据最大,然后再看前一个子树,继续把父子节点的数据调整好,这样一直调整到根节点时,整个完全二叉树的最大值就被调整到根节点了。这个过程有点像冒泡,从下往上,把最大的数据慢慢的冒到最上面。

    反过来想想,如果是从上往下挨个子树来看,当从根节点调整到最后一个(最下最右)子树,并不能保证根节点数据最大,只是把较大数据向上整体移动了一次,所以还是要从下往上调整。

    知道了这一点以后就要找到最后一个子树的位置,其实就是找到最后一个父节点,这个节点之前的数据都在非叶子节点上,这个节点之后的数据都在叶子节点上,只要调整这个最后父节点以及前面的所有节点就可以影响所有数据,关键是找到这个节点的位置。

    要想找到最后一个父节点需要用到之前我们提到的公式,整个数组的元素个数n=11,最后一个元素的索引为n-1,那么其父节点就是最后一个子树的父节点,其索引应该为(int)((n-1-1)/2),也就是n/2-1,这就是最后一个子树父节点的在数组中的索引,其数值为4,接着从这个节点开始从后往前调整子树:

    • 子树父节点,索引i=4时,调整父节点和右孩子的值(从左右孩子中找一个较大的值,并且要大于父节点)

    |idx_0|idx_1|idx_2|idx_3|idx_4|idx_5|idx_6|idx_7|idx_8|idx_9|idx_10|
    |:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|
    |2|3|9|4|7(parent)|12|6|1|11|5(left)|8(right)|

    |idx_0|idx_1|idx_2|idx_3|idx_4|idx_5|idx_6|idx_7|idx_8|idx_9|idx_10|
    |:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|
    |2|3|9|4|8(parent)|12|6|1|11|5(left)|7(right)|

    • 子树父节点,索引i=3时,调整父节点和右孩子的值

    |idx_0|idx_1|idx_2|idx_3|idx_4|idx_5|idx_6|idx_7|idx_8|idx_9|idx_10|
    |:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|
    |2|3|9|4(parent)|8|12|6|1(left)|11(right)|5|7|

    |idx_0|idx_1|idx_2|idx_3|idx_4|idx_5|idx_6|idx_7|idx_8|idx_9|idx_10|
    |:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|
    |2|3|9|11(parent)|8|12|6|1(left)|4(right)|5|7|

    • 子树父节点,索引i=2时,调整父节点和左孩子的值

    |idx_0|idx_1|idx_2|idx_3|idx_4|idx_5|idx_6|idx_7|idx_8|idx_9|idx_10|
    |:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|
    |2|3|9(parent)|11|8|12(left)|6(right)|1|4|5|7|

    |idx_0|idx_1|idx_2|idx_3|idx_4|idx_5|idx_6|idx_7|idx_8|idx_9|idx_10|
    |:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|
    |2|3|12(parent)|11|8|9(left)|6(right)|1|4|5|7|

    • 子树父节点,索引i=1时,调整父节点和左孩子的值,这时左孩子同时也是下面子树的父节点,所以还要调整一下该节点作为父节点的子树

    |idx_0|idx_1|idx_2|idx_3|idx_4|idx_5|idx_6|idx_7|idx_8|idx_9|idx_10|
    |:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|
    |2|3(parent)|12|11(left)|8(right)|9|6|1|4|5|7|

    |idx_0|idx_1|idx_2|idx_3|idx_4|idx_5|idx_6|idx_7|idx_8|idx_9|idx_10|
    |:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|
    |2|11(parent)|12|3(left)|8(right)|9|6|1|4|5|7|

    • 左子树节点作为父节点进行调整

    |idx_0|idx_1|idx_2|idx_3|idx_4|idx_5|idx_6|idx_7|idx_8|idx_9|idx_10|
    |:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|
    |2|11|12|3(tmp parent)|8|9|6|1(tmp left)|4(tmp right)|5|7|

    |idx_0|idx_1|idx_2|idx_3|idx_4|idx_5|idx_6|idx_7|idx_8|idx_9|idx_10|
    |:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|
    |2|11|12|4(tmp parent)|8|9|6|1(tmp left)|3(tmp right)|5|7|

    • 子树父节点,索引i=0时,调整父节点和右孩子的值,这时右孩子同时也是下面子树的父节点,同样还要调整一下该节点作为父节点的子树

    |idx_0|idx_1|idx_2|idx_3|idx_4|idx_5|idx_6|idx_7|idx_8|idx_9|idx_10|
    |:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|
    |2(parent)|11(left)|12(right)|4|8|9|6|1|3|5|7|

    |idx_0|idx_1|idx_2|idx_3|idx_4|idx_5|idx_6|idx_7|idx_8|idx_9|idx_10|
    |:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|
    |12(parent)|11(left)|2(right)|4|8|9|6|1|3|5|7|

    • 右子树节点作为父节点进行调整

    |idx_0|idx_1|idx_2|idx_3|idx_4|idx_5|idx_6|idx_7|idx_8|idx_9|idx_10|
    |:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|
    |12|11|2(tmp parent)|4|8|9(tmp left)|6(tmp right)|1|3|5|7|

    |idx_0|idx_1|idx_2|idx_3|idx_4|idx_5|idx_6|idx_7|idx_8|idx_9|idx_10|
    |:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|
    |12|11|9(tmp parent)|4|8|2(tmp left)|6(tmp right)|1|3|5|7|

    到此为止整个大根堆就调整完成了,为了看的更加清楚。我不得不打脸再画一个图了,有时候还是看图更加方便,一图胜千言啊:

    |idx_0|idx_1|idx_2|idx_3|idx_4|idx_5|idx_6|idx_7|idx_8|idx_9|idx_10|
    |:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|
    |12|11|9|4|8|2|6|1|3|5|7|

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    graph TB
    12-->11;
    12-->9;
    11-->4;
    11-->8;
    9-->2;
    9-->6;
    4-->1;
    4-->3;
    8-->5;
    8-->7;
  2. 将大根堆根节点保存的最大值与当前大根堆最后一个节点进行交换,然后将这个节点“砍掉”

    交换数据后,将剩余的这个完全二叉树继续调整成大根堆,既然已经打脸了,那就再画个图,这个砍树杈的动作已经和标题呼应了,每次生成大根堆后,将根节点和堆的最后一个结点交换,然后砍掉这个树杈,等整棵树被砍的只剩下根节点,排序也就完成了。

    |idx_0|idx_1|idx_2|idx_3|idx_4|idx_5|idx_6|idx_7|idx_8|idx_9|idx_10|
    |:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|
    |12(head)|11|9|4|8|2|6|1|3|5|7(tail)|

    |idx_0|idx_1|idx_2|idx_3|idx_4|idx_5|idx_6|idx_7|idx_8|idx_9|idx_10|
    |:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|
    |7(head)|11|9|4|8|2|6|1|3|5|12(tail)|

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    graph TB
    7-->11;
    7-->9;
    11-->4;
    11-->8;
    9-->2;
    9-->6;
    4-->1;
    4-->3;
    8-->5;
    8-->|X|12;
  3. 重复上面的步骤,循环执行调整剩余元素为大根堆,首位交换,砍掉末尾元素这三步

    这里需要注意的是除了第一次初始化成大根堆的过程比较麻烦,后面重复调整成大根堆的过程都很容易,因为只有这一个根节点不满足大根堆的定义,所以只从这个节点调整就可以,同时递归调整其不符合条件的子树即可。

    每次交换之后都会“砍掉”树杈,所以大根堆每次都会减少元素,交换的索引也发生这变化,第一个是array[0]和array[n-1],然后是array[0]和array[n-2],最后一直交换到array[0]和array[1],也就完成了整体的排序。

代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
/*
功能: 交换两个变量
参数: element1--被交换的第一个元素的地址
element2--被交换的第二个元素的地址
返回值:无
注意: 只用来表示思路,不考虑指针为空等特殊情况
*/
void swap_data(int* element1, int* element2)
{
int middle_value = *element1;
*element1 = *element2;
*element2 = middle_value;
}

/*
功能: 从start到end构成最大堆,前提是start之后的部分已满足最大堆,
也就是说start存在左右子树的情况下,子树已经是最大堆
参数: array--表示待排序的数组,此处会退化成指针
start--需要调整的父节点索引
end --最后一个可以被调整的节点索引,当形成最大堆后,第一个节点与当前最后节点交换后,那么这个当前最后节点下一轮就不能被调整了
返回值:无
注意: 只用来表示思路,不考虑指针为空等特殊情况
*/
void max_heapify(int array[], int start, int end)
{
int parent_index = start;
int child_index = start * 2 + 1;
while (child_index <= end)
{
if (child_index + 1 <= end && array[child_index] < array[child_index + 1])
++child_index; // 如果右边的孩子更大,选择右边的

if (array[parent_index] > array[child_index])
break;

swap_data(&array[parent_index], &array[child_index]);
parent_index = child_index;
child_index = parent_index * 2 + 1;
}
}

/*
功能: 堆排序,实现数组元素从小到大排列
参数: array--表示待排序的数组,此处会退化成指针
count--数组元素的个数
返回值:无
注意: 只用来表示思路,不考虑指针为空等特殊情况
*/
void heap_sort(int array[], int count)
{
for (int pos = count / 2 - 1; pos >= 0; --pos)
max_heapify(array, pos, count - 1);

for (int target_pos = count - 1; target_pos > 0; --target_pos)
{
swap_data(&array[0], &array[target_pos]);
max_heapify(array, 0, target_pos - 1);
}
}

代码分析

堆排序其实是一种选择排序,但是堆排序的代码比选择排序要复杂一下,其实理解了算法思路这些代码还是很容易看懂的,函数heap_sort是堆排序的主体逻辑,第一个for循环是从最后一个父节点开始调整,将父节点与较大的子节点交换,一直调整到根节点,初始化成一个大根堆。

第二个for循环就是重复做交换首尾元素,然后调整剩余元素使其成为大根堆这两件事,重复n-1轮,排序过程也就完成了。

运行测试

在线编辑器是一个很方便的测试代码的环境,如果想本地调试一下,也可以直接下载堆排序–源码,在本地编译后进行调试,其实单步调试是理解算法思路很有效的方式。

Albert Shi wechat
欢迎您扫一扫上面的微信公众号,订阅我的博客