
今天复习到二维数组翻出之前的笔记发现当初踩过的坑和现在理解的深度完全是两回事。如果你也是Java初学者、准备面试或者刷LeetCode刷到二维数组就开始犯迷糊这篇日记应该适合你。二维数组在Java里几乎是绕不开的基础但真正把它用对、用明白的人不多。很多人写了几遍int[][]就以为自己会了一遇到杨辉三角、对角线求和、螺旋矩阵就懵。其实二维数组的难点不在语法而在于你脑中的内存模型和遍历视角是否清晰。这里我会从底层存储讲起把声明、初始化、遍历、经典算法和易错点全部过一遍所有代码都可以直接跑起来验证。很多教材喜欢用矩阵来类比二维数组这个比喻能帮你开个头但也埋了不少隐患——所以我们先从一个反直觉的结论开始Java里的二维数组根本不是一块连续的大表格。1. 二维数组的本质它从来不是一块连续的大表格1.1 数组的数组到底长什么样int[][]的正确读法应该是int[]的数组。也就是一个一维数组里面的每个元素又是一个int[]。我习惯用一个生活例子来理解把二维数组想象成一个文件柜。外层数组是一排抽屉每个抽屉里放一叠文件内层数组是每叠文件本身。你想拿某个具体数据得先打开外层抽屉再抽出里面的文件。C语言里的二维数组更像一个大柜子所有格子都在同一块连续的内存里排开而Java里的二维数组是一排独立抽屉每个抽屉自己有独立的存放位置抽屉之间并不相邻。看这行最常用的初始化代码int[][] arr new int[3][4];JVM实际做了三件事在堆内存里分配一个长度为3的引用数组元素类型是int[]再创建3个长度为4的int一维数组把这3个一维数组的内存地址分别存入arr[0]、arr[1]、arr[2]。用文字把内存图画出来大概是这样变量存储内容arr外层数组的引用arr[0]指向第1个int[4]数组的引用arr[1]指向第2个int[4]数组的引用arr[2]指向第3个int[4]数组的引用这正好解释了为什么Java能支持不规则数组也叫锯齿数组。外层数组只负责装引用它不关心每个内层数组有多长只要内层数组都是int[]类型就行。1.2 两条记忆点帮你绕开最常见误区我见过不少初学者包括我当初也是这样总会用C语言那套连续内存的逻辑去套Java结果出了bug都不知道怎么回事。这里只需要记两条arr本身只是一根引用指向外层数组对象真正装数据的是内层那些一维数组它们散落在堆的不同位置彼此独立。有了这两条后面讲到的clone()浅拷贝、Arrays.toString()打印错误、空指针异常你都会觉得理所应当。本质上所有二维数组的坑几乎都出自同一个原因你把它当成一个有行有列的矩阵而它其实是一组一维数组的集合。1.3 不规则数组不是语法糖是真实本领为什么要支持不规则数组最典型的场景是存储每个学生选的科目数量不同这种成绩表。比如3个学生第一个考了2门第二个考了3门第三个考了5门int[][] scores new int[3][]; scores[0] new int[2]; scores[1] new int[3]; scores[2] new int[5];这样既能省内存又贴近业务逻辑。如果硬要用规则矩形存反而得按最长的行来分配白白浪费空间。这个特性后面还会在杨辉三角那里派上大用场。所以现在就可以把二维数组数组的数组这个概念焊死在脑子里。2. 声明、创建与初始化五种写法其实是一个套路2.1 静态初始化一步到位但不灵活最直观的写法是花括号赋值int[][] arr {{1, 2, 3}, {4, 5}, {6}};编译器会自动推断外层长度是3第一行3列第二行2列第三行1列。写起来很爽适合元素已经确定、直接写死的场景比如测试数据、常量表。但注意一个限制这种简写只能在声明的同时使用。int[][] arr; arr {{1, 2}, {3, 4}}; // 编译错误如果先声明后赋值必须写成完整形式int[][] arr; arr new int[][]{{1, 2}, {3, 4}};这个坑我在刚开始经常踩IDE里报了错才意识到简写是语法糖不是语句。2.2 动态初始化理解new int[3][4]的等价拆解new int[3][4]看着是一步到位实际可以拆成四步int[][] arr new int[3][]; arr[0] new int[4]; arr[1] new int[4]; arr[2] new int[4];把这四步看懂了很多问题就迎刃而解。比如为什么new int[][3]是编译错误因为外层数组必须先有长度JVM才知道要创建几个引用槽位至于每个引用指向的数组多长那是后面单独决定的。你可以试着在纸上写一遍拆解过程我保证比死记语法有效得多。2.3 默认值你不赋值它也有值动态初始化后数组元素不会是一片空白每种类型都有默认值数组类型默认值int[][]内层元素为0double[][]内层元素为0.0boolean[][]内层元素为falseString[][]内层元素为null任意引用类型[][]内层元素为null如果你是new int[3][]只创建了外层那么arr[0]、arr[1]、arr[2]是引用类型变量默认值是null。这时候直接去读arr[0][0]会抛出空指针异常在后面第5部分会专门讲。2.4 分步初始化的常见写法把循环用起来实际开发中最常用的是先定外层再用循环初始化每一行int n 5; int[][] arr new int[n][]; for (int i 0; i n; i) { arr[i] new int[i 1]; }这段代码创建了一个下行比上行多一列的三角形数组是后面写杨辉三角的前置技能。每行长度不同完全合法。这时候如果你心里想的还是矩阵写起来就会别扭但如果你清楚它是数组的数组这个循环就特别自然外层数组的长度n决定循环次数每次循环给arr[i]赋一个不同长度的一维数组。2.5 一个附加提醒静态初始化适合小数据动态初始化适合批量数据静态初始化写起来好看但不适合行数由用户输入决定的场景。比如int rows sc.nextInt(); int[][] arr new int[rows][];就只能走动态初始化路线。所以我的建议是优先用动态初始化把每一行当成独立的一维数组来思考只有数据写死且不会变的常量才用静态初始化。3. 遍历二维数组从索引进阶到增强for循环思考顺序最重要3.1 标准for循环一切遍历的基础二维数组的遍历最常用双层for循环int[][] arr {{1, 2, 3}, {4, 5, 6}, {7, 8, 9}}; for (int i 0; i arr.length; i) { for (int j 0; j arr[i].length; j) { System.out.print(arr[i][j] ); } System.out.println(); }这里有两个长度必须分清arr.length外层数组的长度也就是行数arr[i].length第i行这个一维数组的长度也就是这一行的列数。在规则矩形数组里每行的arr[i].length都相等所以也有人用arr[0].length来代表列数。但在不规则数组里这么写就有隐患。内层循环用arr[i].length遍历每一行都会准确拿到当前行的真实长度这是最稳妥的写法。3.2 增强for循环内层变量类型千万别写错Java 5开始支持的foreach写法遍历一维数组很舒服扩展二维时有一个关键点for (int[] row : arr) { for (int value : row) { System.out.print(value ); } System.out.println(); }注意外层循环的变量类型是int[]不是int因为二维数组里的每个元素本身就是int数组。我见过有人写成for (int row : arr)编译直接报错——这个错误提示会把初学者看懵其实就是类型不匹配。增强for的好处是拿不到索引代码更简洁、不容易越界。但你一旦需要用到i、j坐标比如对角线求和、矩阵转置就必须回到标准for循环。两种遍历方式不是谁替代谁而是看场景。3.3 三个最常见的统计操作先定义数组int[][] arr { {3, 8, 1}, {9, 2, 7}, {5, 6, 4} };求和int sum 0; for (int[] row : arr) { for (int value : row) { sum value; } }求最大值int max arr[0][0]; for (int[] row : arr) { for (int value : row) { if (value max) { max value; } } }按列求和仅适用于规则矩形数组int colSum 0; int colIndex 1; // 求第1列的和 for (int i 0; i arr.length; i) { colSum arr[i][colIndex]; }如果是不规则数组按列求和处理起来要小心因为不是每一行都有那一列。一个通用做法是遍历每一行只对长度大于目标列号的行累加。3.4 我在遍历时要求自己先想清楚的两句话写遍历之前我喜欢在注释里先写两句话避免写到一半思路跑偏外层i代表行还是列默认外层行、内层列但某些题是列优先看需求。需要索引吗需要就用标准for只是逐个处理数据就foreach。还有一个经验如果题目是二维网格类比如岛屿问题、图像渲染我会直接在纸上把行列坐标标一遍再把代码里的arr[i][j]和纸上坐标对齐。坐标对不齐bug找一天。4. 经典算法实操转置、对角线、杨辉三角、螺旋矩阵4.1 矩阵转置先想清楚行列的互换关系转置就是把matrix[i][j]放到res[j][i]。public static int[][] transpose(int[][] matrix) { int m matrix.length; int n matrix[0].length; int[][] res new int[n][m]; for (int i 0; i m; i) { for (int j 0; j n; j) { res[j][i] matrix[i][j]; } } return res; }注意结果数组的尺寸是new int[n][m]不是new int[m][n]因为转置后原来的行数变成列数列数变成行数。如果是方阵行列相等可以原地交换temp如果不是方阵原地转置会越界或者数据丢失必须新开数组。这个小细节我建议自己动手跑一遍3x2矩阵和2x3矩阵感受最直观。4.2 主对角线与副对角线坐标规律是核心限定在方阵n x n中编程主对角线i j副对角线i j n - 1对角线求和可以这样写public static int diagonalSum(int[][] matrix) { int n matrix.length; int sum 0; for (int i 0; i n; i) { sum matrix[i][i]; // 主对角线 sum matrix[i][n - 1 - i]; // 副对角线 } if (n % 2 1) { sum - matrix[n / 2][n / 2]; // 中心元素被加了两次去掉一次 } return sum; }很多题目会问两条对角线之和中心元素只算一次。这个去重逻辑初学特别容易漏。我当时就是自己测了一个奇数阶矩阵发现答案比预期大一倍才明白中心元素被重复计算了。4.3 杨辉三角不规则数组的最佳代言杨辉三角每一行的首尾都是1中间每个数等于上一行相邻两个数之和。用Java写最优雅的方式就是利用不规则数组特性public static int[][] yangHui(int n) { int[][] triangle new int[n][]; for (int i 0; i n; i) { triangle[i] new int[i 1]; triangle[i][0] 1; triangle[i][i] 1; for (int j 1; j i; j) { triangle[i][j] triangle[i - 1][j - 1] triangle[i - 1][j]; } } return triangle; }这里每一行的长度是i 1正好利用了外层数组的每个元素可以指向不同长度的一维数组这个特性。如果硬要用规则矩形存反而需要分配n x n空间还额外多了很多没用的位置。我建议你把这个代码在IDE里跑一遍打印前面6行你会对数组的数组的理解上一个台阶。4.4 面试进阶从二维数组查找、旋转图像到螺旋矩阵的思路参考这三道题是LeetCode和面试里的常客基础都是二维数组。二维数组中的查找一个行列都递增的矩阵从左下角或者右上角开始搜索。以右上角(0, col-1)为例当前值大于目标就往左走小于目标就往下走每一步都能排除一行或一列时间复杂度O(mn)。这个思路的关键是把每次比较的价值最大化。旋转图像先把矩阵按行翻转再按主对角线交换对称元素或者先转置再左右翻转。边界处理要靠坐标公式推导比硬绕圈简单。这类题目建议动手列出一两个例子找到规律再写代码。螺旋矩阵用top、bottom、left、right四条边界控制循环每跑完一条边就收缩一个单位int top 0, bottom m - 1, left 0, right n - 1; while (top bottom left right) { // 从左到右遍历 top 行然后 top // 从上到下遍历 right 列然后 right-- // 检查边界后从右到左遍历 bottom 行然后 bottom-- // 检查边界后从下到上遍历 left 列然后 left }这类题能帮你说清楚二维数组的坐标系统行、列、边界收缩。我复习的时候是先把上面4个算法都手写一遍再去做对应LeetCode题效果比直接刷题好很多。5. 这些坑我全部踩过length、clone、deepToString、空指针一个都别跑5.1arr.length和arr[i].length的地位不一样标准遍历里我已经强调过外层用arr.length内层用arr[i].length。但在不规则数组里如果你拿arr[0].length当所有行的列数去遍历遇到比第一行短的行就会越界遇到比第一行长的行就会漏值。一个小例子int[][] arr {{1, 2}, {3, 4, 5}, {6}}; for (int i 0; i arr.length; i) { for (int j 0; j arr[0].length; j) { // 错误示范 System.out.print(arr[i][j]); } }第二行arr[1][2]就会越界。正确做法永远是用arr[i].length。5.2clone()是浅拷贝改内层数据会影响原数组二维数组的clone()只拷贝外层引用内层数组还是共享的int[][] a {{1, 2}, {3, 4}}; int[][] b a.clone(); b[0][0] 100; System.out.println(a[0][0]); // 100原数组也被改了想实现真正的深拷贝只能逐行复制int[][] copy new int[a.length][]; for (int i 0; i a.length; i) { copy[i] a[i].clone(); }Arrays.copyOf对二维数组同理。面试如果问如何深拷贝二维数组就是这个答案。5.3 用Arrays.toString()打印二维数组打印的是地址直接Arrays.toString(arr)输出会是[[I1b6d3586, [I4554617c, ...]这种内存地址。这是因为二维数组的元素是int[]它调用的是Object的toString()。正确打印要用System.out.println(Arrays.deepToString(arr));输出[[1, 2, 3], [4, 5, 6]]一目了然。debug的时候特别方便。5.4new int[3][]之后马上访问arr[0][0]会空指针正如第2部分所说只创建外层数组时内层还是null。访问之前必须先给每个内层数组赋值。int[][] arr new int[3][]; arr[0][0] 1; // 空指针异常这个错误提示是NullPointerException新手往往会困惑我明明创建了数组啊原因就是arr[0]此刻是null。判断一个初始化语句是否安全就问自己一句话每个内层数组都创建出来了吗是new int[3][4]已经创建是new int[3][]还没创建。5.5 方法传参传的是引用函数内部修改会影响原数组public static void change(int[][] arr) { arr[0][0] 99; }调用之后外面传进来的数组arr[0][0]会变成99。二维数组作为参数传递时传递的是外层引用和对象一样是共享数据。如果希望函数内部操作不影响原数组先在函数开头做一遍深拷贝。5.6 边界越界特别是i-1、j-1凡是在循环里出现arr[i-1][j-1]这种写法第一步就要确认i、j的起始值。杨辉三角里j从1开始、到i-1结束首尾单独赋1就是典型的边界处理。我自己的检查习惯是写完循环先看边界的三组情况——“最小合法值、中间值、最大合法值”在心里演算一遍确保不越界、不漏数据。6. 复习自测与我的记法复习完这整篇我给自己出了一个自测清单你可以直接对照检查能不能一句话说清int[][]的本质写出new int[3][]的内存状态并说明为什么访问arr[0][0]会报NPEboolean[][]动态初始化后元素默认值是什么int[][] b a.clone()修改b[0][0]会影响a[0][0]吗如何用Arrays.deepToString()正确打印二维数组手写杨辉三角、矩阵转置、对角线求和能不能在5分钟内无错误跑通如果这六个问题你都能脱口而出说明这篇复习笔记你真正吃透了。如果还有模糊把第1部分和第2部分再看一遍二维数组的难点真的不在语法而在你对数组的数组这个底层的理解。最后分享一个小技巧我每次复习完二维数组都会在记事本上画一遍内存示意再手写那三个经典算法。这个过程花不了二十分钟但比刷十道题都管用。画图的时候你会发现Java里所有二维数组的奇奇怪怪行为几乎都能用外层数组存引用、内层数组存数据这个模型解释清楚。