栈的基本概念
开始之前我们依然先思考几个问题:
- 生活中有哪些类似于栈的例子?
- 你认为栈在实际开发中可以解决哪些问题?
前面我们说了线性表的两种基本实现,顺序存储实现和链式存储实现。现在我们给这种线性表的基本操作增加一个限制条件,如下图所示。
只允许在表的一端进行插入和删除操作。
拥有这种特性的线性表,我们称之为==“栈(stack)”==,表的顶部我们称之为==“栈顶(top)”==,表的底部我们称之为==“栈底(bottom)”==。每次我们只对栈顶位置进行操作,因此具有先进后出,后进先出的特性,简写为FIFO。栈在我们生活中有很多例子,比如放盘子的时候,最后放的总是在最上面;再比如走迷宫,总是先试探性地往前走,走到最后没有路了,然后又回到最近走过的地方,探索其他出口。在软件方面也有很多应用,比如判断运算表达式是否合规,编译原理中的语法分析等。
在实现栈的相关操作之前,我们介绍一个和栈有关的数学性质,n 个不同的元素按照顺序入栈,其出栈元素不同排列顺序的个数可以根据以下公式计算得到。该公式我们称之为卡特兰(Catalan)数:
具体证明可以参考组合数学教材或者维基百科。
栈的实现
- 顺序存储结构
// 采用静态分配内存的方式
#define MAX_SIZE 100
typedef int ElemType;
typedef struct {
ElemType data[MAX_SIZE];
int top // 栈顶指针
} SqStack;
// 采用动态分配内存的方式,当栈的存储空间不够用的时候,重新分配一块内存
#define INIT_SIZE 100
#define INCREMENT 20
typedef struct {
ElemType *bottom;
ElemType *top;
int stackSize;
} SqStack;
为了方便操作,这里我们使用静态分配的数组实现相关的操纵。
// 我们假设栈顶指针初始位置为-1,top指向栈顶元素
// 初始化
bool InitStack(SqStack *stack) {
stack->top = -1;
return true;
}
// 判断空栈
bool EmptyStack(SqStack *stack) {
return stack->top == -1 ? true : false;
}
// 判断满栈
bool FullStack(SqStack *stack) {
return stack->top == MAX_SIZE-1 ? true : false;
}
// 入栈操作
bool PushStack(SqStack *stack, ElemType data) {
if (FullStack(stack)) {
printf("stack full\n");
return false;
}
stack->data[++stack->top] = data;
return true;
}
// 出栈操作
bool PopStack(SqStack *stack, Elemtype *data) {
if (EmptyStack(stack)) {
printf("stack empty\n");
return false;
}
*data = stack->data[stack->top--];
return true;
}
动态分配内存的方式,同上一节的基本操作类似,这里不在赘述,具体可以参考严蔚敏,吴伟民老师的教材《数据结构(C语言版)》。接下来我们通过链式结构来实现栈的基本操作。
- 链式存储结构
typedef int ElemType;
typedef struct LinkNode
{
ElemType data;
struct LinkNode *next;
} *LinkStack;
// 判断空栈
bool EmptyStack(LinkStack linkStack)
{
return linkStack->next == NULL ? true : false;
}
// 判断满栈(链式结构略)
// 入栈
bool PushStack(LinkStack linkStack, ElemType data)
{
if (linkStack == NULL)
{
return false;
}
struct LinkNode *newNode = (struct LinkNode*)malloc(sizeof(struct LinkNode));
if (newNode == NULL)
{
return false;
}
newNode->data = data;
newNode->next = NULL;
newNode->next = linkStack->next;
linkStack->next = newNode;
return true;
}
// 出栈
bool PopStack(LinkStack linkStack, ElemType *data)
{
if (linkStack == NULL || linkStack->next == NULL)
{
return false;
}
struct LinkNode *tmpNode = linkStack->next;
*data = tmpNode->data;
linkStack->next = tmpNode->next;
return true;
}
链表操作应用
- 链表的逆转(补充第二节链表操作的应用),如下图所示。
-
解析
解法一:我们可以逐个遍历链表的每个节点,然后将遍历的节点使用头插法(从头节点中插入)插入新的链表中,实现链表的逆转。
-
示例代码
typedef int ElemType typedef struct LinkNode{ ElemType data; struct LinkNode *next; } *LinkList; bool ReverseLinkList(LinkList Sq) { // 异常处理 if (Sq == NULL) { return false; } LinkList tmpSq = Sq->next; LinkList tmpNode = NULL; LinkList tmpList = (LinkList)malloc(sizeof(struct LinkNode)); if (tmpList == NULL) { return false; } tmpList->next = NULL; while (tmpSq != NULL) { tmpNode = tmpSq; tmpSq = tmpSq->next; // 头插法插入新链表中 tmpNode->next = tmpList->next; tmpList->next = tmpNode; } Sq->next = tmpList->next; free(tmpList); return true; }解法二:针对上一种解法进行优化
bool ReverseLinkList2(LinkList Sq) { if (Sq == NULL) { return false; } LinkList newList = NULL; LinkList curNode = Sq->next; LinkList tmpNode = NULL; while ( curNode != NULL ) { tmpNode = curNode; curNode = curNode->next; tmpNode->next = newList; newList = tmpNode; } Sq->next = newList; return true; }解法三:采用递归的思想
递归的思想就是将一个复杂的问题分解成两部分,分别处理这两部分,如果这两部分还是很复杂,则对这两个子问题再进行分解,直到能够简单处理即可。其实递归的实现大概的思想我们都可以hold住,按照我个人的经验来说,难点在于递归终止条件的确定以及如何正确的将参数传给上一层,保证递归正确处理,链表逆转递归实现具体处理步骤如下。
-
示例代码
typedef int ElemType typedef struct LinkNode{ ElemType data; struct LinkNode *next; } *LinkList; LinkList reverse(LinkList Sq, LinkList *tail) { LinkList reversedSq = Sq; if(Sq->next == NULL) { *tail = Sq; } else { reversedSq = reverse(Sq->next, tail); (*tail)->next = Sq; *tail = Sq; } return reversedSq; } bool ReverseLinkList3(LinkList Sq) { if (Sq == NULL) { return false; } LinkList *tail = (LinkList)malloc(sizeof(LinkList)); Sq->next = reverse(Sq->next, tail); // 这一步不能少,递归函数中tail->next总是指向上一个节点 (*tail)->next = NULL; return true; }
栈在表达式计算中的应用
我们先来看看一个算数表达式:
这个算术表达式由两部分构成,运算数和运算符。根据运算数和运算符的位置不同可以将表达式分为以下几种。
前缀表达式——运算符号位于两个运算数之前;
中缀表达式——运算符号位于两个运算数之间;
后缀表达式——运算符号位于两个运算数之后。
上面的式子为中缀表达式。其对应的前缀表达式和后缀表达式分别为:
中缀表达式就是我们人能够正常处理和识别的表达式,人们在计算机中输入的就是中缀表达式的形式,可是这种形式并不易于计算机的处理。因此我们的计算程序要计算给定的表达式的值,需要将中缀表达式转换成前缀表达式或后缀表达式,再进行计算,下面我们来说说这几种表达式的计算及转换规则。
计算机使用前缀表达式求值:
==从右至左依次扫描表达式==,若为数字字符,则将那个字符压入堆栈,若为运算符,则从堆栈中弹出两个运算数,按照==栈顶元素 OP 次栈顶元素==的顺序计算结果并压入栈中,继续向左扫描表达式字符串,重复前面描述的处理逻辑,最后计算得出的结果即为表达式的值。
例:计算前缀表达式:+ 5 - / 6 2 * 3 4
- 从右至左扫描,将4、3压入栈中;
- 遇到 * 运算符,弹出3和4(3为栈顶元素,4为次顶元素),计算出3*4的值12压入栈中;
- 将2、6压入栈中;
- 遇到 / 运算符,弹出6和2(6为栈顶元素,2为次顶元素),计算出6/2的值3压入栈中;
- 遇到 - 运算符,弹出3和12(3为栈顶元素,12为次顶元素),计算出3-12的值-9压入栈中;
- 将5压入栈中
- 遇到 + 运算符,弹出5和-9(5为栈顶元素,-9为次顶元素),计算出5-9的值-4压入栈中;
- 扫描结束,取出栈中的值即为最终结果。
计算机使用后缀表达式求值: ==从左至右扫描表达式==,若为数字字符,则将该字符压入堆栈,若为运算符,则从堆栈中弹出两个运算数,按照==次栈顶元素 OP 栈顶元素==的顺序计算结果并压入栈中;继续向右扫描表达式的值,重复前述的处理逻辑,最后计算得出的值即为表达式的值。
例:计算后缀表达式:5 6 2 / + 3 4 * -
- 从左至右扫描,将5、6、2压入堆栈;
- 遇到 / 运算符,因此弹出2和6(2为栈顶元素,6为次顶元素,注意与前缀表达式做比较),计算出6/2的值3,压入栈中;
- 遇到 + 运算符,弹出3和5(3为栈顶元素,5为次顶元素),计算5+3的值8,压入栈中;
- 将3、4压入堆栈
- 遇到 * 运算符,弹出4和3(4为栈顶元素,3为次顶元素),计算出3*4的值12压入栈中;
- 遇到 - 运算符,弹出12和8(12为栈顶元素,8为次顶元素),计算出8-12的值-4压入栈中;
- 扫描结束,取出栈中的值即为最终结果。
中缀表达式、后缀表达式和前缀表达式相互转换
下期见。。。
参考文献
[1] 数据结构:C语言版 . 严蔚敏等 . 北京:清华大学出版社,2007
[2] 数据结构考研复习指导:王道论坛 . 电子工业出版社
[3] 数据结构:第二版 . 陈越 . 北京:高等教育出版社
[4]前缀、中缀、后缀表达式:Antineutrino