本题来自力扣杯竞赛真题-2021春赛第一题: LCP 28.采购方案,难度为简单,考察双指针
题目
小力将 N 个零件的报价存于数组 nums。小力预算为 target,假定小力仅购买两个零件,要求购买零件的花费不超过预算,请问他有多少种采购方案。
注意:答案需要以 1e9 + 7 (1000000007) 为底取模,如:计算初始结果为:1000000008,请返回 1
示例
示例1:
输入:nums = [2,5,3,5], target = 6
输出:1
解释:预算内仅能购买 nums[0] 与 nums[2]。
示例2:
输入:nums = [2,2,1,9], target = 10
输出:4
解释:符合预算的采购方案如下:
nums[0] + nums[1] = 4
nums[0] + nums[2] = 3
nums[1] + nums[2] = 3
nums[2] + nums[3] = 10
提示
- 2 <= nums.length <= 10^5
- 1 <= nums[i], target <= 10^5
题解
这道题和两数之和很像,只不过
=
的条件换成了<=
。
对于这种无序数组求元素组合比大小的问题,上来肯定就是咔咔一顿排序:)。
1.双指针
双指针是最容易想到的解法。
这是因为可以找到一个很简单的规律:nums
数组排序后,对于某个数 nums[i]
,如果存在一个数 nums[j]
使得 j > i
的同时满足 nums[i] + nums[j] < target
,则 i
到 j
之间的任意索引 k
都满足 nums[i] + nums[k] < target
。
与此同时,对于 nums[i + 1]
,想要找到对应的右区间,该索引一定在 j
的左边。
因此,解法可以抽象理解为,一个初始为 0 ~ nums.length
的窗口通过不断缩小,统计对数,直到左右相逢。
class Solution {
public int purchasePlans(int[] nums, int target) {
long ans = 0;
long mod = 1000000007;
Arrays.sort(nums);
for (int left = 0, right = nums.length - 1; left < right; left++) {
while (left < right && nums[left] + nums[right] > target) right--;
if (left < right) ans = (ans + right - left) % mod;
}
return (int)ans;
}
}
结果
- 执行用时:32 ms
- 内存消耗:48.2 MB
复杂度分析
- 时间复杂度:O(n2)(最坏情况下)
- 空间复杂度:O(1)
2.双指针+二分法
从上面的双指针可得知,内循环每次 right
移动都是一个数一个数挪,在数据量很大的情况下并不合适,可以采用二分来快速定位到极限值
(1)憨憨二分:找恰好满足小于 target - nums[i]
的最后一个值
对于有序且存在重复元素的二分查找,往往需要多一个“是否是满足条件的重复的元素的第一个或最后一个”的判断。
由于一开始就这么想,结果写了个憨憨二分,效率明显不佳。
class Solution {
public int purchasePlans(int[] nums, int target) {
long ans = 0;
long mod = 1000000007;
Arrays.sort(nums);
for (int left = 0, right = nums.length - 1; left < right; left++) {
int l = left + 1, r = right;
int temp = target - nums[left];
if (temp < nums[l]) break;
while (l <= r) {
int mid = (l + r) >> 1;
if (nums[mid] <= temp && (mid == nums.length - 1 || nums[mid + 1] > temp)) {
right = mid;
break;
} else if (nums[mid] > temp) {
r = mid - 1;
} else {
l = mid + 1;
}
}
if (left < right) ans = (ans + right - left) % mod;
}
return (int)ans;
}
}
结果
- 执行用时:38 ms
- 内存消耗:48.3 MB
复杂度分析
- 时间复杂度:O(nlogn)
- 空间复杂度:O(1)
(2)优化二分:找恰好满足大于 target - nums[i]
的第一个值
在执行一个非常非常长的测试用例时,发现耗时明显优于前面两种。
虽然但是,可能是测试用例组的原因,这种用例较少,不能体现出这里二分法对双指针的性能提升。
class Solution {
public int purchasePlans(int[] nums, int target) {
long ans = 0;
long mod = 1000000007;
Arrays.sort(nums);
for (int left = 0, right = nums.length - 1; left < right; left++) {
int l = left + 1, r = right;
int temp = target - nums[left];
if (temp < nums[l]) break;
while (l <= r) {
int mid = (l + r) >> 1;
if (nums[mid] > temp) {
r = mid - 1;
} else {
l = mid + 1;
}
}
right = r;
if (left < right) ans = (ans + right - left) % mod;
}
return (int)ans;
}
}
结果
- 执行用时:35 ms
- 内存消耗:48.1 MB
复杂度分析
- 时间复杂度:O(nlogn)
- 空间复杂度:O(1)
3.计数法
其实本人第一个写的解法就是计数法。别问,问就是题目条件的 1 ~ 10^5
实在是太靓了,让我情不自禁蠢蠢欲动~
咳咳,事实上,对于这种题,用计数法有点白痴,一是效果不佳,二是确实没必要哈哈,但还是顺便讲一下。
这种解法的思想来源于计数排序,经典思路是使用一个 count
数组来统计以 nums[i]
作为索引的匹配值,以达到空间换时间使时间复杂度降到O(N)。他唯一的条件就是必须满足 nums
的值的取值范围是固定的,而且最好范围不要太大(像 10^5 其实已经算很大了)。
在本题中,count
用于记录 0 ~ 100001
的命中次数,除此之外还涉及前缀和的思想,使 count[i]
包含 count[i - 1]
的值:
对于 count
的每个索引 i
:
- 如果
i
在nums
中,count[i] = count[i - 1] + nums[i]命中次数
- 如果
i
不在nums
中,count[i] = count[i - 1]
因此,从左边开始,对于每个 nums[i]
,都可以在 count
中找到 target - nums[i]
的前缀和即匹配的个数,结合从左往右遍历会有重复的配对,需要去重,只需要减去 count[num]
再减自身的 1 即可。
然而,由于存在重复的 nums[i]
,采用这种解法将无法识别。因此还需要另一个额外的一维空间 repeat
来记录重复次数,同时将上面 “减去 count[num]
”改为“减去 count[num - 1]
”,便能解决这个问题。
class Solution {
public int purchasePlans(int[] nums, int target) {
long ans = 0;
long mod = 1000000007;
Arrays.sort(nums);
long[] count = new long[100001];
int[] repeat = new int[100001];
for (int num : nums) {
count[num]++;
}
for (int i = 1; i < 100001; i++) {
count[i] += count[i - 1];
}
for (int num : nums) {
if (num * 2 > target) break;
ans = (ans + count[target - num] - count[num - 1] - repeat[num] - 1) % mod;
repeat[num]++;
}
return (int)ans;
}
}
结果
- 执行用时:45 ms
- 内存消耗:47.4 MB
复杂度分析
- 时间复杂度:O(n)
- 空间复杂度:O(n)
总结
虽然是道简单题,但既然出现在力扣杯春季赛里,说明有其独特之处,可以发现有很多解题的角度不妨试一试,练习一下算法基本功。