☰
Java矩阵题全攻略:从二维数组到hot100经典套路与避坑指南
2026/10/5 11:23:56 网站建设 项目流程

聊到hot100,很多人第一反应是链表、二叉树、动态规划,矩阵题往往被当成“简单题”一笔带过。真到面试和笔试的时候,矩阵题反而是翻车重灾区——Java选手写二维数组,默认值、边界条件、方向控制,任何一个环节掉链子,整道题就崩了。这篇笔记是我自己刷hot100矩阵题的完整总结,把螺旋矩阵、旋转图像、矩阵置零、搜索二维矩阵、岛屿数量、最大正方形、单词搜索这些高频题全部串起来,讲清楚每类题的解题套路、Java写法要点和容易踩的坑。正在准备面试的朋友可以直接拿来当专题复习资料,刚入门算法、想打牢二维数组基本功的Java开发者,也能从里面找到可复现的代码模板。

1. 先给矩阵题画个像:hot100到底在考什么

1.1 矩阵的本质是一张二维数组,但考的不只是数组

矩阵在Java里就是int[][] matrix,本质是数组的数组,或者说是一个“行优先存储”的二维结构。很多人觉得矩阵题简单,因为语法上不就是两个for循环嵌套吗?但真做起来就会发现,矩阵题考的其实是四件基本功:索引定位、行列遍历、边界判断、空间换时间。这四件事单拎出来都不难,组合在一起就容易乱。

我见过很多朋友刷矩阵题,第一反应就是暴力枚举——把所有格子遍历一遍,能过就算赢。但面试官真正想看的是你对“状态”和“边界”的理解。比如螺旋矩阵要用四条边界收缩,转置要只遍历一半,搜索二维矩阵II要从右上角走迷宫,这些都不是暴力枚举能解决的。矩阵题不考矩阵论里那种分块矩阵求逆、特征值分解的数学推导,考的就是二维数组的操作能力,以及你在复杂度压力下能不能写出干净的代码。

另外,矩阵题特别适合用来考察代码风格。循环变量的命名、边界条件的处理、是否随手防御空数组,这些小细节在面试官眼里都是信号。我自己的体会是,把hot100里的矩阵题刷透,比盲目刷几百道随机题有用得多,因为它们的解题套路高度可复用,一个模板能解决一大片问题。

1.2 hot100矩阵题分类:模拟、搜索、DFS/BFS、DP

我刷完hot100里所有矩阵相关题目之后,把它们分成了四大类。这个分类不是为了好看,而是因为每一类的思维模式完全不同:

类型代表题目核心考点难度
模拟与变换螺旋矩阵、旋转图像边界收缩、转置+翻转中等
原地标记矩阵置零空间复杂度优化、标记位复用中等
搜索类搜索二维矩阵、搜索二维矩阵II一维化二分、右上角走位中等
DFS/BFS与DP岛屿数量、单词搜索、最大正方形、最小路径和Flood Fill、回溯剪枝、状态转移中等到困难

为什么要把分类讲清楚?因为同一张矩阵,考的思维模式完全不同。模拟题考的是“你能不能把转圈圈的过程描述清楚”,搜索题考的是“你能不能利用有序性排除无效区间”,DFS/BFS考的是“你能不能对连通区域做标记”,DP题考的是“你能不能从小问题推到大问题”。

分类刷的好处是,你能在短时间内建立题感。比如看到“矩阵+连通区域”就想到Flood Fill,看到“矩阵+有序”就想到二分或右上角走位,看到“矩阵+最值”就想到DP。这种条件反射,只有分类刷才能练出来。下面我按这个分类,把每道题的核心思路和Java写法逐一拆开讲。

2. 模拟与变换:把转圈圈和翻面写成代码

2.1 螺旋矩阵:四边界收缩法

螺旋矩阵(LeetCode 54)是模拟题里的典型代表。题目要求你从外到内、顺时针遍历整个矩阵。很多人第一次写会陷入“手动模拟每个方向”的陷阱,写出一大堆if-else,最后边界条件一多就崩。

