堆
堆的定义
- 堆是一个完全二叉树
- 堆中每一个节点的值,都必须大于等于或小于等于其子树中每个节点的值。
对于完全二叉树来说,除了最后一层,其他层的节点都是满的,而且最后一层的节点靠左排列,这种性质导致了完全二叉树可以使用数组完美的进行存储。
对于每个节点的值都大于等于子树中节点值的堆叫做大顶堆,每个节点都小于等于子树中每个节点的值的堆叫做小顶堆。大顶堆的堆顶元素为堆中的最大值,小顶堆的堆顶元素为堆中的最小值。
与二叉查找树的区别:在二叉查找树中,每个节点都大于左节点,小于右节点,二叉查找树的中序遍历结果为有序数组。
堆的存储
由于堆是一个完全二叉树,所以可以使用数组来存储。对于堆的操作都是对数组中元素的操作。一般将根节点存储在下标为1的位置,则下标为i
的节点的子节点为2*i
和2*i+1
,父节点为i//2
。若堆顶元素存储在下标为0的位置,则第i
个节点的子节点为2*i+1
和2*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)
20∗k+21∗(k−1)+...+2∗k∗1=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缓存不友好;而对于快速排序来说,数据顺序访问。
对于同样的数据,在排序的过程中,堆排序的数据交换次数多于快速排序。快速排序中数据的交换次数不会多于逆序对数,而堆排序的建堆过程就会打乱原有数据的顺序。
堆的应用
优先级队列
队列是一种先进先出的操作受限的线性表,对于优先级队列来说,优先级越高的元素越先出队。优先级队列的实现方法有很多,但是使用堆来实现是最直接最高效的。一个堆就可以看作一个优先级队列,很多时候只是概念上的区别。元素入队,就是插入元素到堆中;元素出队,就是删除堆顶元素。
优先级队列的应用场景很多,包括霍夫曼编码,图的最短路径,最小生成树等。而且很多语言也实现了优先级队列,包括Java
的PriorityQueue
和C++
的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/2
和n/2+1
个数的均值。
对于静态数据,中位数是固定的,我们可以一次排序得到中位数。虽然排序成本比较高,但是边际成本很小。对于动态数据集合,中位数在不停的变动,如果每次请求的时候都排序再返回,时间复杂度就太高了。
利用堆来实时获取动态数据集合的中位数。维护两个堆,一个大顶堆,一个小顶堆。对于初始的数据来说,排序之后,用大顶堆存储前半部分数据,小顶堆存储后半部分数据,则小顶堆中的数据都大于大顶堆中的数据。如果数据的个数是奇数个,则大顶堆多存储一个数据。此时,若数据总数为奇数个,大顶堆堆顶元素为中位数;若数据总数为偶数个,中位数为大顶堆堆顶元素和小顶堆堆顶元素的均值。
当来了一个新的元素时,若当前元素小于大顶堆堆顶元素,插入大顶堆;否则插入小顶堆,插入后进行堆化,再调整两个堆的元素个数。
插入操作的时间复杂度为O(log n)
,获取中位数的时间复杂度为O(1)
。利用两个堆,使用类似的思路,还可以求任意百分比位数。