当前位置:   article > 正文

6| 栈 : 浏览器的前进和后退功能是如何实现的?_浏览器 返回栈

浏览器 返回栈

目录

如何理解栈?

栈、数组、链表之间有什么区别呢?

如何实现一个栈?

栈有哪些应用?

栈在函数调用中的应用

栈在表达式求值中的应用

栈在括号匹配中的应用

总结

课后思考题


大家好,我是爱好编程的斌斌.

在使用浏览器的时候,假设你连续访问了a-b-c三个网页,点击后退按钮就可以浏览之前的网页a、b。当你后退到a之后,点击前进按钮,又可以回到b、c页面。但当你在b页面访问了新页面d,就不能向前访问c了。

假设你是一名浏览器的开发者,你要如何实现这个功能呢?

这就与这篇文章所描述的内容息息相关,所以带着这个问题看下去吧,相信你会找到答案!

一、如何理解栈?

对于栈而言,生活中有一个非常形象的例子。想象你此刻正在洗碗,然后把碗一个一个地叠放到一起。把碗从下到上叠到一起的过程,可以理解为入栈,放在中间的碗不能直接被抽出来,只能一个一个从上往下取出,这就类似于出栈的过程。这种先进后出,后进先出的数据结构就是栈。

栈、数组、链表之间有什么区别呢?

从功能的角度出发,栈是一种"受限"的数据结构,它只允许在一端插入和删除。

那么问题来了,既然栈没有数组和链表那么灵活,功能没有那么丰富,为什么栈还会存在,甚至有使用的场景呢?众所周知,特定的数据结构应用于特定的场景。那栈有哪些使用场景呢?有些时候功能多不一定好,就会更容易出错。

当某个数据集合只涉及在一端插入和删除数据,并且满足后进先出、先进后出的特性,我们就应该首选“栈”这种数据结构

二、如何实现一个栈?

首先,思考一下,我们需要为栈提供哪些接口(方法)?这就要回到栈的功能,从前面的分析可以看出,栈只涉及两种操作:入栈和出栈。翻译一下就是:往栈顶放入一个元素和从栈顶取出一个元素。既然已经知道了对元素的操作,那我们要用什么类型来存栈的元素呢?这里有两种解决方案,一种是数组,也就是顺序栈;另一种是链表,也就是链式栈。

废话少说,上代码,每个方法的实现都配有注解,相信你很聪明,能够理解:

  1. //顺序栈,基于数组实现
  2. public class ArrayStack {
  3.    private String[] items;//存储元素的数组
  4.    private int count;//栈中元素的个数
  5.    private int capacity;//栈的大小
  6.    /**
  7.     * 初始化,根据大小创建顺序栈
  8.     * @param capacity
  9.     */
  10.    public ArrayStack(int capacity) {
  11.        items = new String[capacity];
  12.        this.capacity = capacity;
  13.        this.count = 0;
  14.   }
  15.    /**
  16.     * 入栈,传递入栈的数据
  17.     * @param item
  18.     * @return
  19.     */
  20.    public boolean push(String item) {
  21.        //栈满
  22.        if (capacity == count) return false;
  23.        //栈未满,往数组最后添加,发现capacity正好指向末尾
  24.        items[capacity] = item;
  25.        count++;
  26.        return true;
  27.   }
  28.    /**
  29.     * 出栈,返回出栈的元素
  30.     * @return
  31.     */
  32.    public String pop() {
  33.        //如果栈为空,直接返回null
  34.        if (count == 0) return null;
  35.        //如果栈不为空,返回数组最后一个元素
  36.        String result = items[count - 1];
  37.        //出去一个元素,长度相对减去1
  38.        count--;
  39.        return result;
  40.   }
  41. }
  1. //链式栈,基于单链表的头插法实现
  2. public class LinkedStack2 {
  3.    private int count;//记录栈的大小
  4.    private Node head;//链表头结点
  5.    private static class Node {
  6.        Object object;
  7.        Node next;
  8.        public Node() {
  9.       }
  10.        public Node(Object object) {
  11.            this.object = object;
  12.       }
  13.        public Node(Object object, Node next) {
  14.            this.object = object;
  15.            this.next = next;
  16.       }
  17.   }
  18.    public LinkedStack2() {
  19.        this.count = 0;
  20.        head = new Node();
  21.   }
  22.    public void push(Object object) {
  23.        if (count == 0) {
  24.            head.object = object;
  25.            return;
  26.       }
  27.        Node newNode = new Node(object);
  28.        newNode.next = head;
  29.        head = newNode;
  30.   }
  31.    public Object pop() {
  32.        if (count == 0) return null;
  33.        Node oldNode = head;
  34.        head = head.next;
  35.        return oldNode;
  36.   }
  37. }

