前缀和算法
目录
1. 前缀和算法的基本原理
1.1 一维前缀和
1.2 二维前缀和
2. 一维前缀和的实现
2.1 代码示例
2.2 查询子数组和
3. 二维前缀和的实现
4. 前缀和列题:
和为K的子数组
和可被K整除的子数组
矩阵区域和
5. 前缀和算法的应用场景
5.1 快速求和
5.2 滑动窗口问题
5.3 矩阵操作
5. 注意事项
6. 总结
前缀和算法是一种非常实用的算法思想,广泛应用于数组和矩阵的求和问题中。它通过预先计算并存储部分结果,从而在后续查询中快速得到答案,大大提高了效率。以下是对前缀和算法的详细讲解,包括其基本原理、实现方法和应用场景。
1. 前缀和算法的基本原理
1.1 一维前缀和
对于一个数组 arr
,其前缀和数组 prefix
定义为:
-
prefix[i]
表示数组arr
从第0
个元素到第i
个元素的累加和,即:
通过前缀和数组,可以快速计算任意子数组的和。例如,计算从索引 i
到索引 j
的子数组和,可以直接通过以下公式得到:
(注意:当 i = 0
时,prefix[-1]
可以认为是 0
)。
1.2 二维前缀和
对于一个二维数组 matrix
,其前缀和数组 prefix
定义为:
-
prefix[i][j]
表示从(0, 0)
到(i, j)
的矩形区域内的元素和,即:
通过前缀和数组,可以快速计算任意子矩阵的和。例如,计算从 (x1, y1)
到 (x2, y2)
的子矩阵和,可以直接通过以下公式得到:
(注意:当 x1 = 0
或 y1 = 0
时,对应的前缀和值可以认为是 0
)。
2. 一维前缀和的实现
2.1 代码示例
以下是一个计算一维前缀和的 C++ 代码示例:
cpp复制
#include <iostream>
#include <vector>
using namespace std;
vector<int> calculatePrefixSum(const vector<int>& arr) {
int n = arr.size();
vector<int> prefix(n, 0);
prefix[0] = arr[0];
for (int i = 1; i < n; ++i) {
prefix[i] = prefix[i - 1] + arr[i];
}
return prefix;
}
int main() {
vector<int> arr = {1, 2, 3, 4, 5};
vector<int> prefix = calculatePrefixSum(arr);
for (int i : prefix) {
cout << i << " ";
}
return 0;
}
题目:前缀和(模板)
讲解:
假设你有一个数组,比如 [3, 1, 4, 1, 5, 9]
,现在需要频繁地计算某个区间内的数字之和。比如,计算从第 2 个数字到第 4 个数字的和(1 + 4 + 1 = 6
)。如果每次都从头计算,会很麻烦,尤其是当数组很大、查询很多的时候。这时候,前缀和算法就能派上用场。
算法思路:
1. 准备工作:读入数据
-
首先,我们知道数组的长度(
n
)和要查询的次数(q
)。 -
然后,把数组的每个数字读进来。注意,代码里数组是从索引 1 开始存的,这样方便处理边界情况。
2. 预处理:计算前缀和
-
创建一个新的数组
dp
,用来存前缀和。dp[i]
表示从数组的第一个数字加到第i
个数字的总和。 -
举个例子,对于数组
[3, 1, 4, 1, 5, 9]
,前缀和数组dp
就是[0, 3, 4, 8, 9, 14, 23]
。这里dp[0]
是 0,方便计算。 -
计算方法很简单:
dp[i] = dp[i - 1] + arr[i]
。也就是说,第i
个位置的前缀和等于第i - 1
个位置的前缀和加上第i
个数字。
3. 查询:快速计算区间和
-
每次查询的时候,输入区间的左右边界
l
和r
。 -
利用前缀和数组,可以直接算出区间
[l, r]
的和,公式是:dp[r] - dp[l - 1]
。 -
这个公式的意思是:
dp[r]
是从第一个数字加到第r
个数字的总和,dp[l - 1]
是从第一个数字加到第l - 1
个数字的总和。相减后,就得到了从第l
个数字到第r
个数字的和
#include <iostream>
using namespace std;
#include <vector>
int main()
{
//读入数据
int n,q;
cin>>n>>q;
vector<int> arr(n+1);
for(int i = 1;i<=n;i++)
{
cin>>arr[i];
}
//预处理数组
vector<long long> dp(n+1);
//dp[0] = 0;
for(int i = 1;i<=n;i++)
{
dp[i] = dp[i-1]+arr[i];
}
//使用前缀数组和
int l = 0,r= 0;
while(q--)
{
cin>>l>>r;
cout<<dp[r]-dp[l-1]<<endl;
}
return 0;
}
// 64 位输出请用 printf("%lld")
2.2 查询子数组和
通过前缀和数组,可以快速查询任意子数组的和:
cpp复制
int querySum(const vector<int>& prefix, int i, int j) {
if (i == 0) {
return prefix[j];
}
return prefix[j] - prefix[i - 1];
}
3. 二维前缀和的实现
二维前缀和
以下是一个计算二维前缀和的 C++ 代码示例:
#include <iostream>
#include <vector>
using namespace std;
int main()
{
//1.矩阵构建
int n,m,q;
cin>>n>>m>>q;
vector<vector<long>> arr(n+1,vector<long>(m+1));
for(int i = 1;i<n+1;i++)
{
for(int j = 1;j<=m;j++)
{
cin>>arr[i][j];
}
}
//2.构造前缀和数组
vector<vector<long>> dp(n+1,vector<long>(m+1));
for(int i = 1;i<n+1;i++)
{
for(int j = 1;j<=m;j++)
{
dp[i][j] = dp[i-1][j]+dp[i][j-1]+arr[i][j]-dp[i-1][j-1];
}
}
//3.使用前缀和矩阵进行查询
int sum = 0;
int x1,y1,x2,y2;
int c = q;
while(q--)
{
cin>>x1>>y1>>x2>>y2;
cout<<dp[x2][y2]-dp[x2][y1-1]-dp[x1-1][y2]+dp[x1-1][y1-1]<<endl;
}
}
// 64 位输出请用 printf("%lld")
4. 前缀和列题:
和为K的子数组
该题的暴力解法比较简单,之间枚举即可,不过可能会超时。
这里要求是连续的非空数组,这里不能用滑动窗口,因为滑动窗口需要符合单调性,如果有负数进入窗口,窗口内的和会减少。
这里我们用前缀法+哈希来解决:
-
前缀和:
前缀和是指从数组的开头到某个位置的累加和。例如,对于数组[1, 2, 3, 4]
,前缀和为:-
prefix[0] = 1
-
prefix[1] = 1 + 2 = 3
-
prefix[2] = 1 + 2 + 3 = 6
-
prefix[3] = 1 + 2 + 3 + 4 = 10
如果我们要求子数组
[i, j]
的和,可以用prefix[j] - prefix[i-1]
来快速计算。 -
-
子数组和为
k
的条件:
假设我们已经计算了当前位置的前缀和sum
,那么如果存在一个子数组的和为k
,那么一定满足:sum - prefix[i-1] = k
变形后得到:
prefix[i-1] = sum - k
这意味着,只要我们在之前的前缀和中找到过
sum - k
,那么就说明存在一个子数组的和为k
。
3.哈希表的作用:
为了快速查找 sum - k
是否出现过,我们用哈希表记录每个前缀和出现的次数。这样,每 次计算新的前缀和时,只需要检查 sum - k
是否在哈希表中。
class Solution {
public:
int subarraySum(vector<int>& nums, int k) {
unordered_map<int, int> hash; // 哈希表,记录前缀和的出现次数
hash[0] = 1; // 初始化:前缀和为0的情况出现1次
int sum = 0, ret = 0; // sum为当前前缀和,ret为结果计数
for (auto& x : nums) { // 遍历数组
sum += x; // 计算当前前缀和
// 检查sum - k是否在哈希表中
if (hash.count(sum - k)) {
ret += hash[sum - k]; // 如果存在,说明存在和为k的子数组,更新结果
}
// 更新哈希表:当前前缀和sum的出现次数加1
hash[sum]++;
}
return ret; // 返回结果
}
};
关键点解释
-
hash[0] = 1
的作用:
这是一个重要的初始化操作。它表示“前缀和为0的情况出现1次”。这样做的目的是为了处理从数组开头到某个位置的子数组和为k
的情况。如果没有这个初始化,代码会漏掉这种情况。例如:数组
[1, 2, 3]
,k = 3
。
如果没有hash[0] = 1
,当sum = 3
时,sum - k = 0
,但哈希表中没有0
,会漏掉子数组[1, 2]
。 -
sum += x
的作用:
每次循环,计算当前的前缀和sum
,即从数组开头到当前位置的累加和。 -
if (hash.count(sum - k))
的作用:
检查sum - k
是否在哈希表中。如果存在,说明之前有某个前缀和等于sum - k
,那么从那个位置到当前位置的子数组和就是k
。
例如:当前sum = 5
,k = 2
,如果哈希表中有3
,那么从sum = 3
到sum = 5
的子数组和就是2
。 -
hash[sum]++
的作用:
每次计算完新的前缀和后,将其加入哈希表,并更新出现次数。这样,后续计算时可以快速查找
和可被K整除的子数组
本篇以前缀和解法为主,所以这里我们依然用哈希+前缀和来解决。
本题和上题解法基本类似,但这里找的是可以被K整除的子数组,能被整除,说明模为0,比如(5+0)%5=0。
那么我们这里需要补充两个知识点:
1. 同余定理
2.负数%正数的结果修正
算法思路:
由图中可知,假设sum-x这个子数组区间的和必然能被K整除,那么我们可以得到(sum-x)%k = 0,由同余定理可以得到,sum%K = X% K。
此时我们只需要找在[0,i-1]区间内有多少个前缀和的余数x%k等于sum%k。
但是sum%k可能算出来是负的,比如数组nums[]={-7,8},k=4,这个时候前缀和数组为[-7,1],这个时候如果用sum%K来算,-7%4=-3,1%4 = 1,这里我们就忽略了一个单独数字的子数组{8},所以我们这个时候需要把负数转为正数,在Java和C++中使用(sum%K+K)%K,这个时候代入可以得到(-7%4+4)%4=3,(1%4+4)%4 = 3,通过哈希表可以统计前面已经出现过一次3,所以从当前下标开始[1]开始到sum[i]的i下标是一个新的子数组区间。
class Solution
{
public:
int subarraysDivByK(vector<int>& nums, int k) {
int sum = 0;
unordered_map<int, int> hash;
int ret = 0;
hash[0] = 1; // 初始化,处理前缀和为0的情况
for (auto& x : nums) {
sum += x;
if (hash.count((sum % k + k) % k)) // 检查是否存在相同余数
ret += hash[(sum % k + k) % k];
hash[(sum % k + k) % k]++; // 更新哈希表,存储当前余数的频率
}
return ret;
}
};
连续数组
本题依然使用前缀和+哈希解决
这里我们把0看成-1,这样就相当于找和为0的子数组,和前面那一道和为k的子数组解法基本类似。只是这里是有一些细节不同。
算法解析
1. 前缀和的转换
数组中的元素只有 0 和 1,为了方便处理,我们将 0 转换为 -1,1 保持不变。这样,问题就变成了找到一个最长的子数组,使得该子数组的和为 0。具体来说:
-
如果
nums[i] == 0
,则将其视为-1
。 -
如果
nums[i] == 1
,则保持为1
。
例如,对于数组 [0, 1, 0, 1]
,转换后为 [-1, 1, -1, 1]
。
2. 哈希表的作用
我们用一个哈希表 hash
来记录每个前缀和第一次出现的位置。哈希表的键是前缀和,值是该前缀和第一次出现的索引。
-
初始化时,
hash[0] = -1
。这是因为如果从索引 0 开始的子数组满足条件(即前缀和为 0),那么它的长度应该是i - (-1) = i + 1
。
3. 遍历数组
我们遍历数组,计算当前的前缀和 sum
:
-
如果当前前缀和
sum
已经在哈希表中出现过,说明从第一次出现该前缀和的位置到当前位置的子数组的和为 0(即 0 和 1 的数量相等)。 -
更新结果
ret
为当前子数组的长度i - hash[sum]
。 -
如果当前前缀和
sum
没有在哈希表中出现过,则将其加入哈希表,记录其第一次出现的位置。
4. 返回结果
最终,ret
中存储的就是最长满足条件的子数组的长度。
。
class Solution {
public:
int findMaxLength(vector<int>& nums) {
unordered_map<int, int> hash; // 哈希表:存储前缀和及其第一次出现的位置
hash[0] = -1; // 初始化:前缀和为0时,位置为-1
int sum = 0, ret = 0; // sum:当前前缀和,ret:最长子数组长度
for (int i = 0; i < nums.size(); i++) {
sum += nums[i] == 0 ? -1 : 1; // 将0转换为-1,1保持不变,计算前缀和
if (hash.count(sum)) { // 如果当前前缀和已经出现过
ret = max(ret, i - hash[sum]); // 更新最长子数组长度
} else { // 如果当前前缀和没有出现过
hash[sum] = i; // 记录当前前缀和第一次出现的位置
}
}
return ret; // 返回最长子数组长度
}
};
矩阵区域和
class Solution {
public:
vector<vector<int>> matrixBlockSum(vector<vector<int>>& mat, int k) {
int m = mat.size();
int n = mat[0].size();
vector<vector<int>> dp(m+1,vector<int>(n+1));
//1.预处理一个前缀和二维数组
for(int i = 1;i<m+1;i++)
{
for(int j =1;j<n+1;j++)
{
dp[i][j] = dp[i-1][j]+dp[i][j-1]+mat[i-1][j-1]-dp[i-1][j-1];
}
}
//2.插入前缀和的计算
vector<vector<int>> answer(m,vector<int>(n));
for(int i =0;i<m;i++)
{
for(int j = 0;j<n;j++)
{
// 计算左上角坐标 (row1, col1) 和右下角坐标 (row2, col2)
//防止越界
int row1 = max(0,i-k),col1=max(0,j-k);
int row2 = min(i+k,m-1),col2 =min(j+k,n-1);
answer[i][j] = dp[row2+1][col2+1]-dp[row2+1][col1]-dp[row1][col2+1]+dp[row1][col1];
}
}
return answer;
}
};
5. 前缀和算法的应用场景
5.1 快速求和
前缀和算法可以快速计算任意子数组或子矩阵的和,时间复杂度为 O(1)。这在处理大量查询时非常高效。
5.2 滑动窗口问题
在滑动窗口问题中,前缀和可以快速计算窗口内的和,从而避免重复计算。
5.3 矩阵操作
在二维数组中,前缀和可以用于快速计算任意子矩阵的和,适用于图像处理、矩阵分析等场景。
6. 注意事项
-
边界条件:在计算前缀和时,需要注意边界条件,例如数组或矩阵的大小为 0 的情况。
-
空间复杂度:前缀和算法需要额外的空间来存储前缀和数组,空间复杂度为 O(n) 或 O(n * m)。
-
适用范围:前缀和算法适用于求和问题,但对于其他类型的操作(如最大值、最小值等),可能需要结合其他数据结构(如线段树)。
7. 总结
前缀和算法是一种简单而高效的算法思想,通过预先计算并存储部分结果,可以在后续查询中快速得到答案。它在处理数组和矩阵的求和问题中非常实用,特别是在需要大量查询的情况下。掌握前缀和算法的基本原理和实现方法,可以帮助你在解决相关问题时更加高效。