|
| 1 | +--- |
| 2 | +layout: post |
| 3 | +title: Refresh - 前缀和 |
| 4 | +date: 2024-05-05 00:54:43 +0800 |
| 5 | +render_with_liquid: false |
| 6 | +--- |
| 7 | + |
| 8 | +前缀和 + hash表优化查询速度。 |
| 9 | + |
| 10 | +1. Table of Contents, ordered |
| 11 | +{:toc} |
| 12 | + |
| 13 | + |
| 14 | +# 前缀和 |
| 15 | +前缀和指的是一个数组当前及之前位置所有数字的和。**获取前缀和数组s后,[i, j)这一段的数组和,可以直接用s[j] - s[i]来得到,所以前缀和常用来处理和“连续子区间”相关的问题**。 |
| 16 | + |
| 17 | +在一个数组中,根据i,在数组中寻找j,使得i + j = k,最快的方式是使用map达到On的时间复杂度。而前缀和需要s[j] - s[i] = k,所以前缀和经常使用map来根据s[j]寻找s[i],来达到加速查询的目的。 |
| 18 | + |
| 19 | +> 关于前缀和的详细定义,可以看[这个回答](https://leetcode.cn/problems/find-longest-subarray-lcci/solutions/2160308/tao-lu-qian-zhui-he-ha-xi-biao-xiao-chu-3mb11/)。 |
| 20 | +
|
| 21 | +# 思路演进 |
| 22 | +> 参考[暴力解法、前缀和、前缀和优化](https://leetcode.cn/problems/subarray-sum-equals-k/solutions/247577/bao-li-jie-fa-qian-zhui-he-qian-zhui-he-you-hua-ja/) |
| 23 | +
|
| 24 | +求一段区间的和,那么就要用两层for遍历所有的位置,作为区间的两端,O(n2)。然后再计算这一段区间内所有数据的和,O(n3)。当然因为是连续的求和,区间终点后移一位的时候可以利用之前一段区间的sum,从而不用把所有元素重新计算一遍,还是n2: |
| 25 | +```java |
| 26 | +public class Solution { |
| 27 | + public int subarraySum(int[] nums, int k) { |
| 28 | + int count = 0; |
| 29 | + for (int start = 0; start < nums.length; ++start) { |
| 30 | + int sum = 0; |
| 31 | + for (int end = start; end >= 0; --end) { |
| 32 | + sum += nums[end]; |
| 33 | + if (sum == k) { |
| 34 | + count++; |
| 35 | + } |
| 36 | + } |
| 37 | + } |
| 38 | + return count; |
| 39 | + } |
| 40 | +} |
| 41 | +``` |
| 42 | +即便如此,还是出现了很多重复计算。 |
| 43 | + |
| 44 | +一般**求一段区间的和为某个值,都会用到前缀和(假设为f(x))**。i~j的和为k,也就意味着f(j)-f(i-1)=k。**从而把题目转化为了:在一个数组中(数组的值为原数组的前缀和),求一共有多少对数,他们的差为k**。这就一下子变成了类似LeetCode第一题:[1. 两数之和](https://leetcode.cn/problems/two-sum/description/)。如果先用for遍历第一个数,再用for寻找另一个数,那也是On2的复杂度。所以用map优化,直接以O1的复杂度找到另一个数。 |
| 45 | + |
| 46 | +创建新的前缀和数组的时候注意,要多加一个元素f(0)=0, 表示一个空数组的元素和。为什么要额外定义它?想一想,**如果符合条件的子数组恰好从0开始,你要用f(right)减去谁呢?通过定义 f(0)=0,任意子数组都可以表示为两个前缀和的差**。此时,**[i, j]区间的和为f(j) - f(i - 1)**。 |
| 47 | + |
| 48 | +[和为 K 的子数组](https://leetcode.cn/problems/subarray-sum-equals-k/description/): |
| 49 | +```java |
| 50 | + public int subarraySum(int[] nums, int k) { |
| 51 | + |
| 52 | + int n = nums.length; |
| 53 | + |
| 54 | + int[] sum = new int[n + 1]; |
| 55 | + // f(0) = 0 |
| 56 | + sum[0] = 0; |
| 57 | + |
| 58 | + // 转成前缀和后,求的是s[j] - s[i] = k。如果直接遍历是n^2 |
| 59 | + for (int i = 1; i < n + 1; i++) { |
| 60 | + sum[i] = sum[i - 1] + nums[i - 1]; |
| 61 | + } |
| 62 | + |
| 63 | + // 在第一题两数之和中,快速找另一个数的办法是用hash map |
| 64 | + int result = 0; |
| 65 | + // number -> freq |
| 66 | + Map<Integer, Integer> map = new HashMap<>(); |
| 67 | + for (int j = 0; j < n + 1; j++) { |
| 68 | + // 找之前的前缀和,s[i] = s[j] - k |
| 69 | + int former = sum[j] - k; |
| 70 | + if (map.containsKey(former)) { |
| 71 | + result += map.get(former); |
| 72 | + } |
| 73 | + |
| 74 | + map.put(sum[j], map.getOrDefault(sum[j], 0) + 1); |
| 75 | + } |
| 76 | + |
| 77 | + return result; |
| 78 | + } |
| 79 | +``` |
| 80 | + |
| 81 | +**前缀和的问题有两个关键点:** |
| 82 | +1. **第一个关键点在于转化:怎么把一段区间的求值问题转化成前缀和数组里的某两个数的关系**; |
| 83 | +2. **第二个关键点在于撇清关系:在转化后,除非让给出原区间的详情,否则转换后的问题已经和原区间没有任何关系了**; |
| 84 | + - 如果真让求原区间,那么**f(j) - f(i)代表[i, j)区间的和**(建议写个简单的数组[1, 2, 3],并写出其前缀和数组[0, 1, 3, 6],再看这个关系就很明确了:f(3) - f(1) = 5 = [1, 3)区间的和); |
| 85 | + |
| 86 | +# 转化 |
| 87 | +## 同余 |
| 88 | +**前缀和经常和同余定理一起出现,因为求子数组的和能被k整除,实际上就是f(j) - f(i) = nk,即f(j)和f(i)同余**。 |
| 89 | + |
| 90 | +比如[连续的子数组和](https://leetcode.cn/problems/continuous-subarray-sum/description/),根据[这个题解](https://leetcode.cn/problems/continuous-subarray-sum/solutions/808246/gong-shui-san-xie-tuo-zhan-wei-qiu-fang-1juse/)可以知道,**其实是在求新数组中(前缀和数组)两个数同余**。题目加了额外限定条件为区间长度至少为2(原区间下标差至少为1),也就是说新的数组里两个数的下标差至少为2。 |
| 91 | + |
| 92 | +按照上面总结的关键点,此时题目变成了: |
| 93 | +1. 求新数组里两个数同余; |
| 94 | +2. 两个数的下标差>=2; |
| 95 | + |
| 96 | +按照转化后的这两点去实现代码就行了,忘记原数组: |
| 97 | +```java |
| 98 | +class Solution { |
| 99 | + public boolean checkSubarraySum(int[] nums, int k) { |
| 100 | + int n = nums.length; |
| 101 | + |
| 102 | + int[] f = new int[n + 1]; |
| 103 | + f[0] = 0; |
| 104 | + |
| 105 | + for (int i = 1; i < n + 1; i++) { |
| 106 | + f[i] = f[i - 1] + nums[i - 1]; |
| 107 | + } |
| 108 | + |
| 109 | + // f(j) - f(i) = nk, 假设k为3,则fj - fi = 3n,所以fj和fi模3同余 |
| 110 | + // <模, 索引> |
| 111 | + Map<Integer, Integer> map = new HashMap<>(); |
| 112 | + for (int j = 0; j < n + 1; j++) { |
| 113 | + int former = f[j] % k; |
| 114 | + if (map.containsKey(former)) { |
| 115 | + // 两个数的距离至少差2 |
| 116 | + if (j - map.get(former) >= 2) { |
| 117 | + return true; |
| 118 | + } |
| 119 | + } else { |
| 120 | + map.put(former, j); |
| 121 | + } |
| 122 | + } |
| 123 | + |
| 124 | + return false; |
| 125 | + } |
| 126 | +} |
| 127 | +``` |
| 128 | + |
| 129 | +[和可被 K 整除的子数组](https://leetcode.cn/problems/subarray-sums-divisible-by-k/description/),整体思路和上题一致,也是同余,但是这一题可出现负数,所以前缀和也可能出现负值。 |
| 130 | + |
| 131 | +一开始还想讨论s[j]和s[i]分别为正负时候的情况,但是太麻烦了。后来发现同余定理里,不分负数和正数(只要负数取正余数即可)。比如-4和2,关于3同余。-4 % 3 = 2, 2 % 3 = 2,则无论-4 - 2还是2 - (-4)都能被3整除。 |
| 132 | + |
| 133 | +**那么-4 % 3应该是几**? |
| 134 | +- **如果取负余数,则-4 % 3 = -1 * 3 + (-1),负余数为-1**; |
| 135 | +- **如果取正余数,则-4 % 3 = -2 * 3 + 2,正余数为2**; |
| 136 | + |
| 137 | +java里的模取的是负余数,python的模取的是正余数。**而[同余定理](https://baike.baidu.com/item/%E5%90%8C%E4%BD%99%E5%AE%9A%E7%90%86/1212360)指的是正余数,所以在java里需要需要把负余数转换成正余数,即(x % k + k) % k,无论x为正还是负,最终取的都是正余数**。 |
| 138 | +```java |
| 139 | +class Solution { |
| 140 | + public int subarraysDivByK(int[] nums, int k) { |
| 141 | + |
| 142 | + int n = nums.length; |
| 143 | + int[] preSum = new int[n + 1]; |
| 144 | + preSum[0] = 0; |
| 145 | + |
| 146 | + for (int i = 1; i < n + 1; i++) { |
| 147 | + preSum[i] = preSum[i - 1] + nums[i - 1]; |
| 148 | + } |
| 149 | + |
| 150 | + int result = 0; |
| 151 | + // mod -> freq |
| 152 | + Map<Integer, Integer> map = new HashMap<>(); |
| 153 | + |
| 154 | + // s[j] % k == s[i] % k |
| 155 | + for (int j = 0; j < n + 1; j++) { |
| 156 | + // java里负数的余数也是负数,再+k可转为正余数 |
| 157 | + int mod = (preSum[j] % k + k) % k; |
| 158 | + result += map.getOrDefault(mod, 0); |
| 159 | + map.put(mod, map.getOrDefault(mod, 0) + 1); |
| 160 | + } |
| 161 | + |
| 162 | + return result; |
| 163 | + } |
| 164 | +} |
| 165 | +``` |
| 166 | + |
| 167 | +[1590. 使数组和能被 P 整除](https://leetcode.cn/problems/make-sum-divisible-by-p/description/),这个题更麻烦一些:总数组的和sum去掉子数组的和(f(j) - f(i))能被p整除,即sum和f(j) - f(i)同余,即f(j) - sum和f(i)同余: |
| 168 | +```java |
| 169 | +class Solution { |
| 170 | + public int minSubarray(int[] nums, int p) { |
| 171 | + int k = p; |
| 172 | + |
| 173 | + int n = nums.length; |
| 174 | + int[] preSum = new int[n + 1]; |
| 175 | + preSum[0] = 0; |
| 176 | + |
| 177 | + for (int i = 1; i < n + 1; i++) { |
| 178 | + preSum[i] = (preSum[i - 1] + nums[i - 1]) % k; |
| 179 | + } |
| 180 | + int sum = preSum[n]; |
| 181 | + |
| 182 | + int result = Integer.MAX_VALUE; |
| 183 | + // mod -> min index |
| 184 | + Map<Integer, Integer> map = new HashMap<>(); |
| 185 | + |
| 186 | + // (s[j] - s[i]) % k = sum % k, (s[j] - sum) % k = s[i] % k |
| 187 | + for (int j = 0; j < n + 1; j++) { |
| 188 | + |
| 189 | + // 如果一个不删,也是符合条件的,所以s[0]也可能满足条件,因此先put,再计算 |
| 190 | + int mod2 = (preSum[j] % k + k) % k; |
| 191 | + map.put(mod2, j); |
| 192 | + |
| 193 | + int mod1 = ((preSum[j] - sum) % k + k) % k; |
| 194 | + if (map.containsKey(mod1)) { |
| 195 | + result = Math.min(result, j - map.get(mod1)); |
| 196 | + } |
| 197 | + } |
| 198 | + |
| 199 | + return result == n ? -1 : result; |
| 200 | + } |
| 201 | +} |
| 202 | +``` |
| 203 | +**注意,前缀和用map的时候,如果f(0)本身也符合条件(即本题中一个元素都不去掉,原数组本来就能被p整除),此时应该先往map里put,否则的话会漏掉f(0)。** |
| 204 | + |
| 205 | +## 同值 |
| 206 | +有一些前缀和的问题,转化条件相对隐晦,但是因为不需要同余,所以反而简单一些。**比如经常遇到的“某一段区间两类元素个数相等”,如果把一种定义为1,另一种定义为-1,其实就是在求子区间和为0,即f[j] = f[i]**。 |
| 207 | + |
| 208 | +[525. 连续数组](https://leetcode.cn/problems/contiguous-array/description/),如果把0当做-1,1当做1,那么某一段0和1个数相同,就是这一段的和为0,即s[j] - s[i] = 0,即s[j] = s[i]: |
| 209 | +```java |
| 210 | +class Solution { |
| 211 | + public int findMaxLength(int[] nums) { |
| 212 | + int n = nums.length; |
| 213 | + |
| 214 | + int[] preSum = new int[n + 1]; |
| 215 | + preSum[0] = 0; |
| 216 | + |
| 217 | + for (int i = 1; i < n + 1; i++) { |
| 218 | + preSum[i] = preSum[i - 1] + (nums[i - 1] == 1 ? 1 : -1); |
| 219 | + } |
| 220 | + |
| 221 | + int result = 0; |
| 222 | + // value -> min index |
| 223 | + Map<Integer, Integer> map = new HashMap<>(); |
| 224 | + for (int i = 0; i < n + 1; i++) { |
| 225 | + if (map.containsKey(preSum[i])) { |
| 226 | + // 找前缀和数组里最远的两个相同的数,即s[j] == s[i],且j - i至少为2 |
| 227 | + int index = map.get(preSum[i]); |
| 228 | + int range = i - index; |
| 229 | + if (range >= 2 && range > result) { |
| 230 | + result = range; |
| 231 | + } |
| 232 | + } else { |
| 233 | + map.put(preSum[i], i); |
| 234 | + } |
| 235 | + } |
| 236 | + |
| 237 | + return result; |
| 238 | + } |
| 239 | +} |
| 240 | +``` |
| 241 | + |
| 242 | +[面试题 17.05. 字母与数字](https://leetcode.cn/problems/find-longest-subarray-lcci/description/)稍微麻烦一些,要把取最值时候的下标记录下来。记住,**前缀和数组和原数组的下标关系为:前缀和数组的s[r] - s[l]代表的是原数组[l, r)的前缀和**: |
| 243 | +```java |
| 244 | +class Solution { |
| 245 | + |
| 246 | + // 前缀和主要记住一个关键点:前缀和数组的s[r] - s[l]代表的是原数组[l, r)的前缀和 |
| 247 | + public String[] findLongestSubarray(String[] array) { |
| 248 | + int n = array.length; |
| 249 | + |
| 250 | + int[] prefixSum = new int[n + 1]; |
| 251 | + prefixSum[0] = 0; |
| 252 | + for (int i = 1; i < n + 1; i++) { |
| 253 | + // 数字和字母,一个1,一个-1 |
| 254 | + int digit = array[i - 1].charAt(0) >= '0' && array[i - 1].charAt(0) <= '9' ? 1 : -1; |
| 255 | + prefixSum[i] = prefixSum[i - 1] + digit; |
| 256 | + } |
| 257 | + |
| 258 | + Map<Integer, Integer> firstIndex = new HashMap<>(); |
| 259 | + int max = 0, maxLeft = -1, maxRight = -1; |
| 260 | + |
| 261 | + // i从0开始遍历前缀和数组,则前缀和数组的s[r] - s[l]代表的是原数组[l, r)的前缀和 |
| 262 | + for (int i = 0; i < n + 1; i++) { |
| 263 | + int sum = prefixSum[i]; |
| 264 | + if (firstIndex.containsKey(sum)) { |
| 265 | + int first = firstIndex.get(sum); |
| 266 | + int diff = i - first; |
| 267 | + if (diff > max) { |
| 268 | + max = diff; |
| 269 | + maxLeft = first; |
| 270 | + maxRight = i; |
| 271 | + } |
| 272 | + } else { |
| 273 | + firstIndex.put(sum, i); |
| 274 | + } |
| 275 | + } |
| 276 | + |
| 277 | + if (max == 0) { |
| 278 | + return new String[] {}; |
| 279 | + } else { |
| 280 | + // 正好求的就是[l, r) |
| 281 | + return Arrays.copyOfRange(array, maxLeft, maxRight); |
| 282 | + } |
| 283 | + } |
| 284 | +} |
| 285 | +``` |
| 286 | + |
| 287 | +[1542. 找出最长的超赞子字符串](https://leetcode.cn/problems/find-longest-awesome-substring/description/) |
| 288 | + |
| 289 | +## dfs + 前缀和 |
| 290 | +这道题很漂亮!和dfs结合在一起。 |
| 291 | + |
| 292 | +[路径总和 III](https://leetcode.cn/problems/path-sum-iii/description/)。 |
| 293 | + |
| 294 | +> 最直观的思路是像树的覆盖一样,内层要做一个dfs用于比较两棵树,外层要做一个dfs用于比较整棵树。On2 |
| 295 | +
|
| 296 | +如果用前缀和,则能达到On的复杂度,不过思路上稍微复杂一些: |
| 297 | +1. 首先,这里的前缀和数组指的是root为起点当前节点为终点的这一枝上的所有节点组成的前缀和数组。 |
| 298 | +2. 按照dfs的思路,如果回溯,前缀和数组里的当前节点应该pop掉。 |
| 299 | + |
| 300 | +那么我们在dfs的过程中实际上形成了无数个前缀和数组,在[和为 K 的子数组](https://leetcode.cn/problems/subarray-sum-equals-k/description/)里,根据前缀和数组获取diff k,即使用map优化,也要On的复杂度。这么一来,其实还是On2的复杂度。 |
| 301 | + |
| 302 | +但是我们其实还可以优化一下[和为 K 的子数组](https://leetcode.cn/problems/subarray-sum-equals-k/description/)的解法:前缀和数组一定要生成出来吗?能不能边遍历边寻找diff k?可以的: |
| 303 | +```java |
| 304 | + public int subarraySum(int[] nums, int k) { |
| 305 | + int n = nums.length, result = 0; |
| 306 | + |
| 307 | + // number -> freq |
| 308 | + Map<Integer, Integer> map = new HashMap<>(); |
| 309 | + // 前缀和数组的第一个 |
| 310 | + map.put(0, 1); |
| 311 | + |
| 312 | + int preSum = 0; |
| 313 | + for (int i = 0; i < n; i++) { |
| 314 | + int curSum = preSum + nums[i]; |
| 315 | + result += map.getOrDefault(curSum - k, 0); |
| 316 | + map.put(curSum, map.getOrDefault(curSum, 0) + 1); |
| 317 | + |
| 318 | + // preSum |
| 319 | + preSum = curSum; |
| 320 | + } |
| 321 | + |
| 322 | + return result; |
| 323 | + } |
| 324 | +``` |
| 325 | + |
| 326 | +> 注意对前缀和数组第一个元素的记录`map.put(0, 1)`,别忘了! |
| 327 | +
|
| 328 | +不过这么写在[和为 K 的子数组](https://leetcode.cn/problems/subarray-sum-equals-k/description/)这道题里没什么优势,复杂度依旧是On,写起来反而容易出错,所以不如先把前缀和数组生成出来。 |
| 329 | + |
| 330 | +但是在[路径总和 III](https://leetcode.cn/problems/path-sum-iii/description/),这么写就很有用了,可以把前缀和数组优化掉,直接边遍历变寻找diff k,不需要先生成很多个前缀和数组,再对每个数组做查找: |
| 331 | +1. 首先,这里的前缀和数组指的是root为起点当前节点为终点的这一枝上的所有节点组成的前缀和数组。 |
| 332 | +2. 按照dfs的思路,如果回溯,前缀和数组里的当前节点应该pop掉。 |
| 333 | +3. dfs的过程中,只维护map而非前缀和数组边,这样的话整个过程的复杂度就是On。 |
| 334 | + |
| 335 | +```java |
| 336 | +class Solution { |
| 337 | + public int pathSum(TreeNode root, int targetSum) { |
| 338 | + if (root == null) { |
| 339 | + return 0; |
| 340 | + } |
| 341 | + |
| 342 | + // map: preSum -> freq |
| 343 | + Map<Long, Integer> map = new HashMap<>(); |
| 344 | + // 前缀和数组的第一个 |
| 345 | + map.put(0L, 1); |
| 346 | + |
| 347 | + return dfs(root, map, 0, targetSum); |
| 348 | + } |
| 349 | + |
| 350 | + // map: preSum -> freq |
| 351 | + private int dfs(TreeNode root, Map<Long, Integer> map, long preSum, int k) { |
| 352 | + int result = 0; |
| 353 | + if (root == null) { |
| 354 | + return 0; |
| 355 | + } |
| 356 | + |
| 357 | + long curSum = preSum + root.val; |
| 358 | + result += map.getOrDefault(curSum - k, 0); |
| 359 | + map.put(curSum, map.getOrDefault(curSum, 0) + 1); |
| 360 | + |
| 361 | + // 不管是否满足条件,都要继续递归下去 |
| 362 | + result += dfs(root.left, map, curSum, k); |
| 363 | + result += dfs(root.right, map, curSum, k); |
| 364 | + |
| 365 | + // 恢复现场 |
| 366 | + map.put(curSum, map.get(curSum) - 1); |
| 367 | + return result; |
| 368 | + } |
| 369 | +} |
| 370 | +``` |
| 371 | +> 这题卡int溢出的用例,所以只能用long保存sum了,map的key也只能是Long。 |
| 372 | +
|
| 373 | +这里只有map是有状态的,所以恢复现场只回复它一个就行了。 |
| 374 | + |
| 375 | + |
| 376 | + |
| 377 | + |
| 378 | + |
0 commit comments