1. 问题背景与题目解析
洛谷P1036《选数》是一道经典的算法练习题,主要考察递归、组合数学和质数判断的综合应用。题目描述如下:给定n个正整数,从中选取k个数,计算这k个数的和,判断这个和是否为质数。要求统计所有可能的选法中满足条件的方案数。
这道题看似简单,但蕴含着几个关键算法点:
- 如何高效生成所有可能的k数组合(避免重复计算)
- 如何快速判断一个大数是否为质数
- 如何优化递归过程避免超时
在实际编程竞赛和面试中,这类组合求和问题非常常见。比如在金融领域计算投资组合收益,在生物信息学中分析基因序列组合等。掌握这类问题的解法,对提升算法思维很有帮助。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 组合生成算法实现
2.1 递归回溯法实现
最直观的解法是使用递归回溯生成所有组合。以下是C++实现的核心代码框架:
cpp复制void dfs(int start, int count, int sum) {
if (count == k) {
if (isPrime(sum)) ans++;
return;
}
for (int i = start; i < n; i++) {
dfs(i + 1, count + 1, sum + nums[i]);
}
}
这个实现有几个关键点需要注意:
start参数确保不会重复选择之前的元素,避免组合重复count记录当前已选数字个数sum动态维护当前组合的和
重要提示:递归深度与k值直接相关,当n较大时(如n>20),需要考虑非递归实现或剪枝优化。
2.2 迭代法实现
对于担心递归栈溢出的情况,可以用位运算模拟组合生成:
cpp复制for (int mask = 0; mask < (1 << n); mask++) {
if (__builtin_popcount(mask) != k) continue;
int sum = 0;
for (int i = 0; i < n; i++) {
if (mask & (1 << i)) sum += nums[i];
}
if (isPrime(sum)) ans++;
}
这种方法虽然直观,但当n>30时会因为2^30次循环而超时,仅适用于小规模数据。
3. 质数判断优化策略
3.1 基础质数判断
最简单的质数判断是试除法:
cpp复制bool isPrime(int x) {
if (x < 2) return false;
for (int i = 2; i * i <= x; i++) {
if (x % i == 0) return false;
}
return true;
}
3.2 预处理优化
当需要多次判断质数时,可以预先用埃拉托斯特尼筛法生成质数表:
cpp复制vector<bool> prime(maxSum + 1, true);
prime[0] = prime[1] = false;
for (int i = 2; i <= maxSum; i++) {
if (prime[i]) {
for (int j = i * 2; j <= maxSum; j += i) {
prime[j] = false;
}
}
}
这样判断时只需查表:if(prime[sum]) ans++
3.3 更高级的质数测试
对于极大数(本题不需要),可以考虑:
- Miller-Rabin概率性质数测试
- AKS确定性测试
4. 算法优化与剪枝
4.1 前缀和剪枝
在递归过程中,如果剩余数字全部选最大的仍达不到要求,可以提前终止:
cpp复制void dfs(int start, int count, int sum) {
if (count + (n - start) < k) return; // 剩余数字不够k个
if (count == k) {
if (isPrime(sum)) ans++;
return;
}
// ...其余代码不变
}
4.2 排序优化
先将数组排序,可以在剪枝时更准确估计剩余数字的和范围:
cpp复制sort(nums.begin(), nums.end());
// 然后在dfs前计算后缀和数组
vector<int> suffix(n + 1);
for (int i = n - 1; i >= 0; i--) {
suffix[i] = suffix[i + 1] + nums[i];
}
这样在dfs中可以添加:
cpp复制if (sum + suffix[start] < minPrime) return;
if (sum + suffix[start] > maxPrime) return;
5. 完整AC代码实现
结合上述优化,给出完整C++实现:
cpp复制#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
int n, k, ans;
vector<int> nums;
bool isPrime(int x) {
if (x < 2) return false;
for (int i = 2; i * i <= x; i++) {
if (x % i == 0) return false;
}
return true;
}
void dfs(int start, int count, int sum) {
if (count == k) {
if (isPrime(sum)) ans++;
return;
}
if (n - start < k - count) return; // 剪枝1:剩余数字不足
for (int i = start; i < n; i++) {
dfs(i + 1, count + 1, sum + nums[i]);
}
}
int main() {
cin >> n >> k;
nums.resize(n);
for (int i = 0; i < n; i++) {
cin >> nums[i];
}
dfs(0, 0, 0);
cout << ans << endl;
return 0;
}
6. 复杂度分析与对比
6.1 时间复杂度
- 组合生成:O(C(n,k)),即从n个数中选k个的组合数
- 质数判断:O(√m),m为可能的和的最大值
- 总复杂度:O(C(n,k)*√m)
6.2 空间复杂度
- 递归栈:O(k)
- 存储数组:O(n)
- 总空间:O(n)
6.3 算法对比
| 方法 | 时间复杂度 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|---|
| 递归回溯 | O(C(n,k)) | 通用 | 代码简洁 | 递归深度大时可能栈溢出 |
| 位运算 | O(n*2^n) | n<20 | 实现简单 | n大时不可行 |
| 动态规划 | O(nkS) | k和S较小 | 可记录中间结果 | 空间消耗大 |
7. 常见错误与调试技巧
7.1 组合重复问题
错误表现:统计的方案数比实际多
解决方法:确保每次递归从start+1开始,避免重复选择
7.2 边界条件处理
常见错误:
- 忽略k=0或n=0的情况
- 未处理数字全为1时的质数判断
- 大数求和溢出
调试建议:
cpp复制// 添加调试输出
void dfs(int start, int count, int sum) {
cout << "start=" << start << " count=" << count << " sum=" << sum << endl;
// ...其余代码
}
7.3 性能优化验证
使用洛谷的在线测试功能:
- 先提交基础版本,查看哪些测试点超时
- 逐步添加剪枝优化,观察运行时间变化
- 对于极端情况(如n=20,k=10)单独测试
8. 算法扩展与应用
8.1 问题变种
- 每个数字可选多次:修改递归条件为
dfs(i, count+1, sum+nums[i]) - 求所有满足条件的组合而不仅是计数:用vector记录路径
- 限制和的范围:添加额外剪枝条件
8.2 实际应用场景
- 投资组合选择:从n个投资项目中选k个,使总风险最低(质数判断改为风险计算)
- 实验样本选择:从n个样本中选k个,使某些指标和为特定值
- 课程安排:选k门课程,使总学分满足某些条件
8.3 相关题目推荐
- 洛谷P1157 组合的输出
- LeetCode 39. Combination Sum
- 洛谷P1219 [USACO1.5]八皇后问题
- LeetCode 77. Combinations
在实际编程中,这类组合问题常与回溯、剪枝、动态规划等技巧结合。掌握好基础的回溯模板,就能应对大多数变种题目。建议从简单题开始,逐步挑战更复杂的组合优化问题。
