信息学竞赛经典题“选数”解析:DFS与素数判定的算法实践
2026/8/24 6:52:46 网站建设 项目流程

1. 项目概述与核心思路拆解

“选数”这道题,是很多信息学竞赛(OI)选手的“老朋友”了,尤其是经历过NOIP(全国青少年信息学奥林匹克联赛)早期赛事的选手。题目本身描述非常简洁:给定n个整数,以及一个整数k,要求你从这n个数中任选k个数相加,问有多少种不同的组合,其和是一个素数。

初看之下,这题似乎不难理解,但真正动手实现,尤其是对于刚接触算法竞赛的新手来说,里面有几个关键点需要想清楚。首先,它考察了两个核心算法思想的结合:组合枚举素数判定。组合枚举,即从n个元素中不重复、不计顺序地选出k个的所有可能;素数判定,则是判断一个数是否为质数。题目将这两个看似独立的知识点串联起来,形成了一个经典的“搜索+数学”问题。

为什么这道题经典?因为它完美地充当了深度优先搜索(DFS)算法的入门练习题。DFS是一种用于遍历或搜索树或图的算法,它会尽可能深地搜索树的分支。在“选数”问题中,我们可以把“从n个数中选k个”的过程,想象成在一棵决策树上进行探索:每个数都有“选”或“不选”两种可能(更常见的DFS写法是“选”或“跳过”),我们需要探索所有恰好选择了k个数的路径,并计算每条路径上数字的和。这个过程天然适合用递归实现的DFS来模拟。

对于素数判定部分,题目数据范围(n≤20,整数x_i ≤ 5×10^6)意味着k个数的和可能非常大(最大可达1亿)。因此,一个高效的素数判定算法至关重要。最朴素的从2遍历到sqrt(n)的试除法在这里是可行的,也是本题最常用的解法。但这也引出了对算法效率的思考,是本题的一个延伸价值点。

所以,解决这道题的总体思路非常清晰:使用DFS递归地枚举所有C(n, k)种组合,对每种组合计算其和,然后用素数判定函数检查和是否为素数,最后统计素数和的个数。接下来,我们就深入每个环节,看看具体怎么做,以及有哪些需要注意的“坑”。

2. 深度优先搜索(DFS)框架设计与实现

DFS是解决本题的骨架。我们需要设计一个递归函数,它能够系统地走过所有可能的k元组合。

2.1 递归函数的状态定义

首先,要明确递归函数需要哪些参数来刻画当前的搜索状态。通常我们需要:

  1. startindex:当前从哪个位置开始考虑选择数字。这是避免重复组合(如[1,2][2,1]被视为同一种)的关键。我们规定每次只能从当前位置及之后的位置选取数字,保证了组合的唯一性(按原数组顺序)。
  2. depthcount:当前已经选择了多少个数字。当这个值等于k时,我们就到达了一个“叶子节点”,即找到了一种完整的组合,需要对其进行处理(检查和是否为素数)。
  3. current_sum:当前已选数字的总和。我们可以在递归过程中累加,这样当到达叶子节点时,current_sum就是最终的和,无需再次遍历已选数字列表进行求和,这是一个重要的优化。

因此,一个典型的递归函数签名可能是:dfs(start, count, current_sum)

2.2 递归的流程与回溯

递归过程遵循标准模式:

  1. 递归终止条件:如果count == k,说明已经选够了k个数。此时,current_sum就是这k个数的和。调用素数判定函数isPrime(current_sum),如果是素数,则全局计数器ans加一。然后返回,结束当前分支的搜索。
  2. 递归主体(枚举与选择):如果还没选够(count < k),我们需要从startn这个区间内,尝试选择下一个数字。这里有一个重要的剪枝优化:如果剩余可选的数字数量(n - start + 1)小于我们还需要选择的数字数量(k - count),那么即使把后面所有数都选上也不够k个,这个分支可以直接放弃(剪枝),无需继续递归。
  3. 做出选择与撤销选择(回溯):对于每一个可选的数字nums[i](i从start到n),我们“选择”它。在代码中,这体现为进行递归调用:dfs(i + 1, count + 1, current_sum + nums[i])。注意,这里的i+1作为新的start,确保了下一个数字从当前数字之后开始选,避免了重复。countcurrent_sum也相应更新。递归调用返回后,意味着以nums[i]作为当前选择的所有分支都已经探索完毕,程序会自动“回溯”到选择nums[i]之前的状态,去尝试下一个可选数字nums[i+1]。由于我们是通过参数传递状态,而不是修改全局变量,所以不需要显式的“撤销”操作,这种写法更简洁安全。