我推荐的做法是维护四个边界变量:top、bottom、left、right。每走完一圈,四个边界各自向内收缩一格,直到边界交叉为止。核心代码如下:

public List<Integer> spiralOrder(int[][] matrix) { List<Integer> result = new ArrayList<>(); if (matrix == null || matrix.length == 0 || matrix[0].length == 0) { return result; } 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++) { result.add(matrix[top][j]); } // 从上到下,遍历当前右列 for (int i = top + 1; i <= bottom; i++) { result.add(matrix[i][right]); } // 从右到左,遍历当前底行(防止单行重叠) if (top < bottom) { for (int j = right - 1; j >= left; j--) { result.add(matrix[bottom][j]); } } // 从下到上,遍历当前左列(防止单列重叠) if (left < right) { for (int i = bottom - 1; i > top; i--) { result.add(matrix[i][left]); } } top++; bottom--; left++; right--; } return result; }

这个解法的关键在于那两个if判断。不加的话,当矩阵只剩一行或只剩一列时,底行遍历或左列遍历会把已经输出过的元素再输出一遍。我第一遍写的时候就是漏了top < bottom这个条件,结果单行矩阵直接给我输出了一串重复元素。后来我把这个场景记死:遍历底行和左列之前,必须确认当前矩阵不是单行或单列。

复杂度和直觉也一致,每个元素恰好被访问一次,时间O(mn),空间O(1)(不考虑结果集)。你可以把整个过程想象成剥洋葱,一圈一圈往里剥,直到剥完。

2.2 旋转图像:先转置再翻转,别硬转

旋转图像(LeetCode 48)要求顺时针旋转90度,而且必须在原地完成。我第一次做这道题的时候,试图用四元素循环交换硬转,结果索引写对了三次,写错了一次,debug了一个小时才反应过来——这种解法太容易错了,四个坐标的映射关系极其反直觉。

后来我换了个思路:顺时针旋转90度,等价于先沿主对角线转置,再逐行左右翻转。比如矩阵:

1 2 3 1 4 7 7 4 1 4 5 6 -> 2 5 8 -> 8 5 2 7 8 9 3 6 9 9 6 3

转置之后每一行再反转,结果正好是顺时针旋转90度。这个解法的优势非常明显:两个步骤各自简单,转置只涉及matrix[i][j]与matrix[j][i]交换,反转就是标准的双指针或者直接循环交换,每一步都好验证。Java代码如下:

