当前位置: 首页 > news >正文

力扣【1277. 统计全为1的正方形子矩阵】——从暴力到最优的思考过程

前端算法实战:用JS解决力扣【1277. 统计全为1的正方形子矩阵】——从暴力到最优的思考过程

引言

各位前端同仁,算法面试中矩阵问题一直是高频考点,而「统计全为1的正方形子矩阵」这道题更是其中的经典。它不仅考察你对二维数组的遍历能力,更深层次地,它引导我们思考如何通过动态规划来优化解题效率。今天,我将以一个前端开发者的视角,带大家一步步拆解这道题,从最直观的暴力解法,到如何巧妙运用动态规划将其优化到极致,希望能帮助大家在算法面试中游刃有余,也能在日常开发中培养更严谨的逻辑思维。

题目分析

原题链接

力扣 1277. 统计全为1的正方形子矩阵

在这里插入图片描述

题目大意

给你一个 m * n 的矩阵,矩阵中的元素不是 0 就是 1。你需要统计并返回其中完全由 1 组成的正方形子矩阵的个数。

输入输出

  • 输入: 一个 m * n 的二维数组 matrix,其中 matrix[i][j] 的值为 0 或 1。
  • 输出: 一个整数,表示完全由 1 组成的正方形子矩阵的总数。

约束条件

  • 1 <= matrix.length <= 300 (矩阵的行数)
  • 1 <= matrix[0].length <= 300 (矩阵的列数)
  • 0 <= matrix[i][j] <= 1 (矩阵中的元素只包含 0 或 1)

示例演示

示例 1:

输入:

matrix = [[0,1,1,1],[1,1,1,1],[0,1,1,1]
]

输出: 15

解释:

  • 边长为 1 的正方形有 10 个。
  • 边长为 2 的正方形有 4 个。
  • 边长为 3 的正方形有 1 个。
  • 正方形的总数 = 10 + 4 + 1 = 15.

示例 2:

输入:

matrix = [[1,0,1],[1,1,0],[1,1,0]
]

输出: 7

解释:

  • 边长为 1 的正方形有 6 个。
  • 边长为 2 的正方形有 1 个。
  • 正方形的总数 = 6 + 1 = 7.

思路推导

笨方法尝试:暴力枚举

对于这类统计子矩阵的问题,最直观的思路就是暴力枚举所有可能的正方形子矩阵,然后判断它们是否完全由 1 组成。具体来说,我们可以:

  1. 确定正方形的左上角: 遍历矩阵中的每一个点 (r, c),将其作为正方形的左上角。
  2. 确定正方形的边长: 对于每个左上角 (r, c),尝试不同的边长 s。边长 s 的最大值受限于当前位置到矩阵右边界和下边界的距离。
  3. 检查子矩阵: 对于确定的左上角 (r, c) 和边长 s,检查以 (r, c) 为左上角、边长为 s 的子矩阵中的所有元素是否都为 1。

复杂度分析:

  • 时间复杂度: 假设矩阵是 m * n。确定左上角需要 O(m * n)。确定边长需要 O(min(m, n))。检查子矩阵需要 O(s * s),即 O(min(m, n)^2)。因此,总的时间复杂度约为 O(m * n * min(m, n)^3)。在最坏情况下,如果 mn 都接近 300,这个复杂度会非常高,达到 300^5 级别,显然会超时。
  • 空间复杂度: O(1),因为我们只使用了常数级别的额外空间。

为什么笨方法不行?

当矩阵规模较大时(例如 m, n 达到 300),O(m * n * min(m, n)^3) 的时间复杂度会导致计算量呈指数级增长,远远超出 LeetCode 的时间限制(通常是 1-2 秒内完成 10^8 次操作)。核心问题在于存在大量的重复计算,每次检查子矩阵时,都会重复检查之前已经检查过的元素。

优化方向:动态规划

