java 头尾 队列_源码|jdk源码之栈、队列及ArrayDeque分析

栈、队列、双端队列都是非常经典的数据结构。和链表、数组不同,这三种数据结构的抽象层次更高。它只描述了数据结构有哪些行为,而并不关心数据结构内部用何种思路、方式去组织。

本篇博文重点关注这三种数据结构在java中的对应设计,并且对ArrayDeque的源码进行分析。

概念

先来简单回顾下大学时的数据结构知识。

什么是栈?数据排成一个有序的序列,只能从一个口弹出数据或加入数据。即后进先出(LIFO)。

什么是队列?数据同样排成一个有序的序列,数据只能在队尾加入,在队头弹出。即先进先出(FIFO)。

什么是双端队列?数据同样排成一个有序的序列,只能从前后两个口插入或删除数据。结合了栈和队列的特点。

这三样东西都可以通过数组或链表来实现。从这种表述就能发现,似乎链表和数组比这三个更“偏底层”。

仔细思考不难发现,栈、队列、双端队列仅仅是描述了接口行为,是一种抽象数据类型;而数组、链表则描述的是数据的具体在内存中的组织方式。

java中栈、队列、双端队列

java中的栈

public

class Stack extends Vector {

/* */

}

java的确有一个叫做Stack的类,它继承自Vector。

个人以为,jdk的这种设计不是很妥当。前面分析过,Stack从概念上是一种抽象数据类型,可以有多种实现方式。因此,将其设计为接口更为合适。

jdk的这种设计导致:

Stack只有数组这一种实现方式,没有办法改用其它的实现方式。

Stack继承自Vector,耦合太紧,同时拥有Vector的大量不属于Stack模型的方法,破坏隐藏。

此外,Vector本身现在已经不建议使用了。

而且,jdk自己也说了,Stack这个类,设计的不好,不推荐使用:

*

A more complete and consistent set of LIFO stack operations is

* provided by the {@link Deque} interface and its implementations, which

* should be used in preference to this class. For example:

* Deque stack = new ArrayDeque();}

好在Deque像是栈和队列的组合,也能当栈使用。因此,在java中,有栈的使用需求时,使用Deque代替。

而且,偶然间在jdk中看到这样一个工具函数Collections.asLifoQueue:

public static Queue asLifoQueue(Deque deque) {

return new AsLIFOQueue<>(deque);

}

它将Deque包装成一个Lifo的队列。LIFO?那不就是栈么!也就是说,得到的虽然是Queue接口,但是行为是LIFO。

java中的队列

public interface Queue extends Collection {

/* ... */

}

jdk中队列的设计没有什么问题,是一个接口。

虽然名字叫Queue,但是这个jdk中Queue接口指代的范围更广。从它的子接口及实现类来看,有这样几种含义:

FIFO队列。也就是数据结构中的先进先出队列。

优先队列。也就是数据结构中的大顶堆或小顶堆。

阻塞队列。也是队列,只不过某些方法在没有元素时或队满时会阻塞,并发中使用的一种结构。

再来看它的几种实现:

FIFO队列。FIFO队列的实现其实是按照Deque实现的了,有LinkedList和ArrayDeque。

优先队列。PriorityQueue。

阻塞队列。这个和并发关系更大,这里先不谈。

java中的双端队列

双端队列的定义也是接口:

public interface Deque extends Queue {

/* ... */

}

Deque也是Queue,Deque也能当Queue用,没有太多额外开销。所以jdk没有单独实现Queue。

Deque有两种实现类:

LinkedList。也就是链表,java的链表同时实现了Deque。

ArrayDeque。Deque的数组实现。为什么不在ArrayList中一把实现Deque接口?

也很简单,实现方式不同。

Deque也有阻塞队列版本的实现,这里也先不谈。

ArrayDeque源码分析

实现思路

我先来总结下ArrayDeque的实现思路。

首先,ArrayDeque内部是拥有一个内部数组用于存储数据。

其次,假设采用简单的方案,即队列数组按顺序在数组里排开,那么:

由于ArrayDeque的两端都能增删数据,那么把数据插入到队列头部也就是数组头部,会造成O(N)的时间复杂度。

假设只再队尾加入而只从队头删除,队头就会空出越来越多的空间。

