环形数组技巧

简介: 环形数组通过模运算在逻辑上形成闭环,利用start和end双指针实现首尾O(1)增删。虽物理结构线性,但通过取余操作使指针循环,结合左闭右开区间设计,高效支持动态扩容缩容,适用于队列、双端队列等场景。

😸一句话总结
环形数组技巧利用求模(余数)运算,将普通数组变成逻辑上的环形数组,可以让我们用 O(1) 的时间在数组头部增删元素。
环形数组原理
数组可能是环形的么?不可能。数组就是一块线性连续的内存空间,怎么可能有环的概念?
但是,我们可以在「逻辑上」把数组变成环形的,比如下面这段代码:
// 长度为 5 的数组
int[] arr = new int[]{1, 2, 3, 4, 5};
int i = 0;
// 模拟环形数组,这个循环永远不会结束
while (i < arr.length) {
System.out.println(arr[i]);
i = (i + 1) % arr.length;
}
这段代码的关键在于求模运算 %,也就是求余数。当 i 到达数组末尾元素时,i + 1 和 arr.length 取余数又会变成 0,即会回到数组头部,这样就在逻辑上形成了一个环形数组,永远遍历不完。
这就是环形数组技巧。这个技巧如何帮助我们在 O(1) 的时间在数组头部增删元素呢?
是这样,假设我们现在有一个长度为 6 的数组,现在其中只装了 3 个元素,如下(未装元素的位置用 标识):
[1, 2, 3,
, , ]
现在我们要在数组头部删除元素 1,那么我们可以把数组变成这样:
[, 2, 3, , , ]
即,我们仅仅把元素 1 的位置标记为空,但并不做数据搬移。
此时,如果我们要在数组头部增加元素 4 和元素 5,我们可以把数组变成这样:
[4, 2, 3, , , 5]
你可以看到,当头部没有位置添加新元素时,它转了一圈,把新元素加到尾部了。
核心原理
上面只是让大家对环形数组有一个直观地印象,环形数组的关键在于,它维护了两个指针 start 和 end,start 指向第一个有效元素的索引,end 指向最后一个有效元素的下一个位置索引。
这样,当我们在数组头部添加或删除元素时,只需要移动 start 索引,而在数组尾部添加或删除元素时,只需要移动 end 索引。
当 start, end 移动超出数组边界(< 0 或 >= arr.length)时,我们可以通过求模运算 % 让它们转一圈到数组头部或尾部继续工作,这样就实现了环形数组的效果。
动手环节
关键点、注意开闭区间
在我的代码中,环形数组的区间被定义为左闭右开的,即 [start, end) 区间包含数组元素。所以其他的方法都是以左闭右开区间为基础实现的。
那么肯定就会有读者问,为啥要左闭右开,我就是想两端都开,或者两端都闭,不行么?
理论上,你可以随意设计区间的开闭,但一般设计为左闭右开区间是最方便处理的。
因为这样初始化 start = end = 0 时,区间 [0, 0) 中没有元素,但只要让 end 向右移动(扩大)一位,区间 [0, 1) 就包含一个元素 0 了。
如果你设置为两端都开的区间,那么让 end 向右移动一位后开区间 (0, 1) 仍然没有元素;如果你设置为两端都闭的区间,那么初始区间 [0, 0] 就已经包含了一个元素。这两种情况都会给边界处理带来不必要的麻烦,如果你非要使用的话,需要在代码中做一些特殊处理。
最后,我给出一个支持泛型的 Java 实现,你可以参考一下:
public class CycleArray {
private T[] arr;
private int start;
private int end;
private int count;
private int size;

public CycleArray() {
    this(1);
}

@SuppressWarnings("unchecked")
public CycleArray(int size) {
    this.size = size;
    // 因为 Java 不支持直接创建泛型数组,所以这里使用了类型转换
    this.arr = (T[]) new Object[size];
    // start 指向第一个有效元素的索引,闭区间
    this.start = 0;
    // 切记 end 是一个开区间,
    // 即 end 指向最后一个有效元素的下一个位置索引
    this.end = 0;
    this.count = 0;
}

// 自动扩缩容辅助函数
@SuppressWarnings("unchecked")
private void resize(int newSize) {
    // 创建新的数组
    T[] newArr = (T[]) new Object[newSize];
    // 将旧数组的元素复制到新数组中
    for (int i = 0; i < count; i++) {
        newArr[i] = arr[(start + i) % size];
    }
    arr = newArr;
    // 重置 start 和 end 指针
    start = 0;
    end = count;
    size = newSize;
}

// 在数组头部添加元素,时间复杂度 O(1)
public void addFirst(T val) {
    // 当数组满时,扩容为原来的两倍
    if (isFull()) {
        resize(size * 2);
    }
    // 因为 start 是闭区间,所以先左移,再赋值
    start = (start - 1 + size) % size;
    arr[start] = val;
    count++;
}

// 删除数组头部元素,时间复杂度 O(1)
public void removeFirst() {
    if (isEmpty()) {
        throw new IllegalStateException("Array is empty");
    }
    // 因为 start 是闭区间,所以先赋值,再右移
    arr[start] = null;
    start = (start + 1) % size;
    count--;
    // 如果数组元素数量减少到原大小的四分之一,则减小数组大小为一半
    if (count > 0 && count == size / 4) {
        resize(size / 2);
    }
}

// 在数组尾部添加元素,时间复杂度 O(1)
public void addLast(T val) {
    if (isFull()) {
        resize(size * 2);
    }
    // 因为 end 是开区间,所以是先赋值,再右移
    arr[end] = val;
    end = (end + 1) % size;
    count++;
}

// 删除数组尾部元素,时间复杂度 O(1)
public void removeLast() {
    if (isEmpty()) {
        throw new IllegalStateException("Array is empty");
    }
    // 因为 end 是开区间,所以先左移,再赋值
    end = (end - 1 + size) % size;
    arr[end] = null;
    count--;
    // 缩容
    if (count > 0 && count == size / 4) {
        resize(size / 2);
    }
}

// 获取数组头部元素,时间复杂度 O(1)
public T getFirst() {
    if (isEmpty()) {
        throw new IllegalStateException("Array is empty");
    }
    return arr[start];
}

// 获取数组尾部元素,时间复杂度 O(1)
public T getLast() {
    if (isEmpty()) {
        throw new IllegalStateException("Array is empty");
    }
    // end 是开区间,指向的是下一个元素的位置,所以要减 1
    return arr[(end - 1 + size) % size];
}

public boolean isFull() {
    return count == size;
}

public int size() {
    return count;
}

public boolean isEmpty() {
    return count == 0;
}

}
文末思考
数组增删头部元素的效率真的只能是 O(N) 么?
我们都说,在数组增删头部元素的时间复杂度是 O(N),因为需要搬移元素。但是,如果我们使用环形数组,其实是可以实现在 O(1) 的时间复杂度内增删头部元素的。
当然,上面实现的这个环形数组只提供了 addFirst, removeFirst, addLast, removeLast 这几个方法,并没有提供 我们之前实现的动态数组 的某些方法,比如删除指定索引的元素,获取指定索引的元素,在指定索引插入元素等等。
但是你可以思考一下,难道环形数组实现不了这些方法么?环形数组实现这些方法,时间复杂度相比普通数组,有退化吗?好像没有吧。环形数组也可以删除指定索引的元素,也要做数据搬移,和普通数组一样,复杂度是 O(N);环形数组也可以获取指定索引的元素(随机访问),只不过不是直接访问对应索引,而是要通过 start 计算出真实索引,但计算和访问的时间复杂度依然是 O(1);环形数组也可以在指定索引插入元素,当然也要做数据搬移,和普通数组一样,复杂度是 O(N)。
你可以思考一下是不是这样。如果是这样,为什么编程语言的标准库中提供的动态数组容器底层并没有用环形数组技巧。

