数据结构与算法9:堆与堆排序

堆的定义

  • 堆是一个完全二叉树
  • 堆中每一个节点的值,都必须大于等于或小于等于其子树中每个节点的值。

对于完全二叉树来说,除了最后一层,其他层的节点都是满的,而且最后一层的节点靠左排列,这种性质导致了完全二叉树可以使用数组完美的进行存储。
对于每个节点的值都大于等于子树中节点值的堆叫做大顶堆,每个节点都小于等于子树中每个节点的值的堆叫做小顶堆。大顶堆的堆顶元素为堆中的最大值,小顶堆的堆顶元素为堆中的最小值。

二叉查找树的区别:在二叉查找树中,每个节点都大于左节点,小于右节点,二叉查找树的中序遍历结果为有序数组。

堆的存储

由于堆是一个完全二叉树,所以可以使用数组来存储。对于堆的操作都是对数组中元素的操作。一般将根节点存储在下标为1的位置,则下标为i的节点的子节点为2*i2*i+1,父节点为i//2。若堆顶元素存储在下标为0的位置,则第i个节点的子节点为2*i+12*i+2,父节点为(i-1)//2

堆的操作

以大顶堆为例,下标为0的位置存储堆中元素个数,堆顶元素存在下标为1的位置。

  • 插入元素:将新元素插入到堆的尾部,对新元素从下往上堆化。
def heap_push(heap, item):
	"""往堆中插入一个元素"""
	heap.append(item)
	heap[0] += 1
	return _sift_up(heap, heap[0])
	
