目录
一、栈
1.1 概念与特点
栈(stack)是限定仅在表尾进行插入或删除操作的线性表。因此,对栈来说,表尾端有其特殊含义,称为栈顶(top),相应地,表头端称为栈底(bottom)。
通过定义会发现,出栈操作只能在栈顶进行,先入栈的元素被压入栈底,最后出栈;而最后入栈的元素在栈顶,最先出栈。故栈是一种后进先出(LIFO,Last In First Out)的结构。
也因为定义,所以栈的实现和线性表的实现差不多,并且要比线性表简化了很多,因为上一篇线性表实在是太详细了,所以这篇就简单介绍一下。传送门:
ADT Stack{
InitStack(&S)
操作结果:构造一个空栈S。
DestroyStack(&S)
初始条件:栈S已存在。
操作结果:栈S被销毁。
ClearStack(&S)
初始条件:栈S已存在。
操作结果:将S清为空栈。
StackEmpty(S)
初始条件:栈S已存在。
操作结果:若栈S为空栈,则返回true,否则返回false。
StackLength(S)
初始条件:栈S已存在。
操作结果:返回S的元素个数,即栈的长度。
GetTop(S, &e)
初始条件:栈S已存在且非空。
操作结果:用e返回S的栈顶元素。
Push(&S, e)
初始条件:栈S已存在。
操作结果:插入元素e为新的栈顶元素。
Pop(&S, &e)
初始条件:栈S已存在且非空。
操作结果:删除S的栈顶元素,并用e返回其值。
}ADT Stack;
既然是线性表,那必然就可以通过顺序表和链表两种不同的存储结构来实现。
1.2 顺序栈
按照栈顶指针(top)的不同处理方式以及内存分配方式(静态、动态)的不同,顺序栈也可以分为4种具体实现方式(甚至更多)。下面将详细介绍动态分配实现的顺序栈(因为应用里用到了),并且栈顶指针指向栈顶元素的上一个位置,其他实现方法仅作浅析和对比。
1.2.1 定义
typedef struct{
Elemtype *data;
int top;
int stacksize;
}SqStack;
结构体的写法和顺序表基本上是一样的,但是多了个指向栈顶的指针top(实际上不是指针,习惯上叫指针),top的作用是标记栈顶元素的位置。
而stacksize,则是标记当前栈的容量,以便在栈满时扩容。
1.2.2 初始化、销毁、清空
bool InitStack(SqStack &S){
S.data = (Elemtype *)malloc(STACK_INIT_SIZE*sizeof(Elemtype));
if(!S.data) return false;
S.top = 0;
S.stacksize = STACK_INIT_SIZE;
return true;
}
因为是动态分配,所以在初始化的时候要给S.data分配空间。
同时,需要让 S.top=0,并且数组第一个元素的下标也是 0,这就意味着在压栈(插入)的时候,需要先在 top 位置压栈,然后再将 top+1。在执行了压栈后,top 是指向栈顶元素的上一个位置(逻辑上)。那么在出栈(删除)的时候,就需要先将 top-1,再在 top 位置执行出栈操作。
最后别忘了将栈的容量初始化为初始容量。
bool DestroyStack(SqStack &S){
if(!S.data) return false;
free(S.data);
return true;
}
bool ClearStack(SqStack &S){
S.top = 0;
return true;
}
因为是动态分配内存,所以在销毁栈时,需要手动释放 S.data 的内存。
而清空栈的操作,只需简单地将 top 指针指向数组下标 0 的位置,这是因为后续插入的时候数据会直接覆盖,并且没有覆盖到的数据是无法访问的(只能通过 top 来访问)。
1.2.3 判空、判满、求长
bool StackEmpty(SqStack S){
if(S.top == 0) return true;
return false;
}
判空操作也很简单,因为初始化的时候 top=0,这就意味着没有元素的时候 top 为 0,也即是判空的依据。
bool StackFull(SqStack S){
if(S.top == S.stacksize) return true;
return false;
}
为了操作的方便,在官方(教材)的基础上新增了判满函数。
因为 top 始终位于栈顶元素的上一个位置,即 top==栈顶元素下标+1,而栈内元素最大的下标是 stacksize-1,所以当 top==stacksize 时,认为栈满。
int StackLength(SqStack S){
return S.top;
}
栈的长度就是 top 的值(栈顶元素下标+1)。
1.2.4 查找、压栈、出栈
bool GetTop(SqStack S, Elemtype &e){
if(StackEmpty(S)) return false;
e = S.data[S.top-1];
return true;
}
因为 top==栈顶元素下标+1 ,所以栈顶元素的下标是 top-1。
bool Push(SqStack &S, Elemtype e){
if(StackFull(S)){
S.data = (Elemtype *)realloc(S.data, (S.stacksize + STACK_INCREMENT)*sizeof(Elemtype));
if(!S.data) return false;
S.stacksize += STACK_INCREMENT;
}
S.data[S.top++] = e;
return true;
}
压栈时,如果栈满,则需要 realloc 扩容,并且修改新的栈容量 stacksize。
如果没满,则需要先在 top 位置插入元素,然后将 top+1。
bool Pop(SqStack &S, Elemtype &e){
if(StackEmpty(S)) return false;
e = S.data[--S.top];
return true;
}
出栈操作和压栈正好相反,需要先将 top-1 指向栈顶元素,然后执行出栈。
1.2.5 遍历
bool StackTraverse(SqStack S){
if(StackEmpty(S)) return false;
Elemtype e;
while(!StackEmpty(S)){
if(Pop(S, e)) printf("Pos%d:\t%d\n", S.top+1, e);
}
return true;
}
遍历仅供参考,只是简单的输出而已。
需要注意的是,在输出的时候使用了出栈函数,但是实际上没有修改栈内元素的值,这是因为实参并没有传引用,传进来的只是栈的拷贝。
1.2.6 其他实现方法
比如,在初始化时将 top 置为 -1。第一次压栈时,需要先将 top+1,再在 top 位置压栈(top==0)。此时 top 将始终位于栈顶元素的位置(等于下标)。
比如,使用静态分配的顺序表,省去了 malloc 和 free 的麻烦,因为静态内存会被系统自动回收。同时,也带来了扩容的不便性。
(最开头的思维导图写得非常非常详细)
1.3 链栈
用链表实现栈,有两种方式。从语义上来说,无头结点的链表更加贴合概念;从实现上来说,有头结点的链表更方便操作。实现起来都很简单,这里就仅贴代码不再赘述了。(最开头的思维导图写得非常非常详细)
1.3.1 有头结点
typedef struct SNode{
Elemtype data;
struct SNode *next;
int len;
}SNode, *LStack;
bool InitStack(LStack &S){
S = (SNode *)malloc(sizeof(SNode));
if(!S) return false;
S->next = NULL;
S->len = 0;
return true;
}
bool DestroyStack(LStack &S){
if(!S) return false;
SNode *p;
while(S){
p = S->next;
free(S);
S = p;
}
return true;
}
bool ClearStack(LStack &S){
if(!S) return false;
SNode *p = S->next;
while(p){
S->next = p->next;
free(p);
p = S->next;
}
return true;
}
bool StackEmpty(LStack S){
if(!S->next) return true;
return false;
}
bool StackFull(LStack S){
}
int StackLength(LStack S){
return S->len;
}
bool GetTop(LStack S, Elemtype &e){
if(!S || StackEmpty(S)) return false;
e = S->next->data;
return true;
}
bool Push(LStack &S, Elemtype e){
if(!S) return false;
SNode *n = (SNode *)malloc(sizeof(SNode));
if(!n) return false;
n->data = e;
n->next = S->next;
S->next = n;
S->len ++;
return true;
}
bool Pop(LStack &S, Elemtype &e){
if(!S || StackEmpty(S)) return false;
SNode *q = S->next;
e = q->data;
S->next = q->next;
free(q);
S->len --;
return true;
}
bool StackTraverse(LStack S){
if(!S || StackEmpty(S)) return false;
Elemtype e;
while(!StackEmpty(S)){
if(Pop(S, e)) printf("Pos%d:\t%d\n", S->len+1, e);
}
}
仅提几点需要注意的地方:
在结构体定义的部分,为了求栈长度更加方便,增加了个 len 来指示长度,在每次压栈的时候 +1,在每次出栈的时候 -1 即可。如果不加,求栈长需要遍历栈,但是栈逻辑上是不允许遍历的。
没写判满,是因为链栈不会满,除非系统内存不够(?)
栈顶是链表的表头(头结点或者说头指针那一侧)。
压栈在头结点处执行后插,出栈在头结点处执行后删。
遍历时,会修改栈的内容。
1.3.2 无头结点
typedef struct SNode{
Elemtype data;
SNode *next;
}SNode, *LStack;
bool InitStack(LStack &S){
S = NULL;
return true;
}
bool DestroyStack(LStack &S){
if(!S) return false;
SNode *p;
while(S){
p = S->next;
free(S);
S = p;
}
return true;
}
bool ClearStack(LStack &S){
}
bool StackEmpty(LStack S){
if(!S) return true;
return false;
}
bool StackFull(LStack S){
}
int StackLength(LStack S){
}
bool GetTop(LStack S, Elemtype &e){
if(!S) return false;
e = S->data;
return true;
}
bool Push(LStack &S, Elemtype e){
SNode *n = (SNode *)malloc(sizeof(SNode));
if(!n) return false;
n->data = e;
n->next = S;
S = n;
return true;
}
bool Pop(LStack &S, Elemtype &e){
if(!S) return false;
e = S->data;
SNode *q = S->next;
if(!q){
S = NULL;
return true;
}
S->data = q->data;
S->next = q->next;
free(q);
return true;
}
bool StackTraverse(LStack S){
if(!S || StackEmpty(S)) return false;
Elemtype e;
while(!StackEmpty(S)){
if(Pop(S, e)) printf("%d\n", e);
}
}
这里结构体定义没加 len,所以求长函数空了。
没有头结点的时候,S 就是栈顶元素。
因为链栈空的时候,头指针==NULL,销毁和清空操作的结果都是一样的,所以清空空了。
在压栈的时候,把每次插入的新结点都当成第一个结点,这也是头插法的思路。
出栈时,如果只有一个结点,需要特殊处理,其他时候,把第二个结点的值和 next 传给第一个结点,然后 free 第二个结点即可。
1.4 共享栈(概念)
在栈的实际实际应用中,如果分配空间过小,可能回复发生栈溢出(stack overflow);而分配空间过大,则会造成存储空间的浪费。因此衍生了共享栈的概念。
共享栈,即两个栈共用同一片存储空间(定义结构体的时候只有一个数组,但是由两个栈顶指针 top1 和 top2),一号栈从线性表表头开始入栈,二号栈从线性表表尾开始入栈。当两个 top 指针相遇的时候(top1+1==top2),存储空间被耗尽,无法继续执行入栈操作。
二、队列
2.1 概念与特点
队列(Queue)是一种只允许在表的一端进行插入,而在另一端删除元素的线性表。在队列中,允许插入的一端叫队尾(rear),允许删除的一端叫队头(front)。
通过定义会发现,入队操作只能在队尾进行,出队操作只能在队头进行,最先入队的元素在队头,最先出队;而最后入队的元素在队尾,最后出队。故队列是一种先进先出(FIFO,First In First Out)的结构。
ADT Queue{
InitQueue(&Q)
操作结果:构造一个空队列Q。
DestroyQueue(&Q)
初始条件:队列Q已存在。
操作结果:队列Q被销毁,不再存在。
ClearQueue(&Q)
初始条件:队列Q已存在。
操作结果:将Q清为空队列。
QueueEmpty(Q)
初始条件:队列Q已存在。
操作结果:若Q为空队列,则返回true,否则返回false。
QueueLength(Q)
初始条件:队列Q已存在。
操作结果:返回Q的元素个数,即队列的长度。
GetHead(Q, &e)
初始条件:队列Q已存在且非空。
操作结果:用e返回Q的队头元素。
EnQueue(&Q, e)
初始条件:队列Q已存在。
操作结果:插入e为Q的新的队尾元素。
DeQueue(&Q, &e)
初始条件:队列Q已存在且非空。
操作结果:删除Q的队头元素,并用e返回其值。
}Queue;
和栈类似,队列也是用线性表实现的,故先用动态分配的例子解释实现原理,其他仅作浅析和对比。
2.2 顺序队列
2.2.1 定义
typedef struct{
Elemtype *data;
int front, rear;
int qsize;
}Queue;
队列结构体由三部分组成,存储数据的数组 data,分别指向队头和队尾的指针 front、rear(和栈的 top 功能类似),以及扩容时用来指示当前队列容量的 qsize(静态分配可以删除)。
2.2.2 初始化、销毁、清空
bool InitQueue(Queue &Q){
Q.data = (Elemtype *)malloc(STACK_INIT_SIZE*sizeof(Elemtype));
if(!Q.data) return false;
Q.front = Q.rear = 0;
Q.qsize = STACK_INIT_SIZE;
return true;
}
初始化时,依旧是分配空间和修改当前容量,除此之外,将 front 和 rear 都置零,这意味着在开始时,front 和 rear 都指向数组的第一个位置(对应下标 0)。当入队时,需要先在 rear位置插入,再将 rear+1,因此,rear 始终指向队尾元素的下一个位置(队尾元素下标+1);当出队时,需要先在 front 位置出队,再将 front+1,因此,front始终指向队头元素(队头元素下标)。
bool DestroyQueue(Queue &Q){
if(!Q.data) return false;
free(Q.data);
return true;
}
bool ClearQueue(Queue &Q){
Q.front = Q.rear = 0;
}
销毁需要手动释放内存;
清空时,只需将 front 和 rear 重新置零,后续入队时会进行数据覆盖,并且访问受到 front 和 rear 的限制(类似栈)。
2.2.3 判空、判满、求长
bool QueueEmpty(Queue Q){
if(!Q.front) return false;
if(Q.front == Q.rear) return true;
return false;
}
bool QueueFull(Queue Q){
if((Q.rear+1)%Q.qsize==Q.front) return true;
return false;
}
首先需要解释,在队列中,front 并不始终指向零,当执行了出队操作后,表头(front端)会空出位置,而 rear 则需要在抵达了最大数组下标后回过头来利用这些空位置。逻辑上看,线性表的表头和表尾好像相连成了一个环状。这也是循环队列名称的由来。
至于如何让 rear 达到最大下标后从 0 开始,只需要让 rear 对当前最大容量 qsize(静态时为MAXSIZE)取余即可。
图自 《数据结构(C语言版)》
值得一提的是,当栈空时,front==rear,而当栈满时,rear指向队尾的下一个位置,front指向队头,front==rear。这样一来,front==rear 便不能作为判空和判满的条件了。因此,解决方案有三个:
牺牲一个存储单元,即线性表最后一个位置不存储数据,当 rear 到倒数第二个位置(最大数组下标-1,容量-2)时,认为队列满。@20221112 此处理解有误,牺牲一个存储单元物理上确实会有一个单元不存储数据,但是这个单元的位置并不确定。原因在于,"牺牲存储单元"主要用于判满,条件是 (Q.rear+1)%Q.qsize==Q.front。由于rear始终指向队尾元素的后一个位置,在插入过程中,先判满,rear+1应当是最后元素的后面第二个位置,即跨过了最后元素的后一个位置,如果此时满,则插入失败。这便是牺牲一个元素的含义。- 设置标志位 tag,当执行入队时,tag=1,当执行出队时,tag=0。而队列满仅会在入队时发生,此时 tag==1;队列空只会在出队时发生,此时 tag==0。
- 用队列长来辅助判断。
这张图在开头的思维导图里就有
而上述代码是采用牺牲了一个存储单元的方式实现的,front==rear 认为队空,而(rear+1)%qsize==front 认为队满。
最后在探讨一下取余操作,a%b=c,即小学时候学的 a÷b=x...c(读作 a 除以 b 等于 x 余 c),所以 c 必然小于 b。当 a==b 时,c==0。假如 size==5,那么 n%size = 0,1,2,3,4,0,1,2,3...(n=0,1,2,3,4,5,6,7,8...),就从逻辑上实现了 n 从 0 到 4 的循环(恰好对应数组下标)。
int QueueLength(Queue Q){
return (Q.rear+Q.qsize-Q.front)%Q.qsize;
}
这里其实比较有意思,扯个题外话,从1数到10有几个数?答案是10个数,10-1+1,因为在做减法的时候,其实并不包括第一个数,所以结果还要加一。同样,假如队尾元素下标大于队头元素,那么假如队头元素下标是 a,队尾元素下标是 b,队长应该是 b-a+1 才对。但是巧妙的地方在于,rear 的值是队尾元素下标+1,这就不是从1数到10了,而是从1数到11,自然也就不用加那个1。后面的代码中我们会发现,当 rear 就是队尾元素下标的时候,就需要加上这个1了。
那为什么还要加 qsize 呢,原因在于 rear 并不一定始终大于 front(循环队列)。事实上,假如队列容量为10,rear 和 front 的范围是 0 ~ 9,rear-front 的范围在 -9 ~ 9 之间。那么想让结果为正,就需要加上 qsize,此时 rear-front+qsize 的范围在 1 ~ 19 之间,但是队长的范围是 0 ~ 9(牺牲了一个存储单元),所以只需要把结果对 qsize 取模即可。这就是代码中 (rear-front+qsize) 的由来。
2.2.4 查找、入队、出队
bool GetHead(Queue Q, Elemtype &e){
if(QueueEmpty(Q)) return false;
e = Q.data[Q.front];
return true;
}
front 的值就是队头元素对应的数组下标。
bool EnQueue(Queue &Q, Elemtype e){
if(QueueFull(Q)){
Q.data = (Elemtype *)realloc(Q.data, (Q.qsize+STACK_INCREMENT)*sizeof(Elemtype));
if(!Q.data) return false;
Q.qsize += STACK_INCREMENT;
}
Q.data[Q.rear] = e;
Q.rear = (Q.rear+1)%Q.qsize;
return true;
}
入队时,如果队列满则需要扩容,同时修改 qsize。
而不满时,则需要先在 rear 位置插入,然后再加一并取模(为什么取模应该已经解释清楚了)。
bool DeQueue(Queue &Q, Elemtype &e){
if(QueueEmpty(Q)) return false;
e = Q.data[Q.front];
Q.front = (Q.front+1)%Q.qsize;
return true;
}
出队时, 先在 front 位置出队,然后 front 后移(注意是加一不是减一,和栈不一样)。
2.2.5 遍历
遍历就挨个出队并输出就好了。
2.2.6 其他实现方法
typedef struct{
Elemtype data[MAXSIZE];
int front, rear;
int tag;
}Queue;
bool InitQueue(Queue &Q){
Q.front = 0;
Q.rear = MAXSIZE-1;
Q.tag = 0;
}
bool DestroyQueue(Queue &Q){
}
bool ClearQueue(Queue &Q){
}
bool QueueEmpty(Queue Q){
if(Q.tag==0 && (Q.rear+1)%MAXSIZE==Q.front) return true;
return false;
}
bool QueueFull(Queue Q){
if(Q.tag==1 && (Q.rear+1)%MAXSIZE==Q.front) return true;
return false;
}
int QueueLength(Queue Q){
return (Q.rear+MAXSIZE-Q.front)%MAXSIZE+1;
}
bool GetHead(Queue Q, Elemtype &e){
if(QueueEmpty(Q)) return false;
e = Q.data[Q.front];
return true;
}
bool EnQueue(Queue &Q, Elemtype e){
if(QueueFull(Q)) return false;
Q.rear = (Q.rear+1)%MAXSIZE;
Q.data[Q.rear] = e;
Q.tag = 1;
return true;
}
bool DeQueue(Queue &Q, Elemtype &e){
if(QueueEmpty(Q)) return false;
e = Q.data[Q.front];
Q.front = (Q.front+1)%MAXSIZE;
Q.tag = 0;
return true;
}
bool QueueTraverse(Queue Q){
if(QueueEmpty(Q)) return false;
Elemtype e;
while(!QueueEmpty(Q)){
if(DeQueue(Q, e)) printf("%d\n", e);
}
return true;
}
这里还是贴一个代码,用的静态的内存分配方式,同时 rear 从 maxsize-1 开始(对应栈的 top==-1),同时增加了 tag 用来标记最后一次操作是入队还是出队。
int QueueLength(Queue Q){
return (Q.rear+MAXSIZE-Q.front)%MAXSIZE+1;
}
还记得前面讨论的从1到10有几个数吗?这里 rear 的值就等于队尾元素下标,多减了一个,所以在最后加了个1。另外还需注意,增加了 tag 后,队列长度最大为 10,但是取模后最大只能为 9,所以+1要写在括号外面。
其他的方法就不多讲了,和栈那部分类似。
2.3 链队列
有了前面的思路,链队列也不难写了。
typedef struct QNode{
Elemtype data;
QNode *next;
}QNode;
typedef struct{
QNode *front, *rear;
int len;
}LQueue;
首先是结构体的定义,这里用了两个结构体,第一个是结点,第二个是队列(用一个指针指向表头,另一个指向表尾)。
其次是如何访问节点,因为链队列本身并不是一个链表(指针结构体),链表只是链队列(LQueue,非指针结构体)当中的一部分,所以,Q.front 才是链表的头指针,而非 Q->front。而 对 data 和 next 的访问,则是 Q.front->data 的形式(参考代码)。
最后,在最后一次出队时,需要对 Q.rear 做特殊处理,有头结点时,rear=front;无头结点时,rear=NULL(参考代码)。
2.3.1 有头结点
typedef struct QNode{
Elemtype data;
QNode *next;
}QNode;
typedef struct{
QNode *front, *rear;
int len;
}LQueue;
bool InitQueue(LQueue &Q){
Q.front = Q.rear = (QNode *)malloc(sizeof(QNode));
if(!Q.front) return false;
Q.front->next = NULL;
Q.len = 0;
}
bool DestroyQueue(LQueue &Q){
if(!Q.front) return false;
while(Q.front){
Q.rear = Q.front->next;
free(Q.front);
Q.front = Q.rear;
}
return true;
}
bool ClearQueue(LQueue &Q){
}
bool QueueEmpty(LQueue Q){
if(!Q.front) return false;
if(Q.front == Q.rear) return true;
return false;
}
bool QueueFull(LQueue Q){
}
int QueueLength(LQueue Q){
return Q.len;
}
bool GetTop(LQueue Q, Elemtype &e){
if(QueueEmpty(Q)) return false;
e = Q.front->data;
return true;
}
bool EnQueue(LQueue &Q, Elemtype e){
if(!Q.rear) return false;
QNode *s = (QNode *)malloc(sizeof(QNode));
if(!s) return false;
s->data = e;
s->next = NULL;
Q.rear->next = s;
Q.rear = s;
Q.len++;
return true;
}
bool DeQueue(LQueue &Q, Elemtype &e){
if(QueueEmpty(Q)) return false;
QNode *q = Q.front->next;
e = q->data;
Q.front->next = q->next;
if(Q.rear == q) Q.rear = Q.front;
free(q);
Q.len--;
return true;
}
bool QueueTraverse(LQueue &Q){
if(!Q.front || QueueEmpty(Q)) return false;
Elemtype e;
while(!QueueEmpty(Q)){
if(DeQueue(Q, e)) printf("%d\n", e);
}
return true;
}
2.3.2 无头结点
typedef struct QNode{
Elemtype data;
QNode *next;
}QNode;
typedef struct{
QNode *front, *rear;
int len;
}LQueue;
bool InitQueue(LQueue &Q){
Q.front = Q.rear = NULL;
Q.len = 0;
return true;
}
bool DestroyQueue(LQueue &Q){
if(!Q.rear) return false;
while(Q.front){
Q.rear = Q.front->next;
free(Q.front);
Q.front = Q.rear;
}
return true;
}
bool ClearQueue(LQueue &Q){
}
bool QueueEmpty(LQueue Q){
if(!Q.front) return true;
return false;
}
bool QueueFull(LQueue Q){
}
int QueueLength(LQueue Q){
return Q.len;
}
bool GetTop(LQueue Q, Elemtype &e){
if(!Q.front) return false;
e = Q.front->data;
return true;
}
bool EnQueue(LQueue &Q, Elemtype e){
QNode *s = (QNode *)malloc(sizeof(QNode));
if(!s) return false;
s->data = e;
s->next = NULL;
if(!Q.front) Q.front = s;
else Q.rear->next = s;
Q.rear = s;
Q.len++;
return true;
}
bool DeQueue(LQueue &Q, Elemtype &e){
if(QueueEmpty(Q)) return false;
QNode *q = Q.front;
e = q->data;
Q.front = q->next;
if(q == Q.rear) Q.rear = NULL;
free(q);
Q.len--;
return true;
}
bool QueueTraverse(LQueue &Q){
if(QueueEmpty(Q)) return false;
Elemtype e;
while(!QueueEmpty(Q)){
if(DeQueue(Q, e)) printf("%d\n", e);
}
return true;
}
三、栈的应用(括号匹配)
3.1 括号匹配的原理
首先,括号匹配是什么?在IDE编译代码时,当程序员漏了半边括号的时候,总会报 error,其实这就是括号匹配,即左半括号总是和右半括号成对出现。比如 "{ [ ( ) ] }" 就成功匹配了所有括号,再入 " { [ ] } ( ) ( ( ) ) " 也是,诸如此类。
以 " { [ ] } ( ) ( ( ) ) " 为例,在手算的过程中,首先从左到右,第一个遇到的是 ' { ';第二个遇到的是 ' [ ';第三个遇到的是 ' ] ',和第二个遇到的 ' [ ' 匹配;第四个遇到的是 ' } ',和第一个遇到的 ' { ' 匹配;第五个遇到的是 ' ( ';第六个遇到的是 ' ) ',和第五个遇到的 ' ( ' 匹配……
可以发现,当遇到左括号的时候,我们会按顺序记下它们,但总是拿着最后一个记住的左括号去找右括号,如果匹配,再拿着倒数第二个左括号去匹配下一个右括号,以此类推。
这种思路和栈的用法很像,即最后一个出现的左括号优先和右括号匹配,符合后进先出的特点。
3.2 算法设计
算法执行的流程也十分简单,当遇到左括号的时候,执行压栈操作;当遇到右括号的时候,执行出栈操作,并比较出栈的左括号和右括号是否是一对,如果是,则继续循环上述操作;如果不是,则认为匹配失败;如果当括号串遍历完后,栈不为空,则有未匹配的左括号,认为匹配失败。
不是很会画流程图……
bool bracketCheck(char str[], int length){
SqStack S;
InitStack(S);
int i;
for(i=0; i<length; i++){
if(str[i]=='(' || str[i]=='[' || str[i]=='{'){
Push(S, str[i]);
}
else{
if(StackEmpty(S)) return false;
char topElem;
Pop(S, topElem);
if(str[i]==')' && topElem!='(') return false;
else if(str[i]==']' && topElem!='[') return false;
else if(str[i]=='}' && topElem!='{') return false;
else if(str[i]!=')' && str[i]!=']' && str[i]!='}') return false;
}
}
if(!StackEmpty(S)) return false;
return true;
}
这里的栈用的是动态分配的顺序栈,因为不确定传进来的括号表达式的长度,所以很难设置静态的MAXSIZE大小。
四、栈的应用(中缀后缀表达式转换和计算)
4.1 前缀、中缀、后缀表达式
首先介绍介绍一下前中后缀的概念:
中缀表达式就是我们最常用的类型,操作符在操作数中间,比如1+1,1*2+3;
后缀表达式又叫逆波兰表达式(Reverse Polish Notation,RPN),操作符在操作数后面,比如11+,12*3+;
前缀表达式又叫波兰表达式(Polish Notation,PN),操作符在操作数后面,比如+11,+*123。
中缀表达式转后缀表达式的过程,用1*2+3举例:
在手算的过程中,需要先算1乘2,再将结果和3相加,先算1乘2转换成后缀就是12*,此时将12*视为一个新的操作数x,再将x和3相加就有了x3+,即12*3+;
后缀表达式转中缀表达式的过程,用12*3+举例:
从左到右看,先找到乘号,左边有两个操作数,则算1*2,此时将1*2视为一个新的操作数x,再找到加号,则算x+3,即1*2+3;
中缀表达式转前缀表达式的过程,用1*2+3举例:
在手算的过程中,需要先算1乘2,再将结果和3相加,先算1乘2转换成前缀就是*12,此时将*12视为一个新的操作数x,再将x和3相加就有了+x3,即+*123;
前缀表达式转中缀表达式的过程,用+*123举例:
从右到左看,先找到乘号,右边有两个操作数,则算1*2,此时将1*2视为一个新的操作数x,再找到加号,则算x+3,即1*2+3;
后缀表达式在实际应用更广泛,故仅阐述中缀转后缀及计算(中缀转前缀恰好相反)。
4.2 算法设计
4.2.1 中缀转后缀
在上面的例子中,虽然很简略,但是已经大体上了解了转换的过程,这里再举一个略复杂的例子。
1 + 2 * ( 3 - 4 ) - 5 / 6
在手算的时候,从左往右看:
第一个看到 1,记下来;
第二个看到 +,记下来;
第三个看到 2,这时候能执行 1+2 了吗?上帝视角看显然不能,后面还有个乘,应该先算乘,所以记下来;
第四个看到 *,记下来;
第五个看到 (,记下来;
第六个看到 3,记下来;
第七个看到 -,记下来;
第八个看到 4,这时候能执行 3-4 了吗?上帝视角看显然是可以,但如果右边是个乘除或者左括号呢?所以能算,但是不敢算,先记下来;
第九个看到 ),这时候可以大胆的算 3-4 了,因为右括号右边无论是什么,都得先算括号里面的东西。算完后表达式变为 1 + 2 * (-1) - 5 / 6,注意这个括号不参与运算了,只是为了括住负数。
第十个看到 -,2*(-1) 好像可以算了,但是得明白道理,2*(-1) 左边是加号,右遍是减号,先乘除后加减,所以 2*(-1) 可以算。算完后表达式变为 1 + (-2) - 5 / 6。然后发现 1+(-2) 好像可以算了,但是也得明白道理,1+(-2) 右边是个减号,先乘除后加减,我加你减,同等重要,所以 1+(-2) 可以算。 算完后表达式变为 (-1) - 5 / 6 最后别忘了把减号记住。
第十一个看到 5,这时候能执行 (-1)-5 了吗?上帝视角看显然不能,因为右遍还有个除,应该先算除,所以记下来。
第十二个看到 /,记下来;
第十三个看到 6,这时候能执行 5/6 了吗?上帝视角看显然是可以,因为右边没东西了。算完后表达式变为 (-1) - 0(假设为整型变量)。然后 (-1) - 0 也算了,结果就出来了。
如果不算结果的话,只看转换成的后缀表达式,那么是 1234-*+56/-。
上面这个例子比较冗长,不看也没关系,其实已经把计算也给说完了,规律总结如下:
见到操作数,直接输出;
见到运算符,需要先记着,如果下一个运算符优先级相同或者低的话,就可以把这次的先算了。比如,第一次碰到个加号,第二次碰到个乘号,第二次优先级高,不能先算加号;第一次碰到个乘号,第二次碰到个除号,两次优先级相同,可以先把乘号算了;第一次碰到个乘号,第二次碰到个加号,第一次优先级高,可以先把乘号算了。
见到界限符(括号),如果是左括号,则存起来;如果碰到右括号,不管右边还有啥,先把括号里的算了。
那么,转换部分的算法描述如下:
首先需要一个运算符栈,然后从左往右依次遍历中缀表达式。
如果当前字符是操作数,则直接输出;
如果当前字符是运算符,则将当前运算符与栈顶运算符比较,如果栈顶的优先级高或相同,则取出栈顶元素并输出;如果栈顶元素优先级低,则不输出。最后把当前运算符压栈。
如果当前字符是左括号,直接压栈,并且左括号的优先级最低(设计上,并非逻辑上,因为总是先算优先级更高的运算符,逻辑上如果把左括号也当作运算符,那它应该是最后被算的那个)。
如果当前字符是右括号,则依次输出栈内左括号之前的运算符,并把左括号出栈。
遍历完后如果栈不空,则依次出栈并输出。
再用上面那个例子来说明一下:
中缀表达式:1 + 2 * ( 3 - 4 ) - 5 / 6
目标后缀表达式:1 2 3 4 - * + 5 6 / -
第一次扫描到 1,直接输出。此时栈空,输出结果为 1。
第二次扫描到 +,因为栈内没有可比较运算符,+ 压栈。此时栈内为 +,输出结果为 1。
第三次扫描到 2,直接输出。此时栈内为 +,输出结果为 1 2。
第四次扫描到 *,栈顶为 +,优先级低,不做操作,* 压栈。此时栈内为 + *,输出结果为 1 2。
第五次扫描到 (,直接压栈。此时栈内为 + * (,输出结果为 1 2。
第六次扫描到 3,直接输出。此时栈内为 + * (,输出结果为 1 2 3。
第七次扫描到 -,栈顶为 (,优先级低,不做操作,- 压栈。此时栈内为 + * ( -,输出结果为 1 2 3。
第八次扫描到 4,直接输出。此时栈内为 + * ( -,输出结果为 1 2 3 4。
第九次扫描到 ),栈顶为 -,出栈并输出。此时栈内为 + * ( ,输出结果为 1 2 3 4 -。此时栈顶为 (,出栈。此时栈内为 + *,输出结果为 1 2 3 4 -。
第十次扫描到 -,栈顶为 *,优先级高,出栈并输出。此时栈内为 +,输出结果为 1 2 3 4 - *。此时栈顶为 +,优先级相同,出栈并输出。此时栈空,因为栈内没有可比较运算符,- 压栈。此时栈内为 -,输出结果为 1 2 3 4 - * +。
第十一次扫描到 5,直接输出。此时栈内为 -,输出结果为 1 2 3 4 - * + 5。
第十二次扫描到 /,栈顶为 -,优先级低,不做操作,/ 压栈。此时栈内为 - /,输出结果为 1 2 3 4 - * + 5。
第十三次扫描到 6,直接输出。此时栈内为 - /,输出结果为 1 2 3 4 - * + 5 6。
第十四次扫描失败,栈内元素依次出栈并输出。此时栈顶为 /,出栈并输出,输出结果为 1 2 3 4 - * + 5 6 /。此时栈顶为 -,出栈并输出,输出结果为 1 2 3 4 - * + 5 6 / -。结果正确!
我认为以上这个例子唯一没有覆盖到的就是括号内还有加减乘除混合运算甚至括号嵌套的情况。在括号内的执行逻辑,只要没有碰到右括号,和碰到普通运算符以及左括号是一致的。
说了这么多,代码如下:
int getWeight(char op){
if(op=='+' || op=='-') return 1;
else if(op=='*' || op=='/') return 2;
else if(op=='(') return -9999;
else return 0;
}
char* rpnConversion(char infix[], int length){
SqStack S;
InitStack(S);
char *suffix = (char *)malloc(length*sizeof(char));
int i, j = 0;
char topElem;
for(i=0; i<length; i++){
if(infix[i]>='0' && infix[i]<='9'){
suffix[j++] = infix[i];
}
else if(infix[i]=='+' || infix[i]=='-' || infix[i]=='*' || infix[i]=='/'){
if(StackEmpty(S)) Push(S, infix[i]);
else{
GetTop(S, topElem);
while(getWeight(topElem)>=getWeight(infix[i])){
Pop(S, suffix[j++]);
if(StackEmpty(S)) break;
GetTop(S, topElem);
}
Push(S, infix[i]);
}
}
else if(infix[i]=='('){
Push(S, infix[i]);
}
else if(infix[i]==')'){
while(true){
GetTop(S, topElem);
if(topElem=='('){
Pop(S, topElem);
break;
}
Pop(S, suffix[j++]);
}
}
}
while(!StackEmpty(S)) Pop(S, suffix[j++]);
suffix[j] = '\0';
return suffix;
}
这个代码很有局限性,仅做参考思路。因为很多情况都没讨论,比如浮点型数,中括号大括号的处理,多位数等等。
但是如果仅作为中缀转后缀使用的话,其实多位数是可以被处理的,比如123+456,遇到数就输出数,最后输出加号,那么就是123456+,看起来很对有没有…如果在加上小数点,就判断小数点也直接输出好了,比如12.3+45.6,结果是12.345.6+,也很对有没有……开玩笑开玩笑。因为本人才疏学浅且目的暂时是为了考研,精力有限,所以不做过多探讨,网上大牛有很多可以借鉴。
4.2.2 后缀计算
后缀表达式计算其实比中缀转后缀要简单许多,由浅入深看几个例子。
11+,1+1
112*+,1*2+1
21/12*+,2/1+1*2,这个解释一下,21/当作一个操作数,12*当作一个操作数,最后俩加起来
不难发现,运算过程就是从左到右扫描,碰到运算符就从左边取俩操作数运算。这就是后缀表达式运算的思路,并没有牵扯到优先级之类的。简单来说,就是需要一个操作数栈来保存扫描到的每个操作数。所以这里就不贴代码了(我没写),可以直接参考中缀计算。
4.2.3 中缀计算(转换+计算)
中缀计算,其实就是一个把中缀表达式转换成后缀表达式并运算的过程,理论上,可以先转换成后缀表达式,然后再计算。但是两部其实是可以同时进行的,这样一来,就需要两个栈了,一个运算符栈,一个操作数栈。
当遇到操作数的时候,压入操作数栈;
当遇到运算符的时候,按照中缀转后缀的逻辑执行,同时每次取出运算符都需要带着俩操作数。
int getWeight(char op){
if(op=='+' || op=='-') return 1;
else if(op=='*' || op=='/') return 2;
else if(op=='(') return -9999;
else return 0;
}
bool isDigit(char c){
if(c>='0' && c<='9') return true;
else return false;
}
bool isOperator(char c){
if(c=='+' || c=='-' || c=='*' || c=='/') return true;
else return false;
}
bool cal(SqStack &Sop, SqStack &Snum, char op){
char a, b;
Pop(Snum, b);
Pop(Snum, a);
int n1 = a-'0';
int n2 = b-'0';
if(op=='+'){
Push(Snum, n1+n2+'0');
}
else if(op=='-'){
Push(Snum, n1-n2+'0');
}
else if(op=='*'){
Push(Snum, n1*n2+'0');
}
else if(op=='/'){
Push(Snum, n1/n2+'0');
}
return true;
}
int infixCalculation(char infix[], int length, char *suffix){
SqStack Sop;
InitStack(Sop);
SqStack Snum;
InitStack(Snum);
char topElem;
int i, j = 0;
for(i=0; i<length; i++){
if(isDigit(infix[i])){
suffix[j++] = infix[i];
Push(Snum, infix[i]);
}
else if(isOperator(infix[i])){
if(StackEmpty(Sop)) Push(Sop, infix[i]);
else{
GetTop(Sop, topElem);
while(getWeight(topElem)>=getWeight(infix[i])){
Pop(Sop, suffix[j++]);
cal(Sop, Snum, suffix[j-1]);
if(StackEmpty(Sop)) break;
GetTop(Sop, topElem);
}
Push(Sop, infix[i]);
}
}
else if(infix[i]=='('){
Push(Sop, '(');
}
else if(infix[i]==')'){
while(true){
GetTop(Sop, topElem);
if(topElem=='('){
Pop(Sop, topElem);
break;
}
Pop(Sop, suffix[j++]);
cal(Sop, Snum, suffix[j-1]);
}
}
}
while(!StackEmpty(Sop)){
Pop(Sop, suffix[j++]);
cal(Sop, Snum, suffix[j-1]);
}
suffix[j] = '\0';
char c;
Pop(Snum, c);
int res = c-'0';
return res;
}
这里同样没有做太高深的探讨,比如浮点运算之类的。实际上,这个代码仅针对一位数的整型变量。两个栈可以都用 char 类型,当整型需要压栈时,+'0' 再压,当整型需要出栈时,出栈后 -'0' 即可。
==总结==