相关文章
|
算法 C++ 索引
【C++STL基础入门】深入浅出string类查找字串、返回字串和交换操作
【C++STL基础入门】深入浅出string类查找字串、返回字串和交换操作
1171 1
|
23天前
|
存储 安全 网络安全
2026年OpenClaw(Clawdbot)小白部署教程及服务器安全配置指南
OpenClaw(原Clawdbot)作为阿里云生态下的轻量级AI自动化代理工具,2026年版本在便捷部署的同时,也对服务器安全提出了更高要求——尤其是对接第三方平台、处理敏感业务数据时,服务器的权限管控、数据加密、网络防护直接决定使用安全。本文将先完整拆解阿里云OpenClaw一键部署的全流程,再从网络防护、权限管控、数据安全、日志审计四大维度,给出可落地的服务器安全配置方案,包含实操代码命令与安全加固技巧,兼顾部署便捷性与使用安全性。
1117 4
|
3月前
|
人工智能 自然语言处理 前端开发
SpringAI+DeepSeek大模型应用开发
SpringAI整合主流大模型,支持对话、函数调用与RAG,提供统一API,简化开发。涵盖多模态、流式传输、会话记忆等功能,助力快速构建AI应用。
|
3月前
|
存储 缓存 算法
哈希表核心原理 哈希表等于Map吗?
哈希表不等于Map。Map是键值映射的接口,哈希表(如HashMap)是其一种实现。增删查改O(1)的前提是哈希函数高效且冲突处理得当。本文详解哈希表原理、哈希冲突解决、负载因子与key不可变性,助你深入理解底层机制。
|
3月前
|
Java API
用数组实现队列/栈
使用数组实现栈时,可将动态数组尾部作为栈顶,利用ArrayList的add和remove操作实现push、pop等,时间复杂度均为O(1)。若以头部为栈顶,则需借助环形数组CycleArray实现高效操作。同样,基于CycleArray可在首尾分别进行出队和入队,轻松实现队列功能,保证操作效率。
|
3月前
|
存储 监控 NoSQL
07 | NoSQL 检索:为什么日志系统主要用 LSM 树而非 B+ 树?
B+树适用于关系型数据库,但在日志、监控等高频写入场景下性能受限。LSM树通过将数据分内存(C0树)和磁盘(C1树)两层,利用批量写入、WAL日志恢复与滚动合并机制,大幅提升写入效率,更适合写多读少的大数据应用。
|
3月前
|
存储 NoSQL 定位技术
13 | 空间检索(上):如何用 Geohash 实现「查找附近的人」功能?
本文介绍了如何高效实现“查找附近的人”功能,针对大规模系统提出基于区域划分与Geohash编码的检索方案。通过将二维空间划分为带编号的区域,并利用一维编码(如Geohash)建立索引,可大幅提升查询效率。支持非精准与精准两种模式:前者直接查所在区域,后者结合邻近8区域扩大候选集以保证准确性。Geohash将经纬度转为字符串编码,便于存储与比较,广泛应用于Redis等系统。适用于社交、餐饮、出行等LBS场景。
|
3月前
|
搜索推荐 算法 UED
15 | 最近邻检索(上):如何用局部敏感哈希快速过滤相似文章?
在搜索引擎与推荐系统中,相似文章去重至关重要。通过向量空间模型将文档转化为高维向量,利用SimHash等局部敏感哈希技术生成紧凑指纹,结合海明距离与抽屉原理分段索引,可高效检索近似重复内容,在百亿网页中快速过滤雷同结果,提升用户体验。该方法适用于文本、图像等多种对象的相似性检测。
|
3月前
|
机器学习/深度学习 搜索推荐 算法
12 | 非精准 Top K 检索:如何给检索结果的排序过程装上加速器
本文介绍了非精准 Top K 检索的优化思路与三种实现方法:基于静态质量得分排序截断、胜者表利用词频打分、分层索引两阶段检索。核心思想是将复杂计算移至离线,在线快速截断,降低打分开销。结合精准检索的两阶段架构,可显著提升检索效率,广泛应用于搜索与推荐系统中。
|
3月前
|
存储 搜索推荐 定位技术
14 | 空间检索(下):「查找最近的加油站」和「查找附近的人」有何不同?
本文探讨了动态范围内查找“最近的k个”地理对象的高效检索方案。针对查询范围不固定的应用场景,如找最近加油站或医院,传统GeoHash分块检索效率低。文章提出利用四叉树、非满四叉树和前缀树优化:四叉树通过层次化空间划分支持快速范围扩展;非满四叉树动态分裂节点,提升稀疏数据下的存储利用率;前缀树则适用于GeoHash字符串编码的索引,实现高效路径匹配。进一步介绍了k-d树在高维空间的应用局限,并引出高维场景下的近邻检索挑战。