为了避免重复计算,我们可以考虑使用动态规划。动态规划的核心思想是「用历史计算结果来推导当前结果」。对于这道题,我们可以思考:(i, j) 为右下角的正方形的最大边长是多少?

假设我们已经知道以 (i-1, j)(i, j-1)(i-1, j-1) 为右下角的最大正方形边长。如果 matrix[i][j] 为 0,那么以 (i, j) 为右下角的正方形边长一定是 0。如果 matrix[i][j] 为 1,那么以 (i, j) 为右下角的最大正方形边长,取决于其左边、上边和左上角的最大正方形边长。

具体来说,如果 matrix[i][j] 是 1,那么以 (i, j) 为右下角,边长为 k 的正方形,需要满足:

  1. matrix[i][j] 是 1。
  2. (i-1, j) 为右下角存在一个边长为 k-1 的正方形。
  3. (i, j-1) 为右下角存在一个边长为 k-1 的正方形。
  4. (i-1, j-1) 为右下角存在一个边长为 k-1 的正方形。

这三者都必须满足,才能在 (i, j) 处扩展出一个边长为 k 的正方形。因此,以 (i, j) 为右下角的最大正方形边长,就是 min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]) + 1

状态定义:

我们定义 dp[i][j] 表示以 matrix[i-1][j-1](为了方便处理边界,dp 数组的索引会比 matrix 数组大 1)为右下角,且只包含 1 的正方形的最大边长。

状态转移方程:

  • 如果 matrix[i-1][j-1] == 0,那么 dp[i][j] = 0
  • 如果 matrix[i-1][j-1] == 1,那么 dp[i][j] = 1 + min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1])

如何统计总数?

dp[i][j] 的值代表以 (i-1, j-1) 为右下角的最大正方形的边长。如果 dp[i][j] = k,这意味着以 (i-1, j-1) 为右下角,存在一个边长为 k 的正方形。同时,这个边长为 k 的正方形也包含了边长为 1, 2, ..., k-1 的所有正方形。因此,每计算出一个 dp[i][j] 的值,就将其累加到总数中。例如,如果 dp[i][j] 为 3,则表示以 (i-1, j-1) 为右下角,存在一个边长为 3 的正方形,同时也存在一个边长为 2 的正方形和一个边长为 1 的正方形,总共贡献了 3 个正方形。

小例子辅助推演:

以示例 1 为例:

matrix = [ [0,1,1,1], [1,1,1,1], [0,1,1,1] ]

初始化 dp 数组(比 matrix 大一圈,填充 0):

dp = [ [0,0,0,0,0], [0,0,0,0,0], [0,0,0,0,0], [0,0,0,0,0] ]

遍历 matrix,填充 dp 数组并累加 count

  • matrix[0][0] = 0 -> dp[1][1] = 0
  • matrix[0][1] = 1 -> dp[1][2] = 1 + min(dp[0][2], dp[1][1], dp[0][1]) = 1 + min(0,0,0) = 1count += 1
  • matrix[0][2] = 1 -> dp[1][3] = 1 + min(dp[0][3], dp[1][2], dp[0][2]) = 1 + min(0,1,0) = 1count += 1
  • matrix[0][3] = 1 -> dp[1][4] = 1 + min(dp[0][4], dp[1][3], dp[0][3]) = 1 + min(0,1,0) = 1count += 1

… (继续填充 dp 数组)

当处理到 matrix[1][1] = 1 时:
dp[2][2] = 1 + min(dp[1][2], dp[2][1], dp[1][1]) = 1 + min(1,1,0) = 1count += 1

当处理到 matrix[1][2] = 1 时:
dp[2][3] = 1 + min(dp[1][3], dp[2][2], dp[1][2]) = 1 + min(1,1,1) = 2count += 2

最终 dp 数组会是这样(以 matrix[i-1][j-1] 为右下角的最大正方形边长):

dp = [ [0,0,0,0,0], [0,0,1,1,1], [0,1,1,2,2], [0,0,1,2,3] ]

