85 阅读9分钟

www.hello-algo.com/chapter_hea…

1. 堆

堆通常用于实现优先队列,大顶堆相当于元素按从大到小的顺序出队的优先队列。从使用角度来看,我们可以将“优先队列”和“堆”看作等价的数据结构。这样看的话,堆顶相当于有序队列的队首,堆底相当于队列队尾。

1.1. 基本概念

堆(heap)是一种满足特定条件的[[#1.4. 二叉树常见类型|完全二叉树]],主要可分为两种类型,两种类型的区别是根节点值和子节点值的大小关系不同。

image.png

堆的特性:

  • 最底层节点靠左填充,其他层的节点都被填满。
  • 我们将二叉树的根节点称为“堆顶”,将底层最靠右的节点称为“堆底”
  • 对于大顶堆(小顶堆),堆顶元素(根节点)的值是最大(最小)的。

1.2. 堆的基本操作

方法名描述时间复杂度
push()元素入堆O(log⁡n)
pop()堆顶元素出堆O(log⁡n)
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. 堆的存储与表示

完全二叉树非常适合用数组来表示,堆是完全二叉树,因此堆使用数组进行存储。

image.png

"""索引映射公式"""
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)。

从入堆节点开始,从底至顶执行堆化。其本质就是不断比较插入节点与父节点的大小,以大顶堆为例,如果大于的话则交换位置,反复执行操作。

image.png

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. 堆顶出堆

由于堆顶元素为根节点和列表首元素,直接删除的话索引都会发生改变,因此:

  1. 交换堆顶元素与堆底元素(交换根节点与最右叶节点)。
  2. 交换完成后,将堆底从列表中删除(注意,由于已经交换,因此实际上删除的是原来的堆顶元素)。
  3. 从根节点开始,从顶至底执行堆化。即,位置变换操作

image.png

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(log⁡n) ,而建堆操作为 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(nlog⁡n) 。 节点数量:n 叶节点数量:(n+1)/2,/为向下整除 需要堆化的节点数量:(n-1)/2 最大迭代次数(堆顶):logn 时间复杂度:O(nlogn)

2.2. 通过遍历堆化实现

每当堆化一个节点后,以该节点为根节点的子树就形成一个合法的子堆。而由于是倒序遍历,因此堆是“自下而上”构建的。

  1. 将列表所有元素原封不动地添加到堆中,此时堆的性质尚未得到满足。
  2. 倒序遍历堆([[#2.1. 层序遍历|层序遍历]] 的倒序),依次对每个非叶节点执行“从顶至底堆化”。
  3. 在此过程中叶节点无需堆化

image.png

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: image.png

3.2. 排序查找

先对数组进行排序,然后返回最右边的k 个元素 image.png

3.3. 建堆查找

  1. 初始化一个小顶堆,其堆顶元素最小。
  2. 先将数组的前 k 个元素依次入堆。
  3. 从第 k+1 个元素开始,若当前元素大于堆顶元素,则将堆顶元素出堆,并将当前元素入堆。
  4. 遍历完成后,堆中保存的就是最大的 k 个元素。

image.png

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(nlog⁡k) 。该方法的效率很高,当 k 较小时,时间复杂度趋向 O(n) ;当 k 较大时,时间复杂度不会超过 O(nlog⁡n) 。

另外,该方法适用于动态数据流的使用场景。在不断加入数据时,我们可以持续维护堆内的元素,从而实现最大的 k 个元素的动态更新。

3.4. 复杂度分析

遍历查找排序查找建堆查找
O(n2)O(n^2)O(nlogn)O(nlog⁡n)O(nlogk)O(nlog⁡k)

4. 小结:

  • [[#1. 堆|堆]] 是一棵完全二叉树,根据成立条件可分为大顶堆和小顶堆。大(小)顶堆的堆顶元素是最大(小)的。
  • 优先队列的定义是具有出队优先级的队列,通常使用堆来实现。
  • 堆的[[#1.2. 堆的基本操作|基本操作]] 及其对应的时间复杂度包括:元素入堆 O(log⁡n)、堆顶元素出堆 O(log⁡n) 和访问堆顶元素 O(1) 等。
  • 完全二叉树非常适合用数组表示,因此我们通常使用数组来[[#1.3.1. 堆的存储与表示|存储堆]]。
  • 堆化操作用于维护堆的性质,在入堆和出堆操作中都会用到。
  • 输入 n 个元素并[[#2. 建堆操作|建堆]]的时间复杂度可以优化至 O(n) ,非常高效。
  • Top-k 是一个经典算法问题,可以使用堆数据结构高效解决,时间复杂度为 O(nlog⁡k) 。