跳到主要内容

栈 (Stack)

问题向导

  • 问题1:如何实现浏览器的前进和后退功能?
    • 问题描述:浏览器的前进、后退功能,我想你肯定很熟悉吧?当你依次访问完一串页面 a-b-c 之后,点击浏览器的后退按钮,就可以查看之前浏览过的页面 b 和 a。当你后退到页面 a,点击前进按钮,就可以重新查看页面 b 和 c。但是,如果你后退到页面 b 后,点击了新的页面 d,那就无法再通过前进、后退功能查看页面 c 了。
  • 思路:
    • 使用两个栈来实现
  • 实现:
    • 使用两个栈,X和Y,把首次浏览的页面依次压入栈X, 当点击后退按钮时,再依次从栈X中出栈,并将出栈的数据依次放入栈Y。当点击前进按钮时,依次从栈Y中取出数据,放入栈X中。当栈X中没有数据时,那就说明没有页面可以继续后退浏览了。当栈Y中没有数据,那就说明没有页面可以点击前进按钮浏览了。

栈的概述:

  • 定义:
    • 后进者先出,先进者后出的数据结构称为“栈”
  • 理解:
    • 一摞叠在一起的盘子,放盘子的时候都是从下往上一个一个放,取的时候都是从上往下一个一个的取,不能从中间任意取
  • 操作特性:
    • 栈是一种“操作受限”的线性表,只允许在一端插入和删除数据
  • 类别:
    • 从功能上,数据或链表都能替代栈,但特定的数据结构是对特定场景的抽象,另一方面,数组或链表暴露了太多的操作接口,操作上的确灵活自由,但使用时比较不可控,就会容易出错
  • 使用场景:
    • 当某个数据集合只涉及到在一端插入和删除数据,并且满足后进先出,先进后出的特性,就可以使用栈
  • 栈的复杂度:
    • 空间复杂度:O(1)
    • 时间复杂度:O(1)
  • 实现:
    • 数组实现->顺序栈
    • 链表实现->链式栈

动态扩容的顺序栈实现:

  • 实现:
    • 只需要底层依赖一个支持动态扩容的数组就可以 了。当栈满了之后,我们就申请一个更大的数组,将原来的数据搬移到新数组中。
  • 复杂度
    • 出栈:O(1)
      • 不会涉及到内存的重新分配和数据的搬迁
    • 入栈:最坏:O(n), 最好:O(1)
    • 分析方法:摊还分析法,

摊还分析法细节: 为了分析的方便,我们需要事先做一些假设和定义:

  • 栈空间不够时,我们重新申请一个是原来大小两倍的数组;
  • 为了简化分析,假设只有入栈操作没有出栈操作;
  • 定义不涉及内存搬移的入栈操作为 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)。

栈的操作

  • 入栈push()

  • 出栈pop()

栈的应用

一、在函数中的应用

函数调用栈

  • 操作系统给每个线程分配了一个独立的内存空间,这块内存被组织成为“栈”这种结构,用来存储函数调用时的临时变量,每进入一个函数,就会将临时变量作为一个栈帧入栈,当被调用函数执行完成,返回之后,将这个函数对应的栈帧出栈。

  • 从代码中我们可以看出,main() 函数调用了 add() 函数,获取计算结果,并且与临时变量 a 相 加,最后打印 res 的值。为了让你清晰地看到这个过程对应的函数栈里出栈、入栈的操作。

二、在表达式求值中的应用

  • eg: 34+13*9+44-12/3
  • 实现思路:
    • 编译器就是通过两个栈来实现的。其中一个保存操作数的栈,另一个是保存运算符的栈。从左向右遍历表达式,当遇到数字,就直接压入操作数栈;当遇到运算符,就与运算符栈的栈顶元素进行比较。
    • 如果比运算符栈顶元素的优先级高,就将当前运算符压入栈;如果比运算符栈顶元素的优先级低或者相同,从运算符栈中取栈顶运算符,从操作数栈的栈顶取 2 个操作数,然后进行计算,再把计算完的结果压入操作数栈,继续比较。

三、在括号匹配中的应用

我们同样简化一下背景。我们假设表达式中只包含三种括号,圆括号 ()、方括号[]和花括号,并且它们可以任意嵌套。比如,{[] ()[{}]}或[{()}([])]等都为合法格式,而{[}()][({)]为不合法的格式。那我现在给你一个包含三种括号的表达式字符串,如何检查它是否合法呢?

  • 使用栈来检查表达式中的括号是否匹配:{[] ()[{}]}或[{()}([])]
  • 实现思路
    • 用栈来保存未匹配的左括号,从左到右依次扫描字符串。当扫描到左括号时,则将其压入栈中;当扫描到右括号时,从栈顶取出一个左括号。如果能够匹配,比如“(”跟“)”匹配,“[”跟“]”匹配,“{”跟“}”匹配,则继续扫描剩下的字符串。如果扫描的过程中,遇到不能配对的右括号,或者栈中没有数据,则说明为非法格式。当所有的括号都扫描完成之后,
    • 如果栈为空,则说明字符串为合法格式;否则,说明有未匹配的左括号,为非法格式。