前面,我们实现了两种常见的线性表 —— 顺序表 和 链表 ,本篇我们来介绍另外一种常用的线性表 —— 栈。
栈
定义
线性表中的一种特殊数据结构,数据只能从固定的一端插入数据或删除数据,另一端是封死的。
特点
FILO(First In Last Out): 先进后出;
栈满还存会“上溢”,栈空再取会“下溢”;
“上溢”:在栈已经存满数据元素的情况下,如果继续向栈内存入数据,栈存储就会出错。
“下溢”:在栈内为空的状态下,如果对栈继续进行取数据的操作,就会出错。
分类
顺序栈
特点
- 采用数组实现,数据在物理结构上保持连续性。
代码实现
1 | package one.wangwei.algorithms.datastructures.stack.impl; |
复杂度
空间复杂度
出栈和入栈的操作,只涉及一两个临时变量的存储空间,所以复杂度为O(1).
时间复杂度
顺序栈在出栈和入栈的操作时,最好情况时间复杂度为O(1),当需要扩容或者缩减时,需要迁移数据,此时最坏情况时间复杂度为O(n). 根据摊还分析法则,它们的均摊时间复杂度还是为O(1).
链表栈
特点
- 用线性表的链式结构存储,数据在物理结构上非连续
代码实现
1 | package one.wangwei.algorithms.datastructures.stack.impl; |
复杂度
空间复杂度
出栈和入栈的操作,只涉及一两个临时变量的存储空间,所以复杂度为O(1).
时间复杂度
出栈和入栈的操作,不涉及数据搬迁,只是顶部元素操作,时间复杂度均为O(1).
栈的应用
接下来,我们看看栈在软件工程中的实际应用。
函数调用
操作系统给每个线程分配了一块独立的内存空间,这块内存被组织成“栈”这种结构, 用来存储函数调用时的临时变量。每进入一个函数,就会将临时变量作为一个栈帧入栈,当被调用函数执行完成,返回之后,将这个函数对应的栈帧出栈。
示例:
1 | int main() { |
main() 函数调用了 add() 函数,获取计算结果,并且与临时变量 a 相加,最后打印 res 的值。这个过程的中函数栈里的出栈、入栈操作,如下所示:
思考:为什么函数要用栈来保存临时变量呢?用其他数据结构不行吗?
函数调用的局部状态之所以用栈来记录是因为这些状态数据的存活时间满足“后入先出”(LIFO)顺序,而栈的基本操作正好就是支持这种顺序的访问。
栈是程序设计中的一种经典数据结构,每个程序都拥有自己的程序栈。栈帧也叫过程活动记录,是编译器用来实现函数调用过程的一种数据结构。C语言中,每个栈帧对应着一个未运行完的函数。从逻辑上讲,栈帧就是一个函数执行的环境:函数调用框架、函数参数、函数的局部变量、函数执行完后返回到哪里等等。栈是从高地址向低地址延伸的。每个函数的每次调用,都有它自己独立的一个栈帧,这个栈帧中维持着所需要的各种信息。
寄存器ebp(base pointer)指向当前的栈帧的底部(高地址),可称为“帧指针”或“基址指针”;寄存器esp(stack pointer)指向当前的栈帧的顶部(低地址),可称为“ 栈指针”。
在C和C++语言中,临时变量分配在栈中,临时变量拥有函数级的生命周期,即“在当前函数中有效,在函数外无效”。这种现象就是函数调用过程中的参数压栈,堆栈平衡所带来的。堆栈平衡是指函数调完成后,要返还所有使用过的栈空间。
函数调用其实可以看做4个过程:
- 压栈: 函数参数压栈,返回地址压栈
- 跳转: 跳转到函数所在代码处执行
- 执行: 执行函数代码
- 返回: 平衡堆栈,找出之前的返回地址,跳转回之前的调用点之后,完成函数调用
表达式求值
以 3 + 5 x 8 - 6
为这个表达式为例,编译器是如何利用栈来实现表达式求值的呢?
编译器会使用两个栈来实现,一个栈用来保存操作数,另一个栈用来保存运算符。从左向右遍历表达式,遇到数字直接压入操作数栈,遇到操作符,就与运算符栈顶元素进行比较。
如果比运算符栈顶元素的优先级高,就将当前运算符压入栈;如果比运算符栈顶元素的优先级低或者相同,从运算符栈中取栈顶运算符,从操作数栈的栈顶取 2 个操作数,然后进行计算,再把计算完的结果压入操作数栈,继续比较。
如图所示:
此前,我们在讲 比特币脚本语言 时,提到过 逆波兰表示法 ,也是运用了栈这种数据结构特征。
练习
Leetcode 224. Basic Calculator
题目具体的分析,我放在了单独的文章中,👉 戳此查看 。
括号匹配
栈还可以用来检测表达式中的括号是否匹配。
我们假设表达式中只包含三种括号,圆括号 ()、方括号 [] 和花括号{},并且它们可以任意嵌套。比如,{[{}]}或 [{()}([])] 等都为合法格式,而{[}()] 或 [({)] 为不合法的格式。那我现在给你一个包含三种括号的表达式字符串,如何检查它是否合法呢?
我们用栈来保存未匹配的左括号,从左到右依次扫描字符串。当扫描到左括号时,则将其压入栈中;当扫描到右括号时,从栈顶取出一个左括号。如果能够匹配,比如“(”跟“)”匹配,“[”跟“]”匹配,“{”跟“}”匹配,则继续扫描剩下的字符串。如果扫描的过程中,遇到不能配对的右括号,或者栈中没有数据,则说明为非法格式。
当所有的括号都扫描完成之后,如果栈为空,则说明字符串为合法格式;否则,说明有未匹配的左括号,为非法格式。
练习
Leetcode 20. Valid Parentheses
我自己在做这道题时,虽说做对了,但是没有像下面的代码那样采用Map存储括号的对应关系,导致代码非常臃肿难堪。
1 | class Solution { |
浏览器的前进、后退
使用两个栈,X 和 Y,把首次浏览的页面依次压入栈 X,当点击后退按钮时,再依次从栈 X 中出栈,并将出栈的数据依次放入栈 Y。当我们点击前进按钮时,我们依次从栈 Y 中取出数据,放入栈 X 中。当栈 X 中没有数据时,那就说明没有页面可以继续后退浏览了。当栈 Y 中没有数据,那就说明没有页面可以点击前进按钮浏览了。
比如你顺序查看了 a,b,c 三个页面,我们就依次把 a,b,c 压入栈,这个时候,两个栈的数据就是这个样子:
当你通过浏览器的后退按钮,从页面 c 后退到页面 a 之后,我们就依次把 c 和 b 从栈 X 中弹出,并且依次放入到栈 Y。这个时候,两个栈的数据就是这个样子:
这个时候你又想看页面 b,于是你又点击前进按钮回到 b 页面,我们就把 b 再从栈 Y 中出栈,放入栈 X 中。此时两个栈的数据是这个样子:
这个时候,你通过页面 b 又跳转到新的页面 d 了,页面 c 就无法再通过前进、后退按钮重复查看了,所以需要清空栈 Y。此时两个栈的数据这个样子:
当然,我们还可以使用双向链表来实现这个功能。
区别
我们都知道,JVM 内存管理中有个“堆栈”的概念。栈内存用来存储局部变量和方法调用,堆内存用来存储 Java 中的对象。那 JVM 里面的“栈”跟本篇说的“栈”是不是一回事呢?如果不是,那它为什么又叫作“栈”呢?
本篇介绍的栈是一种抽象的数据结构,而JVM中的”堆栈”是一种实际存在的物理结构,有关JVM堆栈的了解,可以看我之前的文章:https://wangwei.one/posts/java7-jvm-memory-model.html