尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

DFS与质数判定:从经典算法题P1036看组合枚举与高效素数判断

DFS与质数判定:从经典算法题P1036看组合枚举与高效素数判断 1. 项目概述从一道经典题目看搜索与质数判定的结合如果你刚开始接触算法竞赛或者想巩固递归与搜索的基础那么洛谷上的P1036选数这道题绝对是一个绕不开的经典练手题。它源自NOIP2002普及组题目本身不复杂但完美地将深度优先搜索DFS和质数素数判定这两个核心知识点串联了起来。很多朋友在初次遇到时可能会觉得思路清晰但一写就错或者在数据规模稍大时超时。这道题就像一把钥匙帮你理解如何用递归枚举组合以及如何高效地判断一个数是否为质数。今天我就结合自己多次辅导新手和打比赛的经验把这道题从里到外拆解一遍不仅告诉你“怎么做”更重点分享“为什么这么做”以及“怎么做得更好、更稳”。简单来说题目给你n个整数让你从中任选k个相加问一共有多少种选法使得这k个数的和是一个素数。题目限定了n≤20kn数值范围也很友好。这直接指向了两个核心任务第一如何不重不漏地枚举出所有可能的k个数组合第二如何快速判断它们的和是否为素数。前者是典型的组合枚举问题DFS是直观且易于理解的解决方案后者则考验你对素数判定算法的理解最朴素的试除法可能勉强过关但掌握更高效的筛法或优化判定能让你代码的健壮性和可扩展性大大提升。接下来我们就围绕这两个核心深入细节。2. 核心思路解析为什么DFS是组合枚举的“自然选择”2.1 问题建模与算法选型面对“从n个中选k个”的组合枚举我们有几个候选算法暴力多重循环、递归DFS、以及基于二进制的状态枚举位运算。为什么这道题通常推荐DFS首先k是变量不是固定值。如果用循环你需要写k层嵌套循环但k是输入值写死循环层数在编程上无法实现。其次DFS的递归结构天然适合处理这种“深度不确定”的枚举过程。你可以把“选择第i个数”看作递归的一层用递归深度depth来控制是否已经选了k个数。这种思路非常符合人的直观思考一个个选选够了k个就检查结果。更重要的是DFS便于实现“去重”和“剪枝”。题目要求的是组合而非排列即{1,2,3}和{3,2,1}是同一种选法。在DFS中我们通过引入一个start参数来实现在每一层递归中只从当前索引之后的位置开始选择新的数字。这样就保证了我们选数的索引是单调递增的自然避免了重复组合的产生。这是一种非常经典且有效的技巧。2.2 DFS函数的设计与状态定义一个设计良好的DFS函数是成功的一半。对于本题递归函数通常需要以下几个关键状态depth当前已经选择了几个数字也可以理解为递归的当前层数。当depth k时说明我们已经凑齐了k个数到达了递归的“叶子节点”需要计算和并判断素数。sum当前已选数字的累加和。我们可以在递归过程中实时维护这个和这样到达叶子节点时sum就是最终需要判断的数无需再次遍历已选数字列表求和提升了效率。start当前层可以开始选择的数字的起始索引。这是实现组合去重的关键。假设我们在第i层选择了索引为p的数字那么在第i1层start就应该从p1开始确保后续选择的数字都在这个数字之后避免了[1,2]和[2,1]这样的重复。函数的伪代码框架如下def dfs(depth, start, current_sum): # 终止条件已经选够k个数 if depth k: if is_prime(current_sum): global ans ans 1 return # 从start开始尝试选择每一个可能的数字 for i in range(start, n): # 选择数字 nums[i] dfs(depth 1, i 1, current_sum nums[i]) # 回溯状态已通过参数传递无需显式回溯注意这里没有显式的“撤销选择”步骤因为current_sum和depth都是通过函数参数传递的副本每一层递归调用结束后自动回到上一层的状态。这是一种干净利落的写法。注意start参数的设计是理解组合DFS的关键。务必理解i1作为下一层start的意义——它确保了数字选择的顺序性从而生成唯一的组合。3. 素数判定从试除法到高效优化枚举出和之后我们需要判断它是否为素数。这是本题的第二个核心也是性能瓶颈可能所在。虽然本题n≤20kn数字和最大可能不会特别夸张但养成高效编程的习惯至关重要。3.1 基础试除法及其优化最基础的素数判定是试除法对于一个正整数num如果它能被2到num-1之间的任意整数整除那么它就是合数否则是素数。但这样效率太低。优化一缩小试除范围。实际上如果num是合数那么它一定有一个不大于其平方根的因子。因此我们只需要试除到int(sqrt(num))即可。这是最关键的优化能将时间复杂度从O(n)降到O(√n)。优化二排除偶数。除了2以外所有偶数都不是素数。我们可以先判断num是否为2或者是否为大于2的偶数。在循环试除时可以从3开始每次步进2只检查奇数因子。一个优化后的试除法函数如下def is_prime(num): if num 2: return False if num 2: return True if num % 2 0: return False # 从3开始到sqrt(num)结束步长为2 limit int(num ** 0.5) 1 for i in range(3, limit, 2): if num % i 0: return False return True3.2 埃拉托斯特尼筛法的预应用有同学会问既然数值范围可能很大能不能用更快的筛法比如埃拉托斯特尼筛法埃氏筛这是一个很好的思考方向。但在这道题的具体场景下需要仔细分析。筛法的核心思想是提前预处理出一个布尔数组is_prime标记从2到某个上限MAXN的所有数是否为素数。这样每次判断num时只需要O(1)时间查询is_prime[num]即可非常快。然而难点在于MAXN的确定。我们需要知道k个数的和最大可能是多少。题目没有明确给出每个数字的范围但根据NOIP普及组的惯例和常见数据数字通常不会太大。最保守的估计假设每个数最大为10^4实际上通常更小选20个和最大为2*10^5。这个范围对于埃氏筛时间复杂度约O(n log log n)是完全可行的预处理一次后续判断都是O(1)。但是在竞赛编程中除非题目明确给出了数值范围或者经过分析可以确定一个合理的上限否则依赖预处理的筛法可能有风险。如果某个测试点给出的数字很大导致和超出你预设的MAXN筛法就会失效。而优化后的试除法对于单个数的判断是稳定的。我的建议是对于本题使用优化后的试除法完全足够且更安全。将筛法作为知识扩展理解其思想。如果题目明确“总和不超过10^6”之类的条件那么筛法是更好的选择。4. 完整代码实现与逐行解析下面我将给出一个用Python实现的完整代码并附上详细的注释。选择Python是因为其语法清晰易于理解但逻辑同样适用于C或Java。# 优化后的素数判断函数 def is_prime(num: int) - bool: 判断一个整数是否为素数。 if num 2: return False if num 2: return True if num % 2 0: # 排除所有偶数 return False # 只需检查到平方根且只检查奇数因子 limit int(num ** 0.5) 1 for i in range(3, limit, 2): if num % i 0: return False return True # 深度优先搜索函数 def dfs(depth: int, start: int, current_sum: int): depth: 当前已选数字的个数 start: 当前层可以开始选择的数字索引 current_sum: 当前已选数字的总和 global ans # 使用全局变量记录符合条件的组合数 # 终止条件已选够k个数 if depth k: if is_prime(current_sum): ans 1 return # 枚举所有可能的选择 for i in range(start, n): # 递归进入下一层选择nums[i]深度1下一层从i1开始总和增加 dfs(depth 1, i 1, current_sum nums[i]) # 注意这里没有显式回溯因为状态通过参数传递自动回溯。 # 主程序 if __name__ __main__: # 读取输入n, k 以及 n 个整数 n, k map(int, input().split()) nums list(map(int, input().split())) ans 0 # 初始化结果计数器 # 开始深度优先搜索初始状态选了0个数从索引0开始选择当前和为0 dfs(0, 0, 0) print(ans)关键点解析全局变量ans在递归中需要更新最终结果使用全局变量是简洁的方式。也可以将ans作为参数传递或使用闭包但全局变量在这里最直观。递归起点dfs(0, 0, 0)。第一个0表示当前选了0个数第二个0表示从数组索引0开始选择第三个0表示当前和为0。循环范围for i in range(start, n)。确保在每一层可选的数字索引是从start到n-1。递归调用dfs(depth 1, i 1, current_sum nums[i])。这是核心状态转移选择当前数字nums[i]所以已选数量depth加1为了保证组合不重复下一层只能从i1之后选所以start变为i1当前总和自然要加上nums[i]。5. 深度优化与边界情况处理上面的代码已经可以AC通过本题。但本着精益求精的态度我们还可以探讨一些优化和边界处理这些技巧在更复杂的问题中会非常有用。5.1 搜索剪枝提前终止无效分支虽然本题数据规模小剪枝效果不明显但掌握剪枝思想很重要。一种常见的剪枝是如果剩下的所有数字都选上也达不到k个数那么这条分支就可以提前结束。具体来说当前已选depth个数还需要选k - depth个数。从当前位置start开始数组剩余的数字数量是n - start。如果n - start k - depth那么即使后面所有数字都选也凑不齐k个这条递归路径不可能到达终点可以直接return。在dfs函数的开头加上# 剪枝剩余数字数量不足以凑齐k个数 if n - start k - depth: return这个小优化在n和k接近时能减少一些不必要的递归调用。5.2 输入处理与数据类型题目输入是n k以及一行n个整数。务必注意输入格式使用input().split()正确分割。数字范围没有明确说明但在Python中int类型可以处理很大的整数无需担心溢出。如果在C中需要使用long long来存储总和因为20个10^4量级的数相加可能会超过int范围。5.3 关于数字“1”的特殊处理在素数判定函数中我们首先判断if num 2: return False。这直接处理了数字0和1的情况。0和1既不是素数也不是合数但在本题的上下文中它们作为和出现时肯定不符合条件。这个判断必须要有。6. 常见错误与调试心得在教学和解题过程中我见过同学们踩过不少坑。这里总结几个最常见的错误1重复计算组合这是最常见的问题。表现为结果比标准答案大很多。原因DFS没有控制选择顺序导致了[1,2]和[2,1]被算作两种不同的组合。解决确保在递归函数中使用了start参数并且递归调用时传入的是i1而不是start1或0。i1保证了后续选择的索引一定大于当前索引实现了“组合”去重。错误2素数判断效率低下导致超时虽然本题数据弱但养成好习惯很重要。原因使用了未优化的试除法例如循环到num-1。解决务必使用优化后的试除法循环上界为sqrt(num)并跳过偶数因子。错误3递归函数状态管理混乱原因试图用一个全局的列表来记录当前选择的路径但在回溯时没有正确弹出元素。解决像本例一样采用“参数传递累加和”的方式避免维护显式的路径列表。如果非要记录路径例如需要输出具体组合那么回溯时必须显式地pop()path [] def dfs(depth, start, current_sum): if depth k: if is_prime(current_sum): ans.append(path.copy()) # 注意要拷贝副本 return for i in range(start, n): path.append(nums[i]) # 选择 dfs(depth1, i1, current_sumnums[i]) path.pop() # 撤销选择回溯注意ans.append(path.copy())这里的copy()至关重要。因为path列表在后续回溯中会被修改如果不保存副本ans中所有的结果都会指向同一个最终被清空的path列表。错误4忽略边界和特殊输入场景k0或n0题目已限定kn且n1所以不会出现。但自己写代码时要考虑函数的健壮性。场景数字中包含负数题目说是整数但通常比赛数据是非负整数或正整数。如果真有负数素数定义只针对正整数需要额外处理。不过本题无需考虑。7. 算法扩展与思维提升解完这道题并不意味着结束。我们可以从几个方向进行扩展思考提升自己的算法能力1. 迭代与位运算枚举法除了DFS组合枚举还可以用位运算。对于一个n个元素的集合每个元素有“选”或“不选”两种状态可以用一个n位的二进制数表示。我们遍历所有2^n种状态检查其中恰好有k位是1即选了k个的状态然后计算和并判断素数。ans 0 # 遍历所有状态 for mask in range(1 n): # 统计当前状态中1的个数选中的数字个数 if bin(mask).count(1) ! k: continue total 0 for i in range(n): if mask (1 i): # 检查第i位是否为1 total nums[i] if is_prime(total): ans 1这种方法在n很小比如n20时非常简洁且无需递归。但当n较大时2^n的遍历次数会爆炸式增长不可行。2. 更大数据范围的挑战如果n扩大到30甚至50k为15左右单纯的DFS或位枚举都可能超时。这时需要考虑更高级的算法如“折半搜索”Meet-in-the-Middle。思路是将n个数分成前后两半分别枚举两半所有可能的子集和然后通过排序和双指针从两边各选一部分组合成k个数。这能将复杂度从C(n, k)级别显著降低。当然这已经远超本题范围但了解这种思想对解决更复杂的问题很有帮助。3. 素数判定的终极武器Miller-Rabin算法如果数字和非常大比如超过10^14试除法也会很慢。在算法竞赛中判断大素数的黄金标准是米勒-拉宾素性测试。它是一种概率算法但通过选择特定的底数可以在一定范围内实现确定性判断。对于竞赛常见的64位整数范围有一套固定的底数可以保证结果绝对正确。学习这个算法能让你在面对大数据质数判断时游刃有余。回过头看P1036选数这道题就像一位耐心的教练它用不高的门槛带你走进了递归搜索的世界并让你意识到哪怕是最基础的素数判断也有那么多可以优化的细节。编程的魅力就在于即使是一个简单的任务也蕴含着对效率、鲁棒性和代码优雅的持续追求。把这里的每一个细节吃透无论是start参数的精妙还是试除法里那个sqrt优化都会成为你解决更复杂问题时肌肉记忆的一部分。下次再遇到需要枚举组合的问题你会自然而然地写出结构清晰的DFS再遇到数论问题你也会下意识地去想有没有更优的判断方法。这才是刷经典题目的真正价值所在。
返回列表