栈和队列都是 特殊的线性表(本质上是线性表,因为他们的相邻元素也是一对一的线性关系,前驱后继,且同类型),所以栈和队列也可以用顺序结构和链式结构两种方式实现。他们的 特殊之处就在于限制了插入操作和删除操作的位置,栈的插入操作也叫做压栈,入栈,进栈;栈的删除操作也叫做出栈,弹栈。
栈顶top:允许插入和删除的一端
栈底bottom:固定,最先进栈的元素在栈底
既然栈可以用数组和链表很简单地实现,那为什么我们还要专门把他实现出来,比如C++, Java等语言基本都把栈结构封装好了的,可以直接使用,我们为什么不在需要用的时候自己写呢,毕竟写起来并不难?
这是因为把栈结构实现出来,有助于我们划分不同的关注层次,有助于缩小我们的思考范围,有助于我们去聚焦于问题的核心。如果没把栈封装好,每次都自己写,不利于代码重用,还需要分散精力去考虑实现细节,掩盖了问题的本质。我们可以从这个问题出发,以小见大,看到封装的
好处,把一些常用的结构或者功能封装好,就可以划分关注层次,聚焦问题核心,减小精力的分散和浪费。
现实应用举例(只要用了先进后出,后进先出思想的都是栈的示例)
就因为后进先出,所以栈也被叫做LIFO结构,那队列自然就是FIFO结构拉
- 手枪,,这例子牛逼。先放进去的子弹要后被发射出来,后放的子弹却先被发射出来。
- 浏览器网页回退,先回退到上一个观看的网页,再退到上上个网页,但是上个网页比上上个网页后进来
- word,photoshop等软件的undo撤回操作
栈的抽象数据类型
ADT Stack
Data//元素具有相同类型,相邻元素具有前驱后继的一对一的线性关系
Operation
InitStack(*S);//初始化,建立一个空栈
DestroyStack(*S);//销毁栈
ClearStack(*S);//清空栈
StackEmpty(*S);//是否为空
GetTop(*S, *e);//把栈顶元素返回到e指向的内存中
Push(*S, e);//把新元素e压栈
Pop(*S, *e);//把栈顶元素出栈,放入e指向的内存对象
StackLength(*S);
endADT
顺序栈:栈的顺序存储结构,用数组实现
顺序结构的线性表用数组实现,顺序结构的栈当然也使用数组实现。但是用数组哪一端作为栈顶呢?
答案是:下标为0的那端。让栈顶不断增长,用一个top变量表示栈顶元素在数组中的位置
#define SIZE 100
typedef struct
{
ElemType data[SIZE];
int top;//作为栈顶指针使用
}SqStack;
这里说的指针不是指针类型(指针类型是指向内存的),而是起到一各指向元素的作用,一个形象的说法,此指针非彼指针。这里用了int来起到指针的作用,即指向某个元素,只不过这里指向的不是内存,之前静态链表中使用int类型的cur游标表示指针,和这里类似。
进栈:O(1)
Status Push(SqStack * S, ElemType e)
{
if (S->top == SIZE)
return ERROR;//满栈
S->data[S->top] = e;
++(S->top);
return OK;
}
出栈:O(1)
Status Pop(SqStack * S, ElemType * e)
{
if (S->top == 0)
return ERROR;
--(S->top);
*e = S->data[S->top];
return OK;
}
两个同数据类型的栈共享空间:缓解数组长度必须事先确定的麻烦,适合于两栈的空间需求相反的情况
想法新颖,思路清奇,用这种手段去尽量避免数组长度设置不合理带来的痛苦。真的还是很聪明的,为了克服问题,什么办法都能想出来,非常有技巧性。
这个图特别形象:两个数组的端点一起向中间延伸,延伸的终止条件就是两个栈顶指针相遇(top1 + 1 == top2)。
这种共享很适合用于两个栈的空间需求基本相反的情况,即一个增加则另一个一般会减少,这样则可以最大化利用这个数组的空间。如果两个栈的空间需求一样,两个都同时增加或者同时减少,那要么很快就溢出要么句都空置,和单个栈不共享没有分别。
typedef struct
{
ElemType data[SIZE];
int top1;//栈1的指针从0开始增长,直到SIZE
int top2;//栈2的指针从SIZE-1开始下降,直到-1
}SqDoubleStack;
一个指针增大,一个减小,但都指向各自栈的栈顶,即下一个可存储元素的位置
进栈
Status Push(SqDoubleStack *S, ElemType e, int StackNumber)
{
if (S->top1+1 == S->top2)
return ERROR;//栈满
if (StackNumber == 1)
S->data[(S->top1)++] = e;
else if (StackNumber == 2)
S->data[(S->top2)--] = e;
return OK;
}
出栈
Status Pop(SqDoubleStack * S, ElemType * e, int StackNumber)
{
if (StackNumber == 1)
{
if (S->top1 == 0)
return ERROR;//栈1是空栈
*e = S->data[--(S->top1)];
return OK;
}
else if (StackNumber == 2)
{
if (S->top2 == SIZE - 1)
return ERROR;//栈2是空栈
*e = S->data[++(S->top2)];
return OK;
}
}
链栈: 栈顶结点替代头结点;不会满栈
栈只需在一端插入和删除,选择链表的头部还是尾部呢?头部有一个头结点,那何不把头结点和栈顶结点合二为一呢?所以链式栈不需要链表的头结点。
由于用链表实现栈,插入新结点会动态分配新内存,所以不会出现满栈情况,除非堆内存真的被程序用完了。
typedef struct StackNode//栈结点
{
ElemType data;
struct StackNode * next;
}StackNode, *LinkStackPtr;//LinkStackPtr是struct StackNode *,指向StackNode结点的指针类型
typedef struct LinkStack//链栈
{
LinkStackPtr top;
int count;//链栈的元素个数
}LinkStack;
进栈:O(1)
进栈出栈都没有循环,时间复杂度都是O(1)
Status Push(LinkStack *S, ElemType e)
{
LinkStackPtr s = (LinkStackPtr)malloc(sizeof(StackNode));
s->data = e;
s->next = S->top;
S->top = s;
++(S->count);
return OK;
}
出栈:O(1)
Status Pop(LinkStack * S, ElemType * e)
{
if (S->count == 0)//空栈
return ERROR;
*e = S->top->data;
LinkStackPtr p = S->top;//改变栈顶前保存当前栈顶,便于后面释放
S->top = S->top->next;//栈顶改为原来的第二个元素
--(S->count);
free(p);
return OK;
}
链栈 VS 顺序栈
- 时间上: 进栈出栈的时间复杂度一样,均为O(1)
- 空间上:
- 顺序栈
优点:存取时定位很方便
缺点:需要实现确定数组大小,可能浪费空间或者不够用, 不够用则容易导致栈溢出 stack overflow - 链栈
优点:栈的长度没有限制
缺点:要求每个元素都有指针域,增加了空间开销
二者进栈出栈都无需移动元素
所以如果栈的使用过程中大小不怎么变化且长度是基本可以预测的,就用顺序栈,否则就用链栈
栈的应用
看了这两个应用之后才发现,栈这种数据结构是多么地有用,并不只是咱们自己写代码时有用,而且计算机领域已经很多地方都使用了栈的原理和结构了,比如函数调用,表达式求值。都是非常常见但又不引人注意的地方,原来都隐藏了栈的身影!
递归(函数调用自己,调用自己和调用别的函数并没有什么不同)
但是递归最怕的就是无穷递归,永不结束。所以,递归最重要的就是要至少有一个递归结束条件,一旦满足这个条件就不再调用自己,而是返回上一级并传返回值。
经典递归例子:斐波那契数列 Fibonacci
迭代式代码,使用循环结构
#include <iostream>
int main()
{
int i;
int a[20];
a[0] = 0;
a[1] = 1;
std::cout << a[0] << '\n';
std::cout << a[1] << '\n';
for (i = 2; i < 20; ++i)
{
a[i] = a[i - 1] + a[i - 2];
std::cout << a[i] << '\n';
}
return 0;
}
0
1
1
2
3
5
8
13
21
34
55
89
144
233
377
610
987
1597
2584
4181
递归式代码,使用选择结构
递归的优点:使得程序结构更加清晰,简洁,易于理解
缺点:每一级递归调用都会建立一个函数的副本,需要耗费大量的时间和空间。
递归调用中有前进和回退两个阶段。回退阶段的顺序是前进阶段的逆序。前行阶段,每一层递归的函数副本的局部变量,参数值,返回地址都被压栈,回退阶段里,位于栈顶的函数副本的局部变量,参数值和返回地址被弹出,以恢复调用层次中执行的其余部分,所以实际上编译器是使用栈来实现递归的。只不过对于高级语言来说,这个栈不需要程序员自己管理,编译器和系统会管代劳。
#include <iostream>
int Fibo(int n)
{
if (n < 2)
return n==0 ? 0: 1;
return Fibo(n-1) + Fibo(n-2);
}
int main()
{
int i;
for (i = 0; i < 20; ++i)
std::cout << Fibo(i) << '\n';
return 0;
}
四则运算表达式求值(原来如此!)
对于有括号的表达式:遇到左括号就入栈,遇到右括号就让栈顶左括号出栈
栈顶左括号出栈前的数字们参与运算
没括号表达式:逆波兰表示法reverse Polish Notation RPN
也叫做后缀表示法,因为所有的符号都在数字后面出现,于是就不需要用到括号了,人类看这种表达式是很不方便,但是 计算机很喜欢
逆,是因为每次运算时,都是要出栈栈顶的两个数,且第一个出栈的作为第二操作数,所以是逆序的。
波兰,是因为发明这个表示法的科学家是
示例
原表达式(这种平时咱们使用的标准四则运算表达式叫做中缀表达式):
其后缀表达式(用后缀表示法表示的中缀表达式就是后缀表达式):
计算规则:
计算过程:
注意第一个出栈的数作为减数,第二个出栈的是被减数
然后是符号/,第一个出栈的2作为除数,第二个出栈的10是被除数,两者相除得到5,入栈。
然后是符号+,第一个出栈的是5,第二个是15,相加得到20,进栈。
后缀表达式遍历完毕,出栈20作为结果,栈为空。
如何把中缀表达式转为后缀表达式
平时咱们书写的表达式就是中缀表达式,比如
刚才展示了如何利用栈把后缀表达式的结果计算出来,通过例子可以看到确实是很方便计算,不担心括号了,加减乘除的计算顺序也处理得很正确。但是如何把中缀表达式转为后缀表达式呢?
答案就在下面这段话中。
即只有符号会进栈出栈,数字不会进栈
然后数字1输出,表达式变为 9 3 1
然后遇到符号“)”,右括号,所以要去匹配之前的左括号,于是把栈顶元素依次出栈并输出,直到第一个左括号出栈为止。所以先出栈第一个“-”,表达式变为9 3 1 -, 然后出栈左括号,但是注意左括号出栈但不进入表达式,他只是和右括号匹配上了就好了。
然后是符号“ ∗ * ∗”,它比栈顶符号“+”的优先级高,所以直接进栈。
然后是数字3,输出。表达式变为:9 3 1 - 3
然后遇到“+”, 它比当前栈顶符号“*”的优先级低,所以栈中符号依次出栈,直到栈顶符号优先级低于新遇到的这个“+”,但是当前栈中只有两个符号,从栈底到栈顶分别是“+”和“ ∗ * ∗”,没人比新遇到的“+”优先级低,所以全部出栈并输出。表达式变为:9 3 1 - 3 ∗ * ∗ +。然后再把当前新的“+”入栈。
然后是数字10,输出,表达式:9 3 1 - 3 ∗ * ∗ + 10
然后是符号“ / / /”,除号优先级高于栈顶符号“+”,进栈。
最后一个数字2,输出,表达式变为:9 3 1 - 3 ∗ * ∗ + 10 2
由于中缀表达式遍历完毕,所以栈中符号全部出栈并输出,表达式变为:9 3 1 - 3 ∗ * ∗ + 10 2 / / / +
总结发现,计算机计算表达式的两个关键步骤都要用到栈,中缀转后缀时栈中存符号;后缀计算结果时栈中存数字。之前还真不知道计算机原来是这么计算表达式的,只是直到肯定要想个办法去计算,原来用了后缀表达式。