那么该怎么实现?也很简单。将物理上的连续数组回绕,形成逻辑上的一个 环形结构。即a[size - 1]的下一个位置是a[0].

之后,使用头尾指针标识队列头尾,在队列头尾增删元素,反映在头尾指针上就是这两个指针绕着环赛跑。

这个是大体思路,具体的还有一些细节,后面代码里分析:

head和tail的具体概念是如何界定?

如果判断队满和队空?

数组满了怎么办?

属性

先来看内部属性。elements域就是存储数据的原生数组。

head和tail分别分别为头尾指针。

transient Object[] elements; // non-private to simplify nested class access

transient int head;

transient int tail;

构造函数

public ArrayDeque() {

elements = new Object[16];

}

public ArrayDeque(int numElements) {

allocateElements(numElements);

}

private void allocateElements(int numElements) {

elements = new Object[calculateSize(numElements)];

}

如果没有指定内部数组的初始大小,默认为16.

如果指定了内部数组的初始大小,则通过calculateSize函数二次计算出大小。

来看calculateSize函数:

private static final int MIN_INITIAL_CAPACITY = 8;

private static int calculateSize(int numElements) {

int initialCapacity = MIN_INITIAL_CAPACITY;

// Find the best power of two to hold elements.

// Tests "<=" because arrays aren't kept full.

if (numElements >= initialCapacity) {

initialCapacity = numElements;

initialCapacity |= (initialCapacity >>> 1);

initialCapacity |= (initialCapacity >>> 2);

initialCapacity |= (initialCapacity >>> 4);

initialCapacity |= (initialCapacity >>> 8);

initialCapacity |= (initialCapacity >>> 16);

initialCapacity++;

if (initialCapacity < 0) // Too many elements, must back off

initialCapacity >>>= 1;// Good luck allocating 2 ^ 30 elements

}

return initialCapacity;

}

如果小于8,那么大小就为8.

如果大于等于8,则按照2的幂对齐。

入队

看两个入队方法:

public void addFirst(E e) {

if (e == null)

throw new NullPointerException();

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

if (head == tail)

doubleCapacity();

}

public void addLast(E e) {

if (e == null)

throw new NullPointerException();

elements[tail] = e;

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

doubleCapacity();

}

addFirst是从队头插入,addLast是从队尾插入。

从该代码能够分析出head和tail指针的含义:

head指针指向的是队头元素的位置,除非队列为空。

tail指针指向的是队尾元素后一格的位置,即尾后指针。

因此:

如果队列没有满,tail指向的是空位置,head指向的是队头元素,永远不可能一样。

但是当队列满时,tail回绕会追上head,当tail等于head时,表示队列满了。

理清楚了这一点,上面的代码也就十分容易理解了:

对应位置插入位置,移动指针。

当tail和head相等时,扩容。

最后,这句:

(head - 1) & (elements.length - 1)

曾经在《源码|jdk源码之HashMap分析(二)》中分析过,假如被余数是2的幂次方,那么模运算就能够优化成按位与运算。

也即相当于:

(head - 1) % elements.length

出队

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;

}

出队的代码很显然,不多解释。

扩容

private void doubleCapacity() {

assert head == tail;

int p = head;

int n = elements.length;

int r = n - p; // number of elements to the right of p

int newCapacity = n << 1;

// 扩容后的大小小于0(溢出),也即队列最大应该是2的30次方

if (newCapacity < 0)

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;

}

扩容的实现为按 两倍 扩容原数组,将原数倍拷贝过去。

其中值得注意的是对数组大小溢出的处理。

迭代器

之前《源码|jdk源码之LinkedList与modCount字段》中分析过,

容器的实现中,所有修改过容器结构的操作都需要修改modCount字段。

这样迭代器迭代过程中,通过前后比对该字段来判断容器是否被动过,及时抛出异常终止迭代以免造成不可预测的问题。

不过,在ArrayDeque的插入方法中并没有修改modeCount字段。从ArrayDeque的迭代器的实现中可以看出来:

private class DeqIterator implements Iterator {

/**

* Index of element to be returned by subsequent call to next.

*/

private int cursor = head;

/**

* Tail recorded at construction (also in remove), to stop

* iterator and also to check for comodification.

*/

private int fence = tail;

}

原来,ArrayDeque直接使用了head和tail头尾指针,就能判断出迭代过程中是否发生了变化。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值