数组-数组概述【arr1】
1、什么是数组?
数组是一种线性数据结构,它是相同类型元素的有序集合,这些元素在内存中连续存储。每个元素都有一个唯一的索引(下标),通过索引可以直接访问对应的元素。
:::info
何谓线性数据结构
线性数据结构(Linear Data Structure)是计算机科学中一类基础的数据组织方式,其核心特点是数据元素之间按线性顺序排列,每个元素至多有一个前驱(前一个元素)和一个后继(后一个元素)。
- 线性结构的数据元素在逻辑上是连续的,但在物理存储上可能连续(如数组)或非连续(如链表)。
:::
核心三要素:
- 线性结构:数据像一条线一样排列,每个元素(除了第一个和最后一个)都有一个前驱和一个后继。
- 相同类型:数组中的所有元素都必须是同一种数据类型,比如全是整数或全是字符串。
- 连续存储:这是数组最核心的特征。元素在内存中的地址是紧挨着的。
数组(Array)的核心思想,是把相同类型的数据一个挨着一个地整齐排列起来,就像一排有编号的储物柜。每个“储物柜”都有一个唯一的地址(我们称之为索引或下标),通过这个地址,我们可以非常快地找到里面存放的东西。
2、内存结构和存储
想象一下,计算机的内存 🧠 就像一条很长很长的街道,街道上有很多房子,每个房子都有一个唯一的门牌号(这就是内存地址)。
当我们要创建一个数组时,就相当于在这条街上申请了一排连续的、大小完全相同的房子。
这有两个非常重要的特点:
- **连续存放 **:数组里的所有元素都是肩并肩挨在一起的,中间没有空隙。
- 相同类型:每个“房子”里存放的东西类型都一样大。比如,我们规定这排房子都只能放整数(integer),那么就不能在中间放一个字符串(string)。
因为有了这两个特点,计算机管理数组就变得非常高效。它只需要记住这排房子的起始门牌号(我们称之为基地址),然后给每个房子从 0 开始编号(这就是索引 Index)。
一个长度为 N 的数组,其索引范围是从 0 到 N-1。

内存中数组布局
举个例子,假设我们有一个包含 5 个整数的数组 numbers,它的基地址是 1000,并且每个整数占 4 个字节的内存空间。

看到了吗?地址是连续的,每个地址之间相差 4 个字节(一个整数的大小)。
地址计算公式
正是因为这种整齐的排列,计算机可以瞬间定位到任何一个元素。它使用一个简单的公式:
目标地址 = 基地址 + 索引 × 单个元素的大小
例如,要找索引为 3 的元素(也就是 40),计算机会这么算: 1012 = 1000 + 3 × 4
这个计算非常快,几乎不花时间。这就是为什么数组的“读取”操作效率极高的原因。
:::info
思考一下,索引为什么从0开始,而不是1呢?
数组的下标从 0 开始 是计算机科学中的一个经典设计选择,其背后涉及 底层实现、历史背景、数学逻辑和效率优化 多方面的考量。 0 开始它并非绝对正确,但在实际应用中已被证明是最优解之一。
其实主要是为了寻址方便。
- 下标 0 表示偏移量为 0,即直接指向数组的起始地址。
例如,数组第一个元素的地址是<font style="color:rgb(44, 44, 54);background-color:rgba(175, 184, 193, 0.2);">base + 0 × size = base</font>,无需额外计算。 - 如果下标从 1 开始,则第一个元素的地址为
<font style="color:rgb(44, 44, 54);background-color:rgba(175, 184, 193, 0.2);">base + 1 × size</font>,需要多一次加法操作。尤其是复杂的数组运算时,从0开始能省去不少运算。虽然现代处理器对此优化明显,但在早期硬件性能受限时,这种设计能节省宝贵的计算资源。
:::
3、数组操作
3.1 访问操作
通过索引能直接访问数组元素,时间复杂度为 O(1)。

比如访问numbers[2],对应元素30,能立马获取到。
在 Java中,我们通常用数组来表示:
// 创建一个int数组
int[] numbers = {10, 20, 30, 40, 50};
// 访问索引为2的元素
int element = numbers[2];
System.out.println("索引为 2 的元素是: " + element);

因为元素是连续存放的,计算机可以通过一个简单的数学公式直接计算出任何一个元素的内存地址,而不需要从头开始一个一个地查找。
不管我是访问 numbers[0] 还是 numbers[4],速度都一样快。 我可以“随机”跳到任何一个位置,耗时都是一样的 , 这个“随机访问”特性非常宝贵,是数组高性能的基石。
3.2 更新操作
修改指定索引位置的元素值,时间复杂度为 O(1)。
比如把numbers[2]元素30改为35

