堆和堆排序 - 彻底解析+C语言实现+opencv绘图助解

1. 概述

本文的最原始出发点在于分享一种彻底搞明白堆排序的方法。
百度一下堆排序,会有很多讲解内容。它们中绝大多数分为下面几类:

  • 一来就开始讲堆的属性,读者不知道为什么。
  • 一来就给出堆的数据结构代码,直接从代码开始讲堆排序,读者一片茫然。
  • 直接告诉你堆排序的流程,比如先交换位置,然后执行上浮、下沉等操作,很难理解。

综上,我会在本文中

  • 从堆的前世今生讲起,彻底解析堆和堆排序的原理和实现细节;
  • 我会用C语言实现堆和堆排序;(获取代码/代码目录说明)
  • 我会借助opencv来绘制堆的图,包括绘制一些关键的中间临时状态的堆图、高亮关键节点展示算法过程等,以进一步深刻理解堆和堆排序的实现原理。

注:如果你只想关注堆排序,你可以直接去看堆排序章节,里面会给你导航你至少需要了解的堆相关知识的章节。

2. 算法

2.1 完全二叉树(Complete Binary Btree)

在开始介绍堆之前,需要先了解完全二叉树。因为堆就是完全二叉树的特例,堆具备完全二叉树的所有特性

完全二叉树(Complete Binary Btree,CBT)是一种特殊的二叉树,它满足以下条件:

1)首先它是一棵二叉树,树中每个节点的分叉最多只有2个。
2)所有的层都是完全填满的,除了最后一层可能出现未填满的情况。
3)最后一层的所有节点都尽可能地向左靠拢。

如下图所示的一棵完全二叉树:
在这里插入图片描述

图1:完全二叉树

上图是完全二叉树的判断:
1)满足二叉树特性:所有有分叉的节点(有孩子的节点),分叉最多2个。
2)除了最后一层(节点【7】、【4】和【8】所在层),其他层都是满二叉树,即分叉都是2。
3)最后一层的节点都是向左侧靠拢的。

  • 注:节点【8】如果是节点【1】的右孩子,这棵树就不是完全二叉树了。

完全二叉树的重要特性:

