前言
在实现堆排序之前,我们先来看看常见的数据结构,在网上我看到了一个特别全的版本:数组,栈,链表,队列,树,堆,图,散列表,本着鸡蛋里挑骨头的态度,我们来看看数组和链表,这两个到底算不算数据结构,貌似它们应该算是线性表这个结构,它们更应该被称作是一个实现结构的元素,比如通过数组和链表可以实现线性表、队列、栈,二叉树等等,可是看看数据结构的定义是计算机存储、组织数据的方式,貌似它们又算是数据结构,反正这个概念模模糊糊,不太清楚,要按我的理解常见结构应该只有线性表、栈、队列、树、图,其他的像堆其实是一种树,散列表很多的内部实现也是树。
好了,数据结构的事情也放一边,今天的目的是排序,主角是堆,那么究竟什么是堆呢?它的形状和山一样,只不过比山要“便宜”,比如土堆、煤堆、垃圾堆,这和金山、银山、绿水青山是没法比的,但是形状相似,只是小一点而已,上边小下边大,尖尖的,从侧面看就是第一个三角形。堆排序中的堆也是这种形状,上边窄下边宽呈现出一个三角形,其本质是一颗完全二叉树,一个n层的二叉树只有最后一层叶子节点可能不完整,但是都靠左排列,最多只有一个单叶子节点,如果说到这里你根本不知道什么是完全二叉树,甚至对树结构都是一头雾水,那么请去补补课,查询一下相关的定义就明白了。
一棵树要想被称为堆结构,光满足完全二叉树还不够,其中的元素也有要求,如果每个父节点都大于等于左右子节点被称为大根堆,如果每个父节点都小于等于左右子节点被称为小根堆,这样我们就知道在堆这个结构中,堆的顶端不是最大的值就是最小的值。
堆顶元素恰恰是堆排序的关键,试想一个有大根堆,我们把堆顶的数据拿下来放到一旁,把剩下的元素再调整成一个大根堆,然后再把堆顶数据拿下来放在刚才拿出那个元素的前面,再调整剩下的元素,反复这样操作,最后拿出来的这些元素就构成了一个有序序列,也就达到了排序的目的。
在进行升序排列时常使用大根堆,降序排列时常使用小根堆,这个知识点不是绝对的,也不需要记忆,只是这样操作更加方便,当你理解了堆排序的流程之后,很自然就能明白这样的用意,并且到那时候你完全可以反着操作来提升自己,不过效果和可读性可能会差一点。
堆排序
树结构可以用数组表示出来,特别是完全二叉树用数组表示起来更加方便,从上往下,从左往右依次把数据放入数组,我们就把一颗完全二叉树塞进了一维数组里,本来打算一个图都不画的,但是突然良心发现了,不能对你们太残忍,还是画一个吧,下面这个图展示了完全二叉树与数组的对应关系,这可是本文中唯一的一个图了,可要珍惜一下,把这个图弄明白,之后我们就只操作数组,不再画树了,因为我太懒了。
1 | graph TB |
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 |
先将数组代表的完全二叉树调整成一个大根堆
首先需要确认的是这个调整应该从下往上调整,先看最下边的子树,调整父节点和子节点的数据,使得父节点数据最大,然后再看前一个子树,继续把父子节点的数据调整好,这样一直调整到根节点时,整个完全二叉树的最大值就被调整到根节点了。这个过程有点像冒泡,从下往上,把最大的数据慢慢的冒到最上面。
反过来想想,如果是从上往下挨个子树来看,当从根节点调整到最后一个(最下最右)子树,并不能保证根节点数据最大,只是把较大数据向上整体移动了一次,所以还是要从下往上调整。
知道了这一点以后就要找到最后一个子树的位置,其实就是找到最后一个父节点,这个节点之前的数据都在非叶子节点上,这个节点之后的数据都在叶子节点上,只要调整这个最后父节点以及前面的所有节点就可以影响所有数据,关键是找到这个节点的位置。
要想找到最后一个父节点需要用到之前我们提到的公式,整个数组的元素个数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
11graph TB
12-->11;
12-->9;
11-->4;
11-->8;
9-->2;
9-->6;
4-->1;
4-->3;
8-->5;
8-->7;将大根堆根节点保存的最大值与当前大根堆最后一个节点进行交换,然后将这个节点“砍掉”
交换数据后,将剩余的这个完全二叉树继续调整成大根堆,既然已经打脸了,那就再画个图,这个砍树杈的动作已经和标题呼应了,每次生成大根堆后,将根节点和堆的最后一个结点交换,然后砍掉这个树杈,等整棵树被砍的只剩下根节点,排序也就完成了。
|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
11graph TB
7-->11;
7-->9;
11-->4;
11-->8;
9-->2;
9-->6;
4-->1;
4-->3;
8-->5;
8-->|X|12;重复上面的步骤,循环执行调整剩余元素为大根堆,首位交换,砍掉末尾元素这三步
这里需要注意的是除了第一次初始化成大根堆的过程比较麻烦,后面重复调整成大根堆的过程都很容易,因为只有这一个根节点不满足大根堆的定义,所以只从这个节点调整就可以,同时递归调整其不符合条件的子树即可。
每次交换之后都会“砍掉”树杈,所以大根堆每次都会减少元素,交换的索引也发生这变化,第一个是array[0]和array[n-1],然后是array[0]和array[n-2],最后一直交换到array[0]和array[1],也就完成了整体的排序。
代码实现
1 | /* |
代码分析
堆排序其实是一种选择排序,但是堆排序的代码比选择排序要复杂一下,其实理解了算法思路这些代码还是很容易看懂的,函数heap_sort
是堆排序的主体逻辑,第一个for
循环是从最后一个父节点开始调整,将父节点与较大的子节点交换,一直调整到根节点,初始化成一个大根堆。
第二个for
循环就是重复做交换首尾元素,然后调整剩余元素使其成为大根堆这两件事,重复n-1轮,排序过程也就完成了。
运行测试
在线编辑器是一个很方便的测试代码的环境,如果想本地调试一下,也可以直接下载堆排序–源码,在本地编译后进行调试,其实单步调试是理解算法思路很有效的方式。