数组基础详解:概念、存储结构与常用操作
数组基础详解:概念、存储结构与常用操作
前言
数组是最基础、也最常用的数据结构之一。很多复杂结构,例如动态数组、栈、队列、堆、哈希表、矩阵、字符串处理和部分图像数据处理,本质上都离不开数组思想。
学习数组时,重点不是只会写 int[] arr = new int[10],而是要理解:
- 数组为什么可以通过下标快速访问?
- 数组为什么插入、删除不方便?
- Java 中数组是对象还是裸内存?
- 一维数组、多维数组、不规则数组有什么区别?
- 什么时候用数组,什么时候用
ArrayList?
本文以 Java 为例,从概念、存储模型、常用操作、复杂度和实际使用场景几个角度系统梳理数组基础。
数组是什么?
数组是一组相同类型元素的有序集合。数组中的每个元素都有一个下标,通常从 0 开始。
例如:
int[] numbers = {10, 20, 30, 40};
对应关系是:
| 下标 | 元素 |
|---|---|
| 0 | 10 |
| 1 | 20 |
| 2 | 30 |
| 3 | 40 |
数组的核心特点:
- 元素类型一致:例如
int[]只能存int; - 长度固定:Java 数组创建后长度不能改变;
- 支持下标访问:可以通过
arr[index]访问元素; - 访问效率高:按下标访问通常是 O(1);
- 插入删除成本高:中间插入或删除通常需要移动元素或复制数组。
数组的存储结构
从底层模型看,数组通常可以理解为一段连续的存储空间。
假设数组起始地址是 base,每个元素占用 size 个字节,那么第 i 个元素的位置可以抽象为:
元素地址 = base + i × size
这也是数组能够 O(1) 按下标访问的原因:只要知道起始位置、元素大小和下标,就可以直接定位元素。
不过在 Java 中,需要注意:
Java 不暴露 C/C++ 意义上的裸指针。数组是对象,数组变量保存的是对数组对象的引用。
也就是说,连续存储是理解数组的底层模型;在 Java 代码层面,我们通过引用和下标操作数组,而不是直接操作内存地址。
Java 中数组是对象
Java 语言规范中,数组是对象。数组可以动态创建,也可以赋值给 Object 类型变量。
int[] arr = {1, 2, 3};
Object obj = arr;
System.out.println(arr.length); // 3
数组变量保存的是引用。如果两个变量指向同一个数组对象,通过其中一个变量修改元素,另一个变量也能看到变化:
int[] a = {1, 2, 3};
int[] b = a;
b[0] = 100;
System.out.println(a[0]); // 100
这里不是“Java 指针操作”,而是两个引用变量指向同一个数组对象。
如何创建和初始化数组
一维数组
创建固定长度数组:
int[] arr = new int[5];
System.out.println(Arrays.toString(arr)); // [0, 0, 0, 0, 0]
创建并初始化:
int[] arr1 = new int[]{1, 2, 3, 4, 5};
int[] arr2 = {1, 2, 3, 4, 5};
Java 会为数组元素提供默认值:
| 类型 | 默认值 |
|---|---|
| 整数类型 | 0 |
| 浮点类型 | 0.0 |
boolean |
false |
| 引用类型 | null |
二维数组
二维数组可以用来表示矩阵、表格等数据:
int[][] matrix = new int[2][3];
初始化二维数组:
int[][] matrix = {
{1, 2, 3},
{4, 5, 6},
{7, 8, 9}
};
遍历二维数组:
for (int[] row : matrix) {
System.out.println(Arrays.toString(row));
}
不规则数组
Java 的二维数组本质上是“数组的数组”,因此每一行长度可以不同。
int[][] jagged = new int[2][];
jagged[0] = new int[]{1, 2, 3};
jagged[1] = new int[]{4, 5};
for (int[] row : jagged) {
System.out.println(Arrays.toString(row));
}
输出:
[1, 2, 3]
[4, 5]
这种结构也叫 jagged array,不规则数组。
数组的常用操作
1. 访问元素
int[] arr = {10, 20, 30};
System.out.println(arr[1]); // 20
按下标访问的时间复杂度是 O(1)。
2. 修改元素
arr[1] = 200;
System.out.println(Arrays.toString(arr)); // [10, 200, 30]
3. 遍历数组
普通 for 循环:
for (int i = 0; i < arr.length; i++) {
System.out.println(arr[i]);
}
增强 for 循环:
for (int value : arr) {
System.out.println(value);
}
如果需要下标,使用普通 for;如果只是读取元素,增强 for 更简洁。
4. 插入元素
Java 数组长度固定,不能在原数组上直接扩容。插入元素通常需要创建新数组并复制元素。
在尾部追加:
int[] arr = {1, 2, 3};
int[] newArr = Arrays.copyOf(arr, arr.length + 1);
newArr[newArr.length - 1] = 4;
System.out.println(Arrays.toString(newArr)); // [1, 2, 3, 4]
在中间插入:
int[] arr = {1, 2, 4, 5};
int index = 2;
int value = 3;
int[] newArr = new int[arr.length + 1];
System.arraycopy(arr, 0, newArr, 0, index);
newArr[index] = value;
System.arraycopy(arr, index, newArr, index + 1, arr.length - index);
System.out.println(Arrays.toString(newArr)); // [1, 2, 3, 4, 5]
5. 删除元素
删除元素也需要创建新数组,或者在原数组中移动元素并维护有效长度。
int[] arr = {1, 2, 3, 4, 5};
int index = 1;
int[] newArr = new int[arr.length - 1];
System.arraycopy(arr, 0, newArr, 0, index);
System.arraycopy(arr, index + 1, newArr, index, arr.length - index - 1);
System.out.println(Arrays.toString(newArr)); // [1, 3, 4, 5]
删除中间元素的时间复杂度通常是 O(n),因为需要移动或复制后续元素。
6. 复制数组
常见复制方式:
int[] arr = {1, 2, 3};
int[] copy1 = Arrays.copyOf(arr, arr.length);
int[] copy2 = new int[arr.length];
System.arraycopy(arr, 0, copy2, 0, arr.length);
Arrays.copyOf() 更易读;System.arraycopy() 更底层,适合精确控制复制范围。
数组操作复杂度
| 操作 | 时间复杂度 | 说明 |
|---|---|---|
| 按下标访问 | O(1) | 直接通过下标定位 |
| 修改指定下标 | O(1) | 不改变数组长度 |
| 遍历 | O(n) | 需要访问全部元素 |
| 线性查找 | O(n) | 无序数组常用 |
| 二分查找 | O(log n) | 要求数组有序 |
| 尾部插入 | O(n) | 固定数组需要扩容复制 |
| 中间插入 | O(n) | 需要移动或复制元素 |
| 删除元素 | O(n) | 需要移动或复制元素 |
| 排序 | 取决于算法 | 可使用 Arrays.sort() |
数组读快、改指定位置快,但插入删除不灵活。这也是为什么 Java 提供了 ArrayList 来封装动态扩容和元素移动。
线性查找
线性查找适用于无序数组。它从头到尾逐个比较元素。
public static int linearSearch(int[] arr, int target) {
for (int i = 0; i < arr.length; i++) {
if (arr[i] == target) {
return i;
}
}
return -1;
}
示例:
int[] arr = {3, 8, 2, 5, 1};
System.out.println(linearSearch(arr, 5)); // 3
时间复杂度:O(n)。
二分查找
二分查找适用于有序数组。每次比较中间元素,并把查找范围缩小一半。
public static int binarySearch(int[] arr, int target) {
int left = 0;
int right = arr.length - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (arr[mid] == target) {
return mid;
} else if (arr[mid] < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return -1;
}
示例:
int[] arr = {1, 2, 3, 4, 5, 6, 7};
System.out.println(binarySearch(arr, 5)); // 4
时间复杂度:O(log n)。
注意:
二分查找要求数组已经有序。如果数组无序,必须先排序,或者使用线性查找。
排序与 Arrays 工具类
Java 标准库提供了 java.util.Arrays,可以完成很多常用数组操作。
Arrays.toString
int[] arr = {3, 1, 2};
System.out.println(Arrays.toString(arr)); // [3, 1, 2]
Arrays.sort
Arrays.sort(arr);
System.out.println(Arrays.toString(arr)); // [1, 2, 3]
Arrays.binarySearch
int index = Arrays.binarySearch(arr, 2);
System.out.println(index);
使用 Arrays.binarySearch() 前,需要确保数组已经排序。
Arrays.copyOf
int[] copy = Arrays.copyOf(arr, arr.length + 1);
Arrays.equals
int[] a = {1, 2, 3};
int[] b = {1, 2, 3};
System.out.println(Arrays.equals(a, b)); // true
Arrays.fill
int[] arr = new int[5];
Arrays.fill(arr, 7);
System.out.println(Arrays.toString(arr)); // [7, 7, 7, 7, 7]
数组与 ArrayList 的区别
数组和 ArrayList 都可以按顺序保存元素,但它们的使用方式和适用场景不同。
| 维度 | 数组 | ArrayList |
|---|---|---|
| 长度 | 创建后固定 | 可动态扩容 |
| 元素类型 | 可存基本类型和对象引用 | 只能存对象,基本类型会装箱 |
| 下标访问 | O(1) | O(1) |
| 插入删除 | 需要手动移动或复制 | 内部封装移动和扩容 |
| API 丰富度 | 较少 | 丰富 |
| 内存和性能 | 更直接,基本类型数组更紧凑 | 更易用,但有对象和扩容成本 |
| 适用场景 | 固定长度、性能敏感、基础算法 | 业务开发、动态增删、集合操作 |
示例:
int[] arr = {1, 2, 3};
List<Integer> list = new ArrayList<>();
list.add(1);
list.add(2);
list.add(3);
如果数据长度固定、追求简单和性能,可以使用数组。如果数据规模会动态变化,通常使用 ArrayList 更方便。
关于 ArrayList 的底层结构、扩容和线程安全方案,可以参考:Java List 核心数据结构解析:ArrayList、LinkedList 与线程安全。
常见异常
NullPointerException
数组变量为 null 时访问元素或长度,会抛出空指针异常。
int[] arr = null;
// System.out.println(arr.length); // NullPointerException
ArrayIndexOutOfBoundsException
访问不存在的下标,会抛出数组越界异常。
int[] arr = {1, 2, 3};
// System.out.println(arr[3]); // ArrayIndexOutOfBoundsException
有效下标范围是:
0 <= index < arr.length
ArrayStoreException
对象数组在运行时存入不兼容类型时,可能抛出 ArrayStoreException。
Object[] arr = new String[2];
// arr[0] = 123; // ArrayStoreException
这和 Java 数组的协变特性有关。相比之下,泛型集合会在编译期提供更多类型检查。
数组的适用场景
数组适合以下场景:
- 固定长度数据:例如一周 7 天、月份、棋盘大小等;
- 算法题和基础数据结构实现:排序、查找、栈、队列、堆;
- 性能敏感场景:尤其是基本类型数组,避免装箱成本;
- 矩阵和表格数据:例如二维数组;
- 底层缓冲区:例如字节数组
byte[]。
不太适合:
- 频繁动态增删;
- 长度无法提前估计;
- 需要丰富集合 API;
- 需要线程安全集合语义。
这些场景通常更适合 ArrayList、LinkedList、Deque 或并发集合。
总结
数组是理解数据结构和算法的基础。它的优势是结构简单、按下标访问快、内存模型清晰;缺点是长度固定,插入和删除不灵活。
核心要点:
- 数组是一组相同类型元素的有序集合;
- 数组通常可以抽象理解为连续存储结构;
- Java 中数组是对象,数组变量保存引用;
- 按下标访问是 O(1),遍历和线性查找是 O(n);
- 二分查找要求数组有序,复杂度是 O(log n);
- 插入和删除通常需要移动或复制元素;
java.util.Arrays提供了排序、查找、复制、填充、比较等工具方法;- 长度固定和性能敏感场景适合数组,动态增删场景更适合
ArrayList。
掌握数组后,再理解 ArrayList、栈、队列、堆、哈希表等结构会更容易。