前缀和算法

前缀和是一种预处理技巧,核心思想 是:提前算好 "从起点到当前位置" 的累加和,用O (1) 时间快速求出任意区间和,把原本暴力 O (n) 的查询降到常数级。只能处理静态数组(不能边改边查,修改要 O (n))

# 一维数组前缀和

1)基本概念 ------ 假设有一个数组:

定义前缀和数组s,简单来说:

  • s[i] 表示前 i 项的总和
  • s[0]=0 ,是为了方便计算从第 1 项开始的区间,即数组的下标从1开始计算,也就是往前缀和数组中添加元素从1下标开始添加。但是当创建好一个数组,默认下标都是从0开始的,那么就单独处理一下s[0]的值,直接让它为0,由于创建好的数组已经被默认初始化为0,因此相当于无需处理。
    • 原因:避免 i = 0 时出现 -1 下标越界,从1开始不用写额外判断判断0的情况,代码更干净
    • 注意:如果想要让数组下标从1开始,那么数组大小应该是 n+1

2)核心思路:前缀和的作用就是快速求出数组中某一个连续区间的和。

  • 想求原数组中 [l, r] 区间的和:
  • 用前缀和公式可以直接算出:
    • 该公式中,s[r] 表示前 r 项总和,**s[l−1]**表示前 l -1 项总和。
    • 两者相减,就剩下从 l 到 r 的部分。

3)计算步骤

1.预处理出一个前缀和数组

  • 遍历一遍原数组arr,求出前缀和数组 s:s[i]=s[i−1]+arr[i] ------ 求和公式
  • 解释公式:前缀和数组中的每一个元素都是原数组arr中前 i 个元素的和,即求出原数组中前 i 个元素的和(从下标1到 i),放到对应前缀和数组中的 i(从1开始) 位置
  • 时间复杂度:O(n)

2.使用前缀和数组 查询

  • 任意区间**[l,r]** 和 直接用公式sum[l,r] = s[r] - s[l-1]
  • 即想要知道原数组前 i 个元素的和,直接在前缀和数组中利用上述查询。
  • 时间复杂度:O(1)

3.示例:

有一个数组:a=[1,2,3,4,5],求区间 [2,4]的和。

以上题目的时间复杂度:

  • 预处理前缀和数组:O(n)
    单次区间和查询:O(1)
    整体复杂度:O(n + q)(q 为查询次数)

对比暴力枚举:

  • 暴力每次查询:O (n)
    q 次查询就是 O(n·q),数据一大就会超时。

4)适用场景:

  • 多次查询子数组和
    求连续子数组和等于 k 的数量
    二维数组的二维前缀和(求子矩阵和)
    差分算法配套使用

接下来,我们通过一道算法题的练习,来熟悉这种算法思路。

1.【模板】一维前缀和

牛客-【模板】前缀和

题目以及示例:

算法思路:这道题就是典型的前缀和算法,与前面的示例基本相同,直接写出代码即可。

java 复制代码
public class Main {
    public static void main(String[] args) {
        Scanner in = new Scanner(System.in);
        //n表示原数组的大小,q表示查询任意区间的次数
        System.out.print("输入数组大小:");
        int n = in.nextInt();
        System.out.print("输入查询次数:");
        int q = in.nextInt();
        System.out.print("输入数组arr中的元素:");
        int[] arr = new int[n+1];
        for (int i = 1; i <= n; i++) {
            arr[i] = in.nextInt();
        }

        //1.预处理前缀和数组
        long[] s = new long[n+1];//防止溢出
        for (int i = 1; i <= n; i++) {
            s[i] = s[i-1] + arr[i];
        }
        //2.查询
        while (q > 0) {
            System.out.print("输入要查询的区间的左区间:");
            int l = in.nextInt();
            System.out.print("输入要查询的区间的右区间:");
            int r = in.nextInt();
            System.out.print("该区间的和:");
            System.out.println(s[r] - s[l-1]);
            q--;
        }
    }
}

