数据结构与算法(四):栈与应用

一、定义

栈是一种操作受限的线性表。之所以说它操作受限,是由于其只能在一端插入和删除数据。这一端叫做“栈顶”,另外一端则为“栈底”。插入的操作我们通常称之为“PUSH”(入栈),删除的操作我们通常称之为“POP”(出栈)。所以它具有FILO(先进后出)的特性。如果栈满了,我们是不允许再继续入栈的,称这种情况为“栈上溢”,对应如果栈内为空,也是不允许继续出栈的,这种情况为“栈下溢”。这就像餐馆里的餐盘,工作人员放餐盘的时候都是从下往上一个一个堆积;而取餐盘的时候,则是从上往下依次取。

站在数据结构的角度,栈其实是一种抽象的数据结构,它不像数组这种基础数据结构,我们需要自己去实现它。数组和链表都可以实现栈的结构,用数组实现的栈,我们称之为“线性栈”;而用链表实现的栈,我们则称之为“顺序栈”。

二、实现

这里我们简单实现一下顺序栈。

public class MyStack<T> {
    private Object[] arrays;
    private int position = -1;

    public MyStack(int size) {
        arrays = new Object[size];
    }

    public T pop() {
        return (T) arrays[position--];
    }

    public void push(T item) {
        if (position + 1 >= arrays.length) {
            //扩容,长度翻倍
            Object[] newArrays = new Object[arrays.length << 1];
            System.arraycopy(arrays, 0, newArrays, 0, arrays.length);
            arrays = newArrays;
        }
        arrays[++position] = item;
    }

}

以上就是一个最简单的顺序栈实现,对外提供了pop(出栈)和push(入栈)操作,没有考虑任何并发场景。我们来分析一下对应操作的时间复杂度和空间复杂度。

三、分析时间复杂度

首先是pop方法:我们看到,出栈直接依托于数组的随机访问,所以时间复杂度为O(1),也就是常数阶的;而我们也没有消耗多余的存储空间,所以空间复杂度也为O(1)。

再看push方法:push方法就不那么“直接”了,因为涉及到扩容操作。当arrays数组没有满时,入栈操作只需要把元素放入到对应的位置即可,时间复杂度和空间复杂度都是O(1)。但是当arrays数组存满了的时候(postition+1==arrays.length),会触发一次扩容操作,而扩容涉及到新内存空间的申请和数据的搬移,所以此时的时间复杂度和空间复杂度也就成了O(n)。所以我们得出了,push的最好时间复杂度是O(1),最坏时间复杂度为O(n)。

那push方法的平均时间复杂度是多少呢?前面提到过,平均时间复杂度=每种情况的指令次数的总和/情况的数量。

我们知道push操作的时间复杂度是由当前position在栈中的位置决定的(没有到栈顶为O(1),到了栈顶为O(n))。所以我们执行push操作的时候,遇到的情况就有n种(n-1就代表位置)。而前n-1种情况(栈未满)的指令次数为1,第n种情况(栈满)的指令次数为n。所以我们得出了每种情况的执行次数之和:

                                                                   n+\sum_{i=1}^{n-1}1 = 2n-1

再除以n,就得到我们的平均时间复杂度:O(1)

那么加权平均时间复杂度呢?我们设position出现在每个位置的概率是相同的:\frac{1}{n}。那么我们的加权平均时间复杂度的表达式为:

                                                \frac{n}{n}+\sum_{i=1}^{n-1}{\frac{1}{n}} = \frac{n}{n}+\frac{n-1}{n}=\frac{2n-1}{n}

所以忽略常数阶之后,其复杂度还是为:O(1)。

最后我们使用摊还分析来分析一下均摊时间复杂度(为了区别前面的情况数量n,下面我们以m来表示第几次操作)。

在我们的push操作中,前m次操作时间复杂度都是O(1),第m+1次操作由于需要将全部m个元素搬移到新的数组中,所以需要O(n)的复杂度。由于我们扩容的措施是申请一个两倍于原空间大小的空间,所以搬移之后,m当然也翻倍了,接下来的个m次入栈操作,又是O(1)的复杂度,到第二轮m+1次时又需要搬移元素,复杂度又成了O(n),就这样有“规律”的进行下去。

结合之前提到的均摊的概念,我们可以把第m+1次触发的搬移每个元素的操作(其实就是搬移m个元素)均摊到前m次的入栈操作上。这样的话,我们每次入栈就相当于搬移一个元素和一次O(1)的入栈操作,所以这样我们就得出了均摊时间复杂度为O(1)。

这进一步验证了我们之前提到的,均摊时间复杂度一般就是最好情况时间复杂度。因为我们把个别情况的高复杂度操作均摊到了低复杂度的操作上,而高复杂度操作均摊之后的每一步操作一般都是常量阶的,所以最终均摊的结果就是最好情况时间复杂度。

四、应用

1.方法调用栈

在JVM运行时内存区域划分中,有一个区域叫做虚拟机栈(当然还有本地方法栈,不在此处讨论范围)。这块儿内存就是栈的结构,它是线程私有的,每调用一个方法,就会有一个栈帧入栈,方法返回则将对应的栈帧出栈。如果存在方法嵌套调用的情况,则是按照方法调用的顺序依次入栈,栈顶元素其实就对应当前正在执行的方法。我们可以通过-Xss配置默认大小,而StackOverflowError,就是由于栈中的栈帧太多,超过了限制,出现的栈上溢异常。我们只需要一个简单的递归调用就可引出该Error。

2.进制转换

以十进制转二进制为例,我们“书面”的转换流程为:先除以2,将余数存下来,然后将商继续除以2,将余数存下来,这样依次进行,直到商为0,最后将保存的余数倒叙排列,就成了我们的二进制数(这里只是单纯的讨论二进制,不考虑补位等操作哈)。比如一下例子,为十进制50转二进制的流程(随便在网上找的):

上述结果为:110010。而这种场景就正好符合我们栈的特性。我们将每个余数依次入栈,最后依次出栈,就能得到我们想要的结果。

3.符号匹配检查

假设现在有一个由左右圆括号、左右方括号、左右大括号中的一个或多个符号组成的字符串。我们评判这个字符串有意义的标准是:每一个左(右)括号都有且仅有一个对应的右(左)括号与之匹配,不同的括号可以任意嵌套,但是不能产生交叉。比如这些字符串是有意义的:[](){}、{[({})[]]};而这些字符串是没有意义的:)、[{]}、[{(})]、[{}()。

基于这种场景,我们用栈的思想也很好解决:将字符串中的每个字符从左至右依次入栈,当遇到一个右括号时,将栈顶元素出栈,判断该元素是否和右括号匹配,如果不匹配,则字符串无意义;否则继续迭代,当每个字符都经过上述操作之后,栈如果是空的,字符串才有意义,否则就没有意义。

关于栈的应用可不止这些,比如根据算符优先级进行表达式运算、通过穷举法找迷宫出口之类的,这里就不再举例了。

五、总结

对于栈这种抽象的数据结构,我们主要是要理解其先进后出的思想,而不是纠结栈本身,明白它的适用场景。比如我们平时在操作数组的时候,可能会从末尾往前遍历,这其实就可以当做是栈的一种思想而不用真正要实现一个栈的结构

for (int i = arrays.length - 1; i >= 0; i--) {
    System.out.println(arrays[i]);
}

注:本文是博主的个人理解,如果有错误的地方,希望大家不吝指出,谢谢

猜你喜欢

转载自blog.csdn.net/huangzhilin2015/article/details/88947895