十四、堆(Heap)

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/u012736685/article/details/84671551

0、

堆(Heap)

堆排序是一种原地的、时间复杂度为O(nlogn)的排序算法。

引入:快速排序平均情况下,时间复杂度为O(nlogn),甚至堆排序比快速排序的时间复杂度还要稳定,但在实际中,快排性能要比堆排序好,为什么???

一、堆的概述

堆——是一种特殊的树

  • 堆是一个完全二叉树(除了最后一层,其他层的节点个数都是满的,最后一层的节点都是靠左排列)
  • 堆中每一个节点的值都必须大于等于(或小于等于)其子树中每个节点的值(也就是说,堆中每个节点的值都大于等于(或小于等于)其左右子节点的值)

大顶堆:对于每个节点的值都大于等于子树中每个节点的堆。

小顶堆:对于每个节点的值都小于等于子树中每个节点的堆。

注意:对于同一组数据,可以构建多种不同形态的堆(大顶堆可有不同形态,小顶堆同理)

二、堆的操作

ps:以大顶堆为例

1、插入操作

  • 把新插入的元素放在堆的最后;
  • 堆化(heapify):对堆进行调整,使其重新满足堆的特性
    • 两种堆化方法:
      • 思路:顺着节点所在的路径,向上或向下进行对比,然后交换
      • (1)从下往上(如下图所示)
      • (2)从上往下

在这里插入图片描述
==》code

public class Heap {
	private int[] a;     // 数组,从下标1开始存储堆中的数据
	private int n;       // 堆可以存储的最大数据个数
	private int count;   // 堆中已经存储的数据个数

	// 初始化
	public Heap(int capacity) {
		a = new int[capacity + 1];
		n = capacity;
		count = 0;
	}

	public void insert(int data) {
		if(count >= n)
			return;  // 堆满了
		count++;
		a[count] = data;
		int i = count;
		while(i/2 > 0 && a[i] > a[i/2]) {
			// 自下往上堆化
			// swap()函数用于交换下标为 i 和 i/2 的两个元素
			swap(a, i, i/2);
			i = i/2;
		}
	}
}

2、删除堆顶元素

栈顶元素存储的就是堆中数据的最大值或最小值

以大顶堆为例,堆顶元素就是最大元素。当删除堆顶元素之后,则需要把第二大的元素放到堆顶,第二大元素必为于左右子节点中。然后迭代地删除第二大节点,以此类推,直到叶子节点被删除。
==》会出现数组空洞,也就是堆化出来的堆并不满足完全二叉树的特性。
在这里插入图片描述

扫描二维码关注公众号,回复: 4383458 查看本文章

解决方法

  • 将最后一个元素放在堆顶;
  • 然后利用同样的父子节点对比方法:若不满足父子节点大小关系,则交换两个节点并重复该过程,直到父子节点之间满足大小关系为止。==》从上往下的堆化方法
    在这里插入图片描述

实现代码

public void removeMax() {
	// 堆中没有数据
	if (count == 0)       
		return -1;
	a[1] = a[count];
	--count;
	heapify(a, count, 1);
}

private void heapify(int[] a, int n, int i) {
	// 自上往下堆化
	while(true) {
		int maxPos = i;
		// 寻找最大值的位置
		if(i*2 <= n && a[i] < a[i/2])
			maxPos = i * 2;
		if(i*2+1 <= n && a[maxPos] < a[i*2+1])
			maxPos = i * 2 + 1;
		if(maxPos == i)
			break;
		swap(a, i, maxPos);
		i = maxPos;
	}
}

3、时间复杂度分析

① 一个包含 n 个节点的完全二叉树,树的高度不会超过 log2n;
② 堆化过程是顺着节点所在路径比较交换的
==》堆化时间复杂度与树高成正比,也就是O(logn)
==》插入数据和删除堆顶元素主要逻辑是堆化
==》时间复杂度O(logn)

三、堆的存储

