堆这种数据结构应用的场景非常多,最经典的莫过于堆排序了,堆排序是一种原地的,时间复杂度为 O ( n l o g n ) O(n \ logn) O(n logn)的排序算法,我们学过快速排序,平均情况下,她的时间复杂度为 O ( n l o g n ) O(n \ logn) O(n logn) ,甚至堆排序比快速排序的时间复杂度还要稳定,但是,在实际的软件开发中,快速排序的性能要比堆排序好,这是为什么呢?这是由堆的二叉树的结构决定的。
如何理解堆
一、堆是一个完全二叉树。
二、堆中每个结点必须大于等于(或者小于等于)其子树中每个结点的值。
首先是第一点,完全二叉树,完全二叉树要求是除了最后一层,其他层的节点个数都是满的,最后一层的节点都靠左排列。然后是第二点,第二点我们还可以换个说法,堆中每个节点的值都大于等于(或者小于等于)其左右子节点的值。这两种表述是等价的。满足上面两个条件的就是堆。
对于每个节点的值都大于等于子树中每个节点值的堆,我们叫做“大顶堆”。对于每个节点的值都小于等于子树中每个节点值的堆,我们叫做“小顶堆”。
堆需要支持那些操作
一、往堆中插入一个元素
往堆中插入一个元素后,我们需要继续满足堆的两个特性。插入不是在任意位置插入的,需要进行调整,让其重新满足堆的特性,这个过程我们起了一个名字,就叫做堆化(heapify)。
堆化实际上有两种,从下往上和从上往下。这里我先讲从下往上的堆化方法。
堆化非常简单,就是顺着节点所在的路径,向上或者向下,对比,然后交换。
我们可以让新插入的节点与父节点对比大小。如果不满足子节点小于等于父节点的大小关系,我们就互换两个节点。一直重复这个过程,直到父子节点之间满足刚说的那种大小关系。这里我们用C++代码来表述一下
#include <iostream>
#include <vector>
using namespace std;
class MAXHeap {
public:
MAXHeap(int n) : h_size(n + 1), pos(0) {
heap = new int[h_size];
};
MAXHeap() : h_size(10), pos(0) {
heap = new int[h_size];
};
void insert(int i) {
// 自下向上堆化
pos++;
if (pos >= h_size) expand();
heap[pos] = i;
int temp = pos;
while (temp > 1 && heap[temp / 2] < heap[temp]) {
swap(heap[temp / 2], heap[temp]);
temp = temp / 2;
}
}
void expand() {
// 阔容
h_size = h_size * 2;
int *temp = new int[h_size];
for (int i = 0; i < pos; ++i) {
temp[i] = heap[i];
}
delete[] heap;
heap = temp;
}
int size() {
return pos;
}
private:
int h_size;
int pos;
int *heap;
inline void swap(int &a, int &b) {
int t = a;
a = b;
b = t;
}
};
int main() {
MAXHeap h1 = MAXHeap();
for (int i = 0; i < 10; i++) {
h1.insert(i);
}
h1.insert(100);
h1.insert(200);
h1.insert(-100);
return 0;
}
这里我们是用的大根堆,即最大元素在堆顶
二、删除堆顶元素
假设我们构造的是大顶堆,堆顶元素就是最大的元素。当我们删除堆顶元素之后,就需要把第二大的元素放到堆顶,那第二大元素肯定会出现在左右子节点中。然后我们再迭代地删除第二大节点,以此类推,直到叶子节点被删除。但是这样有点小问题,就是删除的过程中可能删除的就不是最后一个结点了,而是删除了树中最后一层的某个结点,所以我们其实可以把最后一个元素放在要删除的堆顶元素的位置上,然后一层一层的交换最大元素,同样我们给出代码。
#include <iostream>
#include <vector>
using namespace std;
class MAXHeap {
public:
MAXHeap(int n) : h_size(n + 1), pos(0) {
heap = new int[h_size];
};
MAXHeap() : h_size(10), pos(0) {
heap = new int[h_size];
};
void insert(int i) {
// 自下向上堆化
pos++;
if (pos >= h_size) expand();
heap[pos] = i;
int temp = pos;
while (temp > 1 && heap[temp / 2] < heap[temp]) {
swap(heap[temp / 2], heap[temp]);
temp = temp / 2;
}
}
int popMax() {
if (pos == 0) return -1;
int res = heap[1];
heap[1] = heap[pos--];
heapify(1);
return res;
}
void expand() {
// 阔容
h_size = h_size * 2;
int *temp = new int[h_size];
for (int i = 0; i < pos; ++i) {
temp[i] = heap[i];
}
delete[] heap;
heap = temp;
}
int size() {
return pos;
}
private:
int h_size;
int pos;
int *heap;
inline void swap(int &a, int &b) {
int t = a;
a = b;
b = t;
}
// 从上到下堆化
void heapify(int i) {
while (true) {
int maxPos = i;
int left = 2 * i;
int right = 2 * i + 1;
if (left <= pos && heap[i] < heap[left]) maxPos = left;
if (right <= pos && heap[maxPos] < heap[right]) maxPos = right;
if (maxPos == i) break;
swap(heap[i], heap[maxPos]);
i = maxPos;
}
}
};
int main() {
MAXHeap h1 = MAXHeap();
for (int i = 0; i < 10; i++) {
h1.insert(i);
}
h1.insert(100);
h1.insert(200);
h1.insert(-100);
for (int i = 0; i < 15; i++) {
cout << h1.popMax() << endl;
}
return 0;
}
这样相当于我们从上到下的堆化我们的堆。
基于堆实现排序
我们可以把堆排序的过程大致分解成两个大的步骤,建堆和排序。
一、建堆
我们首先将数组原地建成一个堆。所谓“原地”就是,不借助另一个数组,就在原数组上操作。建堆的过程,有两种思路。
第一种是借助我们前面讲的,在堆中插入一个元素的思路。尽管数组中包含 n 个数据,但是我们可以假设,起初堆中只包含一个数据,就是下标为 1 的数据。然后,我们调用前面讲的插入操作,将下标从 2 到 n 的数据依次插入到堆中。这样我们就将包含 n 个数据的数组,组织成了堆。
第二种实现思路,跟第一种截然相反,也是我这里要详细讲的。第一种建堆思路的处理过程是从前往后处理数组数据,并且每个数据插入堆中时,都是从下往上堆化。而第二种实现思路,是从后往前处理数组,并且每个数据都是从上往下堆化。
#include <iostream>
#include <vector>
using namespace std;
class MAXHeap {
public:
MAXHeap(int n) : h_size(n + 1), pos(0) {
heap = new int[h_size];
};
MAXHeap(vector<int> nums) : h_size(nums.size()+1), pos(nums.size()) {
heap = new int[h_size];
for (int i = 1; i < h_size; i++) {
heap[i] = nums[i - 1];
}
for (int i = h_size / 2; i >= 1; --i) {
heapify(i);
}
}
MAXHeap() : h_size(10), pos(0) {
heap = new int[h_size];
};
void insert(int i) {
// 自下向上堆化
pos++;
if (pos >= h_size) expand();
heap[pos] = i;
int temp = pos;
while (temp > 1 && heap[temp / 2] < heap[temp]) {
swap(heap[temp / 2], heap[temp]);
temp = temp / 2;
}
}
int popMax() {
if (pos == 0) return -1;
int res = heap[1];
heap[1] = heap[pos--];
heapify(1);
return res;
}
void expand() {
// 阔容
h_size = h_size * 2;
int *temp = new int[h_size];
for (int i = 0; i < pos; ++i) {
temp[i] = heap[i];
}
delete[] heap;
heap = temp;
}
int size() {
return pos;
}
private:
int h_size;
int pos;
int *heap;
inline void swap(int &a, int &b) {
int t = a;
a = b;
b = t;
}
// 从上到下堆化
void heapify(int i) {
while (true) {
int maxPos = i;
int left = 2 * i;
int right = 2 * i + 1;
if (left <= pos && heap[i] < heap[left]) maxPos = left;
if (right <= pos && heap[maxPos] < heap[right]) maxPos = right;
if (maxPos == i) break;
swap(heap[i], heap[maxPos]);
i = maxPos;
}
}
};
int main() {
MAXHeap h1 = MAXHeap();
for (int i = 0; i < 10; i++) {
h1.insert(i);
}
h1.insert(100);
h1.insert(200);
h1.insert(-100);
for (int i = 0; i < 15; i++) {
cout << h1.popMax() << endl;
}
vector<int> input({1, 2, 3, 4, 5, 6, 7, 8, 9});
MAXHeap h2 = MAXHeap(input);
for (int i = 0; i < 15; i++) {
cout << h2.popMax() << endl;
}
return 0;
}
你可能已经发现了,在这段代码中,我们对下标从 n / 2 n/2 n/2 开始到 1 的数据进行堆化,下标是 n / 2 + 1 n/2 +1 n/2+1 到 n n n 的节点是叶子节点,我们不需要堆化。实际上,对于完全二叉树来说,下标从 2 n + 1 2n+1 2n+1 到 n n n 的节点都是叶子节点。
二、排序
建堆之后其实就已经完成排序了,建堆结束之后,数组中的数据已经是按照大顶堆的特性来组织的。数组中的第一个元素就是堆顶,也就是最大的元素。我们把它跟最后一个元素交换,那最大元素就放到了下标为 n 的位置。
这个过程有点类似上面讲的“删除堆顶元素”的操作,当堆顶元素移除之后,我们把下标为 n 的元素放到堆顶,然后再通过堆化的方法,将剩下的 n−1 个元素重新构建成堆。堆化完成之后,我们再取堆顶的元素,放到下标是 n−1 的位置,一直重复这个过程,直到最后堆中只剩下标为 1 的一个元素,排序工作就完成了。
甚至我们可以将堆的最上面的元素一个个的抽出来,这样也就实现了堆排序。