特性1:按照层级排列,每一层的节点从左到右填满,直到最后一层。
特性2:由于完全二叉树的规则性(特性1),使得它可以有效利用空间,用数组就能表示完全二叉树,不需要为每个节点增加额外的关系指针或索引。
特性3:完全二叉树可以高效地用数组来表示(特性2),数组中的节点关系有:

  • (1) 根节点在数组的第一个位置(假设数组索引从0开始,那么根节点就在0号位置注1
  • (2) 若一个节点的数组索引为i,那么它的(假设数组索引从0开始注1):
    • 左孩子的索引为:2 * i + 1
    • 右孩子的索引为:2 * i + 2
  • (3) 若一个节点(除了根节点)的数组索引为i,那么它的父节点索引为:(i - 1) / 2
    • 根节点没有父节点。

例如,图1可以用数组[3, 2, 6, 5, 1, 0, 9, 7, 4, 8]表示。

  • 根节点:索引为0的数组元素3
  • 2的数组索引为1,它的
    • 左孩子的索引为2 * i + 1 = 3,即数组元素5
    • 右孩子的索引为2 * i + 2 = 4,即数组元素1
  • 7的数组索引为7,那么它的父节点的索引为(i - 1) / 2 = 3,即数组元素5
    4的数组索引为8,那么它的父节点的索引为(i - 1) / 2 = 3,即数组元素5

通过这个示例可以看到完全二叉树的表示非常简单,一维数组就能完成表示。不像其他树结构(比如B-tree),每个节点还会存储孩子节点的指针或者父节点的指针。

注1
对于完全二叉树,有一个优化编码实践供大家参考:
对于节点个数为n的完全二叉树,一般来讲,分配的数组大小就是n,0号位置为根节点。
但本编码实践采取的方案为:分配n+1的数组,0号索引位置不用,让索引从1开始。这样的话

  • (1) 根节点的索引为1
  • (2) 若一个节点的数组索引为i,那么它的
    • 左孩子的索引为:2 * i
    • 右孩子的索引为:2 * i + 1
  • (3) 若一个节点(除了根节点)的数组索引为i,那么它的父节点索引为:i / 2

注意看这里的公式,将之与原来的公式作比较,可以明显看到

  • 计算左孩子的索引时少了1次加法。
  • 计算父节点的索引时少了1次减法。

即,这个方案用多一个数组空间的代价来换取cpu指令的减少。这对于CPU密集型的场景效果是可观的。

2.2 堆

堆是一棵完全二叉树,它具有(继承)完全二叉树的所有特性。最重要的两点是:

  1. 用一维数组就能表示堆,0号元素就是根节点。
  2. 父节点和孩子节点索引之间的关系公式,对于堆也是可用的。

有2种堆:

  • 最大堆:对于堆中的每个节点X,X的值大于或等于其孩子节点的值。这意味着根节点是所有元素中最大的。
    • 一句话,父节点的值大于或等于孩子节点的值
    • 根节点是所有节点中值最大的
  • 最小堆:对于堆中的每个节点X,X的值大于或等于其孩子节点的值。这意味着根节点是所有元素中最大的。
    • 一句话,父节点的值小于或等于孩子节点的值
    • 根节点是所有节点中值最小的

:堆在完全二叉树的基础上,只增加了一条

  • 最大堆:父节点大于或等于孩子节点(最大堆)
  • 最小堆:父节小于或等于孩子节点(最小堆)

除此,就没有其他约束了。比如没有约束左孩子必须小于右孩子,也没有约束左子树中任意节点必须小于右子树任意节点等。

下面介绍堆的四种操作:插入、删除、堆化以及遍历。

2.2.1 插入

往堆中插入一个元素,包含2个步骤:

  1. 将元素插入数组作为最后一个元素

  2. 从数组的最后一个元素开始,执行上浮调整上浮的目的是将新插入的元素放入合适的位置,使得

    • 最大堆的所有父节点大于或等于孩子节点。
    • 最小堆的所有父节点小于或等于孩子节点。


    * 上浮的核心逻辑

    • 最大堆的上浮:当前元素与父节点比较,如果大于父节点,那么当前元素与父节点元素互换位置。然后继续从父节点开始执行上浮。
    • 最小堆的上浮:当前元素与父节点比较,如果小于父节点,那么当前元素与父节点元素互换位置。然后继续从父节点开始执行上浮。


    最大堆的上浮流程如下图所示 :
    在这里插入图片描述

图2:最大堆的上浮流程


       最小堆的上浮流程图:只需要将上图中红色区域中的“大于”二字改为“小于”即可。


堆的插入操作演示,下图是一个最大堆的实例:
在这里插入图片描述

堆和数组之间的关系:从根节点开始,逐层将元素放入数组。
上图所示的堆对应的数组为:[6, 5, 3, 2, 1, 0]

现在执行往堆栈插入9的操作:

  1. 第一步:将元素插入数组作为最后一个元素。即数组变为[6, 5, 3, 2, 1, 0, 9],此刻的临时状态如下图所示:
    在这里插入图片描述
  2. 第二步: 从数组的最后一个元素开始,执行上浮调整。
    即,从【9】开始执行上浮调整:因为是最大堆,所以与父节点比较时,如果大于父节点,则与父节点交互位置,直到父节点大于自己或者到达根节点为止。详细过程:
    • 1)【9】大于父节点【3】,与之交互位置。
      在这里插入图片描述
    • 2)【9】大于父节点【6】,与之交互位置。此时到达根节点,上浮结束,插入元素9也到此结束。
      在这里插入图片描述

2.2.2 删除

堆的删除操作是指删除最值(对于最大堆来说,最值是最大值;对于最小堆来说,最值是最小值)。最值在数组索引为0的地方,所以删除最值也就是删除数组第一个元素。

