当你依次访问完一串页面 a-b-c 以后,点击浏览器的后退按钮,就能够查看以前浏览过的页面 b 和 a。当你后退到页面 a,点击前进按钮,就能够从新查看页面 b 和 c。可是,若是你后退到页面 b 后,点击了新的页面 d,那就没法再经过前进、后退功能查看页面 c 了。python
假设你是 Chrome 浏览器的开发工程师,你会如何实现这个功能呢?算法
这就要用到咱们今天要讲的“栈”这种数据结构。带着这个问题,咱们来学习今天的内容。数组
如何理解“栈”?浏览器
关于“栈”,我有一个很是贴切的例子,就是一摞叠在一块儿的盘子。咱们平时放盘子的时候,都是从下往上一个一个放;取的时候,咱们也是从上往下一个一个地依次取,不能从中间任意抽出。后进者先出,先进者后出,这就是典型的“栈”结构。数据结构
从栈的操做特性上来看,栈是一种“操做受限”的线性表,只容许在一端插入和删除数据。函数
我第一次接触这种数据结构的时候,就对它存在的意义产生了很大的疑惑。由于我以为,相比数组和链表,栈带给个人只有限制,并无任何优点。那我直接使用数组或者链表不就行了吗?为何还要用这个“操做受限”的“栈”呢?学习
事实上,从功能上来讲,数组或链表确实能够替代栈,但你要知道,特定的数据结构是对特定场景的抽象,并且,数组或链表暴露了太多的操做接口,操做上的确灵活自由,但使用时就比较不可控,天然也就更容易出错。this
当某个数据集合只涉及在一端插入和删除数据,而且知足后进先出、先进后出的特性,咱们就应该首选“栈”这种数据结构。操作系统
从刚才栈的定义里,咱们能够看出,栈主要包含两个操做,入栈和出栈,也就是在栈顶插入一个数据和从栈顶删除一个数据。理解了栈的定义以后,咱们来看一看如何用代码实现一个栈。线程
实际上,栈既能够用数组来实现,也能够用链表来实现。用数组实现的栈,咱们叫做顺序栈,用链表实现的栈,咱们叫做链式栈。
我这里实现一个基于数组的顺序栈。基于链表实现的链式栈的代码,你能够本身试着写一下。我会将我写好的代码放到 Github 上,你能够去看一下本身写的是否正确。
我这段代码是用 Java 来实现的,可是不涉及任何高级语法,而且我还用中文作了详细的注释,因此你应该是能够看懂的。
// 基于数组实现的顺序栈 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 加一 items[count] = item; ++count; return true; } // 出栈操做 public String pop() { // 栈为空,则直接返回 null if (count == 0) return null; // 返回下标为 count-1 的数组元素,而且栈中元素个数 count 减一 String tmp = items[count-1]; --count; return tmp; } }
了解了定义和基本操做,那它的操做的时间、空间复杂度是多少呢?
不论是顺序栈仍是链式栈,咱们存储数据只须要一个大小为 n 的数组就够了。在入栈和出栈过程当中,只须要一两个临时变量存储空间,因此空间复杂度是 O(1)。
注意,这里存储数据须要一个大小为 n 的数组,并非说空间复杂度就是 O(n)。由于,这 n 个空间是必须的,没法省掉。因此咱们说空间复杂度的时候,是指除了本来的数据存储空间外,算法运行还须要额外的存储空间。
空间复杂度分析是否是很简单?时间复杂度也不难。不论是顺序栈仍是链式栈,入栈、出栈只涉及栈顶个别数据的操做,因此时间复杂度都是 O(1)。
支持动态扩容的顺序栈
刚才那个基于数组实现的栈,是一个固定大小的栈,也就是说,在初始化栈时须要事先指定栈的大小。当栈满以后,就没法再往栈里添加数据了。尽管链式栈的大小不受限,但要存储 next 指针,内存消耗相对较多。那咱们如何基于数组实现一个能够支持动态扩容的栈呢?
你还记得,咱们在数组那一节,是如何来实现一个支持动态扩容的数组的吗?当数组空间不够时,咱们就从新申请一块更大的内存,将原来数组中数据通通拷贝过去。这样就实现了一个支持动态扩容的数组。
因此,若是要实现一个支持动态扩容的栈,咱们只须要底层依赖一个支持动态扩容的数组就能够了。当栈满了以后,咱们就申请一个更大的数组,将原来的数据搬移到新数组中。我画了一张图,你能够对照着理解一下。
实际上,支持动态扩容的顺序栈,咱们平时开发中并不经常使用到。我讲这一块的目的,主要仍是但愿带你练习一下前面讲的复杂度分析方法。因此这一小节的重点是复杂度分析。
你不用死记硬背入栈、出栈的时间复杂度,你须要掌握的是分析方法。可以本身分析才算是真正掌握了。如今我就带你分析一下支持动态扩容的顺序栈的入栈、出栈操做的时间复杂度。
对于出栈操做来讲,咱们不会涉及内存的从新申请和数据的搬移,因此出栈的时间复杂度仍然是 O(1)。可是,对于入栈操做来讲,状况就不同了。当栈中有空闲空间时,入栈操做的时间复杂度为 O(1)。但当空间不够时,就须要从新申请内存和数据搬移,因此时间复杂度就变成了 O(n)。
也就是说,对于入栈操做来讲,最好状况时间复杂度是 O(1),最坏状况时间复杂度是 O(n)。那平均状况下的时间复杂度又是多少呢?还记得咱们在复杂度分析那一节中讲的摊还分析法吗?这个入栈操做的平均状况下的时间复杂度能够用摊还分析法来分析。咱们也正好借此来实战一下摊还分析法。
为了分析的方便,咱们须要事先作一些假设和定义:
栈空间不够时,咱们从新申请一个是原来大小两倍的数组;
为了简化分析,假设只有入栈操做没有出栈操做;
定义不涉及内存搬移的入栈操做为 simple-push 操做,时间复杂度为 O(1)。
若是当前栈大小为 K,而且已满,当再有新的数据要入栈时,就须要从新申请 2 倍大小的内存,而且作 K 个数据的搬移操做,而后再入栈。可是,接下来的 K-1 次入栈操做,咱们都不须要再从新申请内存和搬移数据,因此这 K-1 次入栈操做都只须要一个 simple-push 操做就能够完成。为了让你更加直观地理解这个过程,我画了一张图。
你应该能够看出来,这 K 次入栈操做,总共涉及了 K 个数据的搬移,以及 K 次 simple-push 操做。将 K 个数据搬移均摊到 K 次入栈操做,那每一个入栈操做只须要一个数据搬移和一个 simple-push 操做。以此类推,入栈操做的均摊时间复杂度就为 O(1)。
经过这个例子的实战分析,也印证了前面讲到的,均摊时间复杂度通常都等于最好状况时间复杂度。由于在大部分状况下,入栈操做的时间复杂度 O 都是 O(1),只有在个别时刻才会退化为 O(n),因此把耗时多的入栈操做的时间均摊到其余入栈操做上,平均状况下的耗时就接近 O(1)。
前面我讲的都比较偏理论,咱们如今来看下,栈在软件工程中的实际应用。栈做为一个比较基础的数据结构,应用场景仍是蛮多的。其中,比较经典的一个应用场景就是函数调用栈。
咱们知道,操做系统给每一个线程分配了一块独立的内存空间,这块内存被组织成“栈”这种结构%2C+用来存储函数调用时的临时变量。每进入一个函数,就会将临时变量做为一个栈帧入栈,当被调用函数执行完成,返回以后,将这个函数对应的栈帧出栈。为了让你更好地理解,咱们一块来看下这段代码的执行过程。
int main() { int a = 1; int ret = 0; int res = 0; ret = add(3, 5); res = a + ret; printf("%d", res); reuturn 0; } int add(int x, int y) { int sum = 0; sum = x + y; return sum; }
从代码中咱们能够看出,main() 函数调用了 add() 函数,获取计算结果,而且与临时变量 a 相加,最后打印 res 的值。为了让你清晰地看到这个过程对应的函数栈里出栈、入栈的操做,我画了一张图。图中显示的是,在执行到 add() 函数时,函数调用栈的状况。
咱们再来看栈的另外一个常见的应用场景,编译器如何利用栈来实现表达式求值。
为了方便解释,我将算术表达式简化为只包含加减乘除四则运算,好比:34+13*9+44-12/3。对于这个四则运算,咱们人脑能够很快求解出答案,可是对于计算机来讲,理解这个表达式自己就是个挺难的事儿。若是换做你,让你来实现这样一个表达式求值的功能,你会怎么作呢?
实际上,编译器就是经过两个栈来实现的。其中一个保存操做数的栈,另外一个是保存运算符的栈。咱们从左向右遍历表达式,当遇到数字,咱们就直接压入操做数栈;当遇到运算符,就与运算符栈的栈顶元素进行比较。
若是比运算符栈顶元素的优先级高,就将当前运算符压入栈;若是比运算符栈顶元素的优先级低或者相同,从运算符栈中取栈顶运算符,从操做数栈的栈顶取 2 个操做数,而后进行计算,再把计算完的结果压入操做数栈,继续比较。
我将 3+5*8-6 这个表达式的计算过程画成了一张图,你能够结合图来理解我刚讲的计算过程。
这样用两个栈来解决的思路是否是很是巧妙?你有没有想到呢?
除了用栈来实现表达式求值,咱们还能够借助栈来检查表达式中的括号是否匹配。
咱们一样简化一下背景。咱们假设表达式中只包含三种括号,圆括号 ()、方括号 [] 和花括号 {},而且它们能够任意嵌套。好比,{ [ {} ] }或[ { [] } { [] } ] 等都为合法格式,而 { [ } ( ) ]或 [ ({ ) ]为不合法的格式。那我如今给你一个包含三种括号的表达式字符串,如何检查它是否合法呢?
这里也能够用栈来解决。咱们用栈来保存未匹配的左括号,从左到右依次扫描字符串。当扫描到左括号时,则将其压入栈中;当扫描到右括号时,从栈顶取出一个左括号。若是可以匹配,好比“(”跟“)”匹配,“[”跟“]”匹配,“(”跟“)”匹配,则继续扫描剩下的字符串。若是扫描的过程当中,遇到不能配对的右括号,或者栈中没有数据,则说明为非法格式。
当全部的括号都扫描完成以后,若是栈为空,则说明字符串为合法格式;不然,说明有未匹配的左括号,为非法格式。
好了,我想如今你已经彻底理解了栈的概念。咱们再回来看看开篇的思考题,如何实现浏览器的前进、后退功能?其实,用两个栈就能够很是完美地解决这个问题。
咱们使用两个栈,X 和 Y,咱们把首次浏览的页面依次压入栈 X,当点击后退按钮时,再依次从栈+X+中出栈,并将出栈的数据依次放入栈 Y。当咱们点击前进按钮时,咱们依次从栈 Y 中取出数据,放入栈 X 中。当栈 X 中没有数据时,那就说明没有页面能够继续后退浏览了。当栈 Y 中没有数据,那就说明没有页面能够点击前进按钮浏览了。
好比你顺序查看了 a,b,c 三个页面,咱们就依次把 a,b,c 压入栈,这个时候,两个栈的数据就是这个样子:
当你经过浏览器的后退按钮,从页面 c 后退到页面 a 以后,咱们就依次把 c 和 b 从栈 X 中弹出,而且依次放入到栈 Y。这个时候,两个栈的数据就是这个样子:
这个时候,你经过页面 b 又跳转到新的页面 d 了,页面 c 就没法再经过前进、后退按钮重复查看了,因此须要清空栈 Y。此时两个栈的数据这个样子:
咱们来回顾一下今天讲的内容。栈是一种操做受限的数据结构,只支持入栈和出栈操做。后进先出是它最大的特色。栈既能够经过数组实现,也能够经过链表来实现。无论基于数组仍是链表,入栈、出栈的时间复杂度都为 O(1)。除此以外,咱们还讲了一种支持动态扩容的顺序栈,你须要重点掌握它的均摊时间复杂度分析方法。
咱们在讲栈的应用时,讲到用函数调用栈来保存临时变量,为何函数调用要用“栈”来保存临时变量呢?用其余数据结构不行吗?
咱们都知道,JVM 内存管理中有个“堆栈”的概念。栈内存用来存储局部变量和方法调用,堆内存用来存储 Java 中的对象。那 JVM 里面的“栈”跟咱们这里说的“栈”是否是一回事呢?若是不是,那它为何又叫做“栈”呢?