【数据结构之栈】实现浏览器的前进后退功能

165 阅读7分钟

今天我们来聊一聊堆栈,要注意,这里说的堆栈中的堆和数据结构中的堆没有任何关系,通常我们所说的栈其实就是堆栈,只是叫法不一样。为了统一,下面的内容中我们统一叫

上一篇中我们讲队列是一种先进先出(FIFO)的数据结构,栈和队列恰好相反,是一种后进先出(LIFO)的数据结构,往栈里添加一个数据叫入栈,从栈里取出一个数据叫出栈。最先进入到栈里的数据总是最后出栈。下图描述了一个栈的入栈和出栈的过程。

image.png

其实,我们在平时开发中,时时刻刻都在使用栈,我们的函数调用就是通过栈这种数据结构来管理的。很多编程语言内存区域划分都有栈空间,这个栈空间就是特指在函数内部声明使用的内存空间。例如,我们有两个函数f1和f2,在函数f1里调用函数f2,当f2在执行的时候f1会被压入栈中,当f2执行完成之后f1出栈继续执行调用f2之后的代码,如下图:

image.png

在正式实现栈之前,我想在这里介绍一下大名鼎鼎的递归算法。毫不夸张的说递归是计算机的灵魂之一,掌握了递归思想才算是真正理解了计算机原理,由于递归的函数调用和栈息息相关,这里我们先介绍一下递归算法,递归的核心思想就是将一个复杂的问题拆分成可重复执行的简单步骤。

这里我们先来看一道谷歌面试题:

两个人玩游戏,第一个人先从1和2中挑一个数字,第二个人在第一个人的基础上选择加1或者加2,然后轮到第一个人在第二个人的基础上选择加1还是加2,如此反复,最终能得到20的人赢得游戏。问,有什么策略一定能赢?

从常识来看,这个问题很难一眼找到答案,人的思维更习惯递推的方式,比如我们要算n的阶乘,我们习惯从1开始 n!=123...*n,我们习惯从小到大向后依次计算,这就是递推思维。而计算机的递归思想是从后往前推,比如计算n的阶乘, 计算机先计算n-1的阶乘然后乘以n得到结果n1, 然后使用n1乘以(n-2)的阶乘,如此往复,最终得到n的阶乘。

我们回到前面的面试题。使用递归的思想,我们从后往前推,要想结果是20,我们只需要抢到17就可以了,要想结果是17我们需要抢到14,依次我们需要抢到11、8、5、2。怎么样,通过递归的思想去推导这个结果是不是就比较容易了?

接着我们将面试题稍微做点调整,问,如果每次你选择加1或者2,加到20一共有多少种组合?是不是有点蒙?当然我们也可以从1开始去记录每一种组合,只要有耐心应该也可以计算出来。其实,我们同样可以使用上面的递归思想,从后往前推,我们知道,要想结果为20有两种可能,第一种18加上2,第二种19加上1。同样的,19可以是17加上2或者18加上1,我们假设到19有F(19)种组合,到18有F(18)种组合,那么可以得到F(20) = F(19) + F(18),这就是递推公式。

递归代码很难一下子想明白,往往越是尝试着去想明白越容易被绕进去。递归思想就是把复杂的问题,拆分成可重复的简单步骤,我们只要把最简单步骤的规律找出来就能写出递归代码了,描述这种规律最好的办法就是递推公式。上述的公式其实还存在问题,因为我们没有指明结束条件,那什么时候是结束条件呢?当函数参数==1的时候就是结束条件了,我们直接返回0就行了。我们看一下最终的递推公式:

F(n) = F(n-1) + F(n-2)
n == 1

递归每一次调用,实际上对应的就是我们上面说的f1调用f2的步骤,只是在递归里,函数调用的是自己而已。在找到终止条件之前的每一次调用,都被压入栈中,当触发终止条件的时候开始出栈,计算结果,不断重复执行这个过程,直到所有函数出栈,我个人认为这可能是栈最出彩的地方了。

很多人都觉得递归的原理很好理解,但是真正用在实际业务中的时候又觉得老是想不透,我想这也是递归比较难的原因吧!但有一点可以肯定,理解递归一定要弄明白函数调用的过程,页理解函数的调用过程又必须要搞明白栈这种数据结构。

说了这么多,我们下面来实现一个栈,前面我们说队列有顺序队列和链式队列,同样栈也有顺序栈链式栈,使用数组实现的栈叫顺序栈,使用链表实现的栈叫链式栈。

数组的实现

我们定义一个数组stackArray来存储数据,stackTop用来保存栈顶的位置,入栈时我们将数据保存在stackTop+1的位置,然后stackTop+1,出栈时返回stackTop位置的数据然后stackTop-1,我们为栈设计三个方法,分别是:push()入栈 pop()出栈 peek()查看栈顶元素。代码如下:

