“栈”,类似于一摞叠在一起的盘子。放盘子的时候,从下往上一个一个放;取的时候,也是从上往下一个一个地依次取,不能从中间任意抽出。后进者先出,先进者后出,这就是“栈”的结构。
为什么选 栈
?
从栈的操作特性上来看,栈是一种“操作受限”的线性表,只允许在一端插入和删除数据。
从功能上来说,数组或链表可以替代栈,但特定的数据结构是对特定场景的抽象,而且,数组或链表暴露了太多的操作接口,操作上的确灵活自由,但使用时就比较不可控,自然也就更容易出错。当某个数据集合只涉及在一端插入和删除数据,并且满足后进先出、先进后出的特性,应该首选 栈
这种数据结构。
如何实现 栈
?
栈主要包含两个操作,入栈和出栈,也就是在栈顶插入一个数据和从栈顶删除一个数据。栈既可以用数组来实现,也可以用链表来实现。用数组实现的栈,叫作顺序栈,用链表实现的栈,叫作链式栈。
不管是顺序栈还是链式栈,存储数据只需要一个大小为 $n$ 的数组。在入栈和出栈过程中,只需要一两个临时变量存储空间,所以时间、空间复杂度都是 $O(1)$。
栈在函数调用中的应用
栈作为一个基础的数据结构,其比较经典的一个应用场景就是函数调用栈。
操作系统给每个线程分配了一块独立的内存空间,这块内存被组织成“栈”这种结构, 用来存储函数调用时的临时变量。每进入一个函数,就会将临时变量作为一个栈帧入栈,当被调用函数执行完成,返回之后,将这个函数对应的栈帧出栈。
1 | def main(): |
从上面代码中可以看出,main()
函数调用了 add()
函数,获取计算结果,并且与临时变量 a
相加,最后打印 res
的值。下图中显示的是,在执行到 add()
函数时,函数调用栈的情况。
栈在表达式求值中的应用
栈的另一个常见的应用场景,编译器如何利用栈来实现表达式求值。
编译器通过两个栈来实现的。其中一个保存操作数的栈,另一个是保存运算符的栈。从左向右遍历表达式,当遇到数字,就直接压入操作数栈;当遇到运算符,就与运算符栈的栈顶元素进行比较。
如果比运算符栈顶元素的优先级高,就将当前运算符压入栈;如果比运算符栈顶元素的优先级低或者相同,从运算符栈中取栈顶运算符,从操作数栈的栈顶取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$,点击前进按钮回到 $b$ 页面,就把 $b$ 再从栈 $Y$ 中出栈,放入栈 $X$ 中。此时两个栈的数据是这个样子:
这个时候,通过页面 $b$ 又跳转到新的页面 $d$ 了,页面 $c$ 就无法再通过前进、后退按钮重复查看了,所以需要清空栈 $Y$。此时两个栈的数据这个样子: