本博客是
GP_堆与TopK/01--09 节的总复习 :以三板斧模板 + TopK 必答对照表 为总纲,串起手写堆、大顶堆模拟、快选 vs 堆、多路归并、双堆中位数。学完整册后用它串一遍;面试前刷它收尾。
对应代码练习在
01_认识堆/...09_数据流的中位数/各目录的practice.py。每个知识点总结后面附完整实现 (纯函数,不含测试代码)。
05、06、09 三节的练习都不止一个解法------本文对它们做了专门的对比总结(含我在 practice 里真实踩过的坑)。
0. 先立骨架:9 个文件夹是怎么互相长出来的
01 认识堆(数组即树 / 下标公式)
└── 02 堆操作心法(两板斧 sift_up / sift_down + heapify)
├── 03 手写小顶堆(MinHeap 类,防身)
├── 04 最后一块石头(heapq 存负弹正 = 大顶)
│ ├── 05 数组第K大(K守门员堆 vs 快选 vs 全排序)──┐
│ └── 06 前K高频(Counter+K堆 vs 桶排序) ├ TopK 主线
├── 07 合并K个有序链表(多路归并 + tuple 垫下标) │
├── 08 丑数II(候选池堆 + seen vs DP 三指针) │
└── 09 数据流中位数(双堆分工,压轴) ──────────────────┘
三条贯穿全册的主线:
| 主线 | 一句话 | 用在哪 |
|---|---|---|
| 三步心法 | 定堆型(谁在堆顶)→ 算下标(父子公式)→ 调堆序(sift 两板斧) | 每题 |
| K 大小的堆方向 | 求"最大 K 个"用小顶(堆顶=第 K 大守门员);求"最小 K 个/反复取最大"用大顶 | 04--06 |
| TopK 取舍 | 静态数组快选平均 O(n);流式/海量数据 K 堆 O(n log k) | 05 的必答 |
★ 总纲:三板斧模板、heapq 速记与 TopK 必答表
这一节是全篇核心。堆题的一切代码都从两板斧 长出来;堆题的一切选择题都查 TopK 必答表。
三板斧(小顶堆版,手写必备)
python
def sift_up(arr, i):
"""尾部新元素上浮:和父比,比父小就换。O(log n)。"""
while i > 0:
p = (i - 1) // 2
if arr[i] < arr[p]:
arr[i], arr[p] = arr[p], arr[i]
i = p
else:
break # 父已经 <= 我,上面更小,停
def sift_down(arr, i, size):
"""i 位置元素下沉:找两个孩子里较小的 m,比孩子大就换。O(log n)。"""
while True:
l, r, m = 2 * i + 1, 2 * i + 2, i
if l < size and arr[l] < arr[m]:
m = l
if r < size and arr[r] < arr[m]:
m = r
if m == i:
break # 我 <= 两个孩子,堆序已恢复
arr[i], arr[m] = arr[m], arr[i]
i = m # ★ 下沉到新位置,继续
def heapify(arr):
"""自底向上建堆:从最后一个非叶子倒着 sift_down。O(n)。"""
for i in range(len(arr) // 2 - 1, -1, -1):
sift_down(arr, i, len(arr))
三个"为什么"(面试必问):
- sift_down 认"较小的孩子":跟大的换完可能还比另一个孩子大,堆序没恢复;
- 弹堆顶 = 换尾 + pop + sift_down(0) :
pop(0)是 O(n) 且断序; - heapify O(n):一半节点是叶子(0 次操作),1/4 只沉 1 层...... Σ(高度×节点数) 收敛到 O(n),不是 O(n log n)。
heapq 速记(Python 刷题用)
python
# heapq.heapify(a) 原地变小顶堆
# heapq.heappush(a, x) O(log n)
# heapq.heappop(a) 弹最小,O(log n)
# heapq.nlargest(k, a) TopK 一行版(内部就是 K 堆)
# heapq.nsmallest(k, a) 前 k 小
#
# 大顶堆:存负弹正 heap = [-x for x in nums]; 堆顶 = -heap[0]
# tuple 防比较崩:值相等时 heapq 逐位比 → 垫一个下标 (val, i, obj)
TopK 必答对照表(背下来,05 必考)
| 解法 | 时间 | 空间 | 场景 |
|---|---|---|---|
| 全排序 | O(n log n) | O(1) | 保底,无亮点 |
| K 大小的堆 | O(n log k) | O(k) | 数据流 / 海量数据(全集进不了内存时唯一可行) |
| 快速选择 | 平均 O(n),最坏 O(n²) | O(1) | 静态数组、一次性查询 |
一句话答法:"静态数组用快选平均 O(n);数据流或数据大到内存放不下,用 K 大小的堆 O(n log k)。"
01 · 认识堆
一句话本质 :数组看成完全二叉树,下标就是路标------堆顶永远是最值,拿最值 O(1),进出 O(log n)。
数组 [1, 3, 2, 6, 5, 4]
下标 0 1 2 3 4 5
0:1
/ \
1:3 2:2
/ \ /
3:6 4:5 5:4
要点:
- 下标公式(地基,先默写):parent = (i-1)//2,children = 2i+1 / 2i+2。
- 堆序只管纵向(父 <= 子),兄弟无关------第 2 小不一定是 arr1,可能在 arr2。
- 堆 vs BST:BST 横向有序(查找快);堆只承诺堆顶最值------所以堆不能查找任意值,扬长避短全是 TopK 主场。
公式:peek O(1) / push·pop O(log n) / heapify O(n)。
完整实现
python
def parent(i):
return (i - 1) // 2
def children(i, size):
return [c for c in (2 * i + 1, 2 * i + 2) if c < size]
def is_min_heap(arr):
"""体检:每个父 <= 自己的孩子(兄弟之间不管)。"""
for i in range(1, len(arr)):
if arr[parent(i)] > arr[i]:
return False
return True
02 · 堆操作心法
一句话本质 :堆只有两个基本动作------插入从尾巴进再上浮 (sift_up),弹顶先换尾再下沉(sift_down);push/pop/heapify 全是组装。
要点:
- sift_up 和父比:新元素必须加在数组末尾(保持完全二叉树形状),一路换到"父 <= 我"。
- sift_down 和较小的孩子比:弹顶后新堆顶可能两头都大,跟较小的换才保证换完两边都服。
- heapify 从
n//2 - 1倒着来:先把每棵子树摆好,父下沉时落到的已是合法堆。 - 弹空序列 = 升序------堆每次弹最小,弹完就是堆排序(验证手写堆的标准手法)。
公式:两板斧各 O(log n);heapify O(n)。
完整实现
python
def heappush(arr, x):
"""push = append + sift_up。"""
arr.append(x)
sift_up(arr, len(arr) - 1)
def heappop(arr):
"""pop = 换尾 + pop + sift_down(0)。绝不 pop(0)。"""
arr[0], arr[-1] = arr[-1], arr[0]
v = arr.pop()
sift_down(arr, 0, len(arr))
return v
03 · 手写小顶堆
一句话本质 :02 节三板斧 + 一个 self.data 装进类------没有新知识,练的是"不许用 heapq"时的肌肉记忆。
要点:
- 手写自查三连:① 单元素 push/pop;② 乱序逐个 push 后堆顶必最小;③ 弹空序列升序。
- 大小顶只差比较器 :所有
<反过来就是大顶堆(或值取负存小顶)------这句话面试说出来很加分。 peek是 O(1) 的"只看不拿"。
公式:push/pop O(log n),peek O(1),空间 O(n)。
完整实现
python
class MinHeap:
def __init__(self):
self.data = []
def _sift_up(self, i):
while i > 0:
p = (i - 1) // 2
if self.data[i] < self.data[p]:
self.data[i], self.data[p] = self.data[p], self.data[i]
i = p
else:
break
def _sift_down(self, i):
size = len(self.data)
while True:
l, r, m = 2 * i + 1, 2 * i + 2, i
if l < size and self.data[l] < self.data[m]:
m = l
if r < size and self.data[r] < self.data[m]:
m = r
if m == i:
break
self.data[i], self.data[m] = self.data[m], self.data[i]
i = m
def push(self, x):
self.data.append(x)
self._sift_up(len(self.data) - 1)
def pop(self):
if not self.data:
return None
self.data[0], self.data[-1] = self.data[-1], self.data[0]
v = self.data.pop()
self._sift_down(0)
return v
def peek(self):
return self.data[0] if self.data else None
def __len__(self):
return len(self.data)
04 · 最后一块石头(LC 1046)
一句话本质 :"反复取最大、处理完放回" = 堆模拟 ;Python heapq 只有小顶 → 存负弹正造大顶。
要点:
- 存的时候
-x,取的时候-heap[0]------"存负弹正"四字口诀。 - 收尾一行
return -heap[0] if heap else 0,覆盖"剩一块 / 全碎"两种结局。 - 02/03 的手写堆随时可以替换 heapq------本题代码不变,只换 API。
公式 :n-1 轮相撞 × O(log n) → O(n log n)。
完整实现
python
import heapq
def last_stone_weight(stones):
heap = [-x for x in stones]
heapq.heapify(heap) # 小顶存负 = 大顶堆
while len(heap) > 1:
x = -heapq.heappop(heap) # 最大(存负弹正)
y = -heapq.heappop(heap) # 次大
if x != y:
heapq.heappush(heap, -(x - y))
return -heap[0] if heap else 0
05 · 数组第 K 大(LC 215,必考)------ 三种解法对比
一句话本质 :第 K 大 = 升序第 n−k 位 ;三种解法不是"会一种就行",是讲清取舍------面试官的标准追问就是这张表。
解法一:K 大小的"小顶"堆(本模块主角)
方向是命门:求第 K 大,维护小顶堆------堆里装"目前的前 K 大",堆顶是它们中最小的 = 第 K 大守门员:
nums = [3, 2, 1, 5, 6, 4] k = 2
进 3 → 堆 {3} 堆顶 3
进 2 → 堆 {2,3} 堆顶 2(目前的第 2 大)
进 1 → 1 < 堆顶 2?不配进前 2 → 无事发生
进 5 → 5 > 2 → 进堆踢掉 2 → {3,5}
进 6 → 6 > 3 → {5,6}
进 4 → 4 < 5 → 无事发生
答案 = 堆顶 5 ✓
python
def find_kth_largest_heap(nums, k):
heap = []
for x in nums:
heapq.heappush(heap, x)
if len(heap) > k:
heapq.heappop(heap) # 踢掉最小的,前 K 之外的不要
return heap[0]
解法二:快速选择(静态数组理论最优)
partition 把 pivot 放到最终位置 ;pivot 恰在 n−k 就收工,否则只递归包含 target 的那一侧------这就是它和快排的全部区别(只走一条路)。
python
def find_kth_largest_quick(nums, k):
arr = list(nums)
target = len(arr) - k # 第 k 大 = 升序第 n-k 位
lo, hi = 0, len(arr) - 1
while lo < hi:
r = random.randint(lo, hi) # 随机 pivot 防退化
arr[r], arr[hi] = arr[hi], arr[r]
pivot, i = arr[hi], lo
for j in range(lo, hi): # Lomuto:小的甩左边
if arr[j] < pivot:
arr[i], arr[j] = arr[j], arr[i]
i += 1
arr[i], arr[hi] = arr[hi], arr[i] # pivot 归位 i
if i == target:
return arr[i]
if i < target:
lo = i + 1 # ★ 跳过整段(i 左侧全部出局)
else:
hi = i - 1 # ★ 跳过整段
return arr[lo]
平均 O(n) 的论证(必背):随机选 pivot 每次期望扔掉一半,n + n/2 + n/4 + ... = 2n → O(n);最坏(每次只扔一个)O(n²),随机化后概率极低。
解法三:全排序(保底)
python
def find_kth_largest_sort(nums, k):
return sorted(nums, reverse=True)[k - 1]
三解法对比表(面试照这张说)
| K 堆 | 快选 | 全排序 | |
|---|---|---|---|
| 时间 | O(n log k) | 平均 O(n) / 最坏 O(n²) | O(n log n) |
| 空间 | O(k) | O(1) | O(1)~O(n) |
| 数据流/海量 | ✓ 唯一可行 | ✗(需要随机访问全量) | ✗ |
| 静态数组 | n·log k(k 小≈n) | 理论最优 | 平庸 |
进阶版 :LC 719(找出第 k 小的距离对)= 快选 + 答案空间二分(二分"距离"而非下标,check 用双指针滑动窗口);LC 410(分割数组最大值)同模子。
06 · 前 K 个高频元素(LC 347)------ 两种解法对比
一句话本质 :比较的键从"数值"换成"频率"------先数数(Counter),再 TopK(05 的守门员堆原样搬)。
解法一:Counter + K 守门员堆
堆里放 (频率, 元素) 的 tuple,堆顶 = "第 K 高频率"守门员------和 05 唯一的区别是比较键:
python
def top_k_frequent(nums, k):
counter = Counter(nums)
heap = []
for num, cnt in counter.items():
heapq.heappush(heap, (cnt, num)) # tuple 先比频率
if len(heap) > k:
heapq.heappop(heap) # 踢掉频率最低的
return [num for _, num in heap]
命门预告 :tuple 按字典序比较,cnt 相同会去比 num(都是数字不崩);等堆里放不可比较对象(ListNode)时必须垫下标------07 节的著名坑。
解法二:桶排序(静态最优 O(n))
频率的上界是 n → 开 n+1 个桶,桶 i 装"出现 i 次"的元素,从后往前收集:
python
def top_k_frequent_bucket(nums, k):
counter = Counter(nums)
buckets = [[] for _ in range(len(nums) + 1)]
for num, cnt in counter.items():
buckets[cnt].append(num)
out = []
for cnt in range(len(buckets) - 1, 0, -1):
for num in buckets[cnt]:
out.append(num)
if len(out) == k:
return out
return out
两解法对比
| K 堆 | 桶排序 | |
|---|---|---|
| 时间 | O(n + m log k) | O(n)(m = 不同元素数) |
| 前提 | 无 | 频率上界已知(≤ n) |
| 数据流 | ✓ | ✗(要开满桶) |
并列频率注意 :{A:3, B:2, C:2} 取 k=2,B、C 并列第 2------两解法可能选不同的(都合法,LC 347 允许任意顺序)。互验时别拿两版结果直接比,要拿"频率集合"比。
进阶版:LC 692(前 K 个高频单词)加一个"频率相同按字典序"的比较键;数据流版(频率动态变化)要堆 + 懒删除。
07 · 合并 K 个有序链表(LC 23,Hard · 联动题)
一句话本质 :K 条链各自有序 → 全局最小必在某条链的头部 ------K 个链头进小顶堆,弹全局最小接上结果,该链下一个顶进堆。多路归并。
[[1,4,5], [1,3,4], [2,6]]
堆 {1(L1), 1(L2), 2(L3)} → 弹 1(L1),4 顶上 → ... 每个节点进出堆各一次
要点:
- tuple 必须垫下标
(val, i, node):val 相等时 heapq 会去比 node------ListNode 不可比较直接 TypeError。垫个整数 i,永远轮不到 node。 - 链表手法(dummy、尾接)全部来自 GP_链表------本题是链表手法 × 堆调度的合体。
- 替代解:两两分治合并也是 O(N log k)(复用两链 merge);顺序两两合并 O(N·k) 是反面教材。
公式 :N 个节点各进出一次 → O(N log k),k 为链数。
完整实现
python
class ListNode: # 节点定义同 GP_链表
def __init__(self, val=0, next=None):
self.val = val
self.next = next
def merge_k_lists(lists):
heap = []
for i, node in enumerate(lists):
if node:
heapq.heappush(heap, (node.val, i, node)) # 垫 i 防 val 相等比 node
dummy = ListNode()
tail = dummy
while heap:
_, i, node = heapq.heappop(heap)
tail.next = node
tail = node
if node.next:
heapq.heappush(heap, (node.next.val, i, node.next))
return dummy.next
08 · 丑数 II(LC 264)------ 两种解法对比
一句话本质 :下一个丑数 = 已有丑数 ×2/×3/×5 里还没出过的最小值------07 多路归并的"数轴版"。
解法一:候选池小顶堆 + seen
python
def nth_ugly_heap(n):
heap = [1]
seen = {1} # ★ 入堆即登记:每个数最多进堆一次
out = 0
for _ in range(n): # 弹 n 次堆顶就是第 n 个
out = heapq.heappop(heap)
for f in (2, 3, 5):
if out * f not in seen:
seen.add(out * f) # 登记在 push 时!
heapq.heappush(heap, out * f)
return out
命门是 seen 去重的登记时机 :6 = 2×3 = 3×2 有多条生成路径,必须在入堆时登记;登记在"弹出时"会指数爆炸(见下方复盘)。
解法二:DP 三指针(O(n) 更优解)
三条"乘 2/3/5 的生产线",dp[i] = min(dp[p2]*2, dp[p3]*3, dp[p5]*5),谁的产品被选中谁的指针前进:
python
def nth_ugly_dp(n):
dp = [0] * n
dp[0] = 1
p2 = p3 = p5 = 0
for i in range(1, n):
dp[i] = min(dp[p2] * 2, dp[p3] * 3, dp[p5] * 5)
if dp[i] == dp[p2] * 2: # 三个 if 不用 elif:
p2 += 1 # 同时命中(6=2×3=3×2)都前进,重复自动跳过
if dp[i] == dp[p3] * 3:
p3 += 1
if dp[i] == dp[p5] * 5:
p5 += 1
return dp[n - 1]
两解法对比
| 候选池堆 | DP 三指针 | |
|---|---|---|
| 时间 | O(n log n) | O(n) |
| 空间 | O(n)(seen + heap) | O(n)(dp 数组) |
| 泛化性 | ✓ 换质因子集合零改动(LC 313 超级丑数) | ✗ 换因子要加指针 |
翻车复盘:seen 登记时机(我踩的最狠的一个)
我在 practice 里把 seen.add 写在了弹出时------n=10/30/60 结果全对,n=1690 直接超时。根因链条:
- 丑数 6 由 2×3 和 3×2 两条路径生成;弹 2 时 push 了 6,此时 6 还没弹过、不在 pool 里;
- 弹 3 时
6 not in pool仍成立 → 6 第二次入堆; - 重复弹出的 6 又生成一遍 12、18、30......指数级膨胀:
| n | 登记在弹出时(我的错版) | 登记在入堆时(正确) |
|---|---|---|
| 10 | 12 次弹出 | 10 次 |
| 30 | 88 次 | 30 次 |
| 60 | 458 次 | 60 次 |
| 1690 | 超时 | 1690 次,0.0013s |
通用纪律:seen 的登记时机 = 元素第一次进入数据结构的时机(入堆/入队/入候选集),不是它被取出的时机。小数据全对、大数据爆炸------这类 bug 只有压力测试才现形。
09 · 数据流的中位数(LC 295,压轴)------ addNum 两个版本对比
一句话本质 :中位数把数分成低半和高半,各自只需要"边界值"------低半大顶堆(存负)+ 高半小顶堆,中位数 = 两堆顶。
低半(大顶堆 lo) 高半(小顶堆 hi)
1, 2, 3, [5] [7], 8, 9, 10
↑ 堆顶=低半最大 ↑ 堆顶=高半最小
总数奇数:中位数 = lo 的堆顶(约定 lo 多放一个)
总数偶数:中位数 = (lo 堆顶 + hi 堆顶) / 2
两个不变量(所有代码都在守护它们):
- 平衡 :
len(lo) == len(hi)或len(lo) == len(hi) + 1(lo 最多多 1); - 有序 :
max(lo) <= min(hi)。
版本一:三步走"先进门,再分工"(首选,白板不易错)
python
def add_num(x): # 版本一:三步走
heapq.heappush(lo, -x) # 1. 一律先进低半
heapq.heappush(hi, -heapq.heappop(lo)) # 2. 低半的最大搬去高半(保"有序")
if len(hi) > len(lo): # 3. 高半超编,搬回一个(保"平衡")
heapq.heappush(lo, -heapq.heappop(hi))
为什么无脑进再搬好?它不关心 x 属于哪半------三步走完两个不变量自动恢复,代码零 if 分支、白板不易写错。
版本二:if 版"先看门牌再进门"(省一次搬动)
python
def add_num_if(x): # 版本二:if 版
if len(lo) == len(hi): # 偶数个 → 放完 lo 多 1
if hi and x > hi[0]: # x 属于高半:进高半,旧边界挤回低半
heapq.heappush(hi, x)
heapq.heappush(lo, -heapq.heappop(hi))
else: # x 属于低半:直接进
heapq.heappush(lo, -x)
else: # 奇数个 → lo 已多 1,补 hi
if x < -lo[0]: # x 属于低半:进低半,旧边界挤到高半
heapq.heappush(lo, -x)
heapq.heappush(hi, -heapq.heappop(lo))
else: # x 属于高半:直接进
heapq.heappush(hi, x)
两版本对比
| 场景 | 三步走(堆操作数) | if 版(堆操作数) |
|---|---|---|
| x 恰好属于该进的半 | 3(进 lo → 搬 hi → 可能搬回) | 1 |
| x 要跨界 | 3~5 | 3 |
三步走不管 x 多大都"先进 lo 再搬"------x 明明属于 hi 也要绕道;if 版先比大小该进哪半直接进,只有跨界才挤一次旧边界。复杂度都是 O(log n),差别在常数。
面试表演顺序 :先写三步走(保正确)→ 口头提 if 版(显深度)。findMedian 两版完全一样:
python
def find_median():
if len(lo) > len(hi):
return float(-lo[0]) # 奇数个:低半最大
return (-lo[0] + hi[0]) / 2 # 偶数个:两堆顶平均
完整实现(三步走版整装)
python
class MedianFinder:
def __init__(self):
self.lo = [] # 大顶堆(存负数):较小的一半
self.hi = [] # 小顶堆:较大的一半
def add_num(self, x):
heapq.heappush(self.lo, -x)
heapq.heappush(self.hi, -heapq.heappop(self.lo))
if len(self.hi) > len(self.lo):
heapq.heappush(self.lo, -heapq.heappop(self.hi))
def find_median(self):
if len(self.lo) > len(self.hi):
return float(-self.lo[0])
return (-self.lo[0] + self.hi[0]) / 2
进阶版:数据流第 K 大(LC 703)= 05 的 K 堆原样搬;滑动窗口中位数(LC 480)= 双堆 + 懒删除(窗口滑出时堆顶作废不急删,取值时校验下标)。
尾声 · 一页纸收束
| 概念 | 一句话 |
|---|---|
| 下标公式 | parent=(i-1)//2,children=2i+1/2i+2------数组即树 |
| 两板斧 | sift_up 和父比(插入);sift_down 和较小的孩子 比(弹顶),别忘 i = m |
| 弹堆顶 | 换尾 + pop + sift_down(0),绝不 pop(0) |
| heapify | 从 n//2-1 倒着 sift_down,O(n) 不是 O(n log n) |
| 大顶堆 | heapq 存负弹正;手写版所有 < 反过来 |
| TopK 必答 | 静态快选平均 O(n);流式/海量 K 堆 O(n log k);全排序保底 |
| K 堆方向 | 求第 K 大 用小顶(堆顶=守门员)------方向反了退化 O(n log n) |
| tuple 垫下标 | (val, i, obj):值相等时 heapq 逐位比,obj 不可比较就崩 |
| seen 时机 | 登记在入堆时不是弹出时------否则重复入堆指数爆炸 |
| 双堆中位数 | 大顶存低半(负数)+ 小顶存高半;三步走零分支最稳,if 版省一次搬动 |
| 多路归并 | K 个链头进堆,弹全局最小,该链下一个顶上------O(N log k) |
| 桶排序 | 频率上界 n → O(n) 静态最优;数据流还得靠堆 |
一句话讲复杂度:"堆的 push/pop 是 O(log n)------每层最多换一次,树高 log n;heapify 是 O(n)------越往下的节点越多、沉得越浅,级数求和收敛。"
相关阅读(同系列):
GP_链表/博客.md------ 单链表 10 题 · 从反转到归并排序GP_双向链表/博客.md------ 双向链表 · 从两句口诀到 LFU 缓存(含踩坑复盘)GP_二分/博客.md------ 二分 · A/B 两套模板总纲GP_滑动窗口/博客.md------ 滑动窗口 · 三套模板与镜像口诀