目录
- 引言与背景
- 堆与优先队列核心思想
- 2.1 堆数据结构的基本概念
- 2.2 堆的性质与存储
- 2.3 建堆过程的时间复杂度分析
- 2.4 堆的插入与删除操作
- 2.5 优先队列的实现与应用场景
- 2.6 堆排序算法
- 力扣hot100堆与优先队列题目深度解析
- 3.1 数组中的第K个最大元素(215)
- 3.2 前K个高频元素(347)
- 3.3 数据流的中位数(295)
- 3.4 合并K个排序链表(23)
- 3.5 天际线问题(218)
- 总结与展望
- 参考文献
1. 引言与背景
堆(Heap)是一种特殊的完全二叉树数据结构,它满足堆性质:对于最大堆,每个节点的值都大于或等于其子节点的值;对于最小堆,每个节点的值都小于或等于其子节点的值。堆常用于实现优先队列(Priority Queue),这是一种能够高效获取最高(或最低)优先级元素的数据结构。
在算法面试中,堆与优先队列是高频考点。力扣(LeetCode)hot100中有多道题目涉及这一知识点,掌握堆的原理与应用能够显著提升解题效率。本专题将系统讲解堆与优先队列的核心思想,并选取5道经典题目进行深度解析,面向27届求职者提供面试准备指南。
2. 堆与优先队列核心思想
2.1 堆数据结构的基本概念
堆是一种完全二叉树,通常使用数组存储。给定节点索引 i i i(从0开始),其父节点索引为 ⌊ ( i − 1 ) / 2 ⌋ \lfloor (i-1)/2 \rfloor ⌊(i−1)/2⌋,左子节点索引为 2 i + 1 2i+1 2i+1,右子节点索引为 2 i + 2 2i+2 2i+2。
堆分为两种类型:
- 最大堆 :父节点的值大于或等于子节点的值,即 ∀ i , A [ parent ( i ) ] ≥ A [ i ] \forall i, A[\text{parent}(i)] \ge A[i] ∀i,A[parent(i)]≥A[i]。
- 最小堆 :父节点的值小于或等于子节点的值,即 ∀ i , A [ parent ( i ) ] ≤ A [ i ] \forall i, A[\text{parent}(i)] \le A[i] ∀i,A[parent(i)]≤A[i]。
堆的根节点(索引0)是最大(或最小)元素。
2.2 堆的性质与存储
由于堆是完全二叉树,可以用数组紧凑存储,无需指针。设堆的大小为 n n n,则数组下标 0 0 0 到 n − 1 n-1 n−1 对应树的层次遍历顺序。
存储示例 :
对于最大堆 [9, 5, 8, 2, 3, 7, 6],其树形结构为:
9
/ \
5 8
/ \ / \
2 3 7 6
注:数组存储节省内存且访问速度快,但需要确保数组大小足够,动态扩容可能涉及 O ( n ) O(n) O(n) 开销。
2.3 建堆过程的时间复杂度分析
将无序数组构建成堆有两种方法:自顶向下插入法和自底向上堆化法。
自顶向下插入法 :从空堆开始,依次插入每个元素,每次插入后向上调整(sift-up)。插入 n n n 个元素的总时间复杂度为 O ( n log n ) O(n \log n) O(nlogn)。
自底向上堆化法(Floyd建堆算法) :从最后一个非叶子节点开始,向前遍历并对每个节点执行向下调整(sift-down)。时间复杂度为 O ( n ) O(n) O(n)。
Floyd建堆算法步骤:
- 找到最后一个非叶子节点索引: ⌊ n / 2 ⌋ − 1 \lfloor n/2 \rfloor - 1 ⌊n/2⌋−1。
- 从该索引向前遍历到根节点(索引0)。
- 对每个节点执行向下调整,确保以该节点为根的子树满足堆性质。
时间复杂度推导:
设树高 h = ⌊ log 2 n ⌋ h = \lfloor \log_2 n \rfloor h=⌊log2n⌋。第 i i i 层有最多 2 i 2^i 2i 个节点,每个节点向下调整最多需要 h − i h-i h−i 次交换。总操作次数:
∑ i = 0 h 2 i ( h − i ) = O ( n ) \sum_{i=0}^{h} 2^i (h-i) = O(n) i=0∑h2i(h−i)=O(n)
具体计算可得精确上界 2 n 2n 2n,因此建堆是线性时间复杂度。
注:面试中经常要求推导建堆时间复杂度,掌握上述推导过程有助于展现分析能力。
2.4 堆的插入与删除操作
插入操作:
- 将新元素追加到数组末尾。
- 向上调整(sift-up):若新元素大于父节点(最大堆),则交换,重复直至满足堆性质。
- 时间复杂度: O ( log n ) O(\log n) O(logn)。
删除堆顶元素:
- 将堆顶元素与末尾元素交换。
- 删除末尾元素(原堆顶)。
- 向下调整(sift-down):从根开始,若当前节点小于子节点(最大堆),则与较大的子节点交换,重复直至满足堆性质。
- 时间复杂度: O ( log n ) O(\log n) O(logn)。
向下调整(sift-down)伪代码(最大堆):
python
def sift_down(arr, i, n):
# arr: 堆数组,i: 当前节点索引,n: 堆大小
while True:
left = 2 * i + 1
right = 2 * i + 2
largest = i
if left < n and arr[left] > arr[largest]:
largest = left
if right < n and arr[right] > arr[largest]:
largest = right
if largest == i:
break
arr[i], arr[largest] = arr[largest], arr[i]
i = largest
2.5 优先队列的实现与应用场景
优先队列是一种抽象数据类型,支持插入元素和提取最高(或最低)优先级元素。堆是实现优先队列的高效数据结构。
操作:
push(item): 插入元素, O ( log n ) O(\log n) O(logn)。pop(): 移除并返回优先级最高的元素, O ( log n ) O(\log n) O(logn)。peek(): 返回优先级最高的元素, O ( 1 ) O(1) O(1)。
应用场景:
- 任务调度:操作系统进程调度(优先级高的先执行)。
- Dijkstra算法:优先队列用于选择当前最短路径节点。
- 哈夫曼编码:优先队列合并最小频率节点。
- 合并K个有序序列:每次取最小元素。
- 实时数据流统计:如中位数、Top K等。
Python中可使用 heapq 模块(最小堆)或自定义最大堆。
注:heapq 仅提供最小堆实现,若要最大堆,可将元素取负后插入。
2.6 堆排序算法
堆排序(Heapsort)是一种基于堆数据结构的比较排序算法。其核心思想是利用堆的性质,逐步将最大(或最小)元素移到数组末尾,最终得到有序序列。
算法步骤:
- 建堆 :将无序数组构建成最大堆, O ( n ) O(n) O(n)。
- 排序 :
- 将堆顶元素(最大值)与末尾元素交换。
- 堆大小减1,对新的堆顶执行向下调整。
- 重复直到堆大小为1。
- 时间复杂度: O ( n log n ) O(n \log n) O(nlogn),空间复杂度: O ( 1 ) O(1) O(1)(原地排序)。
Python代码实现:
python
def heap_sort(arr):
n = len(arr)
# 建堆
for i in range(n // 2 - 1, -1, -1):
sift_down(arr, i, n)
# 排序
for i in range(n - 1, 0, -1):
arr[0], arr[i] = arr[i], arr[0]
sift_down(arr, 0, i)
return arr
注:堆排序是不稳定排序(相同元素可能交换顺序),但时间复杂度稳定,适合对大规模数据排序。
3. 力扣hot100堆与优先队列题目深度解析
3.1 数组中的第K个最大元素(215)
题目链接 :215. Kth Largest Element in an Array
题目描述 :给定整数数组 nums 和整数 k,返回数组中第 k 个最大的元素。请注意,你需要找的是数组排序后的第 k 个最大的元素,而不是第 k 个不同的元素。
示例:
输入: nums = [3,2,1,5,6,4], k = 2
输出: 5
核心解题思路:
-
最小堆维护Top K:
- 维护一个大小为
k的最小堆。 - 遍历数组,当堆大小小于
k时直接插入;否则,若当前元素大于堆顶,则替换堆顶并调整。 - 遍历完成后,堆顶即为第
k个最大元素。 - 时间复杂度: O ( n log k ) O(n \log k) O(nlogk),空间复杂度: O ( k ) O(k) O(k)。
- 维护一个大小为
-
快速选择算法(Quickselect):
- 基于快速排序的分区思想,每次选取一个枢轴,将数组分为左右两部分。
- 根据枢轴位置与
k的关系,递归在相应分区查找。 - 平均时间复杂度: O ( n ) O(n) O(n),最坏 O ( n 2 ) O(n^2) O(n2),空间复杂度: O ( 1 ) O(1) O(1)。
Python代码实现(最小堆解法):
python
import heapq
def findKthLargest(nums, k):
"""
:type nums: List[int]
:type k: int
:rtype: int
"""
min_heap = []
for num in nums:
if len(min_heap) < k:
heapq.heappush(min_heap, num)
else:
if num > min_heap[0]:
heapq.heapreplace(min_heap, num)
return min_heap[0]
注:heapq.heapreplace 相当于先 pop 后 push,但更高效。
时间复杂度分析:
- 建堆过程: O ( k ) O(k) O(k)(初始化大小为
k的堆)。 - 遍历 n − k n-k n−k 个元素,每次堆操作 O ( log k ) O(\log k) O(logk),总 O ( n log k ) O(n \log k) O(nlogk)。
面试考点与注意事项:
- 与排序解法( O ( n log n ) O(n \log n) O(nlogn))对比,堆解法在 k k k 远小于 n n n 时更优。
- 快速选择算法更优但实现稍复杂,需注意随机化枢轴以避免最坏情况。
- 若要求第
k个最小元素,可使用最大堆或最小堆取负。
3.2 前K个高频元素(347)
题目链接 :347. Top K Frequent Elements
题目描述 :给定一个非空的整数数组,返回其中出现频率前 k 高的元素。
示例:
输入: nums = [1,1,1,2,2,3], k = 2
输出: [1,2]
核心解题思路:
-
哈希表统计频率 :遍历数组,使用哈希表记录每个元素的出现次数, O ( n ) O(n) O(n)。
-
优先队列求Top K:
- 将(频率,元素)对放入最小堆(按频率排序)。
- 维护堆大小为
k,当堆满且当前频率大于堆顶频率时,替换堆顶。 - 最终堆中元素即为前
k个高频元素。 - 时间复杂度: O ( n log k ) O(n \log k) O(nlogk)。
-
桶排序优化:
- 创建大小为
n+1的桶数组,索引为频率,值为该频率的元素列表。 - 将频率统计后放入对应桶中。
- 从高频率向低频率遍历桶,收集前
k个元素。 - 时间复杂度: O ( n ) O(n) O(n)。
- 创建大小为
Python代码实现(最小堆解法):
python
import heapq
from collections import Counter
def topKFrequent(nums, k):
"""
:type nums: List[int]
:type k: int
:rtype: List[int]
"""
# 统计频率
freq_map = Counter(nums)
# 最小堆维护Top K
min_heap = []
for num, freq in freq_map.items():
if len(min_heap) < k:
heapq.heappush(min_heap, (freq, num))
else:
if freq > min_heap[0][0]:
heapq.heapreplace(min_heap, (freq, num))
# 提取结果
return [num for freq, num in min_heap]
注:Counter 是 collections 提供的频率统计工具,简化代码。
时间复杂度分析:
- 统计频率: O ( n ) O(n) O(n)。
- 堆操作:最坏 O ( n log k ) O(n \log k) O(nlogk),但实际 k k k 较小,效率较高。
面试考点与注意事项:
- 掌握
Counter和heapq的使用。 - 桶排序解法在频率分布均匀时更优,但需要额外 O ( n ) O(n) O(n) 空间。
- 若频率相同,返回任意顺序通常可接受。
3.3 数据流的中位数(295)
题目链接 :295. Find Median from Data Stream
题目描述:设计一个支持以下两种操作的数据结构:
addNum(num):从数据流中添加一个整数到数据结构中。findMedian():返回目前所有元素的中位数。
示例:
addNum(1)
addNum(2)
findMedian() -> 1.5
addNum(3)
findMedian() -> 2
核心解题思路 :
中位数定义:对于有序列表,若长度为奇数,中位数为中间元素;若长度为偶数,中位数为中间两个元素的平均值。
双堆法(Two Heaps):
- 使用两个堆:最大堆
left存储较小的一半,最小堆right存储较大的一半。 - 维护性质:
left的大小等于right的大小,或比right多1。left的最大值 <=right的最小值。
- 插入操作:
- 若两个堆大小相等,先插入
right,再将right的最小值弹出插入left。 - 若
left比right多1,先插入left,再将left的最大值弹出插入right。
- 若两个堆大小相等,先插入
- 查询中位数:
- 若两个堆大小相等,中位数 = (
left最大值 +right最小值) / 2。 - 若
left比right多1,中位数 =left最大值。
- 若两个堆大小相等,中位数 = (
Python代码实现:
python
import heapq
class MedianFinder:
def __init__(self):
# 最大堆(使用负数实现)
self.left = []
# 最小堆
self.right = []
def addNum(self, num: int) -> None:
if len(self.left) == len(self.right):
# 插入right,然后弹出最小值插入left
heapq.heappush(self.right, num)
heapq.heappush(self.left, -heapq.heappop(self.right))
else:
# left比right多1,插入left,然后弹出最大值插入right
heapq.heappush(self.left, -num)
heapq.heappush(self.right, -heapq.heappop(self.left))
def findMedian(self) -> float:
if len(self.left) == len(self.right):
return (-self.left[0] + self.right[0]) / 2
else:
return -self.left[0]
注:Python的 heapq 是最小堆,最大堆通过取负实现。
时间复杂度分析:
addNum: O ( log n ) O(\log n) O(logn)。findMedian: O ( 1 ) O(1) O(1)。
面试考点与注意事项:
- 双堆法的平衡维护是核心,需确保大小关系。
- 注意浮点数除法,Python 3 中
/返回浮点数。 - 扩展:若数据流有删除操作,可使用更复杂的数据结构(如平衡二叉搜索树)。
3.4 合并K个排序链表(23)
题目链接 :23. Merge k Sorted Lists
题目描述:给定一个链表数组,每个链表都已经按升序排列。请将所有链表合并到一个排序链表中,返回其头节点。
示例:
输入: lists = [[1,4,5],[1,3,4],[2,6]]
输出: [1,1,2,3,4,4,5,6]
核心解题思路:
- 逐一合并 :顺序合并两个链表,时间复杂度 O ( k n ) O(k n) O(kn),其中 n n n 为平均链表长度。
- 分治合并 :类似归并排序,两两合并,时间复杂度 O ( n log k ) O(n \log k) O(nlogk)。
- 优先队列(最小堆) :
- 将每个链表的头节点加入最小堆(按节点值排序)。
- 每次弹出堆顶节点,将其加入结果链表,并将该节点的下一个节点(若存在)加入堆。
- 时间复杂度: O ( n log k ) O(n \log k) O(nlogk)。
Python代码实现(优先队列解法):
python
import heapq
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
def mergeKLists(lists):
"""
:type lists: List[ListNode]
:rtype: ListNode
"""
# 最小堆
min_heap = []
# 初始化堆
for i, head in enumerate(lists):
if head:
heapq.heappush(min_heap, (head.val, i, head))
dummy = ListNode(0)
curr = dummy
while min_heap:
val, i, node = heapq.heappop(min_heap)
curr.next = node
curr = curr.next
if node.next:
heapq.heappush(min_heap, (node.next.val, i, node.next))
return dummy.next
注:堆中存储 (val, i, node),其中 i 用于当 val 相同时避免比较 node 对象(Python 3 不支持比较 ListNode)。
时间复杂度分析:
- 每个节点入堆一次,出堆一次,每次堆操作 O ( log k ) O(\log k) O(logk),总 O ( n log k ) O(n \log k) O(nlogk)。
面试考点与注意事项:
- 掌握优先队列合并多个有序序列的模式。
- 注意边界情况:空链表数组、空链表。
- 分治解法同样重要,需掌握递归实现。
3.5 天际线问题(218)
题目链接 :218. The Skyline Problem
题目描述 :给定城市中建筑物的位置和高度,返回这些建筑物形成的天际线。天际线是由关键点组成的列表,关键点格式为 [x, height],表示在 x 处高度变为 height。
示例:
输入: buildings = [[2,9,10],[3,7,15],[5,12,12],[15,20,10],[19,24,8]]
输出: [[2,10],[3,15],[7,12],[12,0],[15,10],[20,8],[24,0]]
核心解题思路 :
扫描线算法(Sweep Line) + 优先队列(最大堆):
- 将每个建筑物拆分为两个事件:起始点
(left, -height)和结束点(right, height)。高度取负用于区分起始和结束,且起始点优先。 - 按
x坐标排序事件,若x相同,起始点优先(高度负值更小),结束点按高度升序。 - 遍历事件:
- 起始点:将高度加入最大堆。
- 结束点:将高度从堆中删除(延迟删除技巧)。
- 维护当前最大高度,若与之前不同,则记录关键点。
Python代码实现:
python
import heapq
from collections import defaultdict
def getSkyline(buildings):
"""
:type buildings: List[List[int]]
:rtype: List[List[int]]
"""
events = []
for left, right, height in buildings:
events.append((left, -height)) # 起始点
events.append((right, height)) # 结束点
# 排序:x升序,起始点优先,结束点按高度升序
events.sort(key=lambda x: (x[0], x[1]))
# 最大堆(使用负数实现)
heap = [0] # 初始地平线高度0
# 延迟删除映射
removed = defaultdict(int)
result = []
prev_max = 0
for x, h in events:
if h < 0: # 起始点
heapq.heappush(heap, h)
else: # 结束点
removed[-h] += 1 # 标记删除
# 清理堆顶已删除元素
while heap and removed.get(heap[0], 0) > 0:
removed[heap[0]] -= 1
if removed[heap[0]] == 0:
del removed[heap[0]]
heapq.heappop(heap)
# 当前最大高度(取负)
curr_max = -heap[0]
if curr_max != prev_max:
result.append([x, curr_max])
prev_max = curr_max
return result
注:延迟删除技巧避免直接从堆中删除非堆顶元素,通过哈希表记录待删除次数。
时间复杂度分析:
- 排序: O ( n log n ) O(n \log n) O(nlogn)。
- 堆操作:每个事件 O ( log n ) O(\log n) O(logn),总 O ( n log n ) O(n \log n) O(nlogn)。
4. 总结与展望
堆与优先队列是算法设计与面试中的核心工具。本专题系统讲解了堆的性质、建堆时间复杂度、优先队列的实现,并通过5道力扣hot100题目展示了其应用。
核心要点回顾:
- 堆是完全二叉树,数组存储,支持 O ( log n ) O(\log n) O(logn) 插入删除, O ( 1 ) O(1) O(1) 获取最值。
- 建堆时间复杂度为 O ( n ) O(n) O(n),可用Floyd算法。
- 优先队列适用于任务调度、最短路径、Top K、中位数等问题。
- 掌握最小堆/最大堆的转换技巧(Python中取负)。
面试策略:
- 识别问题是否涉及"动态求最值"、"维护有序性"等特征,考虑使用堆。
- 注意时间复杂度和空间复杂度的权衡,例如Top K问题中 k k k 较小时用堆, k k k 接近 n n n 时用排序。
- 熟练使用
heapq模块,并理解其最小堆特性。
扩展学习方向:
- 斐波那契堆:支持更高效的合并操作,适用于某些图算法。
- 左倾堆、斜堆:可合并堆的变种。
- 堆在操作系统中的应用:内存分配、进程调度。
- 堆排序 :基于堆的排序算法,时间复杂度 O ( n log n ) O(n \log n) O(nlogn)。