这里介绍一种重要的略为复杂的数据结构“优先队列”.我所提供的不是教学,而是希望提供一个解决类似问题的思路.因此以学习为目的,而不是以实用, 而且涉及到较多的名词概念或术语,对于不懂的建议查找数据结构的书籍,这里考虑到文章的篇幅就不多做介绍. ^^
优先队列(priority queue)是一个以集合为基础的抽象数据类型,每个元素都有一个优先级,对优先队列执行的操作有1) 查找;2) 插入一个新元素;3) 删除。在最小优先队列(min priority queue)中,查找操作用来搜索优先权最小的元素,删除操作用来删除该元素;对于最大优先队列(max priority queue),查找操作用来搜索优先权最大的元素,删除操作用来删除该元素。优先权队列中的元素可以有相同的优先权,查找与删除操作可根据任意优先权进行。
优先队列的数据结构实现方式有很多种: 无序链表,有序链表,二叉搜索树,左高树,优先级树等等.其中优先级树满足以下两个性质:
1)树中每个结点只存一个元素
2)树中任意一个结点的值高于其儿子结点中存储的元素的值.
特别的,当一棵优先级树为近似满二叉树时,我们称它为堆或偏序树.比如堆排序就是利用这种数据结构(优先队列)来完成的,优先级就是待排数据的值.
以下举5个具体的例子说明优先队列在解决问题中的应用.
(1) 在作业调度算法中常常利用的优先队列数据结构,先举”短作业优先调度”为例子:
一般情况下,我们希望将耗时少的作业尽快完成,也就是说短作业都优先于已经消耗一些时间的作业.使短作业优先而又不让锁死长作业的方法之一就是为每个作业分配一个优先级.分配优先级公式是100 * (Tused(x)-Tinit(x)),Tused()是作业总共消耗的时间,Tinit()是作业到达的时间.100是一个可以根据需要进行调整的数,通常要大等于最大的作业总数.一个作业可以用一个由作业标识符和作业优先级组成的结构体来表示,即:
struct ProcessType{
int id;
int priority;
};
为了给作业安排时间,分时系统中设置了类型为ProcessType的优先队列WAITING. 过程Initial()和Select()对该优先队列做处理.每当一个新作业到达时,过程Initial()就将这个作业插入优先队列WAITING中.当系统有时间段可以使用时,Select()就从中选出一个优先权最高的一个作业,并将该作业从WAITING中删除,由Select()暂存该作业记录,以便完成时间段后带回一个新的优先级重新入队. 这里假设有DeleteMin()函数返回是指向优先级最高的作业的指针,CurrentTime()是用来记时的函数,Execute()是处理作业的过程.下面给出该算法实现的框架.
void Initial(int p) //p是作业号
{
ProcessType process;
process.id = p;
process.priority = -CurrentTime();//记时
Insert(process, WAITING); //对新达到的作业入优先队列
}
void Select()
{
int begintime, endtime;
ProcessType process;
process = (DeleteMin(WAITING))->element;//选中优先级最高的作业并出队
begintime = CurrentTime();
Execute(process.id); //作业调入运行
endtime = CurrentTime();
process.priority += 100 * (endtime-begintime); //重新分配优先级
Insert(process, WAITING);//process带回新的优先级重新入队
}
(2)利用优先队列的堆结构完成排序即堆排序,可以认为其中的优先级就是待排数据的值:
void PushDown_MinHeap(int first, int last)
{//完成构建最小堆性质的功能
long i, j, x;
i = first;
j = i * 2;
x = Data[i];
while (j <= last){
if (j < last && Data[j] < Data[j+1])//选择左右儿子中较大者的下标
j++;
if (x < Data[j]){//压堆,以保持最小堆性质
Data[i] = Data[j];
i = j;
j = 2 * i;
}
else
break;
}
Data[i] = x;
}
void MinHeapSort(int first, int last) //以下是抽象的堆排序算法
{//如果L是非空的表,标记*步骤可以利用到PushDown_MinHeap()直接完成初始化
for (表L中所有元素) do Insert(x,S); //*
while (not EMPTY(S)){
y = DeleteMin(S);
输出y;
}
}/*堆排序具体实现见http://expert.csdn.net/Expert/topic/2059/2059607.xml?temp=.8768579 */
(3) 优先队列在很多”贪心算法”中都可以得到很好的应用,这点很值得注意.例如Huffman树的编码规则具有”贪心选择”的性质.假定给出优先队列的Insert()和DeleteMin()2个基本运算,使用堆的数据结构,抽象的算法实现过程大致如下:
PriorityQueueType *Huffuman(PriorityQueueType *S)//构建霍夫曼编码树
{//如果L是非空的表,标记*步骤可以利用到PushDown_MinHeap()直接完成初始化
for (表L中所有元素) do Insert(x,S); //*
for (int i = 0; i < n; i++){
x = DeleteMin(S); //取两个最小权的结点
y = DeleteMin(S);
PriorityQueueType *t = new < PriorityQueueType>;
MakeTree(x, y, t);//把结点x,y合并成以结点t为根的树.
t.priority = x.priority + y.priority;
Insert(t, S);//把根树t,重新入优先队列
}//循环结束后,优先队列S中只剩1个结点.
return GetMin(S)//返回优先队列S中的最小权的结点,即为Huffman树根
}
(4) 机器调度问题: 考察一个机械厂,其中有m 台一模一样的机器。现有n 个作业需要处理,设作业i 的处理时间为ti ,这个时间为从将作业放入机器直到从机器上取下作业的时间。所谓调度( s c h e d u l e)是指按作业在机器上的运行时间对作业进行分配,使得:
• 一台机器在同一时间内只能处理一个作业。
• 一个作业不能同时在两台机器上处理。
• 作业i 一旦运行,则需要ti 个时间单位。
我们的任务是写一个程序,以便确定如何进行调度才能使在m 台机器上执行给定的n 个作业时所需要的处理时间最短。
调度问题是著名的NP-复杂问题(NP表示nondeterministic polynornial) 中的一种。NP-复杂及NP-完全问题是指尚未找到具有多项式时间复杂性算法的问题。这类问题通常需要采用近似算法来解决. 在调度问题中,采用了一个称为最长处理时间优先(longest processing time first, LPT)的简单调度策略,它可以帮助我们获得一个较理想的调度长度,该长度为最优调度长度的4/3-1/(3*m)。在LPT算法中,作业按它们所需处理时间的递减顺序排列。在分配一个作业时,总是将其分配给最先变为空闲的机器。
定理: F*(I)为在m台机器上执行作业集合I的最佳调度完成时间,F(I)为采用LPT调度策略所得到的调度完成时间,则(F(I) / F*(I)) <= 4/3 – 1/(3*m).
实际上,L P T调度会比上述所给界限更接近最优解.很明显的,LPT调度具有某种贪心(longest processing time first)的性质,据此我们可以采用优先队列来解决.也就是对所给各作业的完成时间T[N]做按降序排序后即为作业调度的序列.算法就是用堆结构实现优先队列的排序,即堆排序.
(5)装箱问题: 设有N件物品,每件物品的体积为S1,S2,..,Sn,且0<Si<=1 (i = 1,2,...,n).现在有一批箱子,每只现在的容量为1个单位.现在的问题是:能够容纳这N件物品的箱子至少需要多少只.或者说,给定正整数M,问能否把这N件物品放入此M只箱子中去?
这个问题是很有实际意义的,例如在计算机内存中分配数据,又如从一个大的原材料中剪裁各种需要尺寸的零件等.装箱问题和装配问题的本质是一样的,属于NP-复杂问题.为了能找到装箱问题的最优解,我们需要将n项的集合划分成n个及少于n个的子集,但这样划分的总数将超过(n/2)^(n/2),因此这种方法将不是多项式界的.根据上述给出的定理,我们同样可以构造出一个近似的贪心(NIFF-Noningressing First Fit)算法,简称First-Fit.其意思是每个物体一次放在它的第一个能够放得进去得箱子中. 设V*(I)是最优解,根据上面给出的定理可以推出,由NIFF算法所产生的放在除V*(I)个箱子外中的物体体积至多为1/3.并且还可以推出由NIFF算法得到的放在额外箱子中的物体个数至多为V*(I)-1个.(证明略) 此外装箱问题可以很多种近似算法,以下先介绍抽象的NIFF算法过程:
/*
B[]为记录放在第j只箱子的所有物品标号的集合.
以下是由大到小的装填算法;
*/
bool BNIFF(int n, int m, PriorityQueueType *S, TSet B[])
{
float *b = new[m+1];
for (int i = 0; i <= m; i++)
b[i] = 0; //b[i]标记第i只箱子已装的容量
物品体积S[i]按其值大小为优先级降序整序; //提高近似精度
for (t = 0; t < n; t++){
j = 1; //从标号为1的箱子开始搜索
while (b[j] + S[t] > 1 && j <= m)
j++; //寻找最先能容纳物品S[t]的第j只箱子,优先放入该箱
if (j >m) return false; //M只箱子容纳不下此N件物品
B[j] <- B[j]∪{t}; //第t件物品存放在第j只箱子中,加入集合B[j]记录
b[j] += S[t];
}
Output(B[1], B[2], ... B[M])
return true;
}
容易证明,上述NIFF算法的时间效率为O(N^2),为多项式界的算法.而上面优先队列S的数据结构仅仅是一个简单的有序数组.
总结: 以上主要是介绍数据结构中的”优先队列”在一些典型问题中的应用.在”优先队列”中,优先是表示”服务”不是排队顺序进行的,而是按照每个对象的优先级顺序提供服务.此种性质很适合利用在某些具有贪心选择性质的算法中(作为实现该算法的数据结构),这点也是叙述上面几个问题所要说明的重心.
特别指出的是,算法是不依赖具体的计算机语言的,上面大都使用抽象的算法模型来描述解决问题的方法,也使用了抽象数据类型来做为描述算法的工具,以上做法都是为了方便描述算法原理而不拘泥于具体实现. 很显然的,不同计算机语言都可以实现同个算法,而实现某种算法不但可以使用不同的抽象数据类型,更可以为该抽象数据类型选择合适的具体实现的手段.
最后还需要注意的是,上面的重点更侧重优先队列这种抽象数据类型在解决某类问题所使用的算法方面的应用或思路.文章中我也没有给出优先队列的某种具体实现代码,因为它的实现方式有多种,也对应于不同情况的需要效率也不尽相同,需要的话请自行查阅参考书,因为数据结构的书会比我说的详细,深刻的多,这里我就不多叙述. :)