1. 问题背景与理解
今天咱们来啃一道LeetCode上的二维数组处理题——第3567题"子矩阵的最小绝对差"。这道题在周赛中出现时让不少选手栽了跟头,主要考察我们对二维数组的预处理和滑动窗口技巧的综合运用能力。
先明确题目要求:给定一个m x n的整数矩阵matrix和一个整数k,我们需要找到所有k x k子矩阵中元素和的最小绝对差。这里的绝对差指的是该子矩阵元素和与所有k x k子矩阵元素和中位数的差的绝对值。
举个例子,假设有个3x3矩阵:
code复制[1, 2, 3]
[4, 5, 6]
[7, 8, 9]
当k=2时,可能的2x2子矩阵有:
- 左上角子矩阵和:1+2+4+5=12
- 右上角子矩阵和:2+3+5+6=16
- 左下角子矩阵和:4+5+7+8=24
- 右下角子矩阵和:5+6+8+9=28
这些和的中位数是(16+24)/2=20(偶数个取中间两个的平均),那么最小绝对差就是min(|12-20|, |16-20|, |24-20|, |28-20|)=4。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 暴力解法与复杂度分析
2.1 直观暴力解法
最直接的思路就是枚举所有可能的k x k子矩阵,计算每个子矩阵的和,然后找出中位数,最后计算最小绝对差。具体步骤:
- 遍历所有可能的子矩阵左上角位置(i,j),其中0≤i≤m-k, 0≤j≤n-k
- 对于每个(i,j),计算子矩阵的和
- 将所有和存入数组,排序后找到中位数
- 遍历所有和,计算与中位数的绝对差,取最小值
Python实现代码框架:
python复制def minAbsoluteDifference(matrix, k):
m, n = len(matrix), len(matrix[0])
sums = []
for i in range(m - k + 1):
for j in range(n - k + 1):
# 计算子矩阵和
sub_sum = 0
for x in range(i, i + k):
for y in range(j, j + k):
sub_sum += matrix[x][y]
sums.append(sub_sum)
sums.sort()
median = sums[len(sums)//2] if len(sums)%2 == 1 else (sums[len(sums)//2-1] + sums[len(sums)//2])/2
min_diff = float('inf')
for s in sums:
min_diff = min(min_diff, abs(s - median))
return min_diff
2.2 复杂度分析
这个暴力解法的时间复杂度是O((m-k+1)(n-k+1)k²) + O(S log S),其中S是子矩阵的数量,S=(m-k+1)(n-k+1)。当k较大时(比如k≈m≈n),这会变成O(n⁴)的复杂度,显然无法通过较大的测试用例。
空间复杂度是O(S),需要存储所有子矩阵的和。
3. 优化思路:二维前缀和
3.1 前缀和概念引入
为了优化子矩阵求和的过程,我们可以使用二维前缀和技巧。前缀和是一种预处理技术,可以在O(1)时间内计算任意子矩阵的和。
定义prefix[i][j]表示从(0,0)到(i-1,j-1)的子矩阵和。递推公式:
code复制prefix[i][j] = matrix[i-1][j-1] + prefix[i-1][j] + prefix[i][j-1] - prefix[i-1][j-1]
有了前缀和数组后,计算从(a,b)到(c,d)的子矩阵和:
code复制sum = prefix[c+1][d+1] - prefix[a][d+1] - prefix[c+1][b] + prefix[a][b]
3.2 实现二维前缀和
先构建前缀和数组:
python复制m, n = len(matrix), len(matrix[0])
prefix = [[0]*(n+1) for _ in range(m+1)]
for i in range(1, m+1):
for j in range(1, n+1):
prefix[i][j] = matrix[i-1][j-1] + prefix[i-1][j] + prefix[i][j-1] - prefix[i-1][j-1]
然后计算子矩阵和就变得非常简单:
python复制def get_submatrix_sum(prefix, i, j, k):
return prefix[i+k][j+k] - prefix[i][j+k] - prefix[i+k][j] + prefix[i][j]
这样,计算所有子矩阵和的时间复杂度降到了O(mn),因为每个子矩阵和的计算都是O(1)了。
4. 中位数计算优化
4.1 寻找中位数的高效方法
现在我们有了所有子矩阵的和,接下来需要找到它们的中位数。直接排序然后取中间元素的方法时间复杂度是O(S log S),当矩阵很大时这可能成为瓶颈。
观察到子矩阵和的范围可能不是特别大(特别是当矩阵元素值范围有限时),我们可以考虑使用计数排序或者快速选择算法来优化。
这里介绍快速选择算法,它可以在平均O(S)时间内找到第k小的元素:
python复制import random
def quick_select(nums, k):
pivot = random.choice(nums)
left = [x for x in nums if x < pivot]
mid = [x for x in nums if x == pivot]
right = [x for x in nums if x > pivot]
if k < len(left):
return quick_select(left, k)
elif k < len(left) + len(mid):
return pivot
else:
return quick_select(right, k - len(left) - len(mid))
4.2 计算中位数
使用快速选择计算中位数:
python复制def find_median(sums):
n = len(sums)
if n % 2 == 1:
return quick_select(sums, n//2)
else:
return (quick_select(sums, n//2 - 1) + quick_select(sums, n//2)) / 2
这样,中位数查找的时间复杂度从O(S log S)降到了平均O(S)。
5. 完整优化解法实现
结合上述优化思路,完整的Python解法如下:
python复制import random
class Solution:
def minAbsoluteDifference(self, matrix, k):
m, n = len(matrix), len(matrix[0])
# 构建前缀和数组
prefix = [[0]*(n+1) for _ in range(m+1)]
for i in range(1, m+1):
for j in range(1, n+1):
prefix[i][j] = matrix[i-1][j-1] + prefix[i-1][j] + prefix[i][j-1] - prefix[i-1][j-1]
# 收集所有子矩阵和
sums = []
for i in range(m - k + 1):
for j in range(n - k + 1):
total = prefix[i+k][j+k] - prefix[i][j+k] - prefix[i+k][j] + prefix[i][j]
sums.append(total)
# 快速选择找中位数
def quick_select(nums, k):
pivot = random.choice(nums)
left = [x for x in nums if x < pivot]
mid = [x for x in nums if x == pivot]
right = [x for x in nums if x > pivot]
if k < len(left):
return quick_select(left, k)
elif k < len(left) + len(mid):
return pivot
else:
return quick_select(right, k - len(left) - len(mid))
n_sums = len(sums)
if n_sums % 2 == 1:
median = quick_select(sums, n_sums // 2)
else:
median = (quick_select(sums, n_sums//2 - 1) + quick_select(sums, n_sums//2)) / 2
# 计算最小绝对差
min_diff = float('inf')
for s in sums:
min_diff = min(min_diff, abs(s - median))
return min_diff
6. 复杂度分析与边界情况
6.1 时间复杂度分析
优化后的算法主要分为三部分:
- 构建前缀和数组:O(mn)
- 计算所有子矩阵和:O(mn)
- 快速选择找中位数:平均O(mn),最坏O(m²n²)(虽然概率极低)
- 计算最小绝对差:O(mn)
因此平均时间复杂度是O(mn),比暴力解法的O(m²n²)有了显著提升。
6.2 空间复杂度
需要O(mn)的空间存储前缀和数组,以及O(mn)空间存储所有子矩阵和(可以优化为O(1)如果边计算边处理)。
6.3 边界情况处理
实际编码时需要注意的边界情况:
- k=1时,子矩阵就是单个元素
- k=m=n时,只有一个子矩阵就是整个矩阵
- 矩阵元素可能为负数,前缀和计算时要注意
- 大矩阵情况下整数和可能溢出(Python不用担心,但其他语言需要注意)
7. 进一步优化思路
7.1 空间优化
我们可以不存储所有子矩阵和,而是在计算每个子矩阵和时直接与当前中位数比较,这样空间复杂度可以降到O(1)。但是需要先知道中位数,这似乎是个先有鸡还是先有蛋的问题。
一个折中方案是进行两次遍历:
- 第一次遍历计算所有子矩阵和并找到中位数
- 第二次遍历计算最小绝对差
这样空间复杂度仍然是O(mn),但避免了同时存储所有子矩阵和。
7.2 并行计算
由于子矩阵和的计算相互独立,可以考虑并行化处理,特别是对于大矩阵。Python中可以使用multiprocessing模块实现。
7.3 近似算法
如果对精度要求不高,可以考虑采样部分子矩阵来计算近似中位数和最小绝对差,这在处理极大矩阵时可能是个实用方案。
8. 实际编码中的注意事项
在实现这个算法时,有几个容易出错的地方需要特别注意:
-
前缀和数组的索引容易混淆。记住prefix[i][j]对应的是matrix[0..i-1][0..j-1]的和,所以子矩阵(a,b)-(c,d)的和应该是prefix[c+1][d+1] - prefix[a][d+1] - prefix[c+1][b] + prefix[a][b]
-
快速选择算法中的分区操作要正确处理等于pivot的元素,否则可能陷入无限循环
-
当子矩阵数量为偶数时,中位数是中间两个数的平均值,这可能不是整数(即使所有子矩阵和都是整数),因此计算绝对差时要注意类型转换
-
对于非常大的矩阵,可以考虑将子矩阵和分批处理,避免内存不足
我在第一次实现时犯的一个错误是忘记处理k=1的情况,导致数组越界。后来通过添加对k的检查修复了这个问题:
python复制if k == 1:
flat = [x for row in matrix for x in row]
flat.sort()
median = flat[len(flat)//2] if len(flat)%2 == 1 else (flat[len(flat)//2-1] + flat[len(flat)//2])/2
return min(abs(x - median) for x in flat)
另一个容易忽略的点是当所有子矩阵和都相同时,最小绝对差应该是0。这种情况在测试用例中往往会出现,需要确保算法能正确处理。
