王道数据结构第三章:栈与队列

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个不同元素进栈,合法的出栈顺序(卡特兰数)有1/(1+n)*_{2n}^{n}\textrm{C}

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是第几个元素?

这种问题我们自己推导即可,不能死记硬背,因为也可以变成上三角矩阵,因此会推导才是关键。

向上取整和二叉树那一章的知识类似。

对于稀疏矩阵,我们可以按照三元组存储,也可以用十字链表存储(书上没有)

 

 矩阵存储考点

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值