Java 集合框架:Java 中的双端队列 ArrayDeque 的实现

CSDN 2024-07-25 11:05:03 阅读 61

大家好,我是栗筝i,这篇文章是我的 “栗筝i 的 Java 技术栈” 专栏的第 019 篇文章,在 “栗筝i 的 Java 技术栈” 这个专栏中我会持续为大家更新 Java 技术相关全套技术栈内容。专栏的主要目标是已经有一定 Java 开发经验,并希望进一步完善自己对整个 Java 技术体系来充实自己的技术栈的同学。与此同时,本专栏的所有文章,也都会准备充足的代码示例和完善的知识点梳理,因此也十分适合零基础的小白和要准备工作面试的同学学习。当然,我也会在必要的时候进行相关技术深度的技术解读,相信即使是拥有多年 Java 开发经验的从业者和大佬们也会有所收获并找到乐趣。

在 Java 编程中,集合框架提供了一系列强大的数据结构来处理各种常见的数据存储和操作需求。其中,双端队列(Deque, Double-ended Queue)是一种灵活的数据结构,它允许在队列的两端进行元素的插入和移除。<code>ArrayDeque 是 Java 集合框架中的一个重要实现,提供了高效的双端操作功能,兼具队列和栈的特性。

ArrayDeque 通过循环数组来实现队列操作,这种设计使得它在执行插入和删除操作时具有卓越的性能。与传统的 LinkedList 相比,ArrayDeque 在内存使用和性能上具有显著优势,尤其是在频繁进行头部和尾部操作时。它避免了 LinkedList 中节点的频繁分配和回收,并且通过数组的循环使用来最大限度地减少了空间浪费。

本文将详细介绍 ArrayDeque 的实现细节,包括其内部数据结构、核心方法的工作原理以及性能优化策略。我们将探讨 ArrayDeque 如何高效地支持双端操作,以及在实际开发中如何利用这一数据结构来优化应用程序的性能和资源使用。通过对 ArrayDeque 的深入分析,读者将能够更好地理解双端队列的运作机制,并在实际项目中充分利用这一强大的数据结构。


文章目录

1、ArrayDeque 概述1.1、ArrayDeque 介绍1.2、ArrayDeque 特点1.3、ArrayDeque 用法

2、ArrayDeque 底层实现2.1、ArrayDeque 数据结构2.2、插入操作2.3、推出操作

3、ArrayDeque 的使用3.1、ArrayDeque 使用示例3.2、ArrayDeque 主要方法


1、ArrayDeque 概述

1.1、ArrayDeque 介绍

Deque 接口表示一个双端队列(Double Ended Queue),允许在队列的首尾两端操作,所以既能实现队列行为,也能实现栈行为。Deque 常用的两种实现 ArrayDeque 和 LinkedList。

ArrayDeque 是 Java 集合中双端队列的数组实现,可以从两端进行插入或删除操作,当需要使用栈时,Java 已不推荐使用 Stack,而是推荐使用更高效的ArrayDeque,当需要使用队列时也可以使用 ArrayDeque。

1.2、ArrayDeque 特点

ArrayDeque 有着如下的主要特性:

双端队列: ArrayDeque 允许在两端(头部和尾部)进行高效的插入和删除操作;可变大小数组: ArrayDeque 使用一个可变大小的数组来存储元素,当数组满了时,会自动扩展其容量;无容量限制: 与 ArrayList 类似,ArrayDeque 没有固定的容量限制,可以根据需要动态扩展;高效操作: 在头部和尾部进行的插入和删除操作通常比 LinkedList 更高效,因为它没有涉及节点的额外开销。

1.3、ArrayDeque 用法

ArrayDeque 典型用法如下:

作为栈 (Stack): 可以使用 ArrayDeque 作为栈来使用,因为它提供了 pushpop 方法;作为队列 (Queue): 也可以将 ArrayDeque 用作队列,因为它提供了 offerpoll 等方法。

例子:

import java.util.ArrayDeque;

import java.util.Deque;

public class ArrayDequeExample {

public static void main(String[] args) {

// 创建一个 ArrayDeque 实例

Deque<String> deque = new ArrayDeque<>();

// 添加元素

deque.add("Element 1");

deque.addFirst("Element 2");

deque.addLast("Element 3");

// 访问元素

System.out.println("Head: " + deque.peek());

System.out.println("Tail: " + deque.peekLast());

// 删除元素

System.out.println("Removed Head: " + deque.removeFirst());

System.out.println("Removed Tail: " + deque.removeLast());

// 栈操作

deque.push("Stack Element 1");

deque.push("Stack Element 2");

System.out.println("Popped from stack: " + deque.pop());

}

}


