循環隊列的底層實現

循環隊列的原理

1.數組隊列的侷限性

數組隊列的實現是具有侷限性的,在於出隊操作,它的時間複雜度是O(n)級別的。

假設數組隊列可以容納9個元素,隊列裏已經有5個元素,“a,b,c,d,e”

0 1 2 3 4 5 6 7 8
a b c d e

此時要出隊,即刪除隊首的元素。假設左側是隊首,右側是隊尾,此時“a”出隊,在出隊之後,所有的其他元素都必須向前挪一個單位,之後size--

0 1 2 3 4 5 6 7 8
b c d e

以上是數組底層實現隊列出隊的整個過程。由於有“b,c,d,e”這些除隊首之外的所有元素都需要向前移一個位置,所以它的時間複雜度變成了O(n)

基於以上的分析,由此得到一個猜想:如果刪掉了“a”元素之後,數組保持下面的樣子,其他元素不朝前挪動一個位置“b,c,d,e”還是保持着隊列的樣子,“b”是隊首,“e”是隊尾。

0 1 2 3 4 5 6 7 8
b c d e

如此可以在數組中記錄一下隊首是誰,雖然數組中雖然索引爲0的元素爲空,但可以用front記錄一下隊首存放在索引爲1的位置。同時,隊尾和之前數組的size一樣,它指的是下一次新的元素入隊應該存儲的位置
當元素“a”出隊以後,只需要維護front的指向,即只需要front++,而不需要其他所有元素前移一個位置。基於這樣的想法,便有了循環隊列的實現方式。

2.循環隊列是怎樣的

初始情況下整個隊列沒有任何元素,此時front指向0tail也指向0。front本來應該指向隊列的第一個元素,tail指向隊列中最後一個元素後一個位置,當整個隊列整體爲空的時候,front和tail指向同一個位置。即front == tail時隊列爲空

front和tail指向同一個位置爲藍色,front指向爲綠色,tail指向爲紅色。

0 1 2 3 4 5 6 7 8
null null null null null null null null null

當隊列中入隊一個元素時,此時只需要維護tail隊尾的指向,tail指向下一個元素入隊的位置即索引爲1的位置。

0 1 2 3 4 5 6 7 8
a

再次入隊一個b,tail指向往後挪1個位置,tail++;

0 1 2 3 4 5 6 7 8
a b

如果隊列中已有5個元素,此時的tail指向索引爲5的位置,front指向索引爲0的位置。

0 1 2 3 4 5 6 7 8
a b c d e

當隊列出需要出列一個元素,即從隊首的位置取出a,此時只需要維護front++

0 1 2 3 4 5 6 7 8
b c d e

再出隊一個元素,此時應出隊的元素是b,front指向2。

0 1 2 3 4 5 6 7 8
c d e

3.爲什麼稱爲循環隊列

入隊f,g,h以後,front還是指向2tail指向8。當需要再次入隊1個元素i的時候,這是的tail就不能做++操作了。但是由於隊首的元素出隊之後,還剩餘的空間並沒有被後面的元素擠掉,對於數組來說,前面還有可以利用的空間。這就是循環隊列的來由。

0 1 2 3 4 5 6 7 8
c d e f g h i

此時可以把這個數組想象看作是一個環,它可以容納9個元素,對應的索引是0-8,8之後的索引其實是0,而不是因爲8之後沒有索引誤判數組已經佔滿了。

0 1 2 3 4 5 6 7 8
c d e f g h i

值得注意的是,tail一直進行着++操作。可是當8的位置入隊i以後,這時taill++會是9,而不是0,所以更準確的說,tail應該是(當前的索引+1)%整個數組長度,即(8+1)%9。所以又有新的元素入隊時,它將放在tail指向0的位置,之後tail++,指向1。

0 1 2 3 4 5 6 7 8
j c d e f g h i

觀察上面的隊列tail指向1,front指向2。此時如果有一個新的元素入隊了,它會放在1的位置,tail需要++等於2。
taill++ == 2,而這時的front==2,這種情況下front==tail。上面說過當front與tail相等時,隊列爲空。可此時的隊列卻是存滿的不爲空,front==tail既可以判斷隊列爲空,也可以用來表示隊列爲滿。這當然不是我希望得到的條件,所以定義tail+1 == front爲隊列滿
換句話說,tail指向的位置始終是下一次需要入隊的位置,一旦有一個新的元素放入,tail就需要+1,但是+1之後tail和front重疊。這時說明整個數組只剩下最後一個空間,可以認爲這個數組已經爲滿,可以進行相應的擴容。
⚠️對於整個數組的空間來說,其實是有意識的浪費掉了一個空間。

