为什么使用堆?
优先队列
普通队列:先进先出;后进后出
优先队列:出队顺序和入队顺序无关;和优先级相关
为什么使用优先队列?
-
对于动态问题
例如操作系统执行任务,每个任务都有优先级,动态选择优先级最高的任务执行,使用优先队列。
比如不同用户对同一界面的请求,回应请求优先队列。
或者游戏中只能选择角色攻击敌人的顺序,角色的攻击范围是一定的,该角色由电脑控制选择敌人攻击,使用优先队列。 -
对于静态问题:
在10000000个元素中选出前一百名?(即在N各元素中选出前M个元素问题)
使用排序? NlogN
也可以使用优先队列进行优化–》 NlogM
优先队列的操作:
入队,出队(取优先级最高的元素)
优先队列的实现:
… | 入队 | 出队 |
---|---|---|
普通数组 | O(1) | O(n) |
顺序数组 | O(n) | O(1) |
堆 | O(lgn) | O(lgn) |
使用堆得时间效率>使用数组
对于总共N个请求:
- 使用普通数组或者顺序数组,最差情况O(n2)
- 使用堆:O(nlgn)
堆的基本存储
二叉堆是一棵完全二叉树
最大堆:堆中节点的值总是不大于其父节点的值,堆总是一颗完全二叉树、
最小堆
用数组存储二叉堆
经典实现:根节点从1标记…
parent(i)=i/2
left child(i)=2i;
right child(i)=2i+1;
template<typename Item>
class MaxHeap{
private:
Item *data;
int count;
int capacity;
void shiftUp(int k){
while( k > 1 && data[k/2] < data[k] ){
swap( data[k/2], data[k] );
k /= 2;
}
}
public:
// 构造函数, 构造一个空堆, 可容纳capacity个元素
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;
}
// 像最大堆中插入一个新的元素 item
void insert(Item item){
assert( count + 1 <= capacity );
data[count+1] = item;
count ++;
shiftUp(count);
}
public:
// 以树状打印整个堆结构
void testPrint(){
//...
}
};
测试:
在这里插入代码片// 测试 MaxHeap
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;
}
shiftup
void shiftUp(int k){
while( k > 1 && data[k/2] < data[k] ){
swap( data[k/2], data[k] );
k /= 2;
}
}
// 像最大堆中插入一个新的元素 item
void insert(Item item){
assert( count + 1 <= capacity );
data[count+1] = item;
count ++;
shiftUp(count);
}
shift down
对于堆,只能取根节点的元素,然后把最后一个元素放在根节点的位置,count(堆中元素个数成员变量)–;
然后,调整元素位置使其保持最大堆的性质,即将根节点的元素一步一步向下挪,最终找到其合适的位置,即shift down。
Item extractMax()
{
assert(count>0);
Item ret = data[1];
swap(data[1],data[count]);
count--;
shiftDown(1);
return ret;
}
void shiftDown(int k){
while(2*k<=count){//判断该节点有左孩子即可
int j=2*k;//在此轮循环中,data[k]和data[j]交换位置
if(j+1<=count&&data[j+1]>data[j])
j+=1;
if(data[k]>=data[j])
break;
swap(data[k],data[j]);
k=j;
}
}
代码中的swap操作非常费事,可以进行优化,使用赋值。
// 测试 MaxHeap
int main() {
MaxHeap<int> maxheap = MaxHeap<int>(100);
srand(time(NULL));
for( int i = 0 ; i < 15 ; i ++ )
maxheap.insert( rand()%100 );
while(!maxheap.isEmpty())
cout<<maxheap.extractMax()<<" ";
cout<<endl;
return 0;
}
基础堆排序和 Heapify
template<typename T>
void heapSort1(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();
}
给定一个数组,让这个数组的排列形成一个堆的形状,这个过程就是Heapfiy。
- heapify
所有叶子节点本身就是一个最大堆,每个堆的元素只有一个
对于非叶子节点,第一个非叶子节点的索引是n/2,从后向前考查每一个非叶子节点。
MaxHeap(Item arr[],int n)
{
data = new Item[n+1];
capacity=n;
for(int i=0;i<n;i++)
data[i+1]=arr[i];
count=n;
for(int i=count/2;i>=1;i--)
shiftDown(i);
}
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();
}
测试可得堆排序的效率依然不如归并排序和快速排序,系统级别的排序很少使用堆排序,堆这种结构更多用于动态数据的维护。
两种建堆过程使得堆排序的效率也不一样。
heapSort2比1块。
即一个很有用的结论:
Heapify的算法复杂度
1.将n个元素逐个插入到一个空堆中,算法复杂度是O(nlogn)
2.heapify的过程,算法复杂度为O(n)(heapify的过程一开始就将n/2的元素直接不考虑了,算法肯定要快一些hhhh)
- 总结:
这一小节介绍了将一个数组构建成堆,同时实现了两个版本的堆排序。同时,在堆排序中,我们都需要将数组的内容逐一放入一个堆中,实际上我们额外开了一个大小为n的空间,空间复杂度为O(n)。
我们可以改造,使得不使用任何额外空间,原地对数组进行排序
优化的堆排序
原地堆排序
这里用数组存储二叉堆(元素从0开始索引
注意在这里进行Heapify的过程中,最后一个非叶子结点的索引为(count-1)/2
template<typename T>
void shiftDown(T arr[],int n,int k)
{
while(2*k+1<n){
int j=2*k+1;//在此轮循环中,arr[k]和arr[j]交换位置
if(j+1<n&&arr[j+1]>arr[j])
j+=1;
if(arr[k]>=arr[j])
break;
swap(arr[k],arr[j]);
k=j;
}
}
//原地堆排序
template<typename T>
void heapSort(T arr[],int n)
{
//heapify
for(int i=(n-1)/2;i>=0;i--)//注意索引从0开始
__shiftDown(arr,n,i);//arr数组;n个元素,对第i个元素进行shiftdown
for(int i=n-1;i>0;i--){//注意这里是i>0不是i>=0,因为堆里只剩下一个元素的时候就不需要排序了
swap(arr[0],arr[i]);
__shiftDown(arr,i,0);//每一次循环有i个元素,对第0个元素进行shiftdown即可
}//完成堆排序
}
由测试可知,原地堆排序比之前的两种堆排序都要快,因为省去了开辟空间,效率更高。
排序算法总结
现在我们来总结一下
总体而言,快速排序相对更快一些,所以,一般系统级别的排序都是使用快速排序来实现,特别是对于有可能有大量重复键值的排序,可以使用三路快速排序。
归并只能开辟额外的空间来完成归并的过程,才能完成归并排序,真因为如此,对于一个对空间敏感的系统,归并排序可能是最坏的选择。
对于插入和堆排序,都可以在数组上完成,额外空间是0(1),归并需要O(n),快排需要o(logn),对于快排,采用递归的方式进行排序,这个过程一共有logn这么多层,就需要有logn层的栈空间,来保存每一次递归过程的变量以便递归返回的时候继续使用,为此,我们要注意,快速排序虽然是一种原地的排序,但他所耗费的空间是logn级别
排序算法的稳定性?
- 什么是排序算法的稳定性Stable?//和具体实现有关
稳定排序:对于相等的元素,在排序后,原来靠前的元素依然靠前,相等元素的相对位置没有发生改变
稳定的排序在实际生活中有很多应用:比如一个学生的成绩单,是按照学生姓名的字典序排序,要按照分数排序,稳定的排序就能做到,对于相同分数的学生,其排序就是按照字典序来排序。
- 可以通过自定义比较函数,让排序算法不存在稳定性的问题
索引堆 Index Heap
为什么要引入索引堆?
对于与普通的堆,
排序前:
排序后:
比较这两页。在堆构建前和堆构建后,对于数组而言,数组的元素位置发生了改变,正是因为这些数组元素位置的改变,我们才可以将这个数组看作是一个堆。
但是在我们构建堆的过程中,改变元素的位置,会有一些局限性:
- 如果元素是非常复杂的结构,例如巨型字符串,交换元素本身消耗巨大。性能消耗
- 更致命的是,由于整个元素在数组中的位置发生了改变,使得当堆建成之后,很难索引到它,很难去改变他。例如说这些元素表示的是一个个系统任务,我们要改变索引为6的任务的优先级提高,若原来的数组中O(1)直接提取,建成堆后,堆的数组中索引不到它,我们可以对这些元素再添加一个属性,但是仍要将整个数组遍历一遍才能找到这个任务,这仍然是低效的
所以,我们引入索引堆:
建堆前:
建堆后:
可以index数组发生改变,表示堆。
可以看到,这样有两个优势
- 建堆的过程只是索引的位置发生变化,索引是简单的int型,如果data是复杂的元素,只交换int型的索引所提升的效率是非常高的
- 可以快速索引到某个元素
可以看到思路与之前一样,元素比较的时候比较的是data的元素,但真正进行交换的是int的索引
indexHeap.c
template<typename Item>
class IndexMaxHeap{
private:
Item *data;
int indexes;
int count;
int capacity;
void shiftUp(int k){
while( k > 1 && data[indexes[k/2]] < data[indexes[k]] ){
swap( indexes[k/2], indexes[k] );
k /= 2;
}
}
void shiftDown(int k){
while(2*k<=count){//判断该节点有左孩子即可
int j=2*k;//在此轮循环中,data[k]和data[j]交换位置
if(j+1<=count&&data[indexes[j+1]]>data[indexes[j]])
j+=1;
if(data[indexes[k]]>=data[indexes[j]])
break;
swap(indexes[k],indexes[j]);
k=j;
}
}
public:
// 构造函数, 构造一个空堆, 可容纳capacity个元素
IndexMaxHeap(int capacity){
data = new Item[capacity+1];
indexes= new int[capacity+1];
count = 0;
this->capacity = capacity;
}
~IndexMaxHeap(){
delete[] data;
delete[] indexes;
}
// 像最大堆中插入一个新的元素 item,索引为i
//传入的i对用户而言,是从0索引的
void insert(int i,Item item){
assert( count + 1 <= capacity );
assert(i+1>=1&&i+1<=capacity);
i+=1;
data[i] = item;
indexes[count+1]=i;
count ++;
shiftUp(count);
}
//返回最大的元素的索引,将这个元素删除
Item extractMaxIndex()
{
assert(count>0);
//对于用户开始,从0开始的索引
int ret = indexes[1]-1;
swap(indexes[1],indexes[count]);
count--;
shiftDown(1);
return ret;
}
Item getItem(int i)
{
return data[i+1];
}
void change(int i,Item newItem)
{
i+=1;
data[i]=newItem;
//找到indexs[j]=i;j表示data[i]在堆中的位置
//之后shiftup(j).在shiftdown(j)
for(int j=1;j<=count;j++)
if(indexes[j]==i)
{
shiftUp(j);//这两个操作可以互换
shiftDown(j);
return;
}
}
};
对于修改元素,change,找到这个j,使用for循环,O(n),在change里,将索引为i的元素修改为新的item值,完成了操作
在这里怎么对change进行优化呢?
索引堆的优化
反向查找思路
rev[i]的意思是i这个索引,他在数组中的位置。
只要维护好rev这个数组,我们就能在更新的操作中,直接把一个索引对应在indexes数组中的位置直接找到, O(1)
加入reverse,使得change变为o(nlogn)
template<typename Item>
class IndexMaxHeap{
private:
Item *data;
int* indexes;
int* reverse;
int count;
int capacity;
void shiftUp(int k){
while( k > 1 && data[indexes[k/2]] < data[indexes[k]] ){
swap( indexes[k/2], indexes[k] );
reverse[indexes[k/2]]=k/2;
reverse[indexer[k]]=k;
k /= 2;
}
}
void shiftDown(int k){
while(2*k<=count){//判断该节点有左孩子即可
int j=2*k;//在此轮循环中,data[k]和data[j]交换位置
if(j+1<=count&&data[indexes[j+1]]>data[indexes[j]])
j+=1;
if(data[indexes[k]]>=data[indexes[j]])
break;
swap(indexes[k],indexes[j]);
reverse[indexes[k]]=k;
reverse[indexes[j]]=j;
k=j;
}
}
public:
// 构造函数, 构造一个空堆, 可容纳capacity个元素
IndexMaxHeap(int capacity){
data = new Item[capacity+1];
indexes= new int[capacity+1];
reverse= new int[capacity+1];
for(int i=0;i<=capacity;i++)
reverse[i]=0;
count = 0;
this->capacity = capacity;
}
~IndexMaxHeap(){
delete[] data;
delete[] indexes;
delete[] reverse;
}
// 返回堆中的元素个数
int size(){
return count;
}
// 返回一个布尔值, 表示堆中是否为空
bool isEmpty(){
return count == 0;
}
// 像最大堆中插入一个新的元素 item,索引为i
//传入的i对用户而言,是从0索引的
void insert(int i,Item item){
assert( count + 1 <= capacity );
assert(i+1>=1&&i+1<=capacity);
i+=1;
data[i] = item;
indexes[count+1]=i;
reverse[i]=count+1;
count ++;
shiftUp(count);
}
//返回最大的元素的索引,将这个元素删除
Item extractMaxIndex()
{
assert(count>0);
//对于用户开始,从0开始的索引
int ret = indexes[1]-1;
swap(indexes[1],indexes[count]);
reverse[indexes[1]]=1;
reverse[indexes[count]=0;//删除了这个元素
count--;
shiftDown(1);
return ret;
}
bool contain(int i)
{
assert(i+1>=1&&i+1<=capacity);
return reverse[i+1]!=0;
}
Item getItem(int i)
{
assert(contain(i));
return data[i+1];
}
void change(int i,Item newItem)
{
assert(contain(i));
i+=1;
data[i]=newItem;
int j =reverse[i];
shiftUp(j);
shiftDown(j);
}
};
和堆相关的其他问题
-
可以使用堆进行多路归并排序。
-
二叉堆
-
d 叉堆 d-ary heap
-
最大堆 最大索引堆
-
堆的实现细节优化
shiftup和shiftdown中使用赋值操作替换swap操作。 表示堆的数组从0开始索引; 没有capacity的限制,动态的调整堆中数组的大小。
-
二项堆
-
斐波那契堆