为什么使用堆
堆的主要用途是在处理优先队列上,相比于普通队列的先进先出,后进后出,优先队列出队顺序和入队顺序无关,只和优先级有关。比如头等舱登机就比普通乘客先登机,而和先来后到没有关系。但是有人会说我把数组先进行排序再处理呗,但是现实生活中往往是需要进行动态处理的,如果每次有新元素都要进行重新排序,那处理起来是十分复杂的。此时使用堆的数据结构处理速度会有一定的提升。
通过对比可以看出,虽然使用堆在入队和出队都不是最优,但是在同时出入队就会有较好的性价比。
如何实现堆
从上可以看出堆的时间复杂度为
O
(
l
o
g
n
)
O(logn)
O(logn),由此可以推测堆应该是一种树形结构。堆最为经典的一种实现方式就是二叉堆,二叉堆就是指每个父亲节点只有两个子节点。同时二叉堆还是一棵完全二叉树,完全二叉树就是指如果这颗树总共有
n
n
n层,那么第
n
−
1
n-1
n−1层子节点必须是满的,按照二叉树的规则应有
2
n
−
2
2^{n-2}
2n−2个节点,而对于最后一层所有的子节点必须从左向右依次排列。满足上述条件的就可以称为一个堆。如果再定义某节点不能大于父节点,那么就成为一个最大堆,反之则是最小堆。
如果想要构建一个堆,我们可以像树一样在节点内定义左右节点的指针。但这里我们先采用数组实现。首先我们将堆从上至下、从左至右按顺序排列,那么每个节点的数据可以存储在数组中。这里是将节点从1开始排列,如果从0开始排列也是可以的。
观察父节点和左右子节点的关系可以发现关系如下:节点
i
i
i的父节点
p
a
r
e
n
t
(
i
)
=
i
/
2
parent(i)=i/2
parent(i)=i/2,父节点
i
i
i的左右子节点可以表示为
l
e
f
t
c
h
i
l
d
(
i
)
=
2
∗
i
,
r
i
g
h
t
c
h
i
l
d
(
i
)
=
2
∗
i
+
1
left child(i) = 2*i,right child(i) = 2*i+1
leftchild(i)=2∗i,rightchild(i)=2∗i+1。
我们可以用一个类来实现堆。
#include <iostream>
#include <algorithm>
#include <string>
#include <ctime>
#include <cmath>
#include <cassert>
using namespace std;
template <typename T>
class maxHeap{
private:
T* date;
int count;
public:
maxHeap(int capacity){
data = new T[capacity+1]; //数组从索引1开始
count = 0;
}
~maxHeap() {delete[] date;}
int size() {return count;}
bool isEmpty() {return count==0;}
}
添加元素
如果需要向堆中添加一个元素,先将新元素插入数组末尾,但是此时并不满足最大堆定义,所以要将新元素调整到合适的位置。
不满足最大堆其实就是父节点的值小于子节点,所以只要将子节点不断与父节点比较,调整至合适位置即可。
在刚刚的类里添加一个public函数insert(),交换的函数设定为private函数
private:
void shiftUp(int k){ //对第k个元素进行shiftup
while(k > 1 && data[k] > data[k/2]){
swap(data[k], data[k/2]);
k = k/2;
}
}
public:
void insert(T item){
data[count+1] = item; //此时并没有判断数组是否越界,可以
//添加一个capacity成员变量,每次添加时进行判断
count++;
shiftUp(count);
}
取出元素
取出元素时,只能取出优先级最高的,也就是值最大的。然后将最后一个元素放到最大元素处,然后不断与两个子节点比较,选择较大的子节点交换,直至满足最大堆。
private:
void shiftDown(int k){
while(2*k <= count+1){ //保证有左子节点
int j = 2*k; //j指向左子节点
if(j+1 <= count && data[j] < data[j+1]) //右节点存在且大于左节点
j = j+1; //使j指向左右子节点中的较大值
if(data[k] > data[j])
break;
swap(data[k],data[j]);
k = j;
}
}
public:
T extractMax(){
assert(count>0);
T ret = data[1];
swap(data[1],data[count]);
count--;
shiftDown(1);
return ret;
}
使用堆进行排序
既然可以使用extractMax函数提取最大元素,所以可以使用堆进行排序。
template <typename T>
void heapSort(T arr[], int n){
maxHeap<T> maxheap = maxHeap<T>(n);
for(int i=0; i<n; i++)
maxheap.insert(arr[i]);
for(int i=n-1; i>=0; i--)
arr[i] = maxheap.extractMax();
}
因为堆也是一种树形结构,所以在排序时每次插入取出元素的复杂度为
O
(
l
o
g
n
)
O(logn)
O(logn),整个排序的复杂度
O
(
n
l
o
g
n
)
O(nlogn)
O(nlogn)。但是整体排序速度还是还是略慢于归并排序和快速排序。我们可以对堆的构造方法进行一定的改善,提高运行速度。我们在插入时可以不使用insert的方法进行插入,而采用一种Heapify的方法。将原数组复制到堆中,可以发现没有叶子节点的元素本身就是最大堆的形式,所以只需要从最后一个有叶子节点的元素开始进行最大堆整理即可。最后一个有叶子节点的元素索引
k
=
c
o
u
n
t
/
2
k=count/2
k=count/2。
那么对于有叶子节点的元素,它们不满足最大堆的性质,那么可以通过之前的shiftDown操作将元素调整至合理位置。
这里使用一个新的构造函数将数组构造成堆。
public:
maxHeap(T arr[], int n){
data = new T[n+1];
for(int i=0; i<n; i++)
data[i+1] = arr[i];
count = n;
for(int k = n/2; k>0; k--)
shiftDown(k);
}
template <typename T>
void heapSort2(T arr[], int n){
maxHeap<T> maxheap = maxHeap<T>(arr , n);
for(int i=n-1; i>=0; i--)
arr[i] = maxheap.extractMax();
}
改进后的堆排序比之前的要快一点,但相比较于其它算法还是不够快,这也是为什么系统级别的排序没有采用堆的原因,堆只要适用于动态数据维护。
优化的堆排序
上述方法都是对数组复制后构造堆,但是这样会开辟新的空间,提高空间复杂度,有没有方法可以原地排序而无需开辟新空间呢?答案是有的,但是就不能将堆按照1,2,3…排列,而需要从0开始。从0开始的话,父节点和子节点之间的关系可以表示为:
可以利用上述关系对原数组进行堆得构造。构造成堆后,原数组是一个最大堆,那么第一个元素就是最大值,因为需要将数组从小到大排序,那么交换第一个元素和最后一个元素就将最大值移至正确位置。
此时前面的数组已经不满足最大堆,但是如果将w进行shiftDown操作后,前面的数字又可以变成最大堆。
此时继续将橙色部分构造的最大堆中最大元素与最后一个元素交换,那么倒数第二大的元素也放入它应该在的位置。
template <typename T>
void __shiftDown(T arr[], int n, int k){
while(2*k + 1 < n){
int j = 2*k + 1;
if(j+1 <= n && arr[j]<arr[j+1])
j = j+1;
if(arr[k] > arr[j])
break;
swap(arr[k], arr[j]);
k = j;
}
}
template <typename T>
void heapSort3(T arr[], int n){
for(int i=(n-2)/2; i>=0; i--) //最后一个有子节点的数据索引为(n-1-1)/2
__shiftDown(arr, n ,i);
for(int i=n-1; i>=0; i--){
swap(arr[i],arr[0]);
__shiftDown(arr, i, 0);
}
}
上述算法节省了元素复制的过程,所以无论从空间复杂度还是时间复杂度都是比之前的要快的。