# 二维数组前缀和

在上一道题以及思路公式中,都是针对一维数组 的前缀和算法,当是二维数组 时,前缀和思路:

  • 一维是求一段区间和,二维就是求一个子矩阵和。
  • 思路完全是一维的扩展,只是公式稍微复杂一点。
  • 依然是两个步骤:预处理一个前缀和数组 + 查询,即使用前缀和数组

1)基本概念

  • 设原二维数组为a[i][j]
  • 定义二维前缀和数组s[i][j] :s[i][j] = 从 左上角 (1,1) 到 右下角 (i,j) 的矩形内所有数的总和
    • 下标从 (1,1) 开始而不是从 (0,0) 开始的原因与一维数组的一样,且s[0][0]=0

2)核心思路步骤:前缀和的作用就是快速求出数组中某一个子矩阵的和

  • 1.求前缀和数组 s(预处理一个前缀和数组):
    • 理解:s[i][j] 表示从 [1,1] 位置到 [i,j] 位置这段区间的所有元素之和,即前缀和数组中每个元素代表原数组 a 中的某个子矩阵的和。
    • 为什么求前缀和数组中每个元素的值是上述的公式呢?如以下图解释:
    • 预处理时间复杂度O(n·m)
  • 2.求任意子矩阵和(查询,使用前缀和数组):
    • 理解:前面一个公式的目的是为了求出前缀和数组中每个元素的值,而这个公式是为了求出原数组中的某一个子矩阵的和,要使用到求好的前缀和公式。
    • 公式分析如以下的图:
    • 单次查询时间复杂度O(1),q次查询时间复杂度O(q)
  • 3.二维前缀和整体时间复杂度O(nm+q)

3)一句话记忆

  • 构建/预处理前缀和:自己(D) + 上边(A+B) + 左边(A+C) - 左上角重复部分(A)
  • 求子矩阵:大矩形(A+B+C+D) - 上面(A+B) - 左面(A+C) + 多减的左上角(A)

接下来,我们通过一道算法题的练习,来熟悉这种算法思路。

2.【模板】二维前缀和

牛客-【模板】二维前缀和

题目以及示例:

算法思路:这道题就是典型的二维前缀和算法,直接根据公式写出代码即可。

java 复制代码
public class Main {
    public static void main(String[] args) {
        Scanner in = new Scanner(System.in);
        System.out.println("矩阵的行和列:");
        int n = in.nextInt(),m = in.nextInt();
        System.out.println("查询次数:");
        int q = in.nextInt();
        //创建数组arr,并写入数据
        int[][] arr = new int[n+1][m+1];
        System.out.println("输入矩阵中的元素:");
        for (int i = 1; i <= n; i++) {
            for (int j = 1; j <= m; j++) {
                arr[i][j] = in.nextInt();
            }
        }

        //1.预处理前缀和数组s
        long[][] s = new long[n+1][m+1];
        for (int i = 1; i <= n; i++) {
            for (int j = 1; j <= m; j++) {
                s[i][j] = s[i-1][j]+s[i][j-1]+arr[i][j]-s[i-1][j-1];
            }
        }
        //2.查询,使用前缀和数组
        while (q > 0) {
            //写入要查询的矩阵区间坐标
            System.out.println("输入矩阵起始和结束坐标:");
            int x1 = in.nextInt(),y1 = in.nextInt(),x2 = in.nextInt(),y2 = in.nextInt();
            System.out.println(s[x2][y2]-s[x1-1][y2]-s[x2][y1-1]+s[x1-1][y1-1]);
            q--;
        }
    }
}

3.寻找数组的中心下标

力扣-寻找数组的中心下标

题目以及示例:

