《Java版数据结构 & 集合类剖析》栈与队列:栈与队列:"push/pop 是栈的灵魂,offer/poll 是队列的骨架------四组 API,两种人生"
学完顺序表和链表,再碰栈和队列,会有一种奇妙的"既视感"------它们的底层不过是数组或链表 的简单封装 ,连一行新算法都没发明。但恰恰是这一层规则的约束,让一个普普通通的线性表,摇身一变成了能解决特定问题的专用工具。顺序表和链表提供的是无限可能,而栈和队列做的,是在可能之上建立秩序 。这种从"自由"到"规矩"的转变,正是数据结构设计的核心思想:不是改变结构,而是约束行为。
1、栈
1.1、概念
栈:一种特殊的线性表 ,其只允许在固定的一端进行 插入和删除元素操作 。进行 数据插入和删除操作的一端称为栈顶 ,另一端成为栈底 。栈中的元素遵循后进先出的原则。
在栈中的操作术语为压栈 和出栈:
- 压栈: 栈的插入操作叫做进栈/压栈/入栈,入数据在栈顶。
- 出栈: 栈的删除操作叫做出栈,出数据在栈顶。
1.2、Stack的封装
虽然在Java的官方文档明确说明不推荐使用Stack来使用栈,但是我们也在学习阶段也需要了解它的实现与构成
- 由上图可以看到,Stack直接继承于Vector,而Vector的继承和实现的类和ArrayList的情况一模一样,这里就不在赘述。
1.3、Vector的源码分析
Vector 的实现逻辑和ArrayList的实现是特别像的,最大的区别就是Vector是线程安全的,因为它的许多方法都被synchronized所修饰
java
//存放元素的数组
protected Object[] elementData;
//有效元素数量,小于等于数组长度
protected int elementCount;
//容量增加量,和扩容相关
protected int capacityIncrement;
private void grow(int minCapacity) {
// overflow-conscious code
int oldCapacity = elementData.length;
//扩容大小
int newCapacity = oldCapacity + ((capacityIncrement > 0) ?
capacityIncrement : oldCapacity);
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
elementData = Arrays.copyOf(elementData, newCapacity);
}
public synchronized E remove(int index) {
modCount++;
if (index >= elementCount)
throw new ArrayIndexOutOfBoundsException(index);
E oldValue = elementData(index);
int numMoved = elementCount - index - 1;
if (numMoved > 0)
//复制数组,假设数组移除了中间某元素,后边有效值前移1位
System.arraycopy(elementData, index+1, elementData, index,
numMoved);
//引用null ,gc会处理
elementData[--elementCount] = null; // Let gc do its work
return oldValue;
}
2、Stack
2.1、Stack的使用
Stack 类常用方法
| 方法 | 功能 |
|---|---|
Stack() |
构造一个空的栈 |
E push(E e) |
将 e 入栈,并返回 e |
E pop() |
将栈顶元素出栈并返回 |
E peek() |
获取栈顶元素 |
int size() |
获取栈中有效元素个数 |
boolean empty() |
检测栈是否为空 |
这里的方法特别简单,这里就不进行使用演示了。
2.2、 Stack的模拟实现
在java集合中的Stack的具体实现其实很多都只是调用了vector的方法,但是这里我要模拟实现的要包括方法的具体实现,所以实际上是Stack+ Vector的内容
java
public class MyStack<T> {
public Object[] elem;
private int usedSize = 0;
static final int DEFAULT_CAPACITY = 10;
public MyStack() {
this.elem = new Object[DEFAULT_CAPACITY];
}
public MyStack(int initial){
this.elem = new Object[initial];
}
public void push(T val){
if(isFull()){
grow();
}
elem[usedSize] = val;
usedSize++;
}
public int size(){
return this.usedSize;
}
private boolean isFull(){
return usedSize == elem.length;
}
private void grow(){
int oldCapacity = elem.length;
int newCapacity = oldCapacity == 0 ? DEFAULT_CAPACITY:oldCapacity * 2;
elem = Arrays.copyOf(elem,newCapacity);
}
public T pop(){
if(isEmpty()){
throw new RuntimeException();
}
usedSize--;
return (T) elem[usedSize - 1];
}
public T peek(){
if(isEmpty()){
throw new RuntimeException();
}
return (T) elem[usedSize - 1];
}
public boolean isEmpty(){
return usedSize == 0;
}
}
3、栈的应用场景
这里不对其进行具体讲解,有时间我会对相关的面试题和算法题开个专题
3.1、改变元素的序列
在栈中的元素,因为有进栈和出栈的规则限制,其依次进栈和出栈的顺序组合也有其限制,具体到题目如下:
1. 若进栈序列为 1,2,3,4 ,进栈过程中可以出栈,则下列不可能的一个出栈序列是() A: 1,4,3,2 B: 2,3,4,1 C: 3,1,4,2 D: 3,4,2,1
分析: 这种题最简单直接的做法只需要画图或脑子里按选项的顺序来出栈就可以快速判断选C
3.2、将递归转化为循环
本身方法所在的内存空间就处在jvm的虚拟机栈内,其本质也是栈这个数据结构的特性,所以当我们用循环 来模拟方法递归 时,自然而然就要用到栈这个数据结构
具体如何来模拟实现,不在这里进行说明
4、队列
4.1、队列的概念
只允许在一端进行插入数据操作,在另一端进行删除数据操作的特殊线性表,队列具有先进先出FIFO(First In First Out) 入队列:进行插入操作的一端称为队尾(Tail/Rear) 出队列:进行删除操作的一端称为队头