完全二叉树比较适合用数组来存储,非常节省存储空间。

  • 原因:不需要存储左右节点的指针,可单纯地通过数组的下标,来找到节点的左右子节点和父节点。(根节点存储在数组下标为1的位置,数组下标为i的节点的左子结点的下标为2*i,右子节点的下标为2*i+1
    在这里插入图片描述

四、堆排序的实现

  • 时间复杂度为O(n2):冒泡排序、插入排序、选择排序
  • 时间复杂度为O(nlogn):归并排序、快速排序、线性排序

1、堆排序

堆排序:基于堆这种数据结构实现的排序算法。

堆排序时间复杂度非常稳定的原地排序算法——O(nlogn)

堆排序的过程大致分解为:建堆和排序

(1)建堆

目标:将数组原地建成一个堆。 原地就是不借助另一个数组,就在原数组上进行操作。

思路一:将元素依次插入堆中,数组下标从1开始。该思路从前往后处理数组数据,并且每个数据插入堆中时,都是从下往上堆化。

思路二

  • 与思路一相反,从后往前处理数组,并且每个数据都是从上往下堆化。
    在这里插入图片描述在这里插入图片描述

  • 思路二代码实现:

private static void buildHeap(int[] a, int n) {
	for(int i = n/2; i >= 1; --i){
		heapify(a, n, i);    // 自上往下堆化
	}
}

private void heapify(int[] a, int n, int i){
	// 自上往下堆化
	while(true){
		int maxPos = i;
		if(i*2 <= n && a[i] < a[i*2])
			maxPos = i*2;
		if(i*2+1 <= n && a[maxPos] < a[i*2+1])
			maxPos = i*2 + 1;
		if(maxPos == i)
			break;
		swap(a, i, maxPos);
		i = maxPos;
	}
}
  • 分析:代码仅对下标从 n/2 开始到 1 的数据进行堆化,下标从 n/2+1 到 n 的节点是叶子节点,不需要堆化

  • 时间复杂度

    • 节点堆化的时间复杂度:O(logn)
    • n/2 个节点堆化的总时间复杂度:O(nlogn) ==》不够精确
    • 堆排序的建堆过程的时间复杂度:O(n)
  • 推导过程:
    ① 由于叶子节点不需要堆化,所以需要堆化的节点从倒数第二层开始;
    ② 每个节点堆化的过程中,需比较和交换的节点个数,与该节点的高度 k (从节点到叶节点的路径长度)成正比
    在这里插入图片描述
    ③ 将每个非叶子节点的高度求和,公式如下:
    在这里插入图片描述在这里插入图片描述
    在这里插入图片描述
    ④ 由于 h = log2n,代入公式S,可得 S = O(n)
    ⑤ 时间复杂度——O(n)

(2)排序——大顶堆《==》小顶堆

  • 建堆后,数组中的数据已经是按照大顶堆的特性组织的;
    • 将数组中的第一个元素(堆顶)与最后一个元素交换,则最大元素就放在下标为 n 的位置;
    • 类似“删除堆顶元素”的操作,然后通过堆化方法将剩下的 n-1 个元素重新构建为堆;
    • 重复上述过程,直到最后堆只剩一个下标为1的一个元素

在这里插入图片描述

  • 代码

// n 表示数据的个数,数组 a 中的数据从下标 1 到 n 的位置。
public static void sort(int[] a, int n) {
	buildHeap(a, n);
	int k = n;
	while(k > 1){
		swap(a, 1, k);
		--k;
		heapify(a, k, 1);
	}
}
  • 分析

    • 原地排序算法:整个堆排序过程,都只需要极个别临时存储空间。
    • 时间复杂度:建堆O(n)+排序O(nlogn)
      • ==》整体时间复杂度: O(nlogn)
    • 不稳定排序算法:在排序过程中,存在将堆的最后一个节点跟堆顶点互换的操作,可以改变值相同数据的原始相当顺序。

注意:若堆中数据从数组下标0开始存储,则节点下标为 i 时,其左子节点下标为 2i+1,右子节点的下标为2i+2,其父节点的下标为 (i-1)/2

五、在实际开发中,为什么快排要比堆排序性能好?

  • 堆排序数据访问的方式没有快速排序友好。
    • 快排数据——顺序访问
    • 堆排序数据——跳着访问==》对CPU缓存不友好
  • 对于同样的数据,在排序过程中,,堆排序算法的数据交换次数要多于快速排序。
    • 快排数据交换的次数不会比逆序度多;
    • 堆排序的建堆过程会打乱数据原有的相对前后顺序,导致原数据的有序度降低;

六、堆应用

场景:假设现有一个包含10亿个搜索关键词的日志文件,如何快速获取热门榜 Top 10 的搜索关键词?

思路

  • 通过哈希算法求取对应的哈希值,然后对哈希值同 10 取模,得到的结果就是这个搜索关键词应被分到的文件编码。
  • 然后利用散列表和堆,分别求取 Top 10,将10个Top 10放在一起,取Top 10。

1、优先级队列

  • 优先级队列:数据的出队顺序不是先进先出,而是按照优先级来,优先级最高的,最先出队。
  • 堆可以看作为一个优先级队列——往优先级队列插入一个元素,就相当于往堆中插入一个元素;从优先级队列中取出最高的元素,就相当于取出堆顶元素。
  • 应用:赫夫曼编码、图的最短路径、最小生成树算法等等。
  • 实现:JAVA 中 PriorityQueue,C++的priority_queue等。

(1)合并有序小文件

目标:假设存在100个小文件,每个文件大小为100M,每个文件中存储都是有序的字符串。==》合并成一个有序的大文件。

思路1:类似归并排序中的合并函数。分别从100个文件中,各取第一个字符串,放入数组,然后比较,将最小的字符串放入合并后的大文件中,并从数组中删除;然后从最小字符串文件中取下一个字符串,并放入数组,重复上述过程,直到所有的文件中的数据都放入到大文件为止。

思路2:利用优先级队列,也就是利用堆。从小文件中取出字符串放入小顶堆中,那堆顶的元素就是优先级队列队首的元素,即最小的字符串;将该字符串放入大文件中,并将其从堆中删除;然后再从小文件中取出下一个字符串,放入到堆中,重复上述过程,直到可以将100个小文件中的数据依次放入大文件中。
==》删除堆顶数据和往堆中插入数据的时间复杂度:O(logn),n表示堆中的数据个数,这里是100

(2)高性能定时器

目标:有个定时器,定时器中维护很多定时任务,每个任务都设定了一个要触发执行的时间点。定时器每过一个很小的单位时间就要扫描一次任务,看是否有任务达到设定的执行时间,若达到,则执行。

思路:利用优先级队列按照设定的执行时间,将这些任务存储到优先级队列中,队列首部(堆顶)存储的就是最先执行的任务。
==》只需取队首任务的执行时间点,与当前时间相减,就得到一个时间间隔 T。
==》该时间间隔 T 就是从当前时间开始到第一个任务需要被执行的所需时间;从当前时间点到(T-1)这段时间内,定时器不需要做任何事情,当T时间过后,定时器取优先级队列中队首的任务执行。然后再计算新的队首任务的执行时间点与当前时间点的差值。

2、求 Top K

场景:将求 Top K 的问题抽象成两类。

  • 针对静态数据集合——数据集合事先确定,不再改变;
  • 针对动态数据集合——数据集合事先不确定,有数据动态地插入到集合中。

(1)静态数据

在包含n个数据的数组中,查找前 K 大数据
==》维护一个大小为 K 的小顶堆,顺序遍历数组,从数组中取出数据与堆顶元素比较。若比堆顶元素大,则删除堆顶元素,将这个元素插入到堆中;若比堆顶元素小,则继续遍历数组。
==》 遍历数组的时间复杂度:O(n)
==》一次堆化操作的时间复杂度:O(logK)
==》最坏情况下,n个元素都入堆一次,时间复杂度:O(nlogK)

(2)动态数据

针对动态数据求得 Top K 就是实时 Top K。

一个数据集合中有两个操作:

  • 添加数据:
  • 询问当前的前 K 大数据:
    • 维护一个大小为 K 的小顶堆,当有数据被添加到集合中时,将其与堆顶元素比较。若比堆顶元素大,则删除堆顶元素,将这个元素插入到堆中;若比堆顶元素小,则不做处理。

3、利用堆求中位数

目标:求动态数据集合中的中位数。

中位数

  • 若数据的个数是奇数,把数据从小往大排,第 n/2+1 个数据就是中位数;
  • 若数据的个数是偶数,把数据从小往大排,第 n/2 个数据和第 n/2+1 个数据的单独一个或均值或…就是中位数(此处为第 n/2 个数据);
    在这里插入图片描述

(1)利用堆高效地实现求中位数

通过维护两个堆:一个大顶堆,一个小顶堆。 大顶堆中存储前半部分数据,小顶堆中存储后半部分数据,且小顶堆中的数据都大于大顶堆中数据。

静态数据

如果有 n 个数据,n 是偶数,我们从小到大排序,那前 n/2 个数据存储在大顶堆中,后 n/2 个数据存储在小顶堆中。这样,大顶堆中的堆顶元素就是我们要找的中位数。如果 n 是奇数,情况是类似的,大顶堆就存储 n/2+1 个数据,小顶堆就存储 n/2 个数据。
在这里插入图片描述

动态数据

若新加入的数据小于等于大顶堆的堆顶元素,则将这个新数据插入到大顶堆;否则若新加入的数据大于等于小顶堆的堆顶元素 ,则将这个新数据插入到小顶堆。

若两个堆中的数据个数不符合前面约定的情况:

  • 若 n 是偶数,则两个堆中数据个数都是 n/2;
  • 若 n 为奇数,则大顶堆有 n/2+1 个数据,小顶堆有 n/2 个数据;
  • 若两个堆数据个数不满足约定,则从一个堆中不停地将堆顶元素移动到另一个堆,通过这样的调整,让两个堆的数据满足上面约定。
    在这里插入图片描述
    ==》插入数据需要堆化操作,所以时间复杂度:O(logn)
    ==》中位数只需返回大顶堆的堆顶元素,所以时间复杂度:O(1)

(2)利用堆求百分位的数据

目标:快速求接口的 99% 响应时间?
注:99 百分位数的概念可以类比中位数,如果将一组数据从
小到大排列,这个 99 百分位数就是大于前面 99% 数据。
在这里插入图片描述

如果有 n 个数据,将数据从小到大排列之后,99 百分位数大
约就是第 n*99% 个数据,同类,80 百分位数大约就是第 n*80% 个数据。

为了保持大顶堆中的数据占 99%,小顶堆中的数据占 1%,
在每次新插入数据之后,我们都要重新计算,这个时候大顶堆和小顶堆中的数据个数,是否还符合 99:1 这个比例。如果不符合,我们就将一个堆中的数据移动到另一个堆,直到满足这个比例。

猜你喜欢

转载自blog.csdn.net/u012736685/article/details/84671551
今日推荐