算法思路:根据题目意思,我们需要返回数组中某一个下标,且该下标的左侧元素之和等于右侧元素之和,如果是,那么就返回这个中心下标 i ,如果数组中有多个这样的中心下标,那么一定返回最左边的那个中心下标,如果数组中不存在这样的中心下标,则返回-1。

注意:此道题中,并不是模板中需要自己输入数组元素,而是已经给你一个数组nums,那么此时就不能再是可以自定从下标1开始放入元素,而是依据nums,是从0开始的,数组的大小就是n,但是在遍历时可以从下标1开始遍历,依然会提前处理下标为0时的情况。

这道题可以使用前缀和思路来做,可以预处理两个前缀和数组,即中心下标 i 前后两个区间,一个前缀个数组f 表示的是 [0,i-1] 这个区间所有元素的和,一个后缀和数组g 表示的是 [i+1,n-1] 这个区间所有元素的和。

处理细节问题:

  • 根据题目,下标 0 左侧无元素,左侧和视为 0,下标 n-1 右侧无元素,右侧和视为 0;也就是说,前0个元素的和为0,后 n-1 个元素的和为0,那么:
    • f[0] = 0
    • g[n-1] = 0
  • 前缀和数组f 是从左向右 开始添加元素的,而后缀和数组 g 是从右向左开始添加元素的,视 中心坐标 i 两边 i-1 和 i+1 为 f 和 g 的最后一个元素。

1)预处理前缀和后缀和数组:

  • 前缀和数组 f:
    • f[i] 表示原数组nums [0,i-1] 区间中的元素的前缀和
      • 求 f 数组中元素的公式: f[i] = f[i-1] + nums[i-1]
  • 后缀和数组 g:
    • g[i] 表示原数组 [i+1,n-1] 区间中元素的前缀和
      • 求 g 数组中元素的公式: g[i] = g[i+1] + nums[i+1]

2)使用前缀和后缀和数组:

  • 遍历原数组nums,如果 i 的左右区间 f[i] == g[i] ,那么该下标为中心下标,直接返回改下标,这个下标一定是最左边的。
java 复制代码
public int pivotIndex(int[] array) {
    int n = array.length;
    int[] f = new int[n];
    int[] g = new int[n];
    //f[0]与g[n-1]在创建数组时已经初始化为0了
    //1.预处理
    //前缀和
    for (int i = 1; i < n; i++) {
        f[i] = f[i-1] + array[i-1];
    }
    //后缀和
    for (int i = n-2; i >= 0; i--) {
        g[i] = g[i+1] + array[i+1];
    }
    //2.使用
    for (int i = 0; i < n; i++) {
        if (f[i] == g[i]) {
            return i;
        }
    }
    return -1;
}

4.除自身以外数组的乘积

力扣-除自身以外数组的乘积

题目以及示例:

算法思路:该题与上一道题类似,只不过这里求的是前缀积和后缀积,即遍历数组nums,求遍历到的除了 i 之外所有元素的乘积,并返回一个数组。

例如示例1中,返回的数组第一个元素24,就是nums中除了1之外所有元素的乘积,其他的一样。

处理细节:

  • 与上一道题不同的是,这里的f[0] = 1,g[n-1] = 1,因为要算的是乘积,如果是0的话那么所有值都是0了。
  • 依然 f 从左向右,g 从右向左。

1)预处理:

  • f[i] = f[i-1] * nums[i-1]
  • g[i] = g[i+1] * nums[i+1]

2)使用:

  • 遍历nums,求出 i 的 f[i]*g[i] 的乘积,就是除了 i 外数组中所有元素的乘积,结果放在数组ret中,最后返回数组ret。
java 复制代码
public int[] productExceptSelf(int[] arr) {
    int n = arr.length;
    int[] f = new int[n];
    int[] g = new int[n];
    //处理边界
    f[0] = 1;
    g[n-1] = 1;
    //1.预处理
    for (int i = 1; i < n; i++) {
        f[i] = f[i-1] * arr[i-1];
    }
    for (int i = n-2; i >= 0; i--) {
        g[i] = g[i+1] * arr[i+1];
    }
    //使用
    int[] ret = new int[n];
    for (int i = 0; i < n; i++) {
        ret[i] = f[i] * g[i];
    }
    return ret;
}

