写在前面
其实Leetcode的题解网上一大堆,笔者想做再做一个题解的主要原因有三:
- 多数题解较为简略,没有详细的过程
- 有详细过程的题解往往没有思考过程,作者为什么能想到这样解?作为一只菜鸟,关心的不止是题目的答案,而是我怎样才能想到这样的答案。很多答案其实是各种零散的思路或技巧组合在一起得到的。我们不知道这些小技巧,所以不能解出复杂的题目。如果我们知道常见的小技巧,那么无论题目怎么变,熟练运用这些技巧总能够解出来。就好像游戏里面的一些小连招,熟悉了自然会用的很灵活,打出很多很漂亮的操作。本题解着重想强调这些小技巧
- 多数题解只满足于一两种解法,但笔者希望覆盖尽可能全面的内容。特别是discuss里面的一些有趣的内容。笔者认为这样才能把题真正做透,达到举一反三的目的。
- C++的答案有很多,但Rust作为一门有趣的新兴语言,还鲜有相关的版本。
因此,笔者将详细描述思考过程,如果有多种方法尝试整理多种解法,同时,提供C++/Rust双语言答案。(当然,有伪代码则以C++为主,毕竟会想到去学Rust的一般都能读懂C++)
题目描述
In an array A of 0s and 1s, how many non-empty subarrays have sum S?
Example 1:
Input: A = [1,0,1,0,1], S = 2
Output: 4
Explanation:
The 4 subarrays are bolded below:
[1,0,1,0,1]
[1,0,1,0,1]
[1,0,1,0,1]
[1,0,1,0,1]
Note:
A.length <= 30000
0 <= S <= A.length
A[i] is either 0 or 1.
题目要求求解一个子数组(也就是原数组中连续的一段数)使其和等于S,有多少种可能?
解题思路及实现
朴素思路
题目要求求和等于S的子数组的所有可能性。最简单的方式就是直接枚举出所有的情况,然后看有多少种等于S。示意代码如下:
int ans = 0;
for (int i = 0; i < A.size(); ++i) {
for (int j = 0; j < A.size(); ++j {
if (sum(i,j) == S) {
ans++;
}
}
}
接下来我们来分析一下代码的复杂度。这段代码一共两重循环,每重循环要循环A.length次。假设sum是最朴素的从i累加到j的实现,那么可以粗略地认为,sum也是A.length规模的。所以这段代码的复杂度是O(n^3)。但是根据题目描述,A.length最大可能为30000。这样显然不可行。
在该思路的基础上,一个最直观的想法是优化sum。因为每次我们都要计算一遍sum,这实际上有很多重复的计算。如果要反复求某个数组某一段的和,有一个常用的技巧。该技巧利用的要点是: s u m ( i , j ) = s u m ( 0 , j ) − s u m ( 0 , i ) sum(i,j) = sum(0,j) - sum(0,i) sum(i,j)=sum(0,j)−sum(0,i)。也就是,如果我们知道a[0]到a[i]的和: a [ 0 ] + a [ 1 ] + ⋯ + a [ i ] a[0]+a[1]+\cdots+a[i] a[0]+a[1]+⋯+a[i] 以及 a[0]到a[j]的和: a [ 0 ] + a [ 1 ] + ⋯ + a [ i ] + ⋯ + a [ j ] a[0]+a[1]+\cdots+a[i]+\cdots+a[j] a[0]+a[1]+⋯+a[i]+⋯+a[j]。此时,如果我们想要知道a[i]到a[j]的和的和,只要把上面两个二者相减就可以了。我们可以预先计算出整个数组所有的 a [ 0 ] + ⋯ + a [ i ] a[0]+\cdots+a[i] a[0]+⋯+a[i]的和,每次用两个前缀和相减就可以在O(1)的复杂度下得到答案。示例代码如下:
vector<int> prefixSum;
// preprocess
int _sum = 0;
for (int i = 0; i < A.size(); ++i) {
_sum += A[i];
prefixSum.push_back(_sum);
}
int sum(int i, int j) {
return _sum[j] - _sum[i];
}
当然,上面的代码只是示意一下思路。即便这样优化以后,代码复杂度仍为O(n^2),无法达到要求。
从上面朴素的思路出发可以做一个额外的优化:在枚举i和j的时候,如果发现和已经大于S了,那么j也就不用再循环了。因为A[j] >= 0,所以,继续累加不可能再加出一个小于S的数。代码如下:
class Solution {
public:
int numSubarraysWithSum(vector<int>& A, int S) {
vector<int> sums;
int sum = 0;
sums.push_back(0);
for (int i = 0; i < A.size(); ++i) {
sum += A[i];
sums.push_back(sum);
}
int ans = 0;
for (int i = 0; i < A.size(); ++i) {
for (int j = i + 1; j <= A.size(); ++j) {
int sum_i_j = sums[j] - sums[i];
if (sum_i_j > S) break; // 不用再循环了,sum_i_j不可能再得到一个小于S的数
if (sum_i_j == S) ++ans;
}
}
return ans;
}
};
但即便这样优化,仍然是
O
(
n
2
)
O(n^2)
O(n2)的,依旧会超时,达不到该题的要求。所以我们需要再度审视,究竟那里还可以简化。可以看到,在最核心的两重循环中,内层循环实际上就是想找到从i加到哪里可以使sum_i_j
刚好等于S。如果我们能够在
O
(
1
)
O(1)
O(1)的时间内找到这个j,就可以让整个循环的复杂度降到
O
(
n
)
O(n)
O(n)了。这个题目有多种解法,这多种解法的差异也就在如何简化这个内层循环上面了。
思路1 (观察法优化无用循环)
朴素的思路可以继续优化,但这是一个特别取巧的思路。这个思路源自于这样一个观察,仔细看在上面优化过的朴素版本中哪些次循环是浪费的:
sums=[0,1,1,2,2,3]
j/i | 0 | 1 | 2 | 3 | 4 |
---|---|---|---|---|---|
1 | 1 | ||||
2 | 1 | 0 | |||
3 | 2 | 1 | 1 | ||
4 | 2 | 1 | 1 | 0 | |
5 | 3 | 2 | 2 | 1 | 1 |
可以看到,sum_i_j < S
的部分是我们浪费的。注意观察sum_i_j == S
的边界上,i
和j
的取值:
sum_i_j=1, i = 0, j = 2;
sum_i_j=1, i = 1, j = 4;
sum_i_j=1, i = 2, j = 4;
sum_i_j=1, i = 3, j = 5;
sum_i_j=1, i = 4, j = 5;
可以看到随着i的增加,j的值是不断单调上升的。仔细想想这里面的原理不难发现,假如我发现 A [ i ] + A [ i + 1 ] + ⋯ + A [ j ] < S A[i] + A[i + 1] + \cdots + A[j] < S A[i]+A[i+1]+⋯+A[j]<S,那么显然,i进入下一次循环的时候, A [ i + 1 ] + ⋯ + A [ j ] < S A[i + 1] + \cdots + A[j] < S A[i+1]+⋯+A[j]<S也必然是成立的,本来还小于S呢,少加一个 A [ i ] A[i] A[i]当然更是小于S啦。所以,作为优化,可以不断的寻找一个 j j j使得 A [ i ] + A [ i + 1 ] + ⋯ + A [ j ] < S A[i] + A[i + 1] + \cdots + A[j] < S A[i]+A[i+1]+⋯+A[j]<S。那么在下一论循环(即i++以后或者说i=i+1以后)中,从上一轮我找到的这个j开始循环就好了。优化后的代码如下:
class Solution {
public:
int numSubarraysWithSum(vector<int>& A, int S) {
vector<int> sums;
int sum = 0;
sums.push_back(0);
for (int i = 0; i < A.size(); ++i) {
sum += A[i];
sums.push_back(sum);
}
int ans = 0;
int min_j = 0;
for (int i = 0; i < A.size(); ++i) {
for (int j = max(i + 1, min_j); j <= A.size(); ++j) {
int sum_i_j = sums[j] - sums[i];
if (sum_i_j > S) break; // 不用再循环了,sum_i_j不可能再得到一个小于S的数
else if (sum_i_j == S) ++ans;
else { // if (sum_i_j < S)
min_j = j;
}
}
}
return ans;
}
};
该代码内层循环基本上只需要循环sum_i_j == S
的范围,这个范围不大,所以可以认为其时间复杂度接近于
O
(
n
)
O(n)
O(n)。所以该代码可以顺利提交通过。Rust版本如下:
impl Solution {
pub fn max(a: usize, b: usize) -> usize {
if a > b {
a
} else {
b
}
}
pub fn num_subarrays_with_sum(a: Vec<i32>, s: i32) -> i32 {
if a.len() == 0 {
return 0;
}
let mut sums : Vec<i32> = Vec::new();
let mut sum = 0;
sums.push(0);
for i in 0 .. a.len() {
sum += a[i];
sums.push(sum);
}
let mut ans = 0;
let mut min_j = 0;
for i in 0 .. a.len() {
for j in Solution::max(i + 1, min_j) ..= a.len() {
let sum_i_j = sums[j] - sums[i];
if sum_i_j > s {
break; // 不用再循环了,sum_i_j不可能再得到一个小于S的数
} else if (sum_i_j == s) {
ans += 1;
} else {
min_j = j;
}
}
}
ans
}
}
思路2 (同思路1,但从结尾入手)
思路2是思路1的一种变形。思路2与思路1的唯一差别在于思路2的外层循环i代表结尾,内层循环j代表开头。采用和思路1同样的方法进行观察,可以发现思路2也可以用相似的手法优化。只不过,思路1里,随着j
增大,sum_i_J
会增大。而思路2中,随着j
增大,sum_i_j
会减小。在注意到这个细节后,就可以顺利写出思路2的代码。这里需要注意的是,从结尾入手可能会对很多序列类的题目有所启发。其实,如果一开始就想着i代表结尾的话,记录一个min_j
来跳过sum_i_j > S
的优化思路似乎更为直观。比起思路1需要观察半天,思路2可能更好想到一点。再比如求最长不降子序列的一种方法也是记录不同长度的子序列的最小的结尾是多少。所以,从结尾入手不失为一种好的解题思路。
C++版本
class Solution {
public:
int numSubarraysWithSum(vector<int>& A, int S) {
vector<int> sums;
int sum = 0;
sums.push_back(0);
for (int i = 0; i < A.size(); ++i) {
sum += A[i];
sums.push_back(sum);
}
int ans = 0;
int min_j = 0;
for (int i = 1; i <= A.size(); ++i) {
for (int j = min_j; j < i; ++j) {
int sum_i_j = sums[i] - sums[j];
if (sum_i_j == S) {
++ans;
} else if (sum_i_j > S) {
min_j = j;
} else {
break;
}
}
}
return ans;
}
};
Rust版本为
impl Solution {
pub fn num_subarrays_with_sum(a: Vec<i32>, s: i32) -> i32 {
if a.len() == 0 {
return 0;
}
let mut sums : Vec<i32> = Vec::new();
let mut sum = 0;
sums.push(0);
for i in 0 .. a.len() {
sum += a[i];
sums.push(sum);
}
let mut ans = 0;
let mut min_j = 0;
for i in 1 ..= a.len() {
for j in min_j .. i {
let sum_i_j = sums[i] - sums[j];
if sum_i_j > s {
min_j = j;
} else if (sum_i_j == s) {
ans += 1;
} else {
break;
}
}
}
ans
}
}
思路3(滑动窗口法)
看到这种与某一段连续的区间的和有关的任务,另一种比较常见的思路是滑动窗口:用两个变量标记序列的左边( l l l)和右边( r r r)。一开始 l l l和 r r r都等于0,也就是都指向序列的第一个元素。然后向右移动 r r r,直到这一段区间内的和刚好大于 S S S了为止。统计其中等于 S S S的这一段有多少可能。之后再把 l l l开始向右移动。反正就是保持 l l l到 r r r这一段区间的和是 S S S。
一开始,L/R都等于0。可以理解L和R就是朴素思路里面的i和j。
sum = 1, S = 2
L/R
V
1 0 1 0 1
然后和朴素思路循环j一样,移动R去找到sum == S
的一段。
sum = 1, S = 2
L R
V V
1 0 1 0 1
---------------------
sum = 2, S = 2
L R
V V
1 0 1 0 1
此时,我们就找到了一段S等于2的情况。此时,L无法向右移动,因为一旦移动,就无法满足sum==S
了。
之后,再向右移动R。移动后右可以找到一种sum==S
的情况。此时,L依然无法移动。但当R继续向右移动后,由于sum > S
了,不再满足要求,所以L也要向右移动。
sum = 2, S = 2
L R
V V
1 0 1 0 1
-----------------------
sum = 3, S = 2
L R
V V
1 0 1 0 1
-----------------------
sum = 2, S = 2
L R
V V
1 0 1 0 1
此时,我们发现,L尚可以向右移动,所以将L再移一个。
sum = 2, S = 2
L R
V V
1 0 1 0 1
从上面的过程,我们可以得到一个思路:当sum < S
或者无法移动L的时候,就向右移动R。如果sum>S
或者移动L不会改变sum
时,就移动L。这也是这种滑动窗口的思路的常见想法。但是在这道题目中,这个思路并不正确。例如这样反例:
sum = 0, S = 1
L/R
V
0 0 1 0
---------------------
sum = 0, S = 1
L R
V V
0 0 1 0
---------------------
** sum = 1, S = 1
L R
V V
0 0 1 0
此时,可以把移动L了。
** sum = 1, S = 1
L R
V V
0 0 1 0
---------------------
** sum = 1, S = 1
L/R
V
0 0 1 0
L无法再继续移动,移动R。
** sum = 1, S = 1
L R
V V
0 0 1 0
---------------------
此时,L和R都无法再移动了,算法结束。一共找出了4种。
- [0,0,1,0]
- [0,0,1,0]
- [0,0,1,0]
- [0,0,1,0]
但实际上,还有这样两种它没有找出来:
- [0,0,1,0]
- [0,0,1,0]
仔细观察,找漏的这两种都源自于以最后那个0结尾的。不难看出,错误在于前面移动L移动得过于草率了。因为我们前面不断的移动L,所以当后面又有0的情况下,前面连续的一段零构成的组合就没有再被算进去。比如,这里我们只算了[0,0,1,0]和[0,0,1,0],却没有算[0,0,1,0],[0,0,1,0]。那么如何解决这个问题呢?
在该题的discuss中出现了一个很巧妙的c++滑动窗口的解法:
class Solution {
public:
int numSubarraysWithSum(const vector<int> &A, int S) {
int left = 0, cnt = 0, n = A.size(), ans = 0;
for (int i = 0; i < n; i++) {
cnt += A[i];
while (left < i && cnt > S)cnt -= A[left++];
if (cnt < S)continue;
if (cnt == S)ans++;
int t = left;
while (t < i && A[t] == 0) {
t++, ans++;
}
}
return ans;
}
};
这个解法最巧妙的在于它如何移动left
这个变量(也就是我们的L
)。它只有在sum > S
(sum
也就是代码里的cnt
)的情况下才移动left。那么怎么枚举左侧这个left
的所有情况呢?巧妙之处就在于这里,它用一个tmp
来临时的代替left
,枚举这一段连续的0。这样就可以在R
(也就是代码中的i
)移动以后仍旧保证还可以枚举到前面这一段0
了。
如果你一时没有看懂或者没有想到这样巧妙的方法也无妨。因为笔者思考后发现,这个思路其实一定程度上和思路2是等价的。也可以认为,是由思路2进一步提炼而来的方法。我们再来看一下思路2中最核心的代码:
vector<int> sums;
int sum = 0;
sums.push_back(0);
for (int i = 0; i < A.size(); ++i) {
sum += A[i];
sums.push_back(sum);
}
int ans = 0;
int min_j = 0;
for (int i = 1; i <= A.size(); ++i) {
for (int j = min_j; j < i; ++j) {
int sum_i_j = sums[i] - sums[j];
if (sum_i_j == S) {
++ans;
} else if (sum_i_j > S) {
min_j = j;
} else {
break;
}
}
}
下面我们来将这段代码逐步优化成类似于思路3的做法,这样我们就能够清晰的看到,巧妙的方法是怎样一步步导出来的。
首先,sums[i]其实不需要在外面单独起一个循环进行计算。因为,每次使用sums的时候,一定不会使用下标大于i的部分。所以,完全可以一边计算sums,一边使用它。
vector<int> sums;
int sum = 0;
sums.push_back(0);
/* 这里不再需要单独计算了
for (int i = 0; i < A.size(); ++i) {
sum += A[i];
sums.push_back(sum);
} */
int ans = 0;
int min_j = 0;
for (int i = 1; i <= A.size(); ++i) {
// 在这里计算sums即可
sum += A[i - 1];
sums.push_back(sum);
for (int j = min_j; j < i; ++j) {
int sum_i_j = sums[i] - sums[j];
if (sum_i_j == S) {
++ans;
} else if (sum_i_j > S) {
min_j = j;
} else {
break;
}
}
}
然后,我们会发现:我们只需要sums[min_j]到sums[i]这一小小段。也就是说,我们其实可以只维护这一小部分,而不需要维护全部的sums。这个思路和常见的采用滑动窗口的题目是一样的。我只维护L到R之间的和。
vector<int> sums;
int sum = 0;
int ans = 0;
int min_j = 0;
for (int i = 1; i <= A.size(); ++i) {
sum += A[i - 1]; // sum现在代表min_j到i之间的和
int sum_i_j = sum; // sum_i_j代表的依旧是i和j之间的和
for (int j = min_j; j < i; ++j) {
if (sum_i_j == S) {
++ans;
} else if (sum_i_j > S) {
if (min_j < j) {
sum -= A[j - 1];
min_j = j;
}
} else {
break;
}
sum_i_j -= A[j];
}
}
最后,想再进一步简化代码,可以基于这样一个观察:min_j
实际上就是刚刚好使得sum_i_j > S
不成立的那个j。所以可以单独弄一个循环求min_j
。
class Solution {
public:
int numSubarraysWithSum(vector<int>& A, int S) {
int ans = 0;
int min_j = 0;
int sum_i_j = 0;
for (int i = 0; i < A.size(); ++i) {
sum_i_j += A[i];
while (sum_i_j > S && min_j < i) {
sum_i_j -= A[min_j];
++min_j;
}
if (sum_i_j == S) {
++ans;
for (int j = min_j; j < i; ++j) {
if (A[j] == 0) { // 只要A[j] == 0, sum_i_j就一定不变,还等于S
++ans;
} else { // A[j] != 0说明sum_i_j要不等于S了,所以直接退出就可以了
break;
}
}
}
}
return ans;
}
};
至此,这段代码已经基本和前面说的巧妙的滑动窗口的思路差不多了。看!我们也可以一步一步找到这样巧妙的方法呦。对应的Rust版本如下:
impl Solution {
pub fn num_subarrays_with_sum(a: Vec<i32>, s: i32) -> i32 {
if a.len() == 0 {
return 0;
}
let mut ans = 0;
let mut min_j = 0;
let mut sum_i_j = 0;
for i in 0 .. a.len() {
sum_i_j += a[i];
while sum_i_j > s && min_j < i {
sum_i_j -= a[min_j];
min_j += 1;
}
if sum_i_j == s {
ans += 1;
for j in min_j .. i {
if (a[j] == 0) { // 只要A[j] == 0, sum_i_j就一定不变,还等于S
ans += 1;
} else { // A[j] != 0说明sum_i_j要不等于S了,所以直接退出就可以了
break;
}
}
}
}
ans
}
}
思路4(利用连续的0)
在前面的思路中,我们尝试了滑动窗口的想法,也看到了,直接采用滑动窗口的问题在于左边有连续的0的时候,会出现错误。思路3用了一个很巧妙的技巧解决了这个问题。思路4则采用了另一种思路来应对这一挑战。
我们重新来看用滑动窗口可能出错的这个例子:
[0,0,1,0], S=1
错就错在没法正确处理连续的0。如果我知道连续有多少个0,我能否直接算出来答案呢?显然可以。对于这个例子,1的左边有2个0。1的右边有1个0。可能组合数就是1左边的0的个数和1右边的0的个数的组合。比如,这里1左边的0的个数可能为0,1,2三种,右边可能为0,1两种,组合起来是3×2=6种。哇,真的可以直接计算出答案。所以,我们就可以把题目转换成这样一个问题:找一段类似于1…1的首尾都是1一段,然后看左边和右边有多少个0。计算出这一段的组合数。找到所有这样的段落,把组合数加起来就是正确答案了。
例如:[0,1,0,1,0,0,1], S=2。首先找到[0,1,0,1,0,0,1],它左边有1个0,右边有2个0,所以组合数为 ( 1 + 1 ) × ( 2 + 1 ) = 2 × 3 = 6 (1+1)\times(2+1)=2\times3=6 (1+1)×(2+1)=2×3=6种。然后,又找到[0,1,0,1,0,0,1]。它左边有1个0,右边没有0。所以组合数为 ( 1 + 1 ) × ( 0 + 1 ) = 2 × 1 = 2 (1+1)\times(0+1)=2\times1=2 (1+1)×(0+1)=2×1=2种。 所以一共有 6 + 2 = 8 6+2=8 6+2=8种可能。
那么,如何来快速找左边右边有多少个连续的0呢?又如何快速找到和为S的一段呢?
首先第一个问题,显然我们需要预先统计好,每一段连续的0有多少个。例如:[0,1,0,1,0,0,1]就可以表示为[1个0,1,1个0,1,2个0,1]。然后一眼就会发现,序列里面的1是没有用的。因为相邻两段0之间一定会隔至少一个1。 所以上面的序列可以被表示为[1,1,2]。但问题来了:[1,0,1]这种怎么办,我们怎么知道[1](也就是[1个0])这种表示指的是[1,0,1]还是[0,1]。为了解决这个问题,可以把[1,0,1]表示为[0,1],也就是[0个0,1,1个0,1]。这样就可以区别[1,0,1]和[0,1]。同理,结尾也需要做同样的处理。所以[1,0,1]需要被表示为[0,1,0],也就是[0个0,1,1个0,1,0个0]。
总结起来,我们将一个序列表示为[第一个1前面连续的0的个数,第二个1前面连续的0的个数,第三个1前面连续的0的个数,…]。例如[1,0,0,1,1,0]表示为[0,2,0,1]。为了便于看清,我将每个1上面对了 |符号作为分隔符。|分隔的数字就是1之间0的个数。
0 | 2 | 0 | 1
[ 1, 0,0, 1, 1, 0]
在这样表示完以后,我们发现,快速找到和为S的一段的方法竟如此简单。因为和为S,所以就是要找S个1。还是采用上面的例子,对于[0,2,0,1],0和2之间隔了1个1,0和0之间隔了2个1,0和1之间隔了3个1。有没有发现什么?对!下标就刚好是中间的1的个数。比如对于上面的例子:
index 0 1 2 3
d = 0 | 2 | 0 | 1
[ 1, 0,0, 1, 1, 0]
假设S=2的话,答案就是 ( d [ 0 ] + 1 ) × ( d [ 0 + 2 ] + 1 ) + ( d [ 1 ] + 1 ) × ( d [ 1 + 2 ] + 1 ) = ( 0 + 1 ) × ( 0 + 1 ) + ( 2 + 1 ) × ( 0 + 1 ) = 1 + 3 = 4 (d[0] + 1)\times(d[0+2] + 1)+(d[1]+1) \times (d[1+2] + 1)=(0+1)\times(0+1)+(2+1)\times(0+1)=1+3=4 (d[0]+1)×(d[0+2]+1)+(d[1]+1)×(d[1+2]+1)=(0+1)×(0+1)+(2+1)×(0+1)=1+3=4
这种方法唯一的例外是,如果S=0,那么就是每一段连续的0单独组成序列。这个组合数就更好算了。比如1个连续的0,那么可能的组合只有1种。2个连续的0,组合可能有0或者0,0。3个就是0、0,0、0,0,0。相信你已经发现规律了。连续n个0就是从1加到n那么多种组合。采用等差数列求和公式: ( 1 + n ) × n 2 \frac{(1+n)\times n}{2} 2(1+n)×n。
代码实现如下(请忽略代码中不甚将变量名命名为dp,写完了才发现其实这个方法和动态规划的联系不大)。
class Solution {
public:
int numSubarraysWithSum(vector<int>& A, int S) {
if (A.size() == 0) {
return 0;
}
vector<int> dp;
int nums_of_zero = 0;
for (int i = 0; i < A.size(); ++i) {
if (A[i] == 0) {
nums_of_zero += 1;
} else {
dp.push_back(nums_of_zero);
nums_of_zero = 0;
}
}
dp.push_back(nums_of_zero);
int ret = 0;
for (int i = 0; i < dp.size(); ++i) {
int right = i + S;
if (right < dp.size()) {
if (S != 0) {
ret += (dp[i] + 1) * (dp[right] + 1);
} else {
ret += (1 + dp[i]) * dp[i] / 2;
}
}
}
return ret;
}
};
Rust版本为:
impl Solution {
pub fn num_subarrays_with_sum(a: Vec<i32>, s: i32) -> i32 {
if a.len() == 0 {
return 0;
}
let mut dp : Vec<i32> = Vec::new();
let l = a.len();
let mut nums_of_zero = 0;
for i in 0 .. l {
if a[i] == 0 {
nums_of_zero += 1;
} else {
dp.push(nums_of_zero);
nums_of_zero = 0;
}
}
dp.push(nums_of_zero);
let ldp = dp.len();
let mut ret = 0;
for i in 0 .. ldp {
let right = i + (s as usize);
if right < ldp {
if s != 0 {
ret += (dp[i] + 1) * (dp[right] + 1);
} else {
ret += (1 + dp[i]) * dp[i] / 2;
}
}
}
ret
}
}
思路5(利用前缀和)
其实思路5是另一大类思路。这道题可能的解法非常多。不过大同小异,就不一一细细展开了。这种思路利用的是一个最最基础的想法,也是我们朴素思路中的那种想法,先求一个sums数组,把所有前缀的和都求出来。接下来,我们要想知道有多少个子数组的和是S。同样,我们也从结尾下手,把问题转换成,有多少个以第i个元素为结尾的子数组,和为S。而和为S的子数组的数目等于sums中,等于sums[i] - S的个数-1。举个例子:
S = 2
sums = [1,1,1,2,2,3]
A = [1,0,0,1,0,1]
^
以最后这个1为结尾的子数组的个数=sums中等于1的元素个数
这个其实很好理解,其实说白了,还是以最后这个1为结尾,等于S的这一段,前面有多少个0。只不过这种思路观察到,前面的0的个数其实就是前缀和为sums-S的数量-1,然后再加上前面什么都不要的这种情况,所以就刚好等于前缀和=sums-S的数目。找前缀和=sums-S的数目,最简单的思路是二分。因为sums是递增的,所以可以二分查找。这种思路可以参考(https://blog.csdn.net/fuxuemingzhu/article/details/83478995#_43) 的做法。博客作者是python写的,为了便于使用C++的小伙伴,我翻译了一下:
class Solution {
public:
int numSubarraysWithSum(vector<int>& A, int S) {
int N = A.size();
vector<int> sums = vector<int>(N + 1, 0);
for (int i = 1; i < N + 1; ++i) {
sums[i] = sums[i - 1] + A[i - 1];
}
int ret = 0;
for (int i = 1; i < N + 1; ++i) {
int remain = sums[i] - S;
if (remain < 0) {
continue;
}
int left = lower_bound(sums.begin(), sums.end(), remain) - sums.begin();
int right = upper_bound(sums.begin(), sums.end(), remain) - sums.begin();
right = min(i, right);
ret += right - left;
}
return ret;
}
};
但既然我们是想求前缀和等于某个值的数目,我们不妨在算出前缀和的时候就直接统计好。于是,就有了更进一步的写法:
class Solution {
public:
int numSubarraysWithSum(vector<int>& A, int S) {
vector<int> cnt(A.size() + 1, 0);
cnt[0] = 1;
int sum = 0, ret = 0;
for (int i : A) {
sum += i;
if (sum >= S) {
ret += cnt[sum - S];
}
cnt[sum]++;
}
return ret;
}
};
在这种写法中,cnt[k]代表前缀和为k的数目。也就是上面我们说的直接统计出前缀和等于某个值。因为sum是递增的,所以在cnt被使用之前,一定能被计算完。当然,还有一处改进是sum的计算也和前面说到过的优化一样,移入了循环内,不再单独计算了。
对应的Rust版本:
impl Solution {
pub fn num_subarrays_with_sum(a: Vec<i32>, s: i32) -> i32 {
let mut cnt:Vec<i32> = vec![0; a.len() + 1];
cnt[0] = 1;
let mut psum = 0;
let mut ret = 0;
for i in a.into_iter() {
psum += i;
if (psum >= s) {
ret += cnt[(psum - s) as usize];
}
cnt[psum as usize] += 1;
}
ret
}
}
后记
其实最开始写就是觉得,很多题解都三两笔带过,明显是写给自己看的。自己看当然看得懂,但对于别人来说,却一头雾水。特别是对于我等小白,该不会还不会。所以想着弄一个特别特别详细的,让大家一看就懂。但完全没想到写给自己容易,想尽量让别人也能看懂很难。一开始就想语言描述一下,后来发现根本描述不清,只好一步步展示出来。
另外,写完一遍确实也有相当的收获。以前自己从不在意思路是哪里来的,怎么一步步联系搞出来的。结果一道题不会,学了,下一道题还不会。这次,总算是从本质上理解了这些思路了。
最后,我特意翻了discuss还有别人的很多博客,希望能把所有有意思的想法都找出来。真心希望,这些想法可以对大家有用。
最后的最后,觉得有用的话可以点赞鼓励一下呦:)如果有什么leetcode上觉得好的题目,想看我梳理一下,也可以留言给我,我会尽力的。一个人可以走得很快,但一群人才能走得更远。希望我们可以一道前行,共同进步~