双端队列Deque——ArrayDeque的实现
Deque 接口表示一个双端队列(Double Ended Queue),允许在队列的首尾两端操作,所以既能实现队列行为,也能实现栈行为。
Deque常用的两种实现ArrayDeque和LinkedList,这篇主要介绍下Deque的常用操作,并重点看下ArrayDeque的实现逻辑。
1、接口API
1.1、Queue接口
Queue 的 API 可以分为 2 类,区别在于方法的拒绝策略上:
- 抛异常:
- 向空队列取数据,会抛出 NoSuchElementException 异常;
- 向容量满的队列加数据,会抛出 IllegalStateException 异常。
- 返回特殊值:
- 向空队列取数据,会返回 null;
- 向容量满的队列加数据,会返回 false。
拒绝策略 | 抛异常 | 返回特殊值 |
---|---|---|
入队(队尾) | add(e) | offer(e) |
出队(队头) | remove() | poll() |
观察(队头) | element() | peek() |
1.2、Deque接口(继承于Queue接口)
Java 没有提供标准的栈接口,而是放在 Deque 接口中:
拒绝策略 | 抛异常 | 等价于 |
---|---|---|
入栈 | push(e) | addFirst(e) |
出栈 | pop() | removeFirst() |
观察(栈顶) | peek() | peekFirst() |
除了标准的队列和栈行为,Deque 接口还提供了 12 个在两端操作的方法:
拒绝策略 | 抛异常 | 返回值 |
---|---|---|
增加 | addFirst(e)/ addLast(e) | offerFirst(e)/ offerLast(e) |
删除 | removeFirst()/ removeLast() | pollFirst()/ pollLast() |
观察 | getFirst()/ getLast() | peekFirst()/ peekLast() |
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; }
从源码也可以看出,offerFirst(e) 只是调用了 addFirst(e),然后返回了一个true,失败时还是会抛异常的。
2、ArrayDeque的实现
2.1、ArrayQueue的特点
- 1、ArrayDeque 是基于动态数组实现的 Deque 双端队列,内部封装了扩容和数据搬运的逻辑;
- 2、ArrayDeque 的数组容量保证是 2 的整数幂;
- 3、ArrayDeque 不是线程安全的;
- 4、ArrayDeque 不支持 null 元素;
- 5、ArrayDeque 虽然入栈和入队有可能会触发扩容,但从均摊分析上看依然是 O(1) 时间复杂度;
2.2、ArrayDeque和LinkedList的区别
-
1、数据结构: 在数据结构上,ArrayDeque 和 LinkedList 都实现了 Deque 双端队列接口。但 ArrayDeque 没有实现了 List 列表接口,所以不具备根据索引位置操作的行为;
-
2、线程安全: ArrayDeque 和 LinkedList 都不考虑线程同步,不保证线程安全;
-
3、底层实现: 在底层实现上,ArrayDeque 是基于动态数组的,而 LinkedList 是基于双向链表的。
-
在遍历速度上: ArrayDeque 是一块连续内存空间,基于局部性原理能够更好地命中 CPU 缓存行,而 LinkedList 是离散的内存空间对缓存行不友好;
-
在操作速度上: ArrayDeque 和 LinkedList 的栈和队列行为都是 O(1) 时间复杂度,ArrayDeque 的入栈和入队有可能会触发扩容,但从均摊分析上看依然是 O(1) 时间复杂度;
-
额外内存消耗上: ArrayDeque 在数组的头指针和尾指针外部有闲置空间,而 LinkedList 在节点上增加了前驱和后继指针。
-
3、如何使用数组实现栈和队列
我们知道栈和队列都是 “操作受限” 的线性表:栈是 LIFO,限制在表的一端入栈和出栈。而队列是 FIFO,限制在表的一端入队,在另一端出队。栈和队列既可以用数组实现,也可以用链表实现:
- 数组:用数组实现时叫作顺序栈和顺序队列;
- 链表:用链表实现时叫作链式栈和链式队列。
3.1、ArrayList相对于LinkedList的局限性
LinkedList既作为 List 的链式表,又作为 Queue 的链式队列,又作为 “Stack” 的链式栈,功能很全面。相较之下,ArrayList 却只作为实现了 List 的顺序表,为什么呢?
这是因为在数组上同时实现 List 和 Queue 时,无法平衡这两个行为的性能矛盾。具体来说:ArrayList 不允许底层数据有空洞,所有的有效数据都会 “压缩” 到底层数组的首部。所以,使用 ArrayList 开发栈的结构或许合适,可以在数组的尾部操作数据。但使用 ArrayList 开发队列就不合适,因为在数组的首部入队或出队需要搬运数据。
3.2、使用数组实现栈结构
使用数组实现栈相对容易,因为栈结构的操作被限制在数组的一端,所以我们可以选择数组的尾部作为栈顶,并且使用一个 top 指针记录栈顶位置:
- 栈空: top == 0;
- 栈满: top == n;
- 入栈: 将数据添加到栈顶位置,均摊时间复杂度是 O(1);
- 出栈: 将栈顶位置移除,时间复杂度是 O(1);
对于出栈而言,时间复杂度总是 O(1),但是对于入栈而言,却不一定。因为当数组的空间不足(top == n)时,就需要扩容和搬运数据来容纳新的数据。此时,时间复杂度就从 O(1) 退化到 O(n)。
对于这种大部分操作时间复杂度很低,只有个别情况下时间复杂度会退化,而且这些操作之间存在很强烈的顺序关系的情况,就很适合用 “均摊时间复杂度分析” 了:
假设我们的扩容策略是将数组扩大到旧数组的 2 倍,用均摊分析法:
- 1、对于一个大小为 K 的空数组,在前 K - 1 次入栈操作中,时间复杂度都是 O(1);
- 2、在第 K 次入栈中,由于数组容量不足,所以我们将数组扩大为 2K,并且搬运 K 个数据,时间复杂度退化为 O(K);
- 3、对于一个大小为 2K 的数组,在接下来的 K - 1 次入栈操作中,时间复杂度都是 O(1);
- 4、在第 2K 次入栈中,由于数组容量不足,所以我们将数组扩大为 4K,并且搬运 2K 个数据,时间复杂度再次退化为 O(K);
- 5、依此类推。
可以看到,在每次搬运 K 个次数后,随后的 K - 1 次入栈操作就只是简单的 O(1) 操作,K 次入栈操作涉及到 K 个数据搬运和 K 次赋值操作。那我们从整体看,如果把复杂度较高的 1 次入栈操作的耗时,均摊到其他复杂度较低的操作上,就等于说 1 次入栈操作只需要搬运 1 个数据和 1 次赋值操作,所以入栈的均摊时间复杂度就是 O(1)。
3.3、使用数组实现队列结构
使用数组实现队列相对复杂,我们需要一个队头指针和一个队尾指针:
- 队空: head == tail;
- 队满: tail == n(并不是真的满,只是无法填充新数据);
- 入队: 将数据添加到队尾位置,均摊时间复杂度是 O(1);
- 出队: 将队头位置移除,时间复杂度是 O(1)。
对于出队而言,时间复杂度总是 O(1)。对于入队而言,当 tail == n
时,就需要扩容和搬运数据来容纳新的数据,我们用均摊分析法得出均摊时间复杂度依然是 O(1),就不重复了。
但是我们发现,栈的 top == n
表示栈空间不足,扩容没有问题,而队列的 tail == n
却不一定表示队列空间不足。因为入队和出队发生在不同方向,有可能出现 tail == n
但队头依然有非常多剩余空间的情况。此时,扩容显得没有必要。
那么,怎么避免没有必要的扩容和数据搬移呢?—— 循环数组。
我们在逻辑上将数组的首尾相连,当 tail == n
时,如果数组头部还有空闲位置,我们就把 tail 指针调整到数组头部,在数组头部添加数据。我们下面要分析的 ArrayDeque 数据结构,就是采用了循环数组的实现。
使用循环数组后,队列空和队列满的判断条件会发生变化:
- 队列空: head == tail;
- 队列满: (tail + 1)%size == head,如果 size 是 2 的整数幂,还可以用位运算判断:(tail + 1) & (size - 1) == head。
理解了使用数组实现栈和队列的思路后,下面再来分析 ArrayDeque 的实现原理,就显得游刃有余了。
4、ArrayDeque源码分析
来分析下ArrayDeque主要流程的源码。
4.1、ArrayDeque的属性
- ArrayDeque 底层是一个 Object 数组;
- ArrayDeque 用
head
和tail
指针指向数组的 “队头位置” 和 “队尾位置”,需要注意 tail 队尾指针实际上是指向队尾最后一个有效元素的下一位。
ArrayDeque 的属性很好理解的,然后我先抛出几个问题:
- 🙋🏻♀️疑问 1: 为什么字段都不声明
private
关键字? - 🙋🏻♀️疑问 2: 为什么字段都声明
transient
关键字? - 🙋🏻♀️疑问 3: 为什么 elements 字段不声明为泛型类型
E
? - 🙋🏻♀️疑问 4:为什么没有看到 ArrayList 类似的
MAX_ARRAY_SIZE
最大容量限制?
这个问题我们在分析源码的过程中回答。
public class ArrayDeque<E> extends AbstractCollection<E> implements Deque<E>, Cloneable, Serializable {// 底层数组 transient Object[] elements; // non-private to simplify nested class access // 队头指针 transient int head; // 队尾指针 transient int tail; // 最小初始容量 private static final int MIN_INITIAL_CAPACITY = 8; // 尾指针 - 头指针 public int size() { return (tail - head) & (elements.length - 1); } }
4.2、ArrayDeque的构造方法
ArrayDeque 有 3 个构造函数:
- 1、无参构造方法: 初始化容量为
16
的数组; - 2、带初始容量的构造方法: 如果初始容量小于
8 (MIN_INITIAL_CAPACITY)
,则初始化数组大小为8
。如果初始容量大于 8,则计算 “最近的 2 的整数幂” 作为初始大小。例如 numElements 为 8,则初始化容量为 16。numElements 为 19,则初始化容量为 32; - 3、带集合的构造方法: 用相同的方法创建初始容量为 2 的整数幂的数组,并调用 addAll 逐个添加元素。
// 无参构造方法 public ArrayDeque() { elements = new Object[16]; } // 带初始容量的构造方法 public ArrayDeque(int numElements) { allocateElements(numElements); } // 带集合的构造方法 public ArrayDeque(Collection<? extends E> c) { allocateElements(c.size()); // 疑问 5:为什么带集合的构造方法不使用 Arrays 工具整体复制,而是逐个添加? addAll(c); } private void allocateElements(int numElements) { elements = new Object[calculateSize(numElements)]; } // 求离 numElements 最近的 2 的整数幂,即 nextPow2 问题 // 疑问 6:为什么 ArrayDeque 要求容量是 2 的整数幂? // 疑问 7:calculateSize() 的函数体解释一下? private static int calculateSize(int numElements) { // 如果 numElements 小于 8(MIN_INITIAL_CAPACITY),则返回 8 int initialCapacity = MIN_INITIAL_CAPACITY; if (numElements >= initialCapacity) { initialCapacity = numElements; // 五轮无符号右移和或运算后,变成最高有效位开始后面都是 1 initialCapacity |= (initialCapacity >>> 1); initialCapacity |= (initialCapacity >>> 2); initialCapacity |= (initialCapacity >>> 4); initialCapacity |= (initialCapacity >>> 8); initialCapacity |= (initialCapacity >>> 16); // 加 1 进位,得到最近 2 的整数幂 initialCapacity++; // 变成负数(高位是 1000,低位都是 0000) if (initialCapacity < 0) // 右移 1 位,取 2^30 为初始容量 initialCapacity >>>= 1;// Good luck allocating 2 ^ 30 elements } return initialCapacity; }
逐个添加元素:
// 逐个添加元素 public boolean addAll(Collection<? extends E> c) { boolean modified = false; for (E e : c) if (add(e)) modified = true; return modified; }
- 🙋🏻♀️疑问 5:为什么带集合的构造方法不使用 Arrays 工具整体复制,而是逐个添加?
因为 ArrayDeque 禁止存储 null
元素,所以需要逐个判断元素是否为 null
值后才添加。
- 🙋🏻♀️疑问 6:为什么 ArrayDeque 要求数组容量是 2 的整数幂?
在循环数组中需要使用取余运算计算游标指针循环后的位置,例如 (tail + 1) % size
,而如果数组的尺寸 size 是 2 的整数幂,那么就可以将取余运算替换为位运算,例如 (tail + 1) & (size - 1)
,不管被除数是正负结果都是正数。 不仅将取余运算替换为位运算,而且减少了一次取绝对值运算,提高了索引的计算效率。
size = 0 0 0 1 0 0 0 0 0 0 // 2^n 的补码 size - 1 = 0 0 0 0 1 1 1 1 1 1 // 2^n - 1 的补码 -1 = 1 1 1 1 1 1 1 1 1 1 // -1 的补码 0 = 0 0 0 0 0 0 0 0 0 0 // 0 的补码 // 尾指针的循环: 1、如果 tail + 1 <= size - 1,则 (tail + 1) & (size - 1) 后保持不变 2、如果 tail + 1 == size,则 size & (size - 1) 为 0 // 头指针的循环 1、如果 head - 1 >= 0,则(head - 1) & (size - 1) 后保持不变 2、如果 head - 1 == -1,则 -1 & (size - 1) 后为 size - 1
- 🙋🏻♀️疑问 7:calculateSize() 的函数体解释一下?
calculateSize() 是求离 numElements 最近的 2 的整数幂,即 nextPow2 问题。
- 1、首先,如果 numElements 小于 8(MIN_INITIAL_CAPACITY),则直接返回 8;
- 2、否则执行 nextPow2 运算,经过五轮无符号右移和或运算,将 numElements 转换为从最高位开始后面都是 1 的数。再执行 +1 运算,就求出了最近的 2 的整数幂(最高有效位是 1,低位都是 0);
- 3、当 numElements 在 2^30 到 2^ 31-1 之间(即最高位是 0100 的数),计算后得到的 nextPow2 就是负数(最高位是 1000,低位都是 0),此时就需要右移一位,取 2^30 为初始容量。
n = 0 0 0 0 1 x x x x x // n n = 0 0 0 0 1 1 x x x x // n |= n >>> 1; n = 0 0 0 0 1 1 1 1 x x // n |= n >>> 2; n = 0 0 0 0 1 1 1 1 1 1 // n |= n >>> 4; n = 0 0 0 0 1 1 1 1 1 1 // n |= n >>> 8;(这一步对 n 没有影响了) n = 0 0 0 0 1 1 1 1 1 1 // n |= n >>> 16;(这一步对 n 没有影响了) n = 0 0 0 1 0 0 0 0 0 0 // n ++(进位,得到最近 2 的整数幂)
4.3、ArrayDeque 的添加和扩容方法
ArrayDeque 可以在数组的两端添加元素,不支持在数组的中间添加元素:
- 在队头添加: 在 head 指针的上一个位置赋值,如果数组越界则循环到数组尾部;
- 在队尾添加: 在 tail 指针的位置赋值,并将 tail 指针指向下一个位置,如果数组越界则循环到数组头部。
public void addLast(E e) { // 疑问8:为什么 ArrayDeque 不支持添加 null 元素 if (e == null) throw new NullPointerException(); // tail 指针本身就是指向下一个位置,所以直接填充 elements[tail] = e; // 修改 tail 指针到下一个位置,并通过取余操作循环到数组头部 if ( (tail = (tail + 1) & (elements.length - 1)) == head) doubleCapacity(); } public void addFirst(E e) { // 疑问 8:为什么 ArrayDeque 禁止存储 null 元素? if (e == null) throw new NullPointerException(); // 修改 head 指针到前一个位置,并通过取余操作循环到数组尾部 elements[head = (head - 1) & (elements.length - 1)] = e; if (head == tail) doubleCapacity(); }
- 🙋🏻♀️疑问 8:为什么 ArrayDeque 禁止存储 null 元素?
其实在 Deque 接口上并不严格禁止存储 null 元素,但是会强烈建议 Deque 的实现不提供存储 null 值的能力。因为 null 通常会作为一个特殊值来判断队列是否为空。
public E pollFirst() { int h = head; E result = (E) elements[h]; // 队列为空 if (result == null) return null; elements[h] = null; // Must null out slot head = (h + 1) & (elements.length - 1); return result; }
在每次添加元素后,如果队头指针和队尾指针相遇,说明数组空间已满,此时就需要扩容操作。ArrayDeque 会将新数组的容量扩大到旧数组的 2 倍 ,由于旧数组的容量也是 2 的整数幂,因此乘以 2 之后依然是 2 的整数幂。
搬运数据的过程就是把 head 头指针到数组末尾的元素拷贝到数组头部,而剩下的从数组头部到尾指针的元素则衔接到后面,使得所有元素规整地排列在数组的头部:
// 扩容操作 private void doubleCapacity() { assert head == tail; int p = head; int n = elements.length; // 队头指针到数组末尾的元素个数 int r = n - p; // 容量翻倍 int newCapacity = n << 1; // 容量变成负数 if (newCapacity < 0) // ArrayList 扩容整型溢出时会抛出 OutOfMemoryError // ArrayDeque 扩容整型溢出时会抛出 IllegalStateException // 看了一下,发现这两个容器出自不同作者,不能统一下吗哈哈 throw new IllegalStateException("Sorry, deque too big"); // 创建新数组 Object[] a = new Object[newCapacity]; // 将队头指针到数组末尾的元素拷贝到数组头部 System.arraycopy(elements, p, a, 0, r); // 拷贝剩下的从数组头部到尾指针的元素 System.arraycopy(elements, 0, a, r, p); // 指向新数组 elements = a; // 重置头尾指针 head = 0; tail = n; }
- 🙋🏻♀️疑问 4:为什么没有看到 ArrayList 类似的
MAX_ARRAY_SIZE
最大容量限制?
现在可以回答这个疑问了, 网上也有资料说 ArrayDeque 没有容量限制,最坑的是代码注释也这么说:“Array deques have no capacity restrictions”。 显然不是这么一回事。 第一,数组的容量显示是被虚拟机固化的,不可能无限容量。第二,从 doubleCapacity() 函数可以看出, 最大容量值是 2^30(高位 0100,低位都是0), 如果超过这个数,在 doubleCapacity() 扩容的时候就会抛出异常了。其实一句话就是容量时使用 int 类型定义的。
4.4 ArrayDeque 的获取和移除方法
ArrayDeque 可以在数组的两端移除元素,不支持在数组的中间移除元素:
- 在队头移除: 在 head 指针的位置获取,再将 head 指向下一个位置,如果数组越界则循环到数组尾部;
- 在队尾移除: 在 tail 指针的上一个位置获取,如果数组越界则循环到数组头部。
public E pollFirst() { // head 指针本身就是指向队头元素,所以直接获取 int h = head; E result = (E) elements[h]; if (result == null) return null; elements[h] = null; // 修改 head 指针指向下一个位置 head = (h + 1) & (elements.length - 1); return result; } public E pollLast() { // tail 指针本身是指向队尾元素的下一个位置,所以需要返回上一个位置 int t = (tail - 1) & (elements.length - 1); E result = (E) elements[t]; if (result == null) return null; elements[t] = null; tail = t; return result; }
4.5 ArrayDeque 的迭代器
Java 的 foreach 是语法糖,本质上也是采用 iterator 的方式。ArrayDeque 提供了 2 个迭代器:
- iterator():DeqIterator(): 正向迭代器
- ListIterator DescendingIterator(): 反向迭代器
ArrayDeque 迭代器同样有 fail-fast 机制,不给过ArrayDeque 并没有使用类似 ArrayList 类似的 modCount 检查并发修改,而是通过头尾指针的位置和元素检查并发修改。这个方法不一定能保证检测到所有的并发修改情况,例如无法检查先移除了尾部元素,又马上添加了一个尾部元素的情况。
private class DeqIterator implements Iterator<E> { private int cursor = head; private int fence = tail; private int lastRet = -1; public boolean hasNext() { return cursor != fence; } public E next() { if (cursor == fence) throw new NoSuchElementException(); E result = (E) elements[cursor]; // 无法检测到所有并发修改的情况 if (tail != fence || result == null) throw new ConcurrentModificationException(); lastRet = cursor; cursor = (cursor + 1) & (elements.length - 1); return result; } ... }
4.6 ArrayDeque 的序列化过程
ArrayDeque 重写了 JDK 序列化的逻辑,只把 elements 数组中有效元素的部分序列化,而不会序列化整个数组。
// 序列化过程 private void writeObject(java.io.ObjectOutputStream s) throws java.io.IOException { s.defaultWriteObject(); // 写入数组长度 s.writeInt(size()); // 写入从 head 指针到 tail 指针之间的有效元素 int mask = elements.length - 1; for (int i = head; i != tail; i = (i + 1) & mask) s.writeObject(elements[i]); } // 反序列化过程 private void readObject(java.io.ObjectInputStream s) throws java.io.IOException, ClassNotFoundException { s.defaultReadObject(); // 读取数组长度 int size = s.readInt(); // 计算最近的 2 的整数幂 int capacity = calculateSize(size); // 分配数组 SharedSecrets.getJavaOISAccess().checkArray(s, Object[].class, capacity); allocateElements(size); // 设置头尾指针 head = 0; tail = size; // 将数据规整到数组下标 0 位置 for (int i = 0; i < size; i++) elements[i] = s.readObject(); }
4.7 ArrayDeque 的 clone() 过程
ArrayDeque 中的 elements 数组是引用类型,因此在 clone() 中需要实现深拷贝,否则原对象与克隆对象会相互影响:
public ArrayDeque<E> clone() { try { @SuppressWarnings("unchecked") ArrayDeque<E> result = (ArrayDeque<E>) super.clone(); result.elements = Arrays.copyOf(elements, elements.length); return result; } catch (CloneNotSupportedException e) { throw new AssertionError(); } }
5. 总结
-
1、ArrayDeque 是基于动态数组的线性表,具备 Queue 和 Stack 的行为,但不具备 List 的行为;
-
2、ArrayDeque 的数组容量是 2 的整数幂,在扩容时容量会翻倍,且不支持 null 元素;
-
3、ArrayDeque 和 LinkedList 的栈和队列行为都是 O(1) 时间复杂度,ArrayDeque 的入栈和入队有可能会触发扩容,但从均摊分析上看依然是 O(1) 时间复杂度;
-
4、ArrayDeque 是一块连续内存空间,基于局部性原理能够更好地命中 CPU 缓存行,而 LinkedList 是离散的内存空间对缓存行不友好;
-
5、ArrayDeque 和 LinkedList 都不考虑线程同步,不保证线程安全。