队列( Queue)简称队,只允许一端进行插入,另一端进行删除。前者可以叫“入队”或者“进队”,后者可以叫“出队”或者“离队”。队列和平时在排队过程中的逻辑是一样的,如果要入队,必然插入到线性结构的后方,如果要离队,肯定是从线性结构的前方离开的。这一原则我们叫FIFO(First In First Out)。
所谓顺序存储其实就和顺序表的思维差不多,定义一个线性结构,只能表头前出队,只能表头后进队。以下是数据结构:
#define Maxsize 50
typedef int ElemType;
typedef struct{
ElemType data[Maxsize]; //存放队列元素
int front,rear; //队头指针和队尾指针
}SqQueue;
队空条件:Q.front==Q.rear==0。
入队操作:队不满,先送值到队尾元素,再将队尾指针+1。
出队操作:队不空时,先取头元素值,再将队尾值-1。
当然,队空的条件是头指针和尾指针相等,现在的问题是怎么判断队满呢?以下分情况讨论一下:
可以看到,如果只是让Q.rear==Maxsize是不能解决问题的。因为在(d)情况下,前面的元素都出队了,但Q.rear==Maxsize且整个队列还有空闲空间,这不能说明队满,这并不是真正的队列溢出。这种情况被称为“假溢出”。为了解决这个问题,可以考虑将该队列模式首尾相接,改为一个循环队列来解决此问题。
2.循环队列
在该思路下,我们可以将存储队列的表从逻辑上视为一个环,这样的队列就成为循环队列。即上图(d)情况下,我们可以让rear指针在最后存满之后,还能在front指针前面存储相关的数据。
初始时:Q.front==Q.rear==0。
队首指针进1:Q.front=(Q.front+1)%Maxsize 。
队尾指针进1:Q.rear=(Q.rear+1)%Maxsize。
此时还面临一个问题,那就是如何判断队满。如果只是简单地使用Q.front==Q.rear==0,就会出现下列情况:
可以看到,队满时会出现这种情况,但队空时也会出现这种情况。所以必须要想办法区别开两种情况。
我们有以下解决办法:
①:最普遍的做法是,入队时少用一个单元,约定“队头指针在队尾指针的下一个位置作为对满的标志”。
这样,我们就可以看到队满的条件可以这样判断:
队满条件:(Q.rear+1)%Maxsize=Q.front
队空条件仍:Q.front==Q.rear
队列中元素的个数:(Q.rear+Maxsize-Q.front)%Maxsize
②:还有一个做法是,增加一个数据成员size。这样队空的条件为Q.size==0;队满的条件就可以变为Q.size==Maxsize。这两种情况就有Q.front==Q.rear。
③:还有一个方法是加入数据成员tag。当tag==0时,若因删除导致Q.front==Q.rear,则为队空;tag==1时,若因插入导致Q.front=Q.rear,则表示队满。
初始化:
void InitQueue(SqQueue Q){
Q->front=Q->rear==0; //初始化队列首尾指针
}
队列判空:
int IsEmpty(SqQueue Q){
//如果是空,则首尾指针相等,返回1,如果是非空,返回0.
if(Q->front==Q->rear) return 1;
else
return 0;
}
队列判满:
//判满返回1,判空返回0
//第一种模式:循环队列空出一个位置的方式判满
int IsFull(SqQueue Q){
if((Q->rear+1)%Maxsize==Q->front) return 1;
return 0;
}
//第二种模式,使用一个变量来计量队列内元素数量
int IsFull(SqQueue Q){
if(Q->size==Maxsize) return 1;
return 0;
}
//第三种模式:使用标识性变量tag来区分队头指针和队尾指针相等时的状态
//如果tag==1,那么说明相等是因为队满所造成,如果tag==0,则相等是由队空所造成的
int IsFull(SqQueue Q){
if(tag==1 && Q->rear==Q->front) return 1;
return 0;
}
入队
//入队操作,成功返回1,否则返回0;
int EnQueue(SqQueue Q,ElemType x){
if((rear+1)%Maxsize==Q->front) return 0;
Q->data[rear]=x;
rear=(rear+1)%Maxsize; //队尾指针取模加1
return 1;
}
出队
//出队操作,成功返回1,否则返回0;
int DeQueue(SqQueue Q,ElemType x){
if((rear+1)%Maxsize==Q->front) return 0;
x=Q->data[Q->front];
front=(front+1)%Maxsize; //队头指针取模加1
return 1;
}
以上就是顺序存储的一些实例。下面介绍第二种解决方式,链式存储。
由于顺序存储需要考虑到溢出问题而产生复杂的边界条件,所以考虑链式存储实现队列也是一个方向。我们一般采用带头结点的单链表来实现该问题,头结点的存在方便入队和出队操作,头指针指向队头结点,尾指针指向队尾结点。结构图如下所示:
当程序需要使用多个队列时,与多个栈的情形一样,最好使用链式队列,可以解决溢出问题和存储分配不合理的问题。
以下是数据结构:
typedef struct{ //队列结点
ElemType data;
struct LinkNode *next;
}LinkNode;
typedef struct{ //链式队列
LinkNode *front,*rear; //队头指针和队尾指针
}LinkQueue;
当然,链式存储基本都是单链表的操作:
//初始化队列
void InitQueue(LinkQueue Q){
Q->front=(LinkNode*)malloc(sizeof(LinkNode)); //建立队头队尾结点
Q->rear=(LinkNode*)malloc(sizeof(LinkNode));
Q->front->next=NULL; //队头初值为空
}
//判空队列
bool IsEmpty(LinkNode Q){
if(Q->front==Q->rear) return true;
else
return false;
}
//入队
void EnQueue(LinkQueue Q,ElemType x){
LinkNode* s=(LinkNode*)malloc(sizeof(LinkNode)); //创建新节点
s->data=x;
s->next=NULL; //插入到队尾
Q->rear->next=s;
Q->rear=s;
}
//出队 成功返回1,否则返回0
int DeQueue(LinkQueue Q,ElemType x){
if(Q->front==Q.rear) return 0;
LinkNode* p=Q->front->next;
x=p->data;
Q->front->next=p->next;
//如果p指向了尾指针,那么删除之后还需要改变尾指针,回到初始状态
if(Q->rear==p)
Q->rear=Q->front;
free(p);
return 1;
}
有一类队列在两端都可以进行插入和删除操作,这样的队列我们称之为“双端队列”。逻辑结构如下图所示:
有时候一边会限制插入/删除操作,所以判断其合法序列的方式分为了两步:
(1)无论怎么限制其插入删除操作,在一端必然可以同时实现插入和删除,即栈能完成的操作双端队列一定能完成。
(2)在用栈的方式排除完合法序列之后,再检查其它序列则需要使用题设条件来判断。简而言之还是一类比较简单的题目。
原则:依次扫描所有字符,如若遇到左括号则入栈,遇到右括号则弹出栈顶元素检查是否可以匹配到。一共有三种可能性:
①:左括号单个出现
②:右括号单个出现
③左右括号不是同一种括号。
以下介绍几种具体的情况:
例如这一个括号序列,就可以完成匹配
但是下面的这种类型就不能实现配对了:
对于一个表达式,计算机计算过程中,由于求值过程中涉及运算优先级的问题,为了让计算机能意识到这些运算的优先次序不同,所以需要一定的转化。最常用的是转化为后缀表达式。即先输入两个操作数,并在后附上操作符,例如A-B的后缀表达式就是AB-。那么我们之前写到的表达式
也被称之为中缀表达式,即符号位于两个操作数中间。
现在先来讨论如何让一个式子从中缀表达式变为后缀表达式。我们的思路是:
1.中缀表达式中,我们读取操作符让它入栈。
2.如果下一个要入栈的操作符的优先级比栈顶的操作符优先级高,那就让栈顶操作符出栈, 再让该操作符入栈。
3.如果下一个要入栈的操作符的优先级比栈顶的操作符优先级低,那么就直接入栈。
4.括号不带有任何操作优先级,直接入栈即可。
例:讨论中缀表达式转化为后缀表达式的过程。
待到该中缀表达式成功转化为后缀表达式之后,我们接下来需要做的就非常简单了。那就是进行运算。进行运算的过程中,我们会把操作数入栈,在遇到操作符时让靠近栈顶的两个操作数出栈进行运算并把得到的数值重新压入栈顶。继续以上题为例,我们来梳理一下整个过程。
后缀表达式:ab/cd*ef*-g/+
步 | 扫描项 | 项类型 | 操作 | 栈中内容 |
1 | a | 操作数 | 入栈 | a |
2 | b | 操作数 | 入栈 | a b |
3 | / | 操作符 | a b出栈,计算a/b返回结果R1 |
R1 |
4 | c | 操作数 | 入栈 | R1 c |
5 | d | 操作数 | 入栈 | R1 c d |
6 | * | 操作符 | c d出栈,计算c*d返回结果R2 | R1 R2 |
7 | e | 操作数 | 入栈 | R1 R2 e |
8 | f | 操作数 | 入栈 | R1 R2 e f |
9 | * | 操作符 | e f出栈,计算e*f返回结果R3 | R1 R2 R3 |
10 | - | 操作符 | R2 R3出栈,计算R2-R3返回结果R4 | R1 R4 |
11 | g | 操作数 | 入栈 | R1 R4 g |
12 | / | 操作符 | R4 g出栈,计算R4/g返回结果R5 | R1 R5 |
13 | + | 操作符 | R1 R5出栈,计算R1+R5返回结果R | R |
以上就是在表达式求值中常用的两个过程。
在递归调用的时候,计算机底层其实是有栈辅助实现的。例如阶乘算法:
int facroria(int n){
if(n==1||n==0)
return 1;
else
rerturn n*factorial(n-1);
}
int main(){
int n=10;
printf("10!=%d",factorial(n));
return0;
}
在函数调用栈内,则是如是存储内容的:
10*9*8*7*6*5*4*3*2*factorial(1) |
10*9*7*8*6*5*4*3*factorial(2) |
10*9*8*7*6*5*4factorial(3) |
10*9*8*7*6*5factorial(4) |
10*9*8*7*6*factorial(5) |
10*9*8*7*factorial(6) |
10*9*8*factorial(7) |
10*9*factorial(8) |
10*factorial(9) |
factorial(10) |
main() |
当factorial调用到1的时候,就会逐次返回值,就像表达式求值一样,得到了10!。
矩阵的存储一般我们考虑存储为一个二维数组A[ ][ ],但是在内存中,往往是线性存储的。所以我们必须考虑存储方式。有时候我们考虑按行存储:即第二行存储在第一行的后面。又有时我们考虑按列存储,即第二列存储在第一列后面。那就是说一个m*n阶矩阵被压缩存储为一个m*n维的向量了,在这个过程中需要寻找矩阵元素A[i][j]在该m*n维向量里面的地址映射关系。有以下的推导:
假定:
1.矩阵元素A[i][j](i>0,j>0)
2.m*n维向量vector[k],k=0,1,2,3……
那么如果按行存储,对元素A[i][j],其向量下标k=i*m+(j-1)
那么如果按列存储,对元素A[i][j],其向量下标k=j*n+(i-1)
讨论完了第一个问题,接下来我们要讨论三类矩阵的存储方式及映射关系。
对称矩阵的压缩存储:
对于一个实对称矩阵A而言,由于其有相对的对称性,所以我们只考虑将其中一半的内容存储下来,然后通过地址映射,来确保其输出的值。
那么我们先来看下三角矩阵的情况:
下三角矩阵的每一个元素都有
存在。所以对于任何一个下三角矩阵的元素,均有
这一映射关系存在。简单解释一下这个原理:前i-1行的所有元素和再加上这一行的列位置j。由于数组是从0开始下标。所以说需要减一。这是一个很好记的公式。上三角元素有类似的规律。我们可以总结如下:
三角矩阵的压缩存储:
现在我们来讨论三角矩阵的压缩存储。比起实对称矩阵而言,三角矩阵很显然并不需要讨论i和j的大小。我们依旧假设数组从0开始计数。那么三角矩阵是上三角矩阵还是下三角矩阵就很值得讨论了。
如果是一下三角矩阵,很显然,这是一个简单的等差数列,对于一个元素而言,有
存在。但是对于一个上三角矩阵而言,情况就比较复杂了。我们需要利用表格推导一遍这个过程:
第1行 | n |
第2行 | n-1 |
…… | …… |
第i-2行 | n-(i-2)+1 |
第i-1行 | n-(i-1)+1 |
第i行 | j-i |
因此,元素在数组B的下标可以通过等差数列求和再加这一行的列下标就可以了。
总结一下就是:
三对角矩阵(带状矩阵)的压缩存储
对于一个三对角矩阵而言:
对于元素在数组中的位置,我们有以下推导:
前i-1行元素有个
是第i行第
个元素
所以是第
在数组中属于第个元素
稀疏矩阵的两种存储方式:三元组法和十字链表法。
三元组法:用行标、列标、值构成一个三元组,来存储稀疏矩阵的值。
十字链表法:
结点数据结构的说明:
1:依旧和三元组法一样的行标、列标、值
2:指向同一行的下一个元素的指针,以及指向同一列下一个元素的指针