不止数据结构(一)——栈(Java)

446 阅读8分钟

1. 写在之前

  这是我的第一篇博客,最近刚刚找到一份程序员的工作,非常有幸能成为广大程序员的一份子。虽然已经找到工作,但是在软件方面算是刚刚入门。由于疫情原因在家还不能入职,所以正好趁此机会恶补一下计算机基础,很多东西需要慢慢学,希望能和大家一起成长!

  这篇文章是想尽量全面地讲解栈数据结构在Java中的实现,除了介绍如何自己实现之外,还介绍了JDK中Stack的实现,最后用一个例子讲解栈的实际应用。本文的所有代码都是基于JDK11。

2. 栈(stack)

  本文只是我的个人理解,如果与权威解释有偏差,请多多指正,谢谢!

  栈是一个先进后出的数据结构,可以想象成一堆叠在一起的盘子,每次放盘子只能放在最上面的盘子之上,每次取盘子也只能取最上面的那一块盘子。

  栈实际上是对线性表的简化。

  线性表的每个元素都是可访问的,而一个栈结构只需要对栈顶的元素可访问就足够了。所以栈数据结构不像查找树、大顶堆等等的结构那样能够为某些操作提供极大的方便,它所提供的是一个解决问题的思想。

3. 栈的基本操作

  栈数据结构中必不可少的成员有两个,元素的容器以及指向栈顶的指针(这里的指针不是C/C++里的指针,仅仅表示一个索引,能通过该索引访问到到栈顶元素)。如下面的代码段所示

// 用一个类表示栈
class Stack {
    int[] container; // 保存栈元素的容器,为了方便理解,容器就用int[]类型来实现
    int top; // 栈顶元素在container中的下标,可通过container[top]来访问栈顶元素
}

栈的基本操作有:

  1. 入栈(poush):往栈中添加一个元素。
  2. 出栈(pop):获取栈顶的元素,并且将该元素从栈中删除。
  3. 访问栈顶元素(peek):获取栈顶的元素,不删除。
  4. 判断栈是否为空(isEmpty):顾名思义。

4. 栈的应用

  我体会最深的栈的应用就是函数的调用,函数的嵌套调用就是用栈实现的,程序每次都运行在栈顶函数的上下文中,当栈顶函数return后操作系统/虚拟机就执行出栈操作,程序恢复到调用方的上下文中。再有新的嵌套调用就再次执行新一轮的入栈和出栈操作。很多语言都有异常处理机制,当异常抛出时,通常都会将程序调用栈显示到控制台,大家平时也可以多跟踪一下这些报错信息,这样能更加深入地了解自己的程序运行逻辑,加深对源码的理解,而不是只知道用库,知其然不知其所以然。

5. 栈的实现

  栈的实现比较简单,没有特别复杂的逻辑,针对不同的场景可以有很多改动,比如增加线程安全的支持,容器用链表实现,容器的扩容,容器对不同数据类型的支持等等,这里我只做一些简单的实现,主要是方便大家理解,如果对Java栈感兴趣,可以看一看java.util包中的Stack类,虽然不常用,但好歹是官方实现。

入栈

class Stack {
    ... 
    void push(int e) {
        this.top++; // 将指针移动到栈顶之上
        this.container[top] = e; // 将元素插入容器中
    }
}

出栈

class Stack {
    ...
    int pop() {
        if (this.isEmpty()) {
            return -1; // 如果容器里没有元素就返回一个特殊值,这里是-1,提示调用方。有时候也可以选择抛出一个异常,取决于你的设计。
        } else {
            int result = this.container[top]; // 返回栈顶元素
            top--; // 栈指针往下移动
            return result;
            // return this.container[this.top--] // 或者将上面三行合并成这一行,简单但是可读性较差
        }
    }
}

peek操作和判断是否为空操作太简单就不再赘述。

6. JDK中的实现

  JDK1.0就已经在java.util包中实现了Stack类,它继承自Vector类,Vector是在JDK1.2之前非常常用的动态数组类,那么到了JDK1.2,我们最熟悉的Collection家族出现了,实现了Collection接口的ArrayList和LinkedList类基本上代替了Vector,成为我们最常用的动态数组类型,但唯一一点代替不了Vector的是,这三者之中只有Vector是线程安全的。

  好像扯远了,现在就来介绍一下java.util.Stack,它实现的内容很少,大部分都是继承自它的父类Vector。

