1. 优先级队列的核心价值与实现思路
在C++标准模板库中,priority_queue(优先级队列)是一个看似简单却蕴含精妙设计的数据结构。与普通队列不同,它每次弹出的不是最早进入的元素,而是当前队列中优先级最高的元素。这种特性使其在任务调度、路径搜索、事件模拟等场景中具有不可替代的价值。
优先级队列的底层通常采用堆结构实现,这保证了插入和删除操作都能在O(log n)时间内完成。堆的完美二叉树特性使得它可以用数组高效存储——对于数组中位置i的节点,其左子节点位于2i+1,右子节点位于2i+2,父节点则位于⌊(i-1)/2⌋。这种紧凑的存储方式完全避免了指针开销,是现代计算机体系结构最爱的内存访问模式。
cpp复制template <class T, class Container = vector<T>,
class Compare = less<typename Container::value_type>>
class priority_queue {
private:
Container c; // 底层容器
Compare comp; // 比较准则
// ... 成员函数实现
};
这个类模板声明揭示了priority_queue的三个关键定制点:存储元素类型T、底层容器Container(默认为vector),以及最重要的比较准则Compare(默认为less)。正是Compare模板参数的存在,使得priority_queue能够灵活适应各种排序需求。
2. 仿函数:让比较逻辑活起来
仿函数(Functor)是重载了operator()的类对象,它们可以像函数一样被调用,却拥有类的一切特性。在priority_queue中,仿函数决定了元素的优先级顺序,是控制堆性质的核心枢纽。
标准库提供了less和greater两个基础仿函数,它们的典型实现如下:
cpp复制template <class T>
struct less {
bool operator()(const T& x, const T& y) const {
return x < y;
}
};
template <class T>
struct greater {
bool operator()(const T& x, const T& y) const {
return x > y;
}
};
当我们需要最小堆时(即每次取最小元素),可以指定比较器为greater;默认的less则对应最大堆。这种设计将比较逻辑与容器实现解耦,使得我们可以通过替换仿函数来改变队列行为,而不需要修改容器代码。
提示:仿函数相比普通函数指针的优势在于可以内联优化,且能携带状态。比如可以实现一个带权重的比较器,在比较时考虑额外因素。
3. 堆算法实现详解
3.1 上浮调整(push操作)
当新元素加入队列尾部时,需要通过上浮调整维持堆性质:
cpp复制void push(const T& value) {
c.push_back(value);
// 从新元素开始向上调整
size_t i = c.size() - 1;
while (i > 0) {
size_t parent = (i - 1) / 2;
if (!comp(c[parent], c[i])) break;
std::swap(c[parent], c[i]);
i = parent;
}
}
这个过程就像气泡上浮:不断比较当前节点与父节点,如果违反堆序(对于最大堆就是子节点大于父节点),就交换它们的位置。最坏情况下需要O(log n)次比较和交换。
3.2 下沉调整(pop操作)
弹出堆顶元素后,我们将末尾元素移到堆顶,然后进行下沉调整:
cpp复制void pop() {
if (empty()) return;
// 将末尾元素移到堆顶
c[0] = c.back();
c.pop_back();
// 从堆顶开始向下调整
size_t i = 0;
size_t n = c.size();
while (true) {
size_t left = 2 * i + 1;
size_t right = 2 * i + 2;
size_t largest = i;
if (left < n && comp(c[largest], c[left]))
largest = left;
if (right < n && comp(c[largest], c[right]))
largest = right;
if (largest == i) break;
std::swap(c[i], c[largest]);
i = largest;
}
}
下沉调整像石头落水:每次选择左右孩子中更大(对于最大堆)的那个,如果它比当前节点大就交换位置。这个过程同样保持O(log n)的时间复杂度。
4. 自定义仿函数实战
标准库的less和greater只能处理基本类型的比较。实际开发中我们经常需要更复杂的比较逻辑。假设我们有一个Task类:
cpp复制struct Task {
int priority; // 优先级
string name; // 任务名称
time_t addTime;// 添加时间
// 当优先级相同时,早添加的任务优先
bool operator<(const Task& other) const {
if (priority != other.priority)
return priority < other.priority;
return addTime > other.addTime;
}
};
我们可以为这个类专门定制一个仿函数:
cpp复制struct TaskCompare {
bool operator()(const Task& a, const Task& b) const {
if (a.priority != b.priority)
return a.priority < b.priority; // 数值大的优先
return a.addTime > b.addTime; // 时间早的优先
}
};
// 使用示例
priority_queue<Task, vector<Task>, TaskCompare> taskQueue;
这个仿函数实现了两级排序:首先按优先级降序,当优先级相同时按添加时间升序。这种灵活的比较逻辑是仿函数最强大的地方。
5. 性能优化与边界处理
5.1 预留空间优化
由于priority_queue底层使用vector,频繁push可能导致多次内存重分配。对于已知大小的队列,可以先预留空间:
cpp复制priority_queue<int> q;
q.c.reserve(1000); // 直接访问底层容器预留空间
注意:标准库设计的priority_queue没有提供reserve接口,这是模拟实现时可以改进的地方。
5.2 异常安全保证
在修改堆结构的操作中,我们需要确保异常发生时数据结构仍保持一致:
- push操作中,先增加容器大小,再构造元素,最后调整堆
- pop操作中,先交换元素,再缩减容器,避免中间状态暴露
5.3 迭代器失效问题
与大多数STL容器不同,priority_queue不提供迭代器接口。这是有深刻原因的:堆结构的有序性是逻辑上的,物理存储并不完全有序。如果允许迭代访问,用户可能会误以为遍历结果是有序的。
6. 典型应用场景剖析
6.1 Dijkstra最短路径算法
在图的最短路径计算中,priority_queue用于高效获取当前距离起点最近的节点:
cpp复制void dijkstra(const Graph& g, int start) {
priority_queue<pair<int, int>, vector<pair<int, int>>, greater<>> pq;
vector<int> dist(g.size(), INT_MAX);
pq.emplace(0, start);
dist[start] = 0;
while (!pq.empty()) {
auto [d, u] = pq.top();
pq.pop();
if (d > dist[u]) continue;
for (auto& [v, w] : g.edges(u)) {
if (dist[v] > dist[u] + w) {
dist[v] = dist[u] + w;
pq.emplace(dist[v], v);
}
}
}
}
这里使用greater仿函数实现最小堆,确保每次取出当前距离最小的节点。
6.2 合并K个有序链表
LeetCode经典问题,使用priority_queue可以优雅解决:
cpp复制struct ListNodeCompare {
bool operator()(const ListNode* a, const ListNode* b) {
return a->val > b->val; // 最小堆
}
};
ListNode* mergeKLists(vector<ListNode*>& lists) {
priority_queue<ListNode*, vector<ListNode*>, ListNodeCompare> pq;
for (auto node : lists) {
if (node) pq.push(node);
}
ListNode dummy(0);
ListNode* tail = &dummy;
while (!pq.empty()) {
auto node = pq.top();
pq.pop();
tail->next = node;
tail = tail->next;
if (node->next) {
pq.push(node->next);
}
}
return dummy.next;
}
这个实现的时间复杂度是O(N log k),其中N是总节点数,k是链表数量。
7. 实现中的常见陷阱
7.1 比较函数的一致性
自定义比较函数必须满足严格弱序(strict weak ordering):
- 非自反性:comp(a,a)必须为false
- 非对称性:若comp(a,b)为true,则comp(b,a)必须为false
- 可传递性:若comp(a,b)和comp(b,c)为true,则comp(a,c)必须为true
违反这些规则会导致未定义行为,可能引发内存访问错误或无限循环。
7.2 元素移动而非拷贝
对于大型对象,频繁的拷贝构造会影响性能。C++11后应该支持移动语义:
cpp复制void push(T&& value) {
c.push_back(std::move(value));
// ... 上浮调整
}
7.3 多线程安全性
标准priority_queue不是线程安全的。如果需要在多线程环境下使用,可以考虑:
- 使用互斥锁保护所有操作
- 使用无锁数据结构(如boost::lockfree::priority_queue)
- 每个线程维护自己的队列,定期合并
8. 进阶技巧:可更新优先级的队列
标准priority_queue不支持修改已有元素的优先级。某些场景(如动态调整任务优先级)需要可更新优先级的队列。这可以通过以下方式实现:
cpp复制template <typename T>
class updatable_priority_queue {
vector<T> heap;
unordered_map<T, size_t> index_map; // 值到索引的映射
void swim(size_t i) { /*...*/ }
void sink(size_t i) { /*...*/ }
public:
void push(const T& value) {
heap.push_back(value);
index_map[value] = heap.size() - 1;
swim(heap.size() - 1);
}
void update(const T& old_value, const T& new_value) {
auto it = index_map.find(old_value);
if (it == index_map.end()) return;
size_t i = it->second;
index_map.erase(it);
heap[i] = new_value;
index_map[new_value] = i;
swim(i);
sink(i);
}
// ... 其他接口
};
这种实现通过维护额外的哈希表来快速定位元素位置,使得更新操作可以在O(log n)时间内完成。当然,这增加了空间复杂度和插入/删除操作的开销,属于典型的空间换时间策略。