5.和为k的子数组

力扣-和为k的子数组

题目以及示例:

算法思路:此道题要求的是nums中和为 k 的子数组的个数,且根据提示,nums中元素可以是0或者是负数,因此这道题是不可以使用 滑动窗口 来解决的,没有单调区间,且由于可以是0或者负数,left和right指针中间的某一段连续区间还可能也是目标区间,但是right永远无法往回走。

这道题的应该使用 前缀和 的思路来做,如果是暴力枚举的做法,是从头开始遍历数组寻找符合的子数组,即寻找以 i 位置开始的符合条件的子数组,那么根据暴力枚举的思路,利用前缀和思想就是 求 以 i 位置为结尾的所有子数组 ,即前 i 个元素的前缀和sum[i] 子数组,在这前 i 个元素的前缀和中,寻找和为 k 的子数组的个数,但是直接寻找这样的子数组比较难,我们可以通过寻找和为 sum[i] - k 的子数组的个数,间接知道了和为 k 的子数组个数

即 求出以 i 位置为结尾的所有子数组,并在 [0,i-1] 区间内,求出前缀和等于 sum[i]-k 的个数。

注意 ,此时如果还是预处理一个前缀和数组的话,时间复杂度甚至会比暴力枚举还高(O(n^2)),原因:创建一个前缀和数组时间复杂度为 O(n),然后还需要在该前缀和数组中遍历找出 前缀和为sum[i]-k 的子数组 的个数,时间复杂度是O(n^2),即直接采用预处理前缀和数组+使用 这样的做法时间复杂度为 O(n^2+n)。

如果把前缀和存在哈希表中,而不是存在一个数组,那么时间复杂度就能优化到 O(n)。因此,这道题的做法是采用 前缀和+哈希表 的做法。

  • 哈希表第一个参数表示 前缀和,第二个参数表示 次数。

总结:首先求出nums数组中前 i 个元素的前缀和,存放在哈希表中,然后在哈希表中寻找 和为sum[i]-k 的前缀和的个数,就是和为 k 的子数组的个数。

处理细节问题

    1. 前缀和加入哈希表的时机?
    • 在前面求前缀和数组的每个元素的值时的公式是 s[i] = s[i-1]+nums[i] ,即为前缀和数组中已经求好的前 i-1 个元素的和 + 该 i 位置上的元素,类比这个,那么应该是 计算 i 位置之前的前缀和加入哈希表,哈希表里面只保存 [0,i-1] 位置的前缀和。
    1. 不用真的创建一个前缀和数组,那么前一个位置的前缀和 s[i-1] 用一个变量 sum 来替换即可,即 sum = sum + nums[i]。
    1. 如果整个前缀和等于 k 呢?
    • 意味着整个原数组元素的和才等于 k,那么此时前一个位置的前缀和变成了 下标为-1的位置开始,即数组之前 ,越界了,那么我们可以提前将这个前缀和定义为 0 ,即在哈希表中预先存入 前缀和 0,计数为 1: hash<0,1> 【=> s[0] = 0】。
    1. 使用变量count 计数,当有前缀和等于 sum-k 时,count++,表示和为 k 的子数组的个数
java 复制代码
public int subarraySum(int[] nums,int k) {
    Map<Integer,Integer> hash = new HashMap<>();
    hash.put(0,1);

    int sum = 0,count = 0;
    for(int n : nums) {
        sum += n;//计算当前位置的前缀和
        count += hash.getOrDefault(sum-k,0);//统计当前位置的前一个位置前缀和中和为sum-k的前缀和个数
        hash.put(sum,hash.getOrDefault(sum,0)+1);//把当前前缀和保存到哈希表中
    } 
    return count;
}

