优先队列与堆排序
首先,介绍优先队列的概念与应用
许多应用程序都需要处理有序元素,但不一定要求全部由序,或者不一定要求一次性排序。多数情况下,我们会收集一部分元素,处理当前键值最大的元素,然后再收集更多的元素,再处理当前键值最大的元素,如此这般。例如,手机能够同时运行多个应用APP。这是通过为每个应用的事件分配一个优先级,并总是处理下一个优先级最高的事件来实现的。绝大多数情况下手机分配给来电的优先级都会比游戏程序的高。
在这样的应用中,一个合适的数据结构应该支持两种操作:删除最大元素和插入元素。这种数据类型就叫做优先队列。优先队列的使用和队列以及栈类似。这里将介绍基于二叉堆数据结构的一种优先队列的经典实现方法,用数组保存元素并按照一定条件排序,以实现高效地删除最大元素和插入元素操作。
通过插入一列元素然后一个个地删掉其中最小的元素,即可以利用优先队列实现排序算法。这种名为堆排序的重要排序算法也是来自于基于堆的优先队列的实现。除应用于排序算法之外,优先队列可以恰到好处地抽象出若干重要的图搜索算法,可以以此为据开发出一种数据压缩算法,等等。
其次,定义优先队列API
优先队列是一种抽象数据类型,它表示了一组值和对这些值的操作,它的抽象层能够方便地将应用程序(用例)和 各种具体实现隔离开来。优先队列最重要的操作就是删除最大元素和插入元素。这里将定义一组API来为数据结构的用例提供足够的信息。顺出最大元素的方法名为 delMax() d e l M a x ( ) ,插入元素的方法名为 insert() i n s e r t ( ) 。
表1 C++ 泛型优先队列的API
template < class Key > class MaxPQ | 说明 |
---|---|
MaxPQ(int maxN) | 构造函数,创建一个最大容量maxN的优先队列 |
~MaxPQ() | 析构函数 |
void insert( Key v) | 向优先队列中插入一个元素 |
Key delMax() | 删除并返回最大元素 |
bool isEmpty() | 返回队列是否为空 |
int size() | 返回优先队列中的元素个数 |
MaxPQ M a x P Q 类的主要操作是插入元素和删除最大元素。同理,可以使用另一个类 MinPQ M i n P Q ,实现插入元素和删除最小元素的操作。它和 MaxPQ M a x P Q 类似,只是含义一个 delMin() d e l M i n ( ) 方法来删除并返回队列中最小元素。 MaxPQ M a x P Q 的任意实现都能很容易地转化为 MinPQ M i n P Q 的实现。
接着,实现优先队列
优先队列的初级实现有多种方法,例如基于无序或有序数组的实现,基于链表的实现。 这里仅介绍更高效的基于二叉堆的实现方式。
堆的定义
对于一个二叉堆数组,每个元素都要保证大于等于另两个特定位置的元素。相应地,这些特定位置的元素又至少要大于等于数组中的另外两个元素,依次类推。如果将所有元素画成一颗二叉树,将每个较大元素和两个较小元素用边连接就可以理解这种结构。
当一颗二叉树中的每个节点对应的元素都大于等于它的两个子节点所对应的元素,则该二叉树被称为堆有序。
在堆有序的二叉树中,每个结点都小于等于它的父节点(如果有的话)。从任意结点向上,都能够得到一列非递减的元素;从任意结点向下,都能够得到一列非递增的元素。特别地:
根节点是堆有序的二叉树中的最大结点。
如果用指针来表示堆有序的二叉树,则每个元素都需要三个指针来找到它的上下结点。但由上图所示,如果使用完全二叉树,表达就会变得特别简单。要画出这样的一颗完全二叉树,可以先定下根节点,然后一层一层地由上向下,由左至右,在每个结点的下方链接两个更小的结点,直至将N个结点全部连接完毕。完全二叉树只用数组而不需要指针就可以表示。二叉堆是一组能够用堆有序的完全二叉树排序的元素,并在数组中按照层级存储(不使用数组的第一个位置)。
在一个二叉堆中,位置k的结点的父节点的位置为k/2, 而它的两个子节点的位置则分别为2k和2k+1。由此可以通过计算数组的索引在树中上下移动。 用数组实现的完全二叉树的结构很严格,但它的灵活性已经足够高效地实现优先队列。需要注意的是,这种灵活性是由不使用数组第一个位置保证的。
堆的算法
通常情况下,用长度为N+1的数组来表示一个大小为N的堆,数组第一个空间不会存放元素,堆元素从数组下个位置开始存放。 堆的操作会首先进行一些简单的改动,打破堆的状态,然后再遍历堆并按照要求将堆的状态恢复。这个过程叫做堆的有序化。在有序化的过程中会遇到两种情况。当某个结点的优先级上升时(或在堆底加入一个新的元素)时,需要由下至上恢复堆的顺序。 当某个结点的优先级下降(例如,将根节点替换为一个较小的元素)时,需要由上至下恢复堆的顺序。
2.1 上浮
如果堆的有序状态因为某个结点变得比它的父结点更大而被打破,那么就需要通过交换它和它的父结点来修复堆。交换后,这个结点比它的两个子结点都大(一个是曾经的父结点,一个是曾经的父结点的子结点),但这个结点仍然可能比它现在的父结点更大。所以需要将这个结点不断地向上移动直到遇到更大的父结点。如图展示了由下至上的堆有序化示意图。
2.2 下沉
如果堆的有序状态因为某个结点变得比它的两个子结点或者其中之一更小而被打破,那么就需要通过交换它和它的两个子结点中较大者来修复堆。同时,类似于上浮,需要将这个结点不断地向下移动直到它的子结点都比它更小或者是到达了堆的底部。如图所示为下沉过程。
2.3 插入与删除
上浮与下沉方法是高效实现优先队列插入操作和删除最大元素操作的基础。插入元素,将新元素加到数组末尾,增加堆的大小并让新元素上浮到合适的位置;删除最大元素,从数组顶端删去最大元素并将数组的最后一个元素放到顶端,减小堆的大小并让这个元素下沉到合适的位置。操作过程如图所示。
API 具体实现:
#include <array>
#include <iostream>
#include <assert.h>
using namespace std;
template <class Key> class MaxPQ
{
public:
MaxPQ();
MaxPQ(int maxN) { pq = new Key[maxN]; N = 0;}
~MaxPQ() { delete[] pq; }
bool isEmpty() { return N == 0 ; }
int size() { return N; }
void insert( Key v)
{
pq[++N] = v;
swim(N);
}
Key delMax()
{
Key maxKey = pq[1];
exch(1,N--); // 与最后一个节点交换
pq[N+1] = NULL; // 防止越界
sink(1);
return maxKey;
}
void show()
{
for(int i = 1; i <= N; i++)
cout<<pq[i]<<" ";
cout<<endl;
}
private:
void swim(int k); // 上浮
void sink(int k); // 下沉
bool less(int i, int j); // 比较
void exch(int i, int j); // 交换
Key* pq;
int N ;
};
template<class Key> bool MaxPQ<Key>::less(int i, int j)
{
return pq[i] < pq[j];
}
template<class Key> void MaxPQ<Key>::exch(int i, int j)
{
Key temp = pq[i];
pq[i] = pq[j];
pq[j] = temp;
}
template<class Key> void MaxPQ<Key>::swim(int k)
{
int j = k / 2;
while(j > 0 && less(j,k))
{
exch(j,k);
k = j;
j = j/2;
}
}
template<class Key> void MaxPQ<Key>::sink(int k)
{
while( 2*k <= N)
{
int j = 2*k;
if(j < N && less(j,j+1)) j = j+1;
if(!less(k,j)) break;
exch(k,j);
k = j;
}
}
int _tmain(int argc, _TCHAR* argv[])
{
string data = "SORTEXAMPLE";
int n = data.length();
cout<<"n = " << n<<endl;
MaxPQ<char>* priority_queue = new MaxPQ<char>(100);
cout<<"input: ";
char * ch = (char *) data.c_str();
for (int i =0; i < n; i++)
{
cout<< ch[i] << " ";
priority_queue->insert(ch[i]);
}
cout<<"\nbinary heap : ";
priority_queue->show();
delete priority_queue;
return 0;
}
>>>
n = 11
input: S O R T E X A M P L E
binary heap : X S T P L R A M O E E
最后,优先队列应用——堆排序
堆排序可以分为两个阶段。在堆的构造阶段中,将原始数组重新组织安排进一个堆中;然后在下沉排序阶段,从堆中按递减顺序取出所有元素并得到排序结果。
堆构造
一个高效的办法是从右至左用下沉方法构造子堆。数组的每个位置都已经是一个子堆的根结点,如果一个结点的两个子结点都已经是堆了,那么在该结点调用sink() 方法可以将它们变成一个堆。开始时,只需要扫描数组中的一半元素,因为可以跳过大小为1的子堆。最后,在位置1上调用sink()方法,结束扫描。在排序的第一阶段,堆构造的目标就是得到一个堆有序的数组并使最大元素位于数组的开头。结论: 用下沉操作由N个元素构造堆只需少于2N次比较以及少于N次交换。
下沉排序
堆排序的主要工作都是在第二阶段完成的。将堆中的最大元素删除,然后放入堆缩小后数组中空出的位置。
排序算法代码:
template <class T> class SortMethod
{
pbulic:
...
static void heap_sort(T a[], int N);
private:
static void sink(T a[], int k, int N); // 堆下沉
static bool less(T a, T b){ return a < b;}
static void heap_exch( T a[], int i, int j)
{
exch(a,i-1,j-1);
}
static void exch(T a[], int i, int j)
{
T t = a[i]; a[i] = a[j]; a[j] = t;
}
}
template<class T> void SortMethod<T>::sink(T a[], int k, int N)
{
while( 2*k <= N)
{
int j = 2*k;
if( j < N && less(a[j-1],a[j]) ) j++;
if(!less(a[k-1],a[j-1])) break;
heap_exch(a,k,j);
k = j;
}
}
// 堆排序
template<class T> void SortMethod<T>::heap_sort(T a[], int N)
{
// 1.堆构造
for (int k = N/2; k >=1; k--)
sink(a,k,N);
// 2.下沉排序
while(N>1)
{
heap_exch(a,1,N--);
sink(a,1,N);
}
}
////////////////////////////////////////////////////////////
// example
string str = "SORTEXAMPLE";
char* data = (char*)str.c_str();
int N = str.length();
SortMethod<char>::print(data,N);
SortMethod<char>::heap_sort(data,N);
SortMethod<char>::print(data,N);
sorted = SortMethod<char>::isSortd(data,N);
cout<<"sorted: "<<sorted<<endl;
>>>
S O R T E X A M P L E
A E E L M O P R S T X
sorted: 1
用堆实现的优先队列在现代应用程序中越来越重要,能在插入操作和删除最大元素操作混合的动态场景中保证对数级别的运行时间。