0. 序言
循环队列是为了解决上一节中从队列队首取出数据的时间复杂度是O(n)的问题。我们再看下这个问题:
由于删除数组队首的数据,需要搬移数据,所以时间复杂度为O(n),而从队列的队首取出数据本身就很频繁,导致性能低下。
除了介绍循环队列,还会用模拟代码来对比循环队列和数组队列关于运行时间方面的差异。
1. 解决方案
假如取出队首的数据后,不搬移数据,而是记录下队首的元素,这样性能自然就提高了。这里front指的是队首元素,而tail指的是下次从队尾添加元素所指向的位置。
2. 实现原理
其实有出队和没有出队的情况下,原理是一样的,但是明白这个原理,需要把两种情况都说清楚,不然你可能一头雾水。
-
第一种情况:有出队的操作
-
当无数据时,即数组为空的时候,front和tail都指向数组的第一个元素,即索引为0的位置。所以当front==tail时表明数组为空。
-
当数据入队以后,tail往后移动一位。当又有数据入队以后,tail再往后移动一位。以此类推。
-
假如队首元素出队,front只需要往后移动一位即可。此时取出队首元素的操作的时间复杂度为O(1)
-
当元素插入最后一个索引的时候,由于front前面有可利用的两个空间,所以此时tail指向索引为0的位置.
-
再次放入元素,此时元素会放在tail之前指向的索引为0的位置。tail往后移动一位,指向索引为1的地方.
当(tail + 1 ) % capacity == front 时,队列满。
注意这个时候浪费了一个空间,即这个数组还剩下一个空间的时候进行扩容。为什么要浪费这样一个空间呢?因为此时如果再添加元素,插入到索引为1的地方,那么tail就要指向索引为2的地方,由于front此时也指向索引为2的地方,且front == tail的时候我们判定数组为空,所以浪费一个空间,是为了把数组满和数组空的情况区别开来。
而这个浪费的空间,我们在初始化数组的时候,把容量设置为需求的最大容量+1即可,这样既然满足需求,又能很好的利用这个额外的1个空间,达到循环队列的目的。
① 原数组data,新数组newData
② front == tail 队列为空 (tail+1)% capacity == front 队列满
③ size 是原数组的元素个数 = 7 需求容量7 数组容量8(浪费一个空间)
④ 扩容:原数组的元素从队首front指向的元素开始到索引不等于tail的元素复制到新的数组中,此时front = 0 tail = size;扩容容量 = 需求容量7 ×2 +1 = 15 。
-
没有出队的操作
与有出队不同的是,data中的tail指向的是最后一个索引。虽然如此,公式front == tail 以及(tail + 1)% capacity == front 队列满 未发生变化。
3. 代码
因为要实现循环队列,而队列的底层我们用的数组,所以不能使用之前的动态数组类Array,我们要用一个循环数组来实现循环队列LoopQueue:
- 定义队列接口类Queue
public interface Queue {
void enqueue(E e);
E dequeue();
E getFront();
int getSize();
boolean isEmpty();
}
- 定义实现类LoopQueue:
public class LoopQueue implements Queue {
private E[] data;
private int front,tail;
private int size;
public LoopQueue(int capacity) {
// 1:创建的数组的大小 = 调用者需要的数组的容量+1
data = (E[]) new Object[capacity+1];
front = 0;
tail = 0;
size = 0;
}
public LoopQueue(){
this(10);
}
// 2:能容纳数据的数量 = 数组容量 -1;
public int getCapacity(){
return data.length - 1;
}
// 3: 入队
@Override
public void enqueue(E e) {
// 4:队列已经满的情况下
if ((tail +1) % data.length == front)
resize(getCapacity() * 2);
// 7:原队列没有满的情况下和原队列满了且扩容后:
data[tail] = e;
tail = (tail + 1) % data.length;
size++;
}
private void resize(int newCapacity){
// 5:每次创建数组都要比最大容量 + 1
E[] newData = (E[]) new Object[newCapacity+1];
for (int i = 0; i < size ; i++)
// 6: newData[0] = data[front] front可能为0也可能不为0
// newData[1] = data[front +1] newData[2] = data[front +2]
// 假设数组data.length = 8 索引0~7 共有元素size = 3 此时front = 6
// 此时从队首到队尾的元素在数组中的索引是 6和7和0,而这个0 = (2+6)%8 此时2真的是这里的i
newData[i] = data[(i + front) % data.length];
data = newData;
front = 0;
tail = size;
}
@Override
public E dequeue() {
if (isEmpty())
throw new IllegalArgumentException("Cannot dequeue from an empty queue.");
E ret = data[front];
data[front] = null;
// 8 front往后移动一位(当有出队,插入元素的位置的索引小于front之前指向的索引时,需要 %
// data.length
front = (front + 1) % data.length;
size --;
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];
}
// 3:定义的是数组中元素的数量
@Override
public int getSize() {
return size;
}
@Override
public boolean isEmpty() {
return front == tail;
}
@Override
public String toString() {
StringBuffer res = new StringBuffer();
res.append(String.format("Queue: size = %d,capacity = %d\n", size, getCapacity()));
res.append("front [");
// 9: 也可以用resize中的遍历方式
for (int i = front; i !=tail; i = (i +1) % data.length) {
res.append(data[i]);
if ((i + 1) % data.length != tail)
res.append(", ");
}
res.append("] tail");
return res.toString();
}
}
① 代码1:
创建的数组的大小 = 需求容量+1 (这个1是需要浪费的空间);数组为空的时候front = 0,tail = 0,size = 0。
② 代码2:
查询数组最大容量 = 数组容量 - 1(这个1不能算是有效容量);注意,这里的capacity和实现原理图中的容量capacity不一样,这里指的是有效容量,这里的data.length == 实现原理图中的容量。
③ 代码3:
定义的是数组中元素的数量。
④ 代码4:
数组满的时候,如果还想插入元素,先扩容。
⑤ 代码5:
扩容:数组的容量(data.length) = 原数组有效容量(getCapacity()) × 2 + 1
缩容:数组的容量(data.length) = 原数组有效容量(getCapacity()) / 2 + 1
⑥ 代码6:
newData[0] = data[front] (front可能为0也可能不为0)
newData[1] = data[front +1]
newData[2] = data[front +2]
假设data.length = 8 索引:0~7 共有元素size = 3 此时front = 6
此时从队首到队尾的元素位于数组中的索引是 6 和 7 和 0 , 而这个0 = ( 2 + 6 ) % 8 此时2就是for循环中的 “ i " 。
⑦ 代码7:
处理原队列没有满的情况下和原队列满了且扩容后,新元素的插入操作。这两种情况执行的操作其实也一样,都是往tail指针指向的索引位置。
⑧ 代码8:
front往后移动一位(当有出队,插入元素的位置的索引小于front之前指向的索引时,需要 % data.length):
⑨ 代码9:
也可以使用resize中的for循环替代。其实意思一样。这里的front就是索引值,所以这里的i也是索引,起始索引为front,当i == tail的停止循环,每次 i 变化遵循 “i = (i +1) % data.length"变化趋势。
(i + 1) % data.length != tail 指这个条件下索引i里面的元素不是队尾元素。
- 测试
public class Test_LoopQueue {
public static void main(String[] args) {
LoopQueue 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);
System.out.println("---------------------------------");
}
}
}
}
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
针对这个测试就不再赘述。至此,我们实现了出队的时间复杂度为O(1)。那循环数组出队的时间复杂度O(1)比数组队列出队的时间复杂度O(n)到底性能上高出多少,下一篇文章讲述。
4. 对比数组队列
public class Test_Loop2Array {
// 测试queue分别运行count个enqueue和dequeue操作所需要的时间,单位:秒
private static double testQueue(Queue queue, int count) {
long startTime = System.nanoTime();
Random random = new Random();
for (int i = 0; i < count; i++) {
queue.enqueue(random.nextInt(Integer.MAX_VALUE));
}
for (int i = 0; i < count; i++) {
queue.dequeue();
}
long endTime = System.nanoTime();
return (endTime - startTime) / 1000000000.0;
}
public static void main(String[] args) {
int count = 100000;
ArrayQueue arrayQueue = new ArrayQueue<>();
double time1 = testQueue(arrayQueue, count);
System.out.println("ArrayQueue,time: " + time1 + " s");
LoopQueue loopQueue = new LoopQueue<>();
double time2 = testQueue(loopQueue, count);
System.out.println("LoopQueue,time: " + time2 + " s");
}
}
ArrayQueue,time: 2.985780695 s
LoopQueue,time: 0.011671535 s
你会发现循环队列在运行时间方面比数组队列所耗费的时间要少很多,足见其优异性。
5. 后续
如果大家喜欢这篇文章,欢迎点赞!
如果想看更多 数据结构 方面的文章,欢迎关注!