为什么循环队列要浪费一个存储空间

什么是队列

队列和数组,链表,栈一样都是属于线性数据结构,而队列又和栈比较相似,都属于操作受限的数据结构,其中栈是“后入先出”,而队列是“先进先出”。

和栈一样,队列也仅有两个基本操作:入队和出队(栈则是入栈和出栈)。往队列中放元素称之为入队,往队列中取元素称之为出队,然而相对于栈来说,队列又会复杂一点。

队列中通常需要定义两个指针:headtail(当然,也有称之为:frontrear)分别用来表示头指针和尾指针。初始化队列时,headtail 相等,当有元素入队时,tail 指针往后移动一位,当有元素出队时,head 指针往后移动一位。

队空和队满

根据上面的队列初始化和入队出队的过程,我们可以得到以下三个关系:

  • 初始化队列时:head=tail=-1(这里也可以设置为 0,看具体实现)。
  • 队列满时:tail=size-1(其中 size 为初始化队列时队列的大小)。
  • 队列空时:head=tail(比如上图中的第一和第三个队列)。

队列的实现

和栈一样,队列也可以通过数组或者链表来实现,通过数组来实现的队列我们称之为“顺序队列”,通过链表实现的队列称之为“链式队列”。

数组实现队列

package com.lonely.wolf.note.queue;

/**
 * 基于数组实现自定义单向队列。FIFO(first in first out)先进先出
 * @author lonely_wolf
 * @version 1.0
 * @date 2021/12/26
 * @since jdk1.8
 */
public class MyQueueByArray<E> {
    public static void main(String[] args) {
        MyQueueByArray myQueue = new MyQueueByArray(3);
        System.out.println("队列是否为空:" + myQueue.isEmpty());//输出:true
        myQueue.enqueue(1);
        myQueue.enqueue(2);
        myQueue.enqueue(3);
        System.out.println("队列是否已满:" + myQueue.isFull());//输出:true
        System.out.println("第1次出队:" + myQueue.dequeue());//输出:1
        System.out.println("第2次出队:" + myQueue.dequeue());//输出:2
        System.out.println("第3次出队:" + myQueue.dequeue());//输出:3
        System.out.println("队列是否为空:" + myQueue.isEmpty());//输出:true
        System.out.println("队列是否已满:" + myQueue.isFull());//输出:true
    }

    private Object[] data;
    private int size;//队列长度
    private int head;//队列头部
    private int tail;//队列尾部

    public MyQueueByArray(int size) {//初始化
        this.size = size;
        data = new Object[size];
        head = -1;
        tail = -1;
    }

    /**
     * tail=size-1 表示队列已满
     * @return
     */
    public boolean isFull(){
        return tail == size - 1;
    }

    /**
     * head=tail表示队列为空
     * @return
     */
    public boolean isEmpty(){
        return head == tail;
    }


    /**
     * 元素入队,tail指针后移一位
     * @param e
     * @return
     */
    public boolean enqueue (E e){
        if (isFull()){
            return false;
        }
        data[++tail] = e;//tail 指针后移
        return true;
    }


    /**
     * 元素出丢
     * @return
     */
    public E dequeue (){
        if (isEmpty()){
            return null;
        }
        E e = (E)data[++head];//出队
        return e;
    }
}

链表实现队列

有了上面基于数组实现的简单队列例子,基于链表实现队列相信大家也能写出来,如果用链表实现队列,我们们同样需要head 指针和 tail 指针。其中 head 指向链表的第一个结点,tail 指向最后一个结点。

入队时:

tail.next = newNode;
tail = tail.next;

出队时:

head = head.next;

假溢出问题

我们回到上面数组实现的例子中,我们发现最后的两个输出语句两个都是 true,也就是队列又是空的又是满的,这看起来是一个自相矛盾的事情却在队列里面出现了,我们来看看这时候队列的示意图:

通过上图中我们发现,因为 tail=size-1,所以队列是满的;而此时又同时满足:head=tail,所以根据上面队空的条件,我们又可以得到当前队列是空的,也就是当前队列是没有元素的。这时候我们已经无法继续入队了,但是这时候队列中的其实是有空间的,有空间却不能被利用,这就是单向队列的“假溢出”问题。

那么如何解决这个问题呢?解决这个问题有两个思路。

  • 思路一

前面我们学习数组的时候提到了,当数组中删除一个元素,那么这个位置之后的所有元素都需要往前移动一位,所以队列中也是同理,假如我们用数组来实现的话,当元素出队时,我们把所有元素都往前移,不过这样因为每次都需要搬移数据,导致入队的时间复杂度是 O(n)

  • 思路二