了解了栈定义和基本操作,那它的操作的时间、空间复杂度是多少呢?

不管是顺序栈还是链式栈,在入栈和出栈过程中,只需要一两个临时变量存储空间,所以空间复杂度是 O(1)。

1.为什么空间复杂度是O(1)而不是O(n)?

这里存储数据需要一个大小为 n 的数组,并不是说空间复杂度就是 O(n)。因为我们所说的空间复杂度,是指除了原本的数据存储空间外,算法运行还需要的额外的存储空间。

空间复杂度分析是不是很简单?时间复杂度也不难。不管是顺序栈还是链式栈,入栈、出栈只涉及栈顶个别数据的操作,所以时间复杂度都是 O(1)。

2.支持动态扩容的顺序栈

不支持动态扩容的顺序栈,但数组中元素存满时,就不能往里面添加新的元素了。虽然说链式栈的元素个数不受限制,但它要存储next属性,需要消耗大量的内存空间。那如何实现支持动态扩容的顺序栈呢?只需要将数组变成动态数组即可。

三、栈有哪些应用?

1.栈在函数调用中的应用

当一个函数被调用之后,系统会为该函数申请一块存储空间,这个存储空间基于栈这种数据结构,用来存储函数中的临时变量。为了更好的理解这个过程,请看下面这段代码:

  1. int main() {
  2.   int a = 1;
  3.   int ret = 0;
  4.   int res = 0;
  5.   ret = add(3, 5);
  6.   res = a + ret;
  7.   printf("%d", res);
  8.   reuturn 0;
  9. }
  10. int add(int x, int y) {
  11.   int sum = 0;
  12.   sum = x + y;
  13.   return sum;
  14. }

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

2.栈在表达式求值中的应用

举个例子,现在我们要求34+13*9+44-12/3,这个表达式的值。相信你很容易就可以口算出结果,那编译器是如何计算的呢?

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

我将 3+5*8-6 这个表达式的计算过程画成了一张图,你可以结合图来理解我刚讲的计算过程。

3.栈在括号匹配中的应用

假设我们现在要判断一串符号{[}()]是否合规,思考一下,怎么使用今天所学的知识来实现这个功能呢?

没错,这里又用到了栈。我们从头到尾遍历这串符号,当遇到左括号时,将其压入栈中;当是右括号时,取栈顶元素与其判断,如果与其不匹配(比如“(”跟“)”匹配,“[”跟“]”匹配,“{”跟“}”)或栈中没有元素,那么该串符号不合规。

四、解答开篇

到这里栈的内容就告一段落喽,既然已经掌握了栈的知识,不妨思考一下开篇的问题:浏览器的前进和后退功能是如何实现的呢?

它是基于两个栈实现的,就拿开头的例子,访问a-b-c网页来说。首先,浏览器会申请两个栈的X和Y,当你按顺序访问a-b-c时,将abc逐一压入栈X。当点击后退按钮访问页面b时,将栈X的栈顶元素c压入栈Y中;当你点击前进按钮回到页面c时,就将栈Y中的元素压回栈X。浏览器就是这样来实现的,是不是很奇妙?

五、总结

栈只能在一端操作数据,是一种受限的数据结构,只有入栈和出栈两种操作。栈既可以用数组实现,也可以基于单链表实现,两种实现的入栈、出栈的时间、空间复杂度都是O(1).

参考资料

数据结构与算法之美

六、课后思考题

  1. 为什么函数调用要用“栈”来保存临时变量呢?用其他数据结构不行吗?

其他数据结构也不一定不行,只不过函数调用正好符合栈后进先出的特点。函数调用喜欢嵌套,也就是函数a中调用函数b,函数b中调用函数c,嵌套的越深的函数越深执行,执行完毕出栈,并将结果返回给调用处。因此每次函数调用,只需要压栈,然后依次执行完毕出栈即可。

  1. 我们都知道,JVM 内存管理中有个“堆栈”的概念。栈内存用来存储局部变量和方法调用,堆内存用来存储 Java 中的对象。那 JVM 里面的“栈”跟我们这里说的“栈”是不是一回事呢?如果不是,那它为什么又叫作“栈”呢?

内存中的堆栈和数据结构堆栈不是一个概念,内存中的堆栈是真实存在的物理空间,而数据结构中的堆栈是抽象出来的数据存储结构。它为什么又叫作“栈”呢?这里可以用第一个问题的答案来回答,JVM中的栈也是在函数调用时使用,来存储方法的形参、局部变量、返回值。

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/繁依Fanyi0/article/detail/536826
推荐阅读
相关标签
  

闽ICP备14008679号