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

目录

如何理解栈?

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

如何实现一个栈?

栈有哪些应用?

栈在函数调用中的应用

栈在表达式求值中的应用

栈在括号匹配中的应用

总结

课后思考题


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

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

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

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

一、如何理解栈?

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

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

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

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

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

二、如何实现一个栈?

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

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

//顺序栈,基于数组实现
public class ArrayStack {
​
    private String[] items;//存储元素的数组
    private int count;//栈中元素的个数
    private int capacity;//栈的大小
​
    /**
     * 初始化,根据大小创建顺序栈
     * @param capacity
     */
    public ArrayStack(int capacity) {
        items = new String[capacity];
        this.capacity = capacity;
        this.count = 0;
    }
​
    /**
     * 入栈,传递入栈的数据
     * @param item
     * @return
     */
    public boolean push(String item) {
        //栈满
        if (capacity == count) return false;
        //栈未满,往数组最后添加,发现capacity正好指向末尾
        items[capacity] = item;
        count++;
        return true;
    }
​
    /**
     * 出栈,返回出栈的元素
     * @return
     */
    public String pop() {
        //如果栈为空,直接返回null
        if (count == 0) return null;
        //如果栈不为空,返回数组最后一个元素
        String result = items[count - 1];
        //出去一个元素,长度相对减去1
        count--;
        return result;
    }
}
//链式栈,基于单链表的头插法实现
public class LinkedStack2 {
    private int count;//记录栈的大小
    private Node head;//链表头结点
​
    private static class Node {
        Object object;
        Node next;
​
        public Node() {
        }
​
        public Node(Object object) {
            this.object = object;
        }
​
        public Node(Object object, Node next) {
            this.object = object;
            this.next = next;
        }
    }
​
    public LinkedStack2() {
        this.count = 0;
        head = new Node();
    }
​
    public void push(Object object) {
        if (count == 0) {
            head.object = object;
            return;
        }
        Node newNode = new Node(object);
        newNode.next = head;
        head = newNode;
    }
​
    public Object pop() {
        if (count == 0) return null;
        Node oldNode = head;
        head = head.next;
        return oldNode;
    }
​
}

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

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

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

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

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

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

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

三、栈有哪些应用?

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

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

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() 函数时,函数调用栈的情况。

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中的栈也是在函数调用时使用,来存储方法的形参、局部变量、返回值。

  • 25
    点赞
  • 23
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值