【数据结构与算法-Day 15】告别“假溢出”:深入解析循环队列与双端队列

Python3.8

Python 是一种高级、解释型、通用的编程语言,以其简洁易读的语法而闻名,适用于广泛的应用,包括Web开发、数据分析、人工智能和自动化脚本

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 “假溢出”问题详解

随着不断的入队和出队操作,frontrear 指针都会向后移动。当 rear 指针移动到数组的末尾时,即使数组的前半部分因为出队操作已经空了出来,我们也无法再进行入队操作了,因为 rear 无法继续后移。这种情况,我们称之为“假溢出”(False Overflow)。

场景图示:

假设我们有一个容量为 5 的数组。

  1. 初始状态: front = 0, rear = 0
    [ , , , , ]

  2. 入队 A, B, C: front = 0, rear = 3
    [ A, B, C, , ]

  3. 出队 A, B: front = 2, rear = 3
    [ , , C, , ]

此时,数组索引 0 和 1 的位置是空闲的,但队列中只有一个元素 C。

  1. 尝试继续入队 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, %) 将数组的线性空间“掰弯”成一个环形空间。

想象一下,数组的索引从 0capacity - 1。当指针需要加 1 时,我们不直接执行 pointer++,而是执行 pointer = (pointer + 1) % capacity

  • pointer0capacity - 2 之间时, (pointer + 1) % capacity 的结果就是 pointer + 1
  • pointercapacity - 1 时,(capacity - 1 + 1) % capacity 的结果是 capacity % capacity,即 0。指针就神奇地“绕”回了数组的开头。

通过这种方式,我们构建了一个逻辑上的环形缓冲区(Ring Buffer)。

逻辑上的环形空间
next
next
next
next
next
1
0
2
...
n-1

2.2 关键实现细节

引入循环逻辑后,frontrear 指针的移动都遵循模运算规则。但这也带来了新的问题:如何准确判断队列是“空”还是“满”?

如果 front == rear,这既可能代表队列是空的(初始状态),也可能代表队列是满的(rear 追上了 front)。为了区分这两种情况,主流实现方案有两种。

2.2.1 方案一:牺牲一个存储单元(主流方案)

这是最常用也是最巧妙的方案。我们约定,数组中始终保留一个空闲单元。当队尾指针 rear 的下一个位置是队头 front 时,我们就认为队列已满。

  • 队列为空的条件front == rear
  • 队列为满的条件(rear + 1) % capacity == front

为什么是这样?

  • 设容量为 capacity。数组的有效索引是 0capacity - 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=(rearfront+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 什么是双端队列?

双端队列,顾名思义,就是两端都可以进行入队和出队操作的队列。它就像一个两头都开放的管道,你既可以从队头添加/移除元素,也可以从队尾添加/移除元素。

它通常包含以下四种核心操作:

  1. addFirst(): 在队头添加元素。
  2. addLast(): 在队尾添加元素 (等同于普通队列的 enqueue)。
  3. removeFirst(): 移除队头元素 (等同于普通队列的 dequeue)。
  4. 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(Nk),在数据量大时效率低下。

Deque 优化解法:使用一个双端队列,队列中存储数组元素的索引。并维持队列的一个特性:队头索引对应的元素始终是当前窗口的最大值,且队列中的索引对应的元素值是单调递减的。

操作流程:

  1. 窗口向右移动,新元素 nums[i] 加入窗口:
    • 队尾开始,如果队列中已有的索引对应的元素小于 nums[i],则将这些索引从队尾移除(因为它们不可能再成为最大值了)。
    • 将新元素的索引 i队尾加入。
  2. 判断队头是否过期:
    • 检查队头的索引是否已经滑出窗口范围(即 deque.peekFirst() <= i - k),如果是,则从队头移除
  3. 记录结果:
    • 当窗口形成后(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, dequeueenqueue, dequeueaddFirst, addLast, removeFirst, removeLast
解决的问题-解决了“假溢出”,提高了空间利用率提供了更高的灵活性,是更通用的数据结构
空间效率低,可能产生大量无法使用的碎片空间高,能充分利用数组的每一个空间高(取决于底层实现)
典型用例任务调度、BFS (基础版)操作系统缓冲区、需要固定大小缓冲区的场景滑动窗口问题、既当栈又当队列用的场景
实现复杂度简单中等,需处理好满/空判断的边界条件较高,需要处理四个方向的操作

五、总结

在本篇博客中,我们完成了对队列数据结构的深度探索,从一个实际问题出发,逐步揭示了更高级队列形态的设计哲学。

  1. 直面问题:我们首先回顾了普通数组队列的“假溢出”问题,理解了其在空间利用上的天然缺陷。
  2. 循环队列的智慧:通过引入模运算,我们将线性数组在逻辑上转变为一个环形缓冲区,诞生了循环队列。它完美解决了空间浪费问题,其实现的关键在于巧妙地区分“队空”与“队满”状态,主流方法是牺牲一个存储单元
  3. 双端队列的强大:我们进一步学习了功能更为强大的双端队列 (Deque)。它打破了单向操作的限制,允许在队头和队尾同时进行添加和删除,使其成为一种极其灵活的工具。
  4. 学以致用:Deque 不仅可以模拟栈和队列,更是在“滑动窗口最大值”等算法问题中大放异彩,展现了其作为高级数据结构的实战价值。
  5. 选择之道:最终我们认识到,从普通队列、循环队列到双端队列,它们是数据结构不断演进、解决特定问题、追求更高效率和灵活性的体现。在实际编程中,根据具体需求选择最合适的数据结构,是提升代码质量与性能的关键一步。

您可能感兴趣的与本文相关的镜像

Python3.8

Python3.8

Conda
Python

Python 是一种高级、解释型、通用的编程语言,以其简洁易读的语法而闻名,适用于广泛的应用,包括Web开发、数据分析、人工智能和自动化脚本

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

吴师兄大模型

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值