0 1 2 3 4 5 6 7 8
a b c d e f g h

再返回來看,tail+1 ==front其實是不準確的,準確的表達應該是(tail+1)%c==front隊列滿。這是由於在循環隊列的情況下,到了數組的末端還可以再返回數組的前端,返回數組的前端的這一過程就是靠%求餘的操作。所以當front爲0,tail爲8的時候隊列也是滿的,這是因爲(8+1)%9=0

在循環隊列的具體實現中,所有的front和tail向後移動的過程都要是循環的移動。可以將循環隊列想象成一個鐘錶,11點過後下一個鐘頭可以是12點,也可以是0點,之後又變成1點,2點,依此類推。整個循環隊列的索引像鐘錶一樣形成了一個環,只不過不一定有12個刻度,刻度的數量是由數組的容基決定的。

代碼實現

1.定義接口

Queue.java

// 隊列接口依舊支持泛型
public interface Queue<E> {
    void enqueue(E e);   // 入隊

    E dequeue();      // 出隊

    E getFront();      // 查看隊首

    int getSize();       // 查看大小

    boolean isEmpty();   // 是否爲空
}

2.LoopQueue.java

具體的LoopQueue的實現和ArrayQueue的實現有很大的區別,不再複用動態數組Array,而是底層實現。需要時刻注意的是,循環隊列中始終有一個浪費掉的空間,遍歷隊列時要注意讓索引循環起來。

public class LoopQueue<E> implements Queue<E> {
    private E[] data;
    private int front, tail; // front指向隊首索引,tail指向隊列中最後一個元素的下一個位置
    private int size;  // 記錄隊列中元素的個數

    public LoopQueue(int capacity) {
        // 用戶傳進來的容基,因爲循環隊列需要浪費掉一個位置,爲了確保期望值,capacity+1
        data = (E[]) new Object[capacity + 1];

        // 初始化成員變量
        front = 0;
        tail = 0;
        size = 0;
    }

    public LoopQueue() {
        // 爲capacity設置默認值
        this(10);
    }

    // 查看一下循環隊列最多可以裝在多少個元素
    public int getCapacity() {
        // 真正數組的容基是數組長度-1
        return data.length - 1;
    }

    @Override
    public boolean isEmpty() {
        // 當front和tail相等時爲空
        return front == tail;
    }

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

    // 入隊過程
    @Override
    public void enqueue(E e) {
        // 首先看一下隊列是否是滿的,
        if ((tail + 1) % data.length == front) {
            // 滿則需要擴容
            resize(getCapacity() * 2);
        }
        // 存放一個元素
        data[tail] = e;
        // 由於是循環對了,維護tail需要取餘
        tail = (tail + 1) % data.length;
        size++;
    }

    // 出隊過程
    @Override
    public E dequeue() {
        // 首先看一下隊列是否爲空
        if (isEmpty()) {
            throw new IllegalArgumentException("Cannot dequeue from an empty dequeue.");
        }
        // 如果不爲空,取出元素先保存一下
        E ret = data[front];
        data[front] = null;
        front = (front + 1) % data.length;
        size--;
        // 出隊之後進行相應的縮容
        // size等於循環隊列可以承載的容基的1/4 縮容的值不能等於0
        if (size == getCapacity() / 4 && getCapacity() / 2 != 0) {
            resize(getCapacity() / 2);
        }
        return ret;
    }

    // 查看隊列中隊首的元素是誰
    @Override
    public E getFront() {
        // 先判斷隊列不能爲空
        if (isEmpty()) {
            throw new IllegalArgumentException("Queue is empty.");
        }
        return data[front];
    }

