作为一名常年拿 LeetCode 周赛练手的人,我遇到这题的第一反应是:这种矩阵分割类题目,十有八九是前缀和变种。等点进 3546 看完题面,心里更确定了一件事——这是那种"想通了只需要两遍遍历,想不通就写出一堆前缀和数组还不一定对"的题。标题里的"记总记当前(遍历)"其实已经把官方解法剧透完了:先记下矩阵总和,再在遍历过程中一直维护当前累计值,当累计值恰好等于总和一半时,就是一刀切下去的位置。今天这篇把这题掰开揉碎讲透,从暴力思路讲到线性做法,再把我踩过的边界坑全列出来,适合刚接触矩阵前缀和、或者周赛卡在这类题上的朋友参考。
1. 题面拆解:这个"等和分割"到底在问什么
1.1 题目背景与题意翻译
LeetCode 3546 是"等和矩阵分割"系列的第一题,题面非常短:给定一个 m x n 的整数矩阵 matrix,判断能否用一条直线把矩阵分割成两个非空子矩阵,使得两个子矩阵的元素和相等。
关键在"用一条直线"这几个字。这里的直线不是随便画的斜线,也不是弯弯曲曲的任意形状,而是两种固定方向:
- 水平分割:在某一行的下方横切一刀,上半部分是一块,下半部分是另一块。比如
2 x 3的矩阵,在第 1 行下方切,得到1 x 3和1 x 3两块。 - 垂直分割:在某一列的右侧竖切一刀,左边是一块,右边是另一块。
翻译成人话就是:你只能整行整行地拿走上方区域,或者整列整列地拿走左侧区域,不能挑挑拣拣拆散行或列。这大大缩小了搜索空间,也是这道题能在线性时间内解决的根本原因。
题目本身是 Series 的第 I 题,通常这个系列还有第 II 题、第 III 题,后面可能会放宽"用一条直线"的限制,改成允许十字分割甚至 L 型分割。但第 I 题的价值在于:它把最基础、最经典的分割模型单独拎出来,考察你是否掌握"遍历中维护累计值"这种基础功。
1.2 "记总记当前"这个标题暗示了什么
标题里的"记总记当前(遍历)"五个字,其实已经把最优解拆成了三步:
- 记总:先完整遍历一遍矩阵,算出所有元素的总和
total。 - 判奇偶:如果
total是奇数,直接返回false。因为两个整数子矩阵的和不可能等于一个奇数的一半。 - 记当前加遍历:再次遍历矩阵,维护一个"当前累计和"。每累加完一行(或一列),就检查一次当前累计和是否等于
total / 2。
为什么"记总"就能解决"判断另一部分是否也等于一半"的问题?因为总和是固定的。如果当前累计部分的和等于一半,那么剩余部分的和就是 total - 累计值,也就是另一半。证明只需要一个减法:total - half = half。所以并列判断两部分各自是否满足条件,是一种冗余——你只需要盯住其中一个部分就行。
这个思路和"两数之和"的解法有异曲同工之处:都是先建立全局信息,再用单次遍历去匹配目标值。只不过"两数之和"用哈希表空间换时间,这里用数学关系省掉了额外数据结构。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 暴力思路与线性优化:为什么不要一上来就写二维前缀和
2.1 暴力写法长什么样
如果没有任何思考,直接按题意枚举所有切分位置,最朴素的写法是这样的:
cpp复制// 伪代码,仅演示暴力思路
for (int i = 1; i < m; ++i) { // 枚举水平分割位置
// 计算上半部分 [0..i-1] 行的和
long long up = 0;
for (int r = 0; r < i; ++r)
for (int c = 0; c < n; ++c)
up += matrix[r][c];
long long down = total - up;
if (up == down) return true;
}
for (int j = 1; j < n; ++j) { // 枚举垂直分割位置
long long left = 0;
for (int r = 0; r < m; ++r)
for (int c = 0; c < j; ++c)
left += matrix[r][c];
long long right = total - left;
if (left == right) return true;
}
这个写法没错,但跑起来让人着急。水平分割部分,每次重新计算 up 是 O(i * n),外层套上 i 从 1 到 m-1 的循环,水平部分就是 O(m^2 * n)。垂直部分同理是 O(n^2 * m)。合在一起,总时间复杂度是 O(m^2 * n + n^2 * m)。
如果矩阵是 100 x 100 或者 500 x 500,勉强能跑。但周赛的数据范围通常会卡到 10^5 级别的元素个数,这个写法直接超时。
2.2 用前缀和数组优化,但要注意维度选择
很多人看到矩阵求和,第一反应是上二维前缀和。没错,二维前缀和可以 O(1) 查询任意矩形区域的和,对于这道题来说是够用的:
cpp复制// 二维前缀和预处理 O(mn),查询 O(1)
prefix[i + 1][j + 1] = prefix[i][j + 1] + prefix[i + 1][j] - prefix[i][j] + matrix[i][j];
// 水平分割:上半部分 [0..i-1] x [0..n-1]
up = prefix[i][n];
// 垂直分割:左半部分 [0..m-1] x [0..j-1]
left = prefix[m][j];
但二维前缀和在这里属于"杀鸡用牛刀"。因为分割方向只有两种——水平方向只看行的累计,垂直方向只看列的累计。你根本不需要任意矩形区域的查询能力,只需要"前 i 行的和"和"前 j 列的和"这两个一维序列。
一维序列用什么维护?一个累计变量就够。
2.3 线性解法的核心:一行一列地累计
真正的线性解法逻辑是这样的:
- 水平分割:维护
rowAccum,按行遍历矩阵,每次累加第i行的行和。当i从0到m-2时,如果rowAccum == half,说明在第i+1行下方切一刀就可以,返回true。 - 垂直分割:维护
colAccum,按列遍历矩阵,每次累加第j列的列和。当j从0到n-2时,如果colAccum == half,说明在第j+1列右侧切一刀就可以,返回true。
为什么不需要额外的一维前缀和数组?因为判断条件是"当前累计和是否等于 half",而不是"任意区间和等于多少"。你在累加的过程中,每加完一行就能知道当前位置的前缀和,这个值本身就是当前需要判断的目标。只要不是在最后一行/列停下,就不需要回头查询中间某个区间。
换句话说,前缀和数组解决的是"任意区间查询"的问题,而这道题只需要"前缀和与固定值比较",连区间减法都用不上。所以一个累计变量在遍历过程中顺手记一下,就是最贴合题目本质的写法。
时间复杂度:水平扫描 O(m * n),垂直扫描 O(m * n),合计 O(m * n)。只遍历了两遍矩阵,额外空间 O(1)(不算输入矩阵本身)。
3. 完整代码实现:一版能直接 AC 的写法
3.1 C++ 实现(推荐,清晰易读)
cpp复制class Solution {
public:
bool canSplit(vector<vector<int>>& matrix) {
int m = matrix.size();
if (m == 0) return false;
int n = matrix[0].size();
if (n == 0) return false;
// 第一遍:记总
long long total = 0;
for (int i = 0; i < m; ++i) {
for (int j = 0; j < n; ++j) {
total += matrix[i][j];
}
}
// 奇数直接不可能
if (total % 2 == 1) return false;
long long half = total / 2;
// 第二遍:水平分割检查(顶部向下累计)
long long rowAccum = 0;
for (int i = 0; i < m - 1; ++i) {
for (int j = 0; j < n; ++j) {
rowAccum += matrix[i][j];
}
if (rowAccum == half) return true;
// 如果 rowAccum 已经超过 half,不提前退出
// 因为矩阵可能含负数,超过 half 之后可能又降回来
}
// 第二遍:垂直分割检查(左侧向右累计)
long long colAccum = 0;
for (int j = 0; j < n - 1; ++j) {
for (int i = 0; i < m; ++i) {
colAccum += matrix[i][j];
}
if (colAccum == half) return true;
}
return false;
}
};
这个版本里我特意没在 rowAccum > half 时提前 break,原因是矩阵元素可能是负数。如果题目保证元素非负,加一个 if (rowAccum > half) break; 可以省点时间;但既然题目没说,安全写法就是完整跑完。你可以在本地针对负数用例验证一下,比如 [[1, -1], [0, 0]],total = 0,half = 0,水平扫描第 0 行累计 0,可以直接返回 true,因为 [[1, -1]] 和 [[0, 0]] 两块和都为 0。
3.2 Python 实现(比赛手速版)
python复制class Solution:
def canSplit(self, matrix: List[List[int]]) -> bool:
m, n = len(matrix), len(matrix[0])
total = sum(sum(row) for row in matrix)
if total % 2:
return False
half = total // 2
# 水平方向:按行累计
row_acc = 0
for i in range(m - 1):
row_acc += sum(matrix[i])
if row_acc == half:
return True
# 垂直方向:按列累计
col_acc = 0
for j in range(n - 1):
col_acc += sum(matrix[i][j] for i in range(m))
if col_acc == half:
return True
return False
Python 写法强调简洁,但 sum(matrix[i]) 每次调用会遍历当前行,整体复杂度仍然是 O(mn),可以接受。如果追求极致性能,可以预先把每行的和、每列的和各存到数组里,再在这两个一维数组上做累加。那是我更推荐的扩展版本,见 3.3。
3.3 更工程化的写法:先算行和与列和
如果你平时打比赛习惯把问题拆成"预处理 + 扫描"两个阶段,我建议用行和数组 rowSum 和列和数组 colSum 来写:
cpp复制class Solution {
public:
bool canSplit(vector<vector<int>>& matrix) {
int m = matrix.size(), n = matrix[0].size();
vector<long long> rowSum(m, 0), colSum(n, 0);
long long total = 0;
for (int i = 0; i < m; ++i) {
for (int j = 0; j < n; ++j) {
rowSum[i] += matrix[i][j];
colSum[j] += matrix[i][j];
total += matrix[i][j];
}
}
if (total % 2) return false;
long long half = total / 2;
long long acc = 0;
for (int i = 0; i < m - 1; ++i) {
acc += rowSum[i];
if (acc == half) return true;
}
acc = 0;
for (int j = 0; j < n - 1; ++j) {
acc += colSum[j];
if (acc == half) return true;
}
return false;
}
};
这个版本的好处是思路更容易迁移:如果后面题目要求分成三块、四块,或者出现"同时切横竖两条线"的情况,你手里有行和、列和的一维前缀数组,扩展起来非常顺手。空间复杂度从 O(1) 变成了 O(m + n),但代码的可读性和可扩展性强很多。
3.4 关于数据范围和溢出问题
这题矩阵元素的值范围题目没给得很极限,但 total 一定要用 long long,千万不要用 int。假设矩阵是 200 x 200,每个元素是 10^4,总和就是 4 x 10^8,距离 int 上限 2.1 x 10^9 还有余量;但如果数据范围到 10^9 级别,int 必炸。周赛里因为 int 溢出 WA 的例子我见过太多,这种低级失误一旦发生非常影响心态,所以所有求和相关的变量一律 long long 起步。
4. 边界条件与典型坑:最容易让人翻车的几个测试用例
4.1 总和为奇数:直接返回 false
这几乎是所有"等和分割"题目的第一条判断。总和为奇数,意味着不存在整数的一半,直接返回 false,不需要做任何扫描。这个优化在数据量大时能省不少时间。如果你漏掉这一步,代码在 total 为奇数时会进入后面的扫描,因为 half 是整除后的值,total 是奇数意味着 half * 2 != total,所以任何累计值都不可能同时满足"等于 half"和"总和等于 2 * half",最后只能返回 false。结果是对的,但多做了无用功。
4.2 切分位置不能是最后一行或最后一列
这是最隐蔽的坑。水平分割时,i 的范围应该是 [0, m-2],也就是最多累加到倒数第二行。如果 i 写成 m-1,那么 rowAccum 会变成整个矩阵的总和 total,如果 total == half,也就是 half == total,只有当 total == 0 时才成立。对于全零矩阵,这个 bug 会导致你返回 true,但切在最后一行下方得到的下半部分是空矩阵,不符合"两个非空子矩阵"的要求。
同样,垂直分割时 j 的范围是 [0, n-2],不能到 n-1。
提示:这个"非空"约束是等和分割题目的灵魂之一。做题时圈出"非空"两个字,能避免一多半 WA。
4.3 单行或单列矩阵的特殊行为
当 m == 1 时,水平分割的循环 for (int i = 0; i < 0; ++i) 不会执行,水平方向自然没有机会。这时候只能靠垂直分割,代码逻辑上没问题。同理 n == 1 时,垂直方向没机会,只能水平分割。但要注意,如果矩阵是 1 x 1,即只有一个元素,那么 m - 1 = 0、n - 1 = 0,两个循环都不会执行,最终返回 false。这是正确的,因为单个元素无论如何也没法分成两个非空子矩阵,除非元素是 0 也不行。
4.4 全零矩阵和负元素矩阵
全零矩阵的 total == 0、half == 0,意味着任意一个非空前缀子矩阵的和都是 0。只要 m >= 2 或 n >= 2,就能在第一个切分位置返回 true。比如 [[0,0],[0,0]],水平扫描第 0 行累计 0,等于 half,返回 true。这块没问题。
负元素矩阵是另一个容易出意外的地方。前面代码里我特意没在累计值超过 half 时 break,就是因为有负数的情况下,累计值可能先涨后跌,或者先跌后涨。举个例子:[[5, -3], [1, 2]],total = 5,half = 2。水平扫描:第 0 行累计 2,刚好等于 half,返回 true。但如果某一行累计过程是先跳过 half 再回来,你的 break 就会挂掉。
| 用例 | 原因 | 期望结果 |
|---|---|---|
[[]](空行) |
m > 0 但 n == 0 | 需要判空防御 |
[[5]] |
1 x 1 无法分割 | false |
[[1, 1], [1, 1]] |
水平/垂直都有解 | true |
[[1, 2, 3], [4, 5, 6]] |
手动算 total=21 为奇数 | false |
[[1, -1, 0], [0, 0, 0]] |
负元素导致累计值可能起伏 | true(第一行和=0) |
4.5 空矩阵的防御性判断
题目一般来说不会给空矩阵,但 Write 的解法第一行加个 if (m == 0) return false; 是零成本的防御。同理,matrix[0] 也可能是空行,n == 0 也要提前返回。比赛里虽然不太会遇到,但平时练习、写题解的时候养成防御性编程的习惯,面试时能少很多尴尬。
5. 从这道题延伸出去:前缀和思想与同类题目联动
5.1 "记总记当前"的方法论价值
这道题提炼出来的方法非常朴素,但适用范围极广:
- 先算总和,作为全局约束。
- 遍历过程中维护一个"当前累计"。
- 累计值一旦命中目标,立即返回。
这就是一维前缀和思想的"流式版本"。很多题你不需要真正把前缀和数组全部算出来存着,只需要一个滚动变量记录到当前位置的和就够了。什么时候需要全量数组?当你需要多次查询任意区间和的时候。什么时候只需要滚动变量?当你只需要判断"是否存在某个前缀和等于某个目标值"的时候。这道题属于后者,所以 O(1) 空间就能做。
这种"存在性问题用流式累计"的思想,在两数之和、连续子数组和、分割数组等题目里反复出现。比如"连续子数组和"那类题,如果在遍历过程中维护前缀和,并用哈希表存下之前出现过的前缀和,就能 O(n) 判断是否存在某个连续子数组和为 K。这和本题的"当前累计"是同一个套路的不同变体。
5.2 与 LeetCode 416「分割等和子集」的关系
很多人看到"等和分割"四个字,会联想到 416「分割等和子集」。这两题的核心目标完全一样——把一个集合(数组或矩阵)分成两部分,让两部分和相等。但解法完全不同:
- 416 是任意选取子集,可以跳着选元素,所以是 0/1 背包问题,需要 DP。
- 3546 是连续块分割,一边是上方连续行或左侧连续列,非此即彼,所以用累计和扫描就行。
这个对比很有启发:同样是"等和",约束不同,算法复杂度天差地别。做题时先看清楚"能不能跳着取元素"——能跳,考虑 DP / 哈希;不能跳,先想前缀和、滑动窗口。
5.3 如果题目升级成"同时切横竖两刀",你该怎么改
等和矩阵分割这个系列后面如果有 II、III,很常见的升级方向是:允许横竖各切一刀,把矩阵分成三块或四块,要求某种和的关系。这时候你手里的 rowSum 和 colSum 一维前缀数组就派上用场了。你可以枚举横切位置,然后用二分或哈希在竖切维度上快速查找,把复杂度控制在 O(m log n) 或 O(m + n) 量级。
所以在做第 I 题时,强烈建议至少手写一遍"先算行和、列和数组"的版本。即使最终 AC 的代码用的是流式累计,这一遍练习能帮你积累二维问题一维化的手感,后面遇到升级题不会慌。
6. 我的实测心得:这道题适合用来做什么
说句实话,3546 本身的难度在 LeetCode 里算中等偏下,它的思维量不大,代码量也小,但它是一道很好的"模板题"。我自己拿它做了三件事:
第一,训练边界条件敏感度。我在本地反复测了 1 x n、m x 1、1 x 1、全零矩阵、含负元素矩阵这些用例,把"非空子矩阵"这个约束刻进了脑子里。后来做其他分割类题目(比如分割数组),遇到要保证两部分非空的情况,我再也没栽过。
第二,练习从暴力到优化的推演过程。我先按暴力写了一遍,然后思考能不能用一维前缀和数组,最后再优化成滚动变量。这个推演链条很短,非常适合作为"遇到矩阵求和类题目"的标准思考路径:暴力枚举切分点 -> 前缀和数组加速区间查询 -> 观察是否只需要前缀和比较 -> 压成滚动变量。
第三,整理"等和分割"类题目的通用套路。现在遇到这类题,我的第一反应是:计算总和 -> 判断奇偶 -> 根据分割方式设计累计路径。这个套路在 416 那边不适用(因为它是任意选取),但在一切"连续块分割"的题目里都能复用。
最后分享一个小经验:周赛里遇到这种"看起来很简单但容易被细节绊住"的题,最稳的做法是先把代码写干净,不要为了耍帅写花活,然后在提交前手动跑一遍单行、单列、单元素三个用例。这三板斧能挡掉一大部分边界 WA。如果你也在这类题上吃过亏,不妨按上面这个流程重新做一遍,应该能对"记总 + 记当前"这个基本功有更深的体感。
