算法学习专栏,本篇前缀和系列算法学习的中章,初章对应的技法练习已经更新「前缀和算法技法初章」。算法学习需要投入大量的时间和精力,不断的练习。本人建议从前缀和开始学习,理由是前缀和算法不需要其他算法或者数据结构为基础,适用于算法初学者(熟悉一门语言的编写。)通过学习,可以体会到算法之美(难度较低,收益较高同时非常实用的算法,直接甩掉其他竞争者 狗头。)~~
前言
通过初章我们已经了解到了什么是「前缀和」,它的「适用场景」是什么?时间空间复杂度是多少?首先我们来回顾一下。前缀和由两部分组成
- 前缀
- 和
通过构建一个「前缀和数组」preSum从而优化计算数组「区间和」的效率。()。计算区间[left,right]的区间和,只需要:
preSum[right+1] - preSum[left]即可得到。
计算区间和的时间复杂度大大提升为,但是我们需要初始化构建一个前缀和数组所需的时间复杂度是,同时需要额外的空间复杂度属于「空间换时间」。不过正如一直说明的,现如今「空间」的消耗是微不足道的,空间的提升往往比提升算力更节省成本,所以问题是不大的。
虽然前缀和适用于计算「区间和」的场景,但是它还是存在条件的。只能够计算数组元素「不发生改变」的数组,否则每次数组元素发生改变,我们都需要重新构建「前缀和数组」。当然这是存在对应的数据结构能解决此类问题的,这都是后话了。同时我们目前所学的「前缀和算法」所涉及到的数组,都有一个共同点,那就是都是「一维数组」,那么前缀和算法也是能适用于「二维数组」的场景的。各位少侠,我们来看下面这道例题~
二维数组的前缀和
304. 二维区域和检索 - 矩阵不可变 - 力扣(LeetCode)
题目给定了一个二维数组matrix同时给出两个坐标,分别是子矩阵的:
- 左上角
(row1,col1) - 右下角
(row2,col2)
让我们计算出这个子矩阵范围内的元素总和。下图就是左上角(1,2),右下角(3,4)红色子矩阵。
明白了这道题的题意,那么我们来想一下怎么解答呢?
首先最容易想到的就是「暴力」~ 不要害怕自己只能想到暴力的解法,其实很多时候题目能想到暴力解法,其实是很不错了,至少你对这道题目的题意已经理解了。那么剩下的就是通过我们所学的各种「算法」和「数据结构」来优化这个暴力的过程。
我们来暴力的使用两个for循环,外层循环控制「行」,内层循环控制「列」
- 行的遍历:row1->row2
- 列的遍历:col1->col2
然后通过遍历给出的子矩阵累加得到它们的元素总和。
class NumMatrix {
int[][] arr;
public NumMatrix(int[][] matrix) {
//赋值matrix给arr
arr = matrix;
}
public int sumRegion(int row1, int col1, int row2, int col2) {
int sum = 0;
//双层for循环遍历
for(int i = row1;i<=row2;++i){
for(int j=col1;j<=col2;++j){
sum += arr[i][j];
}
}
return sum;
}
}
这道题目的数据量分别是
- 行m为200
- 列n为200
我们的时间复杂度是,所以是,而sumRegion()方法的调用次数没有给出,所以不能确定是否能通过。我们尝试提交以下~
耗时2102ms,有惊无险~
虽然通过了,但是还是比较勉强的。所以我们还有什么方法能优化这个计算效率吗?答案就是:前缀和算法。我们可以发现这道题的本质还是求「前缀和」不过这是数组变成了二维。
定义
那么同一维前缀和一样,我们首先来定义「二维前缀和数组」
- 数组的长度为
[m+1][n+1] - 前缀和preSum[i][j]:定义为在「原数组」的左上角为
(0,0)右下角为(i-1,j-1)的子矩阵范围内的元素之和。
以下就是preSum(4,5)在「原数组」所代表的子矩阵范围元素之和。
同一维前缀和数组一样,我们的数组长度是相比原来的数组长度加1即[m+1][n+1]。preSum[i][j]定义为在「原数组」的左上角为(0,0)右下角为(i-1,j-1)的子矩阵范围内的元素之和。 这点非常重要,大家要记得,不要搞混「前缀和数组」下标 对应「原数组下标」之间的关系。
构建
「一维前缀和数组」通过「原数组」构建是非常容易想到的且符合直觉的,那么「二维前缀和数组」应该怎么构建呢?
同「一维前缀数组」构建一样,我们是需要依靠前面构建好的前缀和数组元素,不过不同的是依靠的比较多一点,从一个变成了三个。如下图,如果我们想计算前面表示的preSum(4,5)
我们应该怎么依靠前面计算好的preSum(i,j)
- i的范围为:[0,4]
- j的范围为:[0,5]
其实也很简单,我们首先通过preSum[i-1][j]+preSum[i][j-1],通过图可以看到它们在「原数组」代表的子矩阵。
两者相加我们可以得到以下包含以下元素的图形:
对比preSum(4,5)我们还漏了一个元素,即「原数组」所代表的matrix[3][4]所以我们需要加上。
同时前面两个相加,导致了一块区域preSum[3,4]被重复添加了,我们需要剔除掉。
pre[4,5] = pre[3][5]+pre[4][4]-pre[3][4]+matrix[3][4]
即可以得出:
preSum[i][j] = preSum[i-1][j] + preSum[i][j-1] - preSum[i-1][j-1] + matrix[i-1][j-1]- i和j从下标1开始。
计算
构建好了「前缀和数组」那么我们怎么计算对应范围内的元素和呢?
根据我们构建的前缀和数组的定义
- preSum[i][j]定义为在「原数组」的左上角为
(0,0)右下角为(i-1,j-1)的子矩阵范围内的元素之和。
由于我们的左上角是固定为(0,0)的,同时我们可以使用和「构建」一样的思想,通过对应相关计算,得出符合条件范围的元素。
例如求出
- 左上角(2,1)
- 右下角(4,3) 子矩阵的元素之和。
我们可以使用一个大的子矩阵,包含了所需要的元素,即preSum(5,4)(记住定义,preSum的下标对应原数组的下标需要减1)
然后减去对应不需要的元素,根据定义「前缀和数组」的左上角是固定为(0,0)。所以我们需要减去的两个preSum坐标
pre(5,1)和pre(2,4)
更为直观的表示的话就是这样
红色区域为我们重复减掉的元素区域,我们需要加上pre[2,1],此时剩下的紫色区域就是我们所需的元素之和。
sumRegion(2,1,4,3) = pre[5,4] - pre[5,1] - pre[2,4] + pre[2,1]
即
- sumRegion(r1,c1,r2,c2) = pre[r2+1][c2+1] - pre[r1][c2+1] - pre[r2+1][c1] + pre[r1][c1]
代码
class NumMatrix {
int[][] preSum;
public NumMatrix(int[][] matrix) {
int m = matrix.length,n = matrix[0].length;
preSum = new int[m+1][n+1];
for(int i =1;i<=m;++i){
for(int j =1;j<=n;++j){
preSum[i][j] = preSum[i-1][j]+preSum[i][j-1]-preSum[i-1][j-1]+matrix[i-1][j-1];
}
}
}
public int sumRegion(int row1, int col1, int row2, int col2) {
return preSum[row2+1][col2+1]-preSum[row1][col2+1]-preSum[row2+1][col1] + preSum[row1][col1];
}
}
时间大大缩减。
- 时间复杂度:构建为, sumRegion为
- 空间复杂度为
总结
- 二维前缀和数组长度为[m+1][n+1]。
- preSum[i][j]定义为在「原数组」的左上角为
(0,0)右下角为(i-1,j-1)的子矩阵范围内的元素之和。 - 任何子矩阵都可以通过之前的推导而来。
- 构建前缀和公式为:
preSum[i][j] = preSum[i-1][j] + preSum[i][j-1] - preSum[i-1][j-1] + matrix[i-1][j-1](i和j从下标1开始)。 - 通过前缀和计算子矩阵元素之和公式为:
sumRegion(r1,c1,r2,c2) = pre[r2+1][c2+1] - pre[r1][c2+1] - pre[r2+1][c1] + pre[r1][c1]