堆、优先队列与摊还分析 — 从零精通算法与数据结构——Google 面试系统备战 第6篇

第6章:堆、优先队列与摊还分析

本章目标

读完本章你会:

  • 用数学严格证明建堆是 O(n) 而非 O(n log n)
  • 熟练应用优先队列解决 Top-K 问题
  • 掌握双堆模式(Two Heaps):从数据流中位数到滑动窗口中位数
  • 深入理解三种摊还分析方法:聚合、记账、势能
  • 用摊还分析解释动态数组扩容的 O(1) 摊销代价

知识讲解

从一个生活例子开始

急诊室不是按"先到先看"排队,而是按病情严重程度——这叫优先队列。有人比你晚到但心梗了,他插到你前面是合理的。

优先队列的底层正是——一个能快速找到"最紧急"病人的数据结构:

  • 新病人来了 → 插入 O(log n)
  • 最紧急的病人被叫走 → 取出最大/最小值 O(1) 查询 + O(log n) 删除
  • 病人的病情变化了 → 更新优先级 O(log n)

如果没有堆,你只能给所有病人排序——排序是 O(n log n),而堆的每次操作只是 O(log n)。这就是堆存在的意义。

工作原理

6.1 堆的数组表示

堆 = 完全二叉树 + 堆性质(父 ≥ 子 → 最大堆, 或父 ≤ 子 → 最小堆)。

数组索引技巧:

        16 (索引 0)
       /          \
     14 (1)      10 (2)
    /    \       /    \
   8(3)  7(4)  9(5)  3(6)
  /  \
 2(7) 4(8)

规则:
- 父 → 左子: 2i + 1
- 父 → 右子: 2i + 2
- 子 → 父:  (i - 1) / 2

不需要指针!数组就能完整表示堆——这是堆最大的工程优势。

6.2 建堆 O(n) 的严格证明

这是算法面试中最爱追问的理论点之一。直觉上逐个插入建堆是 O(n log n),但自底向上 heapify 是 O(n)。为什么?

自底向上建堆法:

BUILD-HEAP(A):
  for i = ⌊n/2⌋ - 1 downto 0:    // 从最后一个非叶节点开始
    SIFT-DOWN(A, i)               // 下沉修复以 i 为根的子树

关键洞察:下沉的代价与节点高度相关,而非深度。

树结构分析(n 个节点的完全二叉树):
          
            ●          ← 高度 h = ⌊log₂ n⌋,1 个节点
          /   \
         ●     ●       ← 高度 h-1,2 个节点
        / \   / \
       ●  ●  ●  ●      ← 高度 h-2,4 个节点
      / \  ...
     ●  ●  ...         ← 高度 0 = 叶子,约 n/2 个节点(免费!)

每个节点下沉的代价 = O(该节点的高度)。

总代价 = Σ(各层节点数 × 该层高度)

高度 0: n/2 个节点 × 0 步 = 0
高度 1: n/4 个节点 × 1 步 = n/4
高度 2: n/8 个节点 × 2 步 = 2n/8
高度 3: n/16 个节点 × 3 步 = 3n/16
...
高度 h: 1 个节点 × h 步 = h

总和 = n · Σ_{k=0}^{h} (k / 2^{k+1})
     < n · Σ_{k=0}^{∞} (k / 2^{k+1})
     = n · 1
     = O(n)

因为无穷级数 Σ k/2^{k+1} = 1(等比数列的变体——结果是个常数)。

这就是建堆 O(n) 的完整证明。 面试中能讲清楚这个结论 = 你对数据结构的理解已经达到 Google 面试的标准。

6.3 Top-K 问题

问题: 在 n 个元素中找最大(或最小)的 k 个。

方案对比:

方案 复杂度 k 较小时 k 接近 n 时
全排序 O(n log n) 浪费 刚好
大小为 k 的最小堆 O(n log k) 高效 退化为全排序
QuickSelect O(n) 期望 高效 高效

最小堆方案(找 Top-K 最大): 维护一个大小为 k 的最小堆。遍历所有元素,比堆顶大就替换堆顶并下沉。复杂度 O(n log k)。

// 找最大的 k 个元素
std::vector<int> TopKLargest(const std::vector<int>& data, int k) {
  if (k <= 0) return {};
  // 大小为 k 的最小堆
  std::priority_queue<int, std::vector<int>, std::greater<int>> min_heap;

  for (int x : data) {
    if (static_cast<int>(min_heap.size()) < k) {
      min_heap.push(x);
    } else if (x > min_heap.top()) {
      min_heap.pop();
      min_heap.push(x);
    }
  }
  // 从堆中取出结果
  std::vector<int> result;
  while (!min_heap.empty()) {
    result.push_back(min_heap.top());
    min_heap.pop();
  }
  return result;
}

6.4 双堆模式:数据流中位数

问题: 数据源源不断到来,需要随时能查询当前所有数据的中位数。

双堆方案:

  • 最大堆(左半):存储较小的一半元素
  • 最小堆(右半):存储较大的一半元素

保持 |左堆| = |右堆||左堆| = |右堆| + 1

中位数 = 左堆堆顶(奇数个)或 (左堆顶 + 右堆顶)/2(偶数个)。

插入逻辑:

插入 x:
  if 左堆空 或 x ≤ 左堆顶:
    左堆.push(x)
  else:
    右堆.push(x)
  
  // 平衡:确保左堆不少于右堆,且两堆大小差距 ≤ 1
  if 左堆.size() > 右堆.size() + 1:
    右堆.push(左堆.pop())
  else if 右堆.size() > 左堆.size():
    左堆.push(右堆.pop())