// 创建整数数组
int[] numbers = {10, 20, 30, 40, 50};
// 打印修改前的数组
System.out.print("修改前的数组: [");
for (int i = 0; i < numbers.length; i++) {System.out.print(numbers[i]);if (i < numbers.length - 1) {System.out.print(", ");}
}
System.out.println("]");// 通过索引修改元素值
numbers[2] = 35;
// 打印修改后的数组
System.out.print("修改后的数组: [");
for (int i = 0; i < numbers.length; i++) {System.out.print(numbers[i]);if (i < numbers.length - 1) {System.out.print(", ");}
}
System.out.println("]");

整个“更新/修改”操作,本质上就是 一次“读取”(找到位置) + 一次“写入”(放入新值),这两步都极快,所以总的耗时也非常短,时间复杂度为 O(1)。
3.3 插入操作
在数组中插入数据,时间复杂度从O(1)到O(n)不等

// 初始数组
int[] myArray = {10, 20, 30, 40, 50};
// 要插入的位置和值
int index = 2;
int value = 25;
// 创建新数组(长度+1)
int[] newArray = new int[myArray.length + 1];
// 复制插入位置前的元素
for (int i = 0; i < index; i++) {newArray[i] = myArray[i];
}
// 插入新元素
newArray[index] = value;
// 复制插入位置后的元素(整体后移一位)
for (int i = index; i < myArray.length; i++) {newArray[i + 1] = myArray[i];
}
// 输出结果
System.out.println("插入后的数组: " + Arrays.toString(newArray));

