队列(Queue)-详解

队列(Queue 接口)

概念

队列是一种特殊的线性表,只允许在一端进行插入数据操作,在另一端进行删除数据操作。

队列中的数据遵循先进先出 的顺序,FIFO (First In First Out)。

  • 队头:进行删除操作的一端称为队头
  • 队尾:进行插入操作的一端称为队尾
  • 入队:将元素放进队尾,即为入队操作
  • 出队:将队列中的首元素进行删除(或移出)

常用方法

方法 说明
boolean offer(E e) 入队列
E poll() 出队列
E peek() 获取队头元素
int size() 获取队列中有效元素个数
boolean isEmpty() 检测队列是否为空

队列的模拟实现

链式结构

1. 单链表

用单链表实现队列,核心思路是维护一个尾节点引用,尾插法入队、头删法出队,做到 O(1) 的入队和出队。

  • 添加一个尾节点的引用
  • 入队采用尾插法
  • 出队采用删除头节点

入队图解:

出队图解:

需要注意:哪怕有尾节点的标记,也不能从头节点入队。因为如果从头入队、从尾出队,删除时需要找尾节点的前一个节点,单链表的节点只有 next 指针,做不到 O(1) 删尾(除非维护的是尾节点的前一个节点的引用,但这样明显增加复杂度,没必要)。

所以单链表实现队列,尾插头删是唯一合理的方案------头删不需要前驱节点,尾插只需要尾节点引用。

2. 双链表

双链表每个节点有 prev 和 next,灵活很多。有两种方式:

  • 从头进,从尾出
  • 从尾进,从头出

两种方法都可以,这里采用从尾进,从头出 (即尾插法入队、头删法出队)。利用 headlast 两个引用,入队和出队都能做到 O(1)。

代码实现如下:

java 复制代码
public class MyQueue { // 尾出 头进

    // 链表节点结构
    public static class ListNode {
        public ListNode prev;
        public ListNode next;
        public int val;

        public ListNode(int val) {
            this.val = val;
        }
    }

    public ListNode head = null;  // 队头
    public ListNode last = null;  // 队尾
    public int usedSize = 0;      // 有效元素个数

    // 入队 ------ 链表尾插法
    public void offer(int val) {
        ListNode listNode = new ListNode(val);
        if (isEmpty()) {
            last = head = listNode;
        } else {
            last.next = listNode;
            listNode.prev = last;
            last = last.next;
        }
        this.usedSize++;
    }

    // 出队 ------ 链表删除头节点
    public int poll() {
        if (isEmpty()) {
            return -1;
        }
        int tmpVal = head.val;
        head = head.next;
        if (head != null) {
            // 如果只有一个元素,head 会走向 null,后面操作 head.prev 就会报空指针
            head.prev = null;
        }
        this.usedSize--;
        return tmpVal;
    }

    // 获取队头
    public int peek() {
        if (isEmpty()) {
            return -1;
        }
        return head.val;
    }

    // 判空
    public boolean isEmpty() {
        return head == null;
    }

    // 获取有效元素个数
    public int getUsedSize() {
        return this.usedSize;
    }
}

通过 headlast 双标记,入队和出队都是 O(1)。

顺序结构

1. 数组

定义一个 front 与 rear 引用头与尾:

  • 入队:从 rear 处放入,array[rear] = val; rear++
  • 出队:从 front 处移除,front++

但很快会发现一个问题。假设依次入队 1 2 3 4 5

出队 1 2 3 后:

此刻 front 指向 4(下标 3),rear 指向 5 后面的空位(下标 5)。

此时如果再入队,rear++ 就越界了,虽然对数组进行扩容即可解决。

但前面的位置明明空着,却不用他,会造成资源的浪费------这就是假溢出问题。

解决方案:把数组"魔改"成循环数组 ,让下标能绕回去。

循环数组可以将其抽象看成一个圆环

2. 循环队列

循环队列底层依然是数组,但对下标做了改动:让下标的值一直在 [0, 数组长度) 区间循环波动

下标计算分两种情况:

情况一:从队尾绕回队头

如图所示:

可以看出从 index → destination,需要向后移动两步:

那么可以发现,通过下面的公式即可计算出destination的下标

复制代码
index = (index + 2) % 9 = 1

抽象公式:(当前下标 + 待移动步数) % 数组长度

情况二:从队头绕回队尾

如图所示:

从 index → destination,需要向前移动 3 步:

复制代码
index = ((index + 9) - 3) % 9 = 6

