数据结构——堆
堆与堆排序
堆(heap)分为二叉堆、二项式堆、斐波那契堆,堆是非线性数据结构,相当于一维数组,有两个直接后继。堆又被称为优先队列,尽管名为优先队列,但堆并不是队列。因为队列遵循First in, First out,但是堆是按照元素的优先级取出元素。所以“堆”是实现调度器的理想数据结构。
堆排序与快速排序、归并排序一样都是时间复杂度为O(N*logN)的排序方法。
二叉树
在这里讲二叉树是因为堆通常是一个可以被看做一棵完全二叉树的数组对象。
二叉树(Binary Tree)是每一个节点最多有两个分支的树结构,通常分支被称作左子树和右子树,分支具有左右次序,不能随意颠倒。二叉树第i
层最多拥有 2 ^ (i - 1)
个节点,深度为k
的二叉树最多共有2 ^ (k + 1) - 1
个节点。
满二叉树
假设某个二叉树深度为 k
,第 i
层拥有 2 ^ (i - 1)
个节点,且总共拥有 2 ^ (k + 1) - 1
个节点,这样的二叉树称为满二叉树。简单来说就是,二叉树的每一层都是满的,除了最后一层上的节点,每一个节点都具有左节点和右节点。
完全二叉树
完全二叉树是由满二叉树引出来的,是效率很高的数据结构,若设二叉树的深度为h
,除第 h
层外,其它各层 (1~h-1
) 的结点数都达到最大个数,第 h
层所有的结点都连续集中在最左边,这就是完全二叉树。
对于深度为K
的,有n
个结点的二叉树,当且仅当其每一个结点都与深度为K的满二叉树中编号从1
至n
的结点一一对应时称之为完全二叉树。
- 所有的叶结点都出现在第
K
层或K - 1
层(层次最大的两层) - 对任一结点,如果其右子树的最大层次为
L
,则其左子树的最大层次为L或L + 1
。
这里有一点需要强调:满二叉树以一定是完全二叉树,但完全二叉树不一定是满二叉树。
二叉堆
二叉堆是一种特殊的堆,二叉堆是完全二元树(二叉树)或者是近似完全二元树(二叉树)。
二叉堆分为两种:大顶堆和小顶堆
- 当父节点的键值总是大于或者等于任何一个子节点的键值时为大顶堆
- 当父节点的键值总是小于或者等于任何一个子节点的键值时为小顶堆
因为二叉堆是一个完全二叉树,所以其必须满足上面讲述的完全二叉树的条件。再而,为了实现堆的操作,我们额外增加一个要求: 任意节点的优先级不小于它的子节点
。
所以总结,二叉堆满足以下两个特性:
- 父节点的键值总是大于或者等于(小于或等于)任何一个子节点的键值
- 每个节点的左子树和右子树都是一个二叉堆(都是最大堆或最小堆)
在下面的讲述中采用大顶堆来进行实现。
堆的存储
一般都用数组来表示堆,i
为数组的下标,那么该节点的父节点下标为i / 2
。它的左右子节点下标分别为2 * i
和2 * i + 1
。
说到这里,大家应该可以将堆和树联系在一起了,同时又因为堆通常是一个可以被看做一棵完全二叉树的数组对象。为了方便后面的操作(像插入、删除…),所以堆的结构就并不是左指针和右指针了,而是以数组的形式。
堆的插入删除
- 建立堆:数组具有对应的树表示形式。一般情况下,树并不满足堆的条件,通过重新排列元素,可以建立一颗“堆化”的树。
- 插入一个元素:新元素被插入到列表层(即完全二叉树最后的位置),将其和父节点比较。如果新元素比父节点大,那么交换两者。交换之后,继续和新的父节点比较,直至新元素不比父节点大,随后树被更新以恢复堆次序。(其时间复杂度为O(logN)),以上操作称为上溯(percolate up)操作。
- 删除一个元素:删除总是发生在根节点处。树中最后一个元素被用来填补空缺位置,称为暂时根节点,然后将暂时根节点不断和子节点(左右两子节点中大的那一个节点)进行比较,如果他比子节点小,则交换节点位置,直到暂时根节点不小于任何一个子节点,结果树被更新以恢复堆条件。以上操作被称为下溯(percolate down)。
具体的实现
在实现堆的过程中,我们需要先创建一个堆,堆的底层是数组,由于其第一个位置不存放真实的值,所以在申请空间的时候多申请一个。
#include <iostream>
#include <algorithm>
#include <string>
#include <ctime>
#include <cmath>
#include <cassert>
using namespace std;
template<typename Item>
class MaxHeap{
private:
Item* data;
int count;
public:
MaxHeap(int capacity){
data = new Item[capacity + 1];
count = 0;
this->capacity = capacity;//避免数组长度越界
}
~MaxHeap(){
delete [] data;
}
int size(){
return count;
}
bool isEmpty(){
return count == 0;
}
};
int main() {
MaxHeap<int> maxHeap = MaxHeap<int>(100);
}
向其中插入元素
void insert(Item item){
//避免数组长度越界,如越界则不执行
assert(count + 1 <= capacity);
data[count + 1] = item;
count ++;
//在进行插入操作后,可能破坏堆的结构,所以进行上溯操作
percolate_up( count );
}
//考虑索引越界的问题,k必须大于1才能有父节点
void percolate_up (int k){
while (k > 1 && data[k / 2] < data[k]){
swap(data[k / 2],data[k]);
k = k / 2;
}
}
测试插入元素
int main() {
MaxHeap<int> maxHeap = MaxHeap<int>(100);
srand(time(NULL));
for (int i = 0; i < 15; i++) {
maxHeap.insert(rand()%100);
}
maxHeap.testPrint();
return 0;
}
从堆中取出(删除操作)
Item extractMax(){
assert(count > 0);
Item ret = data[1];
swap(data[1],data[count]);
count --;
percolate_down(1);
return ret;
}
void percolate_down(int k){
while (2 * k <= count){
int j = 2 * k;
//判断此节点是否有右孩子节点,如果有,并且右孩子节点的值大于左孩子节点,
//那么与k交换的就是右孩子节点
if (j + 1 <= count && data[j + 1] > data[j]){
j = j + 1;
}
if (data[k] >= data[j])
break;
swap(data[k],data[j]);
k = j;
}
}
测试取出元素
maxHeap.extractMax();
堆排序
通过上面的学习我们知道了堆的存储结构以及如何用代码来实现堆的插入和删除,在进行堆插入的时候,按照堆的结构进行存储,当我们弹出元素的时候,永远弹出的是堆中的最大值。我们看一下下面的代码以及他的结果。
while( !maxHeap.isEmpty()){
cout << maxHeap.extractMax()<<" ";
}
我们发现了是一组有序的数据,这里就引申出了堆排序。
#include <iostream>
#include "Heap.h"
using namespace std;
template <typename T>
void headSort1(T arr[], int n){
MaxHeap<T> maxHeap = MaxHeap<T>(n);
for (int i = 0; i < n; i++) {
maxHeap.insert(arr[i]);
}
for (int j = n - 1; j >= 0; j --) {
arr[j] = maxHeap.extractMax();
}
}