最近带一批学生刷Java数组进阶的实训,发现一个很有意思的现象:数组在初学阶段是最容易被“跳过”的知识点,int[] arr = new int[10]张口就来,可一旦进阶题目上来,很多人就卡在越界、引用共享、二维数组布局、矩阵旋转这些坎上。与其说数组进阶难,不如说初学时的理解太“表面”了。
这篇文章算是我的一篇阶段性复盘。我会从JVM视角把数组的身份讲清楚,然后展开复制扩容、二维数组内存布局、高频算法套路、工具类陷阱和在线判题环境的实战细节。内容围绕Java数组进阶这个方向,适合正在刷实训平台题目的人、准备笔试的人,也适合想把Java基础重新夯实一遍的开发者。文中代码都可以直接跑通,建议边看边动手。
1. 从JVM视角重新认识Java数组:对象身份与引用共享
1.1 数组对象在JVM里的真实结构
很多人会把数组理解成“一连串变量”,其实在Java里,数组是货真价实的对象。你写int[] arr = new int[10],JVM会在堆上分配一块连续内存,arr这个变量只是指向这块内存的引用。数组对应的类由JVM在运行时动态生成,你在class目录里找不到Array.class。这一点直接解释了为什么数组有length属性而不是size()方法,也解释了为什么数组可以做instanceof Object判断。
length不管是几维数组,拿到的永远是最外层长度。比如int[][] grid = new int[3][4],grid.length是3,grid[0].length才是4。很多人在这上面含糊,后面处理矩阵题的时候自然会错。我见过不少同学写循环时用grid.length去控制内层遍历,结果不是漏了数据就是越界,根子就在这。
另外,Java对每一次数组访问都做边界检查,越界会抛ArrayIndexOutOfBoundsException。对比C/C++那种完全不检查的做法,这是安全换了一部分性能,但JIT编译器会有边界检查消除的优化,在循环里访问数组并不会慢到你需要担心的程度。所以别为了“性能”去写不安全的下标算计,老老实实按边界来。
1.2 默认值与引用共享:进阶题第一层误区
数组创建后,元素会被赋默认值。基本数值类型是0,boolean是false,char是'\u0000',引用类型一律是null。引用数组默认值是null这一点,是空指针高发区。比如:
String[] names = new String[5]; System.out.println(names[0].length()); // NullPointerException二维对象数组更常见:只new了外层new Integer[3][],内层每一行还没有指向具体的一维数组,一访问就NPE。写矩阵类题目之前,先确认内层行数组是否已经初始化。
然后是引用共享。int[] b = a并不是复制数组,只是让b和a指向同一个底层数组。方法传参也一样,数组是对象,传进去的是引用,你在方法里改了内容,外面一定受影响。很多同学在实训里写出“明明在函数里排序了,为什么外面的数据也变了”的疑问,正是因为没建立起“数组是引用传递”的认知。
int[] a = {1, 2, 3}; int[] b = a; b[0] = 99; System.out.println(a[0]); // 99如果你确实需要一份独立的数据,必须用复制而不是赋值。这个需求在数组进阶里太频繁了,所以下一章专门说复制和扩容。
2. 数组复制与扩容:手写一个动态数组,顺便搞懂ArrayList的1.5倍
2.1 四种复制方式,性能与语义差别不小
复制数组至少有四种写法:循环赋值、System.arraycopy、Arrays.copyOf、clone。看起来都能复制,但语义和性能差别很大。
| 方式 | 语法 | 特点 | 适用场景 |
|---|---|---|---|
| 循环赋值 | for +arr[i] = src[i] | 直观,可以自定义规则 | 小数组、复制逻辑复杂时 |
| System.arraycopy | 5个参数 | native方法,速度快,支持同一数组重叠复制 | 通用复制、数组扩容的底层 |
| Arrays.copyOf | 2个参数 | 内部调用arraycopy,可自动扩容并补默认值 | 扩容、生成新数组 |
| clone | arr.clone() | 返回同类型数组,最简洁 | 快速整数组复制 |
System.arraycopy的五个参数很容易记错:源数组、源起点、目标数组、目标起点、复制长度。
int[] src = {1, 2, 3, 4, 5}; int[] dst = new int[5]; System.arraycopy(src, 1, dst, 2, 3); System.out.println(Arrays.toString(dst)); // [0, 0, 2, 3, 4]这里dst的初始默认值都是0,从下标2开始放了2、3、4,剩下的保持0。很多人会把源起点和目标起点搞反,写的时候慢点。
Arrays.copyOf更适合“扩容”场景,它接受原数组和新长度,新长度比原长度大时,多出来的位置填默认值:
int[] oldArr = {1, 2, 3}; int[] newArr = Arrays.copyOf(oldArr, oldArr.length * 2); System.out.println(newArr.length); // 6还有一个坑:clone对一维数组是深拷贝(对基本类型而言),但对二维数组只是浅拷贝——它只复制了外层数组的引用,内层行数组还是共享的。想深拷贝二维数组,必须逐行clone或逐行System.arraycopy。
2.2 手动扩容与均摊复杂度:ArrayList的1.5倍是怎么来的
明白了复制,就可以自己实现一个简单的动态数组。这个过程其实就是ArrayList的底层逻辑。先写一个只支持int的最小版本:
class SimpleIntList { private int[] data = new int[10]; private int size = 0; public void add(int value) { if (size == data.length) { int newLen = data.length + (data.length >> 1); data = Arrays.copyOf(data, newLen); } data[size++] = value; } public int get(int index) { if (index < 0 || index >= size) { throw new IndexOutOfBoundsException("index: " + index); } return data[index]; } public int size() { return size; } }关键点就一个:扩容时机是size == data.length,也就是数组满了才扩。新容量取旧容量 + 旧容量的一半,也就是1.5倍,不是翻倍。为什么是1.5倍而不是2倍?这是空间和时间的平衡。扩得越多,扩容次数越少,但浪费的空间也越多;1.5倍是工程上被广泛验证过的折中方案。ArrayList源码里用的就是oldCapacity + (oldCapacity >> 1)。
扩容本身是O(n)的复制操作,但因为每次扩容后数组变大了,把扩容的成本摊到每一次add上,均摊下来依然是O(1)。这个“均摊复杂度”在算法分析里很容易被忽略,实际上ArrayList能高效处理连续添加,靠的就是这个机制。
如果你在实训题里需要自己维护一个动态数组,就参考这个写法,别每次add都new一个更大的数组然后全量复制,那样复杂度就是O(n^2)。
3. 二维数组的内存真相:它不是你以为的连续矩阵
3.1 二维数组是数组的数组,行与行不一定挨着
int[][] grid = new int[3][4]在内存里并不是一块连续的12个int。JVM先创建一个长度为3的一维数组,里面每个元素都是引用;然后再创建3个长度为4的一维数组,让外层引用分别指向它们。也就是说,二维数组的真实结构是“数组的数组”。
这个布局带来的直接后果是:行与行在堆上不保证连续,甚至每一行的内存地址可能相差很远。你在做矩阵题的时候,如果脑内模型是“一整块矩形内存”,很多性能问题和缓存失效问题就想不通。
而且二维数组每一行其实是一个独立对象,把某一行传给别人修改,这一行外面的数据也会变,因为传的是行数组的引用。浅拷贝二维数组时尤其要注意:clone外层数组,内层数组却是共享的。
3.2 不规则数组:杨辉三角的正确打开方式
二维数组既然是一维数组的数组,那它天然支持不规则形状。你可以只确定外层长度,内层一行行按需分配:
int[][] tri = new int[5][]; tri[0] = new int[1]; tri[1] = new int[2]; tri[2] = new int[3];这个特性在做杨辉三角这类题时特别顺手:
int rows = 5; int[][] yanghui = new int[rows][]; for (int i = 0; i < rows; i++) { yanghui[i] = new int[i + 1]; yanghui[i][0] = 1; yanghui[i][i] = 1; for (int j = 1; j < i; j++) { yanghui[i][j] = yanghui[i - 1][j - 1] + yanghui[i - 1][j]; } }注意第二行开始才有内层循环,i=0时j循环根本不执行。这个例子很好地说明了“每行长度不同”在内存和逻辑上都不构成障碍。
3.3 遍历顺序:为什么习惯统一按行优先
Java的二维数组行与行不连续,行内是连续的,所以按行优先遍历(外层循环i、内层循环j访问grid[i][j])比按列优先遍历通常更友好。按列优先意味着你要反复横跳多个不同的行对象,缓存命中率会更差。
不过Java二维数组本身每行都是独立数组,跨行访问本来就不保证连续,所以行优先也不一定是极致性能。真正追求性能的矩阵运算,可以铺垫一层的一维数组,用index = i * cols + j自己计算下标。做题阶段,我建议统一用行优先,不是为了极限性能,而是为了少出错。按行写循环,边界条件更好推理。
4. 一维数组进阶:双指针、前缀和与滑动窗口的实战模板
4.1 双指针:有序数组去重的快慢指针思路
数组进阶题里双指针绝对是主角。最经典的是有序数组原地去重。要求原地,意味着你不能new一个新数组,只能在原数组上覆盖。
public int removeDuplicates(int[] nums) { if (nums.length == 0) { return 0; } int slow = 0; for (int fast = 1; fast < nums.length; fast++) { if (nums[fast] != nums[slow]) { slow++; nums[slow] = nums[fast]; } } return slow + 1; }核心思想是:slow维护“已整理好”的数组末尾,fast负责往后探测新元素。遇到不同的值,就把slow先向右移一格,再把新值放进来。返回长度是slow + 1,因为slow是下标。
相向双指针也很常见,比如三数之和:先排序,固定一个数,剩下两个用左指针和右指针相向移动。两个指针相遇就是一个完整遍历周期,整个复杂度从O(n^2)降到O(n),加上外层固定一个数的O(n),总复杂度O(n^2)。这类题的共同经验是:见到“有序数组”、“去重”、“两数之和”这些词,优先往双指针想。
4.2 前缀和:把区间查询从O(n)降到O(1)
如果题目反复问“某个区间内的元素和”,每次都循环累加就太慢了。前缀和的思路是:预处理一个新数组,prefix[i]表示nums[0]到nums[i-1]的和,这样任意区间[l, r]的和就是一次减法。
int[] nums = {3, 1, 4, 1, 5}; int n = nums.length; int[] prefix = new int[n + 1]; for (int i = 0; i < n; i++) { prefix[i + 1] = prefix[i] + nums[i]; } // 求 nums[1] 到 nums[3] 的和,也就是 1 + 4 + 1 = 6 int sum = prefix[4] - prefix[1];为什么前缀和数组长度是n + 1而不是n?这是最容易被忽略的设计。prefix[0]固定为0,表示“一个元素都不取”的和。这样求[0, r]区间时不需要单独处理边界,直接用prefix[r + 1] - prefix[0]就行。如果你把prefix[i]定义成nums[0]到nums[i]的和,那l=0的时候就要特判,代码容易漏。
进阶一点还有二维前缀和,公式是:
sum[i][j] = sum[i - 1][j] + sum[i][j - 1] - sum[i - 1][j - 1] + grid[i][j];这个公式用容斥原理理解:加上上方和左方两块,减掉重复加的左上角,再补上当前元素。日常题里遇到矩阵区域和问题,基本就是这套东西。
4.3 滑动窗口:连续子数组问题的最优解套路
滑动窗口和双指针是近亲,但更强调“窗口”的伸缩。一个经典题目是求长度最小的连续子数组,使得子数组和大于等于目标值。
public int minSubArrayLen(int target, int[] nums) { int left = 0; int sum = 0; int ans = Integer.MAX_VALUE; for (int right = 0; right < nums.length; right++) { sum += nums[right]; while (sum >= target) { ans = Math.min(ans, right - left + 1); sum -= nums[left]; left++; } } return ans == Integer.MAX_VALUE ? 0 : ans; }这个模板非常稳定:右指针向前扩展,把新元素纳入窗口;一旦窗口满足条件,尝试收缩左指针找更优解;答案在每次收缩前记录。很多变体题,比如“无重复字符的最长子串”、“水果成篮”,改动核心也就是窗口内维护的信息不同。
习惯写while (sum >= target)而不是if,因为左指针收缩可能连续多次。漏写while是这类题最常见的错误,一漏就是测试用例不过。
5. 矩阵类题目实战:旋转、螺旋与对角线遍历的边界管理
5.1 顺时针旋转90度:转置加翻转
矩阵旋转看起来吓人,其实有标准套路。顺时针旋转90度,可以拆成两步:先沿着主对角线转置,再把每一行反转。反过来说,逆时针旋转就是转置后对每一列反转,或者先上下翻转再转置。
public void rotate(int[][] matrix) { int n = matrix.length; // 第一步:转置 for (int i = 0; i < n; i++) { for (int j = i; j < n; j++) { int tmp = matrix[i][j]; matrix[i][j] = matrix[j][i]; matrix[j][i] = tmp; } } // 第二步:逐行反转 for (int i = 0; i < n; i++) { int left = 0, right = n - 1; while (left < right) { int tmp = matrix[i][left]; matrix[i][left] = matrix[i][right]; matrix[i][right] = tmp; left++; right--; } } }转置的时候内层循环从j = i开始,不是从0开始。如果从0开始,每对元素会被交换两次,等于白做。这个细节很多人栽过。
5.2 螺旋矩阵:四边界收缩的通用写法
螺旋矩阵是面试和实训里的常客,要求按顺时针螺旋顺序输出矩阵元素。最不容易出错的写法是“边界收缩法”:
public List<Integer> spiralOrder(int[][] matrix) { List<Integer> res = new ArrayList<>(); if (matrix.length == 0) { return res; } int top = 0, bottom = matrix.length - 1; int left = 0, right = matrix[0].length - 1; while (top <= bottom && left <= right) { for (int j = left; j <= right; j++) { res.add(matrix[top][j]); } top++; for (int i = top; i <= bottom; i++) { res.add(matrix[i][right]); } right--; if (top <= bottom) { for (int j = right; j >= left; j--) { res.add(matrix[bottom][j]); } bottom--; } if (left <= right) { for (int i = bottom; i >= top; i--) { res.add(matrix[i][left]); } left++; } } return res; }四个方向各走一条边,每走完一条边就把对应的边界往里缩一格。两个if非常关键:当矩阵是单行或单列时,不加判断会导致重复收集元素。这个写法把复杂的方向变换转化成了四个边界变量的变化,逻辑非常直观。
5.3 对角线遍历:用坐标奇偶推导方向
对角线遍历的题目,核心是抓住一条规律:同一条对角线上,所有元素的行 + 列是同一个值。方向变化可以通过row + col的奇偶来判断。比如这样一个实现:
public int[] findDiagonalOrder(int[][] mat) { int m = mat.length, n = mat[0].length; int[] ans = new int[m * n]; int row = 0, col = 0; for (int i = 0; i < ans.length; i++) { ans[i] = mat[row][col]; if ((row + col) % 2 == 0) { if (col == n - 1) { row++; } else if (row == 0) { col++; } else { row--; col++; } } else { if (row == m - 1) { col++; } else if (col == 0) { row++; } else { row++; col--; } } } return ans; }方向定义以题目为准,只要掌握“奇偶决定走左上还是右下,撞墙就先转向再前进”的推导方法,就能应对所有变体。不要死记代码,拿张草稿纸画一个3x3的矩阵,把row + col标出来,规律一眼就清楚。
6. Arrays工具类:排序、二分与Comparator里的隐藏陷阱
6.1 sort与parallelSort:什么时候才值得并行
Arrays.sort是数组排序的主力。对基本类型数组,Java用双轴快速排序;对引用类型数组,用的是TimSort,它是稳定排序。这意味着如果你排序的是对象数组,并且要求原始顺序相等时保持原顺序,Arrays.sort是安全的选择;如果是int[]这种基本类型数组,稳定性没有意义,因为基本类型无法区分“相同的值来自哪里”。
Arrays.parallelSort在数组足够大或者机器核数足够多时,会把排序任务拆到ForkJoin池并行执行。但它不是万能的:数据量小的时候,线程拆分的开销反而比单线程慢。我的经验是,长度几千以下的数组没必要并行,上百万才看得出差别。
6.2 binarySearch找不到元素时到底返回什么
Arrays.binarySearch必须在一个已排序的数组上调用,否则结果未定义。它最反直觉的地方是:找不到时不返回负数“随便一个负数”,而是返回-(插入点) - 1。插入点意思是你把目标值插进去应该放的位置。
int[] arr = {1, 3, 5, 7}; int idx = Arrays.binarySearch(arr, 4); System.out.println(idx); // -3 // 插入点是下标2,-(2) - 1 = -3反过来,如果得到一个负数idx,想求插入位置,代码是insertPos = -idx - 1。这个设计是为了保证一个性质:只有找到时返回值才大于等于0,所以很多老手拿if (idx >= 0)判断存在性。如果你只关心“在不在”,千万别把它和找不到时的任意负值搞混。
6.3 Comparator的溢出陷阱与稳定性
给二维数组按某列排序,常见写法是:
Arrays.sort(points, (a, b) -> a[0] - b[0]);这写法在绝大多数情况没问题,但存在整型溢出风险:如果a[0]是Integer.MAX_VALUE,b[0]是Integer.MIN_VALUE,相减直接溢出成正数,排序结果就错了。正确写法是:
Arrays.sort(points, Comparator.comparingInt(a -> a[0]));如果要对第二列降序,就用Comparator.comparingInt((int[] a) -> a[0]).thenComparing(Comparator.comparingInt((int[] a) -> a[1]).reversed()),或者手动Integer.compare(b[1], a[1])。总之,涉及差值比较的lambda,能用Integer.compare就用它,别贪图简短。
7. 在线判题环境里最容易翻车的细节与自查清单
7.1 输入输出:Scanner不是万能的
实训平台判题和本地跑main方法的最大区别在于输入输出。Scanner写起来确实方便,但数据量大了之后性能差距非常明显。几十万行输入的时候,Scanner的解析开销会让你白白超时,算法再优化也没用。
我的通用模板是:
BufferedReader br = new BufferedReader(new InputStreamReader(System.in)); StringTokenizer st = new StringTokenizer(br.readLine()); int n = Integer.parseInt(st.nextToken()); // 按行读取... StringBuilder sb = new StringBuilder(); sb.append(result).append('\n'); System.out.print(sb);输出别循环调用System.out.println,用StringBuilder攒起来一次输出。这个习惯在数据量大的时候比任何算法微优化都有效。
7.2 越界、空指针、溢出:三个高频运行时错误的复盘
越界最常见的来源是循环条件里用了<=。比如读取n个元素,for (int i = 0; i <= n; i++),最后一次访问nums[n]就炸了。另一种是访问nums[i + 1]时循环条件忘了留余量,循环体还没跑完,下一轮就越界。
空指针最常见的来源是二维数组的内层行没初始化,或者引用数组里的元素是null时就调用方法。尤其要注意:数组默认值是null,不代表它是“空数组”。null和长度为0的数组是完全不同的两回事。
溢出则更隐蔽。前缀和、乘积、最大值累加,很多题把int的范围当作理所当然。做题前先算一下量级:如果n是10^5,每个元素最大10^9,总和轻松超过Integer.MAX_VALUE。这时候要么用long,要么在加法里取模。用long也会溢出时,就要考虑BigInteger或者模运算,看题目要求。
7.3 时间复杂度的快速估算与内存体检
在线判题都有时间和内存限制。一个粗略的估算:Java在一秒内大概能跑10^8次极简单的操作,但循环里一旦有方法调用、对象创建,这个量级会明显下降。所以:
- n = 10^3,O(n^2)基本安全。
- n = 10^5,O(n^2)必超时,必须想O(n log n)或O(n)。
- n = 10^7以上,哪怕O(n)也要小心常数,可能要压输入输出、避免频繁创建对象。
内存方面,一个int[10^6]大约是4MB,int[10^7]是40MB,再加上JVM自身开销,如果内存限制64MB,很容易爆。遇到需要大数组的题,先算一下字节数再动手。
7.4 调试数组问题的三个小技巧
第一,善用Arrays.toString和Arrays.deepToString,而不是自己写循环打印。toString打印一维数组,deepToString打印二维及以上,输出格式清晰,一眼看出预期和实际的差别。
第二,写单测样例时,不只是写正常用例,要写空数组、只有一行/一列的矩阵、全部相同的数组、逆序数组、最大最小值。很多边界问题在这几个样例里暴露得特别快。
第三,动手画下标。我自己做矩阵题的习惯是在草稿纸上把矩阵画出来,标好每一步的row、col、top、bottom。别急着写代码,先在纸上走一遍边界,再上IDE。这个习惯帮我避免了一大半边界错误。
数组进阶题卡人,往往不是算法本身多难,而是对数组这个“基础对象”的理解还停留在表面。把引用共享、复制扩容、二维布局这几个底层概念补扎实,再配合双指针、前缀和、滑动窗口、矩阵边界这些套路,实训题目的通关率会提升一大截。我个人的体会是:每做一道数组题,都先问自己一句“这个操作是共享还是复制、边界有没有可能越界”,养成这个习惯,比多刷十道题都有用。