Langchain系列文章目录
01-玩转LangChain:从模型调用到Prompt模板与输出解析的完整指南
02-玩转 LangChain Memory 模块:四种记忆类型详解及应用场景全覆盖
03-全面掌握 LangChain:从核心链条构建到动态任务分配的实战指南
04-玩转 LangChain:从文档加载到高效问答系统构建的全程实战
05-玩转 LangChain:深度评估问答系统的三种高效方法(示例生成、手动评估与LLM辅助评估)
06-从 0 到 1 掌握 LangChain Agents:自定义工具 + LLM 打造智能工作流!
07-【深度解析】从GPT-1到GPT-4:ChatGPT背后的核心原理全揭秘
08-【万字长文】MCP深度解析:打通AI与世界的“USB-C”,模型上下文协议原理、实践与未来
Python系列文章目录
PyTorch系列文章目录
机器学习系列文章目录
深度学习系列文章目录
Java系列文章目录
JavaScript系列文章目录
Python系列文章目录
Go语言系列文章目录
Docker系列文章目录
数据结构与算法系列文章目录
01-【数据结构与算法-Day 1】程序世界的基石:到底什么是数据结构与算法?
02-【数据结构与算法-Day 2】衡量代码的标尺:时间复杂度与大O表示法入门
03-【数据结构与算法-Day 3】揭秘算法效率的真相:全面解析O(n^2), O(2^n)及最好/最坏/平均复杂度
04-【数据结构与算法-Day 4】从O(1)到O(n²),全面掌握空间复杂度分析
05-【数据结构与算法-Day 5】实战演练:轻松看懂代码的时间与空间复杂度
06-【数据结构与算法-Day 6】最朴素的容器 - 数组(Array)深度解析
07-【数据结构与算法-Day 7】告别数组束缚,初识灵活的链表 (Linked List)
08-【数据结构与算法-Day 8】手把手带你拿捏单向链表:增、删、改核心操作详解
09-【数据结构与算法-Day 9】图解单向链表:从基础遍历到面试必考的链表反转
10-【数据结构与算法-Day 10】双向奔赴:深入解析双向链表(含图解与代码)
11-【数据结构与算法-Day 11】从循环链表到约瑟夫环,一文搞定链表的终极形态
12-【数据结构与算法-Day 12】深入浅出栈:从“后进先出”原理到数组与链表双实现
13-【数据结构与算法-Day 13】栈的应用:从括号匹配到逆波兰表达式求值,面试高频考点全解析
14-【数据结构与算法-Day 14】先进先出的公平:深入解析队列(Queue)的核心原理与数组实现
15-【数据结构与算法-Day 15】告别“假溢出”:深入解析循环队列与双端队列
文章目录
摘要
在上一篇文章中,我们学习了队列(Queue)这一“先进先出”(FIFO)的数据结构。当使用数组实现队列时,我们会遇到一个棘手的问题——“假溢出”。即数组明明还有空余空间,却因为队尾指针到达了数组末端而无法继续入队。本文将深入探讨这一问题的解决方案:循环队列 (Circular Queue),它通过巧妙的模运算让有限的空间“循环”起来,实现空间的高效利用。在此基础上,我们将进一步探索队列的“终极进化版”——双端队列 (Deque),它打破了只能在一端入队、另一端出队的限制,提供了在队列两端进行操作的超能力。本文旨在通过图解、原理剖析和代码实战,带你彻底掌握循环队列与双端队列的设计精髓与应用场景。
一、回顾:普通队列的“成长的烦恼”
在深入了解循环队列之前,让我们先快速回顾一下基于普通数组实现的队列所面临的困境。
1.1 基于数组的队列回顾
一个基于数组的队列通常需要两个指针:front 指向队头元素,rear 指向队尾元素的下一个位置。
- 入队 (Enqueue): 元素被添加到
rear指向的位置,然后rear指针后移。 - 出队 (Dequeue): 从
front指向的位置取出元素,然后front指针后移。
1.2 “假溢出”问题详解
随着不断的入队和出队操作,front 和 rear 指针都会向后移动。当 rear 指针移动到数组的末尾时,即使数组的前半部分因为出队操作已经空了出来,我们也无法再进行入队操作了,因为 rear 无法继续后移。这种情况,我们称之为“假溢出”(False Overflow)。
场景图示:
假设我们有一个容量为 5 的数组。
-
初始状态:
front = 0,rear = 0
[ , , , , ] -
入队 A, B, C:
front = 0,rear = 3
[ A, B, C, , ] -
出队 A, B:
front = 2,rear = 3
[ , , C, , ]
此时,数组索引 0 和 1 的位置是空闲的,但队列中只有一个元素 C。
- 尝试继续入队 D, E, F:
rear已经指向索引 3,入队 D 后rear变为 4,入队 E 后rear变为 5。当rear == 5(数组容量) 时,我们无法再入队 F,系统会报告队列已满。
然而,我们肉眼可见,数组的 [0] 和 [1] 位置是空的。这就是“假溢出”。解决这个问题的暴力方法是每次出队后,都将后面的所有元素向前移动一位,但这将导致出队操作的时间复杂度从
O
(
1
)
O(1)
O(1) 退化到
O
(
n
)
O(n)
O(n),完全违背了使用队列追求高效的初衷。
二、循环队列:让空间“循环”起来
为了解决“假溢出”问题,工程师们设计出了循环队列。其核心思想是将数组在逻辑上视为一个环,当指针走到数组末尾时,会自动回到数组的开头。
2.1 核心思想:模运算的魔法
循环队列的精髓在于利用模运算 (Modulo Operation, %) 将数组的线性空间“掰弯”成一个环形空间。
想象一下,数组的索引从 0 到 capacity - 1。当指针需要加 1 时,我们不直接执行 pointer++,而是执行 pointer = (pointer + 1) % capacity。
- 当
pointer在0到capacity - 2之间时,(pointer + 1) % capacity的结果就是pointer + 1。 - 当
pointer为capacity - 1时,(capacity - 1 + 1) % capacity的结果是capacity % capacity,即0。指针就神奇地“绕”回了数组的开头。
通过这种方式,我们构建了一个逻辑上的环形缓冲区(Ring Buffer)。
2.2 关键实现细节
引入循环逻辑后,front 和 rear 指针的移动都遵循模运算规则。但这也带来了新的问题:如何准确判断队列是“空”还是“满”?
如果 front == rear,这既可能代表队列是空的(初始状态),也可能代表队列是满的(rear 追上了 front)。为了区分这两种情况,主流实现方案有两种。
2.2.1 方案一:牺牲一个存储单元(主流方案)
这是最常用也是最巧妙的方案。我们约定,数组中始终保留一个空闲单元。当队尾指针 rear 的下一个位置是队头 front 时,我们就认为队列已满。
- 队列为空的条件:
front == rear - 队列为满的条件:
(rear + 1) % capacity == front
为什么是这样?
- 设容量为
capacity。数组的有效索引是0到capacity - 1。 - 如果队列满了,意味着它存储了
capacity - 1个元素。此时,rear指针在front指针的“前一个”位置。 - 如果
front在 0,rear就在capacity - 1。(capacity - 1 + 1) % capacity等于0,与front相等。 - 如果
front在 3,rear就在 2。(2 + 1) % capacity等于3,与front相等。
这样,front == rear 就唯一地表示队列为空,完美解决了歧义。
2.2.2 方案二:使用计数器 size
另一种更直观的方案是引入一个额外的变量 size 来记录队列中元素的个数。
- 队列为空的条件:
size == 0 - 队列为满的条件:
size == capacity
入队成功后 size++,出队成功后 size--。这种方法简单易懂,但需要额外维护一个变量。
2.2.3 如何计算队列元素个数?
在使用“牺牲一个单元”的方案时,队列中元素的个数可以通过以下公式计算:
size = ( rear − front + capacity ) % capacity \text{size} = (\text{rear} - \text{front} + \text{capacity}) \% \text{capacity} size=(rear−front+capacity)%capacity
- 当
rear>front时:(rear - front + capacity) % capacity等价于rear - front。 - 当
rear<front时 (已绕圈):rear - front是个负数,加上capacity后变为正数,再取模得到正确的大小。例如,capacity=8, front=6, rear=2,元素个数为4 (6,7,0,1)。公式计算为(2 - 6 + 8) % 8 = 4 % 8 = 4。
2.3 循环队列的实现 (Java)
下面我们采用“牺牲一个存储单元”的方案来实现一个循环队列。
public class CircularQueue {
private int[] data; // 存储数据的数组
private int front; // 队头指针
private int rear; // 队尾指针(指向下一个可插入位置)
private int capacity; // 队列容量
/**
* 构造函数
* @param k 队列的容量
*/
public CircularQueue(int k) {
// 由于我们牺牲一个单元来判断满,所以数组容量需要 k + 1
this.capacity = k + 1;
this.data = new int[this.capacity];
this.front = 0;
this.rear = 0;
}
/**
* 入队操作
* @param value 要插入的元素
* @return 成功返回 true,失败返回 false
*/
public boolean enqueue(int value) {
// 判断队列是否已满
if (isFull()) {
System.out.println("Queue is full. Enqueue failed.");
return false;
}
// 在 rear 位置放入元素
this.data[this.rear] = value;
// rear 指针后移,使用模运算实现循环
this.rear = (this.rear + 1) % this.capacity;
return true;
}
/**
* 出队操作
* @return 成功返回出队的元素,失败(队列为空)可抛出异常或返回特定值
*/
public int dequeue() {
// 判断队列是否为空
if (isEmpty()) {
throw new RuntimeException("Queue is empty. Dequeue failed.");
}
// 取出 front 位置的元素
int value = this.data[this.front];
// front 指针后移,使用模运算实现循环
this.front = (this.front + 1) % this.capacity;
return value;
}
/**
* 获取队头元素
* @return 队头元素
*/
public int front() {
if (isEmpty()) {
throw new RuntimeException("Queue is empty.");
}
return this.data[this.front];
}
/**
* 检查队列是否为空
* @return 空返回 true,否则返回 false
*/
public boolean isEmpty() {
return this.front == this.rear;
}
/**
* 检查队列是否已满
* @return 满返回 true,否则返回 false
*/
public boolean isFull() {
// 关键判断:尾指针的下一个位置是否是头指针
return (this.rear + 1) % this.capacity == this.front;
}
/**
* 获取队列中元素的个数
* @return 元素个数
*/
public int size() {
return (this.rear - this.front + this.capacity) % this.capacity;
}
}
三、双端队列 (Deque):更灵活的出入规则
如果说循环队列是对普通队列的一次重大升级,那么双端队列(Deque,全称 Double-Ended Queue)则是一种功能更强大的“完全体”。
3.1 什么是双端队列?
双端队列,顾名思义,就是两端都可以进行入队和出队操作的队列。它就像一个两头都开放的管道,你既可以从队头添加/移除元素,也可以从队尾添加/移除元素。
它通常包含以下四种核心操作:
addFirst(): 在队头添加元素。addLast(): 在队尾添加元素 (等同于普通队列的enqueue)。removeFirst(): 移除队头元素 (等同于普通队列的dequeue)。removeLast(): 移除队尾元素。
3.2 Deque 的应用场景
由于其高度的灵活性,Deque 的应用场景非常广泛。
3.2.1 作为栈使用
如果只使用 addFirst() 和 removeFirst(),Deque 就表现得像一个栈(后进先出,LIFO)。新元素总是在队头加入,也总是在队头被移除。同样,只使用 addLast() 和 removeLast() 也能实现栈。
3.2.2 作为队列使用
如果只使用 addLast() 和 removeFirst(),Deque 就表现得和普通队列一模一样(先进先出,FIFO)。
3.2.3 经典应用:滑动窗口最大值
这是一个经典的算法题:给定一个数组和一个大小为 k 的滑动窗口,窗口从数组的最左侧滑向最右侧,每次滑动一格,你需要找出每次滑动时窗口内的最大值。
暴力解法:每次窗口滑动后,都遍历一遍窗口内的 k 个元素,找出最大值。时间复杂度为
O
(
N
⋅
k
)
O(N \cdot k)
O(N⋅k),在数据量大时效率低下。
Deque 优化解法:使用一个双端队列,队列中存储数组元素的索引。并维持队列的一个特性:队头索引对应的元素始终是当前窗口的最大值,且队列中的索引对应的元素值是单调递减的。
操作流程:
- 窗口向右移动,新元素
nums[i]加入窗口:- 从队尾开始,如果队列中已有的索引对应的元素小于
nums[i],则将这些索引从队尾移除(因为它们不可能再成为最大值了)。 - 将新元素的索引
i从队尾加入。
- 从队尾开始,如果队列中已有的索引对应的元素小于
- 判断队头是否过期:
- 检查队头的索引是否已经滑出窗口范围(即
deque.peekFirst() <= i - k),如果是,则从队头移除。
- 检查队头的索引是否已经滑出窗口范围(即
- 记录结果:
- 当窗口形成后(
i >= k - 1),当前窗口的最大值就是队头索引对应的元素nums[deque.peekFirst()]。
- 当窗口形成后(
通过 Deque,我们可以在 O ( 1 ) O(1) O(1) 的时间内获取当前窗口的最大值,使得整个算法的时间复杂度降为 O ( N ) O(N) O(N),因为每个元素最多入队一次、出队一次。
3.3 Deque 的实现方式
3.3.1 基于循环数组实现
双端队列可以基于循环数组实现,其思想与循环队列一脉相承。front 指针不仅可以后移(removeFirst),还可以前移(addFirst)。前移时同样需要模运算:front = (front - 1 + capacity) % capacity。
3.3.2 基于双向链表实现
使用双向链表实现 Deque 非常自然和高效。链表头尾节点的存在使得在两端添加或删除节点的操作都非常方便,时间复杂度均为
O
(
1
)
O(1)
O(1)。Java 标准库中的 java.util.LinkedList 就实现了 Deque 接口,其底层就是双向链表。
四、循环队列 vs. 双端队列 vs. 普通队列
为了更清晰地理解它们的区别,我们用一个表格进行总结:
| 特性 | 普通队列 (数组实现) | 循环队列 (数组实现) | 双端队列 (Deque) |
|---|---|---|---|
| 核心思想 | front, rear 指针单向移动 | 指针通过模运算在数组中循环移动 | 支持在两端进行添加和删除操作 |
| 主要操作 | enqueue, dequeue | enqueue, dequeue | addFirst, addLast, removeFirst, removeLast |
| 解决的问题 | - | 解决了“假溢出”,提高了空间利用率 | 提供了更高的灵活性,是更通用的数据结构 |
| 空间效率 | 低,可能产生大量无法使用的碎片空间 | 高,能充分利用数组的每一个空间 | 高(取决于底层实现) |
| 典型用例 | 任务调度、BFS (基础版) | 操作系统缓冲区、需要固定大小缓冲区的场景 | 滑动窗口问题、既当栈又当队列用的场景 |
| 实现复杂度 | 简单 | 中等,需处理好满/空判断的边界条件 | 较高,需要处理四个方向的操作 |
五、总结
在本篇博客中,我们完成了对队列数据结构的深度探索,从一个实际问题出发,逐步揭示了更高级队列形态的设计哲学。
- 直面问题:我们首先回顾了普通数组队列的“假溢出”问题,理解了其在空间利用上的天然缺陷。
- 循环队列的智慧:通过引入模运算,我们将线性数组在逻辑上转变为一个环形缓冲区,诞生了循环队列。它完美解决了空间浪费问题,其实现的关键在于巧妙地区分“队空”与“队满”状态,主流方法是牺牲一个存储单元。
- 双端队列的强大:我们进一步学习了功能更为强大的双端队列 (Deque)。它打破了单向操作的限制,允许在队头和队尾同时进行添加和删除,使其成为一种极其灵活的工具。
- 学以致用:Deque 不仅可以模拟栈和队列,更是在“滑动窗口最大值”等算法问题中大放异彩,展现了其作为高级数据结构的实战价值。
- 选择之道:最终我们认识到,从普通队列、循环队列到双端队列,它们是数据结构不断演进、解决特定问题、追求更高效率和灵活性的体现。在实际编程中,根据具体需求选择最合适的数据结构,是提升代码质量与性能的关键一步。

993

被折叠的 条评论
为什么被折叠?



