零基础学Java|第七篇:数组——数据存储的基石

10 阅读15分钟

你好,欢迎回到 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 工具类是什么?
Arraysjava.util 包中提供的一个工具类,里面全是静态方法,专门用于操作数组(如排序、查找、复制、填充等)。它的出现是为了避免程序员重复造轮子,提供高效可靠的数组操作方法。


4. 数组的内存分析

理解数组的内存模型,对后续学习非常重要。Java 内存主要分为栈(Stack)堆(Heap)

4.1 一个数组的内存变化

int[] arr = new int[3];
arr[0] = 10;
arr[1] = 20;
arr[2] = 30;

内存变化:

  1. int[] arr 声明:在栈中创建变量 arr,初始值为 null
  2. new int[3]:在堆中开辟连续 3 个int大小的空间(12字节),每个元素默认初始化为 0,并将堆内存地址赋值给栈中的 arr
  3. 通过地址找到堆中的数组,为各元素赋值。

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 倍)。这样在多次添加时,复制操作的次数较少,平均时间复杂度低。
  • 缩容:当元素个数远小于容量时,可以缩容释放内存(ArrayListtrimToSize() 方法)。

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. 常见问题与注意事项

  1. 数组下标越界:访问不存在的索引,如长度为5的数组访问 arr[5]。这是最常见的问题,需要确保索引范围在 0length-1
  2. 空指针异常:数组变量为 null 时访问元素。声明数组后若未 new 就使用,会抛出此异常。
  3. 引用传递的误解:数组赋值给新变量时,两个变量指向同一数组,修改一个会影响另一个。
  4. 扩容效率:频繁扩容会影响性能,可预估容量预先分配。
  5. 基本类型数组与对象数组:如 int[]Integer[] 在使用 Arrays.sort()Collections.reverse() 时行为不同,需注意。
  6. 内存泄漏:在自定义动态数组中,如果存储的是引用类型,删除元素后要将对应位置置为 null,否则可能造成内存泄漏。

13. 总结与下期预告

今天我们全面学习了数组这一重要数据结构:

  • 数组基础:声明、创建、初始化、访问
  • 内存分析:栈与堆、引用传递
  • 常见操作:遍历、最值、查找、复制、填充
  • 动态数组:手动实现扩容,理解 ArrayList 原理
  • 反转与排序:多种实现方式及内置工具类
  • 二维数组:不规则数组
  • Arrays 工具类:简化数组操作,理解其作用和来源

数组是后续学习集合框架(List、Set、Map)的基础,理解了数组,你就能更好地理解 ArrayList 的底层实现。同时,我们也介绍了包装类 Integer 和工具类 Arrays 的用途,它们都是 Java 为方便开发者而设计的。

下一篇我们将进入 面向对象编程(OOP) 的世界——学习类与对象、封装、继承、多态。这是 Java 的核心思想,也是你成为真正 Java 程序员的关键一步。

动手实践

  1. 实现一个方法,统计数组中每个元素出现的次数(不考虑复杂情况)。
  2. 实现数组的“去重”操作,返回新数组。
  3. 使用二维数组打印杨辉三角前10行。
  4. 完善上面的成绩管理系统,增加删除和修改功能。

动手实践:零基础学Java | 数组

如果在练习中遇到问题,欢迎在评论区留言。我们下期见! 🚀