因为搬移数据会影响到入队效率,那么如何不搬移数据又能将队列的空闲空间利用起来呢?答案也很简单,那就是当再次有新元素入队时,我们把 tail 指针又重新移动到队列的最开头位置,这样也能避免出现“假溢出”问题,同时时间复杂度仍然保持为 O(1)

循环队列

上面提到的第二种思路来解决单向队列的“假溢出”问题,实际上就构成了一个循环队列。而上面单向队列判断当前队列是否队满时仅需要满足 tail=size-1 即可,但是循环队列肯定不行,因为当 tail=size-1 时继续添加元素,tail 可能可以移动到 size=0 的位置,所以如果要实现循环队列最关键还是需要确定好队空和队满的条件。

队空和队满

在循环队列中,当初始化队列时,一般会将其设置为 0,而队空条件仍然是 head=tail,循环队列最关键的是如何确定队满条件。

下图是初始化一个长度为 6 的队列以及连续入队 5 个元素后的循环队列中 headtail 指针示意图:

上图中第二个队列表示的是入队 5 个元素之后 tail 指针的位置,这时候如果继续入队,那么 tail 只能指向队列开头也就是元素 1 所在的位置,此时会得到下面这个示意图的场景:

这时候发现 head=tail,和队空时的条件冲突了,那么如何解决这个问题呢?主要有三种解决办法:

  1. (tail+1)%size=head 时就表示队列已满,这时候 tail 所在位置为空,所以会浪费一个空间(也就是第一幅图中第二个队列的场景就算队列已满)。
  2. 新增一个容量 capacity 字段,并进行维护,当 capacity=size 表示队列已满。
  3. 和第二个办法差不多,我们可以再维护一个标记,或者当 head=tail 时同步判断当前位置是否有元素来判断当前是队空还是队满。

这三种思路我们发现都各有缺点:方法 1 会浪费一个空间;方法 2 每次入队和出队时候都需要维护 capacity 字段;方法 3 就是不论队列空或者队列满时都要多一次判断方式。

相互比较之下,其实方法一的思路就是空间换时间,而另外两种办法当数据量并发量很大时,多一次判断其实也是会对性能有所影响,常规的循环链表会采用方法 1 进行处理,也就是选择浪费一个空间的方式。当然大家在实际开发过程中也可以自行斟酌。

实现循环队列

下面我们就以方法 1 的思路基于数组来实现一个循环队列:

package com.lonely.wolf.note.queue;

/**
 *
 * 实现循环队列
 * @author lonely_wolf
 * @version 1.0
 * @date 2021/12/26
 * @since jdk1.8
 */
public class MyCycleQueueByArray<E> {

    public static void main(String[] args) {
        MyCycleQueueByArray cycleQueue = new MyCycleQueueByArray(3);
        System.out.println("循环队列是否为空:" + cycleQueue.isEmpty());//输出:true
        System.out.println("1是否入队成功:" + cycleQueue.enqueue(1));//输出:true
        System.out.println("2是否入队成功:" + cycleQueue.enqueue(2));//输出:true
        System.out.println("3是否入队成功:" + cycleQueue.enqueue(3));//输出:false
        System.out.println("循环队列是否已满:" + cycleQueue.isFull());//输出:true
        System.out.println("第1次出队:" + cycleQueue.dequeue());//输出:1
        System.out.println("第2次出队:" + cycleQueue.dequeue());//输出:2
        System.out.println("第3次出队:" + cycleQueue.dequeue());//输出:null,因为 3 入队失败
        System.out.println("循环队列是否为空:" + cycleQueue.isEmpty());//输出:true
        System.out.println("循环队列是否已满:" + cycleQueue.isFull());//输出:false
    }

    private Object[] data;
    private int size;
    
    private int head;//队列头部
    private int tail;//队列尾部

    public MyCycleQueueByArray(int size) {
        this.size = size;
        data = new Object[size];
        head = 0;
        tail = 0;
    }

    public boolean isFull(){
        return (tail + 1) % size == head;
    }

    public boolean isEmpty(){
        return head == tail;
    }

    /**
     * 入队
     * @param e
     * @return
     */
    public boolean enqueue (E e){
        if (isFull()){
            return false;
        }
        data[tail] = e;
        tail =  (tail + 1) % size;//注意这里不能直接 tail++,否则无法循环使用
        return true;
    }