每次插入 O(log n),查询中位数 O(1)。

6.5 摊还分析三方法

摊还分析回答:"一系列操作的总代价是多少?" 有三种经典方法:

1. 聚合分析: 直接计算序列总代价 ÷ 操作数。

例:动态数组 n 次 push_back。扩容只发生在元素数 = 2ᵏ 时。总拷贝次数 = 1 + 2 + 4 + ... + 2^{⌈log n⌉} < 2n。每次操作的摊还代价 = 2n / n = O(1)。

2. 记账法: 给每次操作预收"假想的费用",贵的操作使用之前攒的余额支付。

例:push_back 记账 3 元——1 元用于本次插入,1 元用于自己将来被拷贝,1 元用于提前帮另一个元素付拷贝费。每次扩容时,拷贝费已被之前存的余额覆盖。

3. 势能法: 定义一个"势能函数" Φ,使得:

  • 廉价操作增加 Φ(存能量)
  • 昂贵操作消耗 Φ(用能量)

摊还代价 = 实际代价 + ΔΦ。


代码实战

include/algo/heap.h(添加到 algo_toolkit)

#ifndef ALGO_HEAP_H_
#define ALGO_HEAP_H_

#include <cstddef>
#include <cstdint>
#include <functional>
#include <queue>
#include <stdexcept>
#include <vector>

namespace algo {

// ========== 动态中位数查找器(双堆模式) ==========

class MedianFinder {
 public:
  // 添加一个数 O(log n)
  void AddNum(int num);

  // 查询当前中位数 O(1)
  double FindMedian() const;

  std::size_t size() const { return max_heap_.size() + min_heap_.size(); }

 private:
  void Rebalance();

  // 左半:最大堆(存较小的一半)
  std::priority_queue<int> max_heap_;
  // 右半:最小堆(存较大的一半)
  std::priority_queue<int, std::vector<int>, std::greater<int>> min_heap_;
};

// ========== Top-K 工具 ==========

// 返回 data 中最大的 k 个元素(不保证有序)
std::vector<int> TopKLargest(const std::vector<int>& data, int k);

// 返回 data 中最小的 k 个元素(不保证有序)
std::vector<int> TopKSmallest(const std::vector<int>& data, int k);

}  // namespace algo

#endif  // ALGO_HEAP_H_

include/algo/heap_impl.h

#ifndef ALGO_HEAP_IMPL_H_
#define ALGO_HEAP_IMPL_H_

namespace algo {

inline void MedianFinder::AddNum(int num) {
  if (max_heap_.empty() || num <= max_heap_.top()) {
    max_heap_.push(num);
  } else {
    min_heap_.push(num);
  }
  Rebalance();
}

inline double MedianFinder::FindMedian() const {
  if (max_heap_.empty() && min_heap_.empty()) {
    throw std::runtime_error("MedianFinder is empty");
  }
  if (max_heap_.size() > min_heap_.size()) {
    return max_heap_.top();
  }
  return (max_heap_.top() + min_heap_.top()) / 2.0;
}

inline void MedianFinder::Rebalance() {
  if (max_heap_.size() > min_heap_.size() + 1) {
    min_heap_.push(max_heap_.top());
    max_heap_.pop();
  } else if (min_heap_.size() > max_heap_.size()) {
    max_heap_.push(min_heap_.top());
    min_heap_.pop();
  }
}

inline std::vector<int> TopKLargest(const std::vector<int>& data, int k) {
  if (k <= 0 || data.empty()) return {};
  std::priority_queue<int, std::vector<int>, std::greater<int>> min_heap;
  for (int x : data) {
    if (static_cast<int>(min_heap.size()) < k) {
      min_heap.push(x);
    } else if (x > min_heap.top()) {
      min_heap.pop();
      min_heap.push(x);
    }
  }
  std::vector<int> result;
  result.reserve(k);
  while (!min_heap.empty()) {
    result.push_back(min_heap.top());
    min_heap.pop();
  }
  return result;
}

inline std::vector<int> TopKSmallest(const std::vector<int>& data, int k) {
  if (k <= 0 || data.empty()) return {};
  std::priority_queue<int> max_heap;
  for (int x : data) {
    if (static_cast<int>(max_heap.size()) < k) {
      max_heap.push(x);
    } else if (x < max_heap.top()) {
      max_heap.pop();
      max_heap.push(x);
    }
  }
  std::vector<int> result;
  result.reserve(k);
  while (!max_heap.empty()) {
    result.push_back(max_heap.top());
    max_heap.pop();
  }
  return result;
}

}  // namespace algo

#endif  // ALGO_HEAP_IMPL_H_

本章小结

  1. 堆用数组表示完全二叉树,索引关系:左子 2i+1,右子 2i+2,父 (i-1)/2
  2. 自底向上建堆是 O(n),因为大部分节点(叶子)不需要下沉
  3. Top-K 用大小为 k 的最小堆 → O(n log k),远好于全排序 O(n log n)
  4. 双堆模式解决数据流中位数——左半最大堆 + 右半最小堆
  5. 摊还分析三种方法:聚合(算总账)、记账(预收费)、势能(能量函数)

关键术语

术语 释义
完全二叉树 除最后一层外每层都满,且最后一层左对齐
下沉(sift-down) 将违反堆性质的节点向下调整,直到符合堆性质
Top-K 在海量数据中找到最大(或最小)的 k 个元素
双堆模式 用最大堆+最小堆分别存储数据的两半,实现快速中位数查询
摊还分析 分析一系列操作的平均代价,而非单次的最高代价
posted @ 2026-06-22 01:09  Yobeeo  阅读(5)  评论(0)    收藏  举报