多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

最大矩形与单调栈:LeetCode 85 二维矩阵降维解法全解析

最大矩形与单调栈:LeetCode 85 二维矩阵降维解法全解析 1. 题目拆解最大矩形到底在考什么1.1 题意速览与输入输出先花三十秒把题看清楚。LeetCode 85题最大矩形给的是一张二维的二进制矩阵里面每个格子要么是0要么是1要求在这个矩阵里找出一个“只包含1”的矩形区域让它的面积最大最后返回这个最大面积。举个例子矩阵长这样1 0 1 0 0 1 0 1 1 1 1 1 1 1 1 1 0 0 1 0肉眼扫一下最大的全1矩形是第二行到第三行、第三列到第五列围出来的那块宽度3、高度2面积等于6。如果用代码去算标准答案也是6。注意几个细节矩阵的宽和高都可能为0格子里的1是字符不是数字返回的是面积值不是坐标。这些都是提交时容易翻车的点后面我专门列一节讲。1.2 为什么这题值得花时间如果你刷过LeetCode热榜100题大概率见过这道题。它经常和84题柱状图中最大的矩形绑在一起出现85题本质上就是84题套上了一层二维的外衣。面试里出现频率不低尤其是大厂笔试和算法轮因为它考察的不是你背没背过模板而是能不能把一个陌生问题拆成熟悉问题。另外这题的解法思路很有迁移价值。单调栈、动态规划、逐行累积高度这三个技术点每一个都能单独出题85题把它们串在了一个完整场景里。刷透这一题等于是把三个知识点连着复习了一遍性价比非常高。我自己的感受是这题第一眼看上去特别唬人因为“二维矩阵里找最大矩形”这个描述太开放了新手很容易直接想到暴力枚举左上角和右下角然后被 O(n² × m²) 的复杂度劝退。但如果你已经刷过84题就会敏锐地发现每一行往上数连续1的个数其实就是一个柱状图的高度数组。于是问题就变成了“逐行调用84题的解法”。2. 核心思路把二维问题压成一维2.1 高度数组的累积逻辑先理解一个关键动作从上到下扫描矩阵的每一行同时维护一个一维数组 heightsheights[j] 表示“以当前行为底边、第 j 列往上连续有多少个1”。怎么维护逐行更新就行如果当前行的第 j 列是 1那 heights[j] 就在上一行的基础上加1如果当前行的第 j 列是 0那 heights[j] 直接归零因为一旦出现0往上再多的1也被拦腰截断这个位置不能再向上延伸了。举上面的例子前三行算下来heights 数组的变化是这样第一行结束[1, 0, 1, 0, 0]第二行结束[2, 0, 2, 1, 1]第三行结束[3, 1, 3, 2, 2]到第三行的时候heights 数组就是[3,1,3,2,2]。这意味着什么意味着在这一个高度数组上跑一遍“柱状图中最大矩形”的算法求出来的最大矩形面积就对应着原矩阵里所有“底边落在第三行、向上连续全1”的矩形里的最大值。而原矩阵的最大矩形底边一定落在某一行上所以只要把每一行都当底边算一遍取全局最大答案就出来了。这个转换是整个题目的灵魂。把一个看不清全局的二维问题拆成一组一维柱状图每一步只关心“当前底边之上有多高”信息一点没丢计算却简单了一个维度。2.2 为什么能跳行计算——84题的联动LeetCode 84题给的是一个一维数组每个元素代表一根柱子的高度要求这堆柱子里能框出的最大矩形面积。经典解法是用单调栈在 O(n) 时间内搞定。85题的做法就是把矩阵的每一行当作84题的输入逐行调用。所以如果你84题已经吃透了85题只是多了一个“逐行构建高度数组”的预处理步骤。有人会问为什么不直接对每个格子向左、向右扩展边界那样不也能算矩形吗能做而且这是另一种解法叫“枚举左右边界高度数组”复杂度是 O(m×n²)。但既然我们有 O(m×n) 的单调栈方案就没必要退回去写平方级算法。还有人会问能不能用动态规划直接记录“以某个格子为右下角的最大矩形”理论上可以但状态定义比想象中麻烦。因为矩形面积由高度和宽度共同决定单纯记“到当前格子左边连续1的个数”和“上方连续1的个数”还不够你还要枚举宽度最终复杂度还是平方级。所以单调栈方案在这题里是综合最优的主流解法。2.3 暴力回溯法作为参照系在写单调栈之前可以先在心里过一遍暴力法方便对比复杂度。最朴素的做法是枚举矩形的左上角和右下角然后检查中间区域是否全为1这需要 O(m² × n² × 检查代价)妥妥的不可接受。好一点的暴力是枚举每一行作为矩形的顶边然后向下扩展底边同时维护每一列在这段行区间里是否全是1再去求连续1区间的最大长度作为宽度。这样是 O(m²×n)能过小数据但大数据会超时。我建议初学者把这种暴力思路作为参照系先想清楚“枚举顶边和底边再扩展宽度”逻辑上为什么正确再去看单调栈怎么把其中重复计算的部分优化掉。这样你对算法的理解会深一层而不是单纯背模板。3. 单调栈解法完整实现与逐行注释3.1 单调栈的工作原理先说核心数据结构——单调栈。它维护的是一个索引栈栈里的索引对应的柱高保持严格递增或者非递减看你处理边界的方式。它的作用是当新柱子高度小于栈顶柱高时说明栈顶那根柱子的右边界已经确定了这时候可以弹出它并计算以它为高度的最大矩形面积。这里有一个新手最容易绕晕的点弹出一个柱子时它的左右边界到底是什么右边界就是当前扫描到的位置 i因为这是第一个比它矮的柱子矩形不可能再往右延伸。左边界弹出之后新的栈顶元素对应的位置因为栈内索引递增栈顶元素是左边第一个比它矮的柱子矩形不可能再往左延伸。所以宽度就是当前索引 - 左边界索引 - 1。如果栈空了说明左边没有比它矮的柱子宽度就是当前索引本身。这个逻辑听起来绕画个图马上清楚。比如柱高数组[2,1,5,6,2,3]扫到索引4的柱子高度2时栈里存着[1,3]索引1高度1索引3高度6索引4高度2比栈顶索引3高度6矮于是弹出高度6此时左边界是新的栈顶索引1右边界是4宽度就是4 - 1 - 1 2面积6×212。对这就是84题里那个最大面积12的由来。3.2 Python 实现与逐行说明把84题的单调栈函数单独抽出来85题的主函数逐行构建高度数组并调用它。完整代码如下class Solution: def maximalRectangle(self, matrix: List[List[str]]) - int: if not matrix or not matrix[0]: return 0 rows, cols len(matrix), len(matrix[0]) heights [0] * cols max_area 0 for i in range(rows): # 1. 更新当前行的高度数组 for j in range(cols): if matrix[i][j] 1: heights[j] 1 else: heights[j] 0 # 2. 计算当前高度数组的最大矩形面积 max_area max(max_area, self.largest_rectangle_area(heights)) return max_area def largest_rectangle_area(self, heights: List[int]) - int: # 哨兵在末尾补一个0保证所有柱子都能被弹出 heights.append(0) stack [] area 0 for i, h in enumerate(heights): while stack and heights[stack[-1]] h: height heights[stack.pop()] width i if not stack else i - stack[-1] - 1 area max(area, height * width) stack.append(i) # 还原数组避免影响下一轮 heights.pop() return area讲几个容易被忽略的细节。第一主函数里更新高度数组用的是原地更新heights[j] 1而不是重新赋值。因为 heights 数组跨行复用上一行的信息要保留下来累加。第二判断字符是 1 用的是引号括起来的字符不是数字1。LeetCode 的矩阵输入元素是字符串很多人在这里踩坑写成了 1结果全矩阵都是0答案永远是0。第三largest_rectangle_area函数最后补了一个0作为哨兵这是84题的标准技巧。有了它弹出的逻辑不用在循环外再单独处理一遍栈里剩余的柱子代码更简洁也不容易漏算。第四heights.pop()这一行很多人不写导致下一轮调用时数组末尾多了一个0后面的计算全部出错。单独调试时看起来没问题丢到循环里就暴露问题了。3.3 边界初始化与哨兵技巧哨兵sentinel值是单调栈题里的常用手法。84题里末尾补0是因为0一定是最矮的柱子它会把栈里所有更高的柱子全部逼出来这样循环结束后栈自动清空不需要再写一段收尾代码。而主函数里为什么要heights [0] * cols而不是[]因为高度数组的长度必须等于列数每个位置独立累积如果初始为空列表后面的heights[j] 1就会报索引越界。还有一种写法是把哨兵放在循环内部比如用stack [-1]技巧让宽度计算统一成i - stack[-1] - 1。这个写法更炫但对新手不够直观我这边给出的是可读性优先的版本面试讲思路时也更容易说清楚。另外如果你想追求极致性能可以不用单独写84题的辅助函数而是把单调栈逻辑直接嵌进主函数的行循环里省去反复 append/pop 数组末尾的开销。不过刷题阶段没必要这么做代码清晰更重要。4. 复杂度分析与常见误区4.1 时间与空间复杂度推导时间上外层循环遍历矩阵的每一行是 O(m)。内层分成两部分更新高度数组是 O(n)调用单调栈函数也是 O(n)。关键是单调栈虽然内部有 while 循环但每个索引最多入栈一次、出栈一次所以均摊下来是 O(n)。整体复杂度就是 O(m×n)也就是 O(矩阵元素总数)。如果你写的是枚举左右边界高度数组的版本复杂度是 O(m×n²)虽然也能跑对样例但到数据量大时会超时。LeetCode 上矩阵最大能到200×200m×n² 在最坏情况下是 200×40000 800万次勉强能过如果题面加强到1000×1000800万就变成10亿次直接崩。所以单调栈版本是必要的。空间上heights 数组长度 n单调栈长度最多也是 n所以空间复杂度 O(n)。如果你把单调栈逻辑嵌进主函数连辅助函数的调用栈都省了但空间复杂度不变。4.2 五个高频踩坑点我整理了一下实际操作中遇到的、以及帮别人看代码时常见的坑按出现频率排个序。第一字符与数字混淆。matrix[i][j] 是字符串 1 不是整数1判断条件写错的话整个算法输出恒为0而且编辑器不会报错肉眼调半天发现不了。建议在写代码前先打印一行matrix[0]确认类型。第二heights 数组没复位。有些人喜欢在循环里新建heights [0] * cols而不是复用数组这没问题反而能避开末尾多0的隐患。但如果你在辅助函数里往 heights 末尾 append(0)就一定要记得 pop 还原。这个 bug 特别隐蔽因为单独测辅助函数是对的只有跑完整用例才出错。第三单调栈里用值比较还是索引比较。我自己刚开始写的时候容易写成while stack and heights[i] heights[stack[-1]]之后在循环体里想用 i 又发现 i 被修改了搞得很乱。统一写法栈里存索引比较时通过索引取高度循环变量 i 始终表示当前扫描位置不要动不动修改它。第四宽度计算漏减1。弹出柱子时宽度是右边界 - 左边界 - 1这个 -1 是左右两个端点都不算在内。我第一次写84题时漏了这个1导致所有面积都偏大还以为是矩形定义的问题。第五只更新 heights 但没重新算最大面积。有些新手会在所有行遍历完之后只算一次最大面积忽略了面积取的是每一行底边对应的最大值而不是最后一行的高度数组。你必须在每扫完一行之后立即更新 max_area。4.3 Debug 技巧与测试用例刷这题时我强烈建议准备一个本地调试脚本打印每一轮的高度数组和当前最大面积方便定位问题。比如上面那个矩阵正确的中间输出应该是第1行后 heights [1, 0, 1, 0, 0]当前最大面积 1 第2行后 heights [2, 0, 2, 1, 1]当前最大面积 2 第3行后 heights [3, 1, 3, 2, 2]当前最大面积 6 第4行后 heights [4, 0, 0, 3, 0]当前最大面积 6如果你某一行输出对不上就直接缩小范围用2×2或3×3的小矩阵手动模拟一遍。注意 LeetCode 的输入矩阵每行是字符串比如[10100,10111,11111,10010]在本地调试时可以直接用 Python 的 list 表示。另一个好用的边界测试用例matrix [[0]]期望输出0验证空矩阵以外的零矩阵情况matrix [[1]]期望输出1验证单元素matrix [[1,0,1,0,0]]只有一行时期望输出1因为连续的1区间最多只有一个matrix [[1,1,1,1]]期望输出4验证一行内连续1全取的情况。我建议提交前把这几组用例都跑一遍尤其是全0矩阵很多人因为循环里 heights 更新逻辑写错在这里栽跟头。5. 相似题目与举一反三5.1 每日一题周边84、221 与热榜100题LeetCode 85题不是孤立的它和好几道热门题构成一条知识链。先说84题柱状图中最大的矩形那是85题的一维底座。务必先把84题独立刷懂能把单调栈的思路从头到尾讲清楚再回来做85题。然后是221题最大正方形它和85题共享二维矩阵里找全1区域的设定。区别在于221题限定了形状必须是正方形所以可以用动态规划状态转移方程dp[i][j] min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]) 1简洁漂亮。而85题不限形状矩形的高宽可以自由伸缩反而不能直接用同样的DP只能回到单调栈。这两个题目并排刷能帮你理解“限制条件如何影响算法选型”。除此之外热榜100题里还有不少单调栈的变体比如接雨水42题、每日温度739题、下一个更大元素496题。这些题的核心都是“找某个元素左边或右边第一个比它大/小的位置”和85题弹栈时确定左右边界是同一个套路。我建议把这几题放在一块集中刷三天之内突破单调栈这个技术点。5.2 面试中的延伸考法面试官在85题基础上最常见的延伸是不要求返回面积而是要求返回矩形左上角和右下角的坐标或者要求把最大矩形标记出来。这种时候你需要在计算面积的同时记录最优矩形的位置信息。具体做法也不难在单调栈弹栈计算面积时除了算面积还要记录对应的高度值和左右边界然后比较面积大小更新最佳矩形坐标。左边界是栈空时的0或者弹出后新栈顶的下一个位置右边界是当前扫描位置减1。最终得到最优高度 h、左边界 l、右边界 r再结合当前底边所在行数就能还原整个矩形区域。另一种问法是要求输出最大矩形区域内的所有坐标那更简单拿到矩形的高和宽之后从底边向上扩展即可。还有一种考法是把二维矩阵换成字符矩阵比如char[][]逻辑一样但字符比较时注意类型。更有意思的是有些面试官会给你一个标注了权重的矩阵每个1带一个数值要求最大化权重之和而不是面积这个就变成贪心或DP问题了已经超出了85题的范围但它的出发点仍然是从这个题目展开的。5.3 周赛与每日一题刷题思路最近 LeetCode 每日一题也经常出一些硬核题目。如果你平时在追每日一题会发现单调栈类题型的出现频率相当高。周赛430里虽然没有直接考85题但有一道题也需要用“维护一个前缀数组配合单调栈”的思路。这种“热门题变体”的联动正是刷题的正确姿势把一道题的解法内化成工具遇到新题时先想想能不能套工具再想工具怎么改。就我个人经验来说每日一题的价值不在于每天多刷一道题而在于题型之间的联系。你今天刷的85题可能正是下周某道周赛题的思路跳板。所以每次刷完一道题花十分钟想一想“这题跟哪个经典题是同家族”收获远大于多刷十道新题。6. 我的实操体会与建议前面写了不少干货最后聊一点个人经验。我第一次做85题的时候其实84题已经刷过了但还是卡了很久。原因很搞笑我一直在纠结“怎么在二维矩阵上直接跑单调栈”想找到一个抽象的二维单调结构结果越钻越深完全忘了“逐行转化为一维柱状图”这个最基本的思路。后来在草稿纸上画了几行高度数组才意识到自己想复杂了。从那以后我就养成了一个习惯遇到二维问题先试着把它压缩成一维问题再思考。这个“降维思考”的思路后来在处理很多矩阵类题目时都帮了大忙。实操中还有一个小技巧值得分享如果时间紧张不用每次都把85题的完整代码默写出来但一定要能在纸上画出“某一行高度数组 单调栈弹栈过程”的两步演示图。面试时把这个图画出来把“每一行底边、向上连续1的数量、左右边界”这几个概念串起来基本就能证明你是真的理解了而不是背了答案。另外LeetCode 的代码编辑器支持直接运行测试用例建议把评论区里别人贴的稀奇古怪的矩阵也复制过来跑一下。矩阵类问题的坑往往藏在形状不对称、边界行、全0列这些刁钻用例里多跑几个变态用例比反复提交十次都更能增加信心。最后如果矩阵特别大比如 1000×1000 的规模单调栈版本在 Python 里也可能接近时间上限。这时候可以考虑两种优化一是把 heights 的更新和单调栈计算合并到同一个循环里减少函数调用开销二是将 matrix 的每一行转成字节数组再用 numpy 操作更新列累加但这种写法放在面试里反而显得不自然实战刷题一般没必要用。这题刷透之后建议马上去刷221题最大正方形再回来把84题的题解重新看一遍。三题联动吃下来单调栈和二维矩阵问题这个分支基本就算打通了。后续遇到任何“找最大 XXX 形状区域”的题你的第一反应都会是能不能先降维能不能用单调栈。这种条件反射就是刷题最实在的收获。
返回列表