📊 堆 — TopK问题与中位数维护

用堆这种神奇的数据结构,在海量数据中瞬间找到你要的答案

📖 堆的核心概念

堆(Heap)是一种特殊的完全二叉树,满足堆性质:每个节点的值都≥(或≤)其子节点的值。堆是优先队列的底层实现,也是TopK问题、中位数维护等场景的最优解法。Python中heapq模块默认实现小顶堆。

堆的结构与性质: 小顶堆 (Min-Heap) 大顶堆 (Max-Heap) 1 9 / \ / \ 3 2 7 8 / \ / \ 5 4 3 2 性质: 父 ≤ 子 性质: 父 ≥ 子 根节点 = 最小值 根节点 = 最大值 Python技巧: heapq是小顶堆 大顶堆 → 取负数存入!

1. 堆的底层:数组表示

完全二叉树用数组存储,下标关系非常优雅:

parent(i) = (i-1) // 2   |   left(i) = 2i+1   |   right(i) = 2i+2

这种表示不需要指针,内存连续,缓存友好。这是堆相比链式二叉树的最大优势之一。

2. 堆的核心操作详解

上浮(swim / sift_up):插入元素时,新元素放在数组末尾,逐层与父节点比较并交换,直到满足堆性质。
触发条件:插入新元素
过程:比较当前节点与父节点,若违反堆性质则交换
时间复杂度:O(log n)
下沉(sink / sift_down):删除堆顶时,将末尾元素移到堆顶,逐层与较小(大顶堆则较大)子节点交换。
触发条件:删除堆顶元素
过程:比较当前节点与两个子节点,与更"合适"的子节点交换
时间复杂度:O(log n)
建堆(heapify):从无序数组构建堆。
方法一:逐个插入 O(n log n)
方法二:从最后一个非叶节点向前,逐个下沉 O(n) ← 更优!
为什么O(n):底部节点多但下沉少,顶部节点少但下沉多,总和≈n

3. Python heapq 常用操作速查

import heapq

# 创建空堆并插入
heap = []
heapq.heappush(heap, 5)     # O(log n) 插入
heapq.heappush(heap, 3)
heapq.heappush(heap, 1)
print(heap)                   # [1, 5, 3] — 注意不是完全排序

val = heapq.heappop(heap)    # O(log n) 弹出最小值
print(val)                    # 1

# 从列表建堆 O(n)
nums = [5, 3, 1, 4, 2]
heapq.heapify(nums)
print(nums)                   # [1, 2, 3, 5, 4]

# 堆顶不弹出
print(nums[0])                # 1

# 大顶堆:取负数
max_heap = []
heapq.heappush(max_heap, -5)
heapq.heappush(max_heap, -3)
print(-heapq.heappop(max_heap))  # 5

# nsmallest / nlargest
print(heapq.nsmallest(2, [5,3,1,4,2]))  # [1, 2]
print(heapq.nlargest(2, [5,3,1,4,2]))   # [5, 4]

4. 堆 vs 排序:什么时候用堆?

场景排序
只需要TopKO(n log k) ✅O(n log n)
需要全部排序O(n log n)O(n log n) ✅
动态插入+查询最值O(log n) 每次 ✅O(n log n) 每次重排
只需要最大/最小值O(1) 查询 ✅O(1) 查询但修改O(n)

🔬 题目一:数组中的第K个最大元素(LC 215)

给定整数数组 nums 和整数 k,返回数组中第 k 个最大的元素。

思路分析

