尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

二维前缀和:从暴力求和到O(1)查询的算法优化

二维前缀和:从暴力求和到O(1)查询的算法优化 1. 从暴力求和到二维前缀和一个效率跃迁的故事如果你做过一些算法题尤其是涉及矩阵、图像处理或者游戏地图中区域查询的问题大概率遇到过这样的场景给你一个巨大的二维数组比如1000x1000然后有成千上万次查询每次查询都让你计算某个矩形区域内所有元素的和。新手的第一反应往往是写一个双重循环遍历矩形内的每个格子累加。这个思路直观但效率是灾难性的。假设矩阵是n x n查询次数是q那么总时间复杂度就是O(q * n^2)。当n1000, q10000时计算量将达到百亿级别程序会卡到怀疑人生。二维前缀和2D Prefix Sum就是为解决这类“频繁查询二维区域和”问题而生的“神器”。它的核心思想极其朴素用空间换时间。我们预先计算并存储好从原点(0,0)到任意点(i,j)所形成的矩形区域的和。之后对于任何一次矩形区域查询我们都可以在常数时间O(1)内通过简单的加减运算得出结果。将总时间复杂度从O(q * n^2)优化到O(n^2 q)其中O(n^2)是预处理的时间q次查询每次都是O(1)。这个效率提升是数量级的是算法思维从“蛮力”到“智慧”的一个经典体现。理解二维前缀和不仅是掌握了一个工具更是理解了一种重要的算法设计范式——预处理。它在动态规划、图像卷积、数据统计等领域都有广泛应用。接下来我将彻底拆解它的原理、构建方法、查询公式以及在实际编码中的各种细节和坑点。2. 核心原理如何用四个角“拼”出任意矩形二维前缀和的核心在于一个巧妙的几何容斥原理。我们先定义清楚几个概念。假设我们有一个二维矩阵matrix行数和列数都是n为了简化下标从1开始计数实际编程中我们通常用0-index但原理一致。我们定义二维前缀和数组prefixSum其中prefixSum[i][j]表示原始矩阵中从左上角(1,1)到右下角(i,j)所围成的矩形区域中所有元素的和。那么关键问题有两个如何构建prefixSum数组预处理如何利用prefixSum数组快速计算任意矩形(x1, y1)到(x2, y2)的和查询2.1 预处理递推公式当前格 上方矩形 左方矩形 - 重叠部分 自身值构建prefixSum是一个动态规划的过程。我们观察prefixSum[i][j]它代表的蓝色矩形区域可以由三部分已知区域拼凑而成上方矩形prefixSum[i-1][j]从(1,1)到(i-1, j)的绿色区域左方矩形prefixSum[i][j-1]从(1,1)到(i, j-1)的黄色区域当前格子自身的值matrix[i][j]但是如果我们简单地把prefixSum[i-1][j]和prefixSum[i][j-1]相加它们重叠的部分即左上角的prefixSum[i-1][j-1]区域红色部分会被计算两次。所以我们需要减去一次这个重叠部分。因此递推公式为prefixSum[i][j] prefixSum[i-1][j] prefixSum[i][j-1] - prefixSum[i-1][j-1] matrix[i][j]这个公式是二维前缀和的基石。为了处理边界情况当 i1 或 j1 时我们通常会将prefixSum数组的维度定义为(n1) x (m1)并让prefixSum[0][*]和prefixSum[*][0]全部初始化为0。这样递推公式对所有i1, j1都成立无需特殊判断。2.2 区域查询公式大矩形 - 左矩形 - 上矩形 小矩形现在假设我们要查询原始矩阵中以(x1, y1)为左上角(x2, y2)为右下角的矩形区域和其中x1 x2,y1 y2。我们可以利用已经计算好的prefixSum数组。目标区域蓝色部分可以通过以下步骤“切割”出来拿到最大的矩形prefixSum[x2][y2]从(0,0)到(x2,y2)的整个大矩形。减去左边不需要的矩形prefixSum[x2][y1-1]从(0,0)到(x2, y1-1)的黄色长条。减去上边不需要的矩形prefixSum[x1-1][y2]从(0,0)到(x1-1, y2)的绿色长条。注意左上角(0,0)到(x1-1, y1-1)的区域红色小矩形被减了两次所以需要加回来一次 prefixSum[x1-1][y1-1]。因此查询公式为sum prefixSum[x2][y2] - prefixSum[x2][y1-1] - prefixSum[x1-1][y2] prefixSum[x1-1][y1-1]这个过程完全对应了预处理时的逆操作。通过这四次数组访问和三次加减运算我们就在O(1)时间内得到了任意矩形的和。注意这里所有的坐标x1, y1, x2, y2都是指在prefixSum数组中的坐标它对应原始matrix的坐标关系是matrix[i][j]的值影响的是prefixSum[i1][j1]。在编程中我们通常统一使用0-index的prefixSum数组公式会微调但思想不变。3. 从理论到代码零索引下的实现与细节处理理论很优美但写代码时下标从0开始会带来一些小麻烦。处理不好就容易在边界上出错。下面我们以0-index的矩阵为例给出最稳妥的实现模板。假设原始矩阵grid的大小为m行n列。3.1 预处理创建 (m1) x (n1) 的 prefixSum 数组这是最关键的一步。我们声明一个大小为(m1) x (n1)的二维数组pre并将所有元素初始化为0。多出来的第一行和第一列索引为0作为虚拟的边界使得递推公式对于i1, j1的部分可以统一处理。def build_prefix_sum(grid): m, n len(grid), len(grid[0]) # 创建 (m1) x (n1) 的二维数组初始化为0 pre [[0] * (n 1) for _ in range(m 1)] for i in range(1, m 1): # 对应原始 grid 的 0 ~ m-1 行 for j in range(1, n 1): # 对应原始 grid 的 0 ~ n-1 列 # 注意grid[i-1][j-1] 对应 pre[i][j] pre[i][j] pre[i-1][j] pre[i][j-1] - pre[i-1][j-1] grid[i-1][j-1] return pre这里有一个非常重要的映射关系pre[i][j]存储的是原始矩阵grid中从[0][0]到[i-1][j-1]这个矩形区域的和。pre的索引(i, j)比grid的索引(i-1, j-1)大1。记住这个“1”的偏移是避免所有下标错误的关键。3.2 区域查询调整坐标公式现在如果我们要查询原始矩阵中左上角为(r1, c1)右下角为(r2, c2)的矩形和r1r2, c1c2且都是0-index坐标。我们需要将原始坐标转换到pre数组的坐标。根据上面的映射关系pre中代表“从(0,0)到(r2, c2)区域”的索引是(r21, c21)。同理(r1, c1)对应(r11, c11)。但是在查询公式中我们需要减去的部分是“左矩形”和“上矩形”它们的边界是(r2, c1-1)和(r1-1, c2)。转换到pre坐标时要特别注意“-1”的操作。推导后的查询函数如下def query_prefix_sum(pre, r1, c1, r2, c2): # r1, c1, r2, c2 是原始 grid 的 0-index 坐标 # 转换到 pre 数组的坐标所有坐标 1 # 公式Sum pre[r21][c21] - pre[r21][c1] - pre[r1][c21] pre[r1][c1] # 注意pre[r21][c1] 对应的是原始 grid 中第 c1-1 列结束的区域即我们要减去的左矩形 # pre[r1][c21] 对应的是原始 grid 中第 r1-1 行结束的区域即我们要减去的上矩形 return pre[r21][c21] - pre[r21][c1] - pre[r1][c21] pre[r1][c1]记忆技巧在pre数组中加1的是终点坐标不加1的是起点坐标。即大矩形终点(r21, c21)左矩形终点(r21, c1)// 列坐标是起点c1不加1上矩形终点(r1, c21)// 行坐标是起点r1不加1重叠小矩形终点(r1, c1)// 起点坐标都不加1这个模板非常牢固建议直接理解和记忆这个转换后的公式。3.3 一个完整的计算示例假设grid如下1 2 3 4 5 6 7 8 9我们手动计算pre数组pre[1][1] grid[0][0] 1pre[1][2] pre[1][1] pre[0][2] - pre[0][1] grid[0][1] 1 0 - 0 2 3pre[1][3] 123 6pre[2][1] 14 5pre[2][2] pre[1][2] pre[2][1] - pre[1][1] grid[1][1] 3 5 - 1 5 12... 最终pre数组索引0的行列全为00 0 0 0 0 1 3 6 0 5 12 21 0 12 27 45现在查询grid中(r11, c11)到(r22, c22)的和即{5,6,8,9}和为28。 使用公式pre[3][3] - pre[3][1] - pre[1][3] pre[1][1] 45 - 12 - 6 1 28。结果正确。4. 不止于求和二维前缀和的变体与应用场景二维前缀和的思想并不局限于“求和”。任何满足区间可加性和区间可减性的运算符都可以套用这个模型。这意味着只要我们可以通过两个大区间的结果“拼凑”出一个小区间且这个操作是O(1)的就可以使用前缀和思想。4.1 常见变体前缀最大值、前缀最小值、前缀异或和前缀最大值/最小值preMax[i][j]表示从(0,0)到(i,j)矩形区域内的最大值。但是最大值不满足区间可减性你知道整个区域的最大值是10左边区域最大值是8上边区域最大值是9你无法确定中间重叠区域的最大值也无法通过加减还原出目标区域的最大值。因此标准的二维前缀和模板不能直接用于求矩形区域的最大值/最小值。这类问题通常需要其他数据结构如二维ST表、线段树等。前缀异或和这是完全可以的。因为异或操作满足A ^ B ^ B A即异或同一个数两次等于本身。所以如果我们定义preXor[i][j]为从(0,0)到(i,j)矩形区域所有数的异或值那么查询矩形(x1,y1)-(x2,y2)的异或值公式为preXor[x2][y2] ^ preXor[x2][y1-1] ^ preXor[x1-1][y2] ^ preXor[x1-1][y1-1]。注意这里是三个异或因为重叠部分被异或了两次根据a ^ a 0的性质需要再异或一次来抵消。4.2 经典应用场景剖析图像处理与计算机视觉均值滤波/盒式滤波快速计算图像中每个像素周围邻域如3x3, 5x5窗口的像素值之和用于模糊或降噪。使用二维前缀和计算每个窗口的和是O(1)整个图像处理就是O(n^2)。积分图这正是二维前缀和在图像领域的名称。用于快速计算Haar特征是传统人脸检测算法Viola-Jones的核心加速技术。动态规划许多DP问题中状态转移需要频繁查询一个子矩阵的和。例如一些分割问题、最优子矩阵问题。将二维前缀和作为辅助数组可以极大优化DP的转移速度。游戏开发与地图系统在策略游戏或RPG中地图可能被划分为网格每个格子有资源量、敌人密度等属性。频繁查询某一矩形区域内的总资源或平均威胁度二维前缀和是标准解决方案。数据统计与分析对于一个二维数据表如销售数据行是时间列是产品需要快速统计某个时间段内、某些产品的销售总额。构建二维前缀和后查询是瞬间完成的。4.3 实战例题LeetCode 304. 二维区域和检索 - 矩阵不可变这是学习二维前缀和必做的入门题。题目要求设计一个类初始化时接收一个二维矩阵并实现一个方法能快速返回指定矩形范围内的元素和。解题思路 直接在类中保存原始矩阵每次查询都循环计算是不可行的会超时。标准解法就是在初始化时构建二维前缀和数组self.pre查询时使用O(1)的公式。代码实现class NumMatrix: def __init__(self, matrix): if not matrix or not matrix[0]: m, n 0, 0 else: m, n len(matrix), len(matrix[0]) # 构建 (m1)x(n1) 的前缀和数组 self.pre [[0] * (n 1) for _ in range(m 1)] for i in range(m): for j in range(n): # 注意下标转换 self.pre[i1][j1] self.pre[i][j1] self.pre[i1][j] - self.pre[i][j] matrix[i][j] def sumRegion(self, row1: int, col1: int, row2: int, col2: int) - int: # 使用构建好的 self.pre 进行 O(1) 查询 return (self.pre[row21][col21] - self.pre[row21][col1] - self.pre[row1][col21] self.pre[row1][col1])注意事项构造函数中必须处理输入矩阵可能为空的情况。self.pre的索引设计是代码简洁正确的关键。坚持使用(m1)x(n1)并让第一行第一列为0的模板能避免绝大多数边界错误。5. 高频踩坑点与性能优化指南即使理解了原理和模板在实际编码和解题中依然有几个坑点一不小心就会掉进去。5.1 下标偏移万恶之源这是最常见、最隐蔽的错误。根本原因在于混淆了“原始矩阵坐标”和“前缀和数组坐标”。症状结果偶尔对偶尔错特别是在查询边界区域如第一行、第一列时。根因在预处理或查询时没有严格遵守“pre下标比grid下标大1”的映射关系。解决方案统一使用模板严格按照上面第3节给出的build_prefix_sum和query_prefix_sum函数模板来写。在写查询公式时心中默念“加1的是终点不加1的是起点”。画图验证对于复杂的查询或不确定的推导在纸上画出pre数组和grid的对应关系图标出坐标一目了然。编写单元测试用一个小矩阵如3x3手动计算所有可能的矩形区域和与程序输出对比。这是发现下标错误最有效的方法。5.2 空间复杂度是否可以优化我们的模板使用了O(m*n)的额外空间。对于绝大多数情况m*n在百万级别以下这完全可接受。但在一些极端场景如矩阵非常大或者对内存极其敏感我们可以考虑优化。原地修改如果允许修改输入矩阵可以直接在原矩阵grid上计算前缀和。但这样做会破坏原始数据且查询公式需要调整因为grid本身变成了前缀和矩阵代码可读性会变差一般不推荐。一维前缀和如果查询模式固定例如总是查询整行或整列的和那么维护每行的前缀和数组可能更节省空间。但这牺牲了查询任意矩形的O(1)能力。结论在算法竞赛和面试中无脑使用O(m*n)的额外空间是标准且安全的做法。优先保证正确性和代码清晰度。5.3 大数溢出与数据类型选择当矩阵元素值很大或者矩阵很大时前缀和数组中的值可能会超过普通32位整型int的范围。在Python中整数是任意精度的通常无需担心。在Java/C/Go等语言中需要根据题目给出的数据范围预估前缀和的最大值。例如矩阵元素最大为10^5矩阵大小为200x200那么最大前缀和约为4e9这已经超过了int约21亿的范围需要使用long long(C) 或long(Java) 来存储前缀和数组。检查点在初始化前缀和数组时就应使用足够大的数据类型。5.4 初始化与构造性能预处理的双重循环是O(m*n)的。对于非常大的矩阵这个初始化开销需要考虑。避免重复构造在面向对象的设计中如LeetCode 304题前缀和数组应在构造函数中一次性构建好并保存。多次调用查询方法时直接使用避免重复计算。循环顺序现代CPU有缓存机制按行顺序遍历即外层循环行内层循环列通常比按列遍历更快因为内存访问是连续的。我们的模板写法是符合这个最佳实践的。6. 举一反三从二维前缀和到更高维与差分思想掌握了二维前缀和你的工具箱里就多了一件利器。但它的价值不止于此它代表的思想可以推广。6.1 一维前缀和与多维前缀和一维前缀和这是二维的基础。pre[i]表示数组前i个元素的和。查询区间[l, r]的和为pre[r] - pre[l-1]。思想完全一致只是维度降低。三维乃至k维前缀和原理是相通的但推导和公式更复杂。对于三维pre[x][y][z]表示从(0,0,0)到(x,y,z)的立方体和。递推和查询公式会涉及更多项的加减8项核心依然是容斥原理。在面试和竞赛中三维前缀和已属罕见更高维的则更多是理论探讨。6.2 前缀和的逆运算差分如果说前缀和是“积分”那么差分就是“微分”。它们是一对互逆的操作。一维差分给定一个数组a其差分数组d定义为d[i] a[i] - a[i-1]i0d[0]a[0]。差分数组的妙处在于如果我们想对原数组a的某个区间[l, r]统一加上一个值val只需要在差分数组上操作d[l] val,d[r1] - val。然后对d求前缀和就能得到修改后的a。这个操作是O(1)的。二维差分这是二维前缀和的逆运算。如果我们想对原矩阵的一个矩形区域(x1,y1)-(x2,y2)内所有元素同时加上一个值val可以在差分矩阵上进行四次O(1)的操作diff[x1][y1] val diff[x1][y21] - val diff[x21][y1] - val diff[x21][y21] val操作完成后对diff矩阵求二维前缀和就得到了更新后的原矩阵。这在需要“批量更新最终查询”的场景下先进行多次区间加操作最后再问所有位置的值能提供O(1)更新、O(n^2)重建的极高效率是“离线算法”的常用技巧。6.3 综合应用解决“子矩阵最大和”问题这是一个经典问题给定一个m x n的矩阵找出其中和最大的子矩阵。暴力枚举所有子矩阵需要O(m^2 * n^2)的时间。结合二维前缀和我们可以优化到O(m^2 * n)。思路首先计算矩阵的二维前缀和pre。枚举子矩阵的上下边界第top行到第bottom行这需要O(m^2)。对于每一对固定的上下边界我们将这个“行带”中每一列的元素压缩成一个一维数组。具体地第j列的值colSum[j] sum(grid[top..bottom][j])这个值可以通过前缀和在O(1)时间内得到colSum[j] query_prefix_sum(pre, top, j, bottom, j)。现在问题转化为在一个一维数组colSum中寻找最大子数组和这是经典的Kadane算法问题O(n)可解。对每一对上下边界都做一次Kadane算法总时间复杂度为O(m^2 * n)。这个例子展示了如何将二维问题通过枚举和前缀和压缩转化为一维问题来解决是二维前缀和一个非常漂亮的高级应用。二维前缀和不是一个复杂的算法但它体现了算法设计中最宝贵的“预处理”和“空间换时间”思想。理解它并熟练处理其下标细节能让你在面对矩阵类问题时多一份从容。下次再遇到需要频繁查询矩形区域和的问题别再写双重循环了试试二维前缀和你会感受到算法优化带来的快感。在实际编码时我个人的习惯是永远多开一行一列并立刻写下构建和查询的模板函数这能帮我屏蔽掉几乎所有低级错误。
返回列表