【数据结构与算法】之堆的详解 --- 第十五篇

上一篇:红黑树:https://blog.csdn.net/pcwl1206/article/details/84227825

目 录:

一、堆的基本概念

二、堆的实现

1  往堆中插入一个元素

2、删除堆顶元素

3、时间复杂度分析

三、堆排序

1、建堆

2、排序

四、堆的应用

应用一:优先级队列

应用二:利用堆求Top K

应用三:利用堆求中位数


一、堆的基本概念

堆是一种特殊的树,只要满足下列两点要求,就符合堆的定义:

1、堆是一棵完全二叉树;

2、堆中的一个节点的值都必须大于等于(或小于等于)其子树中每个节点的值。

回顾:完全二叉树:除了最后一层,其他层的节点个数都是满的,最后一层的节点都靠左排列。

对于每个节点的值都大于等于子树中每个节点值得堆,我们称为“大顶堆”。对于每个节点得值都小于等于子树中每个节点值的堆,我们叫做“小顶堆”

图片来自《数据结构与算法之美》专栏

如上图所示:第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个节点的完全二叉树,树的高度不会超过\log_{2}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 = \log_{2}n,代入公式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

猜你喜欢

转载自blog.csdn.net/pcwl1206/article/details/84608381