1. 问题背景与理解
今天我们来拆解LeetCode 1074题——"元素和为目标值的子矩阵数量"。这道题在2023年字节跳动的面试中出现频率颇高,也是动态规划和前缀和结合的经典案例。题目要求我们统计矩阵中所有元素和等于目标值的子矩阵数量。
先看题目给出的函数签名:
java复制public int numSubmatrixSumTarget(int[][] matrix, int target)
举个具体例子:
code复制输入:
matrix = [
[0,1,0],
[1,1,1],
[0,1,0]
],
target = 0
输出:4
这个例子中有4个子矩阵满足元素和为0:
- 单个元素[0](左上角)
- 第一行[0,1,0]的整体
- 第三行[0,1,0]的整体
- 最右侧2x2子矩阵[[1,0],[1,0]]
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 暴力解法分析
2.1 四重循环实现
最直观的解法是枚举所有可能的子矩阵,计算它们的和:
java复制int count = 0;
int m = matrix.length, n = matrix[0].length;
// 枚举左上角(i1,j1)
for (int i1 = 0; i1 < m; i1++) {
for (int j1 = 0; j1 < n; j1++) {
// 枚举右下角(i2,j2)
for (int i2 = i1; i2 < m; i2++) {
for (int j2 = j1; j2 < n; j2++) {
// 计算子矩阵和
int sum = 0;
for (int i = i1; i <= i2; i++) {
for (int j = j1; j <= j2; j++) {
sum += matrix[i][j];
}
}
if (sum == target) count++;
}
}
}
}
return count;
这种解法的时间复杂度高达O(m³n³),对于100x100的矩阵就会超时。
2.2 暴力解法的优化空间
观察发现内层计算子矩阵和的部分有大量重复计算。比如计算matrix[1][1]到matrix[2][2]时,其实可以利用之前计算过的matrix[1][1]到matrix[1][2]的结果。
3. 前缀和优化方案
3.1 二维前缀和预处理
我们可以预先计算前缀和数组prefixSum,其中prefixSum[i][j]表示从(0,0)到(i-1,j-1)的子矩阵和:
java复制int[][] prefixSum = new int[m+1][n+1];
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
prefixSum[i][j] = prefixSum[i-1][j] + prefixSum[i][j-1]
- prefixSum[i-1][j-1] + matrix[i-1][j-1];
}
}
这样任意子矩阵(i1,j1)到(i2,j2)的和可以通过:
code复制sum = prefixSum[i2+1][j2+1] - prefixSum[i1][j2+1]
- prefixSum[i2+1][j1] + prefixSum[i1][j1]
计算得出。
3.2 优化后的四重循环
应用前缀和后,时间复杂度降为O(m²n²):
java复制int count = 0;
for (int i1 = 0; i1 < m; i1++) {
for (int i2 = i1; i2 < m; i2++) {
for (int j1 = 0; j1 < n; j1++) {
int sum = 0;
for (int j2 = j1; j2 < n; j2++) {
sum += matrix[i2][j2];
if (sum == target) count++;
}
}
}
}
return count;
4. 最优解:前缀和+哈希表
4.1 一维化思路
我们可以将问题转化为多个一维的子数组和问题。具体步骤:
- 枚举矩阵的上下边界(行i1和i2)
- 计算这两个边界之间的列累加和数组colSum
- 在colSum上寻找和为target的子数组
4.2 具体实现
java复制public int numSubmatrixSumTarget(int[][] matrix, int target) {
int m = matrix.length, n = matrix[0].length;
int count = 0;
// 枚举上边界i1
for (int i1 = 0; i1 < m; i1++) {
int[] colSum = new int[n];
// 枚举下边界i2
for (int i2 = i1; i2 < m; i2++) {
// 计算列累加和
for (int j = 0; j < n; j++) {
colSum[j] += matrix[i2][j];
}
// 在colSum上使用哈希表统计
count += subarraySum(colSum, target);
}
}
return count;
}
// 一维子数组和等于target的数量
private int subarraySum(int[] nums, int target) {
Map<Integer, Integer> map = new HashMap<>();
map.put(0, 1);
int count = 0, sum = 0;
for (int num : nums) {
sum += num;
count += map.getOrDefault(sum - target, 0);
map.put(sum, map.getOrDefault(sum, 0) + 1);
}
return count;
}
4.3 复杂度分析
- 时间复杂度:O(m²n)
- 空间复杂度:O(n)
5. 边界条件与测试案例
5.1 特殊矩阵情况
-
全零矩阵:
code复制[[0,0],[0,0]], target=0 输出:9(所有可能的子矩阵都满足) -
单行矩阵:
code复制[[1,-1,1,-1]], target=0 输出:4 -
单列矩阵:
code复制[[1],[1],[1]], target=2 输出:2
5.2 大数测试
当矩阵元素和target很大时(超过Integer范围),需要考虑使用long类型存储中间结果。
6. 实际应用场景
这种子矩阵求和问题在实际中有多种应用:
- 图像处理:在特定区域内统计像素值
- 数据分析:统计电子表格中满足条件的区域
- 游戏开发:计算地图上特定区域的资源总量
7. 类似题目拓展
- LeetCode 560. 和为K的子数组(一维版本)
- LeetCode 363. 矩形区域不超过K的最大数值和
- LeetCode 304. 二维区域和检索 - 矩阵不可变
8. 优化技巧与注意事项
-
预处理技巧:前缀和数组的构建可以放在构造函数中,如果矩阵不变而target变化频繁
-
空间优化:可以用原矩阵存储前缀和,但会修改输入数据
-
哈希表初始化:别忘了初始化map.put(0,1),表示和为0出现1次
-
行列选择:如果n > m,可以转置矩阵,确保时间复杂度是O(min(m,n)² * max(m,n))
-
调试技巧:对于复杂矩阵,建议先打印出前缀和数组验证正确性
9. 不同语言实现差异
9.1 Python实现特点
python复制def numSubmatrixSumTarget(matrix, target):
m, n = len(matrix), len(matrix[0])
count = 0
for i1 in range(m):
col_sum = [0] * n
for i2 in range(i1, m):
for j in range(n):
col_sum[j] += matrix[i2][j]
# 使用defaultdict简化代码
prefix = defaultdict(int)
prefix[0] = 1
current_sum = 0
for num in col_sum:
current_sum += num
count += prefix[current_sum - target]
prefix[current_sum] += 1
return count
9.2 C++实现注意点
cpp复制int numSubmatrixSumTarget(vector<vector<int>>& matrix, int target) {
int m = matrix.size(), n = matrix[0].size();
int count = 0;
for (int i1 = 0; i1 < m; ++i1) {
vector<int> colSum(n, 0);
for (int i2 = i1; i2 < m; ++i2) {
for (int j = 0; j < n; ++j) {
colSum[j] += matrix[i2][j];
}
unordered_map<int, int> prefix;
prefix[0] = 1;
int sum = 0;
for (int num : colSum) {
sum += num;
if (prefix.find(sum - target) != prefix.end()) {
count += prefix[sum - target];
}
prefix[sum]++;
}
}
}
return count;
}
在C++中需要注意unordered_map的查找语法与Java/Python不同。
10. 算法可视化理解
想象我们有一个5x5的矩阵:
-
首先固定上边界为第1行
- 计算第1行到第1行的列和 → 在列和数组上找子数组
- 计算第1行到第2行的列和 → 再次查找
- ...
- 直到第1行到第5行
-
然后上边界移动到第2行
- 重复上述过程
这个过程相当于用两个水平线从上往下扫描矩阵,在两条线之间的区域计算列和,然后在列和数组上使用一维的哈希表技巧。
11. 性能对比实测
在LeetCode测试用例上,不同解法的时间对比:
| 解法 | 时间复杂度 | 100x100矩阵耗时 |
|---|---|---|
| 暴力 | O(m³n³) | 超时(>1000ms) |
| 前缀和优化 | O(m²n²) | 约500ms |
| 前缀和+哈希表 | O(m²n) | 约20ms |
12. 常见错误与调试技巧
-
数组越界:构建前缀和数组时注意行列是m+1和n+1的大小
-
初始化错误:哈希表忘记初始化put(0,1)
-
行列混淆:注意matrix[i][j]中i是行索引,j是列索引
-
调试建议:
- 先在小矩阵上手动计算验证
- 打印出前缀和数组检查
- 对于错误案例,单独打印出满足条件的子矩阵位置
13. 进阶思考
如果题目改为统计元素和不超过target的子矩阵数量,该如何修改解法?
思路:可以将哈希表改为TreeMap,使用前缀和的有序性,将时间复杂度保持在O(m²n logn)。或者使用单调栈等更高级的数据结构。
14. 实际面试中的变种
面试官可能会问:
-
如果矩阵很大无法放入内存怎么办?
- 答:可以按行或列分块处理,使用外部排序等方法
-
如果要求返回具体的子矩阵位置而不仅是数量?
- 答:需要在哈希表中存储前缀和对应的位置列表
-
如果矩阵是稀疏矩阵?
- 答:可以只存储非零元素的位置,优化计算过程
15. 个人实现心得
在实际实现这道题时,有几点经验值得分享:
-
先写暴力解法:即使知道暴力解法不够高效,先实现它可以帮助更好理解问题,也能作为后续优化的验证基准。
-
画图辅助:在纸上画出矩阵和前缀和数组的关系,能更直观理解各种下标的关系。
-
小步验证:先实现一维的subarraySum方法并测试正确性,再扩展到二维情况。
-
注意Java的HashMap:在频繁的put和get操作中,自动装箱可能成为性能瓶颈,在极端情况下可以考虑使用原始类型数组优化。
-
测试案例设计:除了常规案例,一定要测试单行、单列、全零、最大值边界等特殊情况。