累加 dp 数组中所有非零元素的值,即可得到最终结果 15。

最优思路选择:

动态规划方法将时间复杂度从指数级降低到多项式级别,且空间复杂度可控,是解决此问题的最优选择。

代码实现

根据上述动态规划思路,我们可以用 JavaScript 实现如下:

/*** @param {number[][]} matrix* @return {number}*/
var countSquares = function(matrix) {const m = matrix.length; // 获取矩阵的行数const n = matrix[0].length; // 获取矩阵的列数let count = 0; // 初始化正方形子矩阵的数量,用于累加所有符合条件的子矩阵// 创建一个dp数组,用于存储以当前位置为右下角的最大正方形边长。// dp[i][j] 表示以 matrix[i-1][j-1] 为右下角的正方形的最大边长。// dp数组的尺寸比原矩阵大1,这样可以方便地处理边界情况(即原矩阵的第一行和第一列,// 它们在dp数组中对应的是dp[i][0]和dp[0][j],这些位置的值默认为0,无需特殊判断)。const dp = Array(m + 1).fill(0).map(() => Array(n + 1).fill(0));// 遍历矩阵,填充dp数组。// 注意:这里的i和j是从1开始,对应dp数组的索引,而matrix的索引是i-1和j-1。for (let i = 1; i <= m; i++) {for (let j = 1; j <= n; j++) {// 如果当前矩阵元素 matrix[i-1][j-1] 为1,说明这个点可以作为正方形的右下角。if (matrix[i - 1][j - 1] === 1) {// 动态规划状态转移方程的核心:// 以当前位置 (i-1, j-1) 为右下角的最大正方形边长,// 等于其左边 (i, j-1)、上边 (i-1, j) 和左上角 (i-1, j-1) // 三个位置所能形成的最大正方形边长中的最小值,再加上1。// 这是因为要形成一个更大的正方形,必须保证这三个相邻位置也都能形成相应大小的正方形。dp[i][j] = 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);// 将当前位置能形成的所有正方形数量累加到总数中。// 如果 dp[i][j] 的值为 k,意味着以 matrix[i-1][j-1] 为右下角,// 存在一个边长为 k 的正方形。同时,这个边长为 k 的正方形也包含了// 边长为 1, 2, ..., k-1 的所有正方形。// 因此,dp[i][j] 的值直接代表了以当前点为右下角所能贡献的正方形数量。count += dp[i][j];}}}return count; // 返回正方形子矩阵的总数
};

代码说明:

  1. 初始化 dp 数组: 我们创建了一个 (m+1) x (n+1) 大小的 dp 数组,并用 0 填充。这样做的好处是,当 ij 为 1 时(对应原矩阵的第一行或第一列),dp[i-1][j]dp[i][j-1]dp[i-1][j-1] 会访问到 dp 数组的第 0 行或第 0 列,这些位置的值默认为 0,天然地处理了边界条件,避免了额外的 if 判断。
  2. 状态转移逻辑: dp[i][j] = 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]) 是动态规划的核心。它表示如果当前 matrix[i-1][j-1] 为 1,那么以它为右下角的最大正方形边长,取决于其左、上、左上三个相邻位置的最大正方形边长。取三者中的最小值,再加上当前这个 1,就是以 (i-1, j-1) 为右下角的最大正方形边长。
  3. 累加 count: 每当计算出 dp[i][j] 的值时,我们直接将其累加到 count 中。这是因为 dp[i][j] 的值 k 不仅代表了边长为 k 的正方形,也隐含了边长为 1k-1 的所有正方形。例如,如果 dp[i][j] 为 3,意味着我们找到了一个 3x3 的正方形,这个 3x3 的正方形内部也包含了 2x2 和 1x1 的正方形,所以它贡献了 3 个正方形。

优化提升