从堆中删除最值,包含2个步骤:

  1. 将最后一个元素(也就是数组最后一个元素)替换当前的最值(数组第一个元素),数组大小减一。

  2. 从数组第一个元素开始,执行下沉调整下沉的目的是将上一步移动来的数组第一个元素放到合适它的位置。这个元素的下沉必将导致新的最值上浮到根节点。根本也是为了维持堆的特性(最大堆的所有父节点大于或等于孩子节点,最小堆的所有父节点小于或等于孩子节点)。

    * 下沉的核心逻辑:

    • 最大堆的下沉:
      • 如果当前元素小于左孩子和右孩子中较的那个,交换自己和这个较孩子的位置(当前元素下沉到孩子节点)。然后继续对这个孩子节点执行下沉。
      • 如果当前元素于或等于左和右孩子,或者当前元素没有孩子,那么下沉结束。
    • 最小堆的下沉:
      • 如果当前元素大于左孩子和右孩子中较的那个,交换自己和这个较孩子的位置(当前元素下沉到孩子节点)。然后继续对这个孩子节点执行下沉。
      • 如果当前元素于或等于左和右孩子,或者当前元素没有孩子,那么下沉结束。

    最大堆的下沉流程如下图所示 :
    在这里插入图片描述

图3:最大堆的下沉流程


       最小堆的下沉流程图,只需要将上图中红色区域中的“小于”二字改为“大于”即可。


堆的删除操作演示,下图是一个最大堆的实例:
在这里插入图片描述

上图对应的数组为[9, 7, 6, 5, 1, 0, 3, 2, 4]

现在执行删除堆的最值(9)的操作:

  1. 第一步:将最后一个元素(也是数组中最后一个元素)替换当前的最值(数组第一个元素),数组大小减一。
    即:4替换9,然后数组大小减少1个。数组变更为[4, 7, 6, 5, 1, 0, 3, 2]。对应的堆变为:
    在这里插入图片描述
  2. 从数组第一个元素开始,执行下沉调整。
    即,从【4】开始执行下沉调整:因为是最大堆,所以先与左孩子比较,小于左孩子则与之交互位置;否则与右孩子比较,小于右孩子则与之交互位置。直到没有孩子节点或者孩子都小于自己为止。详细过程:
    • 1)【4】小于左孩子【7】,与之交互位置。
      在这里插入图片描述
    • 2)继续:【4】小于左孩子【5】,与之交互位置。此时只有一个孩子【2】满足小于【4】,则下沉终止。删除元素9结束。
      在这里插入图片描述

2.2.3 堆化

堆化操作:对一个任意大小一维数组进行调整,使其成为一个堆(最大堆或者最小堆)。

堆化操作的流程是从最后一个父节点开始,逆向逐个对节点执行下沉操作

假设数组大小为n那么最后一个元素索引为n-1。最后一个父节点,就是数组最后这个元素的父节点。
即,最后一个父节点的索引为:((n - 1) - 1) / 2 = (n - 2) / 2

还是写出详细推导流程:
前面已经讲过:若一个节点的数组索引为i,那么它的父节点索引为:(i - 1) / 2
现在求最后一个节点(数组索引为n-1)的父节点,带入公式,其索引为:(n-1 - 1) / 2 = (n - 2) / 2

所以,堆化操作就是从索引 (n - 2) / 2开始,逆向对每个节点执行下沉操作