抽象公式:((当前下标 + 数组长度) - 待移动步数) % 数组长度

3. 判空与判满

front == rear 既可能是空,也可能是队列绕了一圈回来了。

如图为循环队列 为空 和 为满 的情况

有三种方式来进行判断:

  • 判空front == rear
  • 判满
    1. 添加 boolean 标记记录是否为满
    2. 定义 size,size == 数组长度 即为满
    3. 浪费一个空间,(rear + 1) % len == front 即为满(如上图所示)
4. 数组模拟实现循环队列

有了以上知识作为补充,用数组实现队列就很简单了。

核心思路:维护 front(队头下标)和 rear(队尾下标,指向下一个入队位置)两个变量,入队时在 rear 处放入元素,出队时移动 front。下标通过取模运算循环。

这里使用浪费一个空间的方式来区分空和满(即始终保留一个空位),从尾进、从头出,入队和出队都是 O(1)。

代码实现如下:

java 复制代码
public class MyCircularQueue {

    private int[] array;
    private int front;  // 队头下标
    private int rear;   // 队尾下标(指向下一个入队位置)

    // 构造时多开一个空间用于区分空/满
    public MyCircularQueue(int k) {
        array = new int[k + 1];
    }

    // 入队
    public boolean enQueue(int value) {
        if (isFull()) {
            grow();
            return enQueue(value);
        }
        array[rear] = value;
        rear = (rear + 1) % array.length;
        return true;
    }

    // 出队
    public boolean deQueue() {
        if (isEmpty()) {
            return false;
        }
        front = (front + 1) % array.length;
        return true;
    }

    // 获取队头
    public int Front() {
        if (isEmpty()) {
            return -1;
        }
        return array[front];
    }

    // 获取队尾
    public int Rear() {
        if (isEmpty()) {
            return -1;
        }
        // rear 指向下一个入队位置,队尾是 rear 的前一个
        return array[(rear - 1 + array.length) % array.length];
    }

    // 判空
    public boolean isEmpty() {
        return front == rear;
    }

    // 判满
    public boolean isFull() {
        return (rear + 1) % array.length == front;
    }

    // 扩容
    private void grow() {
        int newLen = array.length * 2;
        int[] newArray = new int[newLen];
        // 按逻辑顺序搬运,重新排列成连续存放
        int size = (rear - front + array.length) % array.length;
        for (int i = 0; i < size; i++) {
            newArray[i] = array[(front + i) % array.length];
        }
        array = newArray;
        front = 0;
        rear = size;
    }
}
为什么不用Arrays.copyOf 扩容?

你可能会想,扩容直接用 Arrays.copyOf 不就行了?不行 ,这里有个坑。

举个例子就很直观了↓

通过上图,可以分析出以下内容:
扩容前

复制代码
读取: (4)%7=4→1  (5)%7=5→2  (6)%7=6→3  (7)%7=0→4 ✓
结果: 1, 2, 3, 4 ✓

再分析下扩容后的:

复制代码
读取: (4)%14=4→1  (5)%14=5→2  (6)%14=6→3  (7)%14=7→0 ✗
结果: 1, 2, 3, 0 ← 元素4丢了,读到的是空位置的默认值0

为什么元素 4 丢了?

直接 Arrays.copyOf 的话,元素 4 就成了孤儿 ,后面的 offer/poll 全都会错位。

这就是为什么扩容时必须手动按逻辑顺序搬运,重新排列成连续存放再重置 front=0, rear=size

链式 vs 顺序------哪一个更好?

对比维度 链式队列(双链表) 顺序队列(循环数组)
入队/出队 O(1),节点指针操作 O(1),仅移动下标
内存分配 动态分配,入队才 new 节点 预分配一整块连续内存
空间利用率 每个节点有 prev/next 额外开销(≈ 16 字节) 只有数据本身,无额外开销
扩容代价 无需扩容,来一个 new 一个 需搬移 + 重建,O(n)
CPU 缓存友好 节点分散,缓存不友好 连续存储,缓存友好
代码复杂度 指针操作多,边界情况多 核心是模运算,相对简单

实际开发中,LinkedList(双链表)和 ArrayDeque(循环数组)都实现了 Queue。JDK 官方推荐优先用 ArrayDeque,缓存更友好。

用队列实现栈

LeetCode 225. 用队列实现栈

核心思想

队列是 FIFO,栈是 LIFO,顺序是反的。所以每次出栈时,需要把前面的元素全部搬走,才能拿到最后入队的那个元素。

1. 定义两个队列

  • q1:入栈队列
  • q2:辅助搬运用的空队列