原代码分析

  • 时间复杂度: O(m * n)。我们只对矩阵进行了两次遍历(一次初始化 dp 数组,一次填充 dp 数组并累加 count),每次操作都是常数时间。因此,时间复杂度与矩阵的大小成线性关系,效率非常高。
  • 空间复杂度: O(m * n)。我们创建了一个与原矩阵大小相近的 dp 数组来存储中间结果。在 mn 都为 300 的情况下,dp 数组的大小约为 300 * 300 = 90000,这在内存上是完全可接受的。

优化点:空间优化(滚动数组)

虽然当前的 O(m * n) 空间复杂度已经很优秀,但对于某些极端情况,或者追求极致优化的场景,我们还可以进一步将空间复杂度优化到 O(n)。观察状态转移方程 dp[i][j] = 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]),我们发现计算 dp[i][j] 只依赖于 dp 数组的上一行 (dp[i-1]) 和当前行的前一个元素 (dp[i][j-1])。这意味着我们不需要存储整个 dp 矩阵,只需要存储两行:当前行和上一行。甚至,我们可以只用一个一维数组来模拟这个过程,这就是所谓的滚动数组优化。

滚动数组思路:

创建一个 dp 数组,大小为 n+1(对应矩阵的列数)。在遍历每一行时,dp[j] 代表当前行 matrix[i][j-1] 对应的最大正方形边长。为了计算 dp[j],我们需要 dp[j-1](当前行的前一个值)、prev_dp[j](上一行的当前值)和 prev_dp[j-1](上一行的前一个值)。我们可以用一个变量 prev 来存储 dp[i-1][j-1] 的值,然后更新 dp 数组。

// 滚动数组优化(伪代码,仅供理解思路)
/*
var countSquaresOptimized = function(matrix) {const m = matrix.length;const n = matrix[0].length;let count = 0;// dp 数组只存储当前行和上一行的信息,大小为 n+1const dp = Array(n + 1).fill(0);let prevTopLeft = 0; // 存储 dp[i-1][j-1] 的值for (let i = 0; i < m; i++) {for (let j = 0; j < n; j++) {let temp = dp[j + 1]; // 存储 dp[i-1][j] 的值,用于下一次迭代的 prevTopLeftif (matrix[i][j] === 1) {// dp[j+1] 对应当前 matrix[i][j] 的右下角// dp[j] 对应 dp[i][j-1]// prevTopLeft 对应 dp[i-1][j-1]dp[j + 1] = 1 + Math.min(dp[j + 1], dp[j], prevTopLeft);count += dp[j + 1];} else {dp[j + 1] = 0;}prevTopLeft = temp;}}return count;
};
*/

虽然滚动数组能进一步优化空间,但对于本题 m, n <= 300 的约束,O(m * n) 的空间复杂度已经足够,并且代码可读性更好。在实际面试中,除非面试官明确要求,否则 O(m * n) 的解法通常是更稳妥的选择。

拓展:如果题目要求「返回最大正方形的边长」怎么办?

如果题目要求返回的是最大正方形的边长,那么我们只需要在遍历 dp 数组的过程中,记录 dp[i][j] 的最大值即可。最终返回这个最大值。

拓展:如果矩阵是三维的呢?

如果矩阵是三维的,例如 matrix[z][y][x],那么动态规划的状态转移方程会变得更加复杂,需要考虑六个方向的最小值。但核心思想依然是类似的,即当前状态依赖于其相邻的、更小的子问题。

面试总结

考点提炼

这道题的核心考点在于:

  1. 动态规划的状态定义: 如何将问题转化为动态规划模型,定义 dp[i][j] 的含义是解决这类问题的关键。
  2. 状态转移方程的推导: 理解 dp[i][j] 如何从其子问题 dp[i-1][j]dp[i][j-1]dp[i-1][j-1] 推导而来,是动态规划的精髓。
  3. 边界条件的处理: dp 数组的初始化和索引的对应关系,是避免程序出错的重要细节。
  4. 对空间复杂度的优化: 虽然本题 O(m*n) 空间已足够,但面试中常会考察是否能进一步优化到 O(n)(滚动数组)。

