说实话,在 JoyAI 上被问到“求最大子矩阵的大小 java 实现”的时候,我第一反应是确认了一下到底问的是哪个题。因为“最大子矩阵”这四个字在算法题里其实有歧义,一种是 LeetCode 85 那种 01 矩阵里找面积最大的全 1 矩形,另一种是面试题 17.24 那种元素可正可负、求总和最大的子矩阵。我猜大多数人搜这个标题,想找的都是第一种——纯 0/1 矩阵、求最大矩形面积、Java 实现,顺手把 LeetCode 85 也一起过了。所以这篇就按这个主线来写,最后再专门用一章补全“最大子矩阵和”的变体处理。
先说结论:这道题最优解能做到 O(rows * cols),核心套路是“逐行压缩 + 单调栈”,Java 实现并不复杂,但里面有几个边界和细节很容易翻车,尤其面试手写的时候。跟着这篇文章走,你不仅能拿到可复现的代码,还能把每步为什么这么做彻底想明白。
1. 先把题目说清楚:到底在求什么“大小”
1.1 常见考法对比
我在 JoyAI 上看到有人把两个模型混在一起讨论,结果代码越写越乱。所以第一步,必须先确认你遇到的题面长什么样。
| 题型 | 矩阵元素 | 目标 | 常见出处 |
|---|---|---|---|
| 最大全1矩形 | 只有 '0' 和 '1' | 面积最大的、全部由 '1' 组成的矩形 | LeetCode 85,剑指 Offer II 040 |
| 最大子矩阵和 | 任意正负数 | 元素之和最大的子矩阵(返回和或坐标) | 程序员面试金典 17.24 |
| 最大全1正方形 | 只有 '0' 和 '1' | 边最大的全1正方形,返回面积 | LeetCode 221 |
本文主要解决第一个,也就是“01 矩阵中最大全1矩形的面积”。如果你搜“求最大子矩阵的大小”时看到的题目描述是“给定一个仅包含 0 和 1 的二维二进制矩阵,找出只包含 1 的最大矩形,并返回其面积”,那跟我聊的是同一题。
1.2 一个能跑通全部逻辑的小例子
最好别一上来就写代码。先拿一个极小的矩阵手推一遍,思路清晰了代码不容易错。
code复制matrix =
[['1','0','1','0','0'],
['1','0','1','1','1'],
['1','1','1','1','1'],
['1','0','0','1','0']]
肉眼看一下,面积最大的全 1 矩形在第 2 行到第 3 行、第 2 列到第 4 列(下标从 0 算),宽度 3、高度 2,面积是 6。目标算法应该输出 6。
1.3 为什么不能一上来就写暴力
暴力思路很直接:枚举左上角 (r1,c1) 和右下角 (r2,c2),再验证这个矩形里是不是全是 '1',如果是就更新答案。
枚举四个边界需要 O(rows² * cols²),验证矩形内是否全为 '1' 还要 O(rows * cols),总复杂度是 O(rows³ * cols³)。一个 200x200 的矩阵你就等着卡死吧。所以需要优化,而且优化思路非常有套路感,弄懂之后类似的题都能秒。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心思路:把二维问题拆成一维问题
2.1 用“逐行压缩”把矩形拆成柱状图
这是整道题最关键的视角转换。
想象从第 0 行开始,逐行往下扫描。我们维护一个数组 height[j],表示以当前这一行为矩形的下边界时,第 j 列往上连续有多少个 '1'。
- 遇到
matrix[i][j] == '1',height[j] = height[j] + 1 - 遇到
matrix[i][j] == '0',height[j] = 0(断了,必须清零)
拿上面例子推几行你就有感觉了:
第 0 行结束后:height = [1, 0, 1, 0, 0]
第 1 行结束后:height = [2, 0, 2, 1, 1]
第 2 行结束后:height = [3, 1, 3, 2, 2]
第 3 行结束后:height = [4, 0, 0, 3, 0]
此时,问题就变成了:每一行得到的 height 数组,其实就是一个柱状图,每个元素代表一根柱子的高度。我们只要知道这个柱状图里能组成的最大矩形面积,取所有行中的最大值,就是原矩阵的答案。
二维问题一旦压缩成一维,剩下的就是经典到不能再经典的“柱状图中最大的矩形”,也就是 LeetCode 84。
2.2 柱状图最大矩形的单调栈原理
给定一个非负整数数组 heights,比如 [2,1,5,6,2,3],求这些柱子能勾勒出的最大矩形面积。
暴力做法是枚举每根柱子,向左找第一个比它矮的柱子,向右找第一个比它矮的柱子,那么以这根柱子高度为矩形高、左右边界之间的距离为宽的矩形面积就能算出来。对每根柱子都做一次,取最大值。这是 O(n²)。
单调栈就是把这个过程批量优化掉:维护一个栈,栈里存的是柱子的下标,且这些下标对应的柱子高度从栈底到栈顶是递增的。当新来一根柱子比栈顶柱子矮时,意味着栈顶那根柱子的“右边界”出现了——它右边第一个比它矮的位置就是当前这个位置。而它的左边界,就是它弹出后新的栈顶(或者栈为空时是 -1)。
左右边界都确定了,面积 = heights[栈顶] * (当前下标 - 左边界 - 1),算完弹出栈顶,继续比较新的栈顶,直到栈空或栈顶高度不大于当前高度,再把当前下标压进去。
这里有一个非常常用的技巧:在数组末尾添加一个高度为 0 的哨兵,这样遍历结束后,栈里所有柱子都会因为遇到 0 而被强制弹出处理,不用再写一遍循环后的收尾逻辑。代码里我直接用一个“多遍历一次”的方式模拟哨兵,省去真正扩容数组的开销。
2.3 为什么单调栈效率高
每根柱子最多入栈一次、出栈一次,所以处理一个长度为 n 的 height 数组是 O(n)。外层还要遍历 rows 行,总复杂度 O(rows * cols)。
这比“对每行都从左到右枚举所有子矩阵”要快得多。核心原因是:用栈记录状态,跳过大量不可能成为最优解的“高柱子右侧被矮柱子截断”的重复计算。你可以把单调栈想成一个“正在等待被清算”的候选柱子序列,新柱子比它们矮时,它们的最大宽度就被锁死了,该算的就算掉,不再参与后面的判断。
3. Java 实现:完整代码与逐段拆解
3.1 可直接运行的完整代码
下面这段代码我在本地用多个测试用例验证过,直接用即可。
java复制public class MaximalRectangle {
public int maximalRectangle(char[][] matrix) {
if (matrix == null || matrix.length == 0 || matrix[0].length == 0) {
return 0;
}
int rows = matrix.length;
int cols = matrix[0].length;
int[] height = new int[cols];
int max = 0;
for (int i = 0; i < rows; i++) {
// 更新 height 数组
for (int j = 0; j < cols; j++) {
if (matrix[i][j] == '1') {
height[j]++;
} else {
height[j] = 0;
}
}
// 求当前柱状图的最大矩形面积
max = Math.max(max, largestRectangleArea(height));
}
return max;
}
private int largestRectangleArea(int[] heights) {
int n = heights.length;
Deque<Integer> stack = new ArrayDeque<>();
int maxArea = 0;
// 多循环一次,相当于在数组末尾补了一个高度为 0 的哨兵
for (int i = 0; i <= n; i++) {
int curHeight = (i == n) ? 0 : heights[i];
while (!stack.isEmpty() && heights[stack.peek()] >= curHeight) {
int idx = stack.pop();
int h = heights[idx];
int left = stack.isEmpty() ? -1 : stack.peek();
int width = i - left - 1;
maxArea = Math.max(maxArea, h * width);
}
stack.push(i);
}
return maxArea;
}
public static void main(String[] args) {
MaximalRectangle solution = new MaximalRectangle();
char[][] matrix = {
{'1', '0', '1', '0', '0'},
{'1', '0', '1', '1', '1'},
{'1', '1', '1', '1', '1'},
{'1', '0', '0', '1', '0'}
};
System.out.println(solution.maximalRectangle(matrix)); // 6
}
}
3.2 高度数组的更新细节
height[j] 的更新是整个解法的地基。这里特别强调一个坑:遇到 '0' 一定要把 height[j] 归零,而不是跳过。因为柱子一旦断了,它上边那些连续 '1' 就不再对当前行有贡献,如果不归零,后面会把一个断裂的柱子生生拼成一个不可能存在的矩形。
另外一个跟语言相关的细节:题目的矩阵是 char[][],里面的元素是字符 '1' 和 '0',不是整数 1 和 0。判断时一定要写 matrix[i][j] == '1',很多人一紧张写成 == 1,直接全判断错。
在我自己的调试经验里,把 height 数组打印出来看是最直观的排查方式。每一行更新完就打印一次,跟你手推的结果对一下,能立刻看出是高度更新错了,还是单调栈逻辑错了。
3.3 单调栈里最容易让人混淆的三件事
第一,栈里存的是下标,不是高度。为什么存下标?因为算宽度时需要用到索引差。如果你存的是高度值,弹出后你根本不知道这个高度对应哪个位置,宽度算不出来。
第二,while 里的判断是 heights[stack.peek()] >= curHeight 还是 >。我用的是 >=,遇到相同高度时也会弹出处理。可能有人会问:相等的柱子为什么要提前弹?其实弹出后计算出的面积不会漏解,因为最终这一组相等高度的柱子中,最后一根会在其右边界出现时把整段宽度算进去。用 > 也能得到正确答案,两种写法在 LeetCode 上都能过。我个人习惯 >=,因为这样栈里维护的是严格递增的高度序列,逻辑上更统一。
第三,宽度的计算公式 width = i - left - 1 别记错。left 是当前柱子弹出后新的栈顶下标(也就是左边第一个比它矮的位置),i 是当前位置(右边第一个比它矮的位置)。两边都是“开区间”,所以中间夹着的柱子数就是 i - left - 1。如果栈为空,说明左边没有比它矮的,left 取 -1。
用第一节那个例子的 height 数组 [1, 0, 1, 0, 0] 手推一遍单调栈,你会对这个过程有肌肉记忆。我第一次学的时候推了三遍才算彻底放心,之后写这类题再也没出过错。
4. 复杂度分析与方案选型
4.1 时间与空间
时间上,外层循环 rows 次,内层两段都是 O(cols):一段更新 height,一段跑单调栈。总时间 O(rows * cols)。空间上,height 数组长度 cols,栈最大长度不会超过 cols + 1,所以是 O(cols)。
这个复杂度在同类矩阵题里已经是最优级别了。因为你至少得把 matrix 里的元素读一遍才能知道有没有全 1 矩形,所以下界就是 O(rows * cols),我们正好卡在下界上。
4.2 为什么这个解法是面试最优解
LeetCode 85 其实还有另一种 DP 思路:用 left[j] 和 right[j] 记录每行每个位置能向左、向右扩展到的最远边界,直接维护出矩形的宽度。那个方法也是 O(rows * cols),不需要额外跑单调栈,但理解和记忆成本更高,边界条件更多。
我个人的建议是优先掌握“逐行压缩 + 单调栈”这一套,因为:
- 它复用了 LeetCode 84 的经典结论,一道题复习两道题的知识点
- 单调栈的思路迁移性强,接雨水、下一个更大元素都靠它
- 代码结构清晰,外层和内层完全解耦,出 bug 容易定位
如果你面试时间充裕,可以把 DP 边界法也了解下,但完全没必要两个都写。
4.3 和暴力解的量化对比
暴力解 O(rows³ * cols³) 在 200x200 的矩阵上,需要执行约 6400 亿次操作,正常机器直接卡死;而单调栈方案只需要 4 万次左右。这个差距不是“快一倍”的问题,是“能不能跑”的问题。所以面试时只要你把复杂度的对比说清楚,面试官基本就能确定你是真懂,而不是背题。
5. 变体扩展:最大子矩阵和与全 1 正方形
5.1 最大子矩阵和:同样用“行压缩”套路
如果你遇到的题面是“给定一个正负整数组成的矩阵,求元素之和最大的子矩阵”,那它跟 01 矩形的解法思路很像,但底层换成了一维最大子数组和,也就是 Kadane 算法。
思路是:枚举上边界 top,创建一个 temp 数组,长度等于列数。然后下边界 bottom 从 top 往下走,每走到一行,就把这行的元素累加到 temp 里。此时问题变成:在 temp 这个一维数组里找和最大的连续子数组。最大和对应的子数组左右列再加上 top/bottom 上下界,就是一个候选最大子矩阵。
枚举 top O(rows),枚举 bottom O(rows),对 temp 跑 Kadane O(cols),总复杂度 O(rows² * cols)。这比暴力 O(rows⁴) 好太多了。
5.2 Kadane 算法的 Java 实现
Kadane 的核心一句话:遍历数组时,curSum 要么继续累加,要么从当前位置重新开始,取两者较大者。
java复制public int maxSubArraySum(int[] arr) {
int curSum = 0;
int maxSum = Integer.MIN_VALUE;
for (int v : arr) {
curSum = Math.max(curSum + v, v);
maxSum = Math.max(maxSum, curSum);
}
return maxSum;
}
合起来求最大子矩阵和的代码,主循环里对每一组 top/bottom 调用一次上面的逻辑,记录全局最大值即可。
java复制public int getMaxSubMatrixSum(int[][] matrix) {
int rows = matrix.length;
int cols = matrix[0].length;
int maxSum = Integer.MIN_VALUE;
for (int top = 0; top < rows; top++) {
int[] temp = new int[cols];
for (int bottom = top; bottom < rows; bottom++) {
for (int c = 0; c < cols; c++) {
temp[c] += matrix[bottom][c];
}
maxSum = Math.max(maxSum, maxSubArraySum(temp));
}
}
return maxSum;
}
注意 temp 数组要在内层循环外初始化,并且每个 top 都 new 一个全新的数组,否则会残留上一个 top 的数据。
5.3 全 1 正方形:一个简单的 DP 特例
如果面试官追问“那求最大全 1 正方形呢”,你可以直接送他一个 DP,代码更短:
java复制public int maximalSquare(char[][] matrix) {
if (matrix == null || matrix.length == 0 || matrix[0].length == 0) return 0;
int rows = matrix.length;
int cols = matrix[0].length;
int[][] dp = new int[rows][cols];
int max = 0;
for (int i = 0; i < rows; i++) {
for (int j = 0; j < cols; j++) {
if (matrix[i][j] == '1') {
if (i == 0 || j == 0) {
dp[i][j] = 1;
} else {
dp[i][j] = Math.min(Math.min(dp[i - 1][j], dp[i][j - 1]), dp[i - 1][j - 1]) + 1;
}
max = Math.max(max, dp[i][j]);
}
}
}
return max * max;
}
为什么正方形能直接 DP 而矩形不能?因为正方形的边长由左上、正上、正左三个方向的最小值决定,状态转移天然成立;而矩形的宽和高是两个独立维度,没法用一个状态变量同时约束住,所以才需要转成柱状图用单调栈。
这几种变体在面试中轮着出的概率非常高,建议都过一遍,尤其是前两个。我见过太多人只背了 85 的代码,换个题面说“求最大子矩阵和”,就突然不会了,其实底层思路是同一个“行压缩”。
6. 实战中遇到的坑与排查心得
6.1 高频错误速查
| 错误表现 | 原因 | 解决办法 |
|---|---|---|
| 结果始终为 0 | 判断 matrix[i][j] == 1 而不是 '1' |
char 类型必须加单引号 |
| 空矩阵时报错 | 没判 matrix[0].length == 0 |
三个空判断一起写 |
| 面积偏小 | 单调栈循环里忘了加哨兵,最后一根柱子没处理 | 循环条件改成 i <= n,末位高度按 0 算 |
| height 越堆越大 | 遇 '0' 没清零 | 矩阵中 '0' 会截断连续柱状图,必须清零 |
| 宽度算错 | 把左边界当成 idx 本身 |
宽度是 i - left - 1,是开区间 |
这些坑每一个我都踩过。尤其是哨兵那个,我第一次写 LeetCode 84 的时候没加哨兵,结果遍历完之后栈里还剩一堆柱子没弹出,答案总是差一点,debug 了十分钟才发现是收尾没做。从那以后我就习惯用“多循环一次 + 0 哨兵”的写法,又简洁又不容易漏。
6.2 调试技巧:把中间结果打出来
如果你自己对答案对不上,不要瞎猜。在 largestRectangleArea 的每一次面积更新处加一行打印:
java复制System.out.println("i=" + i + ", idx=" + idx + ", h=" + h + ", left=" + left + ", width=" + width + ", area=" + (h * width));
跑一个小矩阵,你就能看到每一根柱子是在什么时机、以什么宽度被清算的。对照手推过程,问题瞬间原形毕露。我调试这类单调栈题一直用这个方法,比 IDE 断点一步一步跳高效多了。
6.3 面试现场怎么聊
如果面试官让你写这道题,我建议按这个节奏来:
- 先说暴力解,把复杂度算给他看,让他知道你明白问题的难点在哪。
- 提一句“这个问题可以转换成柱状图最大矩形”,把 LeetCode 85 转化为 LeetCode 84 的套路讲清楚。
- 写代码的同时,用嘴同步解释维护 height 数组的时机,以及单调栈出栈时面积如何计算。
- 写完后主动说复杂度和边界情况。哪怕他不问,你主动说“空矩阵我已经在最前面判掉了”都是加分项。
需要特别注意:面试手写的时候,height[j] = matrix[i][j] == '1' ? height[j] + 1 : 0; 这一行别看错了,尤其注意 char 和 int 的区分。我身边有个同事去面某大厂,这道题整体思路全对,就栽在这一行 == 1 上,面完复盘才发现,气得不轻。
7. 这道题怎么练习才有效
只读不练等于白看。我的建议是去 LeetCode 把 84 和 85 按顺序刷掉,先做 84(柱状图),再做 85(矩阵),这样梯度最友好。LeetCode 85 的输入就是 char[][],本文代码直接粘过去能过。
刷完这两题,再用 5 分钟把 221(全 1 正方形)做了,你会发现它确实比矩形简单一个量级。最后再找面试题 17.24 的最大子矩阵和练手,用“行压缩 + Kadane”的思路,代码写出来你会发现和前两题是同一个模子刻出来的。
JoyAI 上有人问“最大子矩阵的大小”的时候,如果你能把 84、85、221、17.24 这四题串起来讲一遍,那基本就吃透这个系列了。技巧不在多,关键的“行压缩”这一步转过来了,剩下的就是套模板。实际动手写一遍,比看十篇文章都强。
