栈Stack和队列Queue

585 阅读7分钟

栈(Stack)

  • 栈是一种线性结构。相比数组,栈对应的操作是数组的子集,所以我们完全可以基于动态数组去实现它
  • 栈只能从一端添加元素,也只能从同一端取出元素,这一端称为栈顶
  • 栈是一种后进先出的数据结构(Last In First Out 简称为LIFO)

栈最常见的应用场景:

  1. 括号匹配-编译器
  2. 无处不在的Undo操作(撤销),将我们每次的操作放入栈中,执行撤销操作时只需要把放入的元素出栈即可
  3. 程序调用的系统栈,方法调用时所展现的调用层级,就是栈的结构,如下图: 在这里插入图片描述

栈的基本结构

我们将基于前面所实现的动态数组的基础上实现一个栈的数据结构。由于栈的底层实现有多种(数组、链表)方式,所以为了隔离实现,我们定义一个接口,来面向接口编程,该接口仅定义栈这个数据结构必要的方法:

public interface Stack<E> {
    /**
     * 获取栈中的元素个数
     *
     * @return 元素个数
     */
    int getSize();

    /**
     * 栈是否为空
     *
     * @return 为空返回true,否则返回false
     */
    boolean isEmpty();

    /**
     * 将一个元素入栈
     *
     * @param e 新元素
     */
    void push(E e);

    /**
     * 将一个元素出栈
     *
     * @return 栈顶的元素
     */
    E pop();

    /**
     * 查看栈顶的元素
     *
     * @return 栈顶的元素
     */
    E peek();
}

然后创建一个实现类实现这个接口,代码如下:

/**
 *  基于动态数组实现的栈数据结构
 **/
public class ArrayStack<E> implements Stack<E> {

    private Array<E> array;

    public ArrayStack() {
        this.array = new Array<>();
    }

    public ArrayStack(int capacity) {
        this.array = new Array<>(capacity);
    }

    @Override
    public int getSize() {
        return array.getSize();
    }

    @Override
    public boolean isEmpty() {
        return array.isEmpty();
    }

    @Override
    public void push(E e) {
        array.addLast(e);
    }

    @Override
    public E pop() {
        return array.removeLast();
    }

    @Override
    public E peek() {
        return array.getLast();
    }

    /**
     * 获取栈的容量
     *
     * @return capacity
     */
    public int getCapacity(){
        return array.getCapacity();
    }

    @Override
    public String toString() {
        if (isEmpty()) {
            return "[]";
        }

        StringBuilder sb = new StringBuilder();
        sb.append(String.format("Stack: size = %d, capacity = %d\n", getSize(), getCapacity()));
        sb.append("[");
        for (int i = 0; i < getSize(); i++) {
            sb.append(array.get(i));
            if (i != getSize() - 1) {
                sb.append(", ");
            }
        }
        return sb.append("] top").toString();
    }
}

从实现代码可以看出,基于上章我们所实现的动态数组的基础上,实现一个栈数据结构是非常简单的。

ArrayStack主要方法的时间复杂度:

void push(E) // O(1) 均摊 E pop() // O(1) 均摊 E peek() // O(1) int getSize() // O(1) boolean isEmpty() // O(1)

栈实现括号匹配

实现括号匹配可以说是栈的一个经典应用了,很多公司也出过这个面试题。其思路也很简单,大概就是先遍历字符串中的字符,遇到左括号就将其入栈,遇到右括号则将栈顶元素出栈与其进行匹配,若匹配则继续循环,不匹配则返回false结束。正常执行完循环后,还需验证栈是否为空,因为进行括号匹配的时候是将栈顶元素出栈进行匹配的,所以循环内逻辑正确的话所有元素都会出栈,此时的栈必需为空。

/**
 * @author li.pan
 * <p>
 * 给定一个只包括 '(',')','{','}','[',']' 的字符串 s ,判断字符串是否有效。
 * 有效字符串需满足:
 * 1. 左括号必须用相同类型的右括号闭合。
 * 2. 左括号必须以正确的顺序闭合。
 * </p>
 */
public class Leetcode20 {
    public boolean isValid(String s) {
        Stack<Character> stack = new ArrayStack<>();
        for (int i = 0; i < s.length(); i++) {
            char c = s.charAt(i);
            if (c == '(' || c == '[' || c == '{') {
                // 只要是左边的括号就入栈
                stack.push(c);
            } else {
                // 如果栈中没有元素代表没有左括号
                if (stack.isEmpty()) {
                    return false;
                }
                // 取出栈顶元素进行匹配,只要有一个不匹配就返回false
                char topChar = stack.pop();
                if (c == ')' && topChar != '(') {
                    return false;
                }
                if (c == ']' && topChar != '[') {
                    return false;
                }
                if (c == '}' && topChar != '{') {
                    return false;
                }
            }
        }
        // 最后需要验证栈是否为空
        return stack.isEmpty();
    }

