深入浅出数据结构C语言版(7)——特殊的表:队列与栈

  从(4)到(6),我们分别讨论了什么是线性表、什么是链表、为什么用链表以及如何用数组模拟链表(游标数组),而现在,我们要进入到对线性表的最终讨论,就是两种最有名的线性表:队列和栈。

  在(4)中,我们说过,其实我们所说的“线性表”用“队列”来形容会更易懂,但“队列”之名被一个更特殊的“线性表”给占用了,所以我们用“线性表”来形容一维排列起来的元素集合(如A1,A2,A3,……An)。那么,队列究竟特殊在何处以至于我们将“队列”之名赋予了它呢?回顾我们之前的文章,可以发现,对于普通的线性表,我们总是“想怎么来就怎么来”:我们可以在任一位置插入、删除,我们处理表中任一位置的元素。但是,当我们回过头来看看现实中的队列,你会发现并不是“想怎么来就怎么来”的,比如排队结账,总是以先到先结账的方式一个一个处理,而后到的则排末尾。所以我们之前所说的“线性表”和我们日常生活中的“队列”还是有蛮大区别的。那么我们现在要讨论的“队列”和“表”相比有什么不同之处以至于它可以用“队列”来称呼呢?答案很简单,那就是它(队列)也符合“先到先处理(出),后到排末尾”的逻辑

  

  想要理解队列是非常简单的,因为它的性质和我们日常生活中的队列是差不多的,线性、先到先处理(出)、后到排末尾。实现队列也是非常简单的,首先我们确定了队列是一种线性表,所以我们可以选择用数组、链表或游标数组来存储它,然后我们要限制可以对队列进行的操作,其实也就是限制Insert()和Delete()。根据队列的性质“先到先处理,后到排末尾”,很容易发现,我们只要令Insert()只能插到表尾,Delete()只能删除表头就行。不过为了更贴切,在队列中我们将使用名称Enqueue()和Dequeue()来代替Insert()和Delete(),并且我们令Dequeue()删除表头同时返回表头的值。

  可能有初学的读者会疑惑:我们为什么要特意实现队列?因为根据我们之前定义的线性表,完全可以达到实现队列的要求,比如Insert()时我们令N=-1(代表插入到表尾),Delete()时我们令N=1就好。其实我们特意实现队列而不是直接用通用的线性表的原因有两个,首先是我们不希望使用者有意或无意的打破“先到先处理,后到排末尾”的规矩,其次是我们如果使用Enqueue()和Dequeue()则我们不需要再给出N,并且在确定数据结构为队列时,Enqueue()和Dequeue()的代码量会比Insert()和Delete()更少,因为我们不需要再考虑插入位置、删除位置。

  也可能有读者会疑惑另一个问题:队列用于何处?在回答这个问题之前,让我们先回顾一下数据结构是什么,按照我们在(1)中的说法,数据结构就是研究如何存储数据的,但现在我们讨论的队列显然超出了这个范围。其实队列就是一种线性表,所以队列从数据结构的角度来说,它就是一个线性的元素集合也即线性表,而我们现在在讨论的,可以说是算法的范畴,我们讨论的是对于线性元素集合的特殊算法,这些特殊算法加上线性元素集合组成了我们所说的“队列”。因此,当我们问“队列用于何处”时,其实是问“什么时候要用队列这样的算法(思想)”?

  对于这个问题,如果泛泛而谈,就是所有需要“先到先处理(出),后到排末尾”的情景都需要使用队列。如果举例来说,那么CPU执行命令时就会运用到队列思想,比如CPU准备执行如下命令:

  深入浅出数据结构C语言版(7)——特殊的表:队列与栈

  显然这些命令组成了一个“命令队列”,CPU应该按照“先到先处理,后到排末尾”的方式来处理命令队列中的元素,否则就会出错。这就是符合队列需要的情景。更常见的一个例子是当我们输出字符串时,例如char s[]="Hello World!";printf("%s",s); 其中printf()就显然是使用了队列的算法(思想)。同时,这两个例子也给我们解释了为什么我们将Dequeue()设定为将当前表头元素返回的原因:因为我们要用它。比如CPU命令队列,我们删除表头表示该命令被CPU取走了,既然取走了,当然得“返回”给调用方来执行。

  说到这儿,想必队列的理解与实现都已经讲清楚了,虽然我们没有写出队列的示例代码,但真正理解了队列思想后,写一个队列也就是分分钟的事情。接下来我们要说一说可能更有趣的另一个表——栈。

  栈也叫做堆栈(但不是堆,堆是另一种数据结构,我们以后会说明),它的名字起源我们不得而知,但它的特性却比较有趣,可以说恰好是和队列“对着干”。队列是“先进先出”,而栈则是“先进后出”,或者说“先到进栈底,后到压上头”。你可以把栈想象成一个箱子,你先放进箱子里的东西显然会被后放进去的东西给压住,而当你拿东西时显然得先把后放进去的东西拿出来(因为它们在上面)。

  深入浅出数据结构C语言版(7)——特殊的表:队列与栈

  (栈顶“标记”着栈中“最上面”元素的“上一个位置”,栈底则“标记”着栈的“最底部位置”,栈底是不变的,栈顶随着入栈的元素数量变化而变化)

  我们已经知道,栈也是一种线性表,因此类似于我们之前讨论队列时的做法,我们只要将表的Insert()和Delete()“改装”一下,使它们都只能“插入到表尾”以及“删除表尾元素”即可实现栈。同样的,为了更贴切,我们将函数的名字分别改为Push()和Pop()。可能pop和push会让人觉得有点奇怪,为什么要用这两个词?原因就是栈与其说像个箱子,不如说像个弹匣更贴切。玩过玩具手枪的人应该都记得弹匣中是有弹簧和卡子的,当我们将一颗子弹“入栈”时,需要push,而当一颗子弹“出栈”时,显然就是pop出来的了。

  根据我们讨论队列时的经验,想必理解和实现一个栈都不会是太难的事情,所以真正“有趣”的事情应该是栈能够做什么。

  在我们讲解栈的“有趣”应用之前,我们先来说说比较无趣的一种栈的应用,那就是处理函数调用顺序。现在我们假设有这么一个函数:

int func (int N )
{
if(N<)
return -;
if(N==)
return ;
else
return N*func(N-);
}

  我们都知道,当我们调用func(5)时,func(5)会调用func(4),func(4)又会调用func(3)……而真正的执行顺序则是反过来,我们先计算出了func(1),然后根据func(1)计算出了func(2),然后根据func(2)计算出func(3)……我们执行函数的顺序恰好是“反过来”的。而这,会带来一个问题,那就是当我们执行完func(x)后,我们应该返回然后继续执行func(x+1),即调用了func(x)的那个函数(此处我们知道是func(x+1),但如果更复杂更不具有规律的多层次函数调用呢?),但是我们该如何确定我们在函数返回后继续执行哪个函数呢?毕竟有很多个函数在等待着继续执行。

  这个时候,就需要用到栈了。假设我们设计了一个栈,栈中的元素保存的是函数的相关信息(哪个函数,参数是什么,执行到哪了等等)。那么,对于上述多层次函数调用,我们就可以通过这个栈来确定执行完某个函数后应该继续执行哪个函数。

  首先,我们执行main函数,执行过程中我们执行到了调用func(3)(为了更容易完整写出整个过程,我们假设不是调用func(5)而是调用func(3))的地方,因为我们现在要跳去执行func(3)了,所以我们把main函数相关信息保存为一个“元素”并Push()入栈:

  深入浅出数据结构C语言版(7)——特殊的表:队列与栈

  然后func(3)中执行到了调用func(2)的地方,于是我们保存func(3)信息,Push(),然后执行func(2)

  深入浅出数据结构C语言版(7)——特殊的表:队列与栈

  接着我们在func(2)中执行到了调用了func(1)的地方,于是我们保存func(2)的信息,Push(),然后执行func(1):

  深入浅出数据结构C语言版(7)——特殊的表:队列与栈

  接着,我们执行func(1)并执行完毕,返回,于是我们检查栈,看其中是否还有未执行完毕的函数,如果有我们则执行它。显然地,我们检查到栈内有未执行完的函数,于是我们Pop()弹出栈顶的那个函数func(2),执行它。可以发现,这样的执行顺序是对的!它符合我们调用函数的顺序、逻辑关系!

  深入浅出数据结构C语言版(7)——特殊的表:队列与栈

  执行func(2)没有遇到其它调用(如果遇到,则又把func(2)入栈,执行那个函数,其它函数调用也以此类推),因此我们执行完了func(2),检查栈,发现还有未执行完的函数,于是Pop()出栈顶的函数func(3)继续执行:

  深入浅出数据结构C语言版(7)——特殊的表:队列与栈

  func(3)执行完后我们Pop()出main函数

  深入浅出数据结构C语言版(7)——特殊的表:队列与栈

  当main函数执行完后,栈内已没有函数,所以程序执行完毕!

  无趣但真实存在的栈的应用说完了,接下来我们来说说“有趣”的应用。之所以说“有趣”,是因为当你仔细看完学完后,可以在命令行界面实现一个四则运算计算器程序!

  可能有些初学者乍一听会觉得实现一个计算器是很容易的事情,但是命令行界面的特殊性导致了它并不是那么容易的事情(如果是win32编程,可能会稍微容易一点,但此处不谈)。当说要实现计算器时,初学者脑海很容易浮现出:使用者键入“2+3=”,然后程序用scanf()接收一个int一个char一个int(也可能有更好一些的初学者会想着用float、double),然后判断char是+、-、*还是/,然后计算、输出的场景。但是,在此提醒一下初学者,如果使用者键入的是类似“2+(3*4)-12*(-1)=”的东西呢?或者使用者键入了非法输入比如“2++3=”呢?要知道,使用者键入的其实是“一个个字符”(这一点很关键,当你真正准备实现你的计算器程序时,请牢记使用者输入的应该看做“一个个字符”),scanf()中的占位符,比如%d等,表示的是我们希望怎样理解这些“字符”,但当用户输入的“字符”并没有绝对的规律、顺序时,不能指望scanf()。所以实现一个计算器并不是那么容易的事情。

  不过为了关注于我们实现计算器的核心部分,或者说关注于栈的应用部分。我们暂且将如何获取输入以及如何判断输入的问题放一边,我们假定我们已经正确处理好了输入并将整个表达式存入了如下数组中(相关解释已在代码注释中):

#include <stdio.h>

#define SIZE 50

struct elem {
int num = ; //当元素为操作数时,num保存操作数的值
char oper = '='; //当元素为操作符时,oper保存操作符的字符,如'+''-''*''/'
bool IsNum = false; //用来表示当前元素是否为操作数
};
typedef struct elem Elem; Elem Expression[SIZE]; //存放表达式的数组,根据元素的成员IsNum来判断元素是操作数还是操作符 //main()中的代码为示例,展示Expression[]的尝试性操作
int main()
{
Expression[].num = ;Expression[].IsNum = true;
Expression[].oper = '+';
Expression[].num = ;Expression[].IsNum = true; //遍历数组每个元素
for (int i = ;i < SIZE;++i)
{
if (Expression[i].IsNum) //若当前元素IsNum,则输出其成员num
printf("%u", Expression[i].num);
else
{
if (Expression[i].oper == '=') //若当前元素为'=',则表示表达式结束,输出'='并跳出循环
{
printf("%c\n", Expression[i].oper);
break;
}
else //若当前元素不是数字也不是'='则输出元素的成员oper
printf("%c", Expression[i].oper);
}
} return ;
}

  这样一来,我们就可以较为简单地处理(输出)表达式。那么,我们现在已经有了表达式了(假设有了),下一步该如何计算它呢?由于篇幅问题,这一点我们这篇文章暂且不谈,只提示处理表达式需要用到栈,具体的方法或者说算法、原理,我们将在下一篇博文中讲解。

上一篇:.net 项目 调用webservice 出错,异常信息:对操作“xxx”的回复消息正文进行反序列化时出错。解决方案。


下一篇:ORM概述(对象关系映射)