力扣【1504. 统计全1子矩形】——从暴力到最优的思考过程

87 阅读11分钟

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

引言

各位前端算法爱好者们,大家好!今天我们来聊一个在面试中可能让你眼前一亮的题目——力扣 1504 题“统计全 1 子矩形”。这道题虽然不是前端面试的“常客”,但它背后蕴含的二维动态规划和问题分解思想,对于提升我们的算法思维,尤其是处理复杂数据结构的能力非常有帮助。掌握它,不仅能让你在算法面试中多一份从容,也能在日常工作中遇到类似的数据处理问题时,提供新的解决思路。

题目分析

原题链接

力扣 1504. 统计全 1 子矩形

image.png

题目大意

简单来说,就是给你一个由 0 和 1 组成的矩阵,你需要找出这个矩阵里面所有由 1 组成的子矩形有多少个。注意,这里是统计所有符合条件的子矩形,而不是找出最大的那个。

输入输出

  • 输入: 一个 m x n 的二进制矩阵 mat,其中 mat[i][j] 仅包含 0 或 1。
  • 输出: 一个整数,表示矩阵中所有元素都是 1 的子矩形的数量。

约束条件

  • 1 <= m, n <= 150
  • mat[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 行(从 i0)。

在向上遍历的过程中,我们维护一个 min_width 变量,它表示从当前行 i 到行 k 之间,第 j 列所有 left 值的最小值。为什么是最小值?因为一个全 1 子矩形的高度是由 ik 决定的,而宽度则受限于这一列上所有行中连续 1 的最短长度。

具体来说,当我们在 (i, j) 处向上遍历到 k 行时:

  1. 初始化 min_width = left[i][j]

  2. 向上遍历 ki0

    • 更新 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] == 1

    • min_width = left[0][0] = 1
    • k=0: min_width = min(1, left[0][0]) = 1ans += 1ans = 1
  • 遍历 (0,1): mat[0][1] == 0。跳过。

  • 遍历 (0,2): mat[0][2] == 1

    • min_width = left[0][2] = 1
    • k=0: min_width = min(1, left[0][2]) = 1ans += 1ans = 2
  • 遍历 (1,0): mat[1][0] == 1

    • min_width = left[1][0] = 1
    • k=1: min_width = min(1, left[1][0]) = 1ans += 1ans = 3
    • k=0: min_width = min(1, left[0][0]) = min(1,1) = 1ans += 1ans = 4
  • 遍历 (1,1): mat[1][1] == 1

    • min_width = left[1][1] = 2
    • k=1: min_width = min(2, left[1][1]) = 2ans += 2ans = 6
    • k=0: min_width = min(2, left[0][1]) = min(2,0) = 0ans += 0ans = 6
  • 遍历 (1,2): mat[1][2] == 0。跳过。

  • 遍历 (2,0): mat[2][0] == 1

    • min_width = left[2][0] = 1
    • k=2: min_width = min(1, left[2][0]) = 1ans += 1ans = 7
    • k=1: min_width = min(1, left[1][0]) = min(1,1) = 1ans += 1ans = 8
    • k=0: min_width = min(1, left[0][0]) = min(1,1) = 1ans += 1ans = 9
  • 遍历 (2,1): mat[2][1] == 1

    • min_width = left[2][1] = 2
    • k=2: min_width = min(2, left[2][1]) = 2ans += 2ans = 11
    • k=1: min_width = min(2, left[1][1]) = min(2,2) = 2ans += 2ans = 13
    • k=0: min_width = min(2, left[0][1]) = min(2,0) = 0ans += 0ans = 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. 最大正方形:要求最大正方形的边长,可以使用动态规划解决。

结尾互动

大家在解决这道题的时候有没有遇到什么有趣的思路或者“坑”呢?比如在处理边界条件时?欢迎在评论区分享你的想法!如果你觉得这篇博客对你有帮助,别忘了点赞收藏哦!后续我还会带来更多前端算法实战内容,敬请期待!