    public static void main(String[] args) {
        System.out.println((new Leetcode20()).isValid("()[]{}"));  // true
        System.out.println((new Leetcode20()).isValid("{[()]}"));  // true
        System.out.println((new Leetcode20()).isValid("([{}]"));   // false
    }
}

队列(Queue)

  • 队列也是一种线性结构,相比数组队列对应的操作是数组的子集
  • 队列只能从队尾添加元素,并且只能从队首取出元素
  • 队列是一种先进先出的数据结构(先进先出),实际上FIFO就是First In First Out的缩写

数据结构中的队列与我们现实生活中的队列是一样的,例如我们在排队到柜台办理业务的时候,就是一个队列结构,先排队的先办理业务,后排队的后办理业务,符合先进先出的特性。

队列的基本结构

同样的,队列这种结构的底层实现也有多种方式,常见的就有数组队列、循环队列以及链表队列等,所以我们得定义一个Queue接口,来面向接口编程,该接口仅定义队列这个数据结构必要的方法:

public interface Queue<E> {
    /**
     * 新元素入队
     *
     * @param e 新元素
     */
    void enqueue(E e);

    /**
     * 元素出队
     *
     * @return 元素
     */
    E dequeue();

    /**
     * 获取位于队首的元素
     *
     * @return 队首的元素
     */
    E getFront();

    /**
     * 获取队列中的元素个数
     *
     * @return 元素个数
     */
    int getSize();

    /**
     * 队列是否为空
     *
     * @return 为空返回true,否则返回false
     */
    boolean isEmpty();
}

数组队列

同样的,我们基于前面所实现的动态数组的基础上来实现数组队列

public class ArrayQueue<E> implements Queue<E> {
    private Array<E> array;

    public ArrayQueue() {
        this.array = new Array<>();
    }

    public ArrayQueue(int capacity) {
        this.array = new Array<>(capacity);
    }

    @Override
    public void enqueue(E e) {
        array.addLast(e);
    }

    @Override
    public E dequeue() {
        return array.removeFirst();
    }

    @Override
    public E getFront() {
        return array.getFirst();
    }

    @Override
    public int getSize() {
        return array.getSize();
    }

    @Override
    public boolean isEmpty() {
        return array.isEmpty();
    }
  
    public int getCapacity() {
        return array.getCapacity();
    }

    @Override
    public String toString() {
        if (isEmpty()) {
            return "[]";
        }

        StringBuilder sb = new StringBuilder();
        sb.append(String.format("Queue: size = %d, capacity = %d\n", getSize(), getCapacity()));
        sb.append("front [");
        for (int i = 0; i < getSize(); i++) {
            sb.append(array.get(i));
            if (i != getSize() - 1) {
                sb.append(", ");
            }
        }
        return sb.append("] tail").toString();
    }
}

ArrayQueue主要方法的时间复杂度:

void enqueue(E) // O(1) 均摊 E dequeue() // O(n) 每出队一个元素,底层数组内所有的元素都需要移动位置,所以是O(n)的复杂度 E getFront() // O(1) int getSize() // O(1) boolean isEmpty() // O(1)

上述已经基于动态数组来实现了一个队列,但是有一定局限性的。在出队的操作,复杂度是O(n),如果队列中有大量元素的话,出队一个元素都是很耗时的,例如数组中有10w个元素,那么每出队一个元素就要移动10w个元素。

循环队列

针对于出队时间复杂度很高的情况线下,我们采用另一种方式来实现队列这个数据结构,通常我们会使用循环队列或链表队列,本小节主要介绍循环队列。在循环队列中,我们会在队列里设置两个变量,分别是front和tail,其中front始终指向的是位于队首的元素,而tail则始终指向位于队尾的元素+1的索引位置,当front等于tail时代表队列为空: 在这里插入图片描述 当我们将队首元素出队时,front移动一下指向下一个元素,数组内的其他元素都不移动,这样出队操作的复杂度就是O(1)。同理,当元素入队时,tail移动一下即可: 在这里插入图片描述 当元素继续入队,直到数组后面的空间都填满了怎么办?如下图: 在这里插入图片描述

首先我们从图中可以看到,数组的前面还有可利用的空间,我们可以想办法将tail移动到可利用的空间上。在上文中提到当新元素入队后tail就移动一下,那么这个具体移动的数值是怎么计算的呢?实际上tail移动的具体数值是通过(tail + 1) % capacity得出的,例如这里就是(7 + 1) % 8 = 0,所以此时tail就会指向数组索引为0的位置,而front也是同理: 在这里插入图片描述

将其想象成一个环,可能会更好理解,这也是为什么叫循环队列的原因,如下图: 在这里插入图片描述

当队列满了之后,自然就需要扩容,怎么判断队列满了呢?答案是判断(tail + 1) % capacity的结果是否等于front的值: <img src="/Users/lipan/app/typora-pic/image-20210701141201676.png" alt="image-20210701141201676" style="zoom:33%;" />

front==tail队列为空,(tail + 1) % capacity==front队列为满,(tail + 1) % capacity队列移动

