博主秋招提前批已拿百度、字节跳动、拼多多、顺丰等公司的offer,可加微信:pcwl_Java 一起交流秋招面试经验,可获得博主的秋招简历和复习笔记。
上一篇:红黑树:https://blog.csdn.net/pcwl1206/article/details/84227825
目 录:
一、堆的基本概念
堆是一种特殊的树,只要满足下列两点要求,就符合堆的定义:
1、堆是一棵完全二叉树;
2、堆中的一个节点的值都必须大于等于(或小于等于)其子树中每个节点的值。
回顾:完全二叉树:除了最后一层,其他层的节点个数都是满的,最后一层的节点都靠左排列。
对于每个节点的值都大于等于子树中每个节点值得堆,我们称为“大顶堆”。对于每个节点得值都小于等于子树中每个节点值的堆,我们叫做“小顶堆”。
![](https://i-blog.csdnimg.cn/blog_migrate/10228a200f517e72d0f3bee6ab10f134.png)
如上图所示:第1个和第2个是大顶堆,第3个是小顶堆,第4个不是堆。所以,基于以上的四种表示形式,我们可以得出这样一个结论:对于同一组数据,我们可以构建多种不同形态的堆。
二、堆的实现
要实现一个堆,最重要的是要知道,堆都支持哪些操作以及如何存储堆。
由于堆是一棵完全二叉树,所以用数组来存储比较节省存储空间,不需要存储额外的左右子结点的指针,单纯地通过下标,就可以找到一个节点的左右子节点和父节点。
数组中下标为i的节点的左右子结点的下标分别为:i * 2 和(i * 2)+ 1,其父节点的下标为 i / 2。
1 往堆中插入一个元素
往堆中插入一个元素后,需要继续满足上面提到的堆的两个条件。
每次把要插入的元素放到堆的最后,然后再通过“堆化”(heapify)调整使其满足堆的两个条件。
堆化分为两种:从上往下堆化和从下往上堆化。堆化其实就是顺着节点所在的路径,向上或者向下进行对比然后交换。如下图所示的大顶堆,我们只需要让新插入的节点与父节点对比大小。如果不满足子节点小于等于父节点的大小关系,我们就互换两个节点。一直重复这个过程,直到父子节点之间满足父节点大于子节点为止。
下面展示的是从下往上堆化的过程:
往堆中插入一个元素的代码实现:
public class Heap {
private int[] arr; // 定义一个数组,下标从1开始存储数据;
private int n; // 堆可以存储的最大数据个数
private int count; // 堆中已经存储的数据个数
public Heap(int capacity){
arr = new int[capacity + 1];
n = capacity; // 下标0不存储数据
count = 0;
}
// 插入数据
public void insert(int data){
if(count >= n) return; // 堆满了
++count;
arr[count] = data; // 将数据插入数组中
int i = count;
while(i / 2 > 0 && arr[i] > arr[i/2]){
// 交换下标为i和i/2的两个元素
int temp = arr[i/2];
arr[i/2] = arr[i];
arr[i] = temp;
i = i / 2; // 从下往上堆化
}
}
}
2、删除堆顶元素
由堆的定义不难发现,堆顶元素要么是最大值,要么是最小值。
这里用大顶堆进行说明,故堆顶元素就是最大值。所以,当我们删除堆顶元素之后,就需要把第二大的元素放入堆顶,第二大元素肯定是在堆顶元素的左右节点位置。然后再迭代地删除第二大节点,以此类推,直到叶子节点被删除。这个过程是从下往上的堆化过程,会出现一个问题:可能会出现最后堆化完的结果不满足完全二叉树的情况,比如下图中的值为6的元素如果一开始在最后一层的最左边,那么堆化结束后,就会出现最后一层存在右子节点的情况,不满足完全二叉树的定义。
为了解决这个问题,我们可以使用从上往下的堆化方法,把最后一个节点放到堆顶,然后利用再进行父子节点的对比。对于不满足父子节点关系的,互换两个节点,并且重复进行这个过程,直到父子节点之间满足大小关系为止。
因为移除的是最后一个元素,从上往下的堆化过程中,都是交换操作,不会出现数组中的“空洞”,得到的堆化结果肯定满足完全二叉树的要求。
删除堆顶元素的代码实现:
// 删除堆顶元素
public void removeMax(){
if(count == 0) return; // 堆里面没有数据
arr[1] = arr[count]; // 将最后一个元素放在堆顶位置
--count;
heapify(arr, count, 1);
}
// 从上往下的堆化方法
public void heapify(int[] arr, int n, int i){
while(true){
int maxPos = i;
if(i * 2 <= n && arr[i] < arr[i * 2]){
maxPos = i * 2; // 如果左子节点大于父节点,则交换位置
}
// 如果前面左子节点换到父节点的位置后,它还有可能小于现在的右子节点,所以需要进行判断
if(i * 2 + 1 <= n && arr[maxPos] < arr[i * 2 + 1]){
maxPos = i * 2 + 1; // 如果右子节点大于“现在”的父节点
}
if(maxPos == 1){
break; // 最后一个元素了
}
// 交换下标为i和maxPos位置上的元素
int temp = arr[i];
arr[i] = arr[maxPos];
arr[maxPos] = temp;
i = maxPos; // 从上往下堆化
}
}
3、时间复杂度分析
一个含有n个节点的完全二叉树,树的高度不会超过。堆化的过程是顺着节点所在路径比较交换的,所以堆化的时间复杂度跟树的高度成正比,也就是O(logn)。插入数据和删除堆顶元素的主要逻辑就是堆化,所以,往堆中插入一个元素和删除堆顶元素的时间复杂度都是O(logn)。
三、堆排序
我们把借助于堆这种数据结构实现的排序算法就叫做堆排序。这种排序方法的时间复杂度非常稳定,是O(nlogn),并且是原地排序算法。堆排序分为两个主要的步骤:建堆和排序。
1、建堆
建堆的过程就是不停的调整数组中元素在堆中的位置,直到满足堆定义中的两个条件为止。
建堆的过程有两种方法:
方法一:借助前面讲的在堆中插入一个元素。尽管数组中有n个元素,我们假设最开始堆中只有1个下标为1的元素,我们使用插入操作,将下标为2到n的数据依次插入到堆中,这样就可以将n个元素的数组构建成n个元素的堆了。插入操作是从下往上堆化的过程。
方法二:从后往前处理数组中的元素,每个数组都是从上往下堆化。如下图所示,这里需要说明的是叶子节点往下堆化只能是自己跟自己比较,是没有意义的。所以这里直接从第一个非叶子节点开始依次往前堆化。
这里给出从上往下的建堆代码实现:
// 从上往下的建堆方式
private void buildHeap(int[] arr, int n){
// 非叶子结点的起始下标为n/2
for(int i = n/2; i >= 1; --i){
heapify(arr, n, i);
}
}
// 从上往下的堆化方法
public void heapify(int[] arr, int n, int i){
while(true){
int maxPos = i;
if(i * 2 <= n && arr[i] < arr[i * 2]){
maxPos = i * 2; // 如果左子节点大于父节点,则交换位置
}
// 如果前面左子节点换到父节点的位置后,它还有可能小于现在的右子节点,所以需要进行判断
if(i * 2 + 1 <= n && arr[maxPos] < arr[i * 2 + 1]){
maxPos = i * 2 + 1; // 如果右子节点大于“现在”的父节点
}
if(maxPos == 1){
break; // 最后一个元素了
}
// 交换下标为i和maxPos位置上的元素
int temp = arr[i];
arr[i] = arr[maxPos];
arr[maxPos] = temp;
i = maxPos; // 从上往下堆化
}
}
说明:上述堆化的过程中起始下标为n/2,下标是n/2 + 1到n的节点都是叶子节点,不需要进行堆化。实际上,对于完全二叉树来说,下标从n/2 + 1到n的节点都是叶子节点。
反证法证明上诉结论:
如果n/2 + 1不是叶子节点的话,那么它的左子节点为:2(n/2 + 1) = n + 2,很明显超出了数组的大小,更不用说右子节点了。因此,下标大于n/2 + 1的节点肯定都是叶子节点了。因此,可得出结论: 对于完全二叉树来说,下标从n/2 + 1到n的节点都是叶子节点。
其实,也可以这样想,完全二叉树中最后一个下标为n的节点的父节点的下标为n/2,因为最后一排的叶子节点都是靠左排列的。
建堆操作的时间复杂度分析:
每个节点堆化的时间复杂度是O(logn),那么n/2 + 1个节点堆化的总时间复杂度就是O(nlogn)。但是这个结果不够准确,下面进行推导。
因为叶子节点不需要堆化,所以需要堆化的节点从第二层开始。每个节点堆化的过程中,需要比较和交换的节点个数和当前这个节点的高度K成正比,如下图所示。
将每个非叶子节点的高度求和,得出以下公式:
把S1左右都乘以2,得到S2。然后S = S2 - S1:
S的中间部分是一个等比数列,利用等比数列求和:
因为h = ,代入公式S,可以得到S = O(n)。所以,建堆的时间复杂度就是O(n)。
2、排序
建堆完成之后,数组中的数据已经是按照大顶堆的特性来组织的。数组中的第一个元素是堆顶,也就是最大的元素。把它和最后一个元素交换,那最大元素就放到了下标为n的位置。
上述过程类似于“删除堆顶元素”的过程,当堆顶元素移除之后,我们把下标为n的元素放到堆顶,然后再通过堆化的方法,将剩下的n-1个元素重新构建成堆。堆化完成之后,再取堆顶的元素,放到下标是n-1的位置,一直重复这个过程,直到最后堆中只剩下标为1的一个元素,排序工作就结束了。
说白了,每次都取堆顶元素,然后再用剩下的元素构建堆,再去堆顶元素.........
堆排序过程的代码实现:
// 堆排序
// n表示数据的个数,数组arr中数组的数据从下标1到n的位置
public void sort(int[] arr, int n){
buildHeap(arr,n);
int k = n;
while(k > 1){
// 交换堆顶元素和最后一个元素的下标位置
int temp = arr[1];
arr[1] = arr[k];
arr[k] = arr[1];
--k;
heapify(arr, k, 1); // 堆化
}
}
堆排序的时间复杂度、空间复杂度以及稳定性分析:
时间复杂度分析:堆排序包括建堆和排序两个操作,建堆的时间复杂度是O(n),排序的时间复杂度是O(nlogn),所以,堆排序的整体时间复杂度是O(nlogn)。
空间复杂度分析:整个堆排序的过程都只需要极个别的临时存储空间,所以堆排序是原地排序算法。
稳定性分析:堆排序是不稳定的排序算法,因为在排序过程中,存在将堆的最后一个字节点和堆顶节点互换的操作,所以有可能改变值相同数据的原始相对顺序。
3、堆排序和快速排序的对比
在实际开发中,为什么快速排序要比堆排序要好?
第一点:堆排序数据访问的方式没有快速排序友好。
对于快速排序而言,数据访问是顺序访问的。而对于堆排序而言,数据访问是跳着访问的,这样对CPU的缓存不够友好。
第二点:对于同样的数据,在排序过程中,堆排序算法的数据交换次数要多于快速排序。
快速排序是基于比较和交换的排序算法,其交换数据次数不会超过逆序度。
堆排序的第一步是建堆,建堆的过程会打乱数据原有的相对先后顺序,导致原数据的有序度降低。比如,对于一组已经有序的数据来说,经过建堆后,数据 反而变得无序了。
四、堆的应用
应用一:优先级队列
队列最大的特性就是先进先出。但是,在优先级队列中,数据的出队顺序并不是按照先进先出的规则,而是按照优先级,优先级高的最先出队。
堆和优先级非常相似,一个堆就可以看作是一个优先级队列。很多时候,它们只是概念上有所区分而已。往优先级队列中插入一个元素,就相当于往堆中插入一个元素;从优先级队列中取出优先级最高的元素,就相当于取出堆顶元素。
优先级队列的应用场景非常多,比如:最小生成树算法、图的最短路径以及赫夫曼编码等等。Java语言中也提供了优先级队列的实现:PriorityQueue。
下面讲两个优先级队列的应用案例:
案例1:合并有序小文件
假设我们有100个小文件,每个小文件的大小均为100MB,每个文件中存储的都是字符串。现在要把这100个小文件合并成一个有序的大文件。这里就会用到优先级队列,也就是堆。
把小文件中取出来的字符串放入到小顶堆中,则堆顶中的元素就是优先级队列队首的元素,也就是最小的字符串。现在将这个字符串放入到大文件中,并将其从堆中删除。然后再从小文件中取出下一个字符串,放入到堆中。循环这个过程,就可以将100个小文件中的数据依次放入到大文件中。
案例2:高性能定时器
定时器用于维护定时任务,每个任务都设定了一个要出发执行的时间点。定时器每过一个单位时间(比如1s),就要扫描一遍任务,看是否有任务到达设定执行时间。如果到达了就拿出来执行。
上面每隔单位时间就要扫描任务表中所有的任务,很明显太费时了。优先级队列可以解决这个问题。
可以按照任务设定的执行时间,将这些任务存储在优先级队列中,队列首部,即小顶堆的堆顶,存储的就是下一个要执行的任务。这样定时器就不用每隔单位时间就要扫描一遍任务表,只需要在堆顶任务执行前一个单位时间将其取出即可。这样性能得到了很大的提高。
应用二:利用堆求Top K
Top K问题分为两种场景:静态数据集合和动态数据集合。
针对静态数据集合,如何在一个包含n个数据的数组中,查找前K大的数据呢?
我们可以维护一个大小为K的小顶堆,顺序遍历数组,从数组中取出数据与堆顶元素进行比较。如果比堆顶元素大,就把堆顶元素删除,并且将这个元素插入到堆中;如果比堆顶元素小,则不做处理,则继续遍历数组。堆始终维护着K个元素,且堆顶元素是最小的。
针对动态数据集合求解Top K问题。举个例子进行说明:一个数据集合中有两个操作,一个是插入数据,另一个是查询当前的前K大数据。
维护一个K大小的小顶堆,当有数据插入集合中,就拿它和堆顶元素比较如果比堆顶元素大,就把堆顶元素删除,并且将这个元素插入到堆中;如果比堆顶元素小,则不做处理。
Top K问题的深入:
如何在一个含有10亿个搜索关键词的日志文件中快速获取到Top 10的最热门搜索关键词?处理方法限定为单机,可使用的内存为1GB。
这类问题是典型的有限内存下如何处理海量数据问题。在之前的哈希算法中讲过,相同数据经过哈希算法得到的哈希值是一样的。所以,可以利用哈希算法的这个特点,将10亿条搜索关键词先通过哈希算法分片到10个文件中。
具体这样做:创建10个空文件00,01,02,... 09。遍历这10亿个关键词,并且通过合适的哈希算法对其求哈希值,然后哈希值同10取模,得到的结果就是这个热搜关键词应该被分到的文件编号。
对这10亿个关键词分片,每个文件都只有1一个关键词,假设其中不重复的有1000万个,每个关键词平均50个字节,所以总的大小就是500MB,1GB的内存完全放的下。
针对每个包含1亿个热搜关键词的文件,利用散列表顺序扫描这1亿个关键词。当扫描到某个关键词时,就去散列表中查询。如果存在,就将其对应的次数加1;如果不存在,就将其插入到散列表中,并记录次数为1。依次类推,当遍历完了这1亿个搜索关键词后,散列表中就存储了不重复的搜素关键词及其出现的次数。
再根据前面讲的求Top K问题的方法,建立一个大小为10的小顶堆,遍历散列表,依次取出每个搜索关键词及对应出现的次数,然后与堆顶的热搜关键词对比。如果出现次数比堆顶热搜关键词的次数多,那就删除堆顶的关键词,将这个出现次数更多的关键词加入到堆中。以此类推,当遍历完整个散列表中的关键词之后,堆中的热搜关键词就是出现次数最多的Top 10了。
利用散列表和堆对10个文件分别求出Top 10,然后把这个10个Top 10放在一块,然后取这100个关键词中,出现次数最多的10个关键词,这就是这100亿数据中的Top 10热搜关键词了。
应用三:利用堆求中位数
中位数:n数中处在中间位置上的那个数。如果n是奇数,把数据从小到大排列,则n/2 + 1个数是中位数。如果n是偶数,则中位数有两个,即n/2和n/2 + 1,这个时候,取其中任何一个都可以。
对于一组静态数据集合,中位数是固定的,可以先排序,就找到中位数了。
但是对于动态数据集合,中位数在不停地变化。如果再使用排序的方法,每次查询中位数的时候,都要先进性排序,效率就比较低了。堆结构就很好的解决了这个问题,具体解决方法如下:
需要维护两个堆,一个大顶堆和一个小顶堆。大顶堆中存储前半部分的数据,小顶堆中存储后半部分的数据,且小顶堆中的数据都大于大顶堆中的数据。加入有偶数n个数据,从小到大排序,前n/2个数据存入大顶堆中,后n/2个数据存入小顶堆中。这样,大顶堆中的堆顶元素就是我们要找的中位数。如果n是奇数,则大顶堆存储n/2 + 1个数据,小顶堆存储n/2个元素。
因为该数据集合是动态的,所以当新插入的数据小于等于大顶堆的堆顶元素,就将这个新元素插入到大顶堆,否则将其插入到小顶堆中。
这个时候可能会出现两个堆中的数据个数不符合前面约定的情况,即:【如果 n 是偶数,两个堆中的数据个数都是 n/2;如果 n 是奇数,大顶堆有 n/2 + 1个数据,小顶堆有n/2个数据。】这个时候,需要将一个堆中不停地将堆顶元素移动到另一个堆,以保证上面两个堆元素个数的约定。
至此,我们利用两个堆,一个大顶堆和一个小顶堆实现了动态数据集合中查找中位数的操作。插入操作涉及到堆的堆化操作,时间复杂度为O(logn),但是求中位数只需要返回大顶堆的堆顶元素即可,所以时间复杂度为O(1)。
实际上利用两个堆不仅可以求中位数,只要合理规划两个堆中的元素个数,可以快速求其他任何百分位的数据。
比如:如何快速求接口的99%响应时间?
中位数,即大于等于前面50%的数据,故99百分位数大于前面从小到达的99%数据的那个数据。
如果有100个接口,每个接口请求的响应时间都不同,比如:55毫秒、33毫秒、19毫秒等等,我们把这100个接口的响应时间从小到大排列,排在第99的那个数据就是99%响应时间,也叫99百分位响应时间。
还是维护两个堆,一个大顶堆,一个小顶堆。假设当前数据总个数为n,则大顶堆中保存n*99%个数据,小顶堆中保存n*1%个数据。大顶堆堆顶就是我们要找的99%响应时间。
为了保持大顶堆中的数据占99%,小顶堆中的数据占1%。每次插入新数据后,都要重新计算,它们的数据个数之比是否还符合99:1。如果不符合,就将一个堆中的数据移动到另一个堆,直到满足这个比例。
总结,可以看出来无论求那个百分位数,只要维护好两个堆的数据个数比例就可以,所要找到那个百分位数就是大顶堆堆顶元素。
五、总结
1、堆是一种完全二叉树结构,它的最大特性是:每个节点的值都大于等于(或小于等于)其子树节点的值。因此,堆被分成了两类:大顶堆和小顶堆。
2、堆中比较重要的两个操作是插入一个数据和删除堆顶元素。这两个操作都要用到堆化。插入一个数据的时候,我们要把新插入的数据放到数组的最后,然后从下往上堆化;删除堆顶数据的时候,我们把数组中的最后一个元素放到堆顶,然后从上往下堆化。这两个操作的时间复杂度都是O(logn)。
3、堆排序主要包含建堆和排序两个过程。将下标从n/2到1的节点,依次进行从上到下的堆化操作,然后就可以将数组中的数据组织成堆这种数据结构。然后,迭代地将堆顶元素放到堆的末尾,并将堆的大小减1,然后再堆化,重复这个过程,直到堆中只剩下一个元素,整个数组中的数据就都有序排列了。
4、介绍了堆的三个应用场景:优先队列、Top K问题以及利用堆求中位数问题。
参考及推荐:
说明:本文大部分内容都出自于极客时间中的《数据结构与算法专栏》。
1、《数据结构与算法专栏》第1篇:https://time.geekbang.org/column/article/69913
2、《数据结构与算法专栏》第2篇:https://time.geekbang.org/column/article/70187