6.和可被 k 整除的子数组

力扣-和可被k整除的子数组

题目以及示例:

算法思路:此道题与上一道题的算法思路相同。

我们先了解一下同余定理:

求出前 i 个元素的前缀和sum,假设 子数组x 是和可被 k 整除的子数组,那么要统计 子数组x 的个数,依然可以通过求出 和为 sum-x 的前缀和可以被 k 整除 的个数 而 间接求出 x 的个数:

  • 根据同余定理:由于 (sum-x)%k =0,那么 sum%k = x%k,进一步的,可以直接求可被k整除的 前缀和sum 的个数,来间接求出 x 的个数。

但是,还有一个问题 ------同余的数,取模结果符号不同 ,由于此道题中,nums中元素也可以是0或者负数,这就有可能出现问题,例如,-5%2 和 5%2 的结果其实是一样的,但是哈希表会把本该匹配的余数当成不同值,因此,需要对符号进行修正,将取余结果负数 修正成 正数 ,

修正公式为:a%p ------>变成a%p + p,例如,-5%2+2=1,负数就被修正成了正数。

当时,原本是正数的除数如果使用上述的公式,结果就不正确了,所以,就有

正负统一的修正公式: (a%p + p) % p,无论是正数还是负数,通过这个公式结果都是相同的。

接下来其他的细节等问题都和上一道题一样,不过该题中哈希表中存放的是前缀和的余数值

java 复制代码
public int subarraysDivByK(int[] nums,int k) {
    Map<Integer,Integer> hash = new HashMap<>();
    hash.put(0,1);//哈希表中存放余数,先处理是整个前缀和才被k整除的情况
    //hash.put(0 % k,1);

    int sum = 0,count = 0;
    for(int n : nums) {
        sum += n;//计算当前位置的前缀和
        int r = (sum % k + K) % k;//计算当前位置的前缀和余数
        count += hash.getOrDefault(r,0);//统计符合sum%k=0的前缀和个数
        hash.put(r,hash.getOrDefault(r,0) + 1);//把当前的sum%k结果保存到哈希表中
    }
    return count;
} 

7. 连续数组

力扣-连续数组

题目以及示例:

算法思路:与前两道题类似,不过这里要找的是符合条件的最长子数组。

题目给了一个只有0和1的二进制数组nums,要找出0和1数量相同的一个最长子数组,我们可以转换思路:把 0 转化成 -1 ,即找出 -1 和 1数量相同的最长子数组,也就是和为0 的最长子数组 ,就相当于第5题的 和为k的子数组,因此,我们可以按照这个思路来做这道题 ------ 前缀和+哈希表:

  • 在前缀和sum[i] 中,可以通过 sum[i]-0 间接找出 和为0的子数组 ,即找出前缀和sum[i] 中同样等于 sum 的那个****最短 的子数组 ,这样求出的那个 和为0的子数组 就是最长的子数组

细节问题

  • 1.哈希表中存什么?
    • 在 和为k的子数组 那道题中,求的是符合和为k的子数组的所有个数,即hash中存的分别是 前缀和 以及 这个前缀和出现的个数。
    • 但是在此道题中,求的是 和为0的最长子数组,只需要计算出那一个最长的即可,即hash中存的是最短前缀和 以及 这个前缀和出现的最小的下标
    1. 存入哈希表的时机?
    • 与那一道题一样,都是使用完之后,再存入哈希表中。
    1. 哈希表中有重复的 hash<sum,i> ,即有重复的前缀和相同的元素,但是下标不同,如何存?
    • 只需要保留前面已经存入的 <sum,i> 即可,因为已经存入的 sum,这个前缀和子数组的长度肯定比后存入的短,但是大小却一样,又由于要求出 和为0的最长子数组,因此只需要保留已经存入。
    1. 这个前缀和数组的和才等于0,该如何存?
    • 在前一道题中,是提前设置好有一个前缀和为0的情况,即hash<0,1>
    • 但是,此题中,要计算长度,且要记录的不是个数,而是下标,因此,要提前设置好有一个前缀和为0 的最小下标是-1的情况,即hash<0,-1>
    1. 长度如何算?
    • 如下图,在和为sum的数组中,有一个也是和为sum的最短子数组,那么和为0的子数组的长度是 i-j+1,但是和为0的子数组并不包含 j 位置上的元素,因此长度计算:
    • i-j+1-1 = i-j