2、ArrayDeque 底层实现

2.1、ArrayDeque 数据结构

ArrayDeque 是 Java 集合框架中双端队列 (Deque) 的一种实现,基于可变大小的数组来存储元素。其数据结构设计使得在队列两端进行插入和删除操作非常高效:

基础数组:ArrayDeque 内部使用一个数组来存储元素。这个数组是可变大小的,默认容量为 16。当元素数量超过当前容量时,数组会自动扩展;头部和尾部指针: ArrayDeque 通过两个指针来追踪队列的头部和尾部。这两个指针分别称为 headtail。初始情况下,headtail 都指向数组的第一个位置。

public class ArrayDeque<E> extends AbstractCollection<E>

implements Deque<E>, Cloneable, Serializable

{

/**

* 用于存储双端队列元素的数组。

* 双端队列的容量是该数组的长度,这个长度总是2的幂。

* 除了在某个addX方法内瞬时变满之外,这个数组永远不会变满,

* 一旦变满(参见doubleCapacity方法),就会立即调整大小,从而避免

* 头指针和尾指针绕回并相等。我们还保证所有不持有双端队列元素的数组单元总是为null。

*/

transient Object[] elements; // 非private以简化嵌套类访问

/**

* 双端队列头部元素的索引(即remove()或pop()将要移除的元素的索引);

* 如果双端队列为空,则为等于tail的任意数。

*/

transient int head;

/**

* 下一个元素将被添加到双端队列尾部的索引(通过addLast(E)、add(E)或push(E))。

*/

transient int tail;

/**

* 用于新创建的双端队列的最小容量。

* 必须是2的幂。

*/

private static final int MIN_INITIAL_CAPACITY = 8;

public ArrayDeque() {

elements = new Object[16];

}

public ArrayDeque(int numElements) {

// 调用 allocateElements 方法分配数组空间

// 传入的 numElements 参数决定了初始容量

allocateElements(numElements);

}

public ArrayDeque(Collection<? extends E> c) {

// 调用 allocateElements 方法分配数组空间

// 传入集合 c 的大小决定了初始容量

allocateElements(c.size());

// 将集合 c 中的所有元素添加到当前双端队列中

addAll(c);

}

private static int calculateSize(int numElements) {

// 初始容量设置为最小初始容量

int initialCapacity = MIN_INITIAL_CAPACITY;

// 如果 numElements 大于或等于初始容量,则计算需要的容量

if (numElements >= initialCapacity) {

// 将 initialCapacity 设置为 numElements 的值

initialCapacity = numElements;

// 通过位运算将容量扩展到大于或等于 numElements 的最小2的幂次方

initialCapacity |= (initialCapacity >>> 1);

initialCapacity |= (initialCapacity >>> 2);

initialCapacity |= (initialCapacity >>> 4);

initialCapacity |= (initialCapacity >>> 8);

initialCapacity |= (initialCapacity >>> 16);

// 将容量扩大到下一个2的幂次方

initialCapacity++;

// 如果计算出的容量小于0,说明容量过大,避免超出数组的最大容量

if (initialCapacity < 0)

// 将容量减半,以避免分配过大的内存

initialCapacity >>>= 1;

}

// 返回计算得到的容量

return initialCapacity;

}

private void allocateElements(int numElements) {

// 根据 calculateSize 方法计算的容量来创建数组

elements = new Object[calculateSize(numElements)];

}

// 省略其他方法和实现细节

...

}

详细解读:

elements:这个数组用于存储 ArrayDeque 中的所有元素。数组的长度始终是 2 的幂,确保了高效的取模操作(通过位运算实现)。除了在某些方法中瞬时变满之外,数组永远不会变满。当数组变满时,ArrayDeque 会立即调用 doubleCapacity 方法来扩展容量,以避免头尾指针相等的情况;head:这是一个指向双端队列头部的索引,即最先添加的元素的索引。remove()pop() 方法将会移除该索引处的元素。当双端队列为空时,这个索引将等于 tailtail:这是一个指向双端队列尾部的索引,即下一个元素将要添加到的索引。addLast(E)add(E)push(E) 方法会在这个索引处添加元素;MIN_INITIAL_CAPACITY:这是 ArrayDeque 初始容量的最小值,必须是2的幂。这个值定义了新创建的 ArrayDeque 的初始容量,确保在容量扩展前有足够的空间来存储元素;默认构造器会将 elements 初始化为一个长度为 16 的数组,另外两个构造器将根据参数的长度来初始化,最终会调用calculateSize方法计算初始长度。

