堆、堆排序 - 彻底解析+C语言实现+opencv绘图助解
1. 概述
本文的最原始出发点在于分享一种彻底搞明白堆排序的方法。
百度一下堆排序,会有很多讲解内容。它们中绝大多数分为下面几类:
- 一来就开始讲堆的属性,读者不知道为什么。
- 一来就给出堆的数据结构代码,直接从代码开始讲堆排序,读者一片茫然。
- 直接告诉你堆排序的流程,比如先交换位置,然后执行上浮、下沉等操作,很难理解。
综上,我会在本文中
- 从堆的前世今生讲起,彻底解析堆和堆排序的原理和实现细节;
- 我会用C语言实现堆和堆排序;(获取代码/代码目录说明)
- 我会借助opencv来绘制堆的图,包括绘制一些关键的中间临时状态的堆图、高亮关键节点展示算法过程等,以进一步深刻理解堆和堆排序的实现原理。
注:如果你只想关注堆排序,你可以直接去看堆排序章节,里面会给你导航你至少需要了解的堆相关知识的章节。
2. 算法
2.1 完全二叉树(Complete Binary Btree)
在开始介绍堆之前,需要先了解完全二叉树。因为堆就是完全二叉树的特例,堆具备完全二叉树的所有特性
。
完全二叉树(Complete Binary Btree,CBT)是一种特殊的二叉树,它满足以下条件:
1)首先它是一棵二叉树,树中每个节点的分叉最多只有2个。
2)所有的层都是完全填满的,除了最后一层可能出现未填满的情况。
3)最后一层的所有节点都尽可能地向左靠拢。
如下图所示的一棵完全二叉树:
上图是完全二叉树的判断:
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 堆
堆是一棵完全二叉树,它具有(继承)完全二叉树的所有特性
。最重要的两点是:
- 用一维数组就能表示堆,0号元素就是根节点。
- 父节点和孩子节点索引之间的关系公式,对于堆也是可用的。
有2种堆:
最大堆
:对于堆中的每个节点X,X的值大于或等于其孩子节点的值。这意味着根节点是所有元素中最大的。- 一句话,父节点的值大于或等于孩子节点的值
- 根节点是所有节点中值最大的
最小堆
:对于堆中的每个节点X,X的值大于或等于其孩子节点的值。这意味着根节点是所有元素中最大的。- 一句话,父节点的值小于或等于孩子节点的值
- 根节点是所有节点中值最小的
注
:堆在完全二叉树的基础上,只增加了一条
- 最大堆:
父节点大于或等于孩子节点(最大堆)
;- 最小堆:
父节小于或等于孩子节点(最小堆)
除此,就没有其他约束了
。比如没有约束左孩子必须小于右孩子,也没有约束左子树中任意节点必须小于右子树任意节点等。
下面介绍堆的四种操作:插入、删除、堆化以及遍历。
2.2.1 插入
往堆中插入一个元素,包含2个步骤:
-
将元素插入数组作为最后一个元素
-
从数组的最后一个元素开始,执行
上浮调整
。上浮
的目的是将新插入的元素放入合适的位置,使得- 最大堆的所有父节点大于或等于孩子节点。
- 最小堆的所有父节点小于或等于孩子节点。
* 上浮的核心逻辑
:- 最大堆的上浮:当前元素与父节点比较,如果
大于
父节点,那么当前元素与父节点元素互换位置。然后继续从父节点开始执行上浮。 - 最小堆的上浮:当前元素与父节点比较,如果
小于
父节点,那么当前元素与父节点元素互换位置。然后继续从父节点开始执行上浮。
最大堆的上浮流程如下图所示 :
最小堆的上浮流程图:只需要将上图中红色区域中的“大于”二字改为“小于”即可。
堆的插入操作演示,下图是一个最大堆
的实例:
堆和数组之间的关系:从根节点开始,逐层将元素放入数组。
上图所示的堆对应的数组为:[6, 5, 3, 2, 1, 0]
现在执行往堆栈插入9的操作:
- 第一步:将元素插入数组作为最后一个元素。即数组变为[6, 5, 3, 2, 1, 0, 9],此刻的临时状态如下图所示:
- 第二步: 从数组的最后一个元素开始,执行上浮调整。
即,从【9】开始执行上浮调整:因为是最大堆,所以与父节点比较时,如果大于
父节点,则与父节点交互位置,直到父节点大于自己或者到达根节点为止。详细过程:- 1)【9】大于父节点【3】,与之交互位置。
- 2)【9】大于父节点【6】,与之交互位置。此时到达根节点,上浮结束,插入元素9也到此结束。
- 1)【9】大于父节点【3】,与之交互位置。
2.2.2 删除
堆的删除操作是指删除最值
(对于最大堆来说,最值是最大值;对于最小堆来说,最值是最小值)。最值在数组索引为0的地方,所以删除最值也就是删除数组第一个元素。
从堆中删除最值,包含2个步骤:
-
将最后一个元素(也就是数组最后一个元素)替换当前的最值(数组第一个元素),数组大小减一。
-
从数组第一个元素开始,执行
下沉调整
。下沉
的目的是将上一步移动来的数组第一个元素放到合适它的位置。这个元素的下沉必将导致新的最值上浮到根节点。根本也是为了维持堆的特性(最大堆的所有父节点大于或等于孩子节点,最小堆的所有父节点小于或等于孩子节点)。* 下沉的核心逻辑:
- 最大堆的下沉:
- 如果当前元素
小于
左孩子和右孩子中较大
的那个,交换自己和这个较大
孩子的位置(当前元素下沉到孩子节点)。然后继续对这个孩子节点执行下沉。 - 如果当前元素
大
于或等于左和右孩子,或者当前元素没有孩子,那么下沉结束。
- 如果当前元素
- 最小堆的下沉:
- 如果当前元素
大于
左孩子和右孩子中较小
的那个,交换自己和这个较小
孩子的位置(当前元素下沉到孩子节点)。然后继续对这个孩子节点执行下沉。 - 如果当前元素
小
于或等于左和右孩子,或者当前元素没有孩子,那么下沉结束。
- 如果当前元素
最大堆的下沉流程如下图所示 :
- 最大堆的下沉:
最小堆的下沉流程图,只需要将上图中红色区域中的“小于”二字改为“大于”即可。
堆的删除操作演示,下图是一个最大堆
的实例:
上图对应的数组为[9, 7, 6, 5, 1, 0, 3, 2, 4]
现在执行删除堆的最值(9)的操作:
- 第一步:将最后一个元素(也是数组中最后一个元素)替换当前的最值(数组第一个元素),数组大小减一。
即:4替换9,然后数组大小减少1个。数组变更为[4, 7, 6, 5, 1, 0, 3, 2]。对应的堆变为:
- 从数组第一个元素开始,执行下沉调整。
即,从【4】开始执行下沉调整:因为是最大堆,所以先与左孩子比较,小于左孩子则与之交互位置;否则与右孩子比较,小于右孩子则与之交互位置。直到没有孩子节点或者孩子都小于自己为止。详细过程:- 1)【4】小于左孩子【7】,与之交互位置。
- 2)继续:【4】小于左孩子【5】,与之交互位置。此时只有一个孩子【2】满足小于【4】,则下沉终止。删除元素9结束。
- 1)【4】小于左孩子【7】,与之交互位置。
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.h
和heap/heap.c
是通用版heap
的实现(支持任意类型的数组元素)。heap/draw_heap
目录是opencv绘制heap的代码。heap/int_heap
目录是非通用版、元素类型为int的堆
的实现。大家可以先看int_heap,然后再对比int_heap和通用版heap
的代码,以此帮助理解通用版heap
。heap/heap_sort
是堆排序的代码。
- 测试堆的代码文件为
test/test_heap.h
和test/test_heap.cpp
。 - 代码库中有完整的opencv2的头文件和动态链接库,无需安装opencv就可以编译并运行测试代码。
- 编译的方法参见README中
编译
章节描述 - 运行测试的方法参见README中
运行测试
->6. 堆(heap)
的描述。
代码中关键函数对照表如下,你可以对照本文相关关键点的描述去看代码,结合代码中的注释来进一步理解算法。:
- 非通用版、元素类型为int的堆(intHeap)
- 通用版堆(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
- 对int类型数组堆排序
4. 写在最后
- 欢迎指正文中错误
- 对代码中有疑问的地方,随时来这里咨询讨论。
- 堆排序真正搞明白后,是非常简单的。
本文作为初学入门、笔记记录是不错的选择。