1.队列
数据结构中,队列的概念直接来源于生活中的排队现象。排队讲究个“先来后到”,对应到数据结构的队列中,就被称为“先进先出”,(First in first out,FIFO)--先进入队列的也先从队列中被取出。
类比生活中的排队现象,容易发现队列是一个线性结构,一串结点按照顺序彼此按照顺序彼此相连。如果将人视为结点,就可以类比人们站成一字长龙排队购票的情形。
一个队列总是有两个端口。一个结点放入队列所用到的端口,习惯上被称之为“队列尾部”Tail,将结点从队列取出所用到的端口,习惯上被称之为“队列头部”Head。队列头部始终指示队列中的第一个结点,队列尾部始终指示队列后方第一个可以添加结点的位置。
人们在排队购票时,也具有这样的一头一尾。人们从队伍的尾部开始排队,耐心地等待整个队伍缓缓地向前运动,知道自己到达队伍的最前面,收到队头和队尾说法的影响,大家可能会认为,实际应用中队列应该是从头部开始向前运动。真实情况又是怎样的呢?
从观察者的角度,如果你站在队伍的外面,你相对售票窗口的物理位置是静止的;队伍作为一个整体,它最多只能到达售票窗口的铁栅栏前,队伍也不会远离窗口半步,那么可以说队伍相对售票窗口是静止的。以上述条件为前提,我们认为队伍是静止的。如果假设自己是一个队伍中的观察者,那么观察者相对售票窗口,总是逐渐接近的。排在前面的人,随着队伍的运动,为我们空出了前进的空间;排在后面的人,让我们感觉到了来自后方的压力而迫使我们不得不向前运动。于是,这个时候,我们便又有了队伍正在缓缓地向前运动的错觉。
上面的论证建立在售票窗口位置固定(或者说队列头部所在的位置固定)的基础上。物理学致死告诉我们,运动是相对的。在这一前提下,如果我们假设售票网点是相对移动的,而排队的人们却始终站在原地,这将是怎样的一番情形呢?
首先,作为一个旁观者,我们和队伍中的人一样相对地球是静止的。此时大家将看到,买到票得人离开队伍,新来的人则需站在队列的尾部,静静地等待售票网点的到来。随着售票网点的运动,我们会觉得队伍的头部整沿着队伍延伸的方向渐渐远离;而队伍也随着队列的尾部延伸向远方(队列尾部也在远离我们)。可以说此时的队伍是运动的,只不过运动的方向不是向前而是向后罢了。如果我们将观察的视角放到队伍中去,情形将会大部一样:随着售票网点的接近,我们偶尔会有队伍是向前运动的感觉,但地球是一个相对售票网点大的多的参照物,与之相对静止的感觉非常强烈,因此,我们会坚信队伍是静止的。
在上述的讨论中,无论我们采用何种假设,需要明确一点:售票窗口(网点)并不是队列的头部,我们只能说,售票窗口在队列头部的前面。队列是所有正在 排队的节点的统称。队列的头部仍然属于队列。他们指示的是队列的第一个结点所在的位置。需要注意的,在排队购票的模型中,正在购票的人已经不需要排队等待,因此从属于正在排队的人,可以说已经离开了队列--不具备被称为队列头部的基本条件。真正的队列头部是其审核第一个等待购票的人(所在的位置)。
2.队列的简单空间映射
在数据结构中,基本的队列本身就是线性而连续的,这在前面一节的讨论中已经提到够,简单地实现一个线性结构向地址空间的映射,并非难事。根据模型的需要,我们首先利用数组定义一段连续的空间用于存储队列,声明两个unsigned char型变量用于记录队列的头部和尾部在数组中位置(假设数组的大小不超过256)。
#define QUEUE_SIZE 10
int nQueueArray[QUEUE_SIZE]={0};
unsigned char cQueueHead=0;
unsigned char cQueueTail=0;
如果我们将数组QueueArray[]看成是地球,并假设“售票窗口”相对地球是静止的(队列的头部是固定的),那么变量QueueHead的值就恒为0。此时,想队列中增加一个数结点就好比是增加一个新的排队人,其函数为:
//向队列尾部添加结点
BOOL Add_Item(int nDATA)
{
if(QueueTail==QUEUE_SIZE)//判断队列是否已经满了
{
//队列已满,返回添加失败信号
return FALSE;
}
nQueueArray[cQueueTail]=nData;//将数据添加到队列尾部
cQueueTail++;//队列尾部向后移动一格
return TRUE;//返回队列添加成功信号
}
在这个函数中,我们注意到,由于队列的大小是有限制的(不能超过QUEUE_SIZE),因此,每次向队列中添加新的结点都需要首先判断“队列是否为满”--只是当队列仍有空间的情况下,才允许执行添加操作。理论上,在线性空间中,队列尾部和队列头部的差值就是队列中所含结点的数量。如果再添加一个unsigned char型变量cQueueCount用于记录队列中结点的数量,则上述关系可以表示为:
unsigned cQueueCount=0;
...
cQueueCount=cQueueTail-cQueueHead;
在当前的假设中表示队列头部位置的变量cQueueHead恒为0,因此,上述表达式可以改为:
cQueueCount=cQueueTail;
这就是为什么上面的函数中直接使用变量cQueueTail作为依据来判断队列“是否为满”的原因。
我们假设队列头部相对数组是静止的,因此,每当从队列头部取出一个节点时,队列中所有的后续结点都要向前移动一格,这和实际排队现象十分类似:
//从队列中取出一个结点
BOOL POP_Data(unsigned char *pData)
{
unsigned char n=0;
if(pData==NULL)//指针在使用前判断是否为空
{
return FLASE;
}
if(nQueueTail==nQueueHead)//判断队列是否为空
{
return FLASE;
}
//从队列头部取出数据
*nPopData=nQueueArray[nQueueHead];
//后续结点全部向前移动一格
for(n=nQueueHead;n<nQueueTail;n++)
{
nQueueArray[n]=nQueueArray[n+1];
}
nQueueTail--;
return TRUE;
}
在这个函数中,我们注意到,每次取数据前都需要判断一下”队列是否为空“--只是队列存在数据的情况下,我们可能从中取出有效的数据。根据前面介绍过的数量关系,当队列为空时,队列中结点的个数必定为0,于是此时便有:
cQueueTail-cQueueHead=0;
也就是
cQueueHead==cQueueTail;
这就是我们判定队列是否为空的必要条件。为什么说队列头部和队列尾部重合只是队列为空的必要条件而充分条件呢?这要从队列运动的另一种假设说起。
3.环形队列
在前面,我们提到过队列运动的两种假设:固定队列头部和固定队列结点。在前面我们详细讨论了固定队列头部的情况下,如何在线性地址空间中构建一个队列。这一队列通过简单的模仿生活中的排队现象,成功地实现了队列的基本功能,体现了队列“先进先去”的本质特征。然而,这种队列“可用却不实用”—每次从队列头部取出一个结点就必须依次移动后面所有的结点,这样消耗了大量的系统时间,因而在实际应用中不常见。
如果在头部取出结点以后,移动的不是结点而是队列头部,那么就可以省去移动整个队列所需的时间开销了。固定队列的结点 、移动队列的头部的“相对运动”假设正是适应这种需求而产生的。修改后的代码如下:
#define QUEUE_MAX_SIZE 100
int nQueueArray[QUEUE_MAX_SIZE]={0};
unsigned char cQueueHead=0;
unsigned char cQueueTail=0;
unsigned char cQueueCount=0;
//向队列的尾部添加一个数据
BOOL Add_Item(int nData)
{
cQueueCount=cQueueTail-cQueueHead;
if(cQueueCount==QUEUE_MAX_SIZE)//判断队列是否已满
{
//如果已满,失败信号回添加
return FLASE;
}
if(cQueueTaill==QUEUE_MAX_SIZE)
{
//判断队列的尾部是否超过数组的上限,返回添加失败信号
return FLASE;
}
nQueueArray[cQueueTail]=nData;
cQueueTail++;
return TRUE;
}
//从队列中取出一个结点
BOOL Get_Item(int *Pdata)
{
if(*Pdata == NULL)//防止传入的指针为空
{
return FLASE;
}
cQueueCount=cQueueTail-cQueueHead;
if(cQueueCount==0)//判断队列是否为空
{
return FLASE;
}
if(nQueueHead==QUEUE_MAX_SIZE)
{
return FLASE;//防止队列的头部超过数组的上限
}
Pdata=nQueueArray[cQueueHead];
cQueueHead++;
return TRUE;
}
对比2中的代码,程序做了一下的修改:
(1)由于队列的头部不再固定,因此,计算队列中结点的个数就必须按照给定的公式:由变量cQueueCount=cQueueTail-cQueueHead来计算。
(2)由于结点是固定的,因此队列尾部和头部都可以向数组末端移动,为了防止队列移出数组所在的地址空间,在进行队列操作前都需要仔细核对当前队列头部或尾部的运动范围(是否超出限定)
这样的修改虽然成功地解决了从队列中去数据时耗时较多的问题,但却拔出萝卜带出泥,引入了另外一个同样棘手的问题,如图1所示。结合图示,我们容易发现,随着队列的生长,队列的尾部向数组的尽头逐步移动如图(a)所示。随着结点一个一个从队列中取出,队列的头部也向着同意方向运动,如图(b)所示,直到二者重合为止。在这一过程中,队列的前方逐渐出现了一片丢弃了得空白区域,而队列本身的生长空间(当前允许容纳结点的最大数目)却随着队列头部和尾部靠近数组空间的尽头而越来越小。一方面是大片空白区域被闲置,一方面却又是队列空间告急、面临”吃穿家底“的危险--这种鲜明的对比营造了一种极具讽刺的画面,如图(c)所示。
如何解决这种尴尬呢?有人要说,直接把数组的首尾相接不就可以了吗?事实上,答案也正是这样,如图13-8所示。我们通过“函数构造法”将数组首尾相接,实现了一个环形结构。
ps:环形队列中在两种情况下会发生首尾重合的现象:一个是队列为空,一个是队列为满。区分二者的常见方法就是根据当前队列中结点的数量来判断。
在新的环形队列中,我们将两个分别用作指示队列头部和队列尾部的变量称为“头指针”和“尾指针”。这里所提到的“指针”概念和C语言中的指针概念是不相同的,仅仅只是一个逻辑意义上的指针,标明这两个变量分别用于“指示”某些信息。实际应用中,我们常常把这种逻辑上的指针称为“广义的指针”;将C语言中用于指示地址空间的指针称为“狭义指针”。理解“广义指针”与“狭义指针”的相同点,区别两者之间的不同点是灵活使用指针概念的关键。
在环形队列中,头尾指针都能按照“函数构造法”的限定访问整个地址空间。在这个过程中,有两种情形两者会发生重合现象。当队列为空时,由于首尾指针的差值始终为0,因此,必然是重合的。只不过,当队列为空时,一定是头指针追赶上了尾指针发生重合;而队列为满时,一定是尾指针“整整丢了头指针一圈”,从背后赶上了头指针。而区分二者最简单的方法就是判断此时队列中结点的数量。
由于队列为空我们能够推导出,此时头尾指针一定重合--这是该命题的必要条件。但是,由头尾指针重合却病不能委以地导出,此时队列为空--充分条件并不成立。结合队列结点数量,我们很容易得到两个充要条件:
(1)当首尾指针重合并且队列中结点的数量为零时,队列为空;
(2)当首尾指针重合并且队列中结点的数量不为零时,队列为满。
这两个结论是正确操作环形队列的关键。根据上面的讨论,我们修改后得到的环形队列代码为:
#define QUEUE_MAX_SIZE 10
int nQueueArray[QUEUE_MAX_SIZE]={0};
unsigned char cQueueHead=0;
unsigned char cQueueTail=0;
unsigned char cQueueCount=0;
//向队列中添加一个结点
BOOL Add_Item(int nData)
{
//判断队列是否为满
if((cQueueHead==cQueueTial) && (cQueueCount!=0))
{
return FALSE;
}
nQueuearray[cQueueTial]=nData;
//环形队列
cQueueTail++;
if(cQueueTail==QUEUE_MAX_SIZE)
{
cQueueTail=0;
}
return TRUE;
}
//从队列中取出一个结点
BOOL Get_Item(int *pData)
{
//判断指针是否为空
if(pData==NULL)
{
return FALSE;
}
//判断队列是否为空
if((cQueueTail==cQueueHead) && (cQueueCount==0))
{
return FALSE;
}
*pData=QueueArray[cQueueHead];
cQueueHead++;
if(cQueueHead==QUEUE_MAX_SIZE)
{
cQueueHead=0;
}
}
队列在逻辑上是线性的,数据从一端进入,从另一端被取出,满足先入先出(FIFO)的关系。环形队列也是队列,虽然对其进行访问的时候,使用到了环形的概念,但其逻辑形式仍然满足先入先出的特征。可以说,环形队列只是队列在具体实现(空间映射)时的一种变通,并没有改变队列的任何本质属性。对于用户来说,一个封装良好的队列,是否使用了环形结构,对其来说完全是透明的。因此,在以后的讨论中,如果不涉及到队列的具体实现细节,我们就可以把包括环形队列在内的所有队列,都看成是与“13.6节介绍的简单队列相同”的线性逻辑关系。