Priority Queue and Heap
普通队列按照到达顺序服务。
优先队列按照优先级服务。
典型场景:
Linux 调度器要在就绪任务中选择下一项
Kafka 延迟、重试和时间驱动事件处理
PostgreSQL Top-N、排序和查询执行计划
C++ STL
std::priority_queue 封装堆操作
最大优先队列:
| 操作 | 含义 |
|---|---|
create() |
创建空优先队列 |
size() |
返回元素个数 |
max() |
返回最大优先级元素 |
insert(x) |
插入元素 |
deleteMax() |
删除并返回最大优先级元素 |
最小优先队列把 max/deleteMax 换成 min/deleteMin。
| 表示 | 插入 | 查找最大 | 删除最大 |
|---|---|---|---|
| 无序线性表 | \(\Theta(1)\) | \(\Theta(n)\) | \(\Theta(n)\) |
| 有序线性表 | \(\Theta(n)\) | \(\Theta(1)\) | \(\Theta(1)\) 或 \(\Theta(n)\) |
| 二叉堆 | \(O(\log n)\) | \(O(1)\) | \(O(\log n)\) |
堆在插入和删除之间取得平衡。
最大堆满足:
最小堆满足:
堆不要求左右子树之间有全局有序关系。
根结点一定是最大值。
堆是完全二叉树,适合用数组紧凑存放。
若使用 1-based 下标:
\[ parent(i)=\lfloor i/2\rfloor \]
\[ left(i)=2i,\quad right(i)=2i+1 \]
空出 heap[0] 可以让公式更简单。
template <class T>
class MaxHeap {
public:
explicit MaxHeap(int capacity = 16);
int size() const { return currentSize_; }
bool empty() const { return currentSize_ == 0; }
const T& max() const;
void insert(const T& value);
T deleteMax();
void initialize(std::vector<T> values);
private:
std::vector<T> heap_; // 1-based
int currentSize_ = 0;
};新元素沿父指针向上移动,最多经过树高层。
template <class T>
T MaxHeap<T>::deleteMax() {
if (empty()) throw std::underflow_error("empty heap");
T answer = heap_[1];
T last = heap_[currentSize_--];
int hole = 1;
while (hole * 2 <= currentSize_) {
int child = hole * 2;
if (child != currentSize_ && heap_[child] < heap_[child + 1]) {
++child;
}
if (last >= heap_[child]) break;
heap_[hole] = heap_[child];
hole = child;
}
heap_[hole] = last;
return answer;
}下滤时总是选择更大的孩子。
堆高为:
\[ \lfloor \log_2 n \rfloor \]
因此:
| 操作 | 时间 |
|---|---|
max() |
\(O(1)\) |
insert() |
\(O(\log n)\) |
deleteMax() |
\(O(\log n)\) |
逐个插入 \(n\) 个元素。
每次插入最坏 \(O(\log n)\)。
总时间:
\[ O(n\log n) \]
这种方法简单,但不是最优建堆。
把所有元素先放入数组,再从最后一个内部结点开始下滤:
靠近叶子的结点很多,但下滤距离短。
靠近根的结点少,虽然下滤距离长。
总工作量为:
\[ \sum_{h\ge 0} \frac{n}{2^{h+1}}h = O(n) \]
因此 Floyd 建堆是线性时间。
堆排序流程:
复杂度:
\[ O(n)+nO(\log n)=O(n\log n) \]
堆排序原地,但不稳定。
稳定排序要求相等关键字保持原相对顺序。
堆排序中,元素会沿树路径与远处元素交换。
相同关键字可能跨越彼此,因此堆排序通常不稳定。
方法一:建最大堆。
deleteMax方法二:维护大小为 \(k\) 的最小堆。
当 \(k\ll n\) 时,方法二更合适。
堆顶始终是当前前 \(k\) 大元素中的最小者。
离散事件模拟维护一组未来事件。每次取出时间最早的事件执行,执行过程中又可能产生新的未来事件。
用最小优先队列保存事件,可以把“取下一事件”和“插入新事件”都控制在 \(O(\log n)\)。
| 维度 | 二叉堆 |
|---|---|
| 结构 | 完全二叉树 |
| 存储 | 数组,无指针 |
| 查找堆顶 | \(O(1)\) |
| 插入 | 上滤,\(O(\log n)\) |
| 删除堆顶 | 下滤,\(O(\log n)\) |
| 建堆 | Floyd,\(O(n)\) |
| 随机查找 | 不支持高效查找任意 key |
| 稳定性 | 堆排序不稳定 |
已知序列:
\[ 5,8,12,19,28,20,15,22 \]
是最小堆,插入 \(3\)。
过程:
结果根为 3。
判断数组是否为最大堆:
只需要检查所有内部结点。
堆排序建堆是 \(O(n)\)。
之后执行 \(n-1\) 次删除堆顶,每次 \(O(\log n)\)。
总复杂度:
\[ O(n\log n) \]
额外空间可以做到 \(O(1)\),但排序不稳定。