注意:这里容易混淆“组合”和“排列”。题目要求的是组合,即[1,2][2,1]是同一种。我们的DFS通过start参数确保了搜索顺序是单向的(只往后看),从而天然地生成了所有组合,不会产生重复。如果去掉start参数,每次递归都从0开始枚举,就会生成所有排列,导致大量重复计数。

2.3 代码框架示例(C++风格伪代码)

int n, k; int nums[25]; int ans = 0; // 全局答案计数器 // 素数判定函数,后文详述 bool isPrime(int num) { ... } void dfs(int start, int count, int sum) { // 剪枝:如果剩下的数全选也不够k个,直接返回 if (count + (n - start + 1) < k) return; // 终止条件:已选满k个数 if (count == k) { if (isPrime(sum)) { ans++; } return; } // 从start开始,尝试选择每一个数 for (int i = start; i < n; i++) { // 选择 nums[i],进入下一层递归 dfs(i + 1, count + 1, sum + nums[i]); // 递归返回后,自动回溯,尝试下一个i } } int main() { // 输入 n, k 和 nums 数组 dfs(0, 0, 0); // 从第0个数开始,当前选了0个,当前和为0 cout << ans << endl; return 0; }

3. 素数判定算法的选择与优化

在DFS枚举出每一个和之后,我们需要快速且正确地判断它是否为素数。这是本题的第二个技术核心。

3.1 基础试除法

对于给定的整数num,最直接的方法是试除法:检查num是否能被2sqrt(num)之间的任何整数整除。如果能,则不是素数;否则是素数。

