数据结构与算法 - 基础:数组深度解析
一、数组的底层本质
1.1 内存视角下的数组
数组是所有数据结构中最朴素的一种。用最简单的物理比喻:数组就像一排连号的储物柜,每个柜子大小完全一致,编号从 0 开始依次递增。你不需要打开每个柜子才知道里面放了什么------只要知道起始位置和偏移量,就能立刻定位到任意一个柜子。
在 JVM 的堆内存中,数组对象的结构大致如下:
scss
┌─────────────────────────────────────────────────────┐
│ 对象头(Mark Word + 类型指针) │
├─────────────────────────────────────────────────────┤
│ length 字段 (int, 4字节) │
├──────────┬──────────┬──────────┬──────────┬─────────┤
│ 元素[0] │ 元素[1] │ 元素[2] │ ...... │元素[n-1] │
└──────────┴──────────┴──────────┴──────────┴─────────┘
这种排布方式带来了数组最重要的性能特征:随机访问的时间复杂度为 O(1)。计算任意元素 i 的地址只需要一个公式:
css
address(i) = 基地址 + i × 每个元素占用的字节数
不需要循环,不需要遍历,一次乘法加一次加法就完成了定位。
1.2 基础操作实现
下面是一个完整的数组基础操作演示程序:
java
import java.util.Arrays;
public class ArrayFundamentals {
public static void main(String[] args) {
// 三种声明与初始化方式
int[] arr1 = new int[5]; // 方式一:指定长度,默认值 0
int[] arr2 = {10, 20, 30, 40, 50}; // 方式二:静态初始化
var arr3 = new Integer[]{1, 2, 3, 4, 5}; // 方式三:匿名数组声明
System.out.println("========== 数组基础操作演示 ==========");
// 逐元素赋值
for (int i = 0; i < arr1.length; i++) {
arr1[i] = i * 100;
}
System.out.println("赋值后的 arr1: " + Arrays.toString(arr1));
// 按下标读取
System.out.println("arr2[2] = " + arr2[2]); // 输出: 30
// length 是属性不是方法
System.out.println("arr2 长度: " + arr2.length);
// 遍历的多种写法
System.out.print("标准 for: ");
for (int i = 0; i < arr3.length; i++) {
System.out.print(arr3[i] + " ");
}
System.out.print("\n增强 for: ");
for (Integer val : arr3) {
System.out.print(val + " ");
}
// 边界检查:以下代码会抛出 ArrayIndexOutOfBoundsException
// System.out.println(arr2[100]); // 取消注释测试越界
}
}
二、多维数组的内部实现
2.1 二维数组 ≠ 矩阵
很多人以为 Java 的二维数组是一个连续的矩阵块,实际上它是一种"数组的数组"结构。int[3][4] 意味着堆上有 1 个长度为 3 的引用数组 + 3 个长度为 4 的实际 int 数组------它们可能在内存的任何位置。
java
public class MultiDimensionalArray {
public static void main(String[] args) {
System.out.println("========== 二维数组结构分析 ==========");
// 声明 3×4 的标准二维数组
int[][] matrix = new int[3][4];
initMatrix(matrix);
printMatrixSimple(matrix);
// 不规则(锯齿)二维数组:每一行长度可以不同
System.out.println("\n--- 不规则二维数组 ---");
int[][] jagged = {
{1, 2, 3},
{4, 5},
{6, 7, 8, 9}
};
for (int i = 0; i < jagged.length; i++) {
System.out.println("第" + i + "行长度: " + jagged[i].length);
}
// 三维数组示例:2×3×4
System.out.println("\n--- 三维数组遍历 ---");
int[][][] cube = new int[2][3][4];
fillCube(cube);
printCubeSlice(cube, 0);
}
private static void initMatrix(int[][] matrix) {
for (int i = 0; i < matrix.length; i++) {
for (int j = 0; j < matrix[i].length; j++) {
matrix[i][j] = i * matrix[i].length + j + 1;
}
}
}
private static void printMatrixSimple(int[][] matrix) {
System.out.println("标准二维数组内容:");
for (int[] row : matrix) {
for (int val : row) {
System.out.printf("%3d ", val);
}
System.out.println();
}
}
private static void fillCube(int[][][] cube) {
int value = 1;
for (int i = 0; i < cube.length; i++) {
for (int j = 0; j < cube[i].length; j++) {
for (int k = 0; k < cube[i][j].length; k++) {
cube[i][j][k] = value++;
}
}
}
}
private static void printCubeSlice(int[][][] cube, int layer) {
System.out.println("三维数组第" + layer + "层:");
for (int j = 0; j < cube[layer].length; j++) {
for (int k = 0; k < cube[layer][j].length; k++) {
System.out.printf("%3d ", cube[layer][j][k]);
}
System.out.println();
}
}
}
2.2 内存布局图
csharp
int[2][3] 的内存视图:
栈上: matrix (引用)
↓
堆: ┌─────────┬─────────┐
│ ref[0] │ ref[1] │ ← 长度为 2 的引用数组
└────┬────┴────┬────┘
↓ ↓
┌───┬───┬───┐ ┌───┬───┬───┐
│0,0│0,1│0,2│ │1,0│1,1│1,2│ ← 两个长度为 3 的 int 数组
└───┴───┴───┘ └───┴───┴───┘
三、Arrays 工具类全解
java.util.Arrays 是操作数组的瑞士军刀。即使你已经使用它多年,以下这个完整示例仍然可能包含你没注意过的功能:
java
import java.util.Arrays;
import java.util.Comparator;
public class ArraysUtilityDemo {
public static void main(String[] args) {
System.out.println("========== Arrays 工具类完整演示 ==========\n");
int[] numbers = {5, 2, 8, 1, 9, 3, 7, 4, 6, 0};
// ======== 排序 ========
System.out.println("--- 排序 ---");
System.out.println("原数组: " + Arrays.toString(numbers));
int[] copy1 = Arrays.copyOf(numbers, numbers.length);
Arrays.sort(copy1);
System.out.println("全量排序: " + Arrays.toString(copy1));
int[] copy2 = Arrays.copyOf(numbers, numbers.length);
Arrays.sort(copy2, 2, 7);
System.out.println("区间排序[2,7):" + Arrays.toString(copy2));
// ======== 查找 ========
System.out.println("\n--- 二分查找 ---");
int[] sorted = {1, 3, 5, 7, 9, 11, 13};
System.out.println("有序数组: " + Arrays.toString(sorted));
System.out.println("查找 7 的位置: " + Arrays.binarySearch(sorted, 7)); // 输出: 3
System.out.println("查找 8 的位置: " + Arrays.binarySearch(sorted, 8)); // 输出: -5(插入点取反-1)
// ======== 填充 ========
System.out.println("\n--- 填充 ---");
int[] filled = new int[5];
Arrays.fill(filled, 42);
System.out.println("全量填充 42: " + Arrays.toString(filled));
Arrays.fill(filled, 1, 3, 99);
System.out.println("区间[1,3)填99:" + Arrays.toString(filled));
// ======== 相等判断 ========
System.out.println("\n--- 相等判断 ---");
int[] a = {1, 2, 3};
int[] b = {1, 2, 3};
System.out.println("a == b: " + (a == b)); // false(引用比较)
System.out.println("a.equals(b): " + a.equals(b)); // false(未重写equals)
System.out.println("Arrays.equals: " + Arrays.equals(a, b)); // true
// ======== 拷贝 ========
System.out.println("\n--- 拷贝 ---");
int[] original = {10, 20, 30, 40, 50};
int[] copyAll = Arrays.copyOf(original, original.length);
int[] copyTruncated = Arrays.copyOf(original, 3);
int[] copyExpanded = Arrays.copyOf(original, 8);
System.out.println("原数组: " + Arrays.toString(original));
System.out.println("完整拷贝: " + Arrays.toString(copyAll));
System.out.println("截断拷贝(3):" + Arrays.toString(copyTruncated));
System.out.println("扩展拷贝(8):" + Arrays.toString(copyExpanded));
// 使用 System.arraycopy 做区段拷贝
int[] dest = new int[5];
System.arraycopy(original, 1, dest, 2, 3);
System.out.println("System.arraycopy(从1开始,复制3个,放到dest[2]): "
+ Arrays.toString(dest));
// ======== Stream 流式操作 (JDK8+) ========
System.out.println("\n--- Stream 流式操作 ---");
int[] streamArr = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
int sum = Arrays.stream(streamArr).sum();
double avg = Arrays.stream(streamArr).average().orElse(0);
int max = Arrays.stream(streamArr).max().orElse(-1);
System.out.println("求和: " + sum + ", 平均: " + avg + ", 最大值: " + max);
int[] evens = Arrays.stream(streamArr)
.filter(x -> x % 2 == 0)
.toArray();
System.out.println("所有偶数: " + Arrays.toString(evens));
}
}
四、数组 vs ArrayList ------ 深度对比
4.1 核心差异矩阵
| 维度 | 数组 | ArrayList |
|---|---|---|
| 容量 | 固定,创建后不可变 | 动态,自动扩容(1.5倍) |
| 元素类型 | 基本类型 + 引用类型 | 仅引用类型(有自动装箱) |
| 泛型支持 | 不支持 | 支持 <T> |
| 内存效率 | 更高(无额外包装) | 稍低(对象头 + 扩容预留) |
| 性能 | 略快 | 方法调用有一定开销 |
| 工具方法 | 依赖 Arrays 工具类 | 丰富的实例方法 |
| 线程安全 | 需自行同步 | Vector / CopyOnWriteArrayList |
4.2 性能对比实测
java
import java.util.ArrayList;
import java.util.Random;
public class ArrayVsArrayList {
private static final int SIZE = 10_000_000;
private static final Random rand = new Random();
public static void main(String[] args) {
System.out.println("========== 数组 vs ArrayList 性能对比 (n=" + SIZE + ") ==========\n");
testWriteRandomAccess();
testReadRandomAccess();
testSequentialSum();
}
/** 测试随机写入性能 */
private static void testWriteRandomAccess() {
int[] arr = new int[SIZE];
ArrayList<Integer> list = new ArrayList<>(SIZE);
for (int i = 0; i < SIZE; i++) {
list.add(0); // 预填充,排除扩容干扰
}
// 生成随机索引序列
int[] indices = new int[100_000];
for (int i = 0; i < indices.length; i++) {
indices[i] = rand.nextInt(SIZE);
}
long t1 = System.nanoTime();
for (int idx : indices) {
arr[idx] = idx;
}
long t2 = System.nanoTime();
long t3 = System.nanoTime();
for (int idx : indices) {
list.set(idx, idx);
}
long t4 = System.nanoTime();
System.out.printf("随机写入(×%d): 数组=%.2fms ArrayList=%.2fms 比值=%.2fx\n",
indices.length,
(t2 - t1) / 1_000_000.0,
(t4 - t3) / 1_000_000.0,
(double)(t4 - t3) / (t2 - t1));
}
/** 测试随机读取性能 */
private static void testReadRandomAccess() {
int[] arr = new int[SIZE];
ArrayList<Integer> list = new ArrayList<>(SIZE);
for (int i = 0; i < SIZE; i++) {
arr[i] = i;
list.add(i);
}
int[] indices = new int[100_000];
for (int i = 0; i < indices.length; i++) {
indices[i] = rand.nextInt(SIZE);
}
long sum1 = 0, sum2 = 0;
long t1 = System.nanoTime();
for (int idx : indices) {
sum1 += arr[idx];
}
long t2 = System.nanoTime();
long t3 = System.nanoTime();
for (int idx : indices) {
sum2 += list.get(idx);
}
long t4 = System.nanoTime();
System.out.printf("随机读取(×%d): 数组=%.2fms ArrayList=%.2fms 比值=%.2fx\n",
indices.length,
(t2 - t1) / 1_000_000.0,
(t4 - t3) / 1_000_000.0,
(double)(t4 - t3) / (t2 - t1));
System.out.println(" (校验: sum1=" + sum1 + ", sum2=" + sum2 + ")");
}
/** 测试顺序遍历求和 */
private static void testSequentialSum() {
int[] arr = new int[SIZE];
ArrayList<Integer> list = new ArrayList<>(SIZE);
for (int i = 0; i < SIZE; i++) {
arr[i] = i;
list.add(i);
}
long t1 = System.nanoTime();
long sum1 = 0;
for (int val : arr) {
sum1 += val;
}
long t2 = System.nanoTime();
long t3 = System.nanoTime();
long sum2 = 0;
for (int val : list) {
sum2 += val;
}
long t4 = System.nanoTime();
System.out.printf("顺序遍历求和: 数组=%.2fms ArrayList=%.2fms 比值=%.2fx\n",
(t2 - t1) / 1_000_000.0,
(t4 - t3) / 1_000_000.0,
(double)(t4 - t3) / (t2 - t1));
}
}
五、数组的经典算法应用
5.1 双指针技巧
双指针是数组问题中最常用的技巧之一。基本思想是使用两个索引变量(如 left 和 right)从不同方向或不同速度遍历数组。
应用场景一:反转数组
java
// 双指针从两端向中间移动,交换元素
// 时间复杂度 O(n),空间复杂度 O(1)
public static void reverse(int[] arr) {
int left = 0;
int right = arr.length - 1;
while (left < right) {
int temp = arr[left];
arr[left] = arr[right];
arr[right] = temp;
left++;
right--;
}
}
应用场景二:两数之和(有序数组)
java
public class TwoPointerAlgorithms {
public static void main(String[] args) {
System.out.println("========== 双指针算法演示 ==========\n");
// 测试反转
int[] nums1 = {1, 2, 3, 4, 5, 6, 7};
System.out.print("反转前: ");
printArray(nums1);
reverseInPlace(nums1);
System.out.print("反转后: ");
printArray(nums1);
// 测试两数之和
int[] sortedArr = {2, 5, 7, 10, 13, 18, 25};
int target = 23;
int[] result = twoSum(sortedArr, target);
if (result[0] != -1) {
System.out.printf("\n两数之和: %d + %d = %d (下标 %d, %d)\n",
sortedArr[result[0]], sortedArr[result[1]], target,
result[0], result[1]);
}
// 测试移除重复元素
int[] withDups = {1, 1, 2, 2, 2, 3, 4, 5, 5, 6};
System.out.print("\n去重前: ");
printArray(withDups);
int newLen = removeDuplicates(withDups);
System.out.print("去重后: ");
for (int i = 0; i < newLen; i++) {
System.out.print(withDups[i] + " ");
}
System.out.println("(有效长度=" + newLen + ")");
}
/**
* 原地反转数组 ------ 对撞指针
* 时间复杂度: O(n)
* 空间复杂度: O(1)
*/
public static void reverseInPlace(int[] arr) {
int left = 0;
int right = arr.length - 1;
while (left < right) {
int tmp = arr[left];
arr[left] = arr[right];
arr[right] = tmp;
left++;
right--;
}
}
/**
* 在有序数组中查找两数之和等于 target 的下标
* 利用有序性使用对撞指针,时间复杂度 O(n)
* 返回 [-1, -1] 表示未找到
*/
public static int[] twoSum(int[] sortedArr, int target) {
int left = 0;
int right = sortedArr.length - 1;
while (left < right) {
int sum = sortedArr[left] + sortedArr[right];
if (sum == target) {
return new int[]{left, right};
} else if (sum < target) {
left++; // 和太小,左指针右移增大和
} else {
right--; // 和太大,右指针左移减小和
}
}
return new int[]{-1, -1};
}
/**
* 移除有序数组中的重复元素(原地修改)
* 使用快慢指针,返回去重后的有效长度
* 时间复杂度: O(n)
* 空间复杂度: O(1)
*/
public static int removeDuplicates(int[] arr) {
if (arr.length == 0) return 0;
int slow = 0; // 慢指针:跟踪已处理的不重复元素末尾
for (int fast = 1; fast < arr.length; fast++) {
if (arr[fast] != arr[slow]) {
slow++;
arr[slow] = arr[fast];
}
}
return slow + 1;
}
private static void printArray(int[] arr) {
System.out.print("[");
for (int i = 0; i < arr.length; i++) {
System.out.print(arr[i]);
if (i < arr.length - 1) System.out.print(", ");
}
System.out.println("]");
}
}
5.2 滑动窗口
滑动窗口用于处理连续子数组问题。窗口的左右边界都在数组范围内滑动,避免重复计算。
java
import java.util.HashSet;
public class SlidingWindowDemo {
public static void main(String[] args) {
System.out.println("========== 滑动窗口算法演示 ==========\n");
// 测试最大子数组和
int[] arr1 = {2, 1, 5, 1, 3, 2};
int k = 3;
int maxSum = maxSubarraySum(arr1, k);
System.out.printf("数组: %s, 窗口大小 k=%d\n", arrayToString(arr1), k);
System.out.printf("最大子数组和 = %d\n\n", maxSum);
// 测试最长无重复子串长度
int[] arr2 = {1, 2, 3, 2, 5, 6, 1, 7};
int maxLen = longestUniqueSubarray(arr2);
System.out.printf("数组: %s\n", arrayToString(arr2));
System.out.printf("最长无重复子数组长度 = %d\n\n", maxLen);
// 测试最小长度子数组(和 >= target)
int[] arr3 = {2, 3, 1, 2, 4, 3};
int target = 7;
int minLen = minSubarrayLen(arr3, target);
System.out.printf("数组: %s, target=%d\n", arrayToString(arr3), target);
System.out.printf("和>=%d的最小子数组长度 = %d\n", target, minLen);
}
/**
* 固定窗口大小:求长度为 k 的连续子数组的最大和
* 时间复杂度: O(n)
*/
public static int maxSubarraySum(int[] arr, int k) {
if (arr.length < k) return -1;
int windowSum = 0;
// 先计算第一个窗口的和
for (int i = 0; i < k; i++) {
windowSum += arr[i];
}
int maxSum = windowSum;
// 滑动窗口:加入右侧新元素,移除左侧旧元素
for (int i = k; i < arr.length; i++) {
windowSum += arr[i] - arr[i - k];
maxSum = Math.max(maxSum, windowSum);
}
return maxSum;
}
/**
* 可变窗口大小:求不包含重复元素的最长连续子数组的长度
* 使用 HashSet 记录窗口内的元素
* 时间复杂度: O(n)
*/
public static int longestUniqueSubarray(int[] arr) {
HashSet<Integer> window = new HashSet<>();
int left = 0;
int maxLen = 0;
for (int right = 0; right < arr.length; right++) {
// 遇到重复时,从左侧收缩直至移除重复
while (window.contains(arr[right])) {
window.remove(arr[left]);
left++;
}
window.add(arr[right]);
maxLen = Math.max(maxLen, right - left + 1);
}
return maxLen;
}
/**
* 可变窗口大小:求和 >= target 的最短连续子数组的长度
* 时间复杂度: O(n)
*/
public static int minSubarrayLen(int[] arr, int target) {
int left = 0;
int windowSum = 0;
int minLen = Integer.MAX_VALUE;
for (int right = 0; right < arr.length; right++) {
windowSum += arr[right];
// 满足条件时,尝试收缩左侧
while (windowSum >= target) {
minLen = Math.min(minLen, right - left + 1);
windowSum -= arr[left];
left++;
}
}
return minLen == Integer.MAX_VALUE ? 0 : minLen;
}
private static String arrayToString(int[] arr) {
StringBuilder sb = new StringBuilder("[");
for (int i = 0; i < arr.length; i++) {
sb.append(arr[i]);
if (i < arr.length - 1) sb.append(", ");
}
sb.append("]");
return sb.toString();
}
}
六、常见面试题精解
6.1 如何高效地向一个有序数组中插入元素
有序数组插入的难点在于需要"腾出位置"。如果直接遍历找到插入点再移动,整个过程是 O(n)。
java
// 二分定位插入点 + System.arraycopy 批量移动
// 时间复杂度: O(log n + n) = O(n),但比逐个移动快得多
import java.util.Arrays;
public class SortedArrayInsert {
public static int[] insert(int[] arr, int value) {
// 使用二分查找找到插入位置
int pos = Arrays.binarySearch(arr, value);
if (pos < 0) {
pos = -(pos + 1); // 转换为插入点
}
// 扩容并复制
int[] newArr = Arrays.copyOf(arr, arr.length + 1);
System.arraycopy(newArr, pos, newArr, pos + 1, newArr.length - pos - 1);
newArr[pos] = value;
return newArr;
}
public static void main(String[] args) {
int[] arr = {1, 3, 5, 7, 9};
System.out.println("原数组: " + Arrays.toString(arr));
arr = insert(arr, 4);
System.out.println("插入 4: " + Arrays.toString(arr));
arr = insert(arr, 0);
System.out.println("插入 0: " + Arrays.toString(arr));
arr = insert(arr, 10);
System.out.println("插入10: " + Arrays.toString(arr));
}
}
6.2 找出数组中第 k 大的元素(不排序)
java
import java.util.PriorityQueue;
public class KthLargest {
/**
* 使用最小堆维护 k 个最大元素
* 时间复杂度: O(n log k)
* 空间复杂度: O(k)
*/
public static int findKthLargest(int[] nums, int k) {
PriorityQueue<Integer> minHeap = new PriorityQueue<>();
for (int num : nums) {
minHeap.offer(num);
if (minHeap.size() > k) {
minHeap.poll(); // 移除堆中最小的
}
}
return minHeap.peek();
}
public static void main(String[] args) {
int[] arr = {3, 2, 1, 5, 6, 4};
System.out.println("数组: [3, 2, 1, 5, 6, 4]");
System.out.println("第 2 大: " + findKthLargest(arr, 2)); // 5
System.out.println("第 4 大: " + findKthLargest(arr, 4)); // 3
}
}
七、数组的边界与陷阱
Java 中数组最容易踩的坑:
1. 数组是协变的
java
Object[] objects = new String[5]; // 编译通过
objects[0] = "hello"; // 运行正常
objects[0] = 42; // 运行时 ArrayStoreException
数组在编译期不检查元素类型兼容性,这是数组相对于泛型集合的一个安全隐患。所以 Joshua Bloch 在《Effective Java》中明确建议:优先使用泛型集合而非数组。
2. length 是属性,size() 是方法
数组的 length 是 JVM 级别的属性,不是方法调用;而 ArrayList.size() 是方法调用。它们看起来相似,但底层机制完全不同。
3. Arrays.asList 返回的 List 长度不可变
java
List<Integer> fixed = Arrays.asList(1, 2, 3);
fixed.set(0, 99); // OK:修改元素
fixed.add(4); // UnsupportedOperationException!
Arrays.asList 返回的是 Arrays 内部类 ArrayList(不是 java.util.ArrayList),它是数组的一个视图,长度固定。
八、总结
数组作为一切数据结构的基础,它的核心优势是:
- O(1) 随机访问:这是数组最不可替代的特性
- 内存紧凑:无额外指针开销,缓存友好
- 简单可靠:没有复杂的内部逻辑
它的核心劣势也同样清晰:
- 容量固化:一旦创建无法改变大小
- 插入/删除慢:需要移动大量元素
- 类型安全性弱:协变数组可能引发运行时异常
在实战中,你应当在以下场景优先使用数组:
- 数据量可控且规模固定
- 需要频繁的随机读写
- 对内存占用敏感的场景
- 基础类型的批量处理(避免装箱开销)
当你需要动态增长、频繁在中间插入删除、或需要强类型安全时,应当果断切换到对应的集合类。
数组是许多高级数据结构的构建材料。栈可以用数组实现,堆可以用数组表示完全二叉树,散列表底层也是数组。理解数组,就是理解数据结构世界的第一块基石。