前端算法实战:用JS解决力扣【1504. 统计全1子矩形】——从暴力到最优的思考过程
引言
各位前端算法爱好者们,大家好!今天我们来聊一个在面试中可能让你眼前一亮的题目——力扣 1504 题“统计全 1 子矩形”。这道题虽然不是前端面试的“常客”,但它背后蕴含的二维动态规划和问题分解思想,对于提升我们的算法思维,尤其是处理复杂数据结构的能力非常有帮助。掌握它,不仅能让你在算法面试中多一份从容,也能在日常工作中遇到类似的数据处理问题时,提供新的解决思路。
题目分析
原题链接
题目大意
简单来说,就是给你一个由 0 和 1 组成的矩阵,你需要找出这个矩阵里面所有由 1 组成的子矩形有多少个。注意,这里是统计所有符合条件的子矩形,而不是找出最大的那个。
输入输出
- 输入: 一个
m x n的二进制矩阵mat,其中mat[i][j]仅包含 0 或 1。 - 输出: 一个整数,表示矩阵中所有元素都是 1 的子矩形的数量。
约束条件
1 <= m, n <= 150mat[i][j]仅包含 0 或 1
示例演示
我们以示例 1 为例,详细拆解一下计数过程:
输入: mat = [[1,0,1],[1,1,0],[1,1,0]]
输出: 13
解释:
-
1x1 的矩形:
mat[0][0](1)mat[0][2](1)mat[1][0](1)mat[1][1](1)mat[2][0](1)mat[2][1](1) 共 6 个。
-
1x2 的矩形:
mat[1][0]到mat[1][1](1,1)mat[2][0]到mat[2][1](1,1) 共 2 个。
-
2x1 的矩形:
mat[0][0]到mat[1][0](1,1)mat[1][0]到mat[2][0](1,1)mat[1][1]到mat[2][1](1,1) 共 3 个。
-
2x2 的矩形:
mat[1][0]到mat[2][1](1,1,1,1) 共 1 个。
-
3x1 的矩形:
mat[0][0]到mat[2][0](1,1,1) 共 1 个。
矩形数目总共 = 6 + 2 + 3 + 1 + 1 = 13。
通过这个示例,我们可以看到,一个全 1 子矩形可以由不同的行和列组合而成,我们需要确保其内部所有元素都是 1。
思路推导
笨方法尝试:暴力枚举(不可取)
面对这类计数问题,最直接的想法就是暴力枚举所有可能的子矩形,然后逐一检查它们是否全为 1。一个子矩形由其左上角坐标 (r1, c1) 和右下角坐标 (r2, c2) 唯一确定。因此,我们需要四重循环来枚举 r1, c1, r2, c2。对于每个确定的子矩形,我们还需要两重循环来遍历其内部所有元素,判断是否都为 1。这样算下来,总的时间复杂度将高达 O(m^3 * n^3)。
对于题目中给出的 m, n <= 150 的约束条件,150^6 是一个天文数字,大约是 1.1 * 10^13 次操作。这在力扣上是绝对会超时的,因此暴力枚举的思路是不可取的。
优化方向:固定右下角,向上/向左扩展
既然暴力枚举行不通,我们就需要寻找更高效的方法。一个常见的优化思路是“固定一个点,然后扩展”。在这道题中,我们可以固定子矩形的右下角 (i, j),然后向上和向左扩展,统计以 (i, j) 为右下角的所有全 1 子矩形。
预处理:计算每个位置向左连续 1 的个数
为了高效地统计,我们可以先进行预处理。定义一个 left[i][j] 数组,表示在 mat[i][j] 处,当前行 i 向左连续 1 的个数。如果 mat[i][j] == 0,那么 left[i][j] 自然就是 0。如果 mat[i][j] == 1,那么 left[i][j] 就等于 left[i][j-1] + 1(如果 j > 0),或者 1(如果 j == 0)。
这个预处理过程非常简单,只需要两重循环,时间复杂度为 O(m * n)。例如,对于 mat = [[1,0,1],[1,1,0],[1,1,0]],其 left 矩阵将是:
mat = [[1,0,1],
[1,1,0],
[1,1,0]]
left = [[1,0,1],
[1,2,0],
[1,2,0]]
有了 left 矩阵,我们就可以知道以 (i, j) 为右下角的矩形,在当前行能够向左扩展的最大宽度。
核心思路:遍历右下角,向上累加
现在,我们遍历矩阵中的每一个位置 (i, j),如果 mat[i][j] == 1,我们就认为它可能是一个全 1 子矩形的右下角。然后,我们从 (i, j) 开始,向上遍历 k 行(从 i 到 0)。
在向上遍历的过程中,我们维护一个 min_width 变量,它表示从当前行 i 到行 k 之间,第 j 列所有 left 值的最小值。为什么是最小值?因为一个全 1 子矩形的高度是由 i 到 k 决定的,而宽度则受限于这一列上所有行中连续 1 的最短长度。
具体来说,当我们在 (i, j) 处向上遍历到 k 行时:
-
初始化
min_width = left[i][j]。 -
向上遍历
k从i到0:- 更新
min_width = min(min_width, left[k][j])。这一步确保了我们考虑的矩形宽度不会超过从k行到i行,第j列所有left值的最小值。 - 将当前的
min_width加到总的ans中。因为以(i, j)为右下角,高度为i - k + 1,宽度为min_width的矩形,就是一个全 1 子矩形。并且,以(i, j)为右下角,高度为i - k + 1,宽度小于min_width的所有矩形,也都是全 1 子矩形。这些矩形恰好就是min_width个。
- 更新
让我们用示例 1 来一步步推导这个过程:
mat = [[1,0,1],[1,1,0],[1,1,0]] left = [[1,0,1],[1,2,0],[1,2,0]] ans = 0
-
遍历
(0,0):mat[0][0] == 1min_width = left[0][0] = 1k=0:min_width = min(1, left[0][0]) = 1。ans += 1。ans = 1。
-
遍历
(0,1):mat[0][1] == 0。跳过。 -
遍历
(0,2):mat[0][2] == 1min_width = left[0][2] = 1k=0:min_width = min(1, left[0][2]) = 1。ans += 1。ans = 2。
-
遍历
(1,0):mat[1][0] == 1min_width = left[1][0] = 1k=1:min_width = min(1, left[1][0]) = 1。ans += 1。ans = 3。k=0:min_width = min(1, left[0][0]) = min(1,1) = 1。ans += 1。ans = 4。
-
遍历
(1,1):mat[1][1] == 1min_width = left[1][1] = 2k=1:min_width = min(2, left[1][1]) = 2。ans += 2。ans = 6。k=0:min_width = min(2, left[0][1]) = min(2,0) = 0。ans += 0。ans = 6。
-
遍历
(1,2):mat[1][2] == 0。跳过。 -
遍历
(2,0):mat[2][0] == 1min_width = left[2][0] = 1k=2:min_width = min(1, left[2][0]) = 1。ans += 1。ans = 7。k=1:min_width = min(1, left[1][0]) = min(1,1) = 1。ans += 1。ans = 8。k=0:min_width = min(1, left[0][0]) = min(1,1) = 1。ans += 1。ans = 9。
-
遍历
(2,1):mat[2][1] == 1min_width = left[2][1] = 2k=2:min_width = min(2, left[2][1]) = 2。ans += 2。ans = 11。k=1:min_width = min(2, left[1][1]) = min(2,2) = 2。ans += 2。ans = 13。k=0:min_width = min(2, left[0][1]) = min(2,0) = 0。ans += 0。ans = 13。
-
遍历
(2,2):mat[2][2] == 0。跳过。
最终 ans = 13,与示例输出完全一致。这个思路是正确的,并且时间复杂度在可接受范围内。
代码实现
根据上述思路,我们可以用 JavaScript 实现这个算法。为了符合前端同学的阅读习惯,这里提供 JavaScript 代码,并附上详细注释,方便大家理解其核心逻辑。
/**
* @param {number[][]} mat
* @return {number}
*/
var numSubmat = function(mat) {
const m = mat.length;
const n = mat[0].length;
// left[i][j] 表示 mat[i][j] 所在行,向左连续 1 的个数
// 这是一个预处理步骤,帮助我们快速获取每个位置向左连续 1 的长度
const left = Array(m).fill(0).map(() => Array(n).fill(0));
for (let i = 0; i < m; i++) {
for (let j = 0; j < n; j++) {
if (mat[i][j] === 1) {
// 如果当前位置是 1,则其向左连续 1 的个数等于左边位置的连续 1 个数加 1
// 如果是第一列,则为 1
if (j > 0) {
left[i][j] = left[i][j - 1] + 1;
} else {
left[i][j] = 1;
}
} else {
// 如果当前位置是 0,则向左连续 1 的个数为 0
left[i][j] = 0;
}
}
}
let ans = 0;
// 遍历矩阵中的每一个位置 (i, j),将其视为可能的子矩形的右下角
for (let i = 0; i < m; i++) {
for (let j = 0; j < n; j++) {
// 只有当 mat[i][j] 为 1 时,才可能作为全 1 子矩形的右下角
if (mat[i][j] === 1) {
// minWidth 记录向上遍历过程中,当前列的最小连续 1 宽度
// 初始值为当前位置向左连续 1 的个数
let minWidth = left[i][j];
// 向上遍历 k,从当前行 i 到第 0 行
// 统计以 (i, j) 为右下角的所有全 1 子矩形
for (let k = i; k >= 0; k--) {
// 在向上遍历的过程中,不断更新 minWidth
// minWidth 始终是当前行 k 到行 i 之间,第 j 列所有 left 值的最小值
// 这确保了我们找到的矩形在高度方向上也是全 1 的
minWidth = Math.min(minWidth, left[k][j]);
// 将当前的 minWidth 加到总的答案中
// 因为以 (i, j) 为右下角,高度为 (i - k + 1),宽度为 minWidth 的矩形
// 以及所有宽度小于 minWidth 的矩形,都是全 1 子矩形
// 这样的矩形总共有 minWidth 个
ans += minWidth;
}
}
}
}
return ans;
};
复杂度分析
-
时间复杂度:
- 预处理
left矩阵: 我们使用两重循环遍历m x n的矩阵,每个位置的操作都是常数时间。因此,这一步的时间复杂度是O(m * n)。 - 统计
ans: 外层有两重循环(i, j),遍历m x n的矩阵。内层有一个向上遍历k的循环,最坏情况下会遍历m行。所以,总的时间复杂度是O(m * n * m),即O(m^2 * n)。 - 考虑到题目中
m, n <= 150的约束,150^3大约是3.3 * 10^6。这个操作次数对于现代计算机来说是完全可以接受的,不会导致超时。
- 预处理
-
空间复杂度:
- 我们额外创建了一个
left矩阵来存储预处理的结果,其大小为m x n。因此,空间复杂度是O(m * n)。 - 其他变量只占用常数空间
O(1)。 - 综合来看,总的空间复杂度是
O(m * n)。
- 我们额外创建了一个
优化提升
当前我们实现的 O(m^2 * n) 解法对于 m, n <= 150 的数据范围来说,已经足够高效。但在某些极端情况下,例如 m 很大而 n 很小,或者反之,性能可能会有所波动。如果追求极致的性能,可以将时间复杂度优化到 O(m * n)。
这种优化通常涉及到将每一行的处理转化为一个经典的“柱状图中最大的矩形”问题的变种,并利用单调栈来解决。具体来说,对于每一行,我们可以计算出每个位置向上连续 1 的高度(即 height 数组),然后利用单调栈在 O(n) 的时间复杂度内统计出当前行所能贡献的所有全 1 子矩形。这样,总的时间复杂度就降为 O(m * n)。
然而,单调栈的实现相对复杂,且对于本题的数据范围,O(m^2 * n) 的解法已经足够通过。在前端面试中,清晰地阐述 O(m^2 * n) 的思路和推导过程,通常比实现一个复杂的 O(m * n) 单调栈解法更能体现你的算法思维和解决问题的能力。因此,我们在这里主要聚焦于前面介绍的更直观、更易于理解的 O(m^2 * n) 解法。
面试总结
-
考点提炼: 这道题考察的是二维数组的处理能力,以及将复杂问题分解为子问题的思想。虽然不是典型的动态规划题目,但其核心思想与动态规划中的状态转移有异曲同工之妙。同时,也涉及到了对子矩形计数的理解。
-
技巧总结:
- 预处理: 通过预处理
left矩阵,将每个位置向左连续 1 的个数计算出来,为后续的统计提供了便利。 - 降维思想: 将二维矩阵中的子矩形问题,通过固定右下角,向上遍历的方式,有效地将问题转化为一维的求和问题。
- 枚举与累加: 遍历每个可能的右下角
(i, j),然后向上累加以(i, j)为右下角的所有全 1 子矩形数量。
- 预处理: 通过预处理
-
类似题目:
- 力扣 85. 最大矩形:同样是二维矩阵中的矩形问题,但要求的是最大矩形面积,通常使用单调栈解决。
- 力扣 221. 最大正方形:要求最大正方形的边长,可以使用动态规划解决。
结尾互动
大家在解决这道题的时候有没有遇到什么有趣的思路或者“坑”呢?比如在处理边界条件时?欢迎在评论区分享你的想法!如果你觉得这篇博客对你有帮助,别忘了点赞收藏哦!后续我还会带来更多前端算法实战内容,敬请期待!