bool isPrime(int num) { if (num < 2) return false; // 1和负数不是素数 if (num == 2) return true; // 2是素数 if (num % 2 == 0) return false; // 排除偶数 int limit = sqrt(num); // 只需要检查到平方根 for (int i = 3; i <= limit; i += 2) { // 从3开始,只检查奇数 if (num % i == 0) return false; } return true; }

优化点

  1. 特判:小于2的数不是素数。2是唯一的偶素数。
  2. 排除偶数:在判断大于2的数时,先判断是否为偶数,可以立即筛除一半的情况。
  3. 缩小范围:只需检查到sqrt(num)。因为如果num有一个大于其平方根的因子,那么必然对应一个小于其平方根的因子,检查小因子即可。
  4. 步长为2:在排除了偶数后,循环时i从3开始,每次加2,只检查奇数因子。

对于本题的数据范围(和最大约1亿),sqrt(1e8) = 1e4,每次判定的循环次数最多一万次左右。而DFS枚举的组合数C(20,10)约为18.5万种,在最坏情况下,素数判定的总计算量大约在18.5亿次运算。这在现代计算机上(配合优化)通常能在题目要求的时间限制(通常是1秒)内完成,但已经接近极限。因此,这个基础的试除法是可行但非最优的。

3.2 埃拉托斯特尼筛法(埃筛)的预计算优化

如果我们能预先知道哪些数是素数,那么在DFS过程中判断current_sum时,就可以用O(1)的时间查表完成。这就是空间换时间的思路。

埃筛的原理是:假设所有数初始都是素数,从2开始,将每个素数的所有倍数标记为非素数。最终未被标记的数就是素数。

const int MAX_SUM = 1e8; // 根据题目最大和估计 bool isPrimeTable[MAX_SUM + 1]; // 素数表,true表示是素数 void sieve() { memset(isPrimeTable, true, sizeof(isPrimeTable)); isPrimeTable[0] = isPrimeTable[1] = false; int limit = sqrt(MAX_SUM); for (int i = 2; i <= limit; i++) { if (isPrimeTable[i]) { for (int j = i * i; j <= MAX_SUM; j += i) { isPrimeTable[j] = false; } } } }

然后,isPrime(num)函数就简化为:

bool isPrime(int num) { if (num < 0 || num > MAX_SUM) return false; // 边界检查 return isPrimeTable[num]; }

优劣分析

  • 优势:查询速度极快(O(1)),在需要大量素数判定的场景下优势明显。
  • 劣势
    1. 空间开销大:需要开辟一个大小为MAX_SUM+1的布尔数组。对于最大和1亿的情况,需要约100MB的内存(bool在C++中通常为1字节)。这在一些内存限制严格的竞赛环境中可能无法通过。
    2. 时间开销集中:筛法本身有O(n log log n)的时间复杂度,初始化需要一定时间。

实操心得:在“选数”这道题中,通常不推荐使用埃筛。原因正是内存限制。竞赛题目的内存限制常见为128MB或256MB,一个100MB的数组虽然可能勉强够用,但加上程序其他部分的开销,存在风险。而且,本题的枚举量(约18万次)对于优化后的试除法来说是可以接受的。因此,采用优化后的试除法是更稳妥、更通用的选择。埃筛更适合于需要频繁查询、且查询范围相对固定且可预知的场景。

4. 完整代码实现与逐行解析

下面给出一个整合了DFS和优化试除法的C++完整代码,并附上详细注释。

#include <iostream> #include <cmath> using namespace std; int n, k; int nums[25]; // 题目说n<=20,稍微开大一点 int ans = 0; // 全局答案,记录和为素数的组合数 // 优化后的试除法判断素数 bool isPrime(int num) { if (num < 2) return false; if (num == 2) return true; if (num % 2 == 0) return false; // 偶数不是素数(除了2) int limit = (int)sqrt(num); // 计算平方根,只需检查到此为止 for (int i = 3; i <= limit; i += 2) { // 从3开始,每次加2,只检查奇数因子 if (num % i == 0) { return false; } } return true; } /** * 深度优先搜索函数 * @param start 当前从数组的哪个位置开始考虑选择 * @param count 当前已经选择了多少个数字 * @param sum 当前已选数字的总和 */ void dfs(int start, int count, int sum) { // 剪枝:如果剩下的数字个数不足以凑齐k个,直接返回 // 当前已选count个,还需要 (k - count) 个。 // 从start到末尾(n-1)共有 (n - start) 个数字。 // 如果 (n - start) < (k - count),则不可能成功,剪枝。 if ((n - start) < (k - count)) { return; } // 递归终止条件:已经选择了k个数 if (count == k) { if (isPrime(sum)) { ans++; } return; // 返回上一层,尝试其他选择 } // 从start位置开始,尝试选择每一个数 for (int i = start; i < n; i++) { // 选择nums[i],进入下一层递归 // 新的start是i+1,确保下一个数在当前数之后选,避免重复组合 // count加1,sum加上当前数字的值 dfs(i + 1, count + 1, sum + nums[i]); // 递归返回后,继续循环,尝试选择下一个数(即回溯) } } int main() { // 输入数据 cin >> n >> k; for (int i = 0; i < n; i++) { cin >> nums[i]; } // 初始化搜索:从第0个数开始,当前选了0个,当前和为0 dfs(0, 0, 0); // 输出答案 cout << ans << endl; return 0; }

代码关键点解析

  1. 数组与全局变量nums数组存储输入的n个数。ans作为全局变量,在DFS中找到合法组合时自增。
  2. isPrime函数:如前所述,进行了特判、偶数和范围优化。
  3. dfs函数中的剪枝if ((n - start) < (k - count)) return;这一行是重要的效率优化。它提前终止了那些“即使后面所有数都选上也不够k个”的无用分支,减少了大量不必要的递归调用。
  4. 递归调用dfs(i + 1, count + 1, sum + nums[i])是核心。参数传递实现了状态的前进,递归返回则意味着回溯。
  5. 主函数:读入数据,从初始状态(0,0,0)开始调用DFS,最后输出结果。

5. 常见问题、调试技巧与扩展思考

即使理解了算法,在实现时也可能遇到各种问题。下面是一些常见坑点和解决思路。

5.1 结果为什么是0或特别大?

  • 结果为0:首先检查素数判定函数isPrime。常见错误:
    • 忘记处理小于2的数。
    • 循环条件写错,例如for (i=2; i<sqrt(num); i++),应该用<=,因为平方根本身也可能是因子。
    • 没有对输入数据进行验证,确保DFS能正确遍历。
  • 结果特别大:这几乎肯定是组合重复计数了。检查DFS函数是否保证了“组合”而非“排列”。关键点在于dfs调用中的start参数是否每次都在递增(i+1)。如果错误地将start固定为0或一个不增长的值,就会生成所有排列,导致答案远大于正确值。

5.2 递归深度过深导致栈溢出?

本题n最大为20,k最大为20,递归深度最大为20。这对于任何编程语言的递归栈来说都是安全的,不会造成栈溢出。但这是一个好习惯:在编写DFS时,要心里有数,递归深度是否在合理范围内(通常几百以内是安全的,上千就需要注意)。

5.3 如何调试DFS?

DFS的调试有时比较抽象,可以尝试以下方法:

  1. 打印日志:在dfs函数的开头,打印当前的start,count,sum参数。在终止条件里,打印找到的组合和判断结果。这样可以清晰看到递归的路径和决策。
    void dfs(int start, int count, int sum) { // 打印当前状态 // cout << "dfs called: start=" << start << ", count=" << count << ", sum=" << sum << endl; ... // 原有逻辑 if (count == k) { // cout << "Found combination with sum: " << sum << ", isPrime? " << isPrime(sum) << endl; ... } ... }
  2. 使用小数据测试:用手工可以计算的小数据(如n=4, k=2,数字很简单)进行测试,将程序输出与手工计算结果对比。
  3. 使用IDE调试器:设置断点,单步执行,观察变量变化和调用栈,这是最强大的调试手段。

5.4 时间复杂度的估算与优化思考

  • 时间复杂度:主要来自DFS枚举和素数判定。枚举组合数为C(n, k)。最坏情况n=20, k=10时,C(20,10)=184756。对每个组合,素数判定复杂度为O(√S),S最大约为1e8,√S=1e4。总计算量约184756 * 1e4 ≈ 1.85e9次运算。在现代CPU上,经过优化(如提前排除偶数),勉强能在1秒左右完成。如果n和k更大,这种方法就会超时。
  • 进一步优化方向
    • 记忆化:本题中,不同的组合可能产生相同的和。如果某个和已经被判定过是否为素数,可以缓存结果。但考虑到和的范围很大(到1亿),用数组缓存不现实;用mapunordered_map查询也有开销。在本题数据规模下,收益不一定明显。
    • 更快的素数判定:如米勒-拉宾素性测试,这是一种概率算法,速度极快,适用于大数判定。但对于本题范围,杀鸡用牛刀,且实现稍复杂。
    • 改变枚举策略:使用迭代而非递归?递归的代码更清晰。对于组合枚举,递归是最自然的表达。

5.5 算法扩展:如果n很大(比如100),k也很大怎么办?

当n达到100,C(100,50)是一个天文数字,暴力DFS完全不可行。这就进入了动态规划(DP)折半搜索(Meet-in-the-Middle)的领域。但这已远超本题原意。原题“选数”的核心价值在于引导学习者掌握DFS解决组合枚举问题的基本范式,并理解与基础数学知识的结合。

最后,这道“选数”题虽然基础,但它像一块坚实的基石。熟练掌握它,意味着你真正理解了DFS在组合问题中的应用,以及如何将不同模块(搜索、数学)的代码清晰、高效地组织在一起。在竞赛或日常编程中,这种“分解问题、组合算法”的能力,远比死记硬背代码模板重要得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询