手把手详解堆排序,堆就这么难懂?没有人看一遍学不会的,如果学不会,那就两遍吧

1. 先验知识

堆排序,是使用一种数据结构的排序算法。在了解堆排序前,建议先掌握二叉树相关知识,不然很费劲,很迷茫。

好了,话不多说。接下来先讲一讲 堆 这种数据结构的特点。主要有两点:①堆是一颗完全二叉树;②堆有大顶堆和小顶堆之分。

1.1 满二叉树

在讲完全二叉树之前,需要先了解一下 满二叉树,满二叉树就是一颗二叉树,每一层的节点都是“满”的,如下图。

1.2 完全二叉树

将树中的节点从上至下,从左至右顺序编号,如果和满二叉树相同编号节点的位置相同,那么就是完全二叉树,如下图。

1.3 大顶堆和小顶堆

大顶堆,很明显就是顶点数据比较大的堆;小顶堆则是顶点数据比较小的堆。话不多说,看图。

扫描二维码关注公众号,回复: 12647010 查看本文章

2. 堆排序

好了,第一节费了点时间,如果还不够详细的地方,可以评论或者自己网页搜索一下哈,再不进入主题,黄花菜都凉了。不知道大家根据前面讲解的大顶堆和小顶堆得到什么结论没有?如果没有结论,那我这里帮你总结一下,主要有两点:①大顶堆和小顶堆从上至下,从左至右不是完全有序的,如上面大顶堆扫描得到 {34, 18, 23, 12, 8};小顶堆得到 {3, 9, 7, 9, 12};②大顶堆的根节点是最大值,小顶堆的根节点是最小值。

根据大顶堆的根节点是最大值这个特性,我们可以通过得到大顶堆,取出根节点,然后循环调整大顶堆取出根节点即可得到有序数列。同理,小顶堆也可以。一般大顶堆适用于升序排序,小顶堆适用于降序排序,不是很好分析为什么,下面走一遍你就会体会到,如果没有体会,我们后面再分析;

2.1 堆排序原理

 以大顶堆对数组进行升序排序为例。

① 将待排序数组调整为大顶堆;

② 此时根节点是最大值,交换根节点(数组首元素)和末尾节点(数组待排序元素的最后一个元素,示例会详细说明);

③ 因为根节点和末尾节点交换了数据,需要重新对根节点开始递归调整,其它节点没有变化不需要调整;

④ 重复第②和③步骤,直到最后剩下一个待排序元素。

到这是不是有点理解堆排序的原理,什么?还是一脸懵!那就只能开始分析示例了,接下来我们实际走一遍堆排序的流程。

2.2 堆排序示例

假设初始无序数组arr = {3, 9, 13, 7, 1, 16, 3, 11},根据完全二叉树的构建规则,得到数组对应的完全二叉树形式如下。

2.2.1 将数组调整为大顶堆

① 得先将该数组调整为大顶堆形式对应的数组,怎么将完全二叉树调整为大顶堆呢?我们知道大顶堆的节点值要大于或等于子节点值,所以我们得从树的最底层开始,慢慢地把数值大的节点往上放,最终最大值放在根节点。

② 我们从哪个节点开始调整呢?对于每一个节点,它要和子节点比较,判断是否小于子节点,如果小于子节点则交换,否则不调整。所以我们从拥有子节点的节点开始,也就是非叶子节点

③ 通过完全二叉树的结构图,我们知道是从 第三层的7 开始调整。但如果是写代码,那我们怎么“确定”是 第三层的7 呢?

④ 得益于完全二叉树的结构,下标为index的节点,它的父节点下标是 (index - 1) / 2,左子节点下标是 2 * index + 1,右子节点下标是 2 * index + 2;完全二叉树的最后一个节点,它肯定没有子节点,那么我们可以有个策略:从完全二叉树的最后一个节点往前遍历,每次遍历一个节点,我们就寻找它的父节点,然后父节点和左右子节点进行比较、调整,直到根节点。这样最终我们就能得到一个大顶堆。

⑤ 第一次,我们从完全二叉树最后一个节点开始,它在数组的下标是 arr.length - 1 = 7,它的父节点在数组arr中的下标可推算得 (arr.length - 1 - 1) / 2 = 3,刚好是完全二叉树 第三层的7 的编号。

⑤ 完全二叉树 第三层的7 小于左子节点 第四层的11,也就是数组 arr[3] < arr[2 * 3 + 1];所以交换,得到 arr = {3, 9, 13, 11, 1, 16, 3, 7},对应的完全二叉树如下

⑥ 第二次,我们从完全二叉树倒数第二个节点分析,也就是数组索引往前,指向 arr.length - 2 = 6,它的父节点在数组下标可推算得 (arr.length - 2 -1) / 2 = 2,指向完全二叉树 第二层的13。

⑦ 由于完全二叉树 第二层的13 小于左子节点 第三层的16,也就是 arr[2] < arr[2 * 2 + 1] ,交换得 arr = {3, 9, 16, 11, 1, 13, 3, 7},对应的完全二叉树如下

