队列 Queue
写在开头
- 先进先出的线性数据结构(FIFO)
- 只允许在表的前端(front)进行删除操作,而在表的后端(tail)进行插入操作。进行插入操作的端称为队尾,进行删除操作的端称为队头。
- 队列中没有元素时,称为空队列。
数组队列的实现,结合动态数组,以接口的方式构建ArrayQueue<E>
-
接口:Queue
public interface Queue<E> { /** * 获取队列容量大小 * @return */ int getSize(); /** * 队列空判断 * @return */ boolean isEmpty(); /** * 入队 * @param e */ void enqueue(E e); /** * 出队 * @return */ E dequeue(); /** * 获取队首元素 * @return */ E getFront(); }
-
接口实现类:ArrayQueue<E>
public class ArrayQueue<E> implements Queue<E>{ private Array<E> array; public ArrayQueue() { array = new Array<>(); } public ArrayQueue(int capacity) { array = new Array<>(capacity); } @Override public int getSize() { return array.getSize(); } @Override public boolean isEmpty() { return array.isEmpty(); } @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 String toString() { return "ArrayQueue{" + "array=" + array + '}'; } }
循环队列的实现
- 数组队列的局限性:聚焦于出队操作,时间复杂度为O(n)级别
- 为什么这样讲,出队操作针对队首元素,底层数组在remove索引为0的元素后,会对其余元素进行前移操作,从而涉及到遍历操作,因此时间复杂度上升至O(n)。
- 循环队列,摒弃元素出队后的其他元素前移操作,构建头指针front,尾指针tail(本质为动态数组的size),出队元素前移操作简化为头指针的移动操作(front++)。
- 需要注意的两点:
- 循环队列判空:front == tail [ 初始状态 ]
- 循环队列判满:(tail + 1) % C == front [ C为队列长度,浪费一个数组空间用于尾指针指向 ]
- 关于底层动态数组扩容的问题?
- 在动态数组文章中提到了扩容的实质是开辟新的内存空间,并将原数组内容复制到新数组中,这个地方就出现了一个问题,循环数组由于充分利用了数组的空间,所以当循环队列满时,数组索引处为0的位置,并不一定是循环队列的第一个元素。
- 如下图,数组索引为0的位置是循环队列中最后添加的元素,此刻触发数组扩容操作,数组复制的时候需要注意:由于队列也是线性结构,元素应该有序放入,所以动态数组的resize方法需要做一些变动。
- 改造 ArrayQueue<E>,结合 Queue 接口进行方法重写
-
创建 LoopQueue<E>,完成基本成员属性构造
public class LoopQueue<E> implements Queue<E> { private E[] data; private int front, tail; private int size; // 队列实际容量标识 public LoopQueue(int capacity) { // capacity + 1 适应循环队列满载机制 // (tail + 1) % c == front data = (E[]) new Object[capacity + 1]; front = 0; tail = 0; size = 0; } public LoopQueue() { this(10); } // 获取队列最大容量 public int getCapacity() { return data.length - 1; } }
-
getSize() 获取队列实际容量
@Override public int getSize() { return size; }
-
isEmpty() 队列空判断
@Override public boolean isEmpty() { // 队列判空条件 return front == tail; }
-
getFront() 获取队首元素
@Override public E getFront() { if (isEmpty()) { throw new IllegalArgumentException("Queue is empty"); } return data[front]; }
-
重写 resize(),规整循环队列
/** * 容量重置 * @param capacity */ private void resize(int capacity) { E[] newData = (E[]) new Object[capacity + 1]; for (int i = 0; i < size; i++) { // 新数组中的元素索引相较原数组中索引存在front的偏移量 newData[i] = data[(front + i) % data.length]; } // 数组地址指向、头指针变更为默认值、尾指针指向变更 data = newData; front = 0; tail = size; }
-
enqueue(E e) 入队
@Override public void enqueue(E e) { if ((tail + 1) % data.length == front) { resize(getCapacity() * 2); } data[tail] = e; tail = (tail + 1) % data.length; size ++; }
-
dequeue() 出队
@Override public E dequeue() { if (isEmpty()) { throw new IllegalArgumentException("Queue is empty"); } E res = data[front]; data[front] = null; front = (front + 1) % data.length; size --; // 四等分点进行数组缩容,避免复杂度震荡 if (size == getCapacity() / 4 && getCapacity() / 2 != 0) { resize(getCapacity() / 2); } return res; }
-
比较 - 数组队列和循环队列 (分别从入队角度和出队角度考虑)
-
测试方法
private static double testQueue(Queue<Integer> q, int opCount) { long startTime = System.nanoTime(); Random random = new Random(); for (int i = 0; i < opCount; i++) { q.enqueue(random.nextInt(Integer.MAX_VALUE)); } // 出队测试时使用 for (int i = 0; i < opCount; i++) { q.dequeue(); } long endTime = System.nanoTime(); return (endTime - startTime) / 1000000000.0; }
-
测试Main方法,定义操作次数、分别创建数组队列和循环队列对象
public static void main(String[] args) { int opCount = 100000; Queue<Integer> arrayQueue = new ArrayQueue<>(); Queue<Integer> loopQueue = new LoopQueue<>(); System.out.println("arrayQueue:" + testQueue(arrayQueue, opCount) + " s"); System.out.println("loopQueue:" + testQueue(loopQueue, opCount) + " s"); }
-
入队耗时测试:
-
入对 + 出队耗时测试:两种队列的区别主要在出队,数组队列复杂度上升也因为出队操作
总结:结果显而易见,循环队列的方式合理利用了数组空间,并且将出队操作的时间复杂度拉回O(1)水平,较数组队列有更好的性能。