洛谷P1036 [NOIP 2002 普及组] 选数 题解
目录
题目描述
题目分析
算法思路
数学原理
代码实现
代码详细解析
头文件说明
素数判断函数
全局变量定义
深度优先搜索函数
主函数
算法优化分析
完整优化版本
测试样例分析
常见问题解答
1. 为什么使用DFS而不是其他方法?
2. 如何避免重复组合?
3. 剪枝操作为什么有效?
4. 素数判断有哪些优化?
5. 如果所有数字都是负数怎么办?
6. 递归深度会太大吗?
算法扩展
总结
题目描述
已知 n 个整数 x1,x2,⋯,xn,以及 1 个整数 k(k<n)。从 n 个整数中任选 k 个整数相加,可分别得到一系列的和。例如当 n=4,k=3,4 个整数分别为 3,7,12,19 时,可得全部的组合与它们的和为:
3+7+12=22
3+7+19=29
7+12+19=38
3+12+19=34
现在,要求你计算出和为素数共有多少种。
例如上例,只有一种的和为素数:3+7+19=29。
输入格式
第一行两个空格隔开的整数 n,k(1≤n≤20,k<n)。
第二行 n 个整数,分别为 x1,x2,⋯,xn(1≤xi≤5×106)。
输出格式
输出一个整数,表示种类数。
输入输出样例
输入 #1复制
4 3 3 7 12 19
输出 #1复制
1
说明/提示
【题目来源】
NOIP 2002 普及组第二题
题目分析
选数问题要求从给定的n个整数中选出k个数,使得它们的和为素数。我们需要找出所有满足条件的组合数目。
问题特点
-
组合选择:从n个数中选出k个数的组合
-
素数判断:需要判断选出的k个数之和是否为素数
-
去重处理:不同位置的相同数字视为不同,但组合顺序不重要
-
回溯算法:适合使用深度优先搜索(DFS)或回溯法解决
算法思路
核心思想:回溯法
使用深度优先搜索遍历所有可能的组合,对于每个组合检查其和是否为素数。
递归定义
-
状态:当前已选数字的集合、当前搜索位置、已选数字个数
-
终止条件:已选数字个数等于k
-
递归过程:对于每个位置,选择或不选择当前数字
素数判断
使用试除法判断素数,只需检查到sqrt(n)即可。
数学原理
组合数学
从n个不同元素中取出k个元素的组合数为C(n,k) = n!/(k!(n-k)!)
素数判定
素数定义:大于1的自然数中,除了1和它本身以外不再有其他因数。
优化:只需检查2到sqrt(n)之间的整数是否能整除n。
代码实现
cpp
#include <iostream>
#include <cmath>
using namespace std;// 判断一个数是否为素数
bool isPri(int num) {if (num < 2) return false; // 小于2的数不是素数if (num == 2) return true; // 2是素数if (num % 2 == 0) return false; // 偶数不是素数(除了2)// 检查从3到sqrt(num)的所有奇数int lim = sqrt(num);for (int i = 3; i <= lim; i += 2) {if (num % i == 0) {return false;}}return true;
}// 全局变量
int n, k; // n个数字中选k个
int num[25]; // 存储输入的数字,最大25个
int cnt = 0; // 统计满足条件的组合数// 深度优先搜索函数
// pos: 当前考虑的数字位置
// sel: 当前已选数字个数
// sum: 当前已选数字的和
void dfs(int pos, int sel, int sum) {// 终止条件:已选够k个数字if (sel == k) {// 检查当前和是否为素数if (isPri(sum)) {cnt++;}return;}// 剪枝:如果剩余数字不够选择,直接返回if (n - pos < k - sel) {return;}// 从pos位置开始遍历所有可能的数字for (int i = pos; i < n; i++) {// 选择当前数字,继续递归dfs(i + 1, sel + 1, sum + num[i]);}
}int main() {// 读取输入cin >> n >> k;for (int i = 0; i < n; i++) {cin >> num[i];}// 从第0个位置开始搜索,当前已选0个数字,当前和为0dfs(0, 0, 0);// 输出结果cout << cnt << endl;return 0;
}
代码详细解析
头文件说明
cpp
#include <iostream> // 输入输出流
#include <cmath> // 数学函数,如sqrt
素数判断函数
cpp
// 判断一个数是否为素数
bool isPri(int num) {if (num < 2) return false; // 小于2的数不是素数if (num == 2) return true; // 2是素数if (num % 2 == 0) return false; // 偶数不是素数(除了2)
素数判断优化:
-
小于2的数不是素数
-
2是唯一的偶素数
-
其他偶数都不是素数
cpp
// 检查从3到sqrt(num)的所有奇数int lim = sqrt(num);for (int i = 3; i <= lim; i += 2) {if (num % i == 0) {return false;}}return true;
}
试除法原理:
-
如果n是合数,则它有一个不大于sqrt(n)的质因子
-
只需检查到sqrt(n)即可
-
跳过偶数,只检查奇数(因为已经排除了偶数情况)
全局变量定义
cpp
// 全局变量
int n, k; // n个数字中选k个
int num[25]; // 存储输入的数字,最大25个
int cnt = 0; // 统计满足条件的组合数
使用数组num[25]
存储输入数字,题目保证n ≤ 20,所以25足够用。
深度优先搜索函数
cpp
// 深度优先搜索函数
// pos: 当前考虑的数字位置
// sel: 当前已选数字个数
// sum: 当前已选数字的和
void dfs(int pos, int sel, int sum) {// 终止条件:已选够k个数字if (sel == k) {// 检查当前和是否为素数if (isPri(sum)) {cnt++;}return;}
递归终止条件:
-
当已选数字个数等于k时,检查当前和是否为素数
-
如果是素数,计数器加1
cpp
// 剪枝:如果剩余数字不够选择,直接返回if (n - pos < k - sel) {return;}
重要剪枝优化:
-
如果剩余未考虑的数字数量不足以凑齐k个数字,直接返回
-
避免不必要的递归调用
-
公式:剩余数字 = n - pos,还需要选择 = k - sel
cpp
// 从pos位置开始遍历所有可能的数字for (int i = pos; i < n; i++) {// 选择当前数字,继续递归dfs(i + 1, sel + 1, sum + num[i]);}
}
递归核心逻辑:
-
从当前位置pos开始,依次选择每个数字
-
每次选择后:位置+1,已选个数+1,和加上当前数字
-
这样保证每个组合只被考虑一次,且顺序固定
主函数
cpp
int main() {// 读取输入cin >> n >> k;for (int i = 0; i < n; i++) {cin >> num[i];}// 从第0个位置开始搜索,当前已选0个数字,当前和为0dfs(0, 0, 0);// 输出结果cout << cnt << endl;return 0;
}
程序流程:
-
读取n和k
-
读取n个数字到数组
-
调用DFS开始搜索
-
输出满足条件的组合数
算法优化分析
时间复杂度分析
-
组合数量:C(n,k),最坏情况下是指数级
-
素数判断:O(sqrt(m)),其中m是数字和
-
总复杂度:O(C(n,k) × sqrt(m))
对于n ≤ 20,k ≤ n的数据规模完全可行。
空间复杂度分析
-
递归深度:O(k)
-
数组存储:O(n)
-
总空间复杂度:O(n + k)
剪枝优化效果
剪枝操作避免了大量不必要的递归调用,特别是在k远小于n时效果显著。
完整优化版本
下面是增加更多优化和注释的版本:
cpp
#include <iostream>
#include <cmath>
using namespace std;// 优化素数判断:使用更高效的算法
bool isPri(int num) {// 处理特殊情况if (num < 2) return false;if (num == 2 || num == 3) return true;if (num % 2 == 0 || num % 3 == 0) return false;// 检查6的倍数附近的数// 所有大于3的素数都可以表示为6k±1的形式int lim = sqrt(num);for (int i = 5; i <= lim; i += 6) {if (num % i == 0 || num % (i + 2) == 0) {return false;}}return true;
}int n, k;
int arr[25]; // 存储输入数字
int ans = 0; // 结果计数器// 增强版DFS,增加更多注释
void dfs(int now, int got, int tot) {// now: 当前考虑的数字下标// got: 已经选择的数字个数// tot: 当前已选数字的总和// 如果已经选够k个数字if (got == k) {// 检查总和是否为素数if (isPri(tot)) {ans++; // 满足条件,计数器加1}return; // 返回上一层}// 优化剪枝:如果剩余数字不够凑齐k个,直接返回// 剩余数字数量 = n - now// 还需要选择的数字数量 = k - gotif (n - now < k - got) {return;}// 遍历从当前位置开始的所有数字for (int i = now; i < n; i++) {// 选择当前数字arr[i],然后继续递归// 下一个位置从i+1开始,避免重复选择// 已选数字个数加1,总和加上当前数字dfs(i + 1, got + 1, tot + arr[i]);// 注意:这里没有"不选择"的显式分支// 因为通过循环变量i的自增,我们自然跳过了不选择的情况}
}int main() {// 读取输入数据cin >> n >> k;// 读取n个整数for (int i = 0; i < n; i++) {cin >> arr[i];}// 开始深度优先搜索// 初始状态:从第0个数字开始,已选0个数字,当前总和为0dfs(0, 0, 0);// 输出满足条件的组合数量cout << ans << endl;return 0;
}
测试样例分析
样例1
输入:
text
4 3 3 7 12 19
输出:
text
1
解释:
所有可能的3个数的组合:
-
3+7+12=22(不是素数)
-
3+7+19=29(是素数)✓
-
3+12+19=34(不是素数)
-
7+12+19=38(不是素数)
只有一个组合满足条件。
样例2
输入:
text
5 2 1 2 3 4 5
输出:
text
2
解释:
所有可能的2个数的组合:
-
1+2=3(是素数)✓
-
1+3=4(不是素数)
-
1+4=5(是素数)✓
-
1+5=6(不是素数)
-
2+3=5(是素数)✓
-
2+4=6(不是素数)
-
2+5=7(是素数)✓
-
3+4=7(是素数)✓
-
3+5=8(不是素数)
-
4+5=9(不是素数)
满足条件的组合:1+2, 1+4, 2+3, 2+5, 3+4,共5个。
样例3(边界情况)
输入:
text
3 3 2 3 5
输出:
text
1
解释:
只有一种组合:2+3+5=10,不是素数,但2+3+5=10?等等,2+3+5=10不是素数。
实际上2+3+5=10不是素数,所以输出应该是0。
常见问题解答
1. 为什么使用DFS而不是其他方法?
DFS适合解决组合问题,可以系统地遍历所有可能的组合,代码实现简单直观。
2. 如何避免重复组合?
通过每次递归时从下一个位置开始(i+1),确保不会重复选择相同的数字,也不会产生顺序不同的相同组合。
3. 剪枝操作为什么有效?
当剩余数字数量不足以凑齐k个数字时,后续的递归调用都不可能得到有效解,提前返回可以节省大量时间。
4. 素数判断有哪些优化?
-
排除小于2的数
-
单独处理2和3
-
排除所有偶数(除了2)
-
只检查到sqrt(n)
-
只检查6k±1形式的数
5. 如果所有数字都是负数怎么办?
题目保证输入都是正整数,所以不需要考虑负数情况。
6. 递归深度会太大吗?
由于n ≤ 20,k ≤ n,最大递归深度为20,不会导致栈溢出。
算法扩展
输出所有满足条件的组合
如果需要输出具体的组合,可以修改代码:
cpp
#include <iostream>
#include <cmath>
using namespace std;bool isPri(int num) {if (num < 2) return false;if (num == 2) return true;if (num % 2 == 0) return false;int lim = sqrt(num);for (int i = 3; i <= lim; i += 2) {if (num % i == 0) return false;}return true;
}int n, k;
int num[25];
int cnt = 0;
int tem[25]; // 临时存储当前选择的数字void dfs(int pos, int sel, int sum) {if (sel == k) {if (isPri(sum)) {cnt++;// 输出当前组合cout << "组合 " << cnt << ": ";for (int i = 0; i < k; i++) {cout << tem[i] << " ";}cout << "= " << sum << " (素数)" << endl;}return;}if (n - pos < k - sel) return;for (int i = pos; i < n; i++) {tem[sel] = num[i]; // 记录当前选择的数字dfs(i + 1, sel + 1, sum + num[i]);}
}int main() {cin >> n >> k;for (int i = 0; i < n; i++) {cin >> num[i];}cout << "所有满足条件的组合:" << endl;dfs(0, 0, 0);cout << "总计: " << cnt << " 个组合" << endl;return 0;
}
使用迭代方法
也可以使用位运算或迭代方法:
cpp
#include <iostream>
#include <cmath>
using namespace std;bool isPri(int num) {if (num < 2) return false;if (num == 2) return true;if (num % 2 == 0) return false;int lim = sqrt(num);for (int i = 3; i <= lim; i += 2) {if (num % i == 0) return false;}return true;
}int main() {int n, k;cin >> n >> k;int num[25];for (int i = 0; i < n; i++) {cin >> num[i];}int cnt = 0;// 使用位运算枚举所有组合for (int mask = 0; mask < (1 << n); mask++) {// 检查当前mask中1的个数是否为kint bits = 0;for (int i = 0; i < n; i++) {if (mask & (1 << i)) bits++;}if (bits != k) continue;// 计算选中的数字之和int sum = 0;for (int i = 0; i < n; i++) {if (mask & (1 << i)) {sum += num[i];}}// 检查是否为素数if (isPri(sum)) {cnt++;}}cout << cnt << endl;return 0;
}
总结
选数问题是一个经典的组合搜索问题,主要考察点包括:
-
深度优先搜索:系统地遍历所有可能组合
-
递归思想:将大问题分解为小问题
-
剪枝优化:提前终止不可能的分支
-
素数判断:高效的数学算法
关键思路:
-
使用DFS枚举所有C(n,k)种组合
-
对每种组合检查其和是否为素数
-
通过剪枝减少不必要的计算
算法特点:
-
时间复杂度:O(C(n,k) × sqrt(m))
-
空间复杂度:O(n)
-
适用场景:小规模的组合搜索问题
掌握这个问题的解法对于理解回溯算法和组合数学非常重要,这种思路可以推广到其他类似的组合优化问题