www.hello-algo.com/chapter_hea…
1. 堆
堆通常用于实现优先队列,大顶堆相当于元素按从大到小的顺序出队的优先队列。从使用角度来看,我们可以将“优先队列”和“堆”看作等价的数据结构。这样看的话,堆顶相当于有序队列的队首,堆底相当于队列队尾。
1.1. 基本概念
堆(heap)是一种满足特定条件的[[#1.4. 二叉树常见类型|完全二叉树]],主要可分为两种类型,两种类型的区别是根节点值和子节点值的大小关系不同。
堆的特性:
- 最底层节点靠左填充,其他层的节点都被填满。
- 我们将二叉树的根节点称为“堆顶”,将底层最靠右的节点称为“堆底”。
- 对于大顶堆(小顶堆),堆顶元素(根节点)的值是最大(最小)的。
1.2. 堆的基本操作
| 方法名 | 描述 | 时间复杂度 |
|---|---|---|
push() | 元素入堆 | O(logn) |
pop() | 堆顶元素出堆 | O(logn) |
peek() | 访问堆顶元素(对于大 / 小顶堆分别为最大 / 小值) | O(1) |
size() | 获取堆的元素数量 | O(1) |
isEmpty() | 判断堆是否为空 | O(1) |
# 初始化小顶堆
min_heap, flag = [], 1
# 初始化大顶堆
max_heap, flag = [], -1
# Python 的 heapq 模块默认实现小顶堆
# 考虑将“元素取负”后再入堆,这样就可以将大小关系颠倒,从而实现大顶堆
# 在本示例中,flag = 1 时对应小顶堆,flag = -1 时对应大顶堆
# 元素入堆,这里实现的是最小堆的入堆,为了后续能成为最大堆,因此乘-1,保证最大元素变成最小元素入堆
heapq.heappush(max_heap, flag * 1)
heapq.heappush(max_heap, flag * 3)
heapq.heappush(max_heap, flag * 2)
heapq.heappush(max_heap, flag * 5)
heapq.heappush(max_heap, flag * 4)
# 获取堆顶元素,因为取反了,因此为了实现读取大顶堆的堆顶,则需要再次取反回来
peek: int = flag * max_heap[0] # 5
# 堆顶元素出堆
# 出堆元素会形成一个从大到小的序列
val = flag * heapq.heappop(max_heap) # 5
val = flag * heapq.heappop(max_heap) # 4
val = flag * heapq.heappop(max_heap) # 3
val = flag * heapq.heappop(max_heap) # 2
val = flag * heapq.heappop(max_heap) # 1
# 获取堆大小
size: int = len(max_heap)
# 判断堆是否为空
is_empty: bool = not max_heap
# 输入列表并建堆
min_heap: list[int] = [1, 3, 2, 5, 4]
heapq.heapify(min_heap)
1.3. 堆的实现
1.3.1. 堆的存储与表示
完全二叉树非常适合用数组来表示,堆是完全二叉树,因此堆使用数组进行存储。
"""索引映射公式"""
def left(self, i: int) -> int:
"""获取左子节点的索引"""
return 2 * i + 1
def right(self, i: int) -> int:
"""获取右子节点的索引"""
return 2 * i + 2
def parent(self, i: int) -> int:
"""获取父节点的索引"""
return (i - 1) // 2 # 向下整除
1.3.2. 访问堆顶
访问堆顶,实际上,就是访问列表的首个元素
def peek(self) -> int:
"""访问堆顶元素"""
return self.max_heap[0]
1.3.3. 元素入堆
元素入堆可以等同于一个列表先添加一个元素,此时这个元素位于堆底,但是,该元素可能会破坏堆的特性(该元素的值大于其他元素的值),因此需要修复从插入节点到根节点的路径上的各个节点,这个操作被称为堆化(heapify)。
从入堆节点开始,从底至顶执行堆化。其本质就是不断比较插入节点与父节点的大小,以大顶堆为例,如果大于的话则交换位置,反复执行操作。
def push(self, val: int):
"""元素入堆"""
# 添加节点
self.max_heap.append(val)
# 从底至顶堆化
self.sift_up(self.size() - 1)
def sift_up(self, i: int):
"""从节点 i 开始,从底至顶堆化"""
while True:
# 获取节点 i 的父节点
p = self.parent(i)
# 当“越过根节点”或“节点无须修复”时,结束堆化
if p < 0 or self.max_heap[i] <= self.max_heap[p]:
break
# 交换两节点
self.swap(i, p)
# 循环向上堆化
i = p
1.3.4. 堆顶出堆
由于堆顶元素为根节点和列表首元素,直接删除的话索引都会发生改变,因此:
- 交换堆顶元素与堆底元素(交换根节点与最右叶节点)。
- 交换完成后,将堆底从列表中删除(注意,由于已经交换,因此实际上删除的是原来的堆顶元素)。
- 从根节点开始,从顶至底执行堆化。即,位置变换操作
def pop(self) -> int:
"""元素出堆"""
# 判空处理
if self.is_empty():
raise IndexError("堆为空")
# 交换根节点与最右叶节点(交换首元素与尾元素)
self.swap(0, self.size() - 1)
# 删除节点
val = self.max_heap.pop()
# 从顶至底堆化
self.sift_down(0)
# 返回堆顶元素
return val
def sift_down(self, i: int):
"""从节点 i 开始,从顶至底堆化"""
while True:
# 判断节点 i, l, r 中值最大的节点,记为 ma
l, r, ma = self.left(i), self.right(i), i#获取节点索引
if l < self.size() and self.max_heap[l] > self.max_heap[ma]:#当索引未超出边界且l节点的值大于ma节点的值
ma = l
if r < self.size() and self.max_heap[r] > self.max_heap[ma]:
ma = r
# 若节点 i 最大或索引 l, r 越界,则无须继续堆化,跳出
if ma == i:
break
# 交换两节点
self.swap(i, ma)
# 循环向下堆化
i = ma
1.4. 堆的常见应用
- 优先队列:堆通常作为实现优先队列的首选数据结构,其入队和出队操作的时间复杂度均为 O(logn) ,而建堆操作为 O(n) ,这些操作都非常高效。
- 堆排序:给定一组数据,我们可以用它们建立一个堆,然后不断地执行元素出堆操作,从而得到有序数据。然而,我们通常会使用一种更优雅的方式实现堆排序,详见“堆排序”章节。
- 获取最大的 k 个元素:这是一个经典的算法问题,同时也是一种典型应用,例如选择热度前 10 的新闻作为微博热搜,选取销量前 10 的商品等。
2. 建堆操作
使用一个列表的所有元素来建立一个堆
2.1. 借助入堆操作实现
先创建一个空堆,然后遍历列表,依次对每个元素执行“入堆操作”,即先将元素添加至堆的尾部,再对该元素执行“从底至顶”堆化。此时堆是“自上而下”构建的。
a = [8,65,2182,698,6]
heap = []
for i in range(len(a)):
heap.heappush(heap,a[i])
该建堆方法的时间复杂度为 O(nlogn) 。 节点数量:n 叶节点数量:(n+1)/2,/为向下整除 需要堆化的节点数量:(n-1)/2 最大迭代次数(堆顶):logn 时间复杂度:O(nlogn)
2.2. 通过遍历堆化实现
每当堆化一个节点后,以该节点为根节点的子树就形成一个合法的子堆。而由于是倒序遍历,因此堆是“自下而上”构建的。
- 将列表所有元素原封不动地添加到堆中,此时堆的性质尚未得到满足。
- 倒序遍历堆([[#2.1. 层序遍历|层序遍历]] 的倒序),依次对每个非叶节点执行“从顶至底堆化”。
- 在此过程中叶节点无需堆化
def __init__(self, nums: list[int]):
"""构造方法,根据输入列表建堆"""
# 将列表元素原封不动添加进堆
self.max_heap = nums
# 堆化除叶节点以外的其他所有节点
for i in range(self.parent(self.size() - 1), -1, -1):
self.sift_down(i)
2.2.1. 复杂度分析
假设给定一个节点数量为 n 、高度为 h 的“完美二叉树”,该假设不会影响计算结果的正确性。 然后进行复杂度的计算。最后得到该方法时间复杂度为 O(n)
3. TOP-K 问题
给定一个长度为 n的无序数组 nums ,请返回数组中最大的 k 个元素。
3.1. 遍历查找:
直接对数组进行遍历从而找出想得到的k 个元素,仅适用于 k≪n:
3.2. 排序查找
先对数组进行排序,然后返回最右边的k 个元素
3.3. 建堆查找
- 初始化一个小顶堆,其堆顶元素最小。
- 先将数组的前 k 个元素依次入堆。
- 从第 k+1 个元素开始,若当前元素大于堆顶元素,则将堆顶元素出堆,并将当前元素入堆。
- 遍历完成后,堆中保存的就是最大的 k 个元素。
def top_k_heap(nums: list[int], k: int) -> list[int]:
"""基于堆查找数组中最大的 k 个元素"""
# 初始化小顶堆
heap = []
# 将数组的前 k 个元素入堆
for i in range(k):
heapq.heappush(heap, nums[i])
# 从第 k+1 个元素开始,保持堆的长度为 k
for i in range(k, len(nums)):
# 若当前元素大于堆顶元素,则将堆顶元素出堆、当前元素入堆
if nums[i] > heap[0]:
heapq.heappop(heap)
heapq.heappush(heap, nums[i])
return heap
总共执行了 n 轮入堆和出堆,堆的最大长度为 k ,因此时间复杂度为 O(nlogk) 。该方法的效率很高,当 k 较小时,时间复杂度趋向 O(n) ;当 k 较大时,时间复杂度不会超过 O(nlogn) 。
另外,该方法适用于动态数据流的使用场景。在不断加入数据时,我们可以持续维护堆内的元素,从而实现最大的 k 个元素的动态更新。
3.4. 复杂度分析
| 遍历查找 | 排序查找 | 建堆查找 |
|---|---|---|
4. 小结:
- [[#1. 堆|堆]] 是一棵完全二叉树,根据成立条件可分为大顶堆和小顶堆。大(小)顶堆的堆顶元素是最大(小)的。
- 优先队列的定义是具有出队优先级的队列,通常使用堆来实现。
- 堆的[[#1.2. 堆的基本操作|基本操作]] 及其对应的时间复杂度包括:元素入堆 O(logn)、堆顶元素出堆 O(logn) 和访问堆顶元素 O(1) 等。
- 完全二叉树非常适合用数组表示,因此我们通常使用数组来[[#1.3.1. 堆的存储与表示|存储堆]]。
- 堆化操作用于维护堆的性质,在入堆和出堆操作中都会用到。
- 输入 n 个元素并[[#2. 建堆操作|建堆]]的时间复杂度可以优化至 O(n) ,非常高效。
- Top-k 是一个经典算法问题,可以使用堆数据结构高效解决,时间复杂度为 O(nlogk) 。