    private void resize(int newCapacity) {
        // 開闢一個新的數組空間 +1是因爲時刻注意有意識浪費掉的一個元素
        E[] newData = (E[]) new Object[newCapacity + 1];
        // 將data中的元素放入newData中 循環隊列中一共有size個元素
        for (int i = 0; i < size; i++) {
            // 原來隊首的位置可能不是0,將隊首放在新的空間0的位置
            // 這樣newData[i]對應的不是data[i],存咋一個font的偏移量
            // 防止越界要對length取餘
            newData[i] = data[(i + front) % data.length];
        }
        data = newData; // 改變新的data,保證getCapacity的計算是正確的
        front = 0;
        // 索引從0開始,元素個數剛好是下一個可以存放位置的索引
        tail = size;
    }

    @Override
    public String toString() {
        StringBuilder res = new StringBuilder();
        res.append(String.format("Queue: size = %d,capacity = %d\n", size, getCapacity()));
        res.append("front [");
        // 遍歷循環隊列中的所有元素 與動態數組有很大不同
        // 第一個位置應該在front的位置
        // 隊尾應該是tail-1的位置,由於是循環數組,tail可能比front小,所以i!=tail不能去到tail
        // 每一次i++ 需要變成循環的+1
        for (int i = front; i != tail; i = (i + 1) % data.length) {
            res.append(data[i]);
            // 如果i不是最後一個元素,逗號隔開 (i + 1) % data.length不等於tail
            if ((i + 1) % data.length != tail) {
                res.append(", ");
            }
        }
        res.append("] tail");
        return res.toString();
    }
}

3.測試類Main.java

將0-9這10個數字存放進循環隊列中,並且每隔三個數字執行一下出隊操作。

public class Main {
    public static void main(String[] args) {
        LoopQueue<Integer> queue = new LoopQueue<>();
        // 入列測試
        for (int i = 0; i < 10; i++) {
            queue.enqueue(i);
            System.out.println(queue);

            // 往隊列中每插入三個元素,就取出一個元素
            if (i % 3 == 2) {
                queue.dequeue();
                System.out.println(queue);
            }
        }
    }
}

4.測試結果分析

  • 初始的時候,默認容基爲10,首先將0,1,2這三個元素進行了入隊操作
  • 入隊以後,進行隊首一次出隊操作,此時出隊的元素爲0,拿出0以後,只剩下兩個元素,比當前可以承載的容基10的一半還要少,(少於1/4)這時觸發了一次縮容,0出隊之後容基爲5
  • 接下來“3,4,5”入隊,此時隊列中爲“1,2,3,4,5”。size與capacity都是5。
  • 接着“1”出隊,隊列中爲“2,3,4,5”,此時size=4
  • 之後將要進行“6,7,8”的入隊操作,先將“6”入隊,隊列滿了。之後入隊“7”的時候,此時的隊列是滿的,需要進行一次2倍擴容。capacity=10。接着入隊“8”。
  • “8”入隊後需要進行一次出隊操作,“2”出隊。此時隊列中有“3,4,5,6,7,8”6個元素。
  • 最後“9”入隊,循環隊列中元素爲“3,4,5,6,7,8,9”,循環結束。
  • 最終size=7capacity=10
Queue: size = 1,capacity = 10
front [0] tail
Queue: size = 2,capacity = 10
front [0, 1] tail
Queue: size = 3,capacity = 10
front [0, 1, 2] tail
Queue: size = 2,capacity = 5
front [1, 2] tail
Queue: size = 3,capacity = 5
front [1, 2, 3] tail
Queue: size = 4,capacity = 5
front [1, 2, 3, 4] tail
Queue: size = 5,capacity = 5
front [1, 2, 3, 4, 5] tail
Queue: size = 4,capacity = 5
front [2, 3, 4, 5] tail
Queue: size = 5,capacity = 5
front [2, 3, 4, 5, 6] tail
Queue: size = 6,capacity = 10
front [2, 3, 4, 5, 6, 7] tail
Queue: size = 7,capacity = 10
front [2, 3, 4, 5, 6, 7, 8] tail
Queue: size = 6,capacity = 10
front [3, 4, 5, 6, 7, 8] tail
Queue: size = 7,capacity = 10
front [3, 4, 5, 6, 7, 8, 9] tail

寫在最後

如果代碼有還沒有看懂的或者我寫錯的地方,歡迎評論,我們一起學習討論,共同進步。
推薦學習地址:
liuyubobobo老師的《玩轉數據結構》:https://coding.imooc.com/class/207.html
最後,祝自己早日鹹魚翻身,拿到心儀的Offer,衝呀!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章