Skip to content

Commit 5b139e7

Browse files
committedMay 4, 2024·
prefix sum
1 parent 234c85b commit 5b139e7

File tree

1 file changed

+378
-0
lines changed

1 file changed

+378
-0
lines changed
 

‎_tutorials/2024-05-05-prefix-sum.md

+378
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,378 @@
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

Comments
 (0)
Please sign in to comment.