java 复制代码
public int findMaxLength(int[] nums) {
    Map<Integer,Integer> hash = new HashMap<>();
    hash.put(0,-1);//默认有一个前缀和为0的情况,前缀和为0的最小下标为-1

    int sum = 0,ret = 0;
    for(int i = 0;i < nums.length; i++) {
        sum += (nums[i] == 0 ? -1 : 1);
        if(hash.containsKey(sum)) {
            ret = Math.max(ret,i - hash.get(sum));
        }else {
            hash.put(sum,i);
        }
    }
    return ret;
}

8.矩阵区域和

力扣-矩阵区域和

题目以及示例:

算法思路:翻译一下题目的意思就是:以 (i,j) 为中心,向上下左右各扩展 k 格,形成一个 "矩形区域",求这个区域内所有元素的和(超出矩阵边界的部分忽略)。

使用二维前缀和的思路来解这道题。

那么:

  • 求前缀和数组的公式:sum[i][j] = sum[i][j-1] + sum[i-1][j] - sum[i-1][j-1] + array[i][j]
  • 求任意矩阵和的公式:sum = sum[x2][y2] - sum[x2][y1-1] - sum[x1-1][y2] + sum[x1-1][y1-1]

在此道题中,关于 answer[i,j],有一些细节需要处理:

关于下标映射关系(就是最开始讲二维前缀和思路时,创建的前缀和数组大小+1):

java 复制代码
public static int[][] matrixBlockSum(int[][] mat,int k) {
    int m = mat.length,n = mat[0].length;

    //1.预处理前缀和数组
    int[][] answer = new int[m+1][n+1];
    for (int i = 1; i <= m; i++) {
        for (int j = 1; j <= n; j++) {
            answer[i][j] = answer[i][j-1]+answer[i-1][j]-answer[i-1][j-1]+mat[i-1][j-1];
        }
    }
    //2.使用
    int[][] ret = new int[m][n];
    for (int i = 0; i < m; i++) {
        for (int j = 0; j < n; j++) {
            int x1 = Math.max(0,i-k)+1,y1 = Math.max(0,j-k)+1;
            int x2 = Math.min(m-1,i+k)+1,y2 = Math.min(n-1,j+k)+1;
            ret[i][j] = answer[x2][y2]-answer[x1-1][y2]-answer[x2][y1-1]+answer[x1-1][y1-1];
        }
    }
    return ret;
}
相关推荐
睡觉就不困鸭2 小时前
第十八天 有效的括号
数据结构·算法
_日拱一卒2 小时前
LeetCode:148排序链表
算法·leetcode·链表
IpdataCloud2 小时前
IP查询工具的准确率怎么评估?一份可上生产的选型与验收指南
网络·人工智能·算法
生信研究猿2 小时前
leetcode 78.子集
算法·leetcode·深度优先
sycmancia2 小时前
Qt——文本编辑器中的功能交互
qt·算法
浅念-3 小时前
分治算法专题|LeetCode高频经典题目详细题解
数据结构·c++·算法·leetcode·职场和发展·排序·分治
Magic-Yuan3 小时前
算力的迷雾
人工智能·算法·机器学习
何何____3 小时前
web组第一次考核题解
算法
wayz113 小时前
Day 16:PCA主成分分析与降维
人工智能·算法·机器学习