4.2、Queue的封装
Queue 在这里作为接口直接继承于Collection接口

4.3、Queue的源码剖析
java
public interface Queue<E> extends Collection<E> {
boolean add(E e);
boolean offer(E e);
E remove();
E poll();
E element();
E peek();
}
- 可以看到,增、删、取头部元素的方法有两种,其具体的区别如下:
| 操作 | 失败时抛异常 | 失败时返回特殊值 |
|---|---|---|
| 入队(增) | add(E e) |
offer(E e) |
| 出队(删) | remove() |
poll() |
| 查看队头(查) | element() |
peek() |
-
add / remove / element 在失败时会抛出异常:
-
add 在队列已满时抛 IllegalStateException;
-
remove 和 element 在队列为空时抛 NoSuchElementException。
-
-
offer / poll / peek 在失败时不会抛异常,而是返回一个特殊值:
-
offer 返回 false(表示队列已满,无法添加);
-
poll 返回 null(表示队列为空,没有元素);
-
peek 返回 null(同样表示队列为空)。
-
-
在实际开发中使用队列时应优先使用offer / poll / peek 这一组方法:- 既可以避免异常带来的性能开销和控制流混乱
- 还符合队列"可能空、可能满"的语义
5、Queue
5.1、Queue的使用
| 方法 | 功能 |
|---|---|
boolean offer(E e) |
入队列 |
E poll() |
出队列 |
E peek() |
获取队头元素 |
int size() |
获取队列中有效元素个数 |
boolean isEmpty() |
检测队列是否为空 |
java
public static void main(String[] args) {
Queue<Integer> q = new LinkedList<>();
q.offer(1);
q.offer(2);
q.offer(3);
q.offer(4);
q.offer(5); // 从队尾入队列
System.out.println(q.size());
System.out.println(q.peek()); // 获取队头元素
q.poll();
System.out.println(q.poll()); // 从队头出队列,并将删除的元素返回
if(q.isEmpty()){
System.out.println("队列空");
}else{
System.out.println(q.size());
}
}
特别注意: 在java的实际开发中,如果使用到queue结构,我们 优先使用顺序表实现的queue,因为入队/出队都是 O(1),且内存连续,CPU 缓存命中率高,速度最快
5.2、 Queue的模拟实现
在模拟实现中,我们通常用链表来模拟实现队列:
链表的逻辑天生适合队列(FIFO),对列中的尾插头删操作非常方便
普通数组(非循环)模拟队列有个致命伤------假溢出
java
public class MyQueue<T> {
static class ListNode<T>{
public T val;
public ListNode prev;
public ListNode next;
public ListNode(T val) {
this.val = val;
}
}
public ListNode<T> first = null;
public ListNode<T> last = null;
public int usedSize = 0;
public boolean isEmpty(){
if(first == null){
return true;
}
return false;
}
public void offer(T val){
ListNode<T> newNode = new ListNode<>(val);
if (isEmpty()){
first = newNode;
last = newNode;
}else{
last.next = newNode;
newNode.prev = last;
last = newNode;
}
usedSize++;
}
public T poll() {
if (isEmpty()) {
throw new NoSuchElementException("Queue is empty");
}
T val = first.val;
first = first.next;
if (first == null) {
// 队列已空,last 也要置 null
last = null;
} else {
first.prev = null; // 只有在 first 不为 null 时才操作
}
usedSize--;
return val;
}
public T peek(){
if(isEmpty()){
throw new NoSuchElementException("Queue is empty");
}
T val = first.val;
return val;
}
}
- 以上的实现逻辑和链表中的一些方法如出一辙,所以不进行具体分析
6、 循环队列
6.1、循环队列概念与特性
实际中我们有时还会使用一种队列叫循环队列,比如生产者消费者模型使用的就是循环队列,循环队列通常使用数组实现。
思考: 为什么用数组实现该队列需要进行循环:
-
普通数组队列在头部删除元素后,前面的空间就被浪费了。如果每次头删都移动整块数据,时间复杂度会飙升到 O(n);如果不移动,front 指针虽然可以后移,但数组尾部的空间一旦用完,即使前面有大量空闲位置也无法再插入------这就是"假溢出"。
-
循环队列的核心思想,就是让数组首尾逻辑上相连。当 rear 指针走到数组末尾时,如果前面有空位,就绕回到数组开头继续使用。这样既避免了频繁的数据搬移,又让数组空间得到充分利用,所有操作都维持在 O(1) 的时间复杂度。