实现一个循环队列,与之前的数组队列实现不同的是,我们不再基于Array类进行实现,因为具体的实现逻辑有许多不一样的地方,我们要将数组当成一个环去用,所以无法再复用Array这个数据结构,我们需要从底层完成这个循环队列数据结构。

/**
 *  循环队列数据结构
 **/
public class LoopQueue<E> implements Queue<E> {
    /**
     * 实际存储元素的数组
     */
    private E[] data;

    /**
     * 指向队首元素
     */
    private int front;

    /**
     * 指向队尾元素+1的索引位置
     */
    private int tail;

    /**
     * 元素的个数
     */
    private int size;

    /**
     * 带有队列初始容量参数的构造器
     *
     * @param capacity 队列初始容量
     */
    public LoopQueue(int capacity) {
        // 因为循环队列的结构会浪费一个索引空间,所以这里需要+1
        this.data = (E[]) new Object[capacity + 1];
        this.front = 0;
        this.tail = 0;
        this.size = 0;
    }

    /**
     * 无参构造器,默认队列初始容量为10
     */
    public LoopQueue() {
        this(10);
    }

    /**
     * 获取队列的容量
     *
     * @return 队列的容量
     */
    public int getCapacity() {
        // 因为会浪费一个索引空间,所以实际的容量是数组的长度-1
        return data.length - 1;
    }

    @Override
    public int getSize() {
        return size;
    }

    @Override
    public boolean isEmpty() {
        return front == tail;
    }
  
    @Override
    public E getFront() {
        checkIfEmpty();
        return data[front];
    }

    @Override
    public void enqueue(E e) {
        // 队列是否已满
        if ((tail + 1) % data.length == front) {
            // 扩容
            resize(getCapacity() * 2);
        }

        data[tail] = e;
        tail = (tail + 1) % data.length;
        size++;
    }

    @Override
    public E dequeue() {
        checkIfEmpty();
        E ret = data[front];
        // 释放出队元素
        data[front] = null;
        // 移动front
        front = (front + 1) % data.length;
        size--;

        // 判断是否需要缩容
        if (size == getCapacity() / 4 && getCapacity() / 2 > 0) {
            // 缩容
            resize(getCapacity() / 2);
        }

        return ret;
    }

    @Override
    public String toString() {
        if (isEmpty()) {
            return "[]";
        }

        StringBuilder sb = new StringBuilder();
        sb.append(String.format("Queue: size = %d, capacity = %d\n", size, getCapacity()));
        sb.append("front [");
        // 第一种遍历循环队列的方式
        for (int i = front; i != tail; i = (i + 1) % data.length) {
            sb.append(data[i]);
            if ((i + 1) % data.length != tail) {
                sb.append(", ");
            }
        }
        return sb.append("] tail").toString();
    }

    /**
     * 队列容量重置
     *
     * @param newCapacity 新的队列容量
     */
    private void resize(int newCapacity) {
        E[] newData = (E[]) new Object[newCapacity + 1];
        // 第二种遍历循环队列的方式
        for (int i = 0; i < size; i++) {
            // 因为是循环队列,元素的位置需要通过特定方式计算
            newData[i] = data[(front + i) % data.length];
        }

        data = newData;
        front = 0;
        tail = size;
    }

    /**
     * 检查是否是对空队列进行操作
     */
    private void checkIfEmpty() {
        if (isEmpty()) {
            throw new IllegalArgumentException("Can't operation an empty queue.");
        }
    }
}

LoopQueue主要方法的时间复杂度:

void enqueue(E)     // O(1) 均摊
E dequeue()         // O(1) 均摊
E getFront()        // O(1)
int getSize()       // O(1)
boolean isEmpty()   // O(1)

数组队列和循环队列的性能比较

最后,我们来写一个简单的测试用例,使用10w数据量测试一下数组队列和循环队列的性能,代码如下:

public class Main {

    /**
     * 测试使用queue运行opCount个enqueue和dequeue操作所需要的时间,单位:毫秒
     *
     * @param queue   queue
     * @param opCount opCount
     * @return 耗时
     */
    private static long testQueue(Queue<Integer> queue, int opCount) {
        long startTime = System.currentTimeMillis();

        Random random = new Random();
        for (int i = 0; i < opCount; i++) {
            queue.enqueue(random.nextInt(Integer.MAX_VALUE));
        }

        for (int i = 0; i < opCount; i++) {
            queue.dequeue();
        }

        return System.currentTimeMillis() - startTime;
    }

    public static void main(String[] args) {
        long time1 = testQueue(new ArrayQueue<>(), 100000);
        System.out.println("ArrayQueue, time: " + time1 + "/ms");

        long time2 = testQueue(new LoopQueue<>(), 100000);
        System.out.println("LoopQueue, time: " + time2 + "/ms");
    }
}

控制台输出如下:

ArrayQueue, time: 16531/ms
LoopQueue, time: 15/ms

对于数组队列,每次出队需要移动后全部元素,故此时间消耗较大。

源代码地址:github.com/perkinls/ja…