循環隊列的原理
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指向0
,tail也指向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還是指向2
,tail指向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=7
,capacity=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,衝呀!