昨天第35周双周赛赛有一道题目,没做出来,事后,通过这个题目在力扣上挖出来另外两道题目,而另外两道题目用到的知识点,综合起来,来解决周赛的题目,就能迎刃而解了,所以,虽然周赛的题目没做出来,但学到两个知识点,同样很开心。
周赛题目是这样的。
- 使数组和能被 P 整除
给你一个正整数数组 nums,请你移除 最短 子数组(可以为 空),使得剩余元素的 和 能被 p 整除。 不允许 将整个数组都移除。
请你返回你需要移除的最短子数组的长度,如果无法满足题目要求,返回 -1 。
子数组 定义为原数组中连续的一组元素。
示例 1: 输入:nums = [3,1,4,2], p = 6 输出:1
解释:nums 中元素和为 10,不能被 p 整除。我们可以移除子数组 [4] ,剩余元素的和为 6 。
提示:
1 <= nums.length <= 10^5
1 <= nums[i] <= 10^9
1 <= p <= 10^9
暴力解法的话,需要3层循环,外面两层确定要移出的子数组的区间,第三层循环用来求子区间的和,时间复杂度是O(n^3)
。如果用前缀和来计算子区间的和的话,能把时间复杂度降低到O(n^2)
,我就是这样写的。如下面代码清单所示。
class Solution {
public int minSubarray(int[] nums, int p) {
int n = nums.length;
long[] sum = new long[n + 1];
for (int i = 0; i < n; i++) {
sum[i+1] = (long)sum[i] + nums[i];
}
if (sum[n] % p == 0) {
return 0;
}
for (int len = 1; len < n; len++) {
for (int i = 0; i + len <= n; i++) {
long remain = sum[n] - (sum[i+len] - sum[i]);
if (remain % p == 0) {
return len;
}
}
}
return -1;
}
}
但这种写法还是会超时,看数据规模在10的5次方,要在O(n)
时间内完成才有可能不会超时。时间复杂度要降低,那么一般是要用空间换时间,空间的话,能不能用哈希表,哈希表的查询复杂度为O(1)
,哈希表在这里怎么使用,我在比赛中是没有想到的,需要用到两个知识点:哈希表在子数组中的应用和同余定理。下面的文章会先引入另外两个题目的求解方法,进而探寻周赛的5504题的解法。
和为K的子数组
给定一个整数数组和一个整数 k,你需要找到该数组中和为 k 的连续的子数组的个数。
示例 1 :
输入:nums = [1,1,1], k = 2
输出: 2 , [1,1] 与 [1,1] 为两种不同的情况。
说明 :
数组的长度为 [1, 20,000]。
数组中元素的范围是 [-1000, 1000] ,且整数 k 的范围是 [-1e7, 1e7]。
这个题目暴力方法就是利用前缀和+两层for循环定位子数组,时间复杂度是O(n^2)
。
设sum[i]
表示数组nums[0...i]
的元素和,那么如果要求某一个子数组,比如nums[i...j]
的元素和为sum[j] - sum[i-1]
,用前缀和的方式可以在O(1)
时间得到。
本题中,要使某一个子数组的和为k,用公式描述为sum[j] - sum[i-1]=k
,变换一下公式为sum[j] - k = sum[i-1]
,也就是我们在遍历数组的前缀和的时候,在遍历到sum[j]
的时候,在sum[j]
之前有多少个sum[j]-k
出现。
我们建立一个哈希表map,以前缀和为key,以出现的次数为value,遍历数组的前缀和数组,把所有符合条件map.get(sum[j]-k)
相加,即可得到最终的结果。哈希表的初始值为{0:1}
, 因为需要考虑前缀和本身的值为k的情况。
进一步的优化,前缀和sum[i]
的计算只与前一项前缀和有关,所以可以只用变量preSum
来代替。
下面的动画描述了算法和为k的子数组的个数的过程。
和为k的子数组的个数 – 代码清单:
class Solution {
public int subarraySum(int[] nums, int k) {
int n = nums.length;
// sum[j] - sum[i-1] = k => sum[i-1] = sum[j] - k
int ans = 0, preSum = 0;
Map<Integer, Integer> map = new HashMap<>();
map.put(0, 1);
for (int i = 0; i < n; i++) {
preSum += nums[i];
int needSum = preSum - k;
ans += map.getOrDefault(needSum, 0);
map.put(preSum, map.getOrDefault(preSum, 0) + 1);
}
return ans;
}
}
至此,哈希表在求解子数组类的题目的用法清楚了。
和可被 K 整除的子数组
- 和可被 K 整除的子数组
给定一个整数数组 A,返回其中元素之和可被 K 整除的(连续、非空)子数组的数目。示例:
输入:A = [4,5,0,-2,-3,1], K = 5
输出:7
解释: 有 7 个子数组满足其元素之和可被 K = 5 整除: [4,
5, 0, -2, -3, 1], [5], [5, 0], [5, 0, -2, -3], [0], [0, -2, -3], [-2,
-3]提示:
1 <= A.length <= 30000
-10000 <= A[i] <= 10000
2 <= K <= 10000
这道题目用前缀和+两层循环做同样会超时,这里需要用到同余的性质。
同余定理:
给定一个正整数m,如果两个整数a和b满足a-b能够被m整除,即(a-b)/m得到一个整数,那么就称整数a与b对模m同余,记作a≡b(mod m)。
设sum[i]
表示数组nums[0...i]
的元素和,那么如果要求某一个子数组,比如nums[i...j]
的元素和为sum[j] - sum[i-1]
,用前缀和的方式可以在O(1)
时间得到。
本题中,要使某一个子数组能被K
整除,用公式描述为(sum[j] - sum[i-1]) % K = 0
,由同余定理可知,sum[j]
和sum[i-1]
是同余的,也就是我们在遍历数组的前缀和的时候,在遍历到sum[j]
的时候,要求在sum[j]
之前有多少个sum[j] % K
出现。
我们建立一个哈希表map,以前缀和对K
的余数为key
,以出现的次数为value
,遍历数组的前缀和数组,把所有符合条件map.get(sum[j] % K)
相加,即可得到最终的结果。哈希表的初始值为{0:1}
, 因为需要考虑前缀和本身能被K
整除的情况。
数组中有负数存在,在java语言中,负数的模运算还是负值,所以要进行处理,比如对于数a
进行模运算,a
为负数,要进行的处理为:(K + a % K) % K
。
代码清单如下。
class Solution {
public int subarraysDivByK(int[] A, int K) {
int n = A.length;
int ans = 0, preSum = 0;
Map<Integer, Integer> map = new HashMap<>();
map.put(0, 1);
for (int i = 0; i < n; i++) {
preSum = (K + (preSum + A[i]) % K) % K;
int count = map.getOrDefault(preSum, 0);
ans += count;
map.put(preSum, count + 1);
}
return ans;
}
}
现在,同余定理也掌握了,再回到周赛的题目。
使数组和能被 P 整除
- 使数组和能被 P 整除 给你一个正整数数组 nums,请你移除 最短 子数组(可以为 空),使得剩余元素的 和 能被 p 整除。 不允许 将整个数组都移除。
请你返回你需要移除的最短子数组的长度,如果无法满足题目要求,返回 -1 。
子数组 定义为原数组中连续的一组元素。
示例 1:
输入:nums = [3,1,4,2], p = 6
输出:1
解释:nums 中元素和为 10,不能被 p 整除。我们可以移除子数组
[4] ,剩余元素的和为 6 。
示例 2:输入:nums = [6,3,5,2], p = 9
输出:2
解释:我们无法移除任何一个元素使得和被 9 整除,最优方案是移除子数组[5,2] ,剩余元素为 [6,3],和为 9 。
示例 3:输入:nums = [1,2,3], p = 3
输出:0
解释:和恰好为 6 ,已经能被 3 整除了。所以我们不需要移除任何元素。
示例 4:输入:nums = [1,2,3], p = 7
输出:-1
解释:没有任何方案使得移除子数组后剩余元素的和被 7 整除。
示例 5:
输入:nums = [1000000000,1000000000,1000000000], p = 3
输出:0提示:
1 <= nums.length <= 105
1 <= nums[i] <= 109
1 <= p <= 109
分析:
同样使用前缀和以及哈希表。为写程序方便,设sum[i]
表示数组nums[0...i-1]
的元素和对p取余的结果,sum[0]=0
。sum[n]
表示整个数组的和对p取余,如果sum[n]
为0,说明数组本身就能被p整除,无需移除子数组。
如果sum[n]
不为0,设sum[n]
为r
,那么我们要移除的子数组的和(对p取余)也应该是r
,这样才能保证移除子数组后,sum[n]
为0。
假设我们要移除的子数组为nums[i...j]
,移除子数组部分的和为sum[j+1] - sum[i]
,有如下推导关系。
也就是我们在遍历数组的前缀和的时候,在遍历到sum[j+1]
的时候,要求在sum[j+1]
之前出现sum[j+1]-sum[n]
的位置,进而得到要移除的数组的长度。
我们建立一个哈希表map,以前缀和对p
的余数为key
,以下标位置为value
,遍历数组的前缀和数组,找出所有符合条件map.get((sum[j+1]-sum[n]+p) % p)
对应的下标,维护一个变量ans
记录最短的子数组,即可得到最终的结果。哈希表的初始值为{0:0}
。
代码清单如下:
class Solution {
public int minSubarray(int[] nums, int p) {
int n = nums.length;
int[] sum = new int[n + 1];
for (int i = 0; i < n; i++) {
sum[i+1] = (sum[i] + nums[i]) % p;
}
if (sum[n] == 0) {
return 0;
}
int ans = n + 1;
Map<Integer, Integer> map = new HashMap<>();
map.put(0, 0);
for (int i = 1; i <= n; i++) {
int needSum = (sum[i] - sum[n] + p) % p;
if (map.containsKey(needSum)) {
ans = Math.min(ans, i - map.get(needSum));
}
map.put(sum[i], i);
}
return ans >= n ? -1 : ans;
}
}
至此,大功告成。学到两个知识点:
- 哈希表在解决子数组问题上的应用
- 同余定理的使用
扫二维码关注我,一起讨论算法。