2. 模拟入栈 push()

直接向当前非空的队列中 offer 即可,与队列入队完全一致。

3. 模拟出栈 pop()

思路:将不为空的队列中的 n-1 个元素搬移到另一个队列,剩下的最后一个就是栈顶。

两种实现方案:

方案一(for + size)

java 复制代码
public int pop() {
    int size = q1.size();
    for (int i = 0; i < size - 1; i++) {
        q2.offer(q1.poll());
    }
    int top = q1.poll();
    // 交换 q1 和 q2
    Queue<Integer> tmp = q1;
    q1 = q2;
    q2 = tmp;
    return top;
}

缺点:size 是快照值,逻辑分成「搬 n-1 个」和「取最后 1 个」两步,略显割裂。

方案二(while + tmpVal)

java 复制代码
public int pop() {
    int tmpVal = q1.poll();
    while (!q1.isEmpty()) {
        q2.offer(tmpVal);
        tmpVal = q1.poll();
    }
    Queue<Integer> tmp = q1;
    q1 = q2;
    q2 = tmp;
    return tmpVal;
}

优点:不依赖 size 快照,逻辑统一,更健壮。

4. 模拟获取栈顶 peek()

思路和 pop 一样,但拿到最后一个元素后,要把它放回去,保证数据不丢失。

java 复制代码
public int peek() {
    int tmpVal = q1.poll();
    while (!q1.isEmpty()) {
        q2.offer(tmpVal);
        tmpVal = q1.poll();
    }
    q2.offer(tmpVal); // 放回去

    Queue<Integer> tmp = q1;
    q1 = q2;
    q2 = tmp;
    return tmpVal;
}

用栈实现队列

完整实现已在 【Java数据结构------栈(Stack)详解】 中写过,包括 push/pop/peek/empty 和均摊 O(1) 分析,不再重复。

核心思路:两个栈 stackInstackOut,入队压 stackIn,出队时 stackOut 为空就把 stackIn 全部倒入 stackOut(顺序反过来了),再从 stackOut 弹出。

双端队列(Deque)

概念

Deque(Double Ended Queue),两端都可以入队和出队的队列。可以当栈用(LIFO),也能当队列用(FIFO)。

Java Deque 接口

队头操作 队尾操作
插入 addFirst(e) / offerFirst(e) addLast(e) / offerLast(e)
删除 removeFirst() / pollFirst() removeLast() / pollLast()
查看 getFirst() / peekFirst() getLast() / peekLast()

add/remove 系列失败抛异常,offer/poll/peek 系列返回特殊值。

当栈用:

java 复制代码
Deque<Integer> stack = new ArrayDeque<>();
stack.push(1);
stack.push(2);
System.out.println(stack.pop()); // 2

push 对应 addFirstpop 对应 removeFirst,队头就是栈顶。

当队列用:

java 复制代码
Deque<Integer> queue = new ArrayDeque<>();
queue.offer(1);      // addLast
queue.offer(2);
System.out.println(queue.poll()); // 1

ArrayDeque vs LinkedList

ArrayDeque LinkedList
底层结构 循环数组 双链表
栈/队列操作 O(1) O(1)
内存 连续存储,缓存友好 节点分散,不友好
扩容 需搬移 无需
中间操作 不支持 支持(List 接口)
null 元素 不允许 允许

所以写栈时不太推荐使用 Stack 类(Vector 的子类,线程安全但性能差),梗推荐使用:

java 复制代码
Deque<Integer> stack = new ArrayDeque<>();
相关推荐
想不明白的过度思考者1 小时前
Unity全局事件中心与新版输入架构实现练习——上帝模式与英雄模式的输入系统映射切换
java·unity·架构
小新同学^O^1 小时前
简单学习Spring原理
java·学习·spring
戴西软件2 小时前
戴西软件入选2026年安徽省制造业数智化转型服务商名单
java·大数据·服务器·前端·人工智能
爱棋笑谦2 小时前
springboot—数据源相关配置
java·spring boot·spring
YL2004042610 小时前
048路径总和III
数据结构·dfs
budingxiaomoli11 小时前
Spring IoC &DI
java·spring·ioc·di
Spider Cat 蜘蛛猫11 小时前
Springboot SSO系统设计文档
java·spring boot·后端
未若君雅裁11 小时前
MySQL高可用与扩展-主从复制读写分离分库分表
java·数据库·mysql
学习中.........11 小时前
从扰动函数的变化,感受红黑树带来的性能提升
java