public void rotate(int[][] matrix) { int n = matrix.length; // 第一步:沿主对角线转置,只遍历上三角 for (int i = 0; i < n; i++) { for (int j = i + 1; j < n; j++) { int temp = matrix[i][j]; matrix[i][j] = matrix[j][i]; matrix[j][i] = temp; } } // 第二步:逐行左右翻转 for (int i = 0; i < n; i++) { for (int j = 0; j < n / 2; j++) { int temp = matrix[i][j]; matrix[i][j] = matrix[i][n - 1 - j]; matrix[i][n - 1 - j] = temp; } } }

这里有个细节我要特别强调:转置的二层循环j必须从i + 1开始。如果从0开始,等于每个元素交换两次,转了个寂寞。我第一次写就是忘了这个,结果矩阵原地不动,还以为是翻转出了问题。

同理,逆时针旋转90度就是先转置,再逐行上下翻转。这个思路一旦记住,以后面试遇到旋转矩阵的变形题,直接套“转置+翻转”的组合就行。空间复杂度O(1),完美满足原地要求。

2.3 模拟题实操心得:方向数组真香

模拟类的矩阵题,除了四边界收缩和转置翻转,还有一种是“方向”驱动的。比如螺旋矩阵也可以写成“方向数组+碰撞换向”的版本:维护一个dirs方向数组,每一步都判断下一个格子是否越界或者已经被访问过,是就换方向。

方向数组在Java里写起来非常统一:

int[][] dirs = {{0, 1}, {1, 0}, {0, -1}, {-1, 0}};

这个数组表示右、下、左、上四个方向。之后再配一个visited布尔数组,或者直接在原数组上标记特殊值。为什么方向数组好用?因为它把“方向”这个状态从硬编码的if-else里解放出来了。你只需要维护一个directionIndex,每次走不通就(directionIndex + 1) % 4换方向,代码结构非常清晰。

我自己的习惯是:模拟题先想清楚状态变量是什么,再写代码。螺旋矩阵的状态变量是四条边界,旋转图像的状态变量是“转置+翻转”两步操作,边界驱动的模拟题状态变量就是坐标和方向索引。把状态想清楚了,代码自然就写出来了。

另外,Java里处理矩阵时有一些常用的小工具,比如Arrays.fill可以快速填充一维数组,System.arraycopy可以拷贝数组行,这些在笔试时能省不少时间。虽然面试一般不禁止用库函数,但你要能说清楚这些方法的复杂度,否则面试官会追问。

3. 搜索和标记:二维世界的二分与原地改造

3.1 搜索二维矩阵:先定位行再定位列,也可以直接一维化

搜索二维矩阵(LeetCode 74)的特点是:矩阵的每一行递增,并且下一行的第一个元素大于上一行的最后一个元素。这意味着整张矩阵从行优先的角度看,就是一条排好序的一维数组。

最直接的思路是先二分定位行,再二分定位列,时间复杂度O(log m + log n)。但还有一个更骚的写法:直接把这个逻辑上的一维数组做一次二分。关键是把一位下标mid映射回二维坐标:

int row = mid / n; int col = mid % n;

这里n是列数。为什么要除以列数而不是行数?因为矩阵是行优先存储的,第mid个元素在第mid / n行、第mid % n列。这个映射是99%的人第一次写都会搞错的地方,我见过有人除以m,结果直接越界。

完整代码如下:

public boolean searchMatrix(int[][] matrix, int target) { int m = matrix.length; int n = matrix[0].length; int left = 0, right = m * n - 1; while (left <= right) { int mid = (left + right) >>> 1; int value = matrix[mid / n][mid % n]; if (value == target) { return true; } else if (value < target) { left = mid + 1; } else { right = mid - 1; } } return false; }

这个解法的时间复杂度是O(log(mn))。面试的时候,如果你能先说“矩阵满足全局有序,所以可以一维化二分”,再写出这段代码,会是一个很加分的表现。因为大多数人只会想到“两次二分”,一维化二分说明你抓住了“行优先存储”这个本质。

3.2 搜索二维矩阵II:从右上角走出来的二分思维

搜索二维矩阵II(LeetCode 240)比上一题难一点:矩阵只保证每行递增、每列递增,但不存在“下一行第一个元素大于上一行最后一个元素”这样的全局有序。所以不能一维化二分。

但这道题有一个经典解法的思路非常巧妙。你有没有用过单片机矩阵键盘?它的行列扫描是不断地拉低一行、读一列,从而定位按键。而这道题的最佳解法,是从矩阵的右上角开始“走”:

public boolean searchMatrix(int[][] matrix, int target) { int m = matrix.length; int n = matrix[0].length; int row = 0, col = n - 1; while (row < m && col >= 0) { if (matrix[row][col] == target) { return true; } else if (matrix[row][col] < target) { row++; } else { col--; } } return false; }

为什么要从右上角出发?因为右上角这个位置很特殊:它是当前行的最大值,同时也是当前列的最小值。如果当前值小于target,说明这一行最大的数都比target小,整行都可以排除,所以向下移动一行;如果当前值大于target,说明这一列最小的数都比target大,整列都可以排除,所以向左移动一列。每一步都能排除一整行或一整列,最多走m+n步就能结束,时间复杂度O(m+n)。

这个思路我愿称之为“走迷宫式搜索”,它不需要二分查找,但本质上依然是在利用有序性排除无效区间。面试中如果面试官让你优化,你甚至可以说还能用“对每一行做二分”,不过那是O(m log n),不如右上角走法来得优雅。记住:面对行、列各自递增的矩阵,优先想右上角或左下角。

3.3 矩阵置零:用第一行第一列当标记位

矩阵置零(LeetCode 73)的题意是:如果某个元素是0,就把它所在的行和列全部置为0。最朴素的解法是开两个布尔数组,分别记录哪些行、哪些列需要置零,空间O(m+n)。但题目有进阶要求:能不能用O(1)空间?

答案是肯定的,核心思想是复用矩阵的第一行和第一列作为标记数组。具体分三步:

第一步,单独记录第一行和第一列原本是否有0。这一步非常重要,因为后续我们会在第一行和第一列上做标记,如果不提前记录,原始信息就会被覆盖。

boolean firstRowZero = false; boolean firstColZero = false; for (int j = 0; j < n; j++) { if (matrix[0][j] == 0) { firstRowZero = true; break; } } for (int i = 0; i < m; i++) { if (matrix[i][0] == 0) { firstColZero = true; break; } }

第二步,遍历剩余区域,遇到0就把对应的“行标记”和“列标记”落在第一行和第一列上:

for (int i = 1; i < m; i++) { for (int j = 1; j < n; j++) { if (matrix[i][j] == 0) { matrix[i][0] = 0; matrix[0][j] = 0; } } }

第三步,根据标记把对应行列置零,最后单独处理第一行和第一列:

for (int i = 1; i < m; i++) { for (int j = 1; j < n; j++) { if (matrix[i][0] == 0 || matrix[0][j] == 0) { matrix[i][j] = 0; } } } if (firstRowZero) { for (int j = 0; j < n; j++) matrix[0][j] = 0; } if (firstColZero) { for (int i = 0; i < m; i++) matrix[i][0] = 0; }

这个解法的坑点非常隐蔽:如果你一开始没有记录第一行第一列的状态,而是直接用它们做标记,那么当原来的matrix[0][0] == 0或者第一行本身有0时,你的标记信息就被污染了。我自己就吃过这个亏,写出来运行结果全错,最后单步调试才发现是标记行被提前置零了。

这道题是典型的“原地算法”,核心思路就是复用已有空间。面试时能写出O(1)空间解法,会让面试官觉得你对空间复杂度有真实的敏感度。顺便提一句,类似的“复用已有空间”思想在别的题目里也很常见,比如用原数组标记元素是否出现等,都是一脉相承的。

4. DFS、BFS与动态规划:矩阵题的进阶玩法

4.1 岛屿数量:Flood Fill模板,一次会写到处能用

岛屿数量(LeetCode 200)是矩阵题里最经典的DFS题目。给定一个char[][]矩阵,'1'表示陆地,'0'表示水,连在一起的陆地算一个岛,问总共有几个岛。

解法的核心是Flood Fill(洪泛填充):遍历每一个格子,遇到陆地就计数加一,然后用DFS或BFS把整块陆地区域全部“淹没”(标记为已访问),避免重复计数。

DFS版本写起来最简洁:

public int numIslands(char[][] grid) { if (grid == null || grid.length == 0) { return 0; } int count = 0; for (int i = 0; i < grid.length; i++) { for (int j = 0; j < grid[0].length; j++) { if (grid[i][j] == '1') { count++; dfs(grid, i, j); } } } return count; } private void dfs(char[][] grid, int i, int j) { if (i < 0 || i >= grid.length || j < 0 || j >= grid[0].length || grid[i][j] != '1') { return; } grid[i][j] = '0'; dfs(grid, i - 1, j); dfs(grid, i + 1, j); dfs(grid, i, j - 1); dfs(grid, i, j + 1); }

这里我直接把访问过的陆地改成'0',也就是“淹没”,省掉了一个visited二维数组的空间。为什么可以这么做?因为题目不要求还原现场,我们只需要统计数量,改掉原数组不会影响结果。这在面试里是可以说的:如果不允许修改原数组,再额外开visited数组,否则原地标记优先。

BFS版本用队列也行,逻辑差不多,只是把递归换成迭代,面试时可以看情况展示。这套Flood Fill模板掌握之后,很多题都能直接套:被围绕的区域、岛屿最大面积、太平洋大西洋水流问题,这些hot100里的题本质都是同一个套路。学习算法的乐趣就在这种“一道题顶十道题”的复利效应。

4.2 单词搜索:回溯+剪枝的实战套路

单词搜索(LeetCode 79)是矩阵题里少有的“回溯”题。题目要求你在矩阵中找一条路径,使得路径上的字符按顺序组成给定的单词,每个格子只能走一次(不能重复)。

解法框架是DFS + 回溯。从每个格子出发,如果当前字符匹配,就继续向四个方向搜索;如果某个方向走不通,就回退,并且把访问标记还原。核心代码如下:

public boolean exist(char[][] board, String word) { int m = board.length, n = board[0].length; boolean[][] visited = new boolean[m][n]; for (int i = 0; i < m; i++) { for (int j = 0; j < n; j++) { if (board[i][j] == word.charAt(0) && dfs(board, word, i, j, 0, visited)) { return true; } } } return false; } private boolean dfs(char[][] board, String word, int i, int j, int index, boolean[][] visited) { if (index == word.length()) { return true; } if (i < 0 || i >= board.length || j < 0 || j >= board[0].length) { return false; } if (visited[i][j] || board[i][j] != word.charAt(index)) { return false; } visited[i][j] = true; boolean found = dfs(board, word, i + 1, j, index + 1, visited) || dfs(board, word, i - 1, j, index + 1, visited) || dfs(board, word, i, j + 1, index + 1, visited) || dfs(board, word, i, j - 1, index + 1, visited); visited[i][j] = false; return found; }

这道题的坑点在于:回溯之后必须把访问标记还原。如果你忘了visited[i][j] = false,那么条路径一旦走死,其他路径就再也不能使用这个格子了,结果会漏掉正确答案。我第一次刷这道题就是漏了这行,调试了半天才发现是“路径复用”问题。

剪枝是这道题的另一个重点。最简单有效的剪枝是:在进入DFS前,先判断起点字符是否等于word.charAt(0),不等就直接跳过。更进一步,你还可以在DFS内部提前判断当前字符与目标字符是否匹配。还有一个相对冷门但面试很加分的优化:先统计矩阵里各字符的数量,如果单词中某个字符的数量比矩阵里的还多,直接返回false。这个做法叫字母频率预检,对长单词场景效果很好。

回溯类题目的时间复杂度一般比较高,单词搜索最坏是O(mn * 4^L),其中L是单词长度。这个复杂度面试官知道,他们更多想看你写DFS的细节是否规范。

4.3 最大正方形、最小路径和:二维DP的两个经典套路

矩阵题里DP是一大门类。hot100里最值得做的两道基础题是最大正方形(LeetCode 221)和最小路径和(LeetCode 64),一个练“短板思维”,一个练“转移方程”。

先看最大正方形。题目给一个char[][]矩阵,找只包含'1'的最大正方形的面积。朴素做法是暴力枚举:枚举每个格子作为左上角,然后不断扩大边长去检查,时间复杂度O(mn * min(m, n)),面试基本不给过。DP解法的关键是重新定义状态:

  • dp[i][j]表示以位置(i, j)作为右下角的正方形的最大边长。

转移方程是:

if (matrix[i - 1][j - 1] == '1') { dp[i][j] = Math.min(Math.min(dp[i - 1][j], dp[i][j - 1]), dp[i - 1][j - 1]) + 1; }

为什么要取三个方向的最小值再加1?因为正方形要想以(i, j)为右下角扩展开,它的上方、左方、左上方三个相邻位置都必须是足够大的正方形,短板决定最终边长。我习惯把这类题总结成一句话:正方形DP就看三方向最小值,路径DP就看两个来源取最优。

再来看最小路径和。题目给出一个grid,从左上角走到右下角,每次只能向右或向下,求路径上数字之和的最小值。转移方程非常直白:

dp[i][j] = grid[i][j] + Math.min(dp[i - 1][j], dp[i][j - 1]);

因为走到(i, j)只能从上方或左方过来,取其中较小的那个来源。边界条件也好处理:第一行只能一直往右累加,第一列只能一直往下累加。

这两道题还有一个共同点:都可以用滚动数组把空间从O(mn)降到O(n)。最大正方形只要保留上一行的dp值再加一个左上角的临时变量,最小路径和更是只需要一维数组反复覆盖。面试的时候,你把“原地DP”或者“滚动数组”写出来,面试官眼睛会亮一下。不过第一次刷的时候,我建议先老老实实写二维DP,确保思路正确,再优化空间,不要一上来就挑战滚动数组,那样debug会耗掉太多时间。

5. 刷题避坑指南:这五个坑我每个都踩过

5.1 边界与索引的经典翻车点

刷矩阵题这段时间,我把踩过的坑整理成了一个自查表,每次提交之前先扫一遍:

坑点现象解法
for循环边界写错数组越界或漏遍历统一约定左闭右开,要么左闭右闭,别混用
螺旋矩阵单行单列重复遍历结果多出重复元素底行和左列遍历前加top < bottom、left < right判断
二分映射二维数组除错列数越界或定位错误行优先存储时,mid / n是行,mid % n是列
DFS标记未还原搜索类结果错误回溯代码块结束时,必须visited[i][j] = false
矩阵置零覆盖标记行结果全部错乱先用临时变量记录首行首列的原始状态

这个表里每一个坑都是我真实debug过的。我最惨的一次是在mid / n那边写成了mid / m,调试半小时没发现,最后打印矩阵每一轮的mid值才恍然大悟。矩阵题就是这样,错误往往不在逻辑框架而在细节索引,而这种错误最难定位。

5.2 面试过程中的细节决策

刷题是一回事,面试表现是另一回事。矩阵题在面试中出现频率很高,但面试官其实不只看你最终代码对不对,更看你的解题思路和沟通方式。

我自己的经验是,拿到矩阵题先做三件事:第一,确认矩阵的行列数取值范围,以及是int[][]还是char[][];第二,确认能不能修改原数组,这决定了你能否用原地标记;第三,和面试官确认空间复杂度的要求,这决定了你是用visited数组还是原地染色。

写代码的时候,先把框架写清楚,再填边界条件。比如DFS,先写越界判断,再写访问标记,再写递归调用,最后写回溯还原。顺序对了,代码自然清爽。还有一个小细节:循环变量命名用row、col比用i、j更清晰,尤其在复杂的矩阵题里,可读性会好很多。面试现场代码风格好,是真的有额外加分的。

避坑的终极心法其实就一条:提交之前,手动用一个小矩阵把流程走一遍,尤其关注单行、单列、全0、空矩阵这些极端输入。矩阵题的边界case往往就藏在这些极端输入里,一次手动模拟能省下三次错误提交。

6. 我对矩阵题的个人体会

刷完hot100里这组矩阵题,我最大的感受是:矩阵题本质上考的还是“用变量描述状态”和“边界意识”。螺旋矩阵用四个边界变量描述剩余空间,旋转图像用“转置+翻转”两步描述旋转过程,搜索二维矩阵II用“右上角走位”描述排除策略,矩阵置零用“首行首列标记”描述空间复用。当你能把每一步的状态变量讲清楚,代码正确率会大幅提升。

最后分享一个小技巧:我给自己整理了一套矩阵题通用模板,包含四方向数组、边界判断、二维映射三个子工具。每次刷新的矩阵题,我都是先看能不能套上这套工具,再思考题目独特的约束。这套习惯帮我省下了大量调试时间。如果你也正在刷hot100,我建议你亲手把这套模板写一遍,而不是直接抄我的——只有自己踩过边界条件的坑,才能在面试的时候稳稳避开。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询