堆化的操作演示:
示例数组[3, 2, 6, 5, 1, 0, 9, 7, 4, 8],数组大小为10,其完全二叉树的形态如下图所示
在这里插入图片描述
最后一个父节点索引为(n - 2) / 2 = (10 - 2) / 2 = 4,即元素【1】。
现在开始执行堆化:即从最后一个父节点(索引为4)开始,分别对索引为4, 3, 2, 1, 0的节点(实际上这些节点都是父节点,它们都有孩子)执行下沉操作。

  • 1)对索引为4的元素【1】执行下沉:
    在这里插入图片描述

  • 2)对索引为3的元素【5】执行下沉:
    在这里插入图片描述

  • 3)对索引为2的元素【6】执行下沉:
    在这里插入图片描述

  • 4)对索引为1的元素【2】执行下沉:
    在这里插入图片描述
    上图中,【2】是与左孩子【7】和右孩子【8】中较大的【8】交换的位置。交换后,【2】大于唯一的孩子【1】,所以【2】的下沉终止。

  • 5)对索引为0的元素【3】执行下沉:
    在这里插入图片描述
    上图中,【3】是与左孩子【8】和右孩子【9】中较大的【9】交换的位置。交换后,【3】小于自己的右孩子【6】,所以继续下沉,如下图:
    在这里插入图片描述
    此时【3】没有孩子了,下沉结束。
    至此堆化操作完成。
    原数组[3, 2, 6, 5, 1, 0, 9, 7, 4, 8],经过堆化之后,变为[9, 8, 6, 7, 2, 0, 3, 5, 4, 1]

2.2.4 遍历

对堆的遍历,实际就是完全二叉树的遍历,包括4种:前序遍历、中序遍历、后序遍历以及层序遍历

  • 前序遍历(Pre-order Traversal)

    • 访问根节点。
    • 递归地对根节点的左子树进行前序遍历。
    • 递归地对根节点的右子树进行前序遍历。
  • 中序遍历(In-order Traversal)

    • 递归地对根节点的左子树进行中序遍历。
    • 访问根节点。
    • 递归地对根节点的右子树进行中序遍历。
  • 后续遍历(Post-order Traversal)

    • 递归地对根节点的左子树进行后序遍历。
    • 递归地对根节点的右子树进行后序遍历。
    • 访问根节点。
  • 层序遍历(Level-order Traversal)

    • 从根节点开始,一层一层地从左到右访问每个节点。


    由于可以根据完全二叉树的二叉分叉的特性,计算出每层的节点个数:

    • 第n层的节点个数是2^n-1^个,n从1开始。
      例如,第1层(n=1)的节点个数为21-1=1个(根节点所在层,只有1个根节点)
                 第2层(n=2)的节点个数为22-1=2个
                 …

    所以完全二叉树的层序遍历是很简单的:

    • 顺序遍历数组
    • 统计每一层的个数来判断每个元素是属于哪一层的。

2.3 堆排序

堆排序,就是使用堆数据结构来完成对一组数据的排序的方法。

  • 从小到大排,使用的是最大堆
  • 从大到小排,使用的是最小堆

堆排序只用到了堆的2个操作:堆化删除。所以,如果你想要就彻底拿下堆排序,你至少需要:

堆排序的原理(以最大堆实现从小到大顺序排序为例):

  • 1)构造一个跟堆大小相同的结果数组
  • 2)将堆的根节点(所有元素中最大的元素)从堆中删除,放入结果数组最后的位置
  • 3)删除根节点后的堆重新堆化成一个新的堆,它的根节点是现在新堆中的最大值。
  • 4)将根节点从堆中删除,放入结果数组的倒数第个位置。
  • 5)重复上面的操作,每次填入结果数组的索引减一。直到把堆删空为止。
  • 6)实际上,1)是不需要的,直接复用堆的数组空间就能完成堆排序工作。

so,堆排序流程:

1)将要排序的数组堆化(对数组进行调整,使其成为堆)
2)删除最值,将最值放在当前堆的数组的最后一个位置上。一直重复这个过程,直到堆被删空为止。

  • 每删除一个最值的时候,堆的数组都会减一。所以每次删除最值放入堆的数组最后一个位置,这里的数组最后一个位置每次都是不相同的,实际就是上一次最后一个元素的索引减一。
  • 前面已经描述过堆的删除操作了,删除最值后会重新调整剩余元素,使它们仍是一个堆。


堆排序的操作演示:
以对数组[3, 2, 6, 5, 1, 0, 9, 7, 4]进行从小到大的堆排序的例子来演示。
这个数组的完全二叉树状态如下图:
在这里插入图片描述
开始进行堆排序:
1)堆化数组,因为是从小到大的顺序,所以是堆化最大堆。堆化后如下图所示:
在这里插入图片描述
上图中,上半部分是此时的数组:[9, 7, 6, 5, 1, 0, 3, 2, 4];下半部分是堆的完全二叉树图。对于堆化的流程细节,参见前面的堆化的操作演示

