目录
一、介绍
在java的队列框架中,我们已经详细介绍过基于数组实现的以优先级为标准的优先级队列PriorityQueue,今天给大家讲解队列的另一种实现ArrayDeque,它同样是一个基于数组实现的队列,但它在单向队列的基础上添加了头尾两个变量分别表示队列的头和尾,同时从逻辑上将数组首尾相连形成一个闭环,这就形成了一个循环队列,这样就可以从概念上忽略掉数组的两端了。我们从下面的图片来认识一下循环队列的样子:
- 在队列为空的时候,队头与队尾同时指向数组的0下标,每入队一个操作,队尾便向后(顺时针)移动一位;每出队一个元素,队头便向后(顺时针)移动一位。
- 队头指向当前队列第一个元素所在的数组下标,队尾指向当前队列下一次入队操作时保存元素的数组下标。
- 当队尾指向数组的最大下标时,如果继续入队操作,则队尾继续顺时针移动,指向数组的0下标。以此循环。
- 当入队操作比出队操作频繁时,队尾将会和队头指向相同的数组下标,这时数组没有多余的空间保存元素了,队列也因此而满了。
1.类的声明
我们来看一下ArrayDeque类的声明,可以大致了解他的功能。
public class ArrayDeque<E> extends AbstractCollection<E>
implements Deque<E>, Cloneable, Serializable
- 继承了AbstractCollection类,提供了一些集合相关的基本功能如添加、删除、判空、获取元素数量等
- 实现了Deque接口,说明ArrayDeque可作为双向队列使用
- 实现了Cloneable接口,提供了对象克隆方法,但请注意,是浅克隆。
- 实现了Serializable接口,支持序列化。
2.成员变量
// 底层数据结构为数组
transient Object[] elements; // non-private to simplify nested class access
// 队头元素指向的数组下标,默认为0
transient int head;
// 队尾元素指向的数组下标,默认为0
transient int tail;
// 数组最小的初始化容量8
private static final int MIN_INITIAL_CAPACITY = 8;
二、计算数组长度
为了按照规定确定一个正确的数组长度值,ArrayDeque通过对参数进行一系列按位或与逻辑右移的位运算,最终获得一个大于该参数的最小的2的n次方的数。这里与HashMap中确定table数组长度的计算方法是相似的。看一下源码处理
private static int calculateSize(int numElements) {
int initialCapacity = MIN_INITIAL_CAPACITY;
if (numElements >= initialCapacity) {
initialCapacity = numElements;
initialCapacity |= (initialCapacity >>> 1);
initialCapacity |= (initialCapacity >>> 2);
initialCapacity |= (initialCapacity >>> 4);
initialCapacity |= (initialCapacity >>> 8);
initialCapacity |= (initialCapacity >>> 16);
initialCapacity++;
if (initialCapacity < 0)
initialCapacity >>>= 1;
}
return initialCapacity;
}
我们可以不用完全明白这些位运算,只要知道他们的目的是什么就可以了。
如果要验证结果的话,由于该方法位于ArrayDeque类中,且被private
修饰,我们无法直接调用,那我们直接把方法的实现复制到我们的demo中然后在main()
方法中直接调用就好了。
public static void main(String[] args) {
// 输出16
System.out.println(calculateSize(9));
// 输出16
System.out.println(calculateSize(15));
// 输出32
System.out.println(calculateSize(16));
// 输出32
System.out.println(calculateSize(24));
// 输出8,ArrayDeque要求最小容量为8
System.out.println(calculateSize(3));
}
三、构造函数
无参构造
public ArrayDeque() {
elements = new Object[16];
}
该构造函数非常简单,只是实例化一个默认长度为16的数组作为双端队列。
通过指定初始容量实例化
public ArrayDeque(int numElements) {
allocateElements(numElements);
}
private void allocateElements(int numElements) {
elements = new Object[calculateSize(numElements)];
}
由于ArrayDeque限制最小的初始化容量,因此为了避免调用者指定的容量小于该最低限制,需要对传入的参数进行校验、计算,得到一个正确的数组长度。
通过集合实例化
该方法中所调用了父类AbstractCollection的addAll()方法,通过其源码实现可以看到设计模式中的模版方法思想。addAll()
方法中所调用的add()
方法又是由ArrayDeque实现的,我们在后面的入队部分会说到add()
方法。
public ArrayDeque(Collection<? extends E> c) {
// 根据集合初始化数组长度
allocateElements(c.size());
// 调用父类的addAll()方法将集合
addAll(c);
}
private void allocateElements(int numElements) {
elements = new Object[calculateSize(numElements)];
}
// addAll方法来自父类AbstractCollection
// 而该方法中调用的add()方法又是在子类中实现的
// addAll()方法提供了添加集合的模版,其中add()方法由各子类实现,参考设计模式之模版方法
// 从此方法中看到,此方法就是在遍历集合c的过程中将集合中的元素添加到数组队列中
public boolean addAll(Collection<? extends E> c) {
boolean modified = false;
for (E e : c)
// 添加
if (add(e))
modified = true;
return modified;
}
四、扩容
扩容操作其实比较简单,主要就是两部分:1、声明一个容量为原数组容量两倍的新数组;2、将原数组中的元素以队头元素为第一个元素,队尾元素为最后一个元素,利用System.arraycopy()
方法,将原数组中的元素复制到新数组中。再加上一些判断校验即可。
源码如下:
private void doubleCapacity() {
// 校验队列是否已满,
assert head == tail;
// 队头元素所在下标
int p = head;
// 数组长度
int n = elements.length;
// 队头元素 至 数组最后一个下标 之间的元素数量
// 即队头右边的元素数量
int r = n - p;
// 声明一个容量*2的新数组
int newCapacity = n << 1;
if (newCapacity < 0)
throw new IllegalStateException("Sorry, deque too big");
Object[] a = new Object[newCapacity];
// 分两次将原队列复制到新的队列中
// 并保证新数组中队头元素一定从新数组的0下标处开始
System.arraycopy(elements, p, a, 0, r);
System.arraycopy(elements, 0, a, r, p);
// 赋值
elements = a;
head = 0;
tail = n;
}
第一行的
assert head == tail
断言在队列为空的情况下不是也可以通过吗?非也。在ArrayDeque中,采用的是先入队后扩容的方式,因此在扩容时,队列中至少是有一个元素的
将原数组element中的元素复制到新数组a这个操作为什么执行两次?
当队列在数组中出现“回卷“时,为了保证扩容后队列元素从新数组的0下标位置往后排列,可以将原数组中的队列看成两段,将这两段依次复制到新数组中即可。如下图所示:
- 由于ArrayDeque要求底层数组的容量必须为2的n次方,因此在扩容时,一般来讲只要将原容量*2,即可得到新容量。但注意一点,int类型的最大值为2^32 - 1,当新容量的值超过最大值时,超过最大值的一部分将会从int类型的最小值 -1 * 2^31 开始累加,而最终得到一个负数,因此在扩容时遇到这种情况直接抛出异常“Sorry, deque too big”,表示容量太大无法扩容。
五、常用方法解析
入队
前面说过,ArrayDeque实现了Deque接口,因此它是一个双向队列,提供了从首部入队和从尾部入队四个入队方法。
这四个方法中有两个是没有返回值的,另外两个在入队无异常的情况下返回true。
他们的实现逻辑完全相同,都是先入队,再判断是否需要扩容,且入队的元素不能为空。
public void addFirst(E e) { if (e == null) throw new NullPointerException(); elements[head = (head - 1) & (elements.length - 1)] = e; if (head == tail) doubleCapacity(); } public boolean offerFirst(E e) { addFirst(e); return true; } public void addLast(E e) { if (e == null) throw new NullPointerException(); elements[tail] = e; if ( (tail = (tail + 1) & (elements.length - 1)) == head) doubleCapacity(); } public boolean offerLast(E e) { addLast(e); return true; }
有些朋友可能对上面两个方法中的&按位与运算有所疑惑。
我们打比方当前数组长度为16,当前队列元素队头在数组中的下标为0,队尾的下标为10,以
addFirst()
方法中的代码为例:
得到的结果为15,所以将新入队的元素放在数组下标为15的位置上,其实这也就是通过数组实现循环队列的原理了。
出队
同样的,出队操作也分为首端出队和尾端出队,方法实现基本相同,如果出队元素为null,说明当前队列为空队列。
public E pollFirst() { int h = head; @SuppressWarnings("unchecked") E result = (E) elements[h]; // Element is null if deque empty if (result == null) return null; elements[h] = null; // Must null out slot head = (h + 1) & (elements.length - 1); return result; } public E pollLast() { int t = (tail - 1) & (elements.length - 1); @SuppressWarnings("unchecked") E result = (E) elements[t]; if (result == null) return null; elements[t] = null; tail = t; return result; }
还有另外两个出队方法,这两个方法在队列为空的情况下会抛出异常,即表示如果抛出异常,说明当前队列为空队列。
public E removeFirst() { E x = pollFirst(); if (x == null) throw new NoSuchElementException(); return x; } public E removeLast() { E x = pollLast(); if (x == null) throw new NoSuchElementException(); return x; }
查看队头元素(不出队)
如下源码所示,
peekFirst()
和peekLast()
方法允许队列为空。如果队列为空时,会返回null。getFirst()
和getLast()
方法不允许队列为空,如果队列为空时,会抛出异常。public E getFirst() { E result = (E) elements[head]; if (result == null) throw new NoSuchElementException(); return result; } public E getLast() { E result = (E) elements[(tail - 1) & (elements.length - 1)]; if (result == null) throw new NoSuchElementException(); return result; } public E peekFirst() { return (E) elements[head]; } public E peekLast() { return (E) elements[(tail - 1) & (elements.length - 1)]; }
删除队列中与参数相同的元素
从下面源码中可以看到,首先需要遍历数组,通过
equals()
方法找到与参数相同的元素的下标,再调用delete(int i)
方法根据数组下标删除元素。该方法的使用频率几乎为0,
- 作为队列,我们往往是对他的队头和队尾进行操作。从中间操作元素这种方法,不属于队列。
- 既然我们操作的是队列,那么在删除的时候也应该是操作队列,而非数组。
获取队列长度
public int size() { return (tail - head) & (elements.length - 1); }
判断队列是否为空
一般来讲,当
head == tail
时,既可以是空队列,也可以是满队列。但在ArrayDeque中,当队列已满时会自动扩容,从而避免了head == tail
。public boolean isEmpty() { return head == tail; }
六、总结
- 底层采用数组作为物理结构,循环队列作为逻辑结构
- 默认的初始化容量为16,最小容量为8,且容量始终为2的n次方
- 元素不能为空
- 先入队,后扩容
- 线程不安全