你好,欢迎回到 Java 零基础学习系列!
在前几篇中,我们学习了程序控制结构,已经能够编写有判断和循环逻辑的程序。但之前我们处理的都是单个变量,如果要存储全班 50 个人的成绩,难道要定义 50 个变量吗?显然不行。
今天我们就来学习 数组——一种用来存储多个相同类型数据的容器。掌握了数组,你就能高效地处理批量数据,并为进一步学习集合框架打下坚实基础。
1. 数组初探:什么是数组?
数组是相同类型数据的有序集合,它在内存中是一段连续的空间。你可以把它想象成一排带编号的盒子:
- 每个盒子可以放一个数据(数组元素)
- 盒子有统一的类型(所有元素类型相同)
- 盒子有编号(索引/下标),从 0 开始
1.1 数组的特点
- 长度固定:一旦创建,大小不可改变。
- 类型一致:所有元素必须是同一种数据类型。
- 连续内存:元素在内存中紧挨着存储,访问速度快。
- 索引访问:通过下标快速定位元素,时间复杂度 O(1)。
2. 数组的声明与创建
2.1 声明数组
Java 中声明数组有两种语法:
数据类型[] 数组名; // 推荐写法
数据类型 数组名[]; // C/C++风格,不推荐
示例:
int[] scores; // 声明一个整型数组,用于存储成绩
String[] names; // 声明一个字符串数组,用于存储姓名
double[] prices; // 声明一个浮点型数组
注意:声明数组时,并不分配内存空间,只是定义了一个数组变量。
2.2 创建数组
使用 new 关键字为数组分配内存空间:
数组名 = new 数据类型[数组长度];
示例:
scores = new int[5]; // 创建一个可以存储5个整数的数组
names = new String[3]; // 创建一个可以存储3个字符串的数组
也可以在声明的同时创建:
int[] scores = new int[5];
2.3 数组的初始化
静态初始化(创建时直接赋值)
int[] scores = {95, 87, 76, 92, 84}; // 方式一
int[] scores = new int[]{95, 87, 76, 92, 84}; // 方式二
动态初始化(先创建,后赋值)
int[] scores = new int[5];
scores[0] = 95;
scores[1] = 87;
scores[2] = 76;
scores[3] = 92;
scores[4] = 84;
动态初始化时,如果没有显式赋值,数组元素会有默认值:
- 整数类型:
0 - 浮点类型:
0.0 - 字符类型:
'\u0000'(空字符) - 布尔类型:
false - 引用类型:
null
为什么会有默认值?
Java 为了安全,在分配内存后会对所有内存空间清零(对于引用类型设为null),这样避免了使用未初始化的变量。
3. 数组的访问与遍历
3.1 通过索引访问
数组索引从 0 开始,到 length-1 结束。
int[] scores = {95, 87, 76, 92, 84};
System.out.println(scores[0]); // 输出第一个元素:95
System.out.println(scores[2]); // 输出第三个元素:76
scores[1] = 90; // 修改第二个元素
危险:访问不存在的索引(如
scores[5])会抛出ArrayIndexOutOfBoundsException(数组下标越界异常)。
这个异常的作用是防止程序访问非法内存,保证安全。
3.2 获取数组长度
每个数组都有一个 length 属性,表示数组的长度:
int len = scores.length; // len = 5
length 是数组对象的属性,不是方法,所以不带括号。
3.3 遍历数组
for 循环遍历(需要索引时)
for (int i = 0; i < scores.length; i++) {
System.out.println("scores[" + i + "] = " + scores[i]);
}
增强 for 循环(foreach,不需要索引时)
for (int score : scores) {
System.out.println(score);
}
增强 for 循环只能读取元素,不能修改数组内容(修改的是临时变量)。
使用 Arrays 工具类打印
import java.util.Arrays;
System.out.println(Arrays.toString(scores)); // 输出 [95, 87, 76, 92, 84]
Arrays 工具类是什么?
Arrays是java.util包中提供的一个工具类,里面全是静态方法,专门用于操作数组(如排序、查找、复制、填充等)。它的出现是为了避免程序员重复造轮子,提供高效可靠的数组操作方法。
4. 数组的内存分析
理解数组的内存模型,对后续学习非常重要。Java 内存主要分为栈(Stack)和堆(Heap)。
4.1 一个数组的内存变化
int[] arr = new int[3];
arr[0] = 10;
arr[1] = 20;
arr[2] = 30;
内存变化:
int[] arr声明:在栈中创建变量arr,初始值为null。new int[3]:在堆中开辟连续 3 个int大小的空间(12字节),每个元素默认初始化为0,并将堆内存地址赋值给栈中的arr。- 通过地址找到堆中的数组,为各元素赋值。
4.2 两个数组指向同一内存
int[] arr1 = new int[]{10, 20, 30};
int[] arr2 = arr1; // arr2 和 arr1 指向同一个数组对象
arr2[0] = 100;
System.out.println(arr1[0]); // 输出 100,因为 arr1 和 arr2 共享同一数组
这就是引用传递:数组变量存储的是地址,赋值时复制的是地址,而不是数组内容。
5. 数组的常见操作
5.1 求最大值/最小值
int[] scores = {95, 87, 76, 92, 84};
int max = scores[0];
for (int i = 1; i < scores.length; i++) {
if (scores[i] > max) {
max = scores[i];
}
}
System.out.println("最高分:" + max);
5.2 求和与平均值
int sum = 0;
for (int score : scores) {
sum += score;
}
double avg = (double) sum / scores.length;
5.3 查找元素
int target = 76;
int index = -1;
for (int i = 0; i < scores.length; i++) {
if (scores[i] == target) {
index = i;
break;
}
}
if (index != -1) {
System.out.println("找到了,索引:" + index);
} else {
System.out.println("没找到");
}
5.4 复制数组
使用循环复制
int[] source = {1, 2, 3, 4, 5};
int[] target = new int[source.length];
for (int i = 0; i < source.length; i++) {
target[i] = source[i];
}
使用 System.arraycopy()(高效)
System.arraycopy(source, 0, target, 0, source.length);
// 参数:源数组,源起始位置,目标数组,目标起始位置,复制长度
System.arraycopy()是 Java 提供的本地方法,由 JVM 底层实现,复制效率远高于手动循环。
使用 Arrays.copyOf()
int[] target = Arrays.copyOf(source, source.length); // 复制全部
int[] target = Arrays.copyOf(source, 10); // 复制并扩容到10个元素,多出的补0
Arrays.copyOf()内部调用了System.arraycopy(),封装了复制逻辑,更简洁。
5.5 数组填充
int[] arr = new int[5];
Arrays.fill(arr, 100); // 全部填充为100
Arrays.fill(arr, 1, 4, 50); // 索引1到3(不包括4)填充为50
System.out.println(Arrays.toString(arr)); // [0, 50, 50, 50, 0](注意索引0未被填充)
Arrays.fill()的作用是快速将数组的某段区域设置为指定值,省去手动循环。
6. 数组的扩容(动态数组思想)
Java 原生数组长度固定,但我们可以通过“创建新数组 + 复制”的方式实现“扩容”效果。这正是 ArrayList 的底层原理。
6.1 为什么需要扩容?
因为实际开发中,我们往往无法预知需要存储多少数据。如果数组长度固定,当数据超过容量时程序就无法继续。因此需要一种机制让数组能动态增长。
6.2 手动实现扩容
int[] arr = new int[3];
arr[0] = 1; arr[1] = 2; arr[2] = 3;
// 需要添加第4个元素,但数组已满 → 扩容
int[] newArr = new int[arr.length * 2]; // 通常扩容为原来的1.5或2倍
System.arraycopy(arr, 0, newArr, 0, arr.length);
arr = newArr; // 让arr指向新数组
arr[3] = 4; // 现在可以添加了
System.out.println(Arrays.toString(arr)); // [1, 2, 3, 4, 0, 0]
6.3 扩容策略
实际开发中,扩容需要考虑效率和空间利用:
- 扩容因子:通常为 1.5 倍或 2 倍(
ArrayList默认扩容为原来的 1.5 倍)。这样在多次添加时,复制操作的次数较少,平均时间复杂度低。 - 缩容:当元素个数远小于容量时,可以缩容释放内存(
ArrayList的trimToSize()方法)。
6.4 实现一个简单的动态数组
下面我们模拟 ArrayList 的核心功能,加深对数组扩容的理解:
class MyArrayList {
private int[] data; // 存储数据的数组
private int size; // 实际元素个数
private static final int DEFAULT_CAPACITY = 10;
public MyArrayList() {
data = new int[DEFAULT_CAPACITY];
size = 0;
}
public MyArrayList(int initialCapacity) {
if (initialCapacity < 0) {
throw new IllegalArgumentException("容量不能为负数");
}
data = new int[initialCapacity];
size = 0;
}
// 添加元素
public void add(int value) {
if (size == data.length) {
grow(); // 扩容
}
data[size++] = value;
}
// 在指定位置插入元素
public void add(int index, int value) {
checkPositionIndex(index); // 检查插入位置是否合法(允许 index == size)
if (size == data.length) {
grow();
}
// 将 index 及之后的元素后移一位
System.arraycopy(data, index, data, index + 1, size - index);
data[index] = value;
size++;
}
// 获取元素
public int get(int index) {
checkElementIndex(index); // 检查元素索引是否合法(必须 index < size)
return data[index];
}
// 修改元素
public int set(int index, int value) {
checkElementIndex(index);
int oldValue = data[index];
data[index] = value;
return oldValue;
}
// 删除元素
public int remove(int index) {
checkElementIndex(index);
int removedValue = data[index];
// 将 index+1 及之后的元素前移一位
System.arraycopy(data, index + 1, data, index, size - index - 1);
size--;
// 将最后一个位置置为0(基本类型无所谓,但引用类型要置null避免内存泄漏)
// data[size] = 0; // int 类型默认0,但为清晰可写
return removedValue;
}
// 扩容:2倍
private void grow() {
int newCapacity = data.length * 2;
int[] newData = new int[newCapacity];
System.arraycopy(data, 0, newData, 0, size);
data = newData;
}
// 检查元素索引(必须 < size)
private void checkElementIndex(int index) {
if (index < 0 || index >= size) {
throw new IndexOutOfBoundsException("索引越界: " + index + ", 大小: " + size);
}
}
// 检查插入位置索引(可以 == size)
private void checkPositionIndex(int index) {
if (index < 0 || index > size) {
throw new IndexOutOfBoundsException("插入位置非法: " + index + ", 大小: " + size);
}
}
public int size() { return size; }
public boolean isEmpty() { return size == 0; }
public void display() {
System.out.println("size = " + size + ", capacity = " + data.length);
System.out.println(Arrays.toString(Arrays.copyOf(data, size)));
}
}
旧数组满 → 创建2倍大小新数组 → 复制元素 → 旧数组被回收
7. 数组的反转
反转数组就是将数组元素的顺序颠倒,例如 [1,2,3,4,5] 变成 [5,4,3,2,1]。
7.1 方式一:双指针原地反转
这是最高效的方式,无需额外空间。
int[] arr = {1, 2, 3, 4, 5};
int left = 0;
int right = arr.length - 1;
while (left < right) {
// 交换 arr[left] 和 arr[right]
int temp = arr[left];
arr[left] = arr[right];
arr[right] = temp;
left++;
right--;
}
System.out.println(Arrays.toString(arr)); // [5, 4, 3, 2, 1]
7.2 方式二:创建新数组逆序赋值
int[] arr = {1, 2, 3, 4, 5};
int[] reversed = new int[arr.length];
for (int i = 0; i < arr.length; i++) {
reversed[i] = arr[arr.length - 1 - i];
}
System.out.println(Arrays.toString(reversed)); // [5, 4, 3, 2, 1]
这种方式需要额外空间,但原数组保持不变。
7.3 方式三:使用 Collections.reverse()(仅限对象数组)
Integer[] arr = {1, 2, 3, 4, 5}; // 必须是包装类数组
List<Integer> list = Arrays.asList(arr);
Collections.reverse(list);
System.out.println(Arrays.toString(arr)); // [5, 4, 3, 2, 1]
为什么只能用对象数组?
Collections.reverse()方法接受一个List参数,而Arrays.asList()在转换基本类型数组(如int[])时,会将整个数组视为一个对象,返回List<int[]>,导致反转的不是数组元素。因此,必须使用包装类数组(如Integer[])才能正确反转。
包装类(如Integer)是为了让基本类型也能作为对象使用而设计的,它们位于java.lang包中,提供了许多实用方法,并在集合框架中扮演重要角色。
8. 数组的排序
排序是将数组元素按一定顺序(如升序、降序)重新排列。
8.1 使用 Arrays.sort()
Java 提供了强大的内置排序方法:
int[] arr = {5, 2, 8, 1, 9};
Arrays.sort(arr); // 升序排序
System.out.println(Arrays.toString(arr)); // [1, 2, 5, 8, 9]
对于对象数组,可以指定排序规则:
String[] names = {"Tom", "Alice", "Bob"};
Arrays.sort(names); // 按字典序升序
System.out.println(Arrays.toString(names)); // [Alice, Bob, Tom]
// 自定义排序(降序)
Integer[] nums = {5, 2, 8, 1, 9};
Arrays.sort(nums, (a, b) -> b - a); // 降序
System.out.println(Arrays.toString(nums)); // [9, 8, 5, 2, 1]
Arrays.sort()底层针对不同情况采用不同算法:
- 基本类型数组:使用双轴快速排序(Dual-Pivot QuickSort),平均时间复杂度 O(n log n),但不稳定。
- 对象类型数组:使用TimSort(归并排序的优化版),时间复杂度 O(n log n),稳定排序。
这些算法的选择是为了在性能和稳定性之间取得平衡。
8.2 常见排序算法对比
| 算法 | 平均时间复杂度 | 最坏时间复杂度 | 空间复杂度 | 稳定性 | 适用场景 |
|---|---|---|---|---|---|
| 冒泡排序 | O(n²) | O(n²) | O(1) | 稳定 | 教学示例,小规模数据 |
| 选择排序 | O(n²) | O(n²) | O(1) | 不稳定 | 简单,数据量小 |
| 插入排序 | O(n²) | O(n²) | O(1) | 稳定 | 小规模或基本有序的数据 |
| 快速排序 | O(n log n) | O(n²) | O(log n) | 不稳定 | 大规模数据(默认优化避免最坏) |
| 归并排序 | O(n log n) | O(n log n) | O(n) | 稳定 | 需稳定排序,数据量大 |
| 堆排序 | O(n log n) | O(n log n) | O(1) | 不稳定 | 内存受限场景 |
实际开发:绝大多数情况直接用
Arrays.sort()即可,无需手动实现排序算法。
8.3 手动实现冒泡排序(理解原理)
int[] arr = {5, 2, 8, 1, 9};
for (int i = 0; i < arr.length - 1; i++) {
for (int j = 0; j < arr.length - 1 - i; j++) {
if (arr[j] > arr[j + 1]) {
int temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
}
}
}
System.out.println(Arrays.toString(arr)); // [1, 2, 5, 8, 9]
9. 二维数组
9.1 声明与创建
int[][] matrix = new int[3][4]; // 3行4列的二维数组
int[][] matrix = {{1, 2}, {3, 4}, {5, 6}}; // 静态初始化
9.2 遍历二维数组
int[][] matrix = {{1, 2, 3}, {4, 5, 6}, {7, 8, 9}};
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();
}
9.3 不规则数组
Java 的二维数组实际上是“数组的数组”,每行的长度可以不同:
int[][] triangle = new int[5][];
for (int i = 0; i < triangle.length; i++) {
triangle[i] = new int[i + 1]; // 每行长度递增
for (int j = 0; j < triangle[i].length; j++) {
triangle[i][j] = j + 1;
}
}
10. Arrays 工具类常用方法总结
java.util.Arrays 提供了丰富的数组操作方法,我们已在前文陆续介绍。下面总结它的常用方法,并再次强调其作用:为开发者提供高效、可靠的数组操作工具,避免重复造轮子。
| 方法 | 说明 |
|---|---|
toString() | 将数组转换为字符串形式 |
sort() | 排序 |
binarySearch() | 二分查找(数组必须已排序) |
copyOf() | 复制数组并可指定新长度 |
copyOfRange() | 复制数组的指定范围 |
fill() | 填充数组 |
equals() | 比较两个数组是否相等 |
deepEquals() | 比较多维数组是否相等 |
asList() | 将数组转换为 List(注意基本类型陷阱) |
stream() | 将数组转换为流(Java 8+) |
示例:
int[] arr = {5, 2, 8, 1, 9};
Arrays.sort(arr);
int index = Arrays.binarySearch(arr, 8); // 返回 3
int[] copy = Arrays.copyOf(arr, 3); // [1, 2, 5]
boolean isEqual = Arrays.equals(arr, copy); // false
11. 综合示例:学生成绩管理系统(Scanner)
下面我们综合运用数组知识,实现一个简单的学生成绩管理系统。这个系统演示了数组的常见操作,并体现了动态扩容思想。
import java.util.Arrays;
import java.util.Scanner;
public class ScoreManager {
private int[] scores; // 存储成绩
private int count; // 实际学生数
private static final int INIT_CAPACITY = 5;
public ScoreManager() {
scores = new int[INIT_CAPACITY];
count = 0;
}
// 添加成绩
public void addScore(int score) {
if (count == scores.length) {
// 扩容为2倍
scores = Arrays.copyOf(scores, scores.length * 2);
}
scores[count++] = score;
System.out.println("添加成功!");
}
// 显示所有成绩
public void displayScores() {
if (count == 0) {
System.out.println("暂无成绩");
return;
}
System.out.println("成绩列表:");
for (int i = 0; i < count; i++) {
System.out.println("学生" + (i + 1) + ":" + scores[i]);
}
}
// 统计信息
public void statistics() {
if (count == 0) {
System.out.println("暂无成绩");
return;
}
int sum = 0;
int max = scores[0];
int min = scores[0];
for (int i = 0; i < count; i++) {
sum += scores[i];
if (scores[i] > max) max = scores[i];
if (scores[i] < min) min = scores[i];
}
double avg = (double) sum / count;
System.out.println("总人数:" + count);
System.out.println("总分:" + sum);
System.out.println("平均分:" + String.format("%.2f", avg));
System.out.println("最高分:" + max);
System.out.println("最低分:" + min);
}
// 排序并显示
public void sortAndDisplay() {
if (count == 0) {
System.out.println("暂无成绩");
return;
}
int[] temp = Arrays.copyOf(scores, count);
Arrays.sort(temp);
System.out.println("成绩排序(升序):" + Arrays.toString(temp));
}
// 查找成绩
public void searchScore(int score) {
boolean found = false;
for (int i = 0; i < count; i++) {
if (scores[i] == score) {
System.out.println("成绩 " + score + " 出现在第 " + (i + 1) + " 位学生");
found = true;
}
}
if (!found) {
System.out.println("未找到该成绩");
}
}
public static void main(String[] args) {
ScoreManager manager = new ScoreManager();
Scanner scanner = new Scanner(System.in);
while (true) {
System.out.println("\n===== 学生成绩管理系统 =====");
System.out.println("1. 添加成绩");
System.out.println("2. 显示所有成绩");
System.out.println("3. 统计信息");
System.out.println("4. 排序显示");
System.out.println("5. 查找成绩");
System.out.println("6. 退出");
System.out.print("请选择:");
int choice = scanner.nextInt();
if (choice == 6) {
System.out.println("感谢使用!");
break;
}
switch (choice) {
case 1:
System.out.print("请输入成绩:");
int score = scanner.nextInt();
manager.addScore(score);
break;
case 2:
manager.displayScores();
break;
case 3:
manager.statistics();
break;
case 4:
manager.sortAndDisplay();
break;
case 5:
System.out.print("请输入要查找的成绩:");
int target = scanner.nextInt();
manager.searchScore(target);
break;
default:
System.out.println("无效选择");
}
}
scanner.close();
}
}
12. 常见问题与注意事项
- 数组下标越界:访问不存在的索引,如长度为5的数组访问
arr[5]。这是最常见的问题,需要确保索引范围在0到length-1。 - 空指针异常:数组变量为
null时访问元素。声明数组后若未new就使用,会抛出此异常。 - 引用传递的误解:数组赋值给新变量时,两个变量指向同一数组,修改一个会影响另一个。
- 扩容效率:频繁扩容会影响性能,可预估容量预先分配。
- 基本类型数组与对象数组:如
int[]和Integer[]在使用Arrays.sort()和Collections.reverse()时行为不同,需注意。 - 内存泄漏:在自定义动态数组中,如果存储的是引用类型,删除元素后要将对应位置置为
null,否则可能造成内存泄漏。
13. 总结与下期预告
今天我们全面学习了数组这一重要数据结构:
- 数组基础:声明、创建、初始化、访问
- 内存分析:栈与堆、引用传递
- 常见操作:遍历、最值、查找、复制、填充
- 动态数组:手动实现扩容,理解 ArrayList 原理
- 反转与排序:多种实现方式及内置工具类
- 二维数组:不规则数组
- Arrays 工具类:简化数组操作,理解其作用和来源
数组是后续学习集合框架(List、Set、Map)的基础,理解了数组,你就能更好地理解 ArrayList 的底层实现。同时,我们也介绍了包装类 Integer 和工具类 Arrays 的用途,它们都是 Java 为方便开发者而设计的。
下一篇我们将进入 面向对象编程(OOP) 的世界——学习类与对象、封装、继承、多态。这是 Java 的核心思想,也是你成为真正 Java 程序员的关键一步。
动手实践:
- 实现一个方法,统计数组中每个元素出现的次数(不考虑复杂情况)。
- 实现数组的“去重”操作,返回新数组。
- 使用二维数组打印杨辉三角前10行。
- 完善上面的成绩管理系统,增加删除和修改功能。
如果在练习中遇到问题,欢迎在评论区留言。我们下期见! 🚀