栈、队列、双端队列都是非常经典的数据结构。和链表、数组不同,这三种数据结构的抽象层次更高。它只描述了数据结构有哪些行为,而并不关心数据结构内部用何种思路、方式去组织。
本篇博文重点关注这三种数据结构在java中的对应设计,并且对ArrayDeque的源码进行分析。
1. 概念
先来简单回顾下大学时的数据结构知识。
- 什么是栈?数据排成一个有序的序列,只能从一个口弹出数据或加入数据。即后进先出(LIFO)。
- 什么是队列?数据同样排成一个有序的序列,数据只能在队尾加入,在队头弹出。即先进先出(FIFO)。
- 什么是双端队列?数据同样排成一个有序的序列,只能从前后两个口插入或删除数据。结合了栈和队列的特点。
这三样东西都可以通过数组或链表来实现。从这种表述就能发现,似乎链表和数组比这三个更“偏底层”。
仔细思考不难发现,栈、队列、双端队列仅仅是描述了接口行为,是一种抽象数据类型;而数组、链表则描述的是数据的具体在内存中的组织方式。
2. java中栈、队列、双端队列
2.1. java中的栈
|
|
java的确有一个叫做Stack
的类,它继承自Vector
。
个人以为,jdk的这种设计不是很妥当。前面分析过,Stack从概念上是一种抽象数据类型,可以有多种实现方式。因此,将其设计为接口更为合适。
jdk的这种设计导致:
- Stack只有数组这一种实现方式,没有办法改用其它的实现方式。
- Stack继承自Vector,耦合太紧,同时拥有Vector的大量不属于Stack模型的方法,破坏隐藏。
此外,Vector本身现在已经不建议使用了。
而且,jdk自己也说了,Stack这个类,设计的不好,不推荐使用:
好在Deque像是栈和队列的组合,也能当栈使用。因此,在java中,有栈的使用需求时,使用Deque代替。
而且,偶然间在jdk中看到这样一个工具函数Collections.asLifoQueue
:
它将Deque包装成一个Lifo的队列。LIFO?那不就是栈么!也就是说,得到的虽然是Queue接口,但是行为是LIFO。
2.2. java中的队列
|
|
jdk中队列的设计没有什么问题,是一个接口。
虽然名字叫Queue,但是这个jdk中Queue接口指代的范围更广。从它的子接口及实现类来看,有这样几种含义:
- FIFO队列。也就是数据结构中的先进先出队列。
- 优先队列。也就是数据结构中的大顶堆或小顶堆。
- 阻塞队列。也是队列,只不过某些方法在没有元素时或队满时会阻塞,并发中使用的一种结构。
再来看它的几种实现:
- FIFO队列。FIFO队列的实现其实是按照Deque实现的了,有LinkedList和ArrayDeque。
- 优先队列。PriorityQueue。
- 阻塞队列。这个和并发关系更大,这里先不谈。
2.3. java中的双端队列
双端队列的定义也是接口:
Deque也是Queue,Deque也能当Queue用,没有太多额外开销。所以jdk没有单独实现Queue。
Deque有两种实现类:
- LinkedList。也就是链表,java的链表同时实现了Deque。
- ArrayDeque。Deque的数组实现。为什么不在ArrayList中一把实现Deque接口?
也很简单,实现方式不同。
Deque也有阻塞队列版本的实现,这里也先不谈。
3. ArrayDeque源码分析
3.1. 实现思路
我先来总结下ArrayDeque的实现思路。
首先,ArrayDeque内部是拥有一个内部数组用于存储数据。
其次,假设采用简单的方案,即队列数组按顺序在数组里排开,那么:
- 由于ArrayDeque的两端都能增删数据,那么把数据插入到队列头部也就是数组头部,会造成O(N)的时间复杂度。
- 假设只再队尾加入而只从队头删除,队头就会空出越来越多的空间。
那么该怎么实现?也很简单。将物理上的连续数组回绕,形成逻辑上的一个 环形结构。即a[size - 1]的下一个位置是a[0].
之后,使用头尾指针标识队列头尾,在队列头尾增删元素,反映在头尾指针上就是这两个指针绕着环赛跑。
这个是大体思路,具体的还有一些细节,后面代码里分析:
- head和tail的具体概念是如何界定?
- 如果判断队满和队空?
- 数组满了怎么办?
3.2. 属性
先来看内部属性。elements域就是存储数据的原生数组。
head和tail分别分别为头尾指针。
3.3. 构造函数
|
|
- 如果没有指定内部数组的初始大小,默认为16.
- 如果指定了内部数组的初始大小,则通过
calculateSize
函数二次计算出大小。
来看calculateSize
函数:
- 如果小于8,那么大小就为8.
- 如果大于等于8,则按照2的幂对齐。
3.4. 入队
看两个入队方法:
addFirst
是从队头插入,addLast
是从队尾插入。
从该代码能够分析出head和tail指针的含义:
- head指针指向的是队头元素的位置,除非队列为空。
- tail指针指向的是队尾元素后一格的位置,即尾后指针。
因此:
- 如果队列没有满,tail指向的是空位置,head指向的是队头元素,永远不可能一样。
- 但是当队列满时,tail回绕会追上head,当tail等于head时,表示队列满了。
理清楚了这一点,上面的代码也就十分容易理解了:
- 对应位置插入位置,移动指针。
- 当tail和head相等时,扩容。
最后,这句:
曾经在《源码|jdk源码之HashMap分析(二)》中分析过,假如被余数是2的幂次方,那么模运算就能够优化成按位与运算。
也即相当于:
3.5. 出队
|
|
出队的代码很显然,不多解释。
3.6. 扩容
|
|
扩容的实现为按 两倍 扩容原数组,将原数倍拷贝过去。
其中值得注意的是对数组大小溢出的处理。
3.7. 迭代器
之前《源码|jdk源码之LinkedList与modCount字段》中分析过,
容器的实现中,所有修改过容器结构的操作都需要修改modCount
字段。
这样迭代器迭代过程中,通过前后比对该字段来判断容器是否被动过,及时抛出异常终止迭代以免造成不可预测的问题。
不过,在ArrayDeque的插入方法中并没有修改modeCount字段。从ArrayDeque的迭代器的实现中可以看出来:
原来,ArrayDeque直接使用了head和tail头尾指针,就能判断出迭代过程中是否发生了变化。