06 | 栈:如何实现浏览器的前进和后退功能?

当你依次访问完一串页面 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 里面的“栈”跟咱们这里说的“栈”是否是一回事呢?若是不是,那它为何又叫做“栈”呢?

相关文章
相关标签/搜索