(^ _ ^)
今天来看这个题!!!
深入浅出堆结构:从优先队列到TopK问题
堆(Heap),一个看似简单却功能强大的数据结构,在算法和数据处理中扮演着至关重要的角色。无论是实现高效的优先队列,还是解决TopK问题,堆都能大显身手。本文将带你深入浅出地理解堆结构,特别是小根堆和大根堆,并通过生动的例子和代码示例,帮助你掌握它们的应用场景和实现方法。
一、什么是堆?
堆是一种特殊的完全二叉树,它满足以下性质:
- 堆序性:每个节点的值都大于等于(或小于等于)其子节点的值。
- 大根堆:每个节点的值都大于等于其子节点的值,根节点是最大值。
- 小根堆:每个节点的值都小于等于其子节点的值,根节点是最小值。
- 完全二叉树:除了最后一层,其他层的节点都必须填满,且最后一层的节点都靠左排列。
二、堆的操作
堆的核心操作包括:
- 插入(Insert):将新元素插入堆中,并保持堆的性质。
- 删除堆顶元素(Delete):移除堆顶元素(最大值或最小值),并保持堆的性质。
- 获取堆顶元素(Peek):返回堆顶元素的值,但不移除它。
1. 插入操作
以大根堆为例,插入操作的步骤如下:
- 将新元素插入到堆的末尾。
- 比较新元素与其父节点的值。
- 如果新元素的值大于父节点的值,则交换它们的位置。
- 重复步骤2和3,直到新元素的值小于等于其父节点的值,或者到达堆顶。
2. 删除堆顶元素操作
以大根堆为例,删除堆顶元素的操作步骤如下:
- 将堆顶元素与堆的最后一个元素交换。
- 移除最后一个元素(即原来的堆顶元素)。
- 比较新的堆顶元素与其子节点的值。
- 如果新的堆顶元素的值小于其子节点的值,则与较大的子节点交换位置。
- 重复步骤3和4,直到新的堆顶元素的值大于等于其子节点的值,或者到达叶子节点。
三、堆的应用
堆的应用非常广泛,以下列举几个典型的应用场景:
1. 优先队列
优先队列是一种特殊的队列,元素按照优先级顺序出队。堆可以高效地实现优先队列:
- 大根堆:优先级高的元素先出队。
- 小根堆:优先级低的元素先出队。
2. TopK问题
TopK问题是指从海量数据中找出最大(或最小)的K个元素。使用堆可以高效地解决TopK问题:
- 找出最大的K个元素:使用小根堆,维护一个大小为K的小根堆,遍历数据,如果当前元素比堆顶元素大,则替换堆顶元素,并调整堆。
- 找出最小的K个元素:使用大根堆,维护一个大小为K的大根堆,遍历数据,如果当前元素比堆顶元素小,则替换堆顶元素,并调整堆。
3. 堆排序
堆排序是一种基于堆的排序算法,其时间复杂度为O(nlogn)。堆排序的步骤如下:
- 将待排序的数组构建成一个大根堆。
- 将堆顶元素(最大值)与堆的最后一个元素交换。
- 移除最后一个元素(即原来的堆顶元素),并对剩余的堆进行调整。
- 重复步骤2和3,直到堆中只剩下一个元素。
四、代码实现
在 C++ 里,可以借助标准库中的 std::priority_queue
来实现小根堆和大根堆,它本质是一个优先队列,底层采用堆数据结构实现。下面为你分别展示使用 std::priority_queue
实现小根堆和大根堆的示例代码,同时也会给出手动实现堆的示例。
使用 std::priority_queue
实现
大根堆
大根堆中每个节点的值都大于或等于其子节点的值,std::priority_queue
默认就是大根堆。
#include <iostream>
#include <queue>
#include <vector>
int main() {
// 定义一个大根堆
std::priority_queue<int> maxHeap;
// 插入元素
maxHeap.push(3);
maxHeap.push(1);
maxHeap.push(2);
// 依次取出元素并输出
while (!maxHeap.empty()) {
std::cout << maxHeap.top() << " ";
maxHeap.pop();
}
std::cout << std::endl;
return 0;
}
小根堆
小根堆中每个节点的值都小于或等于其子节点的值,可通过指定 std::greater
比较函数来实现。
#include <iostream>
#include <queue>
#include <vector>
int main() {
// 定义一个小根堆
std::priority_queue<int, std::vector<int>, std::greater<int>> minHeap;
// 插入元素
minHeap.push(3);
minHeap.push(1);
minHeap.push(2);
// 依次取出元素并输出
while (!minHeap.empty()) {
std::cout << minHeap.top() << " ";
minHeap.pop();
}
std::cout << std::endl;
return 0;
}
手动实现堆
大根堆
#include <iostream>
#include <vector>
class MaxHeap {
private:
std::vector<int> heap;
// 上浮操作,用于维护堆的性质
void swim(int k) {
while (k > 0 && heap[(k - 1) / 2] < heap[k]) {
std::swap(heap[(k - 1) / 2], heap[k]);
k = (k - 1) / 2;
}
}
// 下沉操作,用于维护堆的性质
void sink(int k) {
int n = heap.size();
while (2 * k + 1 < n) {
int j = 2 * k + 1;
if (j + 1 < n && heap[j] < heap[j + 1]) {
j++;
}
if (heap[k] >= heap[j]) {
break;
}
std::swap(heap[k], heap[j]);
k = j;
}
}
public:
// 插入元素
void push(int val) {
heap.push_back(val);
swim(heap.size() - 1);
}
// 删除堆顶元素
void pop() {
if (heap.empty()) {
return;
}
std::swap(heap[0], heap[heap.size() - 1]);
heap.pop_back();
sink(0);
}
// 获取堆顶元素
int top() {
if (heap.empty()) {
return -1;
}
return heap[0];
}
// 判断堆是否为空
bool empty() {
return heap.empty();
}
};
int main() {
MaxHeap maxHeap;
maxHeap.push(3);
maxHeap.push(1);
maxHeap.push(2);
while (!maxHeap.empty()) {
std::cout << maxHeap.top() << " ";
maxHeap.pop();
}
std::cout << std::endl;
return 0;
}
小根堆
#include <iostream>
#include <vector>
class MinHeap {
private:
std::vector<int> heap;
// 上浮操作,用于维护堆的性质
void swim(int k) {
while (k > 0 && heap[(k - 1) / 2] > heap[k]) {
std::swap(heap[(k - 1) / 2], heap[k]);
k = (k - 1) / 2;
}
}
// 下沉操作,用于维护堆的性质
void sink(int k) {
int n = heap.size();
while (2 * k + 1 < n) {
int j = 2 * k + 1;
if (j + 1 < n && heap[j] > heap[j + 1]) {
j++;
}
if (heap[k] <= heap[j]) {
break;
}
std::swap(heap[k], heap[j]);
k = j;
}
}
public:
// 插入元素
void push(int val) {
heap.push_back(val);
swim(heap.size() - 1);
}
// 删除堆顶元素
void pop() {
if (heap.empty()) {
return;
}
std::swap(heap[0], heap[heap.size() - 1]);
heap.pop_back();
sink(0);
}
// 获取堆顶元素
int top() {
if (heap.empty()) {
return -1;
}
return heap[0];
}
// 判断堆是否为空
bool empty() {
return heap.empty();
}
};
int main() {
MinHeap minHeap;
minHeap.push(3);
minHeap.push(1);
minHeap.push(2);
while (!minHeap.empty()) {
std::cout << minHeap.top() << " ";
minHeap.pop();
}
std::cout << std::endl;
return 0;
}
上述代码中,使用 std::priority_queue
实现堆比较简洁,而手动实现堆则有助于你更深入理解堆的底层原理和操作过程。
五、总结
堆是一种高效的数据结构,在解决优先队列、TopK问题、堆排序等问题时具有显著优势。理解堆的性质和操作,并掌握其应用场景,对于提升算法能力和解决实际问题至关重要。希望本文能够帮助你更好地理解和应用堆结构!
六.AC代码
#include <iostream>
#include <queue>
using namespace std;
int main() {
int n;
cin >> n;
// 定义一个小根堆
priority_queue<int, vector<int>, greater<int>> pq;
// 读取每种果子的数目并插入到优先队列中
for (int i = 0; i < n; i++) {
int num;
cin >> num;
pq.push(num);
}
int total_cost = 0;
// 当优先队列中元素个数大于 1 时,继续合并
while (pq.size() > 1) {
// 取出两堆重量最小的果子
int a = pq.top();
pq.pop();
int b = pq.top();
pq.pop();
// 计算合并这两堆果子的体力消耗
int cost = a + b;
total_cost += cost;
// 将合并后的新堆插入到优先队列中
pq.push(cost);
}
// 输出最小的体力耗费值
cout << total_cost << endl;
return 0;
}
七.类似问题
小根堆(最小堆)是一种完全二叉树,其中每个节点的值都小于或等于其子节点的值。下面为你提供几道小根堆的模板题及对应的代码实现。
1. 合并果子问题(前文已提及)
题目描述:在一个果园里,将果子按不同种类分成不同堆,每次可将两堆果子合并成一堆,消耗体力为两堆果子重量之和,求将所有果子合并成一堆的最小体力消耗。
代码实现:
#include <iostream>
#include <queue>
using namespace std;
int main() {
int n;
cin >> n;
priority_queue<int, vector<int>, greater<int>> pq;
for (int i = 0; i < n; ++i) {
int num;
cin >> num;
pq.push(num);
}
int totalCost = 0;
while (pq.size() > 1) {
int a = pq.top();
pq.pop();
int b = pq.top();
pq.pop();
int cost = a + b;
totalCost += cost;
pq.push(cost);
}
cout << totalCost << endl;
return 0;
}
2. 第 k 小元素问题
题目描述:给定一个无序数组,找出数组中第 k
小的元素。
思路:可以使用小根堆来解决这个问题。将数组中的所有元素插入小根堆,然后依次弹出堆顶元素 k - 1
次,此时堆顶元素即为第 k
小的元素。
代码实现:
#include <iostream>
#include <queue>
#include <vector>
using namespace std;
int findKthSmallest(vector<int>& nums, int k) {
priority_queue<int, vector<int>, greater<int>> pq;
for (int num : nums) {
pq.push(num);
}
for (int i = 0; i < k - 1; ++i) {
pq.pop();
}
return pq.top();
}
int main() {
vector<int> nums = {3, 2, 1, 5, 6, 4};
int k = 2;
cout << "第 " << k << " 小的元素是: " << findKthSmallest(nums, k) << endl;
return 0;
}
3. 数据流中的第 k 大元素
题目描述:设计一个类来查找数据流中第 k
大的元素。注意是排序后的第 k
大元素,不是第 k
个不同的元素。
思路:使用一个大小为 k
的小根堆,在添加元素时,如果堆的大小小于 k
,直接将元素插入堆中;如果堆的大小已经达到 k
,且新元素大于堆顶元素,则将堆顶元素弹出,插入新元素。这样堆顶元素始终是第 k
大的元素。
代码实现:
#include <iostream>
#include <queue>
#include <vector>
using namespace std;
class KthLargest {
private:
priority_queue<int, vector<int>, greater<int>> pq;
int k;
public:
KthLargest(int k, vector<int>& nums) {
this->k = k;
for (int num : nums) {
add(num);
}
}
int add(int val) {
if (pq.size() < k) {
pq.push(val);
} else if (val > pq.top()) {
pq.pop();
pq.push(val);
}
return pq.top();
}
};
int main() {
vector<int> nums = {4, 5, 8, 2};
KthLargest kthLargest(3, nums);
cout << kthLargest.add(3) << endl;
cout << kthLargest.add(5) << endl;
return 0;
}
这些题目都体现了小根堆在解决实际问题中的应用,核心在于利用小根堆能快速获取最小元素的特性。
大根堆(最大堆)是一种完全二叉树,其中每个节点的值都大于或等于其子节点的值。以下为你介绍几道常见的大根堆模板题及对应的解题思路与代码实现。
1. 数组中第 k 大的元素
题目描述
给定一个未排序的整数数组 nums
和一个整数 k
,找出数组中第 k
大的元素。
解题思路
使用大根堆存储数组元素,然后依次弹出堆顶元素 k - 1
次,此时堆顶元素即为第 k
大的元素。在 C++ 中,std::priority_queue
默认就是大根堆。
代码实现
#include <iostream>
#include <vector>
#include <queue>
int findKthLargest(std::vector<int>& nums, int k) {
std::priority_queue<int> pq(nums.begin(), nums.end());
for (int i = 0; i < k - 1; ++i) {
pq.pop();
}
return pq.top();
}
int main() {
std::vector<int> nums = {3, 2, 1, 5, 6, 4};
int k = 2;
std::cout << "第 " << k << " 大的元素是: " << findKthLargest(nums, k) << std::endl;
return 0;
}
2. 滑动窗口最大值
题目描述
给定一个数组 nums
,有一个大小为 k
的滑动窗口从数组的最左侧移动到数组的最右侧。你只可以看到在滑动窗口内的 k
个数字。滑动窗口每次只向右移动一位。返回滑动窗口中的最大值。
解题思路
使用大根堆来维护滑动窗口内的元素。每次移动窗口时,将新元素加入堆中,同时检查堆顶元素是否已经不在当前窗口内,如果是则将其弹出。堆顶元素即为当前窗口的最大值。
代码实现
#include <iostream>
#include <vector>
#include <queue>
std::vector<int> maxSlidingWindow(std::vector<int>& nums, int k) {
std::vector<int> result;
std::priority_queue<std::pair<int, int>> pq;
for (int i = 0; i < k; ++i) {
pq.push({nums[i], i});
}
result.push_back(pq.top().first);
for (int i = k; i < nums.size(); ++i) {
pq.push({nums[i], i});
while (pq.top().second <= i - k) {
pq.pop();
}
result.push_back(pq.top().first);
}
return result;
}
int main() {
std::vector<int> nums = {1, 3, -1, -3, 5, 3, 6, 7};
int k = 3;
std::vector<int> result = maxSlidingWindow(nums, k);
for (int num : result) {
std::cout << num << " ";
}
std::cout << std::endl;
return 0;
}
3. 最后一块石头的重量
题目描述
有一堆石头,每块石头的重量都是正整数。
每一回合,从中选出两块最重的石头,然后将它们一起粉碎。假设石头的重量分别为 x
和 y
,且 x <= y
。那么粉碎的可能结果如下:
- 如果
x == y
,那么两块石头都会被完全粉碎; - 如果
x != y
,那么重量为x
的石头将会完全粉碎,而重量为y
的石头新重量为y - x
。
最后,最多只会剩下一块石头。返回此石头的重量。如果没有石头剩下,就返回 0。
解题思路
使用大根堆来存储石头的重量,每次取出堆顶的两块石头进行粉碎操作,根据结果更新堆,直到堆中元素数量小于等于 1。
代码实现
#include <iostream>
#include <vector>
#include <queue>
int lastStoneWeight(std::vector<int>& stones) {
std::priority_queue<int> pq(stones.begin(), stones.end());
while (pq.size() > 1) {
int y = pq.top();
pq.pop();
int x = pq.top();
pq.pop();
if (x != y) {
pq.push(y - x);
}
}
return pq.empty() ? 0 : pq.top();
}
int main() {
std::vector<int> stones = {2, 7, 4, 1, 8, 1};
std::cout << "最后一块石头的重量是: " << lastStoneWeight(stones) << std::endl;
return 0;
}
这些题目都充分利用了大根堆能快速获取最大元素的特性,通过对堆的操作来解决实际问题。