1. 问题背景与核心概念
这道题目属于典型的数组操作与组合数学结合的算法问题。给定一个整数数组和一个目标值,要求统计所有满足"最小元素与最大元素之和小于等于目标值"的子序列数量。子序列的定义是原数组中不连续的元素序列,这与子数组(要求连续)有本质区别。
在实际编程面试中,这类问题经常出现在大厂的中高级算法岗位考察中。它综合考察了候选人对以下能力的掌握:
- 双指针技巧的灵活运用
- 组合数学的快速计算能力
- 边界条件的处理意识
- 算法优化的思考深度
注意:子序列与子数组的区分至关重要。例如数组[1,2,3]的子序列包括[1,3]这样不连续的元素,而子数组只能是[1,2]或[2,3]这样的连续片段。
2. 暴力解法与复杂度分析
最直观的解法是枚举所有可能的子序列,然后检查每个子序列是否满足条件。对于一个长度为n的数组,子序列总数是2^n(每个元素都有选或不选两种可能)。
python复制def countSubsequences(nums, target):
n = len(nums)
count = 0
for mask in range(1, 1<<n): # 枚举所有非空子序列
sub = [nums[i] for i in range(n) if (mask >> i) & 1]
if min(sub) + max(sub) <= target:
count += 1
return count
这种解法的时间复杂度是O(n * 2^n),当n=20时运算量已经超过百万,完全无法应对常规的算法题数据范围(通常n在10^5量级)。我们需要更高效的算法。
3. 双指针优化思路
关键观察点在于:如果将数组排序,对于任意固定的最小值nums[i],要找到最大的nums[j]使得nums[i]+nums[j]<=target。此时所有以nums[i]为最小值,最大值不超过nums[j]的子序列都满足条件。
具体步骤:
- 将数组升序排序
- 初始化两个指针i=0(最小值),j=len(nums)-1(最大值)
- 当nums[i] + nums[j] > target时,j--(缩小最大值)
- 否则,统计i到j之间的子序列数,然后i++(尝试更大的最小值)
4. 组合数学的巧妙应用
当确定i和j后,如何快速计算符合条件的子序列数?这些子序列必须包含nums[i](保证它是最小值),可以包含i+1到j之间的任意元素(但不能都不选,因为需要nums[j]是最大值)。
数学表达式为:2^(j-i)
解释:i+1到j共有j-i个元素,每个可选可不选,所以是2的幂次。
但需要注意边界情况:
- 当i==j时,只有子序列[nums[i]]符合,计数1
- 当j < i时,没有符合条件的子序列
5. 完整算法实现
python复制def countSubsequences(nums, target):
nums.sort()
n = len(nums)
res = 0
left, right = 0, n - 1
while left <= right:
if nums[left] + nums[right] <= target:
res += 1 << (right - left) # 等同于2^(right-left)
left += 1
else:
right -= 1
return res % (10**9 + 7) # 题目通常要求取模
时间复杂度分析:
- 排序O(n log n)
- 双指针遍历O(n)
- 总复杂度O(n log n),可以轻松处理n=10^5的数据规模
6. 边界条件与特殊测试用例
需要特别注意的测试场景:
- 空数组:根据题意通常返回0
- 所有元素相同:[3,3,3] target=6
- 所有子序列都满足条件:[1,1,1] target=5
- 没有任何子序列满足条件:[5,6,7] target=2
- 大数取模:当n很大时,2^n会超过整数范围,需要及时取模
7. 算法优化技巧
- 快速幂优化:虽然Python的整数不会溢出,但在其他语言中计算2^n时可以使用快速幂算法
- 提前终止:当nums[i]*2 > target时可以直接终止循环,因为后续更大的i不可能找到满足条件的j
- 去重处理:如果数组中有大量重复元素,可以考虑先压缩统计频次,但会增大实现复杂度
8. 同类问题扩展
双指针技巧在子序列问题中的应用非常广泛,类似的问题包括:
- 三数之和(LeetCode 15)
- 最接近的三数之和(LeetCode 16)
- 较小的三数之和(LeetCode 259)
- 有效三角形的个数(LeetCode 611)
这些问题的共同特点是都需要先排序,然后通过指针移动来高效地枚举符合条件的组合,避免暴力搜索。
9. 实际工程中的应用场景
虽然这类算法题看起来抽象,但其核心思想在真实开发中很有价值:
- 推荐系统中的筛选逻辑(价格区间过滤)
- 数据库查询优化(组合条件索引)
- 统计分析中的区间计数
- 资源分配中的匹配算法
理解这类算法可以帮助我们写出更高效的数据处理代码,特别是在处理大规模数据集时。
10. 常见错误与调试技巧
新手容易犯的错误:
- 忘记排序:双指针技巧的前提是有序数组
- 子序列计数公式错误:特别是边界情况i==j时
- 整数溢出:没有及时取模导致大数计算错误
- 指针移动条件错误:混淆了i++和j--的逻辑
调试建议:
- 先用小规模测试用例验证(n=3,4)
- 打印指针位置和中间结果
- 单独测试子序列计数函数
- 对比暴力解法的结果
11. 复杂度优化的理论极限
对于这个问题,O(n log n)已经是最优复杂度,因为:
- 排序本身需要O(n log n)时间
- 问题本身需要比较元素大小,基于比较的排序下界就是O(n log n)
- 如果给定数组已经排序,可以优化到O(n)
这提醒我们在实际面试中,如果被问到"能否进一步优化",需要先确认数组是否已排序等前提条件。
12. 不同语言的实现差异
虽然算法逻辑相同,但不同语言的实现有细微差别:
C++版本需要注意:
cpp复制int countSubsequences(vector<int>& nums, int target) {
sort(nums.begin(), nums.end());
int res = 0, mod = 1e9 + 7;
int left = 0, right = nums.size() - 1;
while (left <= right) {
if (nums[left] + nums[right] <= target) {
res = (res + (1 << (right - left))) % mod;
left++;
} else {
right--;
}
}
return res;
}
Java版本需要注意类型转换和取模处理:
java复制int countSubsequences(int[] nums, int target) {
Arrays.sort(nums);
int res = 0, mod = (int)1e9 + 7;
int left = 0, right = nums.length - 1;
while (left <= right) {
if (nums[left] + nums[right] <= target) {
res = (res + (1 << (right - left))) % mod;
left++;
} else {
right--;
}
}
return res;
}
13. 数学证明与正确性验证
为什么这个算法是正确的?可以从两个方面证明:
-
完备性:所有满足条件的子序列都会被计数。对于任意满足min+max<=target的子序列,设排序后min在位置i,max在位置j,算法在i和j指针相遇时一定会统计到这个子序列。
-
排他性:不会重复计数或漏计。每次统计的都是以当前nums[i]为最小值的唯一组合,且指针移动保证了不会重复处理同一对(i,j)。
14. 可视化理解算法过程
以nums = [3,5,6,7], target = 9为例:
排序后数组:[3,5,6,7]
初始化:i=0(3), j=3(7)
3+7=10>9 → j=2(6)
3+6=9<=9 → 计数2^(2-0)=4个子序列:
[3],[3,5],[3,6],[3,5,6]
然后i=1(5),j=2(6)
5+6=11>9 → j=1(5)
5+5=10>9 → j=0
循环结束
最终结果:4
15. 进阶思考:带权重的变种问题
如果每个元素有一个权重,要求统计满足条件的子序列的权重和,该如何修改算法?
提示:需要预处理权重的前缀和,并在统计时计算权重贡献。这展示了算法如何适应问题变种。