数组-环形数组【arr2】
1、什么是环形数组?
环形数组(Circular Array),也称为循环缓冲区或环形队列,是一种在逻辑上将普通数组的“首”和“尾”连接起来形成一个环的数据结构。
想象一下,一个普通的数组就像一条单行道,走到尽头就是终点。而环形数组则像一个环形的跑道,跑到终点后,下一步自然就回到了起点。

:::info
💡 关键理解
环形数组的"环形"特性并不是物理上的,而是逻辑上的。在内存中,数组仍然是线性连续存储的,但我们通过取模运算来实现环形的访问模式。
:::
为什么需要环形数组?
它最主要的应用场景是实现队列。使用普通数组实现队列时,每次出队(删除头部元素)后,为了保持队列的连续性,可能需要移动后续所有元素,这个操作的时间复杂度是 O(n)。而环形数组通过移动指针来代替数据移动,使得入队和出队操作的时间复杂度都能达到 O(1),极大地提高了效率。
2、核心思想:取模运算
环形数组“环”的特性并不是物理上实现的,而是通过一个简单的数学技巧——取模运算 (在编程中通常用 <font style="color:rgb(239, 68, 68);background-color:rgb(243, 244, 246);">%</font> 符号表示) 来在逻辑上实现的。
公式非常简单:<font style="color:rgb(239, 68, 68);background-color:rgb(243, 244, 246);">下一个位置 = (当前位置 + 1) % 数组容量</font>
让我们来看一个容量为 5 的数组:
- 索引从 0 开始,正常移动:0 → 1 → 2 → 3 → 4
- 当指针在索引 4 (末尾) 时,我们想移动到下一个位置:
- 计算:
<font style="color:rgb(239, 68, 68);background-color:rgb(243, 244, 246);">(4 + 1) % 5</font> - 结果:
<font style="color:rgb(239, 68, 68);background-color:rgb(243, 244, 246);">5 % 5 = 0</font> - 看!指针神奇地从索引 4 “跳”回了索引 0,形成了一个环。
为什么是取模运算?
取模运算求的是一个数除以另一个数后的余数。当一个数小于除数时,余数就是它本身 (例如_<font style="color:rgb(239, 68, 68);background-color:rgb(243, 244, 246);">3 % 5 = 3</font>_)。当一个数等于除数时,余数是 0 (例如_<font style="color:rgb(239, 68, 68);background-color:rgb(243, 244, 246);">5 % 5 = 0</font>_)。这个特性完美地契合了我们想要的效果:在数组范围内,索引正常递增;一旦超出范围,就自动归零,回到起点。
3、关键组成部分
一个完整的环形数组需要以下几个关键组件:
<font style="color:rgb(239, 68, 68);background-color:rgb(243, 244, 246);">capacity</font>: 数组的总容量,即数组能存储多少个元素。这个值是固定的。<font style="color:rgb(239, 68, 68);background-color:rgb(243, 244, 246);">head</font>: 头指针,指向队列中的第一个有效元素。当有元素出队时,<font style="color:rgb(239, 68, 68);background-color:rgb(243, 244, 246);">head</font>指针会移动。<font style="color:rgb(239, 68, 68);background-color:rgb(243, 244, 246);">tail</font>: 尾指针,指向下一个可插入元素的位置。注意,它指向的是队尾元素的下一个位置,是一个空位。当有新元素入队时,会放在这个位置,然后<font style="color:rgb(239, 68, 68);background-color:rgb(243, 244, 246);">tail</font>指针移动。
为什么需要头尾双指针?
简单来说,头尾指针(head 和 tail)是为了以极高的效率(O(1) 时间复杂度)追踪队列的“起点”和“终点”,从而避免了普通数组在实现队列时需要进行的大量数据移动。
让我们从“如果没有头尾指针会怎么样”来思考,就更能理解它们的重要性了。
场景一:使用普通数组实现队列(没有
**head**和**tail**指针)假设我们用一个普通数组
**[A, B, C, D, E]**来模拟一个队列。
- 入队 : 很简单,直接在数组末尾添加元素即可。比如再加入一个
**F**,数组变成**[A, B, C, D, E, F]**。这个操作很快。- 出队 : 队列的规则是“先进先出”(FIFO),所以我们必须移除第一个元素
**A**。
- 移除
**A**之后,数组变成了**[null, B, C, D, E, F]**。- 问题来了:** 数组的开头出现了一个空位!为了保持队列的紧凑和正确性(我们希望队头永远在索引
**0**的位置),我们必须将**B**、**C**、**D**、**E**、**F**全部向前移动一位**。- 移动后的数组是
**[B, C, D, E, F, null]**。这个“全体移动”的操作是性能的瓶颈。如果队列里有成千上万个元素,每一次出队都意味着成千上万次的数据移动。这就是所谓的 O(n) 时间复杂度,效率非常低下。
场景二:使用环形数组(带有
**head**和**tail**指针)现在,我们有了
**head**和**tail**指针。它们就像两个智能书签,告诉我们队列的有效数据在哪里。
**head**** 指针:永远指向队头元素(下一个要出队的元素)。****tail**** 指针:永远指向队尾的下一个空位(下一个新元素要插入的位置)。**
- 入队 : 在
**tail**指针的位置放入新元素,然后把**tail**指针“向前”移动一位。这个操作只涉及一次写入和一次指针位置的计算,非常快。- 出队 : 取出
**head**指针指向的元素,然后把**head**指针也“向前”移动一位。我们完全不需要移动数组中的任何其他元素! 我们只是改变了**head**指针的值,告诉程序:“现在队列的起点在这里了”。这个“只移动指针”的操作,不管队列里有多少元素,花费的时间都是一样的。这就是 O(1) 时间复杂度,是最高效的方式。
4、核心操作
环形数组最核心的操作就是入队 和出队 。
4.1 入队
当一个新元素需要加入队列时:
- 检查队列是否已满。如果满了,则无法添加。
- 将新元素放入
<font style="color:rgb(239, 68, 68);background-color:rgb(243, 244, 246);">tail</font>指针指向的位置。 - 更新
<font style="color:rgb(239, 68, 68);background-color:rgb(243, 244, 246);">tail</font>指针:<font style="color:rgb(239, 68, 68);background-color:rgb(243, 244, 246);">tail = (tail + 1) % capacity</font>。
4.2 出队
当一个元素需要从队列中移除时:
- 检查队列是否为空。如果为空,则无元素可取。
- 取出
<font style="color:rgb(239, 68, 68);background-color:rgb(243, 244, 246);">head</font>指针指向的元素。 - 更新
<font style="color:rgb(239, 68, 68);background-color:rgb(243, 244, 246);">head</font>指针:<font style="color:rgb(239, 68, 68);background-color:rgb(243, 244, 246);">head = (head + 1) % capacity</font>。
4.3 状态判断的挑战与解决
一个有趣的问题出现了:我们如何判断队列是“空”还是“满”?
- 初始状态(空):
<font style="color:rgb(239, 68, 68);background-color:rgb(243, 244, 246);">head</font>和<font style="color:rgb(239, 68, 68);background-color:rgb(243, 244, 246);">tail</font>都指向 0,即<font style="color:rgb(239, 68, 68);background-color:rgb(243, 244, 246);">head == tail</font>。 - 入队一圈后(满): 假设容量为 5,我们依次存入 5 个元素。
<font style="color:rgb(239, 68, 68);background-color:rgb(243, 244, 246);">tail</font>指针会移动 5 次,经过 0 → 1 → 2 → 3 → 4 → 0。此时,<font style="color:rgb(239, 68, 68);background-color:rgb(243, 244, 246);">tail</font>又回到了 0,也出现了<font style="color:rgb(239, 68, 68);background-color:rgb(243, 244, 246);">head == tail</font>的情况!
这就产生了歧义:<font style="color:rgb(239, 68, 68);background-color:rgb(243, 244, 246);">head == tail</font> 既可以表示队列为空,也可以表示队列为满。为了解决这个问题,通常有两种主流方案:
方案一:使用计数器
引入一个额外的变量 <font style="color:rgb(239, 68, 68);background-color:rgb(243, 244, 246);">count</font> 来记录队列中元素的数量。
- 入队成功:
<font style="color:rgb(239, 68, 68);background-color:rgb(243, 244, 246);">count++</font> - 出队成功:
<font style="color:rgb(239, 68, 68);background-color:rgb(243, 244, 246);">count--</font> - 判空:
<font style="color:rgb(239, 68, 68);background-color:rgb(243, 244, 246);">count == 0</font> - 判满:
<font style="color:rgb(239, 68, 68);background-color:rgb(243, 244, 246);">count == capacity</font>
优点: 逻辑直观,易于理解,数组空间可以被完全利用。
方案二:牺牲一个存储空间
约定当 <font style="color:rgb(239, 68, 68);background-color:rgb(243, 244, 246);">tail</font> 的下一个位置是 <font style="color:rgb(239, 68, 68);background-color:rgb(243, 244, 246);">head</font> 时,队列就为满。
- 判空:
<font style="color:rgb(239, 68, 68);background-color:rgb(243, 244, 246);">head == tail</font> - 判满:
<font style="color:rgb(239, 68, 68);background-color:rgb(243, 244, 246);">(tail + 1) % capacity == head</font>
优点: 无需额外变量。
缺点: 数组会浪费一个存储单元,即容量为 <font style="color:rgb(239, 68, 68);background-color:rgb(243, 244, 246);">k</font> 的数组最多只能存 <font style="color:rgb(239, 68, 68);background-color:rgb(243, 244, 246);">k-1</font> 个元素。
为什么能解决歧义?
因为我们人为地规定队列满时,_<font style="color:rgb(239, 68, 68);background-color:rgb(243, 244, 246);">tail</font>_ 和 _<font style="color:rgb(239, 68, 68);background-color:rgb(243, 244, 246);">head</font>_ 之间必须有一个空位。这样,_<font style="color:rgb(239, 68, 68);background-color:rgb(243, 244, 246);">head == tail</font>_ 的情况就唯一地代表队列为空。当队列满时,它们永远不会相遇,而是保持一个位置的距离。
5、可视化演示
下面是一个容量为 5 的环形数组(使用计数器方案)。通过输入数字并点击按钮来亲自体验它的工作流程。
juejin
6、Java实现
基于Java(使用计数器方案)实现一个简易的环形数组。
package array;public class CircularQueue {private int capacity;private Object[] queue;private int head;private int tail;private int count;// 构造方法,初始化队列容量public CircularQueue(int capacity) {this.capacity = capacity;this.queue = new Object[capacity];this.head = 0;this.tail = 0;this.count = 0;}// 检查队列是否为空public boolean isEmpty() {return count == 0;}// 检查队列是否已满public boolean isFull() {return count == capacity;}// 向队列中添加一个元素public boolean enqueue(Object item) {if (isFull()) {System.out.println("Error: Queue is full");return false;}// 在尾指针位置放入元素queue[tail] = item;// 使用取模运算更新尾指针,实现环形效果tail = (tail + 1) % capacity;// 元素数量加一count++;return true;}// 从队列中移除一个元素public Object dequeue() {if (isEmpty()) {System.out.println("Error: Queue is empty");return null;}// 从头指针位置取出元素Object item = queue[head];queue[head] = null; // 可选:将出队位置清空// 使用取模运算更新头指针head = (head + 1) % capacity;// 元素数量减一count--;return item;}// 方便打印队列状态@Overridepublic String toString() {StringBuilder sb = new StringBuilder();sb.append("Queue: [");for (int i = 0; i < capacity; i++) {sb.append(queue[i]);if (i < capacity - 1) {sb.append(", ");}}sb.append("]\n");sb.append("Head: ").append(head).append(", Tail: ").append(tail).append(", Count: ").append(count);return sb.toString();}public static void main(String[] args) {CircularQueue q = new CircularQueue(5);// 插入元素q.enqueue(10);q.enqueue(20);q.enqueue(30);System.out.println(q);// Output:// Queue: [10, 20, 30, null, null]// Head: 0, Tail: 3, Count: 3// 出队System.out.println("Dequeued: " + q.dequeue()); // Dequeued: 10System.out.println(q);// Output:// Queue: [null, 20, 30, null, null]// Head: 1, Tail: 3, Count: 2// 再次添加元素q.enqueue(40);q.enqueue(50);q.enqueue(60);System.out.println(q);// Output:// Queue: [60, 20, 30, 40, 50]// Head: 1, Tail: 1, Count: 5// 再次添加元素 已满 报错q.enqueue(70); // Error: Queue is full}
}
7、总结
环形数组通过取模运算在逻辑上将线性数组的头尾连接起来,形成一个环。它通过维护 <font style="color:rgb(71, 85, 105);">head</font> 和 <font style="color:rgb(71, 85, 105);">tail</font> 两个指针,并巧妙地利用一个空闲单元来区分“满”和“空”状态,从而实现了高效的、固定大小的队列结构。
它的核心优势在于空间复用和O(1)的时间复杂度,避免了数据的频繁移动。