这里需要注意一点,用 elements 存储的是双端队列,headtail 参数表示双端队列的头和尾的索引,但并不意味着 head 值永远小于 tail,当 tail 移动至数组末尾,但队列长度小于数组时(意味着此时 head大 于0),再向队列末尾添加数据将会使 tail 移动至数组的开头。

假设下图为当前的 elements 数组,黄色方块表示其中有数据:

img

当我们向队列末尾添加一个元素时,数组变成了下面这样:

img

目前都是按照我们预期发展的,现在我们来调用<code>poll方法移除队列头部的一个元素,此时elements变成了下图:

img

这个时候因为我们移除了队列的头部元素,数组的开头已经空下来了一个位置,这时候再向队列末尾追加一个元素。

img

可以看到,此时 <code>head 在 tail 的右侧,ArrayDeque 为了不浪费数组空间进行了这样的设计,也不难理解。

2.2、插入操作

addFirst(E e)addLast(E e) 两个方法分别在头部和尾部添加元素,并在必要时调用 doubleCapacity() 方法扩展容量。

public class ArrayDeque<E> extends AbstractCollection<E>

implements Deque<E>, Cloneable, Serializable

{

// 省略其他方法和实现细节

...

/**

* 将指定的元素插入到此双端队列的前面。

*

* @param e 要添加的元素

* @throws NullPointerException 如果指定的元素为 null

*/

public void addFirst(E e) {

if (e == null)

throw new NullPointerException();

// 计算新的 head 索引,并将元素 e 插入到新的 head 位置

elements[head = (head - 1) & (elements.length - 1)] = e;

// 如果 head 与 tail 相等,表示数组已满,调用 doubleCapacity 方法扩展容量

if (head == tail)

doubleCapacity();

}

/**

* 将指定的元素插入到此双端队列的末尾。

*

* <p>此方法等效于 {@link #add}.

*

* @param e 要添加的元素

* @throws NullPointerException 如果指定的元素为 null

*/

public void addLast(E e) {

if (e == null)

throw new NullPointerException();

// 将元素 e 插入到 tail 位置

elements[tail] = e;

// 计算新的 tail 索引,并进行循环数组操作

if ((tail = (tail + 1) & (elements.length - 1)) == head)

doubleCapacity();

}

/**

* 扩展此双端队列的容量。仅在满时调用,即当 head 和 tail

* 环绕相等时。

*/

private void doubleCapacity() {

assert head == tail;

int p = head;

int n = elements.length;

int r = n - p; // p右侧的元素个数

int newCapacity = n << 1; // 新容量为当前容量的两倍

if (newCapacity < 0)

throw new IllegalStateException("对不起,双端队列太大了");

Object[] a = new Object[newCapacity];

// 将元素从原数组复制到新数组

System.arraycopy(elements, p, a, 0, r);

System.arraycopy(elements, 0, a, r, p);

elements = a;

// 更新 head 和 tail 指针

head = 0;

tail = n;

}

// 省略其他方法和实现细节

...

}

2.3、推出操作

ArrayDequepollremove 相关方法通过循环数组实现高效的双端操作。pollFirst()pollLast() 方法尝试从队列的前端或末尾移除并返回元素,如果队列为空则返回 null;而 removeFirst()removeLast() 方法在移除元素时会检查队列是否为空,若为空则抛出 NoSuchElementException 异常。这些方法通过更新数组的 headtail 指针来维护队列状态,并在移除元素后将相应的数组槽置为空,以便进行垃圾回收。

public class ArrayDeque<E> extends AbstractCollection<E>

implements Deque<E>, Cloneable, Serializable

