提到浏览器的前进、后退功能,大家都很熟悉。假如当你依次访问完一串页面a-b-c之后,点击浏览器的后退按钮,就可以查看之前浏览的页面b和a,当你后退到a,点击前进按钮,就可以重新看到页面b和c。但是,如果你后退到b后,点击了新的页面d,那就无法再通过前进、后退功能查看页面c了。
那么如何实现这个功能?这就要说道我们今天要学习的“栈”。
关于“栈”,我有一个非常贴切的例子,就是一摞叠在一起的盘子。我们平时放盘子的时候,都是从下往上一个一个放;取得时候,我们也是从上往下一个一个依次取,不能从中间取。后进者先出,先进者后出,这就是典型的“栈”结构.
二.如何实现一个“栈”从刚才栈的定义里,我们可以看出,栈主要包括两个操作,入栈和出栈,也就是说在栈顶插入一个数据和从栈顶删除一个数据。理解了栈的定义之后,我们来看一看如何用代码实现一个栈。
实际上,栈既可以用数组来实现,也可以用链表来实现。用数组实现的栈,我们叫顺序栈,用链表实现的栈,我们叫链式栈。
下面我们使用数组来实现顺序栈。(java实现)
package com.milogenius.collection;
/**
* @program: Milogenius
* @description:
* @author: Milogenius
* @create: 2019-01-14 17:35
* @description:
**/
public class ArrayStack {
private String[] items; //数组
private int count; //栈中元素的数量
private int n; //栈的大小
//初始化数组,申请一个大小为n的数组空间
public ArrayStack(int n){
this.items = new String[n];
this.n = n;
this.count = 0;
}
//入栈操作
public boolean push(String item){
//数组空间不够了,直接返回false,入栈失败
if (count == n) {
return false;
}
//将item放到下标为count的位置,并且count+1
items[count] = item;
++count;
return true;
}
//出栈操作
public String pop(){
//栈为空,则直接返回null
if (count == 0) {
return null;
}
//返回下标为count-1的数组元素,并且栈中的元素个数count -1
String tmp = items[count - 1];
--count;
return tmp;
}
}
了解了定义和基本操作,那它的操作的时间,空间复杂度是多少呢?
不管是顺序栈和链式栈,我们存储数据只需要一个大小为n的数组就够了。在入栈和出栈的过程中,只需要一两个临时变量存储空间,所以空间复杂度是O(1)。
注意,这里存储数据的数组大小为n,并不是说空间复杂度就是O(n)。因为,这n个空间是必须的,无法省掉。所以我们说空间复杂度时,是指除了原本的存储空间之外,算法运行还需要额外的存储空间。对于时间复杂度,因为入栈、出栈涉及栈顶个别数据的操作,所以时间复杂度都是O(1)。
三.支持动态扩容的顺序栈刚才哪个是基于数组实现的栈,是一个固定大小的栈。也就是说,在初始化栈时需要制定栈的大小。当栈满了之后,就无法再往栈里存储数据了。尽管链式栈的大小不受限,但要存储next指针,内存消耗相对较多,那我们如何基于数组实现一个可以支持动态扩容的栈?
在前面的数组课程,当数组空间不够时,我们就重新申请一块更大的内存,将原来数组中的数据统统拷贝过来。这样就实现一个支持动态扩容的数组。
因此,如果要实现一个支持动态扩容的栈,我们只需要底层依赖一个支持动态扩容的数组就可以了,当栈满了之后,我们就申请一个更大的数组,将原来的数据搬移到新数组中。我们可以根据下面的图理解。
对于出栈操作来说,我们不会涉及内存的重新申请和数据搬移,所以出栈的时间复杂度为O(1)。但是,对于入栈来说,情况就不一样了。当栈中还有空闲空间时,入栈操作的时间复杂度为O(1)。但当空间不够时,就需要重新申请内存和数据搬移,所以时间复杂度就变为O(n)。
基于前面的理论知识,我们来看看具体应用,其中,比较经典的一个场景就是函数调用栈.
我们知道,操作系统给每个线程分配一块独立的内存空间,这件内存被组织为”栈”这种结构,用来存储函数调用时的临时变量。每进入一个函数,就会将临时变量做为一个栈帧入栈,当被函数调用执行完成后,将这个函数对应的栈帧出栈。
int main(){
int a = 1;
int ret = 0 ;
int res = 0;
ret = add(3,5);
res = a + ret;
printf("%d", res);
return 0;
}
private int add(int x, int y) {
int sum = 0;
sum = x + y;
return sum;
}
从代码中可以看出,main()函数调用了add()函数,获取计算结果,并且与临时变量a相加,最后打印res的值。具体函数调用栈情况,看下图:
我们再来看栈的另一个应用场景,编译器如何利用栈来实现表达式求值。
为了方便解释,我将算数表达式简化为只包含加减乘除运算。比如:34+13*9+44-12/3。对于这个四则运算,我们人脑可以很快求解出答案,但是对于计算机,理解这个表达式确实很难。
实际上,编译器就是通过两个栈来实现的,其中一个保存操作数的栈,另一个是保存运算符的栈。我们从左到右遍历表达式,当遇到数字,我们就直接压入操作数栈;当遇到运算符,就与运算符栈的栈顶元素进行比较。
如果比运算符栈顶元素的优先级高,就将当前运算符压入栈;如果比运算符栈栈顶元素优先级低或者相同,从运算符栈中取栈顶运算符,从操作栈的栈顶取2个操作数,然后进行计算,再把计算完的结果压入操作数栈,继续比较。
下面我将3+5*8-6这个表达式画出一张图,来理解下计算过程。
现在我们来看看开篇问题,考虑一下如何利用栈来实现浏览器的前进、后退功能?
我们使用两个栈,X和Y,我们把首次浏览的页面依次压入栈x,当点击后退时,在依次从X中出栈,并将出栈的数据依次放入栈Y。当我们点击前进按钮时,我们依次从栈Y中取出数据,放入栈X中。
比如我们按顺序a , b ,c 三个页面,我们就依次把a b c 压入栈,这个时候,栈的数据如下图
当我们通过浏览器的后退按钮,从页面c后退到页面a之后,我们就依次把c和b 从栈X种弹出,并且依次放入栈y。这个时候,两个栈的数据就是这个样子:
这个时候你又想看页面b,于是点击前进按钮回到b页面,我们就把b从栈Y中出栈,放入栈X中,此事两个栈的数据如下图
这个时候,你通过页面b又跳转到新的页面b,页面c就无法再通过前进,后退按钮重复查看了,所以清空栈Y,此时两个栈的数据这个样子:
栈时一种操作受限的数据结构,只支持入栈和出栈操作,后进先出是最大的特点。栈既可以通过数组实现,也可以通过链表实现。不管基于数组还是链表,入栈、出栈的时间复杂度都是O(1).