前端算法实战:用JS解决力扣【1277. 统计全为1的正方形子矩阵】------从暴力到最优的思考过程
引言
各位前端同仁,算法面试中矩阵问题一直是高频考点,而「统计全为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 组成。具体来说,我们可以:
- 确定正方形的左上角 : 遍历矩阵中的每一个点
(r, c)
,将其作为正方形的左上角。 - 确定正方形的边长 : 对于每个左上角
(r, c)
,尝试不同的边长s
。边长s
的最大值受限于当前位置到矩阵右边界和下边界的距离。 - 检查子矩阵 : 对于确定的左上角
(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)
。在最坏情况下,如果m
和n
都接近 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
的正方形,需要满足:
matrix[i][j]
是 1。- 以
(i-1, j)
为右下角存在一个边长为k-1
的正方形。 - 以
(i, j-1)
为右下角存在一个边长为k-1
的正方形。 - 以
(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) = 1
。count += 1
。matrix[0][2] = 1
->dp[1][3] = 1 + min(dp[0][3], dp[1][2], dp[0][2]) = 1 + min(0,1,0) = 1
。count += 1
。matrix[0][3] = 1
->dp[1][4] = 1 + min(dp[0][4], dp[1][3], dp[0][3]) = 1 + min(0,1,0) = 1
。count += 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) = 1
。count += 1
。
当处理到 matrix[1][2] = 1
时:
dp[2][3] = 1 + min(dp[1][3], dp[2][2], dp[1][2]) = 1 + min(1,1,1) = 2
。count += 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 实现如下:
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; // 返回正方形子矩阵的总数
};
代码说明:
- 初始化
dp
数组 : 我们创建了一个(m+1) x (n+1)
大小的dp
数组,并用 0 填充。这样做的好处是,当i
或j
为 1 时(对应原矩阵的第一行或第一列),dp[i-1][j]
、dp[i][j-1]
或dp[i-1][j-1]
会访问到dp
数组的第 0 行或第 0 列,这些位置的值默认为 0,天然地处理了边界条件,避免了额外的if
判断。 - 状态转移逻辑 :
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)
为右下角的最大正方形边长。 - 累加
count
: 每当计算出dp[i][j]
的值时,我们直接将其累加到count
中。这是因为dp[i][j]
的值k
不仅代表了边长为k
的正方形,也隐含了边长为1
到k-1
的所有正方形。例如,如果dp[i][j]
为 3,意味着我们找到了一个 3x3 的正方形,这个 3x3 的正方形内部也包含了 2x2 和 1x1 的正方形,所以它贡献了 3 个正方形。
优化提升
原代码分析
- 时间复杂度 :
O(m * n)
。我们只对矩阵进行了两次遍历(一次初始化dp
数组,一次填充dp
数组并累加count
),每次操作都是常数时间。因此,时间复杂度与矩阵的大小成线性关系,效率非常高。 - 空间复杂度 :
O(m * n)
。我们创建了一个与原矩阵大小相近的dp
数组来存储中间结果。在m
和n
都为 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
数组。
javascript
// 滚动数组优化(伪代码,仅供理解思路)
/*
var countSquaresOptimized = function(matrix) {
const m = matrix.length;
const n = matrix[0].length;
let count = 0;
// dp 数组只存储当前行和上一行的信息,大小为 n+1
const 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] 的值,用于下一次迭代的 prevTopLeft
if (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]
,那么动态规划的状态转移方程会变得更加复杂,需要考虑六个方向的最小值。但核心思想依然是类似的,即当前状态依赖于其相邻的、更小的子问题。
面试总结
考点提炼
这道题的核心考点在于:
- 动态规划的状态定义 : 如何将问题转化为动态规划模型,定义
dp[i][j]
的含义是解决这类问题的关键。 - 状态转移方程的推导 : 理解
dp[i][j]
如何从其子问题dp[i-1][j]
、dp[i][j-1]
、dp[i-1][j-1]
推导而来,是动态规划的精髓。 - 边界条件的处理 :
dp
数组的初始化和索引的对应关系,是避免程序出错的重要细节。 - 对空间复杂度的优化 : 虽然本题
O(m*n)
空间已足够,但面试中常会考察是否能进一步优化到O(n)
(滚动数组)。
技巧总结
- 遇到矩阵问题,优先考虑动态规划: 许多矩阵相关的计数、最大/最小路径等问题,都可以通过动态规划来解决。
- 巧用
dp
数组的额外行/列 : 在dp
数组的维度上比原矩阵多加一行一列,可以简化边界条件的处理,使代码更简洁。 - 理解
dp[i][j]
的累加含义 : 本题中dp[i][j]
的值直接代表了以当前点为右下角所能贡献的正方形数量,这是巧妙之处。
类似题目
- 力扣 221. 最大正方形 : 这道题与本题非常相似,它要求返回的是最大正方形的边长,而不是数量。解题思路几乎一致,只需要在遍历
dp
数组时记录最大值即可。 - 力扣 85. 最大矩形: 这道题是最大正方形的进阶版,要求在一个只包含 0 和 1 的二维二进制矩阵中找到最大的矩形。通常可以转化为每一行作为底边,向上寻找最大高度的柱状图问题,然后使用单调栈等方法解决。
结尾互动
通过今天的拆解,相信大家对「统计全为1的正方形子矩阵」这道题有了更深入的理解。动态规划作为算法中的重要思想,在前端面试中出现的频率非常高。你有没有在面试中遇到过类似的矩阵问题?或者在解决这道题时,有没有遇到什么让你印象深刻的"坑"?欢迎在评论区分享你的经验和思考!
如果你觉得这篇文章对你有帮助,或者想继续学习更多前端算法实战内容,请点赞、收藏并关注我,后续会持续更新更多优质内容!