技巧总结

  • 遇到矩阵问题,优先考虑动态规划: 许多矩阵相关的计数、最大/最小路径等问题,都可以通过动态规划来解决。
  • 巧用 dp 数组的额外行/列: 在 dp 数组的维度上比原矩阵多加一行一列,可以简化边界条件的处理,使代码更简洁。
  • 理解 dp[i][j] 的累加含义: 本题中 dp[i][j] 的值直接代表了以当前点为右下角所能贡献的正方形数量,这是巧妙之处。

类似题目

  • 力扣 221. 最大正方形: 这道题与本题非常相似,它要求返回的是最大正方形的边长,而不是数量。解题思路几乎一致,只需要在遍历 dp 数组时记录最大值即可。
  • 力扣 85. 最大矩形: 这道题是最大正方形的进阶版,要求在一个只包含 0 和 1 的二维二进制矩阵中找到最大的矩形。通常可以转化为每一行作为底边,向上寻找最大高度的柱状图问题,然后使用单调栈等方法解决。

结尾互动

通过今天的拆解,相信大家对「统计全为1的正方形子矩阵」这道题有了更深入的理解。动态规划作为算法中的重要思想,在前端面试中出现的频率非常高。你有没有在面试中遇到过类似的矩阵问题?或者在解决这道题时,有没有遇到什么让你印象深刻的“坑”?欢迎在评论区分享你的经验和思考!

如果你觉得这篇文章对你有帮助,或者想继续学习更多前端算法实战内容,请点赞、收藏并关注我,后续会持续更新更多优质内容!

http://www.dtcms.com/a/348144.html

相关文章:

  • 商超客流密度统计误差率↓35%!陌讯多模态融合算法在零售智慧运营的实战解析
  • 智慧零售商品识别误报率↓74%!陌讯多模态融合算法在自助结算场景的落地优化
  • Ubuntu24.04 安装 Zabbix
  • 使用UE5开发2.5D开放世界战略养成类游戏的硬件配置指南
  • IDM 下载失败排查指南:全面解析与解决方案
  • 马斯克宣布开源Grok 2.5:非商业许可引争议,模型需8×40GB GPU运行,Grok 3半年后开源
  • Redis实战-缓存的解决方案(一)
  • 【贪心算法】day1
  • 【数学建模】灰色关联分析的核心步骤
  • 上位机知识篇---电脑参数
  • Shell脚本-影响shell程序的内置命令
  • [机械结构设计-32]:机械加工中,3D图评审OK,没有问题,后续的主要风险有哪些
  • Bright Data MCP:突破AI数据获取限制的革命性工具
  • M8504报错,开票数量大于收货数量
  • 请求上下文对象RequestContextHolder
  • 【datawhale组队学习】RAG技术 - TASK04 向量及多模态嵌入(第三章1、2节)
  • AI Agent全栈开发流程推荐(全栈开发步骤)
  • 在 vue3 和 vue2 中,v-for 和 v-if 可以一起用吗,区别是什么
  • Win10部署ElasticSearch、Logstash、Kibana
  • wpf之Grid控件
  • 图像均衡化详解:从直方图均衡到 CLAHE,让图片告别 “灰蒙蒙“
  • 征程 6X 常用工具介绍
  • 第16届蓝桥杯C++中高级选拔赛(STEMA)2024年12月22日真题
  • elasticsearch 7.x elasticsearch 使用scroll滚动查询中超时问题案例
  • 【C#】构造函数实用场景总结
  • PostgreSQL interval 转换为 int4 (整数)
  • Flink SQL执行SQL错误排查
  • 结构化智能编程:用树形向量存储重构AI代码理解范式
  • RAGFlow (二)小试牛刀:登陆页重构
  • 《链路状态路由协议OSPF》