第K大元素 — 三种解法对比: 解法一: 排序 O(n log n) O(1) 解法二: 小顶堆TopK O(n log k) O(k) ← 推荐 解法三: 快速选择 O(n)平均 O(1) 小顶堆思路: 维护大小为k的小顶堆,堆顶就是第k大 ┌─────────────────┐ │ 遍历每个元素: │ │ 堆未满 → 入堆 │ │ 堆已满 且 >堆顶 → 弹堆顶,入堆 │ │ 堆已满 且 ≤堆顶 → 跳过 │ └─────────────────┘ 示例: [3,2,1,5,6,4], k=2 遍历过程: 3 → 堆[3] 2 → 堆[2,3] (堆满,堆顶2) 1 → 1<2, 跳过 5 → 5>2, 弹2入5, 堆[3,5] 6 → 6>3, 弹3入6, 堆[5,6] 4 → 4<5, 跳过 堆顶5 → 答案!
方法一:小顶堆维护TopK(推荐)
① 维护一个大小为k的小顶堆
② 遍历数组时:堆未满(size < k)→ 直接入堆
③ 堆已满且当前元素 > 堆顶 → 弹出堆顶,当前元素入堆
④ 堆已满且当前元素 ≤ 堆顶 → 跳过
⑤ 最终堆顶就是第k大元素
方法二:快速选择(QuickSelect)
基于快排的partition,每次只递归一侧,平均 O(n),但最坏 O(n²)
面试中一般用堆解法即可,快速选择作为加分项

代码实现

class Solution:
    def findKthLargest(self, nums: List[int], k: int) -> int:
        # 小顶堆维护前k大的元素
        heap = []
        for num in nums:
            heapq.heappush(heap, num)
            if len(heap) > k:
                heapq.heappop(heap)  # 弹出最小的
        return heap[0]  # 堆顶即第k大

# 快速选择版本(进阶)
class Solution:
    def findKthLargest(self, nums: List[int], k: int) -> int:
        k = len(nums) - k  # 第k大 = 第(n-k)小

        def quick_select(l, r):
            pivot = nums[r]
            p = l
            for i in range(l, r):
                if nums[i] <= pivot:
                    nums[p], nums[i] = nums[i], nums[p]
                    p += 1
            nums[p], nums[r] = nums[r], nums[p]

            if p > k:   return quick_select(l, p - 1)
            elif p < k: return quick_select(p + 1, r)
            else:       return nums[p]

        return quick_select(0, len(nums) - 1)

复杂度分析

方法时间复杂度空间复杂度稳定性
小顶堆O(n log k)O(k)稳定 ✅
快速选择O(n) 平均O(1)最坏O(n²)
TopK问题是堆最经典的应用场景。面试中推荐先说堆解法(简单可靠),再提快速选择(更优但实现复杂)。注意k=1时就是最大值,k=n时就是最小值。在实际工程中,堆解法更常用因为可以处理流式数据。

🔬 题目二:数据流的中位数(LC 295)

设计一个支持以下两种操作的数据结构:addNum(num) 添加一个数,findMedian() 返回当前所有数的中位数。

思路分析

双堆求中位数 — 对顶堆: 数据流: 1, 2, 3, 4, 5 ... ┌──────────────┐ ┌──────────────┐ │ 大顶堆 │ │ 小顶堆 │ │ (存较小半) │ │ (存较大半) │ │ │ │ │ │ [3,2,1] │ │ [4,5] │ │ 最大值=3 ←─┼─────┼─→ 最小值=4 │ └──────────────┘ └──────────────┘ 奇数个: 大顶堆多一个,中位数=大顶堆顶 偶数个: 两堆大小相等,中位数=两堆顶平均 添加元素流程: 新元素 → 入大顶堆 → 弹出最大入小顶堆 → 若小顶堆多则弹回 这保证了: 大顶堆所有元素 ≤ 小顶堆所有元素 大顶堆元素数 = 小顶堆 或 = 小顶堆+1
核心策略:对顶堆
① 大顶堆存较小的一半(左侧),小顶堆存较大的一半(右侧)
② 保持大顶堆元素数 = 小顶堆元素数 或 大顶堆元素数 = 小顶堆元素数 + 1
③ 添加元素时:先入大顶堆 → 弹出最大入小顶堆 → 若小顶堆多则弹回大顶堆
④ 中位数:奇数取大顶堆顶,偶数取两顶平均
⑤ 为什么这样添加:保证大顶堆的所有值 ≤ 小顶堆的所有值

代码实现

