算法系列4:前缀和



**🎬 博主名称**:迷途之人不知返

🔥 个人专栏 : 《C语言》《数据结构》《C++》《Linux》

🗂️ Gitee仓库 : 《C语言》《数据结构》《C++》《Linux》

</> 算法专栏 : 《算法精选集》


位运算

  • [1 ···> 一维前缀和(模板)](#1 ···> 一维前缀和(模板))
  • [2 ···> 二维前缀和(模板)](#2 ···> 二维前缀和(模板))
  • [3 ···> 寻找数组的中心下标](#3 ···> 寻找数组的中心下标)
  • [4 ···> 除了自身以外数组的乘积](#4 ···> 除了自身以外数组的乘积)
  • [5 ···> 和为 K 的子数组](#5 ···> 和为 K 的子数组)
  • [6 ···> 和可被 K 整除的子数组](#6 ···> 和可被 K 整除的子数组)
  • [7 ···> 连续数组](#7 ···> 连续数组)
  • [8 ···> 矩阵区域和](#8 ···> 矩阵区域和)

位运算的算法思想,专门用于解决一段区间内子区间的快速求和

1 ···> 一维前缀和(模板)

一维前缀和

1.1、描述

比如题目给出的示例:

1, 2, 4

这个数组有3个元素,

  • 第一次查询,是从1 ~ 2两个数的和,1 + 2 = 3,所以打印的是3;
  • 第二次查询,是从2 ~ 3两个数的和,2 + 4 = 6,所以打印的是6。

1.2、解决

我们很容易想到的暴力解法就是遍历求和。但在这道题中,暴力解法的时间复杂度是O(m*n),不是最优,而且暴力解法肯定超时。

此时的数组是一维的,我们就可以采用一维前缀和的算法。

1、做出前缀和数组

利用动态规划,做出前缀和数组:
dpi = dpi - 1 + arri

2、使用前缀和数组

比如对于序列: 1, 2, 4 ,我们做出了如下的前缀和数组dp:

0, 1, 3, 7

  • 要求1 ~ 2两个数的和,就用dp2 - dp0
  • 要求2 ~ 3两个数的和,就用dp3 - dp1
  • 要求l ~ r两个数的和,就用dpr - dpl - 1
cpp 复制代码
#include <iostream>
#include <vector>

using namespace std;

int main()
{
	int n, m;
	cin >> n >> m;
	
	// 1. 读入数组
	vector<int> arr(n + 1);
	for (int i = 1; i <= n; ++i) cin >> arr[i];
	
	// 2. 制作前缀和数组
	vector<long long> dp(n + 1); // 防止溢出
	for (int i = 1; i <= n; ++i)
		dp[i] = dp[i - 1] + arr[i]; // 注意下标匹配

	// 3. 使用前缀和
	while (m--)
	{
		int l, r;
		cin >> l >> r;
		cout << dp[r] - dp[l - 1] << endl;
	}
	
	
	return 0;
}

2 ···> 二维前缀和(模板)

二维前缀和

2.1、描述

分析题目给出的示例:

这是一个3行4列的二维数组:

第一次查询,求(1, 1)到(2, 2)矩阵范围内数值和,结果是:1 + 2 + 3 + 2 = 8。

第二次查询,求(1, 1)到(3, 3)矩阵范围内数值和,结果是:
1 + 2 + 3 + 3 + 2 + 1 + 1 + 5 + 7 = 25

第三次查询,求(1, 2)到(3, 4)矩阵范围内数值和,结果是:
2 + 3 + 4 + 2 + 1 + 0 + 5 + 7 + 8 = 32

2.2、解决

1、做出前缀和数组

我们先做出前缀和数组dp,

  • dpij表示从(1, 1)到(i, j),所有数值的和。

关键是状态转移方程,我们似乎无法直接看出来。我们可以把问题抽象成一个矩阵图:

如图表示的是原二维数组,ABCD表示的是对应区域的和。要求dpij,就是求(1, 1)到(i, j)所有值,就是:
dpij = A + B + C + D

D我们知道是原数组(i, j)位置上的值,A是dpi - 1j - 1。而B和C似乎表示不出来。

我们对上面表达式做一个变形:
dpij = (A + B) + (A + C) + D - A

(A + B)和(A + C)就很好表示了:

  • (A + B):dpi - 1j
  • (A + C):dpij - 1

那么状态转移方程:
dpij = arrij + dpi - 1j + dpij - 1 - dpi - 1j - 1

注意我们为了使原数组与dp数组的位置匹配上,统一在题目给出的行列基础上再加一行一列,这样我们不需要调整下标,避免了很多麻烦。

2、使用前缀和数组

要求(x1, y1)到(x2, y2)的和,我们也无法直接写出式子。我们依旧借助一个抽象图分析:

ABCD依旧代表区域和。我们要求D,不妨做一步转化:
D = (A + B + C + D) - (A + B) - (A + C) + A

那么各部分就很好表示了:

  • (A + B + C + D): dpx~2~y~2~
  • (A + B): dpx~1~ - 1y~2~
  • (A + C): dpx~2~y~1~ - 1
  • A: dpx~1~ - 1y~1~ - 1
cpp 复制代码
#include <iostream>
#include <vector>

using namespace std;

int main()
{
	int n, m, q;
    cin >> n >> m >> q;

    // 1. 读入数组
    vector<vector<int>> arr(n+1, vector<int>(m+1));
    for (int i = 1; i <= n; ++i)
        for (int j = 1; j <= m; ++j)
            cin >> arr[i][j];

    // 2. 创建前缀和数组
    vector<vector<long long>> dp(n+1, vector<long long>(m+1));
    for (int i = 1; i <= n; ++i)
        for (int j = 1; j <= m; ++j)
            dp[i][j] = arr[i][j] + dp[i-1][j] + dp[i][j-1] - dp[i-1][j-1];

    // 3. 使用
    while (q--)
    {
        int x1, y1, x2, y2;
        cin >> x1 >> y1 >> x2 >> y2;
        cout << dp[x2][y2] - dp[x1-1][y2] - dp[x2][y1-1] + dp[x1-1][y1-1] << endl;
    }
	
	return 0;
}

3 ···> 寻找数组的中心下标

寻找数组的中心下标

"中心下标"满足的条件是,以中心下标为界,左边子数组之和与右边子数组之和相等

暴力解法是遍历,每遍历到一个数,就再通过遍历分别将左边子数组和右边子数组做求和。但是时间复杂度为O(N2),提交代码一定会超时。

既然提到了子区间求和,那么我们可以借助"前缀和"的思想解题。

原来求中心下标的问题,就转化成比较左边之和与右边之和是否相等的问题。

对于左边之和,即0, i - 1区间求和,我们可以构造一个前缀和数组front:

  • 由于对于原数组的每一位数,都有一个0, i - 1区间,所以前缀和数组的元素个数与原数组一样,也是n;
  • 0, i - 1区间和,即求fronti,由于0, i - 1区间和可以由0, i - 2区间和(fronti - 1)与numsi - 1求得,而fronti - 1又可以分为fronti - 2和numsi - 2......所以我们可以得出状态转移方程:
  • fronti = fronti - 1 + numsi - 1
  • front0应初始化成0,否则直接套用状态转移方程会越界;
  • 填表顺序是从左往右。

同理,对于右边之和,即i + 1, n - 1区间求和,可以构造一个后缀和数组back:

  • 前缀和数组的元素个数与原数组一样也是n;
  • i + 1, n - 1区间和,即求backi,由于i + 1, n - 1区间和可以由i + 2, n - 1区间和(backi + 1)与numsi + 1求得,而backi + 1又可以分为backi + 2和numsi + 2......所以我们可以得出状态转移方程:
  • backi = backi + 1 + numsi + 1
  • backn - 1应初始化成0,否则直接套用状态转移方程会越界;
  • 填表顺序是从右往左。
cpp 复制代码
class Solution {
public:
    int pivotIndex(vector<int>& nums) {
        int n = nums.size();
        vector<int> front(n), back(n);

        for (int i = 1; i < n; ++i) front[i] = front[i-1] + nums[i-1];
        for (int i = n-2; i >= 0; --i) back[i] = back[i+1] + nums[i+1];

        for (int i = 0; i < n; ++i)
            if (front[i] == back[i])
                return i;
        return -1;
    }
};

其实我们也可以只用一个0, i的前缀和数组来解决问题,只不过判断相等的逻辑比较复杂:

cpp 复制代码
class Solution {
public:
    int pivotIndex(vector<int>& nums) {
        int n = nums.size();
        vector<int> dp(n + 1);
        dp[0] = 0;
        for (int i = 1; i <= n; ++i) dp[i] = dp[i-1] + nums[i-1];

        for (int i = 1; i <= n; ++i)
            if (dp[i-1] == dp[n] - dp[i])
                return i - 1;
        return -1;
    }
};

4 ···> 除了自身以外数组的乘积

除了自身以外数组的乘积

题目要求是返回一个与原数组长度相同的数组,每一个位置上的数,为除原数组对应位置数之外所有数的乘积。

暴力的解法是遍历,遍历原数组到每一个数,然后再遍历原数组将其它数乘起来。时间复杂度一定是O(N2),一定超时。

分析题目,我们就能发现题目需要求子区间的乘积。所以我们就可以采用前缀和思想,并进行一些改造。

对于任意位置i,要求除i位置的所有位置的乘积,我们就需要求:

  • 0, i - 1区间的积,我们称为"前缀积";
  • i + 1, n - 1区间的积,我们称为"后缀积"。

原数组每一个位置i,都有一个前缀积和后缀积,所以我们需要创建两个数组,数组与原数组长度一致,分别用来放前缀积、后缀积。

对于前缀积,

  • 要求0, i - 1区间的积,我们设为fronti;求fronti,可以求0, i - 2区间的积与numsi - 1的乘积,即fronti - 1 * numsi - 1......所以前缀积的状态转移方程为:
  • fronti = fronti - 1 * numsi - 1
  • front0应初始化成1,以保证不会影响后续前缀积的计算;
  • 要知道fronti必须先知道fronti - 1,所以填表顺序是从左往右。

对于后缀积,

  • i + 1, n - 1区间的积为backi。状态转移方程为:
  • backi = backi + 1 * numsi + 1
  • backn - 1应初始化成1,以保证不会影响后续后缀积的计算;
  • 填表顺序是从右往左。
cpp 复制代码
class Solution {
public:
    vector<int> productExceptSelf(vector<int>& nums) {
        int n = nums.size();
        vector<int> front(n), back(n);

        front[0] = 1;
        for (int i = 1; i < n; ++i)
            front[i] = front[i-1] * nums[i-1];
        back[n-1] = 1;
        for (int i = n-2; i >= 0; --i)
            back[i] = back[i+1] * nums[i+1];

        vector<int> ret(n);
        for (int i = 0; i < n; ++i)
            ret[i] = front[i] * back[i];
        return ret;
    }
};

5 ···> 和为 K 的子数组

和为 K 的子数组

5.1、描述

例如,给出和 k = 2 ,数组为:

1, 1, 1

子数组有两个,分别为:

给出和 k = 3 ,数组为:

1, 2, 3

子数组有两个,分别为:

5.2、解决

乍一看,我们可能会想用"滑动窗口"思路做题。但是这道题不能用滑动窗口做,因为题目说明了数组里面可能有0或负值。如果使用同向的双指针,我们就会漏掉情况:

如图,假设此时left和right找到了一种情况。由于数组中可能有0或负数,那么left和right之间就很可能也有一种情况(紫色圈标注)。但是left, right指针式是同向的,紫圈标注的情况就不会被统计到。所以我们不能用滑动窗口做题。

既然题目要我们求和为k的子数组个数,那么我们就可以想一想如何用前缀和思想做题。

我们可以重新定义前缀和的状态表示:到i位置为止所有数的和,设为dpi

状态转移方程还是不变:
dpi = dpi - 1 + numsi

然后dp0应初始化成0。

我们需要在0, i - 1区间内找一个子区间和为k。假设存在某一个位置x,使得x, i - 1之间和为k,那么0, x之间和就为dpi - k

问题就变成了:前面用i在遍历得出前缀和数组,后面返回来求前缀和数组中值为dpi - k的个数,即原数组在i位置之前有多少个和为dpi - k的区间。

这里有四个细节。

1、哈希表

我们需要根据区间和,来统计区间个数。如果我们直接创建一个前缀和数组,处理就会变得非常麻烦。我们可以借助哈希表来建立前缀和与区间个数的关系。

2、不用创建一个真的前缀和数组

我们可以定义一个变量sum(i),然后每次加numsi,即可在每一次循环中迭代出前缀和。

3、哈希表入数据的时机

我们是先做好一个哈希表,然后求子区间的个数吗?

当然不是。我们使用i遍历到每一个位置时,得先统计 0, i - 1 区间内有多少个和为 sum(i) - k 的子区间,再将sum入哈希表

如果我们直接先做好哈希表,或者先统计子区间个数再入哈希表,我们就既统计到 0, i - 1 区间内符合要求的子区间,又会统计到 i + 1, n - 1 区间内的子区间,就会重复统计

4、整个区间和为k?

如果是先统计个数,再入哈希表,那么当整个区间和为k的时候,返回值应该是1,而此时sum - k = 0,所以我们要让hash0 = 1。

cpp 复制代码
class Solution {
public:
    int subarraySum(vector<int>& nums, int k) {
        unordered_map<int, int> hash;
        hash[0] = 1;
        int sum = 0, ret = 0;
        for (auto i : nums)
        {
            sum += i;
            if (hash.count(sum - k)) ret += hash[sum-k];// 往后遍历时,sum可能会一样,但是位置一定不一样
            // 后面sum相同的序列,是前面序列加上几个和为0的数而成的序列,所以后面求的时候就已经统计好了
            hash[sum]++;
        }

        return ret;
    }
};

6 ···> 和可被 K 整除的子数组

和可被 K 整除的子数组

首先补充两个知识点。

6.1、补充知识

1、同余定理

对于等式:
(a - b) % p = 0
我们可以得出:
a % p = b % p

证明的方式也比较简单。等式(a - b) % p = 0可以表示成:
(a - b) / p = k ······ 0

移项得:
a = b + p*k

再等号两边同时%p。(b + p*k) % p的结果为b % p,证毕。

2、C++与java的 负数 % 正数 的结果修正

C++与java的 负数 % 正数 的结果都是负数。我们可以将负数的结果修正成正数:
a % p + p

而为了正负统一,即正数和负数都可以用修正式,我们对上面修正式做进一步修正:
(a % p + p) % p

6.2、解决

这道题与第五题思路非常类似。

定义sum(i)为原数组到i位置为止的前缀和。

我们要在0, i区间中找和能被5整除的子区间:

由同余定理,问题就转化为了在 0, i - 1 内求子区间的个数。子区间满足子区间本身和%k的结果,与sum(i)%k的结果相等。

由于子区间和,和sum可能为负数,所以我们要使用修正式修正取模的结果。同时我们也要注意和第五题一样需要处理的细节

  1. 哈希表的使用
  2. 不需要真的创建一个真的前缀和数组
  3. 哈希表入数据的时机
  4. 整个数组满足条件的情况
cpp 复制代码
class Solution {
public:
    int subarraysDivByK(vector<int>& nums, int k) {
        unordered_map<int, int> hash;
        hash[0 % k] = 1;
        int ret = 0, sum = 0;
        for (auto& i : nums)
        {
            sum += i;
            int in = (sum % k + k) % k;
            if (hash.count(in)) ret += hash[in];
            hash[in]++;
        }

        return ret;
    }
};

7 ···> 连续数组

连续数组

初看此题,我们似乎无从下手。

我们不妨将数组中所有的0转变成-1 。例如示例3中的数组:

将所有的0变成-1,数组转化为:

1 + -1 = 0,所以找一个0和1数量相同的最长子区间,我们就转化成了找和为0的最长子区间

这类题我们之前是做过的:定义dpi为到i位置为止0, i之间的前缀和,然后在0, i - 1区间内找一个下标x,使得0, x区间和dpx等于dpi,接着比较得出最长下标。

但是这道题处理的细节,与"和为k的子数组"有所不同,

  1. 遇到nums[i]为0,加的是-1;
  2. 这里也使用哈希表,但是哈希表存的不再是[前缀和]:[出现次数],而是[前缀和]:[出现位置]
  3. 不用真的创建前缀和数组,只需迭代sum(i);
  4. 入哈希表的时机也有差别。当遍历到i位置
    • 如果当前存在位置x ∈ \in ∈ 0, i - 1hash.count(sum)不为0,更新最大长度(比谁大);
    • 如果不存在,hash.count(sum)为0,那么可以入哈希表。
  5. 如果整个数组的和为0,此时x的位置应该为-1,所以我们要让前缀和结果0的hash值默认为位置-1,即hash[0] = -1
  6. 子数组长度的计算。很明显,和为0的子区间的范围是x + 1, i,所以子数组长度的计算式是i - x,即i - hash[sum]
cpp 复制代码
class Solution {
public:
    int findMaxLength(vector<int>& nums) {
        unordered_map<int, int> hash;
        hash[0] = -1;
        int sum = 0, ret = 0;
        for (int i = 0; i < nums.size(); ++i)
        {
            sum += nums[i] == 0 ? -1 : 1;
            if (hash.count(sum)) ret = max(ret, i - hash[sum]);
            else hash[sum] = i;
        }

        return ret;
    }
};

8 ···> 矩阵区域和

矩阵区域和

8.1、描述

以示例一为例。整数k == 1,矩阵mat为:

要返回的矩阵是answer,我们简称ans。ans[0][0]是以mat[0][0]为中心,边长为2 * k + 1 == 3的正方形区域内的和:

ans[0][1]是以mat[0][1]为中心,边长为3的正方形区域内的和:

ans[1][1]

ans[2][2]

8.2、解决

我们会发现,这道题似乎可以用"二维前缀和"的思路来做。所以我们可以先处理出来一个二维前缀和数组。

接下来的问题是:对于ans的每一个位置,都对应一个区域和,那么如何使用二维前缀和数组,以正确地表示每一个区域呢?

设ans上某一位置为(i, j)。由于制作前缀和数组时,为了防止越界,多开辟了一行一列,所以(i, j)映射到dp上应该是(i + 1, j + 1)。

要求区域和,根据"二维前缀和"一问,我们就需要求(x1, y1),(x2, y2),很明显:

  • x1 = i + 1 - k
  • y1 = j + 1 - k
  • x2 = i + 1 + k
  • y2 = j + 1 + k

但是,x1和y1计算结果可能越界,即小于1。所以我们要将计算结果与1取一个最大:

  • x1 = max(1, i + 1 - k)
  • y1 = max(1, j + 1 - k)

类似的,x2和y2计算结果可能越界,即x2可能大于mat的行数(row),y2可能大于mat的行数(col)。所以我们要将计算结果与mat的行、列数最大值取一个最小:

  • x2 = min(row, i + 1 + k)
  • y2 = min(col, j + 1 + k)
cpp 复制代码
class Solution {
public:
    vector<vector<int>> matrixBlockSum(vector<vector<int>>& mat, int k) {
        int row = mat.size(), col = mat[0].size();

        vector<vector<int>> dp(row + 1, vector<int>(col + 1));
        for (int i = 1; i <= row; ++i)
            for (int j = 1; j <= col; ++j)
                dp[i][j] = mat[i - 1][j - 1] + dp[i][j - 1] + dp[i - 1][j] - dp[i - 1][j - 1];

        vector<vector<int>> ans(row, vector<int>(col));
        for (int i = 0; i < row; ++i)
            for (int j = 0; j < col; ++j)
            {
                int x1 = max(1, i + 1 - k), y1 = max(1, j + 1 - k);
                int x2 = min(row, i + 1 + k), y2 = min(col, j + 1 + k);
                ans[i][j] = dp[x2][y2] - dp[x2][y1 - 1] - dp[x1 - 1][y2] + dp[x1 - 1][y1 - 1];
            }

        return ans;
    }
};

小贴士


千万不要乱取变量名!!!

如果col取成c,x1, x2, y1, y2取成a, b, c, d,就会出现命名冲突,就会发生意想不到的错误。

相关推荐
吠品1 小时前
Python 写入 Excel 的两种主流方案实际用法总结
c语言·开发语言·算法
Zane19941 小时前
归并排序和堆排序都能保证O(nlogn),为什么谁也没法把稳定和原地两个优点占全
算法·排序算法
天天喝旺仔1 小时前
Go 泛型实战:从类型参数、约束到可复用泛型容器与函数
数据结构·算法·容器·go
可爱的小小小狼1 小时前
【无标题】
java·算法
和裕2 小时前
平口开槽箱 vs 飞机盒 vs 扣底盒:自动化、展示效果与成本核心区别
大数据·运维·网络·人工智能·算法·自动化
AgentMaster2 小时前
从售前到售后全链路覆盖:智能客服在企业 5 大场景的落地实践与工具选型
大数据·人工智能·算法
Cccp.1232 小时前
【leetcode】(六) 图和贪心算法
数据结构·算法·leetcode
y1su2 小时前
【Leetcode】1477. 找两个和为目标值且不重叠的子数组
数据结构·后端·算法·leetcode·职场和发展
不会就选b11 小时前
算法日常・每日刷题--<贪心>14
算法