数组这个知识点,我在带新人和当面试官的时候,几乎每次都会聊到。它不是Java独有的概念,但Java里的数组却有很多跟C语言、Python不一样的地方——一旦没搞清楚,刷题和写业务代码都会踩坑。这篇就把JAVA数组从内存模型到经典练习题完整过一遍,内容包括连续内存的底层逻辑、三种初始化方式、Arrays工具类的高频操作、二维数组和动态数组的实现原理,以及一道高频练习题"数组是否同构"的完整拆解。无论你是刚学完基础语法准备练手,还是在备战Java面试,这篇文章都值得认真读完。
1. 数组的内存模型与设计逻辑:先把底层想明白
1.1 连续内存和O(1)随机访问:数组最核心的优势
很多人学数组的时候只记住了"数组是一组相同类型的数据",但对"为什么数组访问速度这么快"完全没有概念。这背后的核心原因是:数组在堆内存中申请的是一段连续的空间。
想象一排带编号的储物柜,从1号到100号一字排开,每个柜子大小完全一样。你要找第50号柜子,不需要从1号开始一个个数过来,直接走到第50号的位置就行。数组在内存里就是这个物理结构。每个元素占用的字节数是固定的,所以计算机可以通过一个简单的公式直接算出某个下标对应的内存地址:
地址 = 首地址 + 下标 × 每个元素占用的字节数这就是O(1)随机访问的由来。链表做不到这一点,因为它的节点散落在内存各处,必须从头开始遍历才能找到目标。这也是为什么很多算法题里,数组和哈希表能轻松达到O(1)查询,而链表往往是O(n)。
顺带说一个面试官爱问的问题:为什么数组下标从0开始?因为上面的地址公式如果从0开始,就是"首地址 + index × size",无需任何额外运算;如果从1开始,每次访问都要算一次"首地址 + (index - 1) × size",白白多一次减法。历史上C语言这么设计,Java沿用了这个约定,后来几乎所有编程语言都跟着这么干了。
1.2 数组是对象:引用传递的本质
这个点太关键了,因为它是Java和C语言一个非常大的差异。在Java里,除了8种基本类型之外,一切皆对象,数组也是对象。也就是说,当你写下这样一行代码:
int[] arr = new int[3];arr并不是数据本身,它只是一个引用,真正的数组对象在堆内存中。这带来一个直接后果:把数组传给方法时,传递的是引用,不是拷贝。方法内部对数组元素的修改,会直接影响原数组。
public static void main(String[] args) { int[] arr = {1, 2, 3}; change(arr); System.out.println(Arrays.toString(arr)); // 输出 [99, 2, 3] } public static void change(int[] a) { a[0] = 99; }很多刚学Java的人在这里翻车,以为像基本类型传参一样,数组进方法之后就"各改各的",结果调试半天发现原数组被动过。记住结论:基本类型传值,数组传引用。遇到不想让对方改你的数组时,要么方法里拷贝一份,要么调用方显式用arr.clone()再传进去。
1.3 Java数组与C语言指针数组的差异
热词里出现了"指针数组",这正好是一个经常被拿来对比的点。C语言的指针数组,本质上是一个数组,每个元素是一个指针,这些指针可以指向任意内存地址。Java没有指针概念,但引用类型数组和C语言的指针数组在形态上有相似之处:String[]数组的每个元素,其实是一个指向堆中字符串对象的引用。
不过两者有一个重要区别:类型安全。C语言的void*指针数组可以往里塞任何类型的地址,编译期不检查,运行期就可能出问题。Java的数组在编译期就强制类型统一。比如写String[] arr = new int[3];编译器直接报错。当然,如果数组声明为父类类型,是可以放入子类对象的,这也是面向对象多态的一种体现:
Object[] arr = new Object[3]; arr[0] = "hello"; arr[1] = 123; arr[2] = new Student();这种写法在业务代码里偶尔有用,但刷算法题时不建议滥用,因为取出来之后还得强转类型,增加出错概率。
2. 数组初始化和必会操作:新手最容易漏掉的细节
2.1 三种初始化方式与默认值规则
Java数组的初始化看着简单,其实区分度很高,至少熟悉以下三种写法:
// 1. 静态初始化:声明时直接给出元素 int[] a = {1, 2, 3}; // 2. 动态初始化:只指定长度,系统分配默认值 int[] b = new int[3]; // 3. 匿名数组:不赋值给变量直接作为参数传递 printArray(new int[]{4, 5, 6});动态初始化创建的数组,每个位置都有默认值,规则很固定:
| 类型 | 默认值 |
|---|---|
| byte / short / int / long | 0 |
| float / double | 0.0 |
| boolean | false |
| char | '\u0000' |
| 引用类型(String、对象等) | null |
这里有一个高频坑:基本类型的默认值明确,但引用类型的默认值是null。如果在循环里直接arr[i].xxx()不判空,满屏的NullPointerException等着你。
还有一个细节是字符串数组的初始化。热词里出现了"C++字符串数组初始化",Java版其实更简洁:
String[] names = {"张三", "李四", "王五"};String本身是不可变对象,但String[]数组是可变长集合的引用载体,元素可以替换,只不过替换的是引用,被换掉的字符串对象最终交给GC回收。
2.2 遍历的几种写法
遍历数组有几种姿势,不同场景选择不同写法:
int[] arr = {10, 20, 30}; // 传统for:需要下标参与运算时用 for (int i = 0; i < arr.length; i++) { System.out.println(arr[i]); } // 增强for(foreach):只读遍历时最推荐 for (int value : arr) { System.out.println(value); } // Lambda / Stream流(Java 8+) Arrays.stream(arr).forEach(System.out::println);注意:增强for遍历时不能修改数组元素的值。因为value只是数组元素的一份拷贝,你改value不影响原数组。如果想修改元素,必须用传统for加下标。
for (int value : arr) { value = 999; // 无效,原数组不变 } for (int i = 0; i < arr.length; i++) { arr[i] = 999; // 这才是正确的修改方式 }这个细节在开发中很常见,比如批量把数组里所有偶数加1,用增强for写完发现数组没变,排查半天才反应过来。
2.3 Arrays工具类高频方法速查
Java标准库提供了java.util.Arrays,里面全是静态方法,专门操作数组。我按实际使用频率整理一下最关键的方法:
| 方法 | 作用 | 注意点 |
|---|---|---|
Arrays.sort(arr) | 升序排序 | 基本类型用双轴快排,对象类型用归并排序 |
Arrays.binarySearch(arr, key) | 二分查找 | 前提是数组必须已排序,否则结果不确定 |
Arrays.copyOf(arr, newLen) | 扩容/截断 | 返回新数组,原数组不变 |
Arrays.copyOfRange(arr, from, to) | 拷贝区间 | 含头不含尾 |
Arrays.fill(arr, value) | 全部赋相同值 | 常用于初始化测试数据 |
Arrays.toString(arr) | 一维数组转字符串 | 打印数组必须用这个 |
Arrays.deepToString(arr) | 二维数组转字符串 | 多维数组打印专用 |
Arrays.equals(arr1, arr2) | 比较一维数组是否相等 | 比较的是内容和长度,不是引用 |
Arrays.deepEquals(arr1, arr2) | 比较多维数组是否相等 | 多维数组不能用equals |
特别注意排序方法的一个坑:Arrays.sort()对基本类型数组用的是快速排序,对对象数组用的是稳定归并排序。如果你排序的对象数组里有两个相同字段的元素,排序后它们的相对顺序会被保留;但基本类型数组不会保证这个性质。刷题时判断"相同身高按编号排序"这类需求,要注意这个差异。
2.4 数组转字符串和List互转的用法
数组转字符串的需求非常频繁,项目里经常要把数组拼接成日志或接口返回参数。两种高频方式:
int[] arr = {1, 2, 3}; // 方式一:Arrays.toString String s1 = Arrays.toString(arr); // 输出 [1, 2, 3] // 方式二:Java 8+ Stream拼接 String s2 = Arrays.stream(arr) .mapToObj(String::valueOf) .collect(Collectors.joining(",")); // 输出 1,2,3数组转List同样高频,但坑也集中在这里:
List<String> list = Arrays.asList("a", "b", "c");这个Arrays.asList()返回的是一个内部类Arrays$ArrayList,它的长度固定,不能调用add()和remove(),否则直接抛UnsupportedOperationException。很多新手在代码里顺手加了个元素,运行期现场出bug。
正确的做法是这样:
// 可变列表,随便增删 List<String> list = new ArrayList<>(Arrays.asList("a", "b", "c")); list.add("d"); // OK还有一个隐蔽问题:如果数组是基本类型,Arrays.asList(int[])返回的是List<int[]>,数组中每个元素不会拆箱成Integer。想要正确转换,要借助Stream:
int[] arr = {1, 2, 3}; List<Integer> list = Arrays.stream(arr).boxed().collect(Collectors.toList());3. 从一维到多维:二维数组、动态数组与冒泡排序
3.1 二维数组的内存布局和不规则数组
二维数组可以理解成"数组的数组"。声明int[][] matrix = new int[3][3]时,外层数组有3个元素,每个元素又是一个指向内层一维数组的引用。也就是说,Java的二维数组并不要求每行长度一样,这是和C语言很大的一个区别。
// 不规则二维数组 int[][] triangle = new int[3][]; triangle[0] = new int[1]; triangle[1] = new int[2]; triangle[2] = new int[3];这种不规则数组在很多题目里有妙用,比如杨辉三角的存储,每一行的长度正好是行号加1。
二维字符数组在热词里也出现了,它最常见的应用场景就是地图、棋盘、迷宫:
char[][] map = { {'#', '#', '#', '#'}, {'#', '.', '.', '#'}, {'#', 'S', 'E', '#'}, {'#', '#', '#', '#'} };刷题时经常要从小地图里找起点'S'和终点'E',二维数组遍历模板需要背熟:
int rows = map.length; int cols = map[0].length; for (int i = 0; i < rows; i++) { for (int j = 0; j < cols; j++) { char c = map[i][j]; // 处理当前格子 } }注意不要用arr.length直接当列数,要先确认矩阵不是空数组,否则map[0]会越界。
3.2 数组如何"增加"元素:复制扩容的本质
热词里有"数组增加",这个问题初学者几乎必问:数组长度不是固定的吗,怎么增加元素?答案是:数组本身不能增加长度,所谓"增加"其实是创建一个新数组,把旧数据复制过去,再在指定位置写入新值。核心操作是System.arraycopy或者Arrays.copyOf。
public static int[] addElement(int[] arr, int value) { int[] newArr = Arrays.copyOf(arr, arr.length + 1); newArr[arr.length] = value; return newArr; }这里的Arrays.copyOf底层调用的就是System.arraycopy,一个native方法,直接从内存层面批量复制,效率很高。
ArrayList的动态扩容底层就是这个思路。它初始容量为10,每次扩容时新容量是旧容量的1.5倍(oldCapacity + (oldCapacity >> 1)),然后把旧数组元素搬到新数组。之所以选择1.5倍而不是翻倍,是因为扩容成本太高,频繁扩容会导致性能下降。这个知识点面试里很常问,背后其实就是数组复制的原理。
3.3 冒泡排序从原理到优化
冒泡排序是Java面试和笔试的高频手写题。原理简单说:每一轮从头开始,相邻元素两两比较,如果顺序不对就交换,每一轮结束后最大的元素像气泡一样浮到末尾。重复n-1轮,数组有序。
手写基础版很快:
public static void bubbleSort(int[] arr) { 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; } } } }但面试官一般会追问优化。最常见的优化是:如果某一轮遍历过程中一次交换都没发生,说明数组已经有序,直接结束。
public static void bubbleSortOptimized(int[] arr) { for (int i = 0; i < arr.length - 1; i++) { boolean swapped = false; 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; swapped = true; } } if (!swapped) { break; } } }还有一个进阶优化:记录最后一轮最后发生交换的位置,该位置之后的数据已经有序,下一轮只需遍历到该位置为止。这个写法在笔试里算加分项,能体现你对排序过程理解到位。排序这个热词在Java面试里出现频率极高,冒泡排序是最基础的,能把优化的原理讲清楚的候选者通常能给面试官留下不错印象。
4. 经典练习题实录:数组是否同构的完整拆解
4.1 还原题目完整描述
网上流传的题面信息不完整,我根据多年刷题经验把这道"数组是否同构"补全成一个可落地的版本:
有两个长度为n的数组a和b。如果存在一个整数x,满足:对数组中的每一个下标i,都有 b[i] = a[i] + x,并且保持数组原有的顺序,则称这两个数组同构。请判断给定的两个数组是否同构。
说白了,就是判断两个数组是不是"整体平移"的关系:一个数组的所有元素都加上同一个整数x,能得到另一个数组。举个例子:
a = [1, 2, 3] b = [4, 5, 6]这里 x = 3,b[i] = a[i] + 3 对所有下标成立,所以两个数组同构。再比如:
a = [1, 2, 4] b = [5, 6, 7]3对应5没问题,2对应6也差4,但4对应7差3,差值不统一,所以不同构。
另外还有一个变体版本需要知道:两个数组元素之间如果存在一一映射关系,也叫同构,类似LeetCode的同构字符串问题。我会在4.3单独给出解法,两种版本面试都可能遇到。
4.2 解法一:保持顺序的差值恒定法
最简单也最高效的思路是:既然要求 b[i] = a[i] + x 成立,那么 b[i] - a[i] 必须始终等于同一个数x。所以我们只需要遍历一次数组,记录第一个位置的差值,然后逐一比对。
public static boolean isIsomorphic(int[] a, int[] b) { if (a == null || b == null || a.length != b.length) { return false; } if (a.length == 0) { return true; } long diff = (long) b[0] - a[0]; for (int i = 1; i < a.length; i++) { if ((long) b[i] - a[i] != diff) { return false; } } return true; }这里有两个细节是真正工作过的人才会注意到的。第一,差值要用long类型保存。因为如果a是Integer.MIN_VALUE、b是Integer.MAX_VALUE,相减会溢出,int类型的差值计算出来是错误的。第二,先判空、再判断长度,这是个防御性编程习惯,避免后面的a[0]直接抛空指针或越界异常。
复杂度:时间O(n),空间O(1)。这是在"保持原有顺序"的前提下最优的解法,没有多余开销。
4.3 解法二:排序后对比(不要求保持原顺序的情况)
如果题目不限制"保持原有顺序",那在排序后再判断差值恒定也是正确的。这里有一个数学事实:如果 b 是 a 整体加上 x 得到的,那么排序后 b 排序的结果,也必然是 a 排序结果整体加上 x。因为每个元素都加同一个x,相对大小关系完全不变。
public static boolean isIsomorphicAfterSort(int[] a, int[] b) { if (a == null || b == null || a.length != b.length) { return false; } if (a.length == 0) { return true; } int[] sortedA = a.clone(); int[] sortedB = b.clone(); Arrays.sort(sortedA); Arrays.sort(sortedB); long diff = (long) sortedB[0] - sortedA[0]; for (int i = 1; i < sortedA.length; i++) { if ((long) sortedB[i] - sortedA[i] != diff) { return false; } } return true; }时间复杂度O(n log n),主要是排序的开销。写这道题时还要记住为什么先clone():因为Arrays.sort是原地排序,会修改传入的数组。如果直接把原数组排序了,后续逻辑就用不了原顺序了。
4.4 变体版本:元素一一映射的同构判断
再来一个很多面试官喜欢追加问的变体:给定两个数组a和b,判断它们的元素是否存在一一对应的映射关系。比如:
a = [1, 2, 1] b = [9, 8, 9]a中的1映射到b中的9,2映射到8,一一对应,不存在一个字符映射到两个不同对象的情况,所以同构。再看:
a = [1, 2, 1] b = [9, 8, 7]这里1同时映射到9和7,冲突,所以不同构。这个变体需要用两个HashMap维护双向映射:
public static boolean isIsomorphicMapping(int[] a, int[] b) { if (a == null || b == null || a.length != b.length) { return false; } Map<Integer, Integer> mapA2B = new HashMap<>(); Map<Integer, Integer> mapB2A = new HashMap<>(); for (int i = 0; i < a.length; i++) { Integer mapped = mapA2B.get(a[i]); if (mapped == null) { mapA2B.put(a[i], b[i]); } else if (!mapped.equals(b[i])) { return false; } Integer mappedB = mapB2A.get(b[i]); if (mappedB == null) { mapB2A.put(b[i], a[i]); } else if (!mappedB.equals(a[i])) { return false; } } return true; }这里也必须强调一个Java基础知识的坑:Integer对象之间的比较必须用equals,不能用==。因为-128到127范围内的Integer会被缓存复用,超出这个范围的Integer对象即使数值相同,引用也不同,用==比较会得到false。我见过太多人在这一步栽跟头,跑几个用例没问题,一换大数字就翻车。
4.5 配套练手题清单
这一节配合数组题目给出几道经典的练手题,每个都对应了热词里的高频搜索项:
| 练习题目 | 考点 | 推荐思路 |
|---|---|---|
| 数组去重 | 哈希表、双指针 | Set去重后转数组,或排序后原地去重 |
| 找出数组中重复的数字 | 哈希表、原地哈希 | 用HashSet记录已出现元素 |
| 反转数组 | 双指针 | 首尾指针交换,O(n)时间O(1)空间 |
| 冒泡排序手写 | 排序基础 | 结合3.3的优化版本 |
| 从控制台读入char数组 | 输入处理 | new Scanner(System.in).next().toCharArray() |
| 二维数组转置 | 二维数组遍历 | 行列下标交换 |
| 数组元素整体右移k位 | 数组操作 | 三步反转法:反转整体、反转前k个、反转后n-k个 |
| 判断两个数组是否互为排列 | 排序/计数 | 排序后比较相等,或用HashMap统计频次 |
5. 实战避坑清单:数组相关的经典问题
5.1 asList和Integer比较的隐蔽问题
前面提过Arrays.asList返回的List不能add和remove。更隐蔽的是,直接对Arrays.asList的结果调用clear()会抛出UnsupportedOperationException,而set()是可以用的。这是因为返回的列表是基于原数组的视图,底层还是那个数组。如果你用set()修改元素,原数组也会跟着变。业务代码里最保险的做法永远是new ArrayList<>(Arrays.asList(...)),把数据复制到真正的ArrayList里。
Integer比较的坑前面说过了,这里再补充一个真实案例:项目里把两个Integer用==比较,线上偶发出现"相等却被判断为不等"的诡异bug,查来查去发现是Integer缓存范围之外的数值触发了问题。所以规则只有一条:所有包装类型的大小比较,一律用equals或者转成基本类型再比。
5.2 数组拷贝的浅拷贝陷阱
clone()、Arrays.copyOf、System.arraycopy这三种拷贝方式,对一维数组是深拷贝,因为基本类型是直接复制值;但如果数组里存的是对象类型,拷贝的只是引用,不是对象本身。也就是说,两个数组中的元素指向堆里同一个对象,改一个数组的元素对象,另一个也变。
二维数组更要注意:int[][] copy = original.clone()只是复制了外层数组,内层数组依然是共享的。想实现真正的深拷贝,需要逐行复制:
int[][] original = {{1, 2}, {3, 4}}; int[][] copy = new int[original.length][]; for (int i = 0; i < original.length; i++) { copy[i] = Arrays.copyOf(original[i], original[i].length); }这个知识点在刷题时经常用到。比如回溯算法里要保存棋盘状态,如果直接赋值引用,回溯后状态就乱了,必须做深拷贝。
5.3 数组下标越界和空指针的组合拳
数组遍历最容易出现的两个运行时异常,一是ArrayIndexOutOfBoundsException,二是NullPointerException。前者几乎都是因为循环边界写错。记住几个黄金规则:
- 循环条件用
i < arr.length,不要写i <= arr.length,更不要写死数字 - 访问二维数组时,先确认
arr.length > 0再访问arr[0] - 数组元素是引用类型时,使用前判断是否为null
还有一种特殊场景:方法返回数组时,如果无数据可返回,返回空数组new int[0],不要返回null。否则调用方每次都得判空,代码又臭又长。这是一条非常实用的编码规范,Java标准库里很多API都是这么设计的(比如String.split()在没有匹配时返回长度为0的数组)。
5.4 刷题时更推荐的数组算法范式
最后分享一些我自己的实操习惯。数组相关的算法题虽然千变万化,但核心范式就那几个,熟练之后很多题都能秒归类:
- 双指针:解决有序数组两数之和、去重、反转、滑动窗口等问题,空间复杂度能压到O(1)
- 前缀和:频繁查询子数组和时,先预处理前缀和数组,查询O(1)
- 哈希表辅助:把数组元素值作为key,下标作为value,很多"找两数之和"类题目的标准解法
- 原地操作:覆盖类问题(去重、移除元素)尽量在同一个数组上操作,不额外开辟空间
- 排序辅助:很多看似复杂的题目,先排序就能大幅简化,例如判断是否为排列、找中位数
我个人调试数组的习惯是:任何地方打印数组,一律Arrays.toString(arr)或Arrays.deepToString(arr)。直接把数组对象传给println,打印出来的是[I@1b6d3586这种哈希地址,对排查问题一点帮助都没有。这个习惯是我刚开始写Java时踩过坑才养成的,看似不起眼,但能省下大量排查时间。
数组在Java里看起来很简单,但每一个细节背后都牵扯内存、类型系统、工具类设计甚至JVM的实现,把这些基础吃透,不管是刷题还是做项目,都会顺手很多。