Stack类的方法

  Stack的方法也只是简单的封装了父类Vector的方法。但从语义上来说,这是一个标准的栈,没有多余的操作暴露给外部。需要注意的是,我们上面实现的栈示例中没有考虑入栈越界的情况,那么jdk中的Stack类是怎么处理越界的呢?我们先来看看Stack类的push方法的源代码。

public E push(E item) {
    addElement(item);
    return item;
}

  没错,就这么简单,就像最开始的时候说的,栈并不是一个有强大机制的数据结构,它只是一种解决问题的思想,JDK中的Stack除了对Vector的一些方法封装成栈的语义之外再没有任何附加的功能了,尽管它继承了Vector,但可以说是不合格的继承,它既没有对Vector的功能做扩展,也没有与其他Vector的子类形成明显的多态关系,不像ArrayList和LinkedList,就是典型的多态,同样功能确有不同地实现。

  push方法里addElemnt方法是继承自父类Vector的,Vector的底层容器是一个固定长度的数组,如果没有在调用Vector构造方法时指定,那么默认的数组长度是10,当不断往底层数组中添加元素时,默认会开辟一个新的数组空间,长度为之前的两倍(内存没有溢出的情况下),然后将原来的数组copy到新数组中。当然,每次扩容的长度也可以在重载的构造方法中指定。注意,Stack类只有无参构造方法,所以底层数组长度只能默认是10,扩容也能默认扩容为之前的两倍长。这就是Stack的push方法涉及的操作,并且,它也是线程安全的。

  另外,我还想向大家推荐一个我经常用来代替Stack的接口类型:java.util.Deque(double-ended queue ),也就是双端队列,大家仔细观察一下它的方法。

  是不是很熟悉,既有栈的push、pop方法,也有队列的add、remove方法,还有很多有双端队列语义的方法。而且最重要的是它有一个大家非常熟悉的实现类——LinkedList!。这也是我非常青睐使用LinkedList的原因。所以这两条语句几乎存在于我所有的程序中:

1. List<E> list = new LinkedList<>();
2. Deque<E> list = new LinkedList<>();

  可能这就是面向LinkedList编程吧,hhh。

7. 实际应用(Leetcode举例)

  在Leetcode的20.有效的括号一题中,提出了如下一个问题:

给定一个只包括 '(',')','{','}','[',']' 的字符串,判断字符串是否有效。 有效字符串需满足:

  1. 左括号必须用相同类型的右括号闭合。
  2. 左括号必须以正确的顺序闭合。

注意空字符串可被认为是有效字符串。

  看到这个题目其实可以想到IDE的括号检查,如果括号不匹配了IDE会在某处提示我们错误消息,当然,实际应用肯定比这道题要复杂得多。那么这道题用栈怎么解决呢?我们的思路是将所有的括号从内到外,逐渐将匹配的括号消除掉,如果往外消除的过程中出现有括号无法匹配,那么就返回错误。用栈解决的流程是:

1. 将括号字符串不断入栈。
2. 入栈的过程中检查要入栈的字符是否与栈顶字符构成一对括号,如果构成就弹出栈顶字符,相当于消除了内层的括号。
3. 所有的字符都经过了第二步之后判断栈是否为空,如果不为空则代表有外层括号未匹配,返回错误,如果为空则代表匹配成功。
代码
public boolean isValid(String s) {
    if (s.length() == 0) return true;

    Stack<Character> stack = new Stack<>();
    for (char c : s.toCharArray()) {
        if (stack.empty()) {
            stack.push(c);
            continue;
        }
        switch (c) {
            case '(':
            case '[':
            case '{':
                stack.push(c);
                break;
            case ')':
                if ('(' == stack.peek()) stack.pop();
                else stack.push(c);
                break;
            case ']':
                if ('[' == stack.peek()) stack.pop();
                else stack.push(c);
                break;
            case '}':
                if ('{' == stack.peek()) stack.pop();
                else stack.push(c);
                break;
            default:
                break;
        }
    }
    if (stack.empty()) return true;
    else return false;
}

8. 结语

  以后我还会更新更多的数据结构相关的总结,希望大家能够多多指正我的问题,共同学习,谢谢。