环形数组技巧

简介: 环形数组通过模运算在逻辑上形成闭环,利用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)。
你可以思考一下是不是这样。如果是这样,为什么编程语言的标准库中提供的动态数组容器底层并没有用环形数组技巧。

相关文章
|
Python
python使用pip镜像源加速安装包(清华、阿里、中科大)
python使用pip镜像源加速安装包(清华、阿里、中科大)
10465 0
python使用pip镜像源加速安装包(清华、阿里、中科大)
|
Java Maven
IDEA 2021 整合 SSM 配置离线 Maven 3.8.1 报错大全 Since Maven 3.8.1 http repositories are blocked.
IDEA 2021 整合 SSM 配置离线 Maven 3.8.1 报错大全 Since Maven 3.8.1 http repositories are blocked.
8448 0
IDEA 2021 整合 SSM 配置离线 Maven 3.8.1 报错大全 Since Maven 3.8.1 http repositories are blocked.
|
消息中间件 运维 监控
微服务架构的优点和缺点分别有哪些?
微服务架构的优点和缺点分别有哪些?
1395 0
微服务架构的优点和缺点分别有哪些?
|
7月前
|
SQL JSON 关系型数据库
慢SQL排查三板斧:SHOW PROCESSLIST + 慢查询日志 + EXPLAIN 实战
教你三招快速定位CPU 100%元凶:SHOW PROCESSLIST查活跃查询、开启慢日志+mysqldumpslow分析、EXPLAIN深度诊断SQL性能。干货不啰嗦,专治线上急症!
|
7月前
|
Kubernetes Cloud Native Java
Spring Boot 4.0:云原生 Java 开发的范式革命
Spring Boot 4.0是面向云原生的里程碑版本:基于JDK 21虚拟线程,启动更快、内存更省;全面升级AOT编译、响应式Web/数据访问、K8s原生支持及统一可观测性。
768 3
|
人工智能 API 开发者
这个「看图识万物」API,直接把万物百科打包进你的应用
看图识万物提供对话式多模态识别:不止识别物体,更能问答明星新作、动植物品种、文物背景等,并融合实时网络与百科知识。支持URL/Base64输入,开箱即用,大幅降低开发成本。
394 0
|
11月前
|
存储 人工智能 前端开发
基于若依框架+AI的养老项目
中州养老系统是专为养老院打造的一体化管理软件,覆盖来访、入住、服务、财务等全流程。分管理后台与家属端,实现机构高效运营与家属便捷互动。技术上采用Vue3+Element Plus前端,若依+SpringBoot后端,MySQL+Redis存储,集成阿里云IOT、OSS及AI工具,助力智慧养老。
|
9月前
|
传感器 资源调度 数据处理
【滤波跟踪】基于EkF和无迹卡尔曼滤波(UKF)的目标跟踪Matlab代码,核心是利用笛卡尔坐标系下的状态转移模型处理位置、速度等状态估计
✅作者简介:热爱科研的Matlab仿真开发者,擅长数据处理、建模仿真、程序设计、完整代码获取、论文复现及科研仿真。 🍎 往期回顾关注个人主页:Matlab科研工作室 👇 关注我领取海量matlab电子书和数学建模资料 🍊个人信条:格物致知,完整Matlab代码获取及仿真咨询内容私信。 🔥 内容介绍 一、核心痛点:目标跟踪中的状态估计难题 在雷达探测、视觉跟踪、无人机导航等场景中,目标跟踪的核心是通过传感器(如雷达、摄像头)观测数据,实时估计目标的位置、速度、加速度等状态。但实际场景中存在三大核心难题: 非线性系统耦合:目标运动常呈现非线性特性(如匀速转弯、机动加速),而
477 6
|
计算机视觉
YOLOv5改进 | 2023 | DWRSeg扩张式残差助力小目标检测 (附修改后的C2f+Bottleneck)
YOLOv5改进 | 2023 | DWRSeg扩张式残差助力小目标检测 (附修改后的C2f+Bottleneck)
902 1