堆排序在topK场景题中的应用及原理

参考以下文章:

零、先简单说下处理topK问题的答案:

一般我们说 topK 问题,通常使用大顶堆小顶堆来实现:

  • 1.从N个数中寻找最大的 K 个数 / 在未排序的数组中找到第k个最大的元素
    • 小顶堆(优先推荐:【空间复杂度 O(K)】,因为只需要维护数量为K个节点的堆结构;【时间复杂度 N*O(logK)】
    • 大顶堆(不推荐):【空间复杂度 O(N)】,因为需要将N个数全部都填充进 [大顶堆] 中,然后再弹出K次;【创建堆的时间复杂度:O(N);调整堆的时间复杂度:KO(logN),因为 k < n,故总的渐进时间复杂为 O(N + KlogN) = O(NlogN)】
  • 2.从N个数中寻找最小的 K 个数 / 在未排序的数组中找到第k个最小的元素
    • 大顶堆(优先推荐:【空间复杂度 O(K)】,因为只需要维护数量为K个节点的堆结构;【时间复杂度 N*O(logK)】
    • 小顶堆:【空间复杂度 O(N)】,因为需要将N个数全部都填充进 [小顶堆] 中,然后再弹出K次;【创建堆的时间复杂度 O(N);调整堆的时间复杂度 KO(logN),因为 k < n,故总的渐进时间复杂为 O(N + KlogN) = O(NlogN)】
  • 总的来说,无论是【寻找最大的 K 个数 / 在未排序的数组中找到第k个最大的元素】,还是【从N个数中寻找最小的 K 个数 / 在未排序的数组中找到第k个最小的元素】,它们都分别可以使用 [大顶堆/小顶堆] 来处理,只是不同方式,其时间复杂度和空间复杂度也有所不同。
    • 推荐【从N个数中寻找最大的 K 个数 / 在未排序的数组中找到第k个最大的元素】使用小顶堆!
    • 推荐【从N个数中寻找最小的 K 个数 / 在未排序的数组中找到第k个最小的元素】使用大顶堆!

举例:LeetCode215.数组中的第k个最大的元素,推荐其题解:『TopK问题 』快速排序、堆排序详解

这一题如果我们采用[堆]的思想,要怎么考虑呢?

求第k大,就要用小顶堆,每一次更新后,顶点时刻都是整个树中数值最小的节点,它的孩子都比他大,而我们求第k大,就构建一个只有 k 个节点的堆,最后返回堆顶。是不是很妙?!

我把堆的排序规则简要归纳为 [对比] → [移除] → [调整] 三步。看一个示意图:
在这里插入图片描述
由于节点个数的限制,一个堆里只能存放k个节点(空间复杂度:O(K)),那么在初始堆填满后,后续新来的每一个值都要先跟堆顶比较值的大小。因为小顶堆的堆顶始终保存整个树结构中最小的那个值。刷新,然后调整,继续保持根节点最小的队形。

一、什么是堆?

堆是一种非线性结构,(本篇随笔主要分析堆的数组实现)可以把堆看作一个数组,也可以被看作一个完全二叉树,通俗来讲堆其实就是利用 完全二叉树 的结构来维护的一维数组。

按照堆的特点可以把堆分为大顶堆和小顶堆:

  • 大顶堆:每个节点的值都大于或等于其左右孩子节点的值

  • 小顶堆:每个节点的值都小于或等于其左右孩子节点的值

(堆的这种特性非常的有用,堆常常被当做优先队列使用,因为可以快速的访问到“最重要”的元素)

二、堆的特点(数组实现)

大顶堆和小顶堆
我们对堆中的节点按层进行编号,将这种逻辑结构映射到数组中就是下面这个样子
堆的数组实现
我们用简单的公式来描述一下堆的定义就是:(读者可以对照上图的数组来理解下面两个公式)

  • 大顶堆:arr[i] >= arr[2i+1] && arr[i] >= arr[2i+2]
  • 小顶堆:arr[i] <= arr[2i+1] && arr[i] <= arr[2i+2]

三、堆和普通树的区别

  • 内存占用:
    普通树占用的内存空间比它们存储的数据要多。你必须为节点对象以及左/右子节点指针分配额外的内存。堆仅仅使用数组,且不使用指针
    (可以使用普通树来模拟堆,但空间浪费比较大,不太建议这么做)

  • 平衡:
    二叉搜索树必须是“平衡”的情况下,其大部分操作的复杂度才能达到O(nlogn)。你可以按任意顺序位置插入/删除数据,或者使用 AVL 树或者红黑树。但是在堆中实际上不需要整棵树都是有序的,我们只需满足堆属性即可,所以在堆中平衡不是问题。因为堆中数据的组织方式可以保证O(nlogn) 的性能

  • 搜索:
    在二叉树中搜索会很快,但是在堆中搜索会很慢。在堆中搜索不是第一优先级,因为使用堆的目的是将最大(或者最小)的节点放在根节点,从而快速的进行相关插入、删除操作

四、堆排序的过程

先了解下堆排序的基本思想:

将待排序序列构造成一个大顶堆,此时,整个序列的最大值就是堆顶的根节点。将其与末尾元素进行交换,此时末尾就为最大值。然后将剩余n-1个元素重新构造成一个堆,这样会得到n个元素的次小值。

如此反复执行,便能得到一个有序序列了,建立最大堆时是从最后一个非叶子节点开始从下往上调整的(这句话可能不好太理解),下面会举一个例子来理解堆排序的基本思想

给一个无序序列如下:
int a[6] = {7, 3, 8, 5, 1, 2};
现在可以根据数组将完全二叉树还原出来
在这里插入图片描述
好了,现在我们要做的事情就是要把7,3,8,5,1,2变成一个有序的序列,如果想要升序就是1,2,3,5,7,8 如果想要降序就是8,7,5,3,2,1 ,这两种就是我们要的最终结果,然后我们就可以根据我们想要的结果来选择适合类型的堆来进行排序。

  • 升序 → 使用大顶堆
  • 降序 → 使用小顶堆

五、堆排序的原理

上面提到过大顶堆的特点:每个节点的值都大于或等于其左右孩子节点的值,我们把大顶堆构建完毕后根节点的值一定是最大的,然后把根节点的和最后一个元素(也可以说最后一个节点)交换位置,那么末尾元素此时就是最大元素了(理解这点很重要)

知道了堆排序的原理后,再理清一下步骤(假设我们想要升序的排列)

  1. 构建堆:先用n个元素的无序序列,构建成大顶堆
  2. 调整堆 1:将根节点与最后一个元素交换位置,(将最大元素"沉"到数组末端)
  3. 调整堆 2:交换过后可能不再满足大顶堆的条件,所以需要将剩下的 n-1 个元素重新构建成大顶堆
  4. 重复第二步、第三步直到整个数组排序完成

六、图解交换过程(得到升序序列,使用大顶堆来调整)

这里以 int a[6] = {7, 3, 8, 5, 1, 2}为例子

先要找到最后一个非叶子节点,数组的长度为6,那么最后一个非叶子节点就是:长度/2-1,也就是6/2-1=2,然后下一步就是比较该节点值和它的子树值,如果该节点小于其左\右子树的值就交换(意思就是将最大的值放到该节点)

8只有一个左子树,左子树的值为2,8>2不需要调整
在这里插入图片描述
下一步,继续找到下一个非叶子节点(其实就是当前坐标-1就行了),该节点的值为3小于其左子树的值,交换值,交换后该节点值为5,大于其右子树的值,不需要交换
在这里插入图片描述
在这里插入图片描述
下一步,继续找到下一个非叶子节点,该节点的值为7,大于其左子树的值,不需要交换,再看右子树,该节点的值小于右子树的值,需要交换值
在这里插入图片描述
在这里插入图片描述
下一步,检查调整后的子树,是否满足大顶堆性质,如果不满足则继续调整(这里因为只将右子树的值与根节点互换,只需要检查右子树是否满足,而7>2刚好满足大顶堆的性质,就不需要调整了。

如果运气不好整个树的根节点的值是1,那么就还需要调整右子树)

到这里大顶堆的构建就算完成了,然后下一步交换根节点(8)与最后一个元素(2)交换位置(将最大元素"沉"到数组末端),此时最大的元素就归位了,然后对剩下的5个元素重复上面的操作
在这里插入图片描述
(注:这里用粉红色来表示已经通过交换后归位的元素)

剩下只有5个元素,最后一个非叶子节点是5/2-1=1,该节点的值(5)大于左子树的值(3)也大于右子树的值(1),满足大顶堆性质不需要交换
在这里插入图片描述
找到下一个非叶子节点,该节点的值(2)小于左子树的值(5),交换值,交换后左子树不再满足大顶堆的性质再调整左子树,左子树满足要求后再返回去看根节点,根节点的值(5)小于右子树的值(7),再次交换值
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
得到新的大顶堆,如下图,再把根节点的值(7)与当前数组最后一个元素值(1)交换,再 重建大顶堆→交换值→重建大顶堆→交换值…,直到整个数组都变成有序序列
在这里插入图片描述
在这里插入图片描述
最后得到的升序序列如下图:
在这里插入图片描述

七、堆排序的代码实现

堆排序 Go版本实现:
另外,各种排序实现参考:常用各种sort排序实现(Go/C++版本)

// 堆排(大顶堆:每个节点的值都大于或等于其左右孩子节点的值)
func HeapSort(arr []int) {
	var CreateHeap = func(arr []int, i, length int) {
		tmp := arr[i]
 
		// 注意for循环条件:是 j<length 而不是 j<len(arr)
		for j := 2*i + 1; j < length; j = j*2 + 1 { // j=2i+1:当前根节点的左孩子下标 j= 2*j + 1:以当前叶子节点为新根节点,该新根节点的下一层叶子节点左孩子下标
			if j+1 < length && arr[j] < arr[j+1] { // j+1<length:右孩子(j+1)不能超出len长度范围
				j++
			}
 
			if tmp > arr[j] { // 左右孩子节点中选较大的节点值,并与父节点比较大小
				break // 若父节点值满足"大于或等于其左右孩子节点的值"则break,否则与较大的孩子节点相互交换
			}
 
			arr[i] = arr[j]
			i = j
		}
		arr[i] = tmp // 将最终比较后较小值放到合适的位置
	}
 
	// 首次构建堆
	l := len(arr)
	for i := l / 2; i >= 0; i-- { // 从二叉树最后一个父节点从底向上遍历(最上面的父节点:i = 0;最后一个父节点下标:i = len(arr) / 2)
		CreateHeap(arr, i, l)
	}
 
	// 再次重建堆
	for i := l - 1; i > 0; i-- { // 从下往上不断在每轮循环中置换出当前最大值,arr长度i也逐渐减到0
		arr[0], arr[i] = arr[i], arr[0] // swap 把大顶堆根节点(下标为0)上的最大值交换到末尾,置换出来.
		CreateHeap(arr, 0, i)
	}
}

func main() {
	array := []int{5, 28, 73, 19, 6, 0, 5}
	HeapSort(array)
	fmt.Println(array)
	return
}
  • 1
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值