{

// 省略其他方法和实现细节

...

/**

* 从队列的前面移除并返回第一个元素。

*

* @throws NoSuchElementException 如果队列为空

*/

public E removeFirst() {

// 调用 pollFirst 方法尝试移除并返回第一个元素

E x = pollFirst();

// 如果 pollFirst 返回 null,则抛出 NoSuchElementException 异常

if (x == null)

throw new NoSuchElementException();

return x;

}

/**

* 从队列的末尾移除并返回最后一个元素。

*

* @throws NoSuchElementException 如果队列为空

*/

public E removeLast() {

// 调用 pollLast 方法尝试移除并返回最后一个元素

E x = pollLast();

// 如果 pollLast 返回 null,则抛出 NoSuchElementException 异常

if (x == null)

throw new NoSuchElementException();

return x;

}

public E pollFirst() {

int h = head;

@SuppressWarnings("unchecked")

// 获取并强制转换头部的元素

E result = (E) elements[h];

// 如果头部的元素为 null,表示队列为空,直接返回 null

if (result == null)

return null;

// 置空头部的元素槽

elements[h] = null;

// 更新 head 索引,移到下一个位置

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];

// 如果尾部的元素为 null,表示队列为空,直接返回 null

if (result == null)

return null;

// 置空尾部的元素槽

elements[t] = null;

// 更新 tail 索引,移到前一个位置

tail = t;

return result;

}

// 省略其他方法和实现细节

...

}


3、ArrayDeque 的使用

3.1、ArrayDeque 使用示例

ArrayDeque 使用循环数组来存储元素,当在数组头部添加元素时,如果当前头部指针在数组的起始位置(索引为 0),新元素将被插入到数组的最后一位。这是通过循环数组的特性实现的,确保在队列的两端进行操作时能够高效利用数组的空间。

假设我们有一个初始的 ArrayDeque,数组的初始状态如下:

[ 1, null, null, null, null, null, null, null ] // head = 0, tail = 1

数组索引 0 位置有元素 1,head 指向 0,tail 指向 1。

现在,如果我们在队列前面添加一个元素 2,那么新元素将被插入到数组的最后一位(索引7),并且 head 指针将会更新:

deque.addFirst(2);

此时,数组和指针的状态如下:

[ 1, null, null, null, null, null, null, 2 ] // head = 7, tail = 1

是的,ArrayDeque 使用循环数组来存储元素,当在数组头部添加元素时,如果当前头部指针在数组的起始位置(索引为 0),新元素将被插入到数组的最后一位。这是通过循环数组的特性实现的,确保在队列的两端进行操作时能够高效利用数组的空间。

现在,如果我们在队列前面添加一个元素2,那么新元素将被插入到数组的最后一位(索引7),并且 head 指针将会更新,下面是一个具体的代码示例:

import java.util.ArrayDeque;

public class ArrayDequeExample {

public static void main(String[] args) {

ArrayDeque<Integer> deque = new ArrayDeque<>(8);

// 初始化添加一个元素

deque.add(1);

// 打印初始状态

System.out.println("Initial deque: " + deque);

// 在队列前面添加一个元素

deque.addFirst(2);

// 打印添加后的状态

System.out.println("After adding to the front: " + deque);

}

}

输出结果:

Initial deque: [1]

After adding to the front: [2, 1]

3.2、ArrayDeque 主要方法

ArrayDeque 主要方法:

add(E e): 将元素添加到队列尾部。addFirst(E e): 将元素添加到队列头部。addLast(E e): 将元素添加到队列尾部。offer(E e): 尝试将元素添加到队列尾部。offerFirst(E e): 尝试将元素添加到队列头部。offerLast(E e): 尝试将元素添加到队列尾部。remove(): 移除并返回队列头部的元素。removeFirst(): 移除并返回队列头部的元素。removeLast(): 移除并返回队列尾部的元素。poll(): 检索并移除队列头部的元素,如果队列为空,则返回 nullpollFirst(): 检索并移除队列头部的元素,如果队列为空,则返回 nullpollLast(): 检索并移除队列尾部的元素,如果队列为空,则返回 nullpeek(): 检索但不移除队列头部的元素,如果队列为空,则返回 nullpeekFirst(): 检索但不移除队列头部的元素,如果队列为空,则返回 nullpeekLast(): 检索但不移除队列尾部的元素,如果队列为空,则返回 nullpush(E e): 将元素推送到栈顶。pop(): 从栈顶弹出元素并返回。

ArrayDeque 是一个非常灵活且高效的双端队列实现,在需要频繁操作队列两端的场景下,ArrayDeque 是一个很好的选择。



声明

本文内容仅代表作者观点,或转载于其他网站,本站不以此文作为商业用途
如有涉及侵权,请联系本站进行删除
转载本站原创文章,请注明来源及作者。