def _sift_up(heap, i):
	"""从下往上堆化堆中的第i个元素"""
	while (i//2) > 0 and heap[i] > heap[i//2]:
		heap[i], heap[i//2] = heap[i//2], heap[i]
		i = i//2
	return heap
  • 删除堆顶元素:将最后一个节点放到堆顶,删除最后一个节点,对堆顶元素从上往下堆化。
def heap_pop(heap):
	”“”删除堆顶元素,返回删除完成后的堆和堆顶元素“”“
	if not heap or heap[0] == 0:
		return None
	value = heap[1]
	heap[1] = heap.pop()
	heap[0] -= 1
	return _sift_down(heap, heap[0], 1), value

def _sift_down(heap, count, i):
	"""对堆heap中的第i个元素从上往下进行堆化
	heap: 要堆化的堆
	n:堆中的元素个数,堆化时考虑的元素个数
	i: 要堆化的元素下标
	"""
	while True:
		maxPos = i
		if (2*i<=count and heap[2*i] > heap[maxPos]:
			maxPos = 2*i
		if (2*i+1<= count and heap[2*i+1] > heap[maxPos]):
			maxPos = 2*i+1
		if maxPos == i:  # 若没有发生交换说明已经满足堆的特性
			break
		heap[i], heap[maxPos] = heap[maxPos], heap[i]
		i = maxPos
	return heap

堆化heapify:堆在进行插入或删除操作后可能不再符合堆的特性,需要对堆进行调整,使其重新满足堆的特性的。

  • 从下往上的堆化:顺着节点所在的路径,依次与父节点进行比较,若当前节点大于父节点,交换两个节点。
  • 从上往下的堆化:顺着节点所在的路径,依次与子节点进行比较,若子节点大于当前节点,交换当前节点与子节点中的较大者。

堆化的时间复杂度为O(log n),插入和删除堆顶元素的时间复杂度为O(log n)

堆排序

堆排序包括两个步骤,建堆+排序。

  • 建堆

建堆有两种思路,一是:借助元素插入的操作,每次插入一个元素,并对新插入的元素从下往上进行堆化。

    def build_heap(nums):
        """从数组建堆:模拟插入操作,从下往上堆化,时间复杂度nlogn。"""
        heap = [0]
        for num in nums:
        	heap.append(num)
        	heap[0] += 1
        	i = heap[0]
        	# 从下往上调整堆中的第i个元素
            heap = _sift_up(heap, i)
        return heap

二是:从最后一个非叶子节点(下标为n/2)开始,对于每个非叶子节点,从上往下堆化。每个非叶子节点比较和交换的次数与当前节点的高度成正比,而每层的节点个数为 2 0 , 2 1 , 2 2 , . . . , 2 k 2^0, 2^1, 2^2, ...,2^k 20,21,22,...,2k,总的时间复杂度为 2 0 ∗ k + 2 1 ∗ ( k − 1 ) + . . . + 2 ∗ k ∗ 1 = O ( n ) 2^0*k+2^1*(k-1)+...+2*k*1=O(n) 20k+21(k1)+...+2k1=O(n)

def build_heap(nums):
	heap = [0]
	heap.extend(nums)
	heap[0] = len(nums)
	for i in range(heap[0]//2, 0, -1):
		heap = _sift_down(heap, heap[0], i)
	return heap

一般采取第二种思路进行建堆。

  • 排序
    建堆之后,我们得到了一个最大堆,其中下标为0的位置存储堆中元素的个数,堆顶元素存储在下标为1的位置。对于排序来说,类似于删除堆顶元素的过程,只不过我们每次将堆顶元素与最后一个元素交换位置,同时将堆的大小减1,然后进行堆化,使堆重新满足大顶堆的特性。重复这个过程直到堆中没有元素,这样整个数组的元素就是有序的了。这种方法的空间复杂度为O(1),如果每次都将堆顶元素实质性删除并插入到新数组中,时间复杂度为O(n)
def heap_sort(nums):
	heap = build_heap(nums)
	while heap[0] > 0:
		heap[1], heap[heap[0]] = heap[heap[0]], heap[1]
		heap[0] -= 1
		heap = _sift_down(heap, heap[0], 1)
	return heap

堆排序涉及建堆和排序,建堆时间复杂度为O(n),排序涉及n个元素的堆化,时间复杂度为O(nlogn);空间复杂度为O(1),为原地排序;设计元素交换,为不稳定排序。

  • 堆排序与快速排序

堆排序在堆化的过程中,数据的访问不连续(从当前节点到子节点或者父节点),对CPU缓存不友好;而对于快速排序来说,数据顺序访问。
对于同样的数据,在排序的过程中,堆排序的数据交换次数多于快速排序。快速排序中数据的交换次数不会多于逆序对数,而堆排序的建堆过程就会打乱原有数据的顺序。

堆的应用

优先级队列

队列是一种先进先出的操作受限的线性表,对于优先级队列来说,优先级越高的元素越先出队。优先级队列的实现方法有很多,但是使用堆来实现是最直接最高效的。一个堆就可以看作一个优先级队列,很多时候只是概念上的区别。元素入队,就是插入元素到堆中;元素出队,就是删除堆顶元素。
优先级队列的应用场景很多,包括霍夫曼编码,图的最短路径,最小生成树等。而且很多语言也实现了优先级队列,包括JavaPriorityQueueC++priority_queue

  • 合并有序小文件

假设我们有100个小文件,每个有100MB,每个文件中都是有序的字符串,现在需要将这100个小文件合并为一个有序的大文件。
类似于归并排序中的合并函数(将两个有序数组合并成一个有序数组),我们从这100个小文件中各取第一个字符串,放入一个数组,然后比较大小,选择其中最小的那个放入合并后的大文件,将他从数组中删除,同时读取对应小文件中的下一个字符串(如果对应小文件非空)。重复上述过程,直到所有的小文件中的数据都被放入大文件为止。
这里每次寻找最小元素都需要遍历整个数组,时间复杂度为O(n),可以使用优先级队列(堆)来改进这个过程。将从小文件中取出来的字符串放到一个小顶堆中,每次将堆顶元素放入到大文件并删除,然后从对应的小文件中取出下一个字符串放入到队中,重复上述过程。删除堆顶元素和插入元素的时间复杂度为O(log n),优于在数组中寻找最小值的O(n)。而且,这里我们可以维护一个容量不变(固定为100)的小顶堆,每次将堆顶元素所在的小文件的下一个字符串入堆,然后删除堆顶元素,再对堆顶元素进行从上往下的堆化,只需要进行一次堆化操作,效率更高。

def heap_push_pop(heap, item):
	heap[1], item = item, heap[1]
	heap = _sift_down(heap, 100, 1)
	return heap, item

求top-k

  • 求静态数据集合的top-k

在一个包含n个数据的数组中,查找前k大元素。维护一个大小为k的小顶堆,顺序遍历数组,从数组中取出元素与堆顶元素进行比较,若大于堆顶元素,则用当前元素替换堆顶元素。重复上述过程,知道数组遍历完成,这时堆中的数据就是数组中的前k大元素。
遍历数组时间复杂度为O(n),一次堆化操作时间复杂度为O(log k)。最好情况下,数组中前k个元素为前k大元素,涉及k个元素的入堆堆化操作,时间复杂度为O(k*log k);最坏情况下,数组正序排列,涉及n个元素的入堆堆化操作,时间复杂度为O(n*log k)

  • 求动态数据集合的top-k

假设动态数据集合有两个操作,一是添加数据,二是请求当前的前k大元素。
如果每次请求当前前k大元素时,我们都进行实时计算,则时间复杂度为O(n*log k)n为当前元素的个数。我们可以维护一个容量为k的小顶堆,每次当有数据更新时,就将更新数据与堆顶元素进行比较,如果比堆顶元素大,就删除堆顶元素,插入当前元素并进行堆化。这样堆中的数据就是实时的前k大元素。

求中位数

中位数是处于中间位置的数,对于奇数个数据来说,中位数为第n/2+1个数;对于偶数个数据来说,中位数为第n/2n/2+1个数的均值。
对于静态数据,中位数是固定的,我们可以一次排序得到中位数。虽然排序成本比较高,但是边际成本很小。对于动态数据集合,中位数在不停的变动,如果每次请求的时候都排序再返回,时间复杂度就太高了。
利用堆来实时获取动态数据集合的中位数。维护两个堆,一个大顶堆,一个小顶堆。对于初始的数据来说,排序之后,用大顶堆存储前半部分数据,小顶堆存储后半部分数据,则小顶堆中的数据都大于大顶堆中的数据。如果数据的个数是奇数个,则大顶堆多存储一个数据。此时,若数据总数为奇数个,大顶堆堆顶元素为中位数;若数据总数为偶数个,中位数为大顶堆堆顶元素和小顶堆堆顶元素的均值。
当来了一个新的元素时,若当前元素小于大顶堆堆顶元素,插入大顶堆;否则插入小顶堆,插入后进行堆化,再调整两个堆的元素个数。
插入操作的时间复杂度为O(log n),获取中位数的时间复杂度为O(1)。利用两个堆,使用类似的思路,还可以求任意百分比位数。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值