每周期赛之后,我都会把当周的中等题重新刷一遍,倒不是单纯为了凑数量,主要是想看看 LeetCode 又在哪些熟面孔上换了层皮。这次看到“3546. 等和矩阵分割 I”这个标题,我的第一反应是:前缀和党的送分题又来了。等和矩阵分割,核心就一句话——给你一个 m x n 的整数矩阵,判断能不能沿着某条水平或垂直的网格线把它切成两部分,使得两部分各自的元素和完全相等。从这个描述也能看出来,它本质上就是经典一维问题“分割等和子数组”的二维化,只不过把数组换成了矩阵,把“切一刀”换成了“横着切一刀或竖着切一刀”。
这道题标的是中等难度,但它没有那么多弯弯绕绕。真正值得聊的是三个点:二维前缀和的构建、矩阵按行/列压缩成一维的思路、以及边界条件的处理。如果你正在准备面试,或者刷了一段时间 LeetCode 想系统整理“矩阵分割”这类题目,这篇文章应该能帮你把这一类题的底层套路捋清楚。我也把代码实现、常见踩坑、以及从“I”延伸到“II”的可能考法都一并整理了,可以直接拿去用。
1. 这道题在问什么:等和、分割、前缀和的三角关系
1.1 从标题拆出三个关键词
先把这个题目名拆开看。“等和”指的是分割后每一块矩阵的元素和相等,“矩阵分割”指的是沿水平或者垂直方向把矩阵切开,“I”通常意味着这是一个系列题的第一问,一般只考最基础的存在性判断。
很多朋友看到“矩阵分割”会以为要用什么高深的图论或者搜索算法,其实不用。这里的“分割”不是任意形状划分,而是沿着整行或整列的边界切一刀。也就是说,水平切的时候,上半部分由前 i 行组成,下半部分由剩下的 m - i 行组成;垂直切的时候,左半部分由前 j 列组成,右半部分由剩下的 n - j 列组成。题目要求的是判断是否存在这样一个 i 或 j,使得两边和相等。
顺着这个思路往下走,核心就变成了:我怎么快速知道“前 i 行的元素和”和“前 j 列的元素和”?这正是前缀和的看家本领。你不需要真的去切一遍矩阵,也不需要每次重新逐行累加,只要预处理出一个前缀和表,后面每次判断都是 O(1) 的查表操作。
1.2 输入规模与“I”的定位
LeetCode 的题目一般会在题面里明确给出数据范围,比如 m、n 最大是多少,元素范围是什么。虽然我写这篇文章时手边没有完整题面,但从这类题型的惯例来看,m 和 n 大概率在几百到一千这个量级,元素值可能是正数,也可能包含负数,总和可能很大,所以用 long long 而不是 int 是必须的。
“I”这个后缀要特别留意。LeetCode 的习惯是:第一问通常比较简单,只要求“存在性判断”,比如“能不能做到”;第二问才开始上强度,比如“输出一种可行的分割方式”或者“所有可能的分割方式”。所以这道“I”我倾向于把它理解为:只切一刀,判断是否能切成两个等和的部分。如果你在考场上遇到它,第一时间确认这个理解没错,再动手写代码,能省下不少返工时间。
1.3 一维版:分割等和子数组,是它的一维影子
如果你刷过“分割等和子集”或者“目标和”这类题,再看这道矩阵题会觉得非常亲切。一维版本的问题描述是:给定一个数组,判断能不能找到一个位置,把数组切成左右两段,两段和相等。解法很直接:先算出总和 total,如果 total 是奇数肯定不行;然后在数组上做前缀和,看某个位置的前缀和是否等于 total / 2。
这道题所谓的“中等”,其实就在于它把一维问题包装成了二维。但包装不等于变难,它只是多了一点点东西:水平分割只看行,垂直分割只看列。换句话说,你既可以把矩阵看成 m 行的集合,也可以看成 n 列的集合,然后分别用一维的思路去判断。想明白这一层,代码写起来就非常快。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 先解决“区域和”问题:二维前缀和的构建与查询
2.1 一维前缀和到二维的推广
一维前缀和的定义很简单:pre[i] 表示数组前 i 个元素的和。要求区间 [l, r) 的和,就用 pre[r] - pre[l]。
二维前缀和的定义是一维的推广:pre[i][j] 表示从矩阵左上角 (0, 0) 到 (i-1, j-1) 这个矩形区域内所有元素的和。注意我用的是从 1 开始编号的下标习惯,这样在代码里可以省去一堆 i - 1 的判断,代码也会更不容易出错。
构建公式是:
code复制pre[i][j] = pre[i - 1][j] + pre[i][j - 1] - pre[i - 1][j - 1] + grid[i - 1][j - 1]
这个公式看着有点绕,其实你把它想成算面积就明白了:我们要计算从左上角到 (i-1, j-1) 的大矩形之和,它等于“上方矩形”加上“左方矩形”,但是左上角那块区域被加了两次,所以要减掉一次,最后再加上当前格子 grid[i-1][j-1] 这个新元素。生活化的类比就是:你手里有两块有重叠区域的拼图碎片,把它们拼在一起时,重叠的那块不能算两遍。
2.2 查询任意矩形区域和怎么做到 O(1)
构建完二维前缀和之后,查询任意子矩阵的和就很方便了。假设要查询左上角为 (r1, c1)、右下角为 (r2, c2) 的区域和,注意这里都用 1-indexed 的下标,那么公式是:
code复制sum(r1, c1, r2, c2) = pre[r2][c2] - pre[r1 - 1][c2] - pre[r2][c1 - 1] + pre[r1 - 1][c1 - 1]
为什么要加回 pre[r1-1][c1-1]?因为减掉上方矩形和左方矩形的时候,左上角那一块被减了两次,实际上它只在减一次的时候应该被扣掉,所以要多加一次回来。这个和构建公式是同一个道理,方向相反。
有了这个查询能力,理论上你可以快速判断任意一种分割方式是否满足等和条件。比如水平切在第 i 行下面,上半区域就是 (1, 1) 到 (i, n),下半区域就是 (i+1, 1) 到 (m, n),两次 O(1) 查询就能比较。
2.3 为什么这道题其实可以绕开二维前缀和
这里我要说一个稍微有点反直觉的点:如果题目只要求“切一刀”判断等和分割,根本用不上二维前缀和。
原因很简单:水平切一刀,只关心每一行的整行元素之和;垂直切一刀,只关心每一列的整列元素之和。你完全可以先把每一行求和得到 rowSum 数组,再把每一列求和得到 colSum 数组,然后把问题转化成两个一维问题:在 rowSum 中是否存在一个前缀和等于 total / 2,或者在 colSum 中是否存在一个前缀和等于 total / 2。
那为什么我还要花一整节讲二维前缀和?因为这一节的内容是“等和矩阵分割”这个系列的基础工具。如果后续题目从 I 变成 II、III,允许切多刀,或者要求输出切割方案,二维前缀和就能派上用场了。而且面试官问这道题的时候,你如果能主动把二维前缀和的思路也说出来,会显得你的知识体系更完整,而不只是会背一两个模板。
3. 核心思路:把矩阵按行/列压缩,变成两个一维问题
3.1 为什么水平分割只看行前缀和
考虑水平分割:我们在第 i 行和第 i+1 行之间切一刀,上半部分由前 i 行组成,下半部分由剩下的 m-i 行组成。因为切割线是沿着水平方向整条切过去的,所以每一行要么完全属于上半部分,要么完全属于下半部分,不可能被劈开。于是“前 i 行的元素和”就可以表示成第 1 行到第 i 行的整行和之和。
这就是“按行压缩”的核心:把 m 行各自的和提出来,形成一个长度为 m 的一维数组 rowSum。水平等和分割问题,就变成了 rowSum 数组上是否存在一个严格切点,使得左边前缀和等于 total / 2。
同理,垂直分割只看列,把 n 列各自的和提出来形成 colSum 数组,然后判断是否存在一个前缀和等于 total / 2。两个方向只要有一个成立,答案就是 true。
3.2 完整判定流程:奇偶剪枝、行扫描、列扫描
这道题的第一步永远是算总和 total,然后立刻检查 total 是否为偶数。因为两个相等的整数相加,结果一定是偶数;如果 total 是奇数,直接返回 false。这一步剪枝虽然简单,但真的很多人会漏,漏掉之后代码在某些 case 上就会给出错误答案。
接下来,行扫描和列扫描的顺序不重要,但逻辑要清晰。
行扫描的做法是:用一个变量 acc 从 0 开始累加 rowSum[0], rowSum[1], ..., rowSum[m-2]。注意这里只加到 m-2,因为切点必须在第 1 行到第 m-1 行之间产生,也就是说上半部分至少要包含一行,下半部分也至少要包含一行。如果 acc 在某一次累加后等于 half,说明找到了一个合法的水平切点,返回 true。
列扫描同理:acc 从 0 开始累加 colSum[0], colSum[1], ..., colSum[n-2],如果中途 acc 等于 half,返回 true。
如果两个方向都没找到,返回 false。
3.3 复杂度分析:O(mn) 时间,O(1) 额外空间也能写
我直接说结论:这道题的时间复杂度是 O(mn),因为无论如何你都要把矩阵里每个元素扫一遍,算出总和、行和和列和。只要矩阵本身是输入的一部分,读入都是 O(mn),所以这个复杂度是下限。
空间复杂度就有讲究了。最直观的写法是开 rowSum 和 colSum 两个数组,空间 O(m + n)。但如果想进一步压缩,可以先扫一遍矩阵,只维护一个行前缀和变量,在扫描过程中完成水平判断;然后再扫一遍矩阵,维护列前缀和变量,完成垂直判断。这样额外空间是 O(1)。不过我感觉没有太大必要,LeetCode 的内存限制一般很宽裕,O(m+n) 已经是足够好的写法,代码也更清晰。
下面把两种写法的复杂度整理一下,方便你对比:
| 方案 | 时间复杂度 | 额外空间 | 适用场景 |
|---|---|---|---|
| 二维前缀和 + 枚举切点 | O(mn) | O(mn) | 后续要扩展多刀、输出方案 |
| rowSum / colSum 数组 | O(mn) | O(m+n) | 本题标准解法,清晰直观 |
| 滚动变量两次扫描 | O(mn) | O(1) | 对空间有极端要求时 |
我个人建议:平时练习用 rowSum / colSum 数组的写法就够了,面试时也能讲明白。二维前缀和属于进阶储备,如果题目要求输出所有分割位置,再搬出来也不迟。
4. 代码实现与边界条件:从 C++ 到暴力校验
4.1 最直接的实现
虽然前面讲了二维前缀和的原理,但这里我给出的核心代码是更贴合本题的 rowSum / colSum 版本,因为代码量小、思路直接,也能避免二维前缀和下标的边界问题。
cpp复制class Solution {
public:
bool canPartition(vector<vector<int>>& grid) {
int m = grid.size();
if (m == 0) return false;
int n = grid[0].size();
long long total = 0;
vector<long long> rowSum(m, 0), colSum(n, 0);
for (int i = 0; i < m; ++i) {
for (int j = 0; j < n; ++j) {
total += grid[i][j];
rowSum[i] += grid[i][j];
colSum[j] += grid[i][j];
}
}
if (total % 2 != 0) 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;
}
};
这里有个很重要的细节:循环条件里的 m - 1 和 n - 1。假设矩阵只有一行,那么水平切没有任何合法切点,因为上半部分至少要有一行,下半部分也至少要有一行,一行矩阵切不出两个非空的行块。同理,一列矩阵垂直切也没有合法切点。所以循环条件写成 i < m - 1 和 j < n - 1 是准确的。
4.2 不用额外数组的滚动写法
如果你想让代码更精简,或者面试时想展示自己空间优化能力,可以写成两次扫描:
cpp复制class Solution {
public:
bool canPartition(vector<vector<int>>& grid) {
int m = grid.size();
if (m == 0) return false;
int n = grid[0].size();
long long total = 0;
for (int i = 0; i < m; ++i) {
for (int j = 0; j < n; ++j) {
total += grid[i][j];
}
}
if (total % 2 != 0) return false;
long long half = total / 2;
long long acc = 0;
for (int i = 0; i < m - 1; ++i) {
for (int j = 0; j < n; ++j) {
acc += grid[i][j];
}
if (acc == half) return true;
}
acc = 0;
for (int j = 0; j < n - 1; ++j) {
for (int i = 0; i < m; ++i) {
acc += grid[i][j];
}
if (acc == half) return true;
}
return false;
}
};
这种写法的优点是额外空间 O(1),缺点是把矩阵扫了两遍,总操作次数比第一种多一倍,但量级还是 O(mn)。如果矩阵很大、元素很多,第一种写法性能更好;如果内存紧,第二种写法更安全。LeetCode 上两种都能过,看你偏好。
4.3 边界条件与测试用例
我把几个容易踩的边界条件单独拎出来说,因为这些地方才是这道题真正的扣分点。
第一,total % 2 != 0 的判断不能省。有人会问,如果 total 是负数怎么办?比如 total = -4,half = -2,这是合法的,因为两个整数相加得到 -4,可以分别是 -2 和 -2。C++ 里 -4 % 2 == 0,所以没问题。但如果 total = -3,-3 % 2 != 0,返回 false,也正确。总之这个判断对正负都成立。
第二,切点不能在最外侧。我见过有人把循环写成 i < m,这样 i = m-1 时,acc 等于 total,而 half 是 total/2,只有当 total=0 时才可能误判。比如全零矩阵,total=0,half=0,如果循环到 i = m-1,acc 变成 0,就返回 true,但这时候上半部分其实是整个矩阵,下半部分是空的,不合法。所以必须限制 i < m - 1。
第三,用 long long 而不是 int。注意元素是 int,但累加和可能超过 int 范围。比如矩阵是 1000x1000,每个元素是 1e9,总和就是 1e18,int 早就爆了。虽然 LeetCode 不一定会给这么极端的数据,但养成用 long long 的习惯能省很多麻烦。
我随手列几个测试用例,方便你本地验证:
- 输入
[[1, 1], [1, 1]],total=4,half=2,行扫描时 i=0 累加 rowSum[0]=2,等于 half,返回 true。这个矩阵横切或竖切都行。 - 输入
[[1, 2, 3], [4, 5, 6]],total=21,是奇数,直接返回 false。这个一眼就能看出来,怎么切都不可能左右相等。 - 输入
[[1, 2], [3, 4]],total=10,half=5,行前缀和是 1、6,没有 5;列前缀和是 1、6,也没有 5,返回 false。 - 输入
[[1, -1], [2, -2]],total=0,half=0,行扫描时 i=0 累加 rowSum[0]=0,立即返回 true。实际上横切后上半部分[1, -1]和为 0,下半部分[2, -2]和为 0,正确。 - 输入
[[0, 0]],total=0,half=0,水平方向只有一行不能切,垂直方向 j=0 累加 colSum[0]=0,返回 true。竖切后左半[0]和右半[0]和相等,正确。
5. 扩展:如果题目从 I 变成 II,思路要怎么长出来
5.1 多刀全水平/垂直切割:前缀和集合判断
既然题目叫“等和矩阵分割 I”,那我猜大概率会有“II”。如果第二问是“能不能通过多次水平切或多次垂直切,把矩阵分成若干块,每块和相等”,思路可以这样延伸:
假设要切成 k 块,每块和都是 target,并且 total = k * target。如果限制只沿水平方向切,那么问题就变成:在 rowSum 数组上,是否存在 k-1 个切点,使得每个区间的和都等于 target。这等价于检查前缀和中是否依次出现 target, 2*target, ..., (k-1)*target。你可以用一个集合存下所有前缀和,然后逐个检查这些目标值是否在集合里。
这里有个小坑:元素可能为负数,所以前缀和不是单调的。你不能在看到前缀和超过某个目标值后就停止扫描,必须把整个数组扫完,用哈希表存所有出现过的前缀和。如果元素都是非负数,倒可以边扫边判断,一旦超过目标值就剪枝,效率更高。
5.2 输出具体切割方案
如果第二问除了判断,还要求输出切割方案,那么你在扫描的时候要记录下命中 half 的位置。比如水平切割命中在 i 位置,就记录下行号 i+1,表示在第 i+1 行下方切;垂直切割同理,记录下列号 j+1。
这里需要注意输出格式。LeetCode 一般会用二维数组或者字符串表示切割位置,具体看题面要求。但核心逻辑不变:在判断等和的同时,把满足条件的切点存下来,最后返回。如果要求所有方案,那就不能提前返回,要完整扫描两个方向。
5.3 混合切割与矩形划分的难度跳跃
真正的难度跳跃来自“混合切割”——既可以水平切,也可以垂直切,最后要求所有小矩阵和相等。这种题就不再是简单的一维前缀和了,因为切第一刀之后,第二刀可以作用在子矩阵上,情况会呈指数级增长。你要是遇到这种题,大概率要用 DFS 加记忆化搜索,甚至要配合哈希映射来压缩状态。
不过从 LeetCode 的命名习惯来看,“等和矩阵分割 I”作为系列的第一题,不太可能直接上这种复杂度。中等难度对应的是今天我讲的单刀切法,多刀和混合切割才是 I 的后续考法。刷题的时候先把基础版吃透,再往复杂方向扩展,节奏是对的。
6. 实战踩坑与面试要点
6.1 三个最容易写错的边界
我在本地把这道题跑了好几遍,总结了三个最容易翻车的点,基本覆盖了大部分人第一次提交的报错原因。
第一个是奇偶判断的位置。有人会在没有算完 total 之前就想当然地判断,或者干脆忘了判断。如果 total 是奇数,你后面所有扫描都是白费的,不仅浪费时间,还可能因为 half 是小数导致 acc == half 永远不成立,返回错误的 false。
第二个是行列下标颠倒。矩阵是 m 行 n 列,rowSum 的长度是 m,colSum 的长度是 n。有些人写 for (int i = 0; i < n - 1; ++i) { acc += rowSum[i]; },直接把行数组用列数去遍历,一旦 m 和 n 不相等就出界,或者漏掉一些行。这种低级错误在 LeetCode 上是编译或者运行时报错,排查起来其实很费时间。
第三个是忽略单行单列的特殊情况。比如输入 [[1, 2, 3]],这是一个 1x3 的矩阵。水平方向没有切点,但垂直方向如果 total 是偶数,可能可以切。如果你在代码里直接对行方向做循环,没有限制 i < m - 1,当 m=1 时循环条件 i < 0 不成立,不会出错;但如果你写成 i <= m - 1,就会在 i=0 时把整个矩阵当成上半部分,可能误判。所以我建议所有涉及切点的循环,统一写成 i < m - 1 这种左闭右开的形式,不容易出问题。
6.2 用暴力 checker 验证正确性
如果你手边有 LeetCode 的测试环境,直接提交就能看到结果。但如果你想在本地做更充分的验证,我建议写一个暴力版本做对照:枚举每一刀的位置,每次切割都用双层循环重新累加左右两部分的和,然后比较。这个暴力版本的时间复杂度是 O(m^2 n + m n^2),只适合小规模数据,但用来验证边界非常合适。
你可以在本地随机生成一些小矩阵,矩阵元素包含正数、负数和 0,然后同时跑暴力版和优化版,对比结果是否一致。这种方法在刷动态规划题时我也经常用,它能帮你快速定位边界条件里的逻辑漏洞,比对着测试用例一个个猜要高效得多。
我个人的习惯是:先把暴力版写出来,再用优化版去对拍,对拍通过之后再上 LeetCode 提交。虽然这道题比较简单,但好的验证习惯能让你在复杂题上少走很多弯路。
6.3 面试官想听到的答题结构
如果这道题出现在面试里,面试官大概率不只想看你写代码,而是想听你的分析过程。一个比较稳妥的回答结构是:
先确认题目的约束条件,比如 m、n 的范围、元素是否可能为负、切割是否允许空块。然后说核心思路:把矩阵按行和列压缩成一维数组,问题转化为两个一维前缀和判断。接着讲复杂度:O(mn) 时间和 O(m) 或 O(n) 的空间。最后再提一嘴边界的处理:total 为奇数直接返回 false,切点必须在内部而不能是最外侧。
如果能把这个流程顺畅地讲出来,面试官对你的评价会明显高于那些上来就闷头写代码的候选人。这道题本身不复杂,但它考察的是你面对一个二维矩阵问题时,有没有能力把它简化成已经熟悉的问题。这种抽象和归约能力,才是面试真正想看的。
最后再分享一个小体会:我做完这道题之后,把代码改成了可以输出具体切割位置的版本,发现其实只多了一个变量的事情。很多“II”版本的题目,都是在“I”的基础上多问一句“怎么切”,而这一问往往只需要你在判断时顺手记录一下位置。所以把基础版本的每一步都吃透,后面遇到变体就不会慌。