public class StackArray {    
    private int[] stackArr;    
    private int stackTop;    
    private int size;
    
    public StackArray(int capcity) {        
        if (capcity <= 0)             
            throw new IllegalArgumentException("Capcity is invalid.");        
        this.size = 0;        
        this.stackTop = -1;        
        this.stackArr = new int[capcity];    
    }
    
    public boolean push(int data) {        
        if (stackArr.length == stackTop)             
            throw new RuntimeException("Out range of index.");        
        stackArr[stackTop+1] = data;        
        size++;        
        return true;    
    }
    
    public Integer pop() {        
        if (stackTop == -1)             
            throw new RuntimeException("Stack is empty.");        
        size--;        
        return stackArr[stackTop--];    
    }
    
    public Integer peek() {        
        return stackArr[stackTop];    
    }        
    
    public Integer size() {        
        return size;    
    }
}

和顺序队列一样上面的代码同样有一个小问题,当数据量达到数组最大容量之后再入栈时由于没有空间了入栈会失败,我们可以改造一下入栈的方法,当数组满了,我们再创建一个更大的数组来保存栈数据。当然,这会触发数据的搬移。所以,我们可以知道基于数组实现的可自动扩容的栈的时间复杂度为O(n)。以下是我们改造之后的入栈的代码:

public boolean push(int data) {    
    if (stackArr.length == stackTop) {        
        int[] tmpArray = new int[stackTop*2];       
        for (int i = 0; i <= stackTop; i++) {           
            tmpArray[i] = stackArr[i];        
        }        
        stackArr = tmpArray;    
    }    
    stackArr[stackTop+1] = data;    
    size++;    
    return true;
}

要注意,当数组的长度达到一定长度的时候我们也需要通过增加一定的比例,页不是简单的创建一个两倍大的数组,具体原因我们在队列那一篇有比较详细的说明,你可以回顾一下。

链表的实现

我们实现一个LinkListStack类,我们也定义三个方法,分别是:push()入栈 pop()出栈 peek()查看栈顶元素。下面是关键代码:

public class LinkListStack<E> {    
    private LinkList<E> list;    
    
    public LinkListStack() {        
        list = new LinkList<>();    
    }
    
    public int getSize() {        
        return list.getSize();    
    }
    
    public void push(E e) {        
        list.addFirst(e);    
    }
    
    public E pop() {        
        return list.delFirst();   
    }
    
    public E peek() {       
        return list.getFirst();    
    }
}

这里用到了之前讲的链表,详细代码可以去我的github上查看:

github.com/seepre/data…

我们看到使用链表实现的栈,没有像数组那样因为数据容量满了触发搬移的问题。由于出栈和入栈都是操作头节点,使用链表实现的栈入栈和出栈的时间复杂度都是O(1)。

回到我们一开始的问题,如何使用堆栈实现浏览器的前进后退功能?

image.png

想象这么一种场景,工作中你遇到了一个棘手的问题不知道怎么解决,然后去百度一下找点别人的经验,你找到了一个页面A,然后在A页面又看到一个相关的页面B,你又进入了B页面,然后你发现B页面并没有找你想找的信息,你又点了返回按钮回到了A页面。

我们把百度首页叫做A',然后用栈来模拟一下整个过程,你在浏览器输入www.baidu.com打开百度首页,你找到A页面并进入的时候,将A'页面的地址压入栈中,你打开B页面的时候,将A页面的地址压入栈中,假如A页面的地址为www.A.com,B页面的地址为www.B.com。这时候栈里的数据如下:

image.png

然后你又从B页面回到A页面,这时,页面A出栈,拿到A页面的地址然后回到A页面,这样就实现了回退功能。

image.png

假如这时你又想回到B页面,这时候怎么办呢?当然,你可以在A页面再次找到B页面的地址,然后点进去。但是,浏览器一般都有前进的功能,可以实现一健回到前一个页面。具体怎么做呢?

我们只需要再创建一个栈,假设我们用来记录后退的栈叫backStack,用来记录前进的的栈叫forwardStack,当我们从B页面回到A页面的时候,我们将B页面压入forwardStack栈。当我们再次点击前进按钮的时候B页面从forwardStack出栈,然后将A页面压入backStack栈。这样我们就实现了浏览器的前进后退功能, 大致流程如下:

image.png

总结

我们来总结一下,和队列的先进先出正好相反,栈是一种后进先出的数据结构。我们可以使用数组和链表来实现栈,分别叫顺序栈和链式栈。函数的调用使用的就是栈来进行管理的,我们讲了递归算法,我们说递归在执行的过程实际就是在践行栈这种数据结构。最后我们使用两个栈来实现了一个类似浏览器前进后退的功能。