⑧ 接下来同样处理即可,就不再具体描述,以下只做简单分析。

⑨ 第三次,完全二叉树倒数第三个节点 第三层的13 ,也就是数组索引为 arr.length - 3 = 5,该节点的父节点 第二层16 ,数组的下标是 (arr.length - 3 - 1) / 2 = 2。满足条件不需要调整。

⑩ 第四次,完全二叉树倒数第四个节点 第三层的1 ,也就是数组索引为 arr.length - 4 = 4,该节点的父节点是 第二层9 ,数组的下标是 (arr.length - 4 - 1) / 2 = 1

11. 由于 第二层的9 小于左子节点 第三层的11, 也就是 arr[1] < arr[1 * 2 + 1],进行交换;交换完之后,得到 arr = {3, 11, 16, 9, 1, 13, 3, 7},对应的完全二叉树如下。

12. 此时还应递归判断完全二叉树刚交换完得到的 第三层的9 是不是符合大顶堆要求,我们这个示例刚好符合,则结束递归,否则要递归判断并调整。

13. 第五次,完全二叉树倒数第五个节点 第三层的9 ,也就是数组索引为 arr.length - 5 = 3,该节点的父节点是 第二层11 ,数组的下标是 (arr.length - 5 - 1) / 2 = 1。满足条件不需要调整。

14. 第六次,完全二叉树倒数第六个节点 第二层的16 ,也就是数组索引为 arr.length - 6 = 2,该节点的父节点是 根节点3 ,数组的下标是 (arr.length - 6 - 1) / 2 = 0

15. 由于 根节点3 小于右子节点 第二层的16, 也就是 arr[0] < arr[0 * 2 + 2],进行交换;交换完之后,得到 arr = { 16, 11, 3, 9, 1, 13, 3, 7},对应的完全二叉树如下。

16. 此时还应递归判断完全二叉树刚交换完得到的 第二层的3 是不是符合大顶堆要求,我们发现节点 第二层的3 小于左子节点 第三层13,也就是 arr[0 * 2 + 2] < arr[(0 * 2 + 2) * 2 + 1],进行交换。

17. 交换后得到 arr = {16, 11, 13, 9, 1, 3, 3, 7},对应的完全二叉树如下。

18. 递归判断刚交换完的 第三层3 是不是符合大顶堆要求,我们发现它没有子节点,并且推算的子节点下标超出了数组的末尾索引,结束递归。

19. 因为我们刚刚调整的就是根节点,所以结束扫描。最终大顶堆结构图如上图所示,数组 arr = {16, 11, 13, 9, 1, 3, 3, 7}

至此,我们完成了一次数组调整为大顶堆的流程。

但是上面的分析是从最后一个节点慢慢往前扫描、调整,效率有点低。但事实上由于完全二叉树的特点,我们可以直接从最后一个节点(一定是叶子节点)的父节点(这个节点是完全二叉树的最后一个非叶子节点,它后面的节点都是叶子节点,不用调整)开始往前扫描、调整。可以仔细思考、理解一下,我就不详细解释了。

2.2.2 将大顶堆的根节点与末尾节点进行交换

在进行了2.2.1之后,我们得到了大顶堆,此时我们需要保存大顶堆根节点(最大值),保存之后,我们还需要对剩下的数据进行调整得到大顶堆,再保存大顶堆根节点,周而复始,直到最后只有一个元素,此时不需要再排序,结束。问题来了,每次保存大顶堆根节点很简单,我们可以新建一个数组,然后依次将数据保存在该数组即可。我们同时需要将该根节点从数组中“刨除”,然后对剩余数据继续调整为大顶堆。如我们上面得到的数组为{ 16, 11, 13, 9, 1, 3, 3, 7},如果把数组首元素“刨除”,也就是“刨除”大顶堆根节点。那么我们接下来需要对剩余元素{11, 13, 9, 1, 3, 3, 7}进行排序,我们需要重新构建完全二叉树,并且调整为大顶堆,然后保存大顶堆根节点,然后再将该元素“刨除”,周而复始对剩余数据构建、调整。

但是这样有点复杂,效率不够高,因为我们第一次得到的大顶堆是大致有序的,如果后续每次都要“刨除”根节点,重新构建、调整的话,效率大打折扣。所以这里有一个操作:直接将数组首元素和数组末尾元素进行交换。这样交换之后,我们只对数组下标为 0——arr.length-2 的元素进行调整,并且只需要对大顶堆的根节点递归调整即可,因为其它的节点经过第一次调整已经满足大顶堆的需求,这样既简单又高效。

2.3 堆排序的Java代码

分析了这么多,终于来到了码代码阶段,以下是Java的堆排序代码、相关代码解释以及排序效果,如下图。

猜你喜欢

转载自blog.csdn.net/yldmkx/article/details/109669261