上图中内圈为下标,外圈为存储的值
- 可以看到,循环队列最核心的点就是解决rear 指针和 front指针重合问题 :
当rear指针和front指针指向同一个位置时该如何分辨该队列为空还是已满。
这里有三种方法对其解决:
- 通过添加size属性记录
- 保留一个位置
- 使用标记
我们这里使用第二个方法:将front的前一个位置永远空出来,随着front的变化,空的位置也变化。

那么这里出现了第二个问题:
rear或者front下标 如何从 7 位置 到 0位置
- 使用:(rear + 1) % len
6.2、循环队列的模拟实现
经过以上的分析,我们不难对其进行模拟实现
java
public class MyCircularQueue<T> {
// 循环队列的成员变量有三个,要记得需要front记录真正的顺序表头
public int front;
public int rear;
public Object[] elem;
public MyCircularQueue(int k) {
elem = new Object[k];
}
public boolean isFull(){
return (rear + 1)%elem.length == front;
}
//入队列:
public boolean enQueue(T value){
if(isFull()){
return false;
}
elem[rear] = value;
rear = (rear + 1) % elem.length;
return true;
}
public boolean isEmpty(){
if (rear == front){
return true;
}
return false;
}
//出队列:
public boolean deQueue(){
if(isEmpty()){
return false;
}
front = (front + 1) % elem.length;
return true;
}
//得到队头元素:
public T Front(){
if(isEmpty()){
return null;
}
return (T) elem[front];
}
//得到队尾元素:
public T Rear(){
if(isEmpty()){
return null;
}
//这里的rear指针越界问题进行处理:
int index = (rear == 0) ? elem.length - 1 : rear-1;
return (T) elem[index];
}
}
- 在java的集合类中,ArrayDeque的实现就容纳了循环队列的设计模式。需要使用循环队列时,使用ArrayDeque中常规的队列方法即可
7、双端队列
7.1、双端队列的概念
双端队列(deque)是指允许两端都可以进行入队和出队操作的队列。
7.2、Deque的封装结构
Deque直接继承了 Queue接口 ,再此基础上增加了双端队列独有的方法。
7.3、Deque源码
java
package java.util;
public interface Deque<E> extends Queue<E> {
//deque的操作方法
void addFirst(E e);
void addLast(E e);
boolean offerFirst(E e);
boolean offerLast(E e);
E removeFirst();
E removeLast();
E pollFirst();
E pollLast();
E getFirst();
E getLast();
E peekFirst();
E peekLast();
boolean removeFirstOccurrence(Object o);
boolean removeLastOccurrence(Object o);
// *** Queue methods ***
boolean add(E e);
boolean offer(E e);
E remove();
E poll();
E element();
E peek();
// 省略一堆stack接口方法和collection接口方法
}
可以看到在Queue的基础上增加了一些方法:
这里我们重点掌握以下六种即可:
| 特殊值方法(推荐) | 操作说明 | 失败时的行为 |
|---|---|---|
offerFirst(E e) |
在队列头部插入元素 | 容量不足时返回 false |
offerLast(E e) |
在队列尾部插入元素 | 容量不足时返回 false |
pollFirst() |
移除并返回头部元素 | 队列为空时返回 null |
pollLast() |
移除并返回尾部元素 | 队列为空时返回 null |
peekFirst() |
返回(不移除)头部元素 | 队列为空时返回 null |
peekLast() |
返回(不移除)尾部元素 | 队列为空时返回 null |
关于队列,其实还有一个叫做优先级队列的数据结构,要想掌握它,我们要必须先了解二叉树相关的内容,所以,在下一个篇章在对其进行阐述。