为了保持内存的连续性,当你在数组中间(例如索引<font style="color:rgb(30, 41, 59);"> i</font>)插入一个新元素时,必须为它腾出空间。这意味着从索引 <font style="color:rgb(30, 41, 59);">i</font> 开始到数组末尾的所有元素,都必须依次向后移动一个位置。如果数组有 n 个元素,在最坏的情况下(在索引0处插入),你需要移动 n 个元素,所以时间复杂度是 O(n)。
3.4 删除操作
与插入类似,删除中间的元素也需要移动其他元素来填补空缺。时间复杂度为O(n)
原生数组实现:
import java.util.Arrays;public class Main {public static void main(String[] args) {// 初始数组int[] myArray = {10, 15, 20, 30, 40, 50};// 要删除的索引int index = 2;// 创建新数组(长度=原数组长度-1)int[] newArray = new int[myArray.length - 1];// 复制删除位置前的元素for (int i = 0; i < index; i++) {newArray[i] = myArray[i];}// 复制删除位置后的元素(整体左移一位)for (int i = index; i < newArray.length; i++) {newArray[i] = myArray[i + 1];}// 输出结果System.out.println("删除后的数组: " + Arrays.toString(newArray));}
}
使用ArrayList实现:
import java.util.ArrayList;
import java.util.Arrays;public class Main {public static void main(String[] args) {// 初始化ArrayListArrayList<Integer> myList = new ArrayList<>(Arrays.asList(10, 15, 20, 30, 40, 50));// 删除索引为2的元素myList.remove(2);// 输出结果System.out.println("删除后的数组: " + myList);}
}
同样是为了保持内存的连续性。当你删除索引 i 的元素后,会留下一个“空洞”。为了填补这个空洞,从索引 <font style="color:rgb(30, 41, 59);">i+1</font> 开始到数组末尾的所有元素,都必须依次向前移动一个位置。在最坏的情况下(删除索引0的元素),也需要移动 n-1 个元素,所以时间复杂度是 O(n)。
4、可视化
下方是一个交互式演示,可以帮助你直观地理解插入和删除操作中“元素移动”的过程。
juejin
5、静态数组和动态数组
数组有两种类型,静态数组和动态数组
5.1 静态数组
静态数组是在编译时确定大小的数组,一旦创建,大小就无法改变。像 C++ 或 Java 中的普通数组,在创建时必须指定大小,且之后大小不能改变。如果空间用完了,就无法再添加新元素。
静态数组的特点是什么?
- 固定大小:编译时确定,运行时不可改变
- 栈内存分配:通常分配在栈上,访问速度快
- 连续内存:保证内存地址连续
- 无额外开销:没有动态分配的开销
- 编译时优化:编译器可以进行更多优化
5.2 动态数组
动态数组是在运行时可以改变大小的数组,如Python的list、C++的vector。它们在底层实现上仍然是静态数组,但提供了一种自动扩容的机制。
动态数组的特点是什么?
- 可变大小:运行时可以增长或缩小
- 堆内存分配:通常分配在堆上,更灵活
- 自动扩容:容量不足时自动分配更大空间
- 额外开销:需要存储容量信息和管理元数据
- 内存重分配:扩容时可能需要复制所有元素
动态数组扩容分析
动态数组是如何实现“动态”的?—— 空间预留与扩容
当一个动态数组被填满时,如果还想添加新元素,会发生以下事情:
- 分配新空间:系统会开辟一块更大的新内存空间(通常是原大小的1.5倍或2倍)。
- 复制数据:将旧数组中的所有元素逐个复制到新的内存空间中。
- 释放旧空间:释放掉原来的、较小的内存空间。
- 添加新元素:在新数组的末尾添加新元素。
这个扩容过程本身是 O(n) 的,因为它需要复制所有元素。但由于它不是每次添加都发生,而是“偶尔”发生一次,经过均摊计算后,向动态数组末尾添加元素的平均时间复杂度依然是 O(1)。
不同的扩容策略会影响性能和内存使用效率。
:::info
🤔 为什么不每次只增加1个位置?
如果每次只增加1个位置,那么插入n个元素需要O(n²)时间:
- 第1次插入:复制0个元素
- 第2次插入:复制1个元素
- …
- 总计:0+1+2+…+(n-1) = O(n²)
:::
| 扩容策略 | 扩容系数 | 平均插入时间 | 空间利用率 | 优缺点 |
|---|---|---|---|---|
| 翻倍增长 | 2x | O(1) | 50-100% | 性能好,但可能浪费内存 |
| 1.5倍增长 | 1.5x | O(1) | 67-100% | 平衡性能和内存 |
| 线性增长 | +k | O(n) | 90-100% | 内存省,但性能差 |
Java ArrayList 扩容机制的详细说明表格:
| 特性 | 具体说明 |
|---|---|
| 初始容量 | 1. 无参构造器:默认初始容量为 10(JDK 8 及以上,早期版本可能不同) 2. 带参构造器:可指定初始容量(如 <font style="color:rgb(30, 41, 59);">new ArrayList<>(20)</font> 直接初始化容量为 20) |
| 扩容触发条件 | 当添加元素后,实际元素数量(<font style="color:rgb(30, 41, 59);">size</font>)大于当前容量(<font style="color:rgb(30, 41, 59);">capacity</font>)时触发扩容 |
| 扩容计算方式 | 1. 常规情况:新容量 = 旧容量 + 旧容量 / 2(即旧容量的 1.5 倍,通过位运算 <font style="color:rgb(30, 41, 59);">oldCapacity >> 1</font> 实现,效率更高) 2. 特殊情况:若计算出的新容量仍小于所需最小容量(如一次性添加大量元素),则直接将新容量设为“所需最小容量” |
| 扩容后容量示例 | 1. 旧容量 10 → 新容量 15(10 + 10/2 = 15) 2. 旧容量 15 → 新容量 22(15 + 7 = 22,15/2 取整为 7) 3. 若需添加 20 个元素,旧容量 10 不足,则直接扩容至 20 |
| 扩容底层操作 | 1. 创建一个新的数组(长度为计算出的新容量) 2. 将原数组中的元素复制到新数组中 3. 丢弃原数组,引用指向新数组 |
| 设计目标 | 通过较大幅度的扩容(1.5 倍)减少扩容次数,降低频繁数组复制的性能开销,优先保证操作效率 |
注:以上逻辑基于 JDK 8 及以上版本,不同 JDK 版本可能存在细微实现差异,但核心扩容策略(1.5 倍扩容)保持一致。
6、时间复杂度分析
不同操作的时间复杂度直接影响数组的使用场景。
| 操作 | 时间复杂度 | 原因说明 | 适用场景 |
|---|---|---|---|
| 访问 | O(1) | 直接地址计算 | 需要频繁随机访问 |
| 搜索 | O(n) | 线性扫描查找 | 无序数据查找 |
| 插入 | O(n) | 元素移动开销 | 不频繁插入操作 |
| 删除 | O(n) | 元素移动开销 | 不频繁删除操作 |
| 尾部插入 | O(1)* | 平均复杂度 | 动态数组常用 |
:::danger
注意:尾部插入的O(1)是平均情况,当需要扩容时为O(n)。
:::
7、Java中的数组
数组的基本特性
- 固定长度:数组一旦创建,长度不可修改(与
ArrayList等动态容器不同)。 - 相同数据类型:数组中的所有元素必须是同一类型(包括基本类型和引用类型)。
- 连续内存空间:元素在内存中连续存储,通过索引快速访问(时间复杂度 O(1))。
- 索引从 0 开始:第一个元素索引为 0,最后一个元素索引为
length - 1。
数组的声明与初始化
- 声明方式
// 声明基本类型数组(以 int 为例)
int[] arr1; // 推荐方式
int arr2[]; // 兼容 C 语言的写法,不推荐// 声明引用类型数组(以 String 为例)
String[] strArr1;
- 初始化方式
(1)静态初始化(直接指定元素)
创建数组时直接赋值,长度由元素数量自动确定:
int[] numbers = {10, 20, 30, 40}; // 长度为 4
String[] names = {"Alice", "Bob"}; // 长度为 2
(2)动态初始化(指定长度,后续赋值)
先指定数组长度,元素默认初始化(基本类型为默认值,引用类型为 null):
int[] scores = new int[3]; // 长度为 3,默认值为 0
scores[0] = 90;
scores[1] = 85;
scores[2] = 95;String[] books = new String[2]; // 长度为 2,默认值为 null
books[0] = "Java编程";
三、数组的访问与遍历
- 访问元素
通过索引访问或修改元素:
int[] arr = {10, 20, 30};
System.out.println(arr[0]); // 访问索引 0 的元素,输出 10
arr[1] = 25; // 修改索引 1 的元素为 25
- 遍历数组
- for 循环:通过索引遍历
int[] arr = {10, 20, 30};
for (int i = 0; i < arr.length; i++) { // length 是数组的属性(非方法)System.out.println(arr[i]);
}
- 增强 for 循环(for-each):直接遍历元素(无法修改元素值)
for (int num : arr) { // 逐个取元素赋值给 numSystem.out.println(num);
}
四、数组的常见操作
- 数组长度
通过 length 属性获取(注意:与 String 的 length() 方法不同,数组是 length 字段):
int[] arr = {1, 2, 3};
System.out.println(arr.length); // 输出 3
- 数组复制
System.arraycopy():高效复制数组(native 方法)
int[] src = {10, 20, 30};
int[] dest = new int[3];
// 参数:源数组、源起始索引、目标数组、目标起始索引、复制长度
System.arraycopy(src, 0, dest, 0, 3); // dest 变为 [10, 20, 30]
Arrays.copyOf():创建新数组并复制(更简洁)
import java.util.Arrays; // 需导入int[] src = {10, 20, 30};
int[] dest = Arrays.copyOf(src, 3); // 复制全部元素
int[] dest2 = Arrays.copyOf(src, 5); // 长度 5,多余位置补默认值(0)
- 数组排序
使用 Arrays.sort() 进行排序(默认升序):
int[] arr = {30, 10, 20};
Arrays.sort(arr); // 排序后:[10, 20, 30]
8、多维数组
Java 支持多维数组(本质是“数组的数组”),以二维数组为例:
// 静态初始化
int[][] matrix = {{1, 2, 3},{4, 5, 6}
};// 动态初始化
int[][] matrix2 = new int[2][3]; // 2 行 3 列
matrix2[0][0] = 1;
matrix2[1][2] = 6;// 遍历二维数组
for (int i = 0; i < matrix.length; i++) { // 行for (int j = 0; j < matrix[i].length; j++) { // 列System.out.print(matrix[i][j] + " ");}System.out.println();
}
数组与 ArrayList 的核心区别
| 特性 | 数组(int[] 等) | ArrayList |
|---|---|---|
| 长度 | 固定,创建后不可变 | 动态可变,自动扩容 |
| 数据类型 | 支持基本类型和引用类型 | 仅支持引用类型(需包装类如 Integer) |
| 方法支持 | 无内置方法(依赖 Arrays 工具类) | 有丰富方法(add()、remove() 等) |
| 内存效率 | 更高(无额外开销) | 较低(有扩容预留空间和对象头开销) |
总结
数组适合存储固定数量、同类型的元素,优势是访问速度快、内存高效;但由于长度固定,灵活性较低。若需动态增删元素,建议使用
ArrayList等集合类。
9、总结
优点
- 访问速度快:基于索引的随机访问时间复杂度为 O(1)。
- 实现简单:基础数组的逻辑和实现都相对简单。
- 内存连续:有利于CPU缓存,可以提高遍历速度。
缺点
- 插入/删除慢:在数组中间操作需要移动大量元素,时间复杂度为 O(n)。
- 大小固定(静态数组):不够灵活,可能造成空间浪费或不足。
- 扩容成本高(动态数组):虽然均摊复杂度低,但单次扩容可能导致瞬间的性能抖动。