2)删除最值,将最值放在当前堆的数组的最后一个位置上。一直重复这个过程,直到堆被删空为止。
下面详细展示每一个删除最值排序的细节流程:

  • (1) 删除最值9,放入当前数组的最后一个位置。另外堆的删除操作会将删除9后的剩余元素重新调整为堆(调整细节参见堆的删除操作)。如下图所示:
    在这里插入图片描述
    上图中,上半部分为删除9以后的数组情况。删除的9放在数组的最后一位。此时9已经不属于当前的数组,红色高亮表示这个区分。
  • (2) 删除最值7,放入数组(删除7之前的堆数组)的最后一个位置。如图:
    在这里插入图片描述
  • (3) 删除最值6,放入数组的最后一个位置。如图:
    在这里插入图片描述
  • (4) 删除最值5,放入数组的最后一个位置。如图:
    在这里插入图片描述
  • (5) 删除最值4,放入数组的最后一个位置。如图:
    在这里插入图片描述
  • (6) 删除最值3,放入数组的最后一个位置。如图:
    在这里插入图片描述
  • (7) 删除最值2,放入数组的最后一个位置。如图:
    在这里插入图片描述
  • (9) 删除最值1,放入数组的最后一个位置。如图:
    在这里插入图片描述
  • (10) 删除最值0,放入数组的最后一个位置。如图:
    在这里插入图片描述
    此时,堆被删空了,堆排序也就结束了。
    此时原始数组中的元素就是堆排序后的结果(上图红色高亮部分)。

3. 代码

我这里就不对代码进行逐行讲解了,代码中关键注释都是有的,请结合上面的讲解去理解代码。
完整的代码,点击链接获取。该代码仓库的一些说明:

  • 堆的代码在heap目录,其中
    • heap/heap.hheap/heap.c通用版heap的实现(支持任意类型的数组元素)。
    • heap/draw_heap目录是opencv绘制heap的代码。
    • heap/int_heap目录是非通用版、元素类型为int的堆的实现。大家可以先看int_heap,然后再对比int_heap和通用版heap的代码,以此帮助理解通用版heap
    • heap/heap_sort是堆排序的代码。
  • 测试堆的代码文件为test/test_heap.htest/test_heap.cpp
  • 代码库中有完整的opencv2的头文件和动态链接库,无需安装opencv就可以编译并运行测试代码。
  • 编译的方法参见README中编译章节描述
  • 运行测试的方法参见README中运行测试 -> 6. 堆(heap)的描述。


代码中关键函数对照表如下,你可以对照本文相关关键点的描述去看代码,结合代码中的注释来进一步理解算法。:

  • 非通用版、元素类型为int的堆(intHeap)
    • 创建/销毁:intHeap_create/intHeap_release
    • 插入intHeap_insert
    • 删除intHeap_delete_most
    • 堆化intHeap_heapify
    • 遍历:略了,参见通用版堆的遍历heap_traverse
  • 通用版堆(heap)
  • 绘制通用版heap:draw_heap
  • 堆排序
    • 对int类型数组堆排序
      heap_sort_int
    • 对char类型数组堆排序
      heap_sort_char
    • 对long类型数组堆排序
      heap_sort_long
    • 堆任意类型数组堆排序(当元素类型不是基础类型时,元素类型需要是指针)
      heap_sort_common(前面的几个堆排序都是对该函数的二次封装)
    • int类型数组的直接堆排序(未使用struct heap的代码实现,原理同上面的所有堆排序函数)
      heap_sort_direct

4. 写在最后

  • 欢迎指正文中错误
  • 对代码中有疑问的地方,随时来这里咨询讨论。
  • 堆排序真正搞明白后,是非常简单的。
    本文作为初学入门、笔记记录是不错的选择。
评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值