1. 拆解“子矩阵最小绝对差”:光看名字就能定算法方向
这题具体文字我拿到的版本大意是:给你一个 m×n 的整数矩阵 grid,再给两个整数 k、t,要求所有大小为 k×t 的子矩阵里,内部最大值与最小值之差的最小值。如果题面表述和你那边略有出入,也别急着关掉页面,因为真正值钱的不是这一道题的具体答案,而是怎么从名字里一眼锁定考点。这一节我先把拆题过程完整走一遍。
1.1 先确认题面里的“绝对差”是哪个定义
很多人在 LeetCode 上看到“绝对差”三个字,脑子里的第一反应是 abs(a - b),然后就开始想是不是要对相邻元素做差。实际上在“子矩阵最小值”这种题里,极少数会把绝对差定义成相邻格子的差,因为那样子矩阵的范围约束就没有意义了,算法重心也会完全跑偏。
绝大多数竞赛场景下,“子矩阵的绝对差”指的是这个子矩阵范围内的:最大值(max) - 最小值(min)。之所以叫“绝对差”,是因为不管这组数正负混合还是全是负数,max 一定不小于 min,所以 max - min 天然非负。这里不需要再包一层 abs,包了反而是多余的。
如果你的题面确实写的是“相邻元素绝对值差”,那考察方向会转向别的东西,我第四节会专门讲这种变体。咱们先把主流的固定窗口极值差模型吃透。
1.2 每个词都在缩减候选集合
“子矩阵”三个字听起来范围巨大,实际上矩阵里所有子矩阵的数量是 O(n² · m²) 这个量级。拿一个 100×100 的矩阵来算,子矩阵数量大约是 (100×101/2)²,也就是 2550 万左右,听起来好像勉强能撑住;但一旦矩阵边长到 1000,数量直接膨胀到 2.5×10¹¹,暴力枚举就彻底没戏了。
这题限定的是所有 k×t 子矩阵,尺寸是固定的。固定尺寸意味着候选子矩阵只有 (m-k+1)(n-t+1) 个,数量是 O(nm)。数量骤降之后,剩下的问题就变成了:怎么快速求出每个固定矩形窗口内的最大值和最小值。
到这里,算法方向其实已经被名字锁死了:先求滑动窗口最值,再做差比较。
1.3 为什么难度卡在“中等”而不是“简单”或“困难”
如果是让你求数组中某个固定长度子数组的最大值最小值,那是一维滑动窗口,属于 LeetCode 239 那种基础题,难度顶多算个中等偏简单。但一旦把数组换成矩阵,事情就微妙了:
- 你需要处理的是二维矩形窗口;
- 行方向上有滑动窗口,列方向上也有滑动窗口,两个方向必须同时考虑;
- 朴素的
O(nm·k·t)方法会超时,必须把一维单调队列扩展成二维形式; - 扩展过程非常容易下标出错,窗口边界错一格,答案就是错的。
所以这题难度放在“中等”非常合理:思路层面并不复杂,难在实现细节。下面我会把“为什么二维最值能分两次压缩”这件事讲透,这是整个解法的地基。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 二维矩形最值的核心套路:先横向压一遍,再纵向压一遍
如果面试或周赛复盘时有人问我这类题的通用解法,我通常只给一句话:先对每一行求横向窗口的最值,得到一个压缩后的矩阵;再对这个矩阵的每一列求纵向窗口的最值,得到的就是每个矩形窗口的最值。这句话很多人听过,但不知道为什么对,代码一写就开始乱。
2.1 一维单调队列:窗口最大值是怎么做到 O(n) 的
一切二维滑动窗口最值题,追到根上都是同一个一维模型:给定数组 arr 和窗口宽度 w,求每个长度为 w 的滑动窗口里的最大值。
暴力做法是每个窗口扫一遍,复杂度 O(n·w)。单调队列的做法巧妙在:它维护了一个“候选项队列”,这个队列从队头到队尾,存的元素值严格递减(求最大值时)。新元素进来时,如果队尾元素比新元素小,那队尾元素就永远不可能成为未来窗口的最大值了,直接弹出;同时,队头位置如果已经滑出当前窗口,也要弹出。
这个过程可以类比成排队买票:窗口里本来有一个身高最高的人,你只需要盯着他即可。但如果来了个更高的人,前面那些比他矮的都不可能再当队头了,因为更高的新人又晚来又更强;那些已经离开窗口范围的人,自然也没有资格继续排队。
关键点在于每个元素最多入队一次、出队一次,所以均摊复杂度是 O(n),而不是看起来的 O(n·w)。代码骨架如下:
python复制from collections import deque
def row_window_max(row, w):
dq = deque()
res = []
for i, v in enumerate(row):
# 1. 清除已经滑出窗口的队头
while dq and dq[0] <= i - w:
dq.popleft()
# 2. 清除不可能再成为最大值的队尾
while dq and row[dq[-1]] <= v:
dq.pop()
# 3. 新元素入队
dq.append(i)
# 4. 窗口长度达到 w 后开始记录结果
if i >= w - 1:
res.append(row[dq[0]])
return res
这里有个细节值得单独强调:求最小值的版本,只需把第 2 步的比较符号从 <= 改成 >=。也就是说,行滑窗函数可以抽象成“求窗口最值”,最大值和最小值只差一个比较方向。
2.2 二维窗口最值为什么可以“两遍压缩”
现在把问题从一维升到二维。假设矩阵是 A,行数 m,列数 n,窗口尺寸是 k×t。某个子矩阵的最大值可以写成:
code复制max over i in [r, r+k), j in [c, c+t) of A[i][j]
最大值这个操作天然满足结合律和交换律。所以我完全可以先固定行 i,求第 i 行在列区间 [c, c+t) 上的最大值,得到一个行内横向窗口最大值 rowMax[i][c];然后再对列方向索引 c 固定时,纵向取 k 行,对这个 rowMax 矩阵的第 c 列做一次长度为 k 的窗口最大值。
写成公式就是:
code复制矩形最大值 = max over 纵向行窗口 ( max over 横向列窗口 (A[i][j]) )
内层的 max 先完成横向压缩,外层的 max 再完成纵向压缩。这有点像做图像处理时的“先高斯模糊行方向,再高斯模糊列方向”,两个方向可分离。因为 max 操作的结合律保证了我先横向还是先纵向,结果完全一样。
求最小值同理,只要把 max 换成 min 即可。
所以完整流程分四步:
- 对原矩阵的每一行,求长度为 t 的横向窗口最大值,得到矩阵 HMax;
- 对 HMax 的每一列,求长度为 k 的纵向窗口最大值,得到矩阵 RMax;
- 同样方式求出 RMin;
- 对 RMax 和 RMin 的每个位置求差值,取最小值。
这一步理解到位后,剩下的代码只是实现问题。
2.3 3×3 手算演示:中间矩阵长什么样
用一个 3×3 的矩阵,窗口取 2×2,手动推一遍,比背十遍代码都有用。假设:
code复制grid = [
[4, 8, 1],
[2, 3, 7],
[9, 5, 6]
]
先做横向窗口,t = 2:
- 第 0 行:[4,8] 最大值 8,[8,1] 最大值 8,所以横向最大结果行是 [8, 8];
- 第 1 行:[2,3] 最大值 3,[3,7] 最大值 7,结果是 [3, 7];
- 第 2 行:[9,5] 最大值 9,[5,6] 最大值 6,结果是 [9, 6]。
横向最大矩阵就是:
code复制HMax = [
[8, 8],
[3, 7],
[9, 6]
]
再对这个矩阵按列做纵向窗口,k = 2:
- 第 0 列是 [8, 3, 9],窗口 [8,3] 最大值 8,窗口 [3,9] 最大值 9;
- 第 1 列是 [8, 7, 6],窗口 [8,7] 最大值 8,窗口 [7,6] 最大值 7。
得到:
code复制RMax = [
[8, 8],
[9, 7]
]
用同样的流程走一遍最小值,会得到:
code复制RMin = [
[2, 1],
[2, 3]
]
每个位置的差值分别是 6、7、7、4,最小值是 4。这个 4 正好对应右下角 2×2 子矩阵 [[3,7],[5,6]] 的 max-min = 7-3 = 4。流程完全对得上。
3. Python 实现:从行滑窗函数到完整 AC 主流程
理解了二维压缩原理之后,代码其实非常短。难的是把函数边界写对,以及不把横向纵向顺序搞混。这一节给出一套可以直接用的实现,顺便解释哪些地方容易踩坑。
3.1 行滑窗函数怎么写得顺手
我想强调一个工程上的小决定:不要把求最大值和最小值分别写成两个函数,那样代码重复太严重。比较方向不同,本质上是同一个逻辑。Python 里可以用一个布尔参数控制方向,也可以用 Python 内置的 operator.gt / operator.lt 传进来。为了可读性,我这里直接参数化方向:
python复制from collections import deque
def row_window_extreme(mat, w, take_max=True):
m = len(mat)
n = len(mat[0])
out = []
for row in mat:
dq = deque()
res = []
for i, v in enumerate(row):
# 弹出滑出窗口的旧索引
while dq and dq[0] <= i - w:
dq.popleft()
# 根据求最大还是求最小,决定队尾淘汰条件
if take_max:
while dq and row[dq[-1]] <= v:
dq.pop()
else:
while dq and row[dq[-1]] >= v:
dq.pop()
dq.append(i)
if i >= w - 1:
res.append(row[dq[0]])
out.append(res)
return out
注意这里 row[dq[-1]] 用的是当前行的值。因为每个一维数组都是矩阵里的一行,所以传入 mat 后逐行处理,row 就是当前数组。如果输入是转置后的矩阵,同样成立。
边界条件中的 dq[0] <= i - w 值得重点说。窗口是左闭右开的 [i-w+1, i],也就是位置 i-w+1 到 i 这 w 个元素。所以真正滑出窗口的旧位置必须满足 index < i-w+1,等价于 index <= i-w。我见过不少同学写的是 dq[0] < i-w,那就差了一格,等到一些特殊样例上会爆错。
3.2 完整代码与主流程:先横向后纵向的压缩
接下来涉及一个非常实用的操作:对矩阵的每一列做滑窗最值,最省事的写法不是重新写一个列版本,而是把矩阵转置一下,然后继续复用 row_window_extreme。转置之后,原来的列变成了行,行滑窗函数天然就能处理。
python复制def transpose(mat):
return [list(row) for row in zip(*mat)]
def min_abs_diff_in_window(grid, k, t):
m, n = len(grid), len(grid[0])
# 非法窗口尺寸直接返回
if k <= 0 or t <= 0 or k > m or t > n:
return -1
# 第一次横向压缩,窗口宽度为 t
row_max = row_window_extreme(grid, t, take_max=True)
row_min = row_window_extreme(grid, t, take_max=False)
# 转置后,原本的纵向压缩变成横向压缩
row_max_t = transpose(row_max)
row_min_t = transpose(row_min)
# 第二次纵向压缩,窗口高度为 k
col_max_t = row_window_extreme(row_max_t, k, take_max=True)
col_min_t = row_window_extreme(row_min_t, k, take_max=False)
# 再转置回来,得到每个 k*t 窗口的最值矩阵
max_mat = transpose(col_max_t)
min_mat = transpose(col_min_t)
ans = float("inf")
for i in range(len(max_mat)):
for j in range(len(max_mat[0])):
ans = min(ans, max_mat[i][j] - min_mat[i][j])
return ans
用刚才那个 3×3 例子调用:
python复制grid = [
[4, 8, 1],
[2, 3, 7],
[9, 5, 6]
]
print(min_abs_diff_in_window(grid, 2, 2)) # 4
输出就是 4。
这版代码的时间和空间复杂度都是 O(nm)。时间上,横向行扫描 O(nm),转置后第二次“列扫描”其实也是对 O(nm) 级别的数据做遍历,每个元素在单调队列里入队一次、出队一次。空间上,中间存了两个转置矩阵,最坏情况下大概是 2~3 个 O(nm) 量级的二维数组。竞赛里 n、m 如果到 2000,可能需要留意内存,我会在下一小节给出优化思路。
3.3 边界条件、内存优化和两个 debug 建议
边界情况最容易翻车的几个点:
第一,输入的 k 或 t 可能大于矩阵的行列数,也可能小于等于 0。实际竞赛数据一般不会给 k=0,但代码里最好兜底,否则 row_window_extreme 在窗口长度小于等于 0 时会直接死循环或者输出空列表。
第二,矩阵元素可能是负数。这不会影响算法正确性,因为最大值减最小值永远是非负的。但如果你在验证阶段习惯性给每个差值外面套 abs,其实没有任何帮助,反而掩盖了 RMax 小于 RMin 这种低级错误。
第三,如果 k 和 t 恰好等于 1,答案应该是单个元素窗口中 max-min=0。这个 case 可以手推一遍确保代码没退化:先横向窗口宽 1,得到原矩阵本身;再纵向窗口高 1,仍然得到原矩阵;max_mat 和 min_mat 相等,答案自然是 0。
内存优化方面,如果 n、m 都是 1000 量级,Python 的二维列表存起来已经有点肉疼。四个中间矩阵同时存在时,内存峰值可能接近几百 MB。省内存的方向有两个:
- 核心思路是“分两次遍历”:第一次求 max_mat,算完后马上释放,再走一遍求 min_mat。虽然多了一倍时间常数,但峰值内存只保留一个方向的中间结果,能省不少。
- 另一个方向是行列压缩时不要每次都生成新矩阵,而是在行压缩结果的基础上原地做列压缩,也就是用一个临时一维数组存当前列的数据,避免转置带来的二维复制开销。
如果现场时间紧,我不建议一上来就写内存优化版本,先把正确版本跑通。等 AC 了或者样例规模真的很大,再优化不迟。
真正调试的时候,我的习惯是先用一个 3×3 或 4×5 的小矩阵,把 max_mat 和 min_mat 打印出来,对着手算结果核对,而不是只盯着目标答案。道理很简单:这个算法可能有 20 种方式写错,答案只有一个数字;一旦输出错了,光看答案是定位不了问题的。但如果中间矩阵跟手算对不上,错误发生在哪一步一目了然。
4. 限定条件一换,解法就变:几个很容易认错的“近亲题”
同样叫“子矩阵最小绝对差”,出题人只要稍微改一个限制条件,算法模型就可能完全换一套。这一节专门讲审题时的判别逻辑,这比多刷十道题还重要。
4.1 窗口尺寸不固定时,题目反而退化成“找相邻元素最小差”
如果题目变成:给定一个矩阵,求所有大小至少为 2 的子矩阵的最小绝对差,这时候很多人会习惯性地继续往二维滑窗上想,其实这题已经被降维成简单题了。
为什么?对一个任意子矩形来说,设它的最大值是 M,最小值是 m,那么这个矩形内任意两个格子里的值都在 [m, M] 区间内,任意相邻两个格子的差的绝对值一定不会超过 M - m。也就是说,任何大矩形内部的相邻格子差,都不可能大于这个矩形本身的最大最小差。
而相邻两个格子本身就是一个 1×2 或 2×1 的子矩阵,它的最大最小差正好就是这两个格子值的差的绝对值。因此,全局最优解一定可以在所有相邻格子对里找到,答案就是“上下左右相邻格子的绝对差的最小值”。
4.2 加了“子矩阵和”“第 k 小”之类的限制,难度瞬间改变
近两年周赛里经常出现一类题目:要求子矩阵的和或元素个数满足某些条件,然后问子矩阵的最大最小差的最小
