双端队列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()
  值得一提的是,网上很多文章都说 addFirst(e) 和 offerFirst(e) 拒绝策略不同,失败时 addFirst(e) 会抛异常,而 offerFirst(e) 会返回false,很好奇为什么很多人这么说???看一眼源码就知道了啊,其实二者本质上没什么区别。
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 都不考虑线程同步,不保证线程安全。

posted @ 2024-03-29 17:57  jingyi_up  阅读(632)  评论(0编辑  收藏  举报