class MedianFinder:
    def __init__(self):
        self.small = []  # 大顶堆(存负数模拟)
        self.large = []  # 小顶堆

    def addNum(self, num: int) -> None:
        # 先入大顶堆,再弹出最大入小顶堆
        heapq.heappush(self.small, -num)
        heapq.heappush(self.large, -heapq.heappop(self.small))
        # 平衡:保证大顶堆元素 >= 小顶堆
        if len(self.large) > len(self.small):
            heapq.heappush(self.small, -heapq.heappop(self.large))

    def findMedian(self) -> float:
        if len(self.small) > len(self.large):
            return -self.small[0]
        return (-self.small[0] + self.large[0]) / 2

# 使用示例
mf = MedianFinder()
mf.addNum(1)
mf.addNum(2)
print(mf.findMedian())  # 1.5
mf.addNum(3)
print(mf.findMedian())  # 2.0

复杂度分析

addNum: O(log n) findMedian: O(1) 空间: O(n)

Python没有大顶堆!用存负数的方式模拟:heapq.heappush(small, -num),取出时再取负 -self.small[0]。这是面试常见陷阱,忘记取负会导致结果完全错误。

变体练习

滑动窗口中位数(LC 480):在滑动窗口中求中位数。需要支持删除操作,堆不支持高效删除,需要用延迟删除技巧:标记已删除元素,在堆顶时才真正弹出。这是Hard题,但思想很重要。

🔬 题目三:前K个高频元素(LC 347)

给定整数数组 nums 和整数 k,返回出现频率前 k 高的元素。可以按任意顺序返回答案。

思路分析

两步走策略:
① 统计频率:用哈希表统计每个元素出现次数 O(n)
② 维护TopK:用小顶堆按频率排序,只保留前k个高频元素 O(n log k)

进阶:桶排序优化
③ 以频率为下标建桶:bucket[freq] = [元素列表]
④ 从高频率向低频率遍历,收集前k个 O(n)
⑤ 空间换时间:O(n)时间 O(n)空间

代码实现

class Solution:
    def topKFrequent(self, nums: List[int], k: int) -> List[int]:
        # 第一步:统计频率
        count = {}
        for n in nums:
            count[n] = count.get(n, 0) + 1

        # 第二步:小顶堆维护TopK
        heap = []
        for num, freq in count.items():
            heapq.heappush(heap, (freq, num))
            if len(heap) > k:
                heapq.heappop(heap)

        return [num for freq, num in heap]

# 桶排序版本(O(n)时间)
class Solution:
    def topKFrequent(self, nums: List[int], k: int) -> List[int]:
        count = {}
        for n in nums:
            count[n] = count.get(n, 0) + 1

        # 以频率为下标建桶
        buckets = [[] for _ in range(len(nums) + 1)]
        for num, freq in count.items():
            buckets[freq].append(num)

        # 从高到低收集
        result = []
        for i in range(len(buckets) - 1, -1, -1):
            result.extend(buckets[i])
            if len(result) >= k:
                return result[:k]
        return result

复杂度分析

方法时间空间适用场景
O(n log k)O(n)通用,k远小于n时最优
桶排序O(n)O(n)频率范围有限时最优
变体:如果要求频率排序的所有元素(而非TopK),可以直接排序 O(n log n),或用桶排序 O(n)。面试时先写堆版本(最通用),再提桶排序优化。
成就解锁:堆大师 — 掌握TopK问题与中位数维护的核心技巧,能灵活运用小顶堆与大顶堆
LeetCode AC验证:LC 215 Kth Largest Element in an Array ✅ | LC 295 Find Median from Data Stream ✅ | LC 347 Top K Frequent Elements ✅

📝 课后练习

  1. LC 1046 最后一块石头的重量(堆模拟,简单)
  2. LC 373 查找和最小的K对数字(双数组堆归并)
  3. LC 692 前K个高频单词(TopK+字典序)
  4. LC 480 滑动窗口中位数(对顶堆+延迟删除,Hard)
  5. LC 1801 积压订单中的订单总数(双堆协作)

🔑 本课要点回顾

📚 扩展阅读

思考题:如果需要同时支持"查找中位数"和"查找第k小"两种操作,数据结构该如何设计?如何保证两种操作都高效?