文章目录
3.1 栈
3.1.1 栈的逻辑结构
- 栈的定义
栈是限定仅在表尾进行插入和删除操作的线性表。允许插入和删除的一端称为栈顶,另一端称为栈底,不含任何数据元素的栈称为空栈。
生活中栈的例子:
弹夹中的子弹,先进去的要后出来
计算机中栈的例子:
Word、PS的undo操作
首先它是一个线性表,也就是说,栈元素具有线性关系,即前驱后继关系。只不过它是一种特殊的线性表而已。定义中说是在线性表的表尾进行插入和删除操作,这:
里表尾是指栈顶,而不是栈底。
它的特殊之处就在于限制了这个线性表的插入和删除位置,它始终只在栈顶进行。这也就使得:栈底是固定的,最先进栈的只能在栈底。
栈的插入操作,叫作进栈,也称压栈、入栈。类似子弹入弹夹。
栈的删除操作,叫作出栈,也有的叫作弹栈。如同弹夹中的子弹出夹。
2. 栈的抽象数据类型定义
ADT Stack
Data
栈中元素具有相同类型及后进先出特性,相邻元素具有前驱和后继关系
Operation
InitStack
前置条件:栈不存在
输入:无
功能:栈的初始化
输出:无
后置条件:构造一个空栈
DestroyStack
前置条件:栈已存在
输入:无
功能:销毁栈
输出:无
后置条件:释放栈所占用的存储空间
Push
前置条件:栈已存在
输入:元素值x
功能:在栈顶插入一个元素x
输出:如果插入不成功,抛出异常
后置条件:如果插入成功,栈顶增加了一个元素
Pop
前置条件:栈已存在
输入:无
功能:删除栈顶元素
输出:如果删除成功,返回被删元素值,否则,抛出异常
后置条件:如果删除成功,栈顶减少了一个元素
GetTop
前置条件:栈已存在
输入:无
功能:读取当前的栈顶元素
输出:若栈不空,返回当前的栈顶元素值
后置条件:栈不变
Empty
前置条件:栈已存在
输入:无
功能:判断栈是否为空
输出:如果栈为空,返回1,否则,返回0
后置条件:栈不变
endADT
3.1.2 栈的顺序存储结构及实现
- 栈的顺序存储结构——顺序栈
栈的顺序存储结构称为顺序栈。
a.把数组中下标为0的一端作为栈底,同时附设指针top指示栈顶元素在数组中的位置。
b. 设存储栈元素的数组长度为StackSize,则栈空时栈顶指针top=-1;
栈满时栈顶指针top=StackSize-1。
c.入栈时,栈顶指针top加1;出栈时,栈顶指针top减1。 - 顺序栈的实现
声明
const int StackSize=10; //10只是示例性的数据,可以根据实际问题具体定义
template <class T> //定义模板类SeqStack
class SeqStack
{
public:
SeqStack( ) {top=-1;} //构造函数,栈的初始化
~SeqStack( ) { } //析构函数
void Push( T x ); //将元素x入栈
T Pop( ); //将栈顶元素弹出
T GetTop( ) {if (top!=-1) return data[top];} //取栈顶元素(并不删除)
bool Empty( ) {top==-1? return 1: return 0;} //判断栈是否为空
private:
T data[StackSize]; //存放栈元素的数组
int top; //栈顶指针,指示栈顶元素在数组中的下标
};
入栈(插入)
template <class T>
void SeqStack::Push(T x)
{
if (top== StackSize-1) throw "上溢";
top++;
data[top]=x;
}
出栈(删除)
template <class T>
T SeqStack:: Pop( )
{
if (top==-1) throw "下溢";
return data[top--];
}
- 两栈共享空间
提出问题:在一个程序中如果需要同时使用具有相同数据类型的两个栈时,如何处理呢?
解决方案一:为每个栈开辟一个数组空间;
解决方案二:使用一个数组来存储两个栈,让一个栈的栈底为该数组的始端,另一个栈的栈底为该数组的末端,每个栈从各自的端点向中间延伸,如图所示。
其中,top1和top2分别为栈1和栈2的栈顶指针,StackSize为整个数组空间的大小,栈1的底固定在下标为0的一端;栈2的底固定在下标为StackSize-1的一端。
其实栈的顺序存储还是很方便的,因为它只准栈顶进出元素,所以不存在线性表插入和删除时需要移动元素的问题。不过它有一个很大的缺陷,就是必须事先确定数组存储空间大小,万一不够用了,就需要编程手段来扩展数组的容量,非常麻烦。对于一个栈,我们也只能尽量考虑周全,设计出合适大小的数组来处理,但对于两个相同类型的栈,我们却可以做到最大限度地利用其事先开辟的存储空间来进行操作。
打个比方,两个大学室友毕业同时到北京工作,开始时,他们觉得住了这么多年学校的集体宿舍,现在工作了一定要有自己的私密空间。于是他们都希望租房时能找到独住的一居室,可找来找去却发现,最便宜的一居室也要每月1500元,地段还不好,实在是承受不起,最终他俩还是合租了一套两居室,一共2000元,各出一半,还不错。
对于两个一居室,都有独立的卫生间和厨房,是私密了,但大部分空间的利用率却不高。而两居室,两个人各有卧室,还共享了客厅、厨房和卫生间,房间的利用率就显著提高,而且租房成本也大大下降了。
同样的道理,如果我们有两个相同类型的栈,我们为它们各自开辟了数组空间,极有可能是第一个栈已经满了,再进栈就溢出了,而另一个栈还有很多存储空间空闲。这又何必呢?我们完全可以用一个数组来存储两个栈,只不过需要点小技巧。
其实关键思路是:它们是在数组的两端,向中间靠拢。top1和top2是栈1和栈2的栈顶指针,可以想象,只要它们俩不见面,两个栈就可以一直使用。
从这里也就可以分析出来,栈1为空时,就是top1等于-1时;而当top2等于n时,即是栈2为空时,那什么时候栈满呢?
想想极端的情况,若栈2是空栈,栈1的top1等于n-1时,就是栈1满了。反之,当栈1为空栈时,top2等于0时,为栈2满。但更多的情况,其实就是我刚才说的,两个栈见面之时,也就是两个指针之间相差1时,即top1+1==top2为栈事实上,使用这样的数据结构,通常都是当两个栈的空间需求有相反关系时,也就是一个栈增长时另一个栈在缩短的情况。就像买卖股票一样,你买入时,一定是有一个你不知道的人在做卖出操作。有人赚钱,就一定是有人赔钱。这样使用两栈共享空间存储方法才有比较大的意义。否则两个栈都在不停地增长,那很快就会因栈满而溢出了。
3.1.3 栈的链接存储结构及实现
- 栈的链接存储结构——链栈
栈的链接存储结构称为链栈。
想想看,栈只是栈顶来做插入和删除操作,栈顶放在链表的头部还是尾部呢?由于单链表有头指针,而栈顶指针也是必须的,那干吗不让它俩合二为一呢,所以比较好的办法是把栈顶放在单链表的头部。另外,都已经有了栈顶在头部了,单链表中比较常用的头结点也就失去了意义,通常对于链栈来说,是不需要头结点的。
PS:第三版的ppt和书上p75的图3-5中,一定要注意,普通单链表中的第一个元素是从a1开始到an,而这里则调换了一个方向,栈顶指向的是an,如果不注意还以为插入和删除的一端在普通单链表的尾部,这是不对的。 - 链栈的实现
template <class T>
class LinkStack
{
public:
LinkStack( ) {top=NULL;} //构造函数,置空链栈
~LinkStack( ); //析构函数,释放链栈中各结点的存储空间
void Push(T x); //将元素x入栈
T Pop( ); //将栈顶元素出栈
T GetTop( ) {if (top!=NULL) return top->data;} //取栈顶元素(并不删除)
bool Empty( ) {top==NULL? return 1: return 0;} //判断链栈是否为空栈
private:
Node<T> *top; //栈顶指针即链栈的头指针
};
链栈的基本操作示意图如下图所示:
3.1.4 顺序栈和链栈的比较
顺序栈和链栈的基本操作的时间性能比较
顺序栈和链栈的进栈push和出栈pop操作都很简单,没有任何循环操作,时间复杂度均,为O(1)
顺序栈和链栈的基本操作的空间性能比较
对比一下顺序栈与链栈,它们在时间复杂度上是一样的,均为O(1)。对于空间性能,顺序栈需要事先确定一个固定的长度,可能会存在内存空间浪费的问题,但它的优势是存取时定位很方便,而链栈则要求每个元素都有指针域,这同时也增加了一些内存开销,但对于栈的长度无限制。所以它们的区别和线性表中讨论的一样,如果栈的使用过程中元素变化不可预料,有时很小,有时非常大,那么最好是用链栈,反之,如果它的变化在可控范围内,建议使用顺序栈会更好一些。
补充:栈的作用
有的同学可能会觉得,用数组或链表直接实现功能不就行了吗?干吗要引入栈这样的数据结构呢?这个问题问得好。
其实这和我们明明有两只脚可以走路,干吗还要乘汽车、火车、飞机一样。理论上,陆地上的任何地方,你都是可以靠双脚走到的,可那需要多少时间和精力呢?我们更关注的是到达而不是如何去的过程。
栈的引入简化了程序设计的问题,划分了不同关注层次,使得思考范围缩小,更加聚焦于我们要解决的问题核心。反之,像数组等,因为要分散精力去考虑数组的下标增减等细节问题,反而掩盖了问题的本质。
所以现在的许多高级语言,比如Java、C#等都有对栈结构的封装,你可以不用关注它的实现细节,就可以直接使用Stack的push和pop方法,非常方便。
3.2 队列
3.2.1 队列的逻辑结构
- 队列的定义
队列是只允许在一端进行插入操作,而另一端进行删除操作的线性表。允许插入(也称入队)的一端称为队尾,允许删除(也称出队)的一端称为队头。 - 队列的抽象数据类型定义
ADT Queue
Data
队列中元素具有相同类型及先进先出特性,相邻元素具有前驱和后继关系
Operation
InitQueue
前置条件:队列不存在
输入:无
功能:初始化队列
输出:无
后置条件:创建一个空队列
DestroyQueue
前置条件:队列已存在
输入:无
功能:销毁队列
输出:无
后置条件:释放队列所占用的存储空间
EnQueue
前置条件:队列已存在
输入:元素值x
功能:在队尾插入一个元素
输出:如果插入不成功,抛出异常
后置条件:如果插入成功,队尾增加了一个元素
DeQueue
前置条件:队列已存在
输入:无
功能:删除队头元素
输出:如果删除成功,返回被删元素值,否则,抛出删除异常
后置条件:如果删除成功,队头减少了一个元素
GetQueue
前置条件:队列已存在
输入:无
功能:读取队头元素
输出:若队列不空,返回队头元素
后置条件:队列不变
Empty
前置条件:队列已存在
输入:无
功能:判断队列是否为空
输出:如果队列为空,返回1,否则,返回0
后置条件:队列不变
endADT
3.2.2 队列的顺序存储结构及实现
- 队列的顺序存储结构——循环队列
放宽队列的所有元素必须存储在数组的前n个单元这一条件,入队和出队操作时间性能为O(1),但由此造成了队列的单向移动。
现实当中,你上了公交车,发现前排有两个空座位,而后排所有座位都已经坐满,你会怎么做?立马下车,并对自己说,后面没座了,我等下一辆?
没有这么笨的人,前面有座位,当然也是可以坐的,除非坐满了,才会考虑下一辆。
2.解决的方案(如上图b)所示)
3.结论:队列的这种头尾相接的顺序存储结构称为循环队列
设存储循环队列的数组长度为QueueSize,则循环队列的长度为(rear-front+QueueSize) % QueueSize。
4.这时又会产生什么新问题?(如下图所示)队空和队满的判定问题,如何解决?
- 循环队列的实现
const int QueueSize=100; //定义存储队列元素的数组的最大长度
template <class T> //定义模板类CirQueue
class CirQueue
{
public:
CirQueue( ) {front=rear=0;} //构造函数,置空队
~ CirQueue( ) { } //析构函数
void EnQueue(T x); //将元素x入队
T DeQueue( ); //将队头元素出队
T GetQueue( ); //取队头元素(并不删除)
bool Empty( ) {front==rear? return 1: return 0;} //判断队列是否为空
private:
T data[QueueSize]; //存放队列元素的数组
int front, rear; //队头和队尾指针,分别指向队头元素的前一个位置和队尾元素的位置
};
循环队列的入队算法如下:
template <class T>
void CirQueue::EnQueue(T x)
{
if ((rear+1) % QueueSize ==front) throw "上溢";
rear=(rear+1) % QueueSize; //队尾指针在循环意义下加1
data[rear]=x; //在队尾处插入元素
}
循环队列的出队算法如下:
template <class T>
T CirQueue::DeQueue( )
{
if (rear==front) throw "下溢";
front=(front+1) % QueueSize; //队头指针在循环意义下加1
return data[front]; //读取并返回出队前的队头元素,注意队头指针
}
3.2.3 队列的链接存储结构及实现
队列的链式存储结构,其实就是线性表的单链表,只不过它只能尾进头出而已,我们把它简称为链队列。为了操作上的方便,我们将队头指针指向链队列的头结点,而队尾指针指向终端结点。
- 队列的链接存储结构——链队列
队列的链接存储结构称为链队列。
- 链队列的实现
将队列的抽象数据类型定义在链队列存储结构下用C++中的类实现。
template <class T>
class LinkQueue
{
public:
LinkQueue( ); //构造函数,初始化一个空的链队列
~LinkQueue( ); //析构函数,释放链队列中各结点的存储空间
void EnQueue(T x); //将元素x入队
T DeQueue( ); //将队头元素出队
T GetQueue( ) {if (front!=rear) return front->next->data;} //取链队列的队头元素
bool Empty( ) {front==rear? return 1: return 0;} //判断链队列是否为空
private:
Node<T> *front, *rear; //队头和队尾指针,分别指向头结点和终端结点
};
创建链队列(即构造函数)的算法(略)
入队算法(略)
链队列的出队算法,注意在队列长度为1时的特殊处理(略)
3.2.4 循环队列和链队列的比较
循环队列和链队列的基本操作的时间性能的比较:
对于循环队列与链队列的比较,可以从两方面来考虑,从时间上,其实它们的基本操作都是常数时间,即都为0(1]的,不过循环队列是事先申请好空间,使用期间不释放,而对于链队列,每次申请和释放结点也会存在一些时间开销,如果入队出队频繁,则两者还是有细微差异。
循环队列和链队列的空间性能的比较:
对于空间上来说,循环队列必须有一个固定的长度,所以就有了存储元素个数和空间浪费的问题。而链队列不存在这个问题,尽管它需要一个指针域,会产生一些空间上的开销,但也可以接受。所以在空间上,链队列更加灵活。
总的来说,在可以确定队列长度最大值的情况下,建议用循环队列,如果你无法预估队列的长度时,则用链队列。
参考书目:
大话数据结构
数据结构——从概念到C++实现(第2版)
数据结构(C++版)教师用书
/**********************************
对应教材3.5.1节,括号匹配问题 (改)
原代码没有利用继承,直接写
这里改用了继承
***********************************/
#include <iostream>
#include <string>
using namespace std;
const int StackSize = 10; //10是示例性的数据,根据实际问题具体定义
template <typename DataType> //定义模板类SeqStack
class SeqStack
{
public:
SeqStack( ); //构造函数,初始化一个空栈
~SeqStack( ); //析构函数
void Push( DataType x ); //入栈操作,将元素x入栈
DataType Pop( ); //出栈操作,将栈顶元素弹出
DataType GetTop( ); //取栈顶元素(并不删除)
int Empty( ); //判断栈是否为空
private:
DataType data[StackSize]; //存放栈元素的数组
int top; //游标,栈顶指针,为栈顶元素在数组中的下标
};
template <typename DataType>
SeqStack<DataType> :: SeqStack()
{
top = -1;
}
template <typename DataType>
SeqStack<DataType> :: ~SeqStack()
{
}
template <typename DataType>
int SeqStack<DataType> :: Empty()
{
if(top == -1)
return 1;
else
return 0;
}
template <typename DataType>
DataType SeqStack<DataType> :: GetTop( )
{
if(top == -1)
throw "下溢异常";
else
return data[top];
}
template <typename DataType>
void SeqStack<DataType> :: Push(DataType x)
{
if (top == StackSize - 1) throw "上溢";
data[++top] = x;
}
template <typename DataType>
DataType SeqStack<DataType> :: Pop( )
{
DataType x;
if (top == -1) throw "下溢";
x = data[top--];
return x;
}
//普通类继承类模板
class Matcher: public SeqStack <string>
{
public:
Matcher(string str);
~Matcher(){ };
int match();
private:
string str;
};
Matcher :: Matcher(string str){
this->str = str;
}
int Matcher :: match()
{
int i, top = -1; /* top为字符对象S的尾指针 */
for (i = 0; str[i] != '\0'; i++) /* 依次对str对象的每个字符, str[i]进行处理 */
{
if (str[i] == ')') { /*当前扫描的字符是右括号*/
if (!this->Empty()) this->Pop();
else return -1;
}
else if (str[i] == '(') /*当前扫描的字符是左括号*/
this->Push(&str[i]);
}
if (this->Empty()) return 0; /*栈空则括号正确匹配*/
else return 1;
}
int main( )
{
string str; /*定义尽可能大的字符数组以接收键盘的输入*/
int k; /*k接收调用函数Match的结果*/
cout << "请输入一个算术表达式:";
cin >> str; /*将表达式以字符串方式输入*/
Matcher m(str);
k = m.match( ); /*函数调用,实参为字符数组的首地址*/
if (k == 0)
cout << "正确匹配\n";
else if (k == 1)
cout << "多左括号\n";
else
cout << "多右括号\n";
return 0;
}