    /**
     * 出队
     * @return
     */
    public E dequeue (){
        if (isEmpty()){
            return null;
        }
        E e = (E)data[head];
        head =  (head + 1) % size;//head 也同样不能直接 head++
        return e;
    }
}

这时候我们发现,虽然队列空间有 3 个,但是实际上只能存放 2 个元素,而最后两条输出语句的输出结果也说明了循环队列不会出现单向队列的“假溢出问题”。

队列实战

队列其实在 Javajuc 中有广泛应用,比如 AQS 等,在这里,我们继续来看一看队列的相关算法题来加深对队列的理解。

两个栈实现队列

前面我们讲栈的时候,用了两个队列来实现栈,这道题却正好相反,是利用两个栈来实现队列。

LeetCode232 题:请你仅使⽤两个栈实现先⼊先出队列,队列应当⽀持⼀般队列⽀持的所有操作(push、pop、peek、empty)。

这道题目其实相比较之前两个队列实现栈还是会更简单一点,因为栈是后入先出,所以我们只需要将入队和出队使用不同的栈就可以解决了。

具体解题思路为:将⼀个栈当作输⼊栈,⽤于压⼊(push)传⼊的数据;另⼀个栈当作输出栈,⽤于 pop(出队) 和 peek(查看队列头部元素) 操作。 每次 poppeek 时,若输出栈为空则将输⼊栈的全部数据依次弹出并压⼊输出栈,再将元素从输出栈输出。

具体代码实现为:

package com.lonely.wolf.note.queue;

import java.util.Stack;

/**
 * LeetCode 232
 * 请你仅使⽤两个栈实现先⼊先出队列。队列应当⽀持⼀般队列⽀持的所有操作(push、pop、peek、empty):
 *
 * void push(int x) 将元素 x 推到队列的末尾
 * int pop() 从队列的开头移除并返回元素
 * int peek() 返回队列开头的元素
 * boolean empty() 如果队列为空,返回 true ;否则,返回 false
 *
 * 说明:
 * 你只能使⽤标准的栈操作 —— 也就是只有 push to top, peek/pop from top, size, 和 is empty 操作是合法的。
 * 你所使⽤的语⾔也许不⽀持栈。你可以使⽤ list 或者 deque(双端队列)来模拟⼀个栈,只要是标准的栈操作即可。
 *
 * 解题思路
 * 将⼀个栈当作输⼊栈,⽤于压⼊ push 传⼊的数据;另⼀个栈当作输出栈,⽤于 pop 和 peek 操作。
 * 每次 pop 或 peek 时,若输出栈为空则将输⼊栈的全部数据依次弹出并压⼊输出栈,
 *
 * @author lonely_wolf
 * @version 1.0
 * @date 2021/12/26
 * @since jdk1.8
 */
public class MyQueueByTwoStack<Integer> {
    private Stack<Integer> inStack;//输入栈
    private Stack<Integer> outStack;//输出栈

    /**
     * 即入队:enqueue 操作
     * @param e
     */
    public void push(Integer e){
        inStack.push(e);//压入输入栈
    }

    /**
     * 查看并移除队列头部元素,即:出队 dequeue 操作
     * @return
     */
    public Integer pop(){
        if (!outStack.isEmpty()){//输出栈不为空则直接出栈
            return outStack.pop();
        }
        while (!inStack.isEmpty()){//输出栈为空,则检查输入栈
            outStack.push(inStack.pop());//输入栈不为空,则将其压入输出栈
        }
        if (!outStack.isEmpty()){//再次检查输出栈是否有元素出栈
            return outStack.pop();
        }
        return null;
    }

    /**
     * 查看队列头部元素,相比较 pop,这里只查看元素,并不移除元素
     **/
    public Integer peek(){
        if (!outStack.isEmpty()){
            return outStack.peek();
        }
        while (!inStack.isEmpty()){
            outStack.push(inStack.pop());
        }
        if (!outStack.isEmpty()){
            return outStack.peek();
        }
        return null;
    }

    /**
     * 队列是否为空
     * @return
     */
    public boolean empty(){
        return inStack.isEmpty() && outStack.isEmpty();
    }
}

总结

本文主要讲述了队列这种操作受限的数据结构,文中通过一个例子说明了单向链表为什么会存在“假溢出“问题,并最终引出了循环链表,而循环链表的实现同样有三种不同思路,并通过以空间换时间的方法基于数组实现了一个简单的循环链表。最后我们还讲述了如何利用两个栈来实现一个队列。

Tags: