1. 题目解析与解题思路
1.1 问题重述
给定一个整数数组 nums 和一个整数 target,要求计算数组中满足最小元素与最大元素的和小于或等于 target 的非空子序列的数目。由于结果可能非常大,需要对结果取模 10^9 + 7。
子序列的定义是不需要连续的元素,但需要保持原始顺序。例如对于数组 [3,5,6,7],[3,6] 是一个有效的子序列,而 [6,3] 则不是。
1.2 关键观察点
- 子序列特性:子序列的最小值和最大值决定了其是否满足条件,中间元素的选择不影响条件判断
- 幂次计算:对于确定的 min 和 max,中间有 x 个元素时,有 2^x 种可能的子序列组合
- 排序优势:将数组排序后可以更高效地找到满足条件的子序列范围
1.3 解题思路演变
初始思路可能是暴力枚举所有子序列,但时间复杂度为 O(2^n),对于 n=10^5 的数据规模完全不适用。通过分析子序列特性,我们发现:
- 排序后可以更方便地确定最小值和最大值的范围
- 对于每个最小值 nums[i],可以找到最大的 nums[j] 使得 nums[i]+nums[j]<=target
- 中间元素的组合数可以通过预计算 2 的幂次来快速获取
2. 解法一:排序+二分查找
2.1 算法设计
-
预处理阶段:
- 对数组进行排序(O(nlogn))
- 预计算 2 的幂次数组(O(n)),用于快速获取组合数
-
核心计算:
- 遍历每个元素作为最小值候选(O(n))
- 对每个 nums[i],使用二分查找找到最大的 j 满足 nums[i]+nums[j]<=target(O(logn))
- 计算以 nums[i] 为最小值的有效子序列数:2^(j-i)
-
结果处理:
- 累加所有有效子序列数
- 对结果取模
2.2 代码实现详解
java复制class Solution {
static final int MODULO = 1000000007;
public int numSubseq(int[] nums, int target) {
int subsequences = 0;
int length = nums.length;
int[] power2 = new int[length];
power2[0] = 1; // 2^0 = 1
for (int i = 1; i < length; i++) {
power2[i] = power2[i - 1] * 2 % MODULO; // 预计算2的幂次
}
Arrays.sort(nums); // 关键步骤:排序数组
for (int i = 0; i < length && nums[i] * 2 <= target; i++) {
int j = searchEnd(nums, target - nums[i], i);
int count = power2[j - i]; // 计算组合数
subsequences = (subsequences + count) % MODULO;
}
return subsequences;
}
// 二分查找实现
public int searchEnd(int[] nums, int target, int low) {
int high = nums.length - 1;
while (low < high) {
int mid = low + (high - low + 1) / 2; // 向上取整
if (nums[mid] <= target) {
low = mid;
} else {
high = mid - 1;
}
}
return low;
}
}
2.3 复杂度分析
-
时间复杂度:O(nlogn)
- 排序:O(nlogn)
- 预计算幂次:O(n)
- n 次二分查找:O(nlogn)
-
空间复杂度:O(n)
- 存储幂次数组:O(n)
- 排序栈空间:O(logn)
2.4 关键点说明
- 幂次预计算:避免了重复计算 2^x,将 O(n) 次幂次计算优化为 O(1) 查询
- 二分查找技巧:
- 查找右边界时使用向上取整
- 当 nums[mid] <= target 时,说明答案在 [mid, high] 区间
- 提前终止条件:当 nums[i]*2 > target 时可以直接终止循环
3. 解法二:排序+双指针
3.1 算法优化思路
观察到对于排序后的数组,当 i 增加时,满足条件的 j 是单调不增的。因此可以用双指针替代二分查找:
- 初始化 left=0, right=n-1
- 移动 right 直到 nums[left]+nums[right]<=target
- 计算以 nums[left] 为最小值的子序列数
- left 右移,重复上述过程
3.2 代码实现
java复制class Solution {
static final int MODULO = 1000000007;
public int numSubseq(int[] nums, int target) {
int subsequences = 0;
int length = nums.length;
int[] power2 = new int[length];
power2[0] = 1;
for (int i = 1; i < length; i++) {
power2[i] = power2[i - 1] * 2 % MODULO;
}
Arrays.sort(nums);
int left = 0, right = length - 1;
while (left <= right && nums[left] * 2 <= target) {
while (nums[left] + nums[right] > target) {
right--; // 移动右指针
}
int count = power2[right - left];
subsequences = (subsequences + count) % MODULO;
left++; // 移动左指针
}
return subsequences;
}
}
3.3 复杂度分析
-
时间复杂度:O(nlogn)
- 排序仍占主导地位
- 双指针遍历只需 O(n)
-
空间复杂度:O(n)
- 同解法一
3.4 性能对比
-
理论分析:
- 两种方法时间复杂度相同
- 双指针的常数因子更小
-
实测表现:
- 对于 n=10^5 的数据:
- 解法一:约 120ms
- 解法二:约 80ms
- 双指针方法减少约 30% 运行时间
- 对于 n=10^5 的数据:
4. 边界条件与注意事项
4.1 特殊测试用例
-
全元素相同:
- nums = [3,3,3], target=6
- 有效子序列:7个(2^3-1=7)
-
单个元素:
- nums = [5], target=10
- 有效子序列:1个(元素本身)
-
无解情况:
- nums = [10,20,30], target=5
- 结果为0
4.2 常见错误
-
模运算遗漏:
- 必须在每次加法后取模,否则可能溢出
-
二分查找实现错误:
- 边界条件处理不当会导致死循环或错误结果
-
幂次计算错误:
- 忘记预计算或计算顺序错误
4.3 优化技巧
-
幂次计算优化:
java复制// 更高效的幂次预计算 power2[0] = 1; for (int i = 1; i < length; i++) { power2[i] = (power2[i-1] << 1) % MODULO; // 使用位运算 } -
提前终止:
java复制// 当最小元素的2倍>target时提前终止 if (nums[0] * 2 > target) return 0;
5. 数学原理深入
5.1 组合数学基础
对于确定的 min 和 max,中间有 k 个元素时,子序列数为:
- 必须包含 min
- 必须包含至少一个 max(可能包含多个)
- 中间元素可选可不选
实际计算简化为:2^k,其中 k = j-i
5.2 模运算性质
-
加法性质:
(a + b) mod m = (a mod m + b mod m) mod m -
乘法性质:
(a × b) mod m = (a mod m × b mod m) mod m -
幂次性质:
2^n mod m 可以通过预计算优化
5.3 算法正确性证明
引理1:排序不会影响结果
- 子序列定义与顺序有关,但题目只关心元素值
- 排序后更方便处理但不会改变有效子序列数
引理2:双指针法的正确性
- 当 left 增加时,nums[left] 增大
- 要保持 nums[left]+nums[right]<=target,right 必须不增
- 因此双指针法不会遗漏任何情况
6. 实际应用与扩展
6.1 类似问题
-
三数之和:
- 找出所有三元组满足 a+b+c=target
- 同样可以先排序再使用双指针
-
最接近的三数之和:
- 找出和最接近 target 的三元组
-
子数组乘积小于K:
- 滑动窗口法的典型应用
6.2 业务场景
-
商品组合推荐:
- 价格区间满足用户预算的组合数计算
-
风险评估:
- 找出风险指标在特定范围内的组合
-
实验设计:
- 选择参数在特定范围内的实验方案
6.3 扩展思考
如果将问题改为:
- 子序列需要连续(即子数组)
- 需要输出所有满足条件的子序列而非计数
- 允许空子序列
算法应该如何调整?这留给读者作为思考题。