1. 题目拆解:先分清统计对象与评判标准
1.1 从文字描述到数学条件
2026年3月19日的LeetCode每日一题是第3212题:统计X和Y频数相等的子矩阵数量。题目背景很简单,给定一个二维矩阵,矩阵中每个格子可能是字符'X'或者'Y',要求统计出所有满足"X的数量等于Y的数量"的子矩阵个数。这里的关键是"子矩阵"的边界定义:子矩阵必须是原矩阵中连续行、连续列围成的矩形区域,不能跳行跳列取。
这类题第一次见到容易懵,因为"统计数量"这个动作本身不难,难在数据规模上。LeetCode平台给的约束一般是矩阵行列数在几十到几百之间,如果直接用四重循环枚举左上角和右下角,再遍历内部所有元素做统计,计算量会迅速膨胀到无法接受的程度。我见过不少同学第一次提交暴力版本之后,看到超时提示才意识到问题没这么简单。
这里值得先聊一个很核心的直觉:题面里说的是"X的频数等于Y的频数",翻译成数学语言其实是"X的个数减去Y的个数等于0"。因为子矩阵里只有X和Y两类有效字符,我们完全可以给X赋值为+1,给Y赋值为-1,那么"两者数量相等"就等价于"整个子矩阵的元素和等于0"。这个转换看起来只是换了一种记录方式,但它把字符串比较问题变成了数值统计问题,后续所有高效算法都建立在这一个看似不起眼的等价变换上。
1.2 为什么"X和Y相等"能转化为"和为零"
用一个具体的小例子来感受一下。假设有一个2行2列的矩阵:
X Y
X X
如果按原样数,左上角那个1x1子矩阵是单个'X',X频数是1、Y频数是0,不相等。但如果我们把X看作1、Y看作-1,这个子矩阵的元素和就是1,不是0,结论一致。再看整个2x2矩阵,X有3个、Y有1个,不相等,对应元素和是1+(-1)+1+1=2,也不是0。
那么什么样的子矩阵会被计入统计?比如在一行一列这种极端情况下,一个只有X和Y各一个的1x2子矩阵,X频数1、Y频数1,相等,映射成数值就是1+(-1)=0,和为0,计入统计。所以"X频数等于Y频数"和"子矩阵元素和为0"是同一件事的两种说法。
这个等价变换的价值在于:数值和可以前缀和预处理,而字符频数对比不能直接前缀和。只要预处理出二维前缀和矩阵,任何矩形区域的元素和都能在O(1)时间内算出来,不需要再逐个格子数X和Y。当然,前缀和只是第一步,因为即便单次查询是O(1),要枚举所有子矩阵本身也有O(m²n²)种可能,这个数量级依然很大。这个问题留下的核心矛盾是:如何在不枚举所有子矩阵的前提下,借助"和为0"这一条件快速统计数量。后面几节会一步步把这个矛盾拆开。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 暴力到二维前缀和的演化:性能瓶颈在哪里
2.1 第一版暴力:枚举所有矩形
先来看看最简单的暴力思路,这有助于理解后面优化到底优化了什么。暴力方案分为两层:第一层用四个变量i1、j1、i2、j2分别表示子矩阵左上角和右下角的坐标,第二层遍历这个矩形中的所有格子统计X和Y的数量,最后比较是否相等。如果矩阵尺寸是m行n列,左上角坐标有m×n种可能,右下角坐标在左上角确定后也有O(mn)种可能,所以枚举矩形本身是O(m²n²)的。每个矩形内部再遍历一次,复杂度再乘一个O(mn),总复杂度高达O(m³n³)量级,这还没算比较频数的常数操作。
如果m和n都等于100,100³×100³这个数量级简直是天文数字,现代计算机也不可能在几秒内跑完。即使把内部遍历换成二维前缀和,把单次矩形求和降到O(1),枚举所有矩形的O(m²n²)依然让人头疼。m=200、n=200时,m²n² = 16亿,16亿次O(1)查询在C++里也许勉强能卡线,在Python里几乎不可能通过。
这里也解释了为什么很多人第一版代码写完,自己本地试了3x3、4x4的小矩阵没问题,一提交就超时。不是代码写错了,而是算法复杂度选错了。刷题和业务开发的一个共同点是:先估算数据规模,再决定技术方案,而不是上来就写循环。
2.2 二维前缀和的真正瓶颈:枚举次数而非计算成本
先花一点篇幅把二维前缀和的思路整理清楚,因为后面优化的起点就是它。设矩阵M的行索引从0到m-1,列索引从0到n-1。定义前缀和数组pre[i][j]表示从(0,0)到(i,j)这个矩形区域内所有元素的和。递推公式是pre[i][j] = pre[i-1][j] + pre[i][j-1] - pre[i-1][j-1] + M[i][j],这个公式的本质是"左上角矩形加右上角矩形减重复的左上角子矩形,再加当前格子"。有了pre之后,任意子矩阵(i1,j1)到(i2,j2)的和就等于pre[i2][j2] - pre[i1-1][j2] - pre[i2][j1-1] + pre[i1-1][j1-1]。
这是一个标准的容斥原理。但我要强调的是,二维前缀和解决的是"查询成本"问题,不是"枚举数量"问题。对一个300x300的矩阵,合法子矩阵数量大约是(300×301/2)² ≈ 20亿,这个数字太大了,无论单次查询多快,逐个枚举都不现实。所以真正的突破口必须落在减少枚举次数上,而不是继续优化单次求和的计算速度。有些题解只讲前缀和,不讲枚举降维,读者跟着写一遍还是会超时,原因就在这里。
3. 破题关键:行区间枚举 + 列前缀哈希
3.1 一维子数组和为0的统计套路
要理解本题的高效解法,可以先从一维问题入手。假如给你一个长度为n的数组a,a里只有1和-1,要统计有多少个连续子数组的元素和等于0。经典做法是:遍历数组的同时维护一个当前前缀和cur,同时用哈希表记录"某个前缀和值出现过多少次"。当当前位置的前缀和是cur时,如果之前某个位置的前缀和也是cur,那么这两个位置之间的子数组和就是0。因此每遇到一个cur,就累加哈希表中cur对应的次数,再把当前cur的次数加1。
举个例子,数组是[1, -1, 1, -1],前缀和序列依次为1、0、1、0。开始哈希表为空,cur=1时没有匹配,记录{1:1};cur=0时哈希表里没有0,记录{0:1};cur=1时哈希表里有1的存在1次,答案加1,对应子数组[1,-1]?其实对应的是从第2个元素到第3个元素这段区间,也就是原始数组下标1到2的[-1,1]?需要仔细推敲下标关系。更严谨地讲,哈希表记录的是"从数组头到某个位置j的前缀和",当遍历到i时如果cur和之前某个cur相同,说明j+1到i这一段区间和为0。这是前缀和哈希的通用套路,在LeetCode第560题"和为K的子数组"里也有同样的思想,只是那里哈希表存的是cur - K。
这个一维套路是整道题的基石,因为二维问题最终会被压缩成一维问题来求解。
3.2 扩展到二维矩阵的核心映射
现在回到二维矩阵。高效解法的思路是把二维降成一维:枚举子矩阵的上边界r1和下边界r2,然后考察每一列在r1到r2这个行区间内所有格子的和。这样每一列会得到一个值colSum[j],它表示第j列从上边界到当前下边界的"X减Y的差值"。如果子矩阵的上下边界已经确定为r1和r2,那么任意一个从第c1列到第c2列的子矩阵,其元素和就等于colSum[c1] + colSum[c1+1] + ... + colSum[c2]。这正是"一维子数组和"的形式。
于是整个问题就变成了:对于每组(r1, r2),把colSum数组看成新的"一维数组",统计其中有多少个子数组和为0。这个统计用3.1节的哈希法做,复杂度O(n)。由于上下边界需要两层循环枚举r1和r2,总计O(m²)对边界,每对边界内做一次O(n)的哈希统计,总复杂度O(m²n)。
这个复杂度看起来还是立方级别,但m和n如果都在200以内,200²×200 = 8,000,000,也就是八百万次操作,Python完全能跑下来。如果题目放宽到1000×1000,这题可能就要换思路了,但一般每日一题不会给到那么极端的规模。
实际写代码时,外层枚举上边界r1,内层枚举下边界r2,但colSum不需要每次重新计算。可以这样维护:固定r1后,初始化一个长度为n的全零数组col;每扩张一次下边界r2,就把矩阵第r2行的每个格子的值加到col[j]上。这样col[j]始终表示"第j列在r1到当前r2行区间内的累加和"。这个增量更新的技巧让colSum的构建成本从O(n)降到每行O(n),总构建成本仍然是O(m²n),但代码更简洁且不容易出错。
3.3 时间复杂度与空间开销推算
关于时间复杂度的直观感受,可以做一个简单的规模推演。假设矩阵是m=200行、n=200列。
- 外层循环上边界r1:200种。
- 内层循环下边界r2:平均约100种(实际是从r1到m-1,平局约100,精确点是均值约100)。
- 每对边界内,哈希统计过程中遍历n列:200。
总操作量约为200×100×200=400万,考虑到哈希表常数比较大,实际运行时间在Python里大概几十毫秒到一百毫秒,完全在LeetCode的时限内。
- 空间开销:col数组长度n,哈希表在最坏情况下会记录不超过n+1个不同的前缀和值,所以空间复杂度是O(n)。这个空间开销很小,几乎不需要额外担心。
如果细心一点还会发现,这个算法的复杂度不对称,是O(m²n)。当矩阵是"宽扁"型(行数少、列数多)和"瘦高"型(行数多、列数少)时,性能差异很大。假如m=200、n=2000,复杂度高达200²×2000=8000万,虽然还能跑但已经偏慢。如果反过来m=2000、n=200,复杂度是2000²×200=8亿,Python会明显吃力。所以一个很实用的优化是:遍历前比较m和n,如果m > n,就把矩阵转置,让行数m变小,列数n变大。这个优化在实现上只需要加三行代码,收益却可能巨大,后面我会在变形与拓展部分再展开说。
4. 完整代码实现与提交心得
4.1 Python实现
这题用Python实现非常直观,完整代码如下:
python复制from typing import List
class Solution:
def numberOfSubmatrices(self, grid: List[List[str]]) -> int:
m = len(grid)
n = len(grid[0])
ans = 0
# 预处理:把字符矩阵转成数值矩阵
val = [[0] * n for _ in range(m)]
for i in range(m):
for j in range(n):
if grid[i][j] == 'X':
val[i][j] = 1
elif grid[i][j] == 'Y':
val[i][j] = -1
# 枚举行上边界
for top in range(m):
col = [0] * n # 当前上边界下,每列在[top, bottom]区间的累加和
for bottom in range(top, m):
# 把第bottom行累加进col
for j in range(n):
col[j] += val[bottom][j]
# 一维前缀和哈希统计:有多少个子数组和为0
# 注意初始时前缀和为0出现过一次(表示空区间)
prefix_count = {0: 1}
cur = 0
for j in range(n):
cur += col[j]
if cur in prefix_count:
ans += prefix_count[cur]
prefix_count[cur] = prefix_count.get(cur, 0) + 1
return ans
核心逻辑就两个循环块:外层两个for枚举top和bottom,内层一个for累加列和并做哈希统计。代码量不大,但每行都有明确的职责。val矩阵的预处理不是必须的,也可以在累加时直接判断grid[bottom][j]的字符,但预处理成数值会让主循环更干净,也能减少一些重复的字符比较。
4.2 踩坑复盘:边界条件与初始哈希值
这个代码里最容易踩的坑,是prefix_count的初始值。很多人写一维前缀和哈希时习惯初始化为{0:1},代表一个虚拟的空前缀。这个初始值在"统计子数组和为0"的场景下非常关键,因为第一个col[j]如果是0,它需要和空前缀配对,形成一个长度为1的子数组;如果没有初始的{0:1},就会漏掉所有从第0列开始的合法子矩阵。
用一个小例子验证:假设col = [0, 1, -1]。正确统计应该是:子数组[0]和为0,[1,-1]和为0,整个[0,1,-1]也是0,所以答案是3。代码执行过程:
- 初始prefix_count={0:1},cur=0。
- j=0,cur += 0 = 0,prefix_count[0]=1,所以ans=1。
- j=1,cur=1,prefix_count里没有1,ans不变。
- j=2,cur=0,prefix_count[0]=1,ans加1变成2。
这里少算了一个?实际上[0,1,-1]对应的是整个数组,cur在j=2时又变回0,它应该匹配的是"空前缀"?不对,空前缀匹配的是[0,1,-1],已经在j=2时被计数了。而[1,-1]对应的前缀和是cur在j=1时为1、j=2时回到0?让我重新理一遍。一维哈希统计的逻辑是:当前前缀和cur如果在之前某个位置出现过,那么从那个位置的下一个元素到当前位置的区间和为0。遍历到j=2时cur=0,而prefix_count[0]当前等于1(来自初始的空前缀),说明可以匹配空前缀,对应区间从第0个元素到第2个元素,即[0,1,-1]。但为什么[1,-1]没有计数?因为[1,-1]开始于下标1,结束时下标2,对应的前缀和是cur_之前?在j=1时cur=1,j=2时cur=0,这两个值不相等,匹配不上?这里似乎错位了。
重新验证一维[0,1,-1]的正确子数组:下标0到0是[0],和为0,计数;下标1到2是[1,-1],和为0,计数;下标0到2是[0,1,-1],和为0,计数。所以答案应为3。
用代码走一遍:初始prefix_count={0:1},cur=0。
- j=0:cur=0,查prefix_count[0]=1,ans+=1;prefix_count[0]变为2。
- j=1:cur=1,查prefix_count[1]不存在,ans不变;prefix_count[1]=1。
- j=2:cur=0,查prefix_count[0]=2,ans+=2,ans=3;prefix_count[0]变为3。
这里j=2时prefix_count[0]=2分别对应两个位置:空前缀(下标-1)和下标0(前缀和为0)。匹配空前缀得到整个数组[0,1,-1],匹配下标0得到从下标1到下标2的区间[1,-1]。两个都能计数,所以答案3,正确。我前面手动推演时漏了prefix_count[0]更新为2这一步,其实代码逻辑是对的。这个例子也很好地说明了初始{0:1}不是可选项,而是必需品。
另外一个容易忽略的点是:哈希统计必须在累加col之后立刻进行,不能先累加完所有列再统计。因为col[j]是动态的,每换一个bottom值col都会变化,必须在当前bottom下完成一整轮哈希统计,然后才进入下一个bottom循环。如果把这个顺序写反,统计的就是错误列区间的值。我有一个朋友第一次写这题时,把col累加和哈希统计拆成了两个独立的循环块,导致同一个bottom值下重复统计了多次,答案直接翻倍,调试了很久才对上。这是很典型的循环嵌套顺序问题。
4.3 与同类题目的对比(1074、560)
这题和一维前缀和的经典题LeetCode 560"和为K的子数组"在思想上同源,就是"当前前缀和cur如果在哈希表里能找到cur-K,就累加对应的出现次数"。本题中K=0,所以找的是当前前缀和cur本身。
和LeetCode 1074"元素和为目标值的子矩阵数量"相比,1074是要求子矩阵元素和等于一个给定值target,做法也是枚举行上下边界+列前缀哈希,只是哈希表里查找的是cur-target而不是cur。所以3212、1074、560这三道题其实是同一个思想在不同维度上的变体:560是一维基础版,1074是二维和为目标值版,3212则是二维特化为目标值0并且矩阵只有两个符号。刷题时能把这些题串起来理解,比单刷一道题收获大得多,因为核心技巧"前缀和+哈希表优化枚举"是竞赛和面试中高频出现的套路。
5. 当题目变形时:这套思路还能怎么用
5.1 列越多越慢,转置优化
前面提到过复杂度O(m²n)不对等,这里给出具体的转置优化代码。思路是在进入主循环前判断m和n的关系:
python复制if m > n:
# 转置,让行数尽量小
grid = [list(row) for row in zip(*grid)]
m, n = n, m
为什么转置有效?因为复杂度上m是平方项,n是一次项,所以让m变小、n变大的收益非常明显。比如原始矩阵是500行100列,直接跑复杂度约500²×100=2500万;转置后变成100行500列,复杂度约100²×500=500万,快了五倍。这个技巧在很多二维矩阵枚举题里都有用,不只是这一题。需要注意的是,转置也会改变字符位置,但由于本题对字符种类不敏感,只统计X和Y的频数差值,转置后答案不变。
5.2 统计"X至少比Y多k个"的变体
如果把题目改成"统计X的频数减去Y的频数等于k的子矩阵数量",方法几乎一模一样,只是哈希表里查找的值从cur变成cur-k。实际上哈希表里存的就是所有前缀和值的出现次数,查找cur-k代表"是否存在某个前缀和,使得当前位置前缀和减去它等于k"。如果改成"X频数大于Y频数"这种不等式条件,就不能直接用等值哈希了,通常要借助树状数组或平衡树维护前缀和的有序集合,复杂度会上升到O(m²n log n),这属于进阶扩展方向,面试中问到基本属于加分项。
还有一个我在实际刷题中遇到的变形:矩阵里除了X和Y还可能包含'.',并且要求'X'和'Y'的频数都大于0,同时相等。这种题要额外维护X的数量信息,不能只用一个整数值表示X和Y的差值,因为X>0且Y>0意味着X和Y的绝对频数都不能为0,和差值为0并不能保证两者都大于0。比如一个只有X没有Y的子矩阵,差值可能为0?不会,只有X没有Y,差值等于X的数量,只要X数量不为0差就不为0。反过来只有Y没有X,差值为负。所以如果矩阵只包含X和Y,差值等于0且至少有一个元素,就自动保证两者数量相等且都大于0(除非空矩阵)。但如果引入'.'字符,需要处理'.'不计入频数的情况,这时差值等于0并不能排除X=0且Y=0的极端情况,即全是'.'的子矩阵也会被统计进去。这一点必须小心,也是很多扩展版本题目专门设的坑。
5.3 竞赛视角下的进一步降维思考
如果遇到m和n都是1000甚至更大的数据规模,O(m²n)可能就不够了。进一步的优化思路是利用矩阵的特殊性:本题只有X和Y两个符号,如果把X看作1、Y看作-1,求子矩阵和等于0,本质上是在二维平面上做"零和矩形"查询。这时候可以用到"行前缀和压缩 + 扫描线 + 哈希表"的组合,甚至在某些特殊限制下用FFT?实际上更常见的优化是分块或者结合数据结构加速上下边界的枚举,但在笔试题阶段通常用不到。
个人建议是先把O(m²n)的写法练到熟练,因为它在m、n≤500的场景下都非常够用。这类题考察的核心往往不是更高级的数据结构,而是你能不能想到"枚举行区间、把二维压成一维"这个降维手段。面试官看重的是思路的清晰度:先做数值映射,再前缀和,再哈希优化。每一步都有明确的动机,说清楚为什么这么做,比写出一段复杂但难调的代码更有说服力。
6. 实测中的意外与日常刷题建议
6.1 本地测试小用例的核对方法
代码写完不要急着提交,先在本地用小规模用例做正确性验证。我常用的验证方式是构造一个3x3的小矩阵,手工列出所有子矩阵,然后用暴力代码和优化代码各跑一遍,对拍结果。一个比较典型的验证用例:
X Y X
Y X Y
X Y X
这个矩阵里'X'和'Y'都是5和4?实际上有5个X、4个Y,不是等频,但子矩阵里有很多是等频的。暴力枚举所有36个子矩阵后可以得到正确答案,再和优化代码对比。如果两边答案一致,再换一个随机生成的更大矩阵继续对拍几轮,可以大幅降低写错公式的概率。这种对拍习惯在刷任何涉及矩阵计数的题目时都值得保留,因为这类题往往有很多边界子矩阵,例如单行单列、全X无Y、全Y无X等,手算容易漏,对拍能兜底。
6.2 我在提交过程中踩过的真实坑
第一次提交时,我写的版本在枚举上下边界的时候,没有在每次top循环开始新建col,而是复用了上一轮的col数组,结果导致top改变后残留了旧数据,答案比预期大很多。排查了很久才发现,col必须重置为全零数组,因为上边界不同,累加的起始行就不同,上一轮的数据必须清空。后来我习惯在代码里直接写col = [0] * n而不是col.clear()再累加,这样更保险。另一个坑是前缀和的哈希表统计目标搞反,一开始我写成统计cur等于0的次数,但实际上应该统计每个位置cur在前面出现的次数,这个"等值前缀和"和"前一个位置前缀和"的区别很容易绕晕,建议手推一遍前面3.1节的例子再写代码。
6.3 刷题笔记的沉淀方式
这类每日一题的文章,我的习惯是记录四个维度:题目本身的数学抽象、至少两种解法思路、本次提交的最优代码、踩过的具体坑。记录时不要只复制官方题解,而是用自己的话重写一遍,特别是复杂度推演和边界条件。这样过一个月翻出来再看,能快速回忆起当时的思考过程,而不是看着一个陌生代码发愣。对于3212这题,核心要沉淀的是"X和Y映射成±1、差值0转为和为0、枚举行区间、列前缀哈希"这四个步骤,它们可以复用到很多类似题目中。
