0. 前言
大家好,我是多选参数的程序锅,一个正在捣鼓操作系统、学数据结构和算法以及 Java 的失业人员。最近忙着搞论文,还有刷刷 LeetCode 上的题,推文的事被耽误了一下,但是并没有忘记要发推文,虽迟但到吧。
整篇文章的主要知识提纲如图所示,本篇相关的代码都可以从 https://github.com/DawnGuoDev/algorithm 获取,另外,该仓库除了包含了基础的数据结构和算法实现之外,还会有数据结构和算法的笔记、LeetCode 刷题记录(多种解法、Java 实现) 、一些优质书籍整理。
堆是一种特殊的树。只要满足以下两个条件就是一个堆。
如下图所示,1、2 是大顶堆,3 是小顶堆,4 不是堆。同时,我们可以看出,对于同一组数据,可以构建出不同形态的堆。
满足堆的一个要求是”堆是一个完全二叉树“,而完全二叉树比较适合用数组存储,一是节省空间,二是通过数组的下标就可以找到父节点、左右子节点(数组下标最好从 1 开始)。本篇讲的例子,及其代码的实现都将从数组下标为 1 的地方开始。
下面是一个用数组存储堆的例子,假如从下标 1 开始存储堆的数据,那么下标为 i 的节点的左子节点在数组中的下标为 2*i
,右子节点在数组中的下标为 2*i+1
,父节点在数组中的下标为 i/2
。假设从下标 0 开始存储的话,下标为 i 的节点的左子节点的下标为 2*i+1
,右子节点的下标为 2*i+2
,父节点的下标为 (i-1)/2
。
堆最核心的操作是分别往堆中插入一个元素以及删除堆顶元素。为什么这边只提到删除堆顶元素?因为删除堆的其他元素是毫无意义,堆顶元素一般是最大或者最小值,所以删除堆顶元素才有意思。
另外,在往堆中插入一个元素或者删除堆顶元素之后,需要确保满足堆的两个特性。而将不符合两个特性的“堆”调整成符合两个特性堆的过程被称为堆化(heapify)。堆化有两种方式:从上往下堆化和从下往上堆化。两者的区别在于调整的顺序,从上往下堆化是指从堆顶元素开始向下调整使其最终符合两个特性,而从下往上则相反。在插入和删除的操作中会具体看到这两种方式。
★下面的阐述都将以大顶堆为例,小顶堆同理。 ”
插入元素时,将新插入的元素放到堆的最后比较方便。此时,采用从下往上的堆化方法比较合适,整个从下往上的堆化过程就是向上不断跟父节点对比,然后交换。如图所示,我们往一棵现成的堆中插入了一个元素 22。那么此时的“堆”不符合堆的两个特性了。那么新插入的节点先与父节点进行比较,不满足父子节点的应有的大小(大顶堆中,父节点的值应该大于子节点)则互换两个节点。互换之后的父节点和它的父节点不满足大小关系的话就继续交换。重复这个过程,直至调整过程中的父子节点都满足大小关系为止。
我们将这个从下到上的堆化过程,实现为如下代码段所示
public void insert(int data) {
if (this.count >= this.capacity) {
return;
}
++this.count;
this.heap[this.count] = data;
heapifyFromBottom();
}
public void heapifyFromBottom() {
int i = this.count;
while (i/2 > 0 && this.heap[i] > this.heap[i/2]) {
swap(i, i/2);
i = i/2;
}
}
从堆的第二点特性“堆中的每个节点的值都必须大于等于(或小于等于)其子节点的值”可以推出堆中最大(或最小)的值存储在堆顶元素中(大顶堆堆顶则是最大值)。因此删除堆顶元素是有意义的,而删除堆中的其他元素是没有意义的。
那么删除堆顶元素之后,整个堆就不符合堆的两个条件了。此时,正确的方式是把最后一个节点放到堆顶处,然后从上而下依次比较父子节点的大小,如果不满足大小关系则进行交换,直至父子节点之间满足大小关系为止。这种方法就是从上往下的堆化方法。
如图所示,假如将堆顶的元素 33 删除之后,那么将最后一个节点的元素 12 放到堆顶处。然后 12 与 27 进行比较,因为不满足大顶堆的要求,12 与 27 进行交换。以此类推,最终调整后的堆满足了所要求的两个特性。
我们将上述从上而下的堆化过程实现为如下图所示
public void delete () {
if (this.count <= 0) {
return;
}
this.heap[1] = this.heap[this.count--];
heapifyFromTop(1);
}
public void heapifyFromTop(int begin) {
while (true) {
int i = begin; // i 是节点及其左右子节点中较大值的那个节点的下标
/* 就是在节点及其左右子节点中选择一个最大的值,与节点所处的位置进行;
但是,需要注意的是假如这个值正好是节点本身,那么直接退出循环;
否则需要进行交换,然后从交换之后的节点开始继续堆化 */
if (begin * 2 <= this.count && this.heap[begin] < this.heap[2 * begin]) {
i = 2 * begin;
}
if ((2 * begin + 1) <= this.count && this.heap[i] < this.heap[2 * begin + 1]) {
i = 2 * begin + 1;
}
if (i == begin) {
break;
}
swap(begin, i);
begin = i;
}
}
堆化的过程是顺着节点的路径进行比较交换的,那么比较交换这一组操作的次数是跟树的层次有关的。对于一棵含 n 个节点的完全二叉树来说,树的层次不会超过 。因此,整个堆化过程的时间复杂度是 O(logn)。那么,插入一个元素和删除堆顶元素的时间复杂度都是 O(logn)。
借助堆这种数据结构实现的排序被称为堆排序。堆排序是一种原地的、时间复杂度为 O(nlogn) 且不稳定的排序算法。整个堆排序分为建堆和排序两个步骤。
首先是将待排序数组建立成一个堆,秉着能不借助额外数组则不借助的原则,我们可以直接在原数组上直接操作。这样,建堆有两个方法:
我们将第二种思路实现成如下代码段所示:
public void buildHeap(int[] datas, int len) {
this.heap = datas;
this.capacity = len - 1;
this.count = len - 1;
for (int i = this.count/2; i >=1; i--) {
heapifyFromTop(i);
}
}
public void heapifyFromTop(int begin) {
while (true) {
int i = begin; // i 是节点及其左右子节点中较大值的那个节点的下标
/* 就是在节点及其左右子节点中选择一个最大的值,与节点所处的位置进行;
但是,需要注意的是假如这个值正好是节点本身,那么直接退出循环;
否则需要进行交换,然后从交换之后的节点开始继续堆化 */
if (begin * 2 <= this.count && this.heap[begin] < this.heap[2 * begin]) {
i = 2 * begin;
}
if ((2 * begin + 1) <= this.count && this.heap[i] < this.heap[2 * begin + 1]) {
i = 2 * begin + 1;
}
if (i == begin) {
break;
}
swap(begin, i);
begin = i;
}
}
★为什么下标从 n/2 到 1 的数组元素所在节点都是非叶子节点,下标从 n/2+1 到 n 的数组元素所在节点都是叶子节点?这个算是完全二叉树的一个特性。严格的证明暂时没有,不严谨的证明还是有的。这里采用反证法,假如 n/2 + 1 不是叶子节点,那么它的左子节点下标应该为 n+2,但是整个完全二叉树最大的节点的下标为 n。所以 n/2 + 1 不是叶子节点不成立,即 n/2 + 1 是叶子节点。那么同理可证 n/2 + 1 到 n 也是如此。而对于下标为 n/2 的节点来说,它的左子节点有的话下标应该为 n,n 在数组中有元素,因此 n/2 有左子节点,即 n/2 不是叶子节点。同理可证 1 到 n/2 都不是叶子节点。 ”
建完堆(大顶堆)之后,接下去的步骤是排序。那么具体该怎么实现排序呢?
此时,我们可以发现,堆顶的元素是最大的,即数组中的第一个元素是最大的。实现排序的过程大致如下:我们把它跟最后一个元素交换,那最大元素就放到了下标为 n 的位置。此时堆顶元素不是最大,因此需要进行堆化。采用从上而下的堆化方法(参考删除堆顶元素的堆化方法),将剩下的 n-1 个数据构建成堆。最后一个数据因为已知是最大了,所以不用参与堆化了。n-1 个数据构建成堆之后,再将堆顶的元素(下标为 1 的元素)和下标为 n-1 的元素进行交换。一直重复这个过程,直至堆中只剩一个元素,此时排序工作完成。如图所示,这是整个过程的示意图。
下面将这个过程使用 Java 实现,如下图所示
public void heapSort() {
while (this.count > 1) {
swap(this.count, 1);
this.count--;
heapifyFromTop(1);
}
}
堆排序的时间复杂度是由建堆和排序两个步骤的时间复杂度叠加而成。
在采用第二方式建堆时,从粗略的角度来看,每个节点进行堆化的时间复杂度是 O(logn),那么 n/2 个节点堆化的总时间复杂度为 O(nlogn)。但是这此时粗略的计算,更加精确的计算结果不是 O(nlogn),而是 O(n)。
因为叶子节点不需要进行堆化,所以需要堆化的节点从倒数第二层开始。每个节点需要堆化的过程中,需要比较和交换的次数,跟这个节点的高度 k 成正比。那么所有节点的高度之和,就是所有节点堆化的时间复杂度。假设堆顶节点对应的高度为 h ,那么整个节点对应的高度如图所示(以满二叉树为例,最后一层叶子节点不考虑)。
那么将每个非叶子节点的高度求和为
求解这个公式可将两边同时乘以 2 得到 S2,
然后再减去 S1,从而就得到 S1
由于
所以最终时间复杂度为 O(2n-logn),也就是 O(n)。
排序过程中,我们需要进行 (n-1) 次堆化,每次堆化的时间复杂度是 O(logn),那么排序阶段的时间复杂度为 O(nlogn)。
那么,整个总的时间复杂度为 O(nlogn)。
★不对建堆过程的时间复杂度进行精确计算,也就是建堆以 O(nlogn) 的时间复杂度算的话,那么总的时间复杂度还是 O(nlogn)。 ”
堆排序不是稳定的排序算法。因为在排序阶段,存在将堆的最后一个节点跟堆顶点进行互换的操作,所以有可能会改变相同数据的原始相对顺序。比如下面这样一组待排序 20、16、13、13
,在排序时,第二个 13 会跟 20 交换,从而更换了两个 13 的相对顺序。
堆排序是原地排序算法,因为堆排序的过程中的两个步骤中都只需要极个别临时存储空间。
在实际开发中,为什么快速排序要比堆排序性能要好?
上面介绍了堆的理论和堆排序,但是堆这种数据结构除了用在堆排序之外,还有其他几个非常重要的应用:优先级队列、求 Top K 问题以及求中位数问题。
优先级队列跟普通队列所有不同。普通队列最大的特性是先进先出,而优先级队列则按照优先级来,优先级高的先出队。我们可以发现,优先级队列和堆有共通之处。堆顶元素都是最大或者最小的,那么取堆顶元素就类似于优先级队列的出队操作。因此,优先级队列最直接、最高效的实现方式是采用堆来实现。
很多语言也都有提供优先级队列的实现,比如 Java 的 PriorityQueue,C++ 的 priority_queue 等。当然,优先级队列也有很多应用场景,这边举两个简单的例子来看一下优先级队列是怎么用的。
假设我们需要将 100 个存储着有序的单词的文件合并成一个有序的大文件。按照之前讲过的方法,我们可以使用数组的方式,也就是从这个 100 个文件中,各取第一个单词。之后,根据字典序进行比较,将字典序最小的那个单词放入合并后的大文件,并从数组中删除。然后,从删去单词的那个小文件中再取一个单词,将其放入数组。之后重复上述操作,直至 100 个文件的单词都被合并到最终的有序的大文件中。使用数组这种方式,每次取字典序最小的那个单词时都需要遍历整个数组。显然,数组的方式很不高效。
那么我们可以使用优先级队列,因为我们每次需要的都是当前字典序最小的那个单词,这跟优先级队列的特性很相似。因此,我们可以维护一个容量为 100 的小顶堆,每次取小顶堆的堆顶单词,然后将其放入合入的大文件中,之后再从小文件中取新的单词放入堆中,并进行堆化。重复这个过程,最终将 100 个文件的单词数据全都合并到大文件中。那么采用堆的方式之后,每次取字典序最小单词的时间主要就是堆化的时间,而堆化的时间复杂度是 O(logn),这边 n 是 100。显然这个时间比使用数组的方式高效多了。
假设有一个定时器,定时器维护了很多任务。每个任务都设定了一个要触发执行的时间点。普通的方法可能就是采用轮询的方式。每隔一段时间轮询一次(比如 1 秒),查看是否有任务到达设定的执行时间。如果到达了,就拿出来执行。然而,这种做法比较低效。一是可能有些任务执行还要很久的时间,那么前面很多次的扫描将是徒劳的;二是当任务列表很大的时候,每次这么扫描是很费时的。
那么我们照样可以使用优先级队列。我们按照任务设定的执行时间构建小顶堆,堆顶元素是任务设定的执行时间距离现在最近的任务,也就是应该最先被执行的任务。当我们拿到堆顶任务的执行时间点之后,与现在的时间进行相减,从而得到一个时间间隔 T1 。接下去,我们就可以不用每隔 1 秒进行扫描,而是在 T1 秒之后,直接取堆顶的任务来执行。并且,对剩下的任务进行堆化,再计算新的堆顶任务距离现在的时间间隔 T2,将其作为执行下一个任务需要等待的时间,以此类推。采用这种方式后,性能相比每隔 1 秒的轮询方式提高了许多。
求 Top K 的问题可以分为两类,一类是针对静态数据集合,也就相当于这个数据集合事先确定了,不会再变了。另一类是针对动态数据集合,也就是说数据事先并不完全确定,会有数据不断的加入到集合中。下面针对这两类分别进行阐述,求 Top K 大的问题为例。
★快速排序也能求 Top K 的问题,但是快速排序更加适合于静态数据,如果数据集合一直在变动,那堆的方式会更适合一点。 另外在 Top K 的问题上,快排和堆这两种方式,堆会更胜一筹。 ”
中位数是按顺序排列的一组数据中居于中间位置的数。如果数据的个数是奇数,那么中位数的位置为 n/2+1(数据是从 1 开始编号,n 是最后一个数据的编号)。如果数据的个数是偶数,那么中位数的位置为 n/2 或者 n/2 +1(同上),一般情况下是取这两个数的平均值作为中位数,当然也可以取 n/2 位置的数据作为中位数。下面我们选择这两个数的平均值作为中位数为例。
那么当插入新的数据时,如果这个数据比大顶堆的数据小,那么则将其插入大顶堆。反之,则将其插入小顶堆。同时,插入新的数据之后需要确保两个堆的数据数量比例按照约定来,即插入之后如果数据数量为偶数,那么两个堆的数量需要相等;如果数据数量为奇数,那么大顶堆要比小顶堆多一个数据。假如插入数据之后,两个堆的数据数量不符合约定了,那么则需要移动数据使其满足约定,移动的数据一般都是堆顶元素。 使用这种方法,插入数据的时候会涉及到堆化,时间复杂度为 O(logn),但是在求中位数的时候,只需要 O(1)。因此,个人觉得跟上述求 Top K 的类似。如果查询中位数很频繁,那么动态方式的方式很 nice 了;假如查询中位数不频繁,那么静态的方式可能会更好。
另外,动态数据集合使用两个堆来求中位数的方法也可以很好地用于求其他百分位的数据。求中位数其实就相当于求 50% 处的数据。比如,当求 90% 处的数据时。我们可以 90% 的数据保存在大顶堆中,10 % 的数据保存在小顶堆中。同样的,插入数据之后也要维护两个堆的比例,并且插入的时间复杂度为 O(logn),而查询的时间复杂度为 O(1)。
★这边一定需要两个堆吗?我觉得是需要的,比如在查找 90% 的数据时,虽然我们都只需要取大顶堆的堆顶元素。但是,假如我们只维护一个大顶堆。某次插入数据时,这个数据没被插入到大顶堆中,结果导致大顶堆和剩下的元素不符合比例了(比如大顶堆比应有的少了一个数据,而小顶堆比应有的多了一个数据)。那么我们需要从剩下的数据中找出在剩下的数据中最小的那个数据插入大顶堆。此时,假如剩下的数据直接使用小顶堆的方式来表示,那么只需要取堆顶元素即可,很方便。这个使用堆的方式和数组的方式,在合并有序小文件的时候已经做过比较了。所以需要两个堆! ”
扫码关注腾讯云开发者
领取腾讯云代金券
Copyright © 2013 - 2025 Tencent Cloud. All Rights Reserved. 腾讯云 版权所有
深圳市腾讯计算机系统有限公司 ICP备案/许可证号:粤B2-20090059 深公网安备号 44030502008569
腾讯云计算(北京)有限责任公司 京ICP证150476号 | 京ICP备11018762号 | 京公网安备号11010802020287
Copyright © 2013 - 2025 Tencent Cloud.
All Rights Reserved. 腾讯云 版权所有