3.1.1 栈的基本概念
栈(stack)是一种特殊的线性表,只允许在一端进行插入或者删除操作。
栈顶:允许插入删除操作的一端,最上面的元素称为栈顶元素。
栈底:不允许插入删除的一端,最下面的元素被称为栈底元素。
栈的特性:先进后出 First in Last out(FILO)
栈的基本操作:
InitStack(&s):初始化栈,构造一个空栈,分配内存空间。
DestroyStack(&s):销毁栈,销毁并释放掉栈所用的内存空间。
Push(&s,x):进栈。若栈未满,则加入x使之成为栈顶。
Pop(&s,&x):出栈。若栈非空,弹出栈顶元素,并用x返回。
GetTop(s,&x):读栈顶元素,但是并不会弹出,用x带回罢了。
StackEmpty(s):判空操作。
最喜欢的考题:给定一个进栈顺序,判断哪些出栈顺序是合法的。
n个不同元素进栈,合法的出栈顺序(卡特兰数)有
3.1.2 栈的顺序存储的实现
我们以下操作,认为栈顶指针为-1时为空,这样就不会浪费数组下标。有些书上认为top指针指向下一个可以插入的位置,那么删除插入判空等函数全部会变!
栈的定义:
#define MaxSize 20
typedef struct {
int data[MaxSize];
int top;//栈顶指针,记录数组下标
}SqStack;
栈的初始化:
//初始化栈
void InitStack(SqStack& s) {
s.top = -1;//初始化栈顶指针
}
栈的判空条件:
//判断栈空
bool StackEmpty(SqStack& s) {
if (s.top == -1) {
return true;
}
else
return false;
}
栈顶元素入栈:
//栈顶元素入栈
bool Push(SqStack& s,int x) {
if (s.top == MaxSize - 1) {//栈满报错
return false;
}
s.top++;//指针先加一
s.data[s.top] = x;//新元素入栈
return true;
}
栈顶元素出栈:
//栈顶元素出栈
//数据还残留在内存中,只是逻辑上被删除了
bool Pop(SqStack& s,int &x) {
if (s.top == -1)//栈空报错
return false;
x = s.data[s.top];//元素先出栈
s.top--;
return true;
}
读取栈顶元素:
//读取栈顶元素
bool GetPop(SqStack& s,int x) {
if (s.top == -1)
return false;
x = s.data[s.top];
return true;
}
共享栈:两个栈共享同一片空间,这样避免了内存浪费。
//共享栈
typedef struct {
int data[MaxSize];
int top0;
int top1;
}ShStack;
//初始化栈
void InitStack(ShStack& s) {
s.top0 = 0;
s.top1 = MaxSize;
}
//栈满的条件:top0+1=top1;
以上操作都可以在O(1) 时间复杂度内完成!
3.1.3 栈的链式存储实现
链栈的定义:
typedef struct LiStack{
int data;
LiStack* next;
};
链栈的插入、删除等等操作都是和单链表一样的,对应着单链表的头插法、删除操作,这里不再重复。考试中对链栈的考查也比顺序栈少很多。
3.2.1 队列的基本概念
队列定义:队列(queue)是一种操作受限的线性表,先进先出(FIrst In First Out)是它的特点。
它只允许在队尾进行插入操作,且只能在队头进行删除操作。
基本操作:
InitQueue(&Q):初始化并构造一个空队列。
DestroyQueue(&Q):销毁一个队列。
EnQueue(&Q,x):入队,若队列未满,将x加入,使之成为新的队尾。
DeQueue(&Q,x):出队,若队列非空,删除队头元素,并用x返回。
GetHead(Q,&x):读队头元素,并且赋值给x。
3.2.2 队列的顺序实现
队列与栈最大的不同在于,队列的判空操作与判满操作是不同的!
拿顺序队列举例,如果入队时rear指针后移,出队时front指针后移,那么随着出队入队过程的不断进行,一定会导致rear和front指针同时达到MaxSize,这时虽然队中没有元素,但是仍然无法让元素入队,这就是所谓的“假溢出”。为此,我们引入了循环队列。
要实现指针在递增的过程中实现循环移动,有一个方法,那就是取模。比如我们让front=(front+1)%MaxSize,这样二者指针就可以循环移动了。
那么,循环队列怎么判断已满呢?有三种方法。
方法一:牺牲一个单元来区分队空和队满。入队时少用一个存储单元,这是一种比较普遍的做法,我们规定“队头指针在队尾指针的下一个位置,这样作为队满的标志”。
故在这种方法下,队空:Q.front==Q.rear; 队满:(Q.rear+1)%MaxSize==Q.front;
队列中元素个数:(Q.rear-Q.front+MaxSize)%MaxSize;可以用数论证明,不如记住。
方法二:类型中增加表示元素个数的数据成员。这样队空的条件为Q.size==0;队满的条件为Q.size==MaxSize;,在两种情况下都有Q.rear==Q.front;
方法三:类型中增加tag成员,表示最近一次成功执行了删除或者插入操作。tag==0时,表示最近一次成功执行删除操作,tag==1时,表示最近成功执行了插入操作。在两种情况下都有Q.rear==Q.front;
在写下面的示范代码时,我们规定,统一使用方法一,并且rear指针指向下一个可以插入的位置。
若题目中要求rear指针指向队尾的元素,在进行插入删除操作时,注意是先移动指针,而后进行存入或删除元素。并且在初始化的过程中,我们可以让rear指针指向n-1的位置而不是0。在进行判断队列空满状态时,仍然使用方法一,队空则为(Q.rear+1)%MaxSize==Q.front,队满则为(Q.rear+2)%MaxSize==Q.front;
因此审题是很重要的。
定义:
#define MaxSize 20
typedef struct {
int data[MaxSize];//用静态数组存放队列元素
int front, rear;//队头指针和队尾指针
}SeQueue;
初始化:
//初始化队列
void InitQueue(SeQueue& Q) {
//队头队尾指针指向0
Q.front = Q.rear = 0;
}
判空:
//判断队列是否为空
bool QueueEmpty(SeQueue& Q) {
if (Q.front == Q.rear) {
return false;
}
else
return true;
}
入队:
//入队
bool EnQueue(SeQueue& Q,int x) {
if ((Q.rear+1)%MaxSize==Q.front)//队列满了就不入队
return false;
Q.data[Q.rear] = x;
Q.rear = (Q.rear + 1)%MaxSize;//队尾指针加1取模
return true;
}
出队:
//出队
bool DeQueue(SeQueue& Q, int& x) {
if (Q.front == Q.rear)
return false;
else
{
x = Q.data[Q.front];
Q.front=(Q.front+1)%MaxSize;
}
return true;
}
获取值:
//查找队头元素的值,并用x返回
bool GetElem(SeQueue& Q, int& x) {
if (Q.front == Q.rear)
return false;
x = Q.data[Q.front];
return true;
}
3.2.3 队列的链式实现
同链栈一样,顺序栈代码实现起来简单,考察点也多,故链栈、链队考察很少,一般我们用顺序实现就够了,除非题目明确指出要使用链式结构。
下文中统一带头结点,并且front指针指向头节点,rear指针指向队尾结点。
定义:
#define MaxSize 20
typedef struct LinkNode {//链式队列结点
int data;
LinkNode* next;
};
typedef struct LinkQueue {//链式队列
LinkNode* rear, * front;//链式队列的队头指针和队尾指针
};
初始化:
//初始化
void InitQueue(LinkQueue& Q) {
Q.front = Q.rear = (LinkNode*)malloc(sizeof(LinkNode));//建立队头队尾结点
Q.rear = Q.front = NULL;
}
判空:
//判空
bool IsEmpty(LinkQueue& Q) {
if (Q.front == Q.rear)//当然也可以判断front指针是否为空
return true;
else return false;
}
入队:
//入队
void EnQueue(LinkQueue& Q, int x) {
LinkNode* s = (LinkNode*)malloc(sizeof(LinkNode));
s->data = x;
s->next = NULL;//建立新节点并带入数据,改变next指针
Q.rear->next = s;//移动队尾指针,便于下次操作
Q.rear = s;
}
出队:
//出队
bool DeQueue(LinkQueue& Q, int& x) {
if (Q.front == Q.rear)
return false;//空队报错
LinkNode* p = Q.front->next;
x = p->data;
Q.front->next = p->next;//修改头节点的next指针
if (Q.rear == p) {//如果删除的是最后一个结点,特殊判断
Q.front = Q.rear;//修改rear指针
}
free(p);
return true;
}
下面我们分析一下,如果不带头节点,如何改变上面操作。
最大的不同在于:front指针指向了第一个队头结点。
初始化:
//不带头节点的初始化
void InitQueue(LinkQueue&Q) {
Q.front = Q.rear = NULL;
}
判空:
//不带头节点判空操作
bool IsEmpty(LinkQueue& Q) {
if (Q.front == NULL)
return true;
else
return false;
}
入队:
//入队操作,不带头节点
void EnQueue(LinkQueue& Q, int x) {
LinkNode* s = (LinkNode*)malloc(sizeof(LinkNode));
s->data = x;
s->next = NULL;
//如果是第一个节点插入,需要特殊操作
if (Q.front == NULL) {
Q.front = s;
Q.rear = s;//修改队头队尾指针
}
else {
Q.rear->next = s;
Q.rear = s;
}
}
出队:
//出队操作,不带头节点
bool DeQueue(LinkQueue& Q, int& x) {
if (Q.front == Q.rear)
return false;//空队报错
LinkNode* p = Q.front;
x = p->data;
Q.front = p->next;//修改头节点的next指针
if (Q.rear == p) {//如果删除的是最后一个结点,特殊判断
Q.front = Q.rear=NULL;//修改rear指针
}
free(p);
return true;
}
链式存储的队列一般是不会存满的,除非内存不够了。
3.2.4 双端队列
操作受限的线性表,允许从两端插入,也允许从两端删除。
输入受限的双端队列:只允许从一端进行插入,两端进行删除。
输出受限的双端队列:只允许从一端进行删除,两端进行插入。
双端队列喜欢的考点是给定一个输入序列,判断哪些输出序列是合法的。对于这种问题,我们先从栈的角度出发,因为栈能实现的操作双端队列也一定可以实现。
然后依次分析输出受限的双端队列,输入受限的双端队列,最好能画个图逆向分析,要得到这样的输出序列,那么我应该怎么样输入。
3.3.1 栈在括号匹配中的应用
((({{【【】】}}))) 通过分析这些括号表达式,我们得出一个结论:越是靠右的左括号,越先和后面的右括号产生配队,每出现一个右括号就消耗一个左括号。这符合栈 后进先出LIFO的特点,为此我们可以用栈来模拟这个过程,同时也说明了栈具有记忆性。
模拟过程如下: 碰到左括号,入栈;碰到右括号,栈顶元素出栈并判断是否配队。
如果右括号取完,栈中仍有剩余的左括号,匹配失败;若栈中左括号消耗完,但 仍有右括号,匹配也失败。
bool bracketCheck(char a[],int length){
SqStack S;//初始化一个空栈
S.InitStack(S);
for (int i = 0; i < length; i++) {
if (a[i] == '[' || a[i] == '(' || a[i] == '{') {
Push(S,a[i]);//扫描到左括号,入栈
}
else {
if (StackEmpty(S)) {//扫描到右括号并且栈空
return false;
}
char Elem;//当做栈顶元素返回变量
Pop(S, Elem);
if (a[i] == ') ' && Elem != '(')
return false;
if (a[i] == '] ' && Elem != '[')
return false;
if (a[i] == '} ' && Elem != '{')
return false;
}
}
return true;
}
3.3.2 栈在表达式求值中的应用
1.三种表达式:前缀表达式 中缀表达式 后缀表达式
算术表达式由三部分组成:操作数、运算符、界限符。中缀表达式中界限符很重要,如果去掉括 号之后,很多运算顺序都会改变,为此人们引入了后缀、前缀表达式。
后缀表达式:reserve polish notation 逆波兰表达式 运算符在两个操作数的后面。
前缀表达式:polish notation 波兰表达式,运算符在操作数的前面。
这部分内容难以理解,并且考频很高,我们尤其要关注后缀表达式,这是考的最多的!
是否注意到了:在后缀表达式中,运算符出现的顺序刚好是他们应该执行的次序?
由于运算顺序的不唯一性,导致后缀表达式不唯一,为了确保后缀表达式性质不变,我们统一规定:只要左边运算符可以先计算,就从左往右进行运算。(即左优先原则) 尽管客观来说两种都对,但是计算机好处理的还是左优先原则。
在还原的过程中,一定要注意左操作数和右操作数的区分!尤其是在除法中。
栈具有记忆性的功能,当我们遇到一个问题暂时无法解决,需要依靠后面的内容来解决时,我们就可以依靠栈的记忆性来实现该功能。
注意每次出栈之后,压入栈中的是二者进行运算之后的结果(所以注意操作数的左右是很重要的)。先出栈的是右操作数!
与求后缀表达式的方法类似,但是不同的是运算符放在左边,并且我们遵循右优先的原则。
用栈实现中缀表达式转后缀表达式:
要理解为什么遇到运算符,要弹出栈中优先级>=当前运算符的所有运算符,这个过程必须理解,因为选择题会考察进行到哪一步时,栈中现在是什么状态,而且考察频率还不低!
我们的CPU其实是很傻的,只会最基本的加减乘除运算,因此将中缀表达式转换过来是很有必要的。
int op(int a,char Op, int b) {//这个是运算函数,完成a Op b的计算
if (Op == '+')return a + b;
if (Op == '-')return a - b;
if (Op == '*')return a * b;
if (Op == '/')
{
if (b == 0) {
cout << "ERROR" << endl;
return 0;
}
else
return a / b;
}
}
int com(char exp[]) {//后缀表达式计算函数
SqStack S;
InitStack(S);//建立并初始化栈
int a, b, c;//a,b作为操作数,c作为运算结果
char Op;//Op作为运算符
for (int i = 0; exp[i] != '\0'; i++) {
//碰到操作数 入栈
if (exp[i] >= '0' && exp[i] <= '9') {
Stack[top++] = exp[i] - '0';
}
else {
Op = exp[i];//取运算符
b = Stack[--top];
//注意这里一定是b!因为先出栈的是右操作数
a = Stack[--top];
c = op(a, Op, b);
Stack[top++] = c;
}
}
return Stack[top];
}
3.3.3 栈在递归中的应用
函数调用的特点:最后被调用的函数最先被执行:LIFO 这符合栈的特点。
函数调用时,需要一个栈存储:调用返回地址,实参,局部变量。函数调用栈也可以称为递归工作栈,每次进入一层递归时,就把递归需要的信息压入栈顶,后续退出一层递归就会弹出相关信息。
太多层递归会导致栈溢出。
3.3.4 队列的应用
1.树的层次遍历(在二叉树章节会详细讲解)
2.图的广度优先遍历(在图章节会详细学习)
3.操作系统中的应用:多个进程争抢着有限的系统资源时,FCFS(First Come First Service)是 一种有效的策略。
3.3.5 特殊矩阵的压缩存储
在这一章我们要重点区分是行优先还是列优先,问题的关键在于key值和行号列号的映射。
在此之前,我们先来回顾一下二维数组的存储:二维数组本质上是一维数组的数组,我们一般认为二维数组是行优先存储,但是具体还是要以题目来。
由于二维数组逻辑结构上和矩阵很类似,因此存储矩阵我们也采用二维数组的方法。需要注意的是:矩阵的下标从1开始,数组的下标一定从0开始。
对于某些特殊矩阵,我们可以采用别的方式压缩存储空间。
数组大小为多少?我们需要统计一共有多少个不同元素,第一行1个,第n行n个,累加求和即可。
我们怎样才知道aij是第几个元素?怎么从矩阵下标映射到数组下标?
aij=1+2+...+i-1+j=(i-1)*i/2+j;所以对应在数组下标k=(i-1)*i/2+j-1;
仍然思考那个关键问题:aij是第几个元素?
这种问题我们自己推导即可,不能死记硬背,因为也可以变成上三角矩阵,因此会推导才是关键。
向上取整和二叉树那一章的知识类似。
对于稀疏矩阵,我们可以按照三元组存储,也可以用十字链表存储(书上没有)