资讯动态

Java数组进阶实战:从JVM内存到双指针与矩阵旋转

发布时间:2026/10/20 23:18:11 来源:尧图企业网站定制
最近带一批学生刷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是3grid[0].length才是4。很多人在这上面含糊后面处理矩阵题的时候自然会错。我见过不少同学写循环时用grid.length去控制内层遍历结果不是漏了数据就是越界根子就在这。另外Java对每一次数组访问都做边界检查越界会抛ArrayIndexOutOfBoundsException。对比C/C那种完全不检查的做法这是安全换了一部分性能但JIT编译器会有边界检查消除的优化在循环里访问数组并不会慢到你需要担心的程度。所以别为了“性能”去写不安全的下标算计老老实实按边界来。1.2 默认值与引用共享进阶题第一层误区数组创建后元素会被赋默认值。基本数值类型是0boolean是falsechar是\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.arraycopy5个参数native方法速度快支持同一数组重叠复制通用复制、数组扩容的底层Arrays.copyOf2个参数内部调用arraycopy可自动扩容并补默认值扩容、生成新数组clonearr.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]; } }注意第二行开始才有内层循环i0时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]的和那l0的时候就要特判代码容易漏。进阶一点还有二维前缀和公式是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 ListInteger spiralOrder(int[][] matrix) { ListInteger 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_VALUEb[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^3O(n^2)基本安全。n 10^5O(n^2)必超时必须想O(n log n)或O(n)。n 10^7以上哪怕O(n)也要小心常数可能要压输入输出、避免频繁创建对象。内存方面一个int[10^6]大约是4MBint[10^7]是40MB再加上JVM自身开销如果内存限制64MB很容易爆。遇到需要大数组的题先算一下字节数再动手。7.4 调试数组问题的三个小技巧第一善用Arrays.toString和Arrays.deepToString而不是自己写循环打印。toString打印一维数组deepToString打印二维及以上输出格式清晰一眼看出预期和实际的差别。第二写单测样例时不只是写正常用例要写空数组、只有一行/一列的矩阵、全部相同的数组、逆序数组、最大最小值。很多边界问题在这几个样例里暴露得特别快。第三动手画下标。我自己做矩阵题的习惯是在草稿纸上把矩阵画出来标好每一步的row、col、top、bottom。别急着写代码先在纸上走一遍边界再上IDE。这个习惯帮我避免了一大半边界错误。数组进阶题卡人往往不是算法本身多难而是对数组这个“基础对象”的理解还停留在表面。把引用共享、复制扩容、二维布局这几个底层概念补扎实再配合双指针、前缀和、滑动窗口、矩阵边界这些套路实训题目的通关率会提升一大截。我个人的体会是每做一道数组题都先问自己一句“这个操作是共享还是复制、边界有没有可能越界”养成这个习惯比多刷十道题都有用。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