1. 堆与优先队列基础解析
堆是一种特殊的完全二叉树结构,在算法竞赛中主要应用于高效维护动态数据的极值。堆的核心特性是:每个节点的值都满足与父节点的特定大小关系。大顶堆中父节点值大于等于子节点,小顶堆则相反。
1.1 堆的物理存储与逻辑结构
虽然堆在逻辑上是树形结构,但在实际实现中通常使用数组存储。对于从1开始索引的数组(竞赛中常用):
- 父节点位置:
i/2(整数除法) - 左子节点:
2*i - 右子节点:
2*i + 1
这种存储方式的空间利用率达到100%,且通过简单的算术运算即可快速定位亲属节点。例如在长度为6的数组中:
code复制Index: 1 2 3 4 5 6
Value: 9 7 5 3 2 1
这表示一个大顶堆,其中:
- 节点2和3的父节点是1
- 节点1的子节点是2和3
- 节点4和5的父节点是2
注意:判断子节点是否存在只需检查计算出的索引是否超出数组范围。例如当
2*i > n时说明没有左子节点。
1.2 堆的核心操作原理
堆的核心操作包含两个基本动作:
上浮(Shift Up):
当新元素插入堆底时,需要与其父节点比较,若违反堆性质则交换位置,递归向上直到满足条件。时间复杂度O(logn)。
cpp复制void shiftUp(int i) {
while (i > 1 && heap[i] > heap[i/2]) { // 大顶堆示例
swap(heap[i], heap[i/2]);
i /= 2;
}
}
下沉(Shift Down):
当堆顶元素被移除后,将末尾元素移到堆顶,然后与其较大(大顶堆)的子节点比较,若违反性质则交换,递归向下直到满足条件。时间复杂度O(logn)。
cpp复制void shiftDown(int i) {
while (2*i <= size) {
int child = 2*i;
if (child+1 <= size && heap[child+1] > heap[child])
child++;
if (heap[i] >= heap[child]) break;
swap(heap[i], heap[child]);
i = child;
}
}
1.3 堆排序的实现细节
堆排序利用堆的性质实现原地排序,分为两个阶段:
-
建堆:将无序数组调整为堆结构。有两种方法:
- 自顶向下:逐个插入,每次插入执行上浮,时间复杂度O(nlogn)
- 自底向上:从最后一个非叶节点开始向前做下沉,时间复杂度O(n)
-
排序:反复取出堆顶元素(与末尾交换后下沉),时间复杂度O(nlogn)
cpp复制void heapSort(int arr[], int n) {
// 建堆(自底向上)
for (int i = n/2; i >= 1; i--)
shiftDown(arr, n, i);
// 排序
for (int i = n; i > 1; i--) {
swap(arr[1], arr[i]);
shiftDown(arr, i-1, 1);
}
}
2. STL优先队列深度剖析
2.1 优先队列的模板参数解析
STL中的priority_queue是一个容器适配器,默认使用vector作为底层容器,实现为大顶堆。其完整模板声明为:
cpp复制template <class T, class Container = vector<T>,
class Compare = less<typename Container::value_type>>
class priority_queue;
关键参数:
T:元素类型(如int,pair<int,int>等)Container:底层容器,必须支持随机访问迭代器和front()/push_back()/pop_back()操作Compare:比较器,决定元素优先级。注意其逻辑与sort相反:当comp(a,b)为true时,b的优先级更高
2.2 三种典型定义方式对比
默认大顶堆:
cpp复制priority_queue<int> q1; // 等价于priority_queue<int, vector<int>, less<int>>
标准小顶堆:
cpp复制priority_queue<int, vector<int>, greater<int>> q2;
自定义类型堆:
cpp复制struct Node {
int val, id;
bool operator<(const Node& t) const {
// 多级排序:先val升序,val相同则id降序
return val != t.val ? val > t.val : id < t.id;
}
};
priority_queue<Node> q3;
易错点:自定义比较时,
operator<返回true表示当前元素优先级更低。这与sort的认知相反,需要特别注意。
2.3 优先队列的边界陷阱
使用优先队列时常见的运行时错误:
- 空队列访问:
cpp复制priority_queue<int> q;
cout << q.top(); // 段错误
q.pop(); // 段错误
正确做法:
cpp复制if (!q.empty()) {
cout << q.top();
q.pop();
}
- 内存消耗:
优先队列不会自动释放内存,持续push/pop可能导致内存居高不下。在需要重复使用的场景,建议:
cpp复制q = priority_queue<int>(); // 清空并释放内存
- 复杂度误判:
虽然单次push/pop是O(logn),但嵌套使用时总复杂度可能超出预期。例如:
cpp复制while (condition) {
q.push(compute()); // 如果compute()复杂度高,整体可能超时
}
3. 双堆维护动态中位数技巧
3.1 问题建模与算法选择
中位数问题要求动态维护一个不断增长的数据集的中位数。暴力解法每次排序的复杂度为O(n^2),无法处理大规模数据。双堆法的优势在于:
- 插入操作O(logn)
- 查询操作O(1)
- 空间复杂度O(n)
3.2 双堆法的实现细节
数据结构设计:
big:大顶堆,存储较小的一半数字small:小顶堆,存储较大的一半数字
平衡规则:
- 始终保持
big.size() == small.size()(偶数个元素)
或big.size() == small.size() + 1(奇数个元素) big的所有元素 ≤small的所有元素
操作步骤:
- 插入新元素x:
- 如果x ≤
big.top(),插入big - 否则插入
small
- 如果x ≤
- 调整平衡:
- 如果
big比small多超过1个元素,将big.top()移到small - 如果
small比big多元素,将small.top()移到big
- 如果
cpp复制void addNum(int x) {
if (big.empty() || x <= big.top()) {
big.push(x);
} else {
small.push(x);
}
// 平衡两个堆
if (big.size() > small.size() + 1) {
small.push(big.top());
big.pop();
} else if (small.size() > big.size()) {
big.push(small.top());
small.pop();
}
}
3.3 复杂度分析与优化
- 时间复杂度:每次插入涉及最多3次堆操作(1次插入+2次调整),每次O(logn),总体O(logn)
- 空间优化:可以用
multiset替代堆,但实际运行效率往往更低 - 提前过滤:对于数据流场景,可以记录当前中位数,只处理可能改变中位数的插入
4. 竞赛中的典型应用场景
4.1 贪心算法优化
优先队列常用于需要反复获取极值的贪心策略,例如:
例题:合并果子(洛谷P1090)
每次合并重量最小的两堆果子,直到只剩一堆。直接实现是O(n^2),使用小顶堆可优化到O(nlogn)。
cpp复制priority_queue<int, vector<int>, greater<int>> q;
for (int weight : weights) q.push(weight);
int total = 0;
while (q.size() > 1) {
int a = q.top(); q.pop();
int b = q.top(); q.pop();
total += a + b;
q.push(a + b);
}
4.2 Dijkstra算法优化
优先队列优化Dijkstra算法的关键点:
- 使用小顶堆存储
(distance, node)对 - 每次取出当前距离最小的节点处理
- 当节点的最短距离更新时,将新状态加入队列(允许重复入队)
cpp复制priority_queue<pair<int,int>, vector<pair<int,int>>, greater<pair<int,int>>> pq;
vector<int> dist(n, INF);
dist[start] = 0;
pq.push({0, start});
while (!pq.empty()) {
auto [d, u] = pq.top(); pq.pop();
if (d > dist[u]) continue; // 过时信息跳过
for (auto [v, w] : adj[u]) {
if (dist[v] > dist[u] + w) {
dist[v] = dist[u] + w;
pq.push({dist[v], v});
}
}
}
4.3 维护滑动窗口极值
对于滑动窗口最大值问题,双端队列解法是O(n),但优先队列解法更易实现:
cpp复制vector<int> maxSlidingWindow(vector<int>& nums, int k) {
priority_queue<pair<int,int>> pq;
vector<int> res;
for (int i = 0; i < nums.size(); ++i) {
pq.push({nums[i], i});
if (i >= k-1) {
while (pq.top().second <= i-k) pq.pop(); // 移除窗口外元素
res.push_back(pq.top().first);
}
}
return res;
}
注意:此解法最坏复杂度O(nlogn),当窗口较大时可能超时,仅适用于小窗口场景。
5. 调试技巧与性能优化
5.1 常见错误排查
-
自定义比较器错误:
- 检查比较逻辑是否符合预期优先级
- 对于多关键字排序,确保所有情况都有明确比较结果
-
堆状态不一致:
- 在关键操作后打印堆内容验证
- 使用断言检查堆大小关系
-
内存超限:
- 检查是否有未释放的临时优先队列
- 对于大数据集,考虑使用
reserve预分配空间
5.2 性能优化策略
-
批量操作:
当需要多次插入时,可以先收集数据再批量建堆:cpp复制vector<int> data; // ...收集数据... priority_queue<int> q(data.begin(), data.end()); // O(n)建堆 -
延迟删除:
对于需要支持删除操作的场景,可以采用"标记删除"法:cpp复制unordered_map<int, int> deleted; while (!q.empty() && deleted.count(q.top())) { if (--deleted[q.top()] == 0) deleted.erase(q.top()); q.pop(); } -
选择合适容器:
- 当元素范围较小时(如0-100),可以用桶代替优先队列
- 对于随机访问频繁的场景,可以考虑使用
make_heap系列函数
5.3 测试用例设计
验证优先队列程序时建议包含以下边界情况:
- 空队列操作
- 重复元素处理
- 大规模数据测试(验证时间复杂度)
- 自定义类型的多关键字排序
- 连续插入和删除交替操作
例如测试双堆法中位数:
cpp复制vector<int> test = {1,3,-1,5,2};
MedianFinder mf;
for (int x : test) {
mf.addNum(x);
cout << mf.findMedian() << " ";
}
// 预期输出:1 2 1 2 2