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

资讯详情

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

Python高效筛选纯质数:从埃氏筛到BFS的算法实战

Python高效筛选纯质数:从埃氏筛到BFS的算法实战 1. 项目概述从一道竞赛题到算法思维的实战最近在复盘一些经典的算法竞赛题目第十二届蓝桥杯的“纯质数”问题让我觉得特别有意思。它不像那些复杂的动态规划或图论题那样让人望而生畏但恰恰是这种题目最能考验一个程序员对基础概念的掌握程度和代码实现的严谨性。所谓“纯质数”题目定义是一个质数其每一位上的数字也都是质数注意这里质数数字指的是2, 3, 5, 7。比如23它本身是质数十位2和个位3也都是质数所以它是纯质数。而29虽然本身是质数但个位9不是质数所以它不是。题目通常要求我们找出在某个范围内比如1到20210605所有这样的数。这题目乍一看很简单不就是判断质数加上逐位判断吗但当你真正动手去实现尤其是当范围上限很大时就会遇到性能这个“拦路虎”。暴力循环判断每个数在竞赛的时间限制内几乎是不可行的。这就引出了我们今天要深入探讨的核心如何用Python高效地“筛选”出纯质数。这个过程不仅仅是写一段能跑的代码更是关于如何将问题拆解、如何选择合适的数据结构与算法、以及如何优化每一个计算环节的思维训练。无论你是正在备赛蓝桥杯的学生还是想提升自己Python编程和算法能力的开发者相信这篇从实战中总结的详细分析与实现都能给你带来直接的启发和可复用的代码方案。2. 核心思路拆解为什么“筛选”是关键面对“找出范围内所有纯质数”这个问题最直接的冲动可能就是写一个双重循环外层遍历范围内每一个数内层先判断它是不是质数再逐位拆解判断每一位是不是2,3,5,7。这个思路完全正确但性能是灾难性的。假设范围N最大到一千万那么我们需要进行N次质数判断。而每次质数判断如果用到试除法时间复杂度是O(√n)整体复杂度接近O(N√N)对于千万级别的数据普通电脑可能得跑上几分钟甚至更久在竞赛的秒级时间限制内绝对会超时。所以“筛选”二字就是破题的关键。我们不应该被动地对每个数进行昂贵的质数判断而应该主动地、批量地“筛”出质数。这背后是典型的“用空间换时间”和“预处理”思想。我们的策略可以分两步走第一步质数筛选。我们需要预先知道在目标范围内哪些数是质数。最经典的算法是埃拉托斯特尼筛法简称埃氏筛。它的核心思想非常巧妙假设我们要找出所有小于等于N的质数。首先列出从2到N的所有整数。然后从最小的质数2开始将其所有的倍数除了2本身标记为非质数。接着找到下一个未被标记的数它一定是质数比如3再将其所有倍数标记掉。重复这个过程直到处理完所有数。最后未被标记的数就是质数。埃氏筛的时间复杂度是O(N log log N)效率远高于对每个数单独试除。第二步纯质数条件过滤。在得到了质数列表后我们并不需要遍历列表中的每一个质数去逐位判断。一个更高效的方法是利用“纯质数”的每一位只能是2,3,5,7这个强约束条件反向构造可能的数字组合然后检查这些构造出来的数是否在我们筛出的质数集合中。因为每一位只有4种选择2,3,5,7所以对于d位数最多只有4^d个可能的数字。这个数量相比N来说是指数级减少的。例如对于8位数小于一亿最多也只需要检查4^8 65536个数计算量微乎其微。将这两步结合我们的完整算法流程就清晰了先用埃氏筛快速得到一个大范围内的质数布尔表is_prime数组然后通过DFS深度优先搜索或BFS广度优先搜索生成所有由{2,3,5,7}组成的、不超过范围上限的数字最后查询is_prime表确认这些数字是否为质数从而得到最终的纯质数列表。这个方案将复杂度从近乎O(N^1.5)降低到了O(N log log N) O(4^d)实现了质的飞跃。注意这里有一个关键的优化点。在生成数字时我们可以利用质数的性质进行剪枝。例如除了数字2以外所有其他质数都是奇数。因此在我们用DFS生成数字时如果最后一位个位是2那么生成的数字是偶数且大于2它肯定不是质数2是特例需要单独处理。所以我们可以在生成过程中就避免生成个位是2除了数字2本身的数字这能减少近1/4的无用查询。3. 工具与算法选型埃氏筛的细节与实现工欲善其事必先利其器。实现高效筛选我们主要依赖两个工具埃氏筛算法和DFS生成算法。我们先来深入看看埃氏筛的Python实现细节。埃氏筛的原理虽然简单但实现上却有多种微调直接影响运行效率和内存使用。最基础的实现如下def eratosthenes_sieve(limit): is_prime [True] * (limit 1) is_prime[0:2] [False, False] # 0和1不是质数 for i in range(2, int(limit**0.5) 1): if is_prime[i]: # 从i*i开始标记因为i*(i-1)等已经被更小的质数标记过了 for j in range(i*i, limit1, i): is_prime[j] False return is_prime这段代码创建了一个布尔列表is_prime初始假设所有数都是质数。然后从2遍历到√limit如果当前数i是质数is_prime[i]为True就把从i*i开始的所有i的倍数标记为非质数。这里从i*i开始标记是一个重要优化因为对于质数ii * k其中k i这个合数一定已经被比i更小的质数比如k的某个质因数标记过了。然而对于极限优化比如范围N接近一亿或更大我们还可以考虑以下两点使用bytearray替代listbytearray在存储大量布尔值时比listofbool更节省内存访问速度也更快。因为list中每个元素都是一个完整的Python对象而bytearray是紧凑的字节数组。只筛选奇数除了2以外所有质数都是奇数。我们可以只初始化一个表示奇数的布尔数组大小约为limit//2这样内存占用减半同时循环次数也减半。不过实现会稍复杂一些需要处理下标和实际数值的映射关系。对于蓝桥杯这道题范围通常在千万级别使用基础的list实现已经完全足够代码也更清晰易读。我们选择清晰性优先。实操心得在实现埃氏筛时内层循环的步长是i这是一个关键性能点。Python的for循环对于大范围的步长迭代是高效的。但务必注意循环的起始点设置为i*i。如果你写成从2*i开始虽然结果正确但会做大量重复的标记工作性能会有可观的下降。我实测过一个简单的对比在N10^7时从i*i开始比从2*i开始快将近一倍。4. 纯质数生成策略DFS与BFS的抉择拿到了质数布尔表is_prime后下一步就是生成所有可能的“纯数字”并检查。所谓“纯数字”就是每一位都由{2, 3, 5, 7}构成的数字。我们需要生成所有不超过上限N的这类数字。这里有两种主要的生成方法深度优先搜索DFS和广度优先搜索BFS。DFS递归实现思路是递归地构建数字字符串或数值。从最高位开始每一位有4种选择。递归的终止条件是当前构造的数字大于上限N或者已经达到了我们想要的最大位数可以根据N的位数算出。def dfs_construct(current_num, limit, prime_check_list, results): # current_num: 当前构造的数字 # limit: 上限 # prime_check_list: 质数布尔表 # results: 存储结果的列表 if current_num limit: return # 如果当前数字是质数则加入结果注意0和1需要排除但我们的生成从2,3,5,7开始不会生成0或1 if prime_check_list[current_num]: results.append(current_num) # 尝试在当前数字末尾添加一位 for digit in [2, 3, 5, 7]: new_num current_num * 10 digit dfs_construct(new_num, limit, prime_check_list, results) # 调用方式需要从2,3,5,7分别作为起点开始递归BFS队列实现使用一个队列初始将[2,3,5,7]放入。每次从队列中取出一个数如果它是质数则记录然后尝试在其末尾添加一位{2,3,5,7}形成新的数如果新数未超过上限则放入队列。from collections import deque def bfs_construct(limit, prime_check_list): results [] queue deque([2, 3, 5, 7]) while queue: num queue.popleft() if num limit: continue if prime_check_list[num]: results.append(num) # 生成下一层数字 for digit in [2, 3, 5, 7]: new_num num * 10 digit if new_num limit: queue.append(new_num) return results两种方法的对比与选择DFS实现简洁尤其适合用递归表达。但它存在递归深度限制的问题。虽然对于本题数字位数不会太多上限20210605是8位数递归深度最多8层完全安全但作为一种通用思路需要留意。DFS生成数字的顺序不是按大小递增的而是按深度优先的路径。BFS使用队列没有递归深度问题。它天然地按照数字的位数从小到大生成一层一层地生成顺序性更好。代码稍微复杂一点但更稳健。对于本题两者均可。我个人更倾向于使用BFS因为它避免了递归可能带来的潜在顾虑尽管这里没有并且逻辑上更直观地模拟了“逐位构造”的过程。此外BFS按位数递增的顺序生成如果我们只需要计数而不需要具体列表可以在生成过程中更方便地做一些统计。注意事项在生成过程中务必注意数字0和1的处理。我们的生成起点是2,3,5,7所以不会生成0或1。但是在质数判断时is_prime列表的索引0和1对应的是False。这是一个细节但保持一致性很重要。另外数字2和5本身是质数但按照“纯质数”定义2的每一位是2质数5的每一位是5质数所以它们都是纯质数需要被包含在结果中。我们的生成算法从它们开始自然能包含。5. 完整代码实现与逐行解析将埃氏筛和BFS生成法结合起来我们就可以得到完整的解决方案。下面是我优化后的完整代码并附上详细的注释。def count_pure_primes(limit): 计算在[1, limit]范围内纯质数的个数。 纯质数定义该数是质数且其每一位数字十进制也都是质数即2,3,5,7。 # ---------- 1. 埃拉托斯特尼筛法生成质数表 ---------- is_prime [True] * (limit 1) is_prime[0] is_prime[1] False # 0和1不是质数 # 只需遍历到sqrt(limit) for i in range(2, int(limit**0.5) 1): if is_prime[i]: # 从i*i开始标记i的倍数步长为i # 使用切片赋值可能更快但这里用循环清晰表达逻辑 for j in range(i * i, limit 1, i): is_prime[j] False # ---------- 2. BFS生成所有“纯数字”并检查 ---------- from collections import deque pure_prime_count 0 # 初始化队列种子数字就是一位数的质数数字 queue deque([2, 3, 5, 7]) while queue: num queue.popleft() # 如果当前数字已经超过上限跳过其后续数字一定更大但由BFS特性同层可能还有小的 if num limit: continue # 检查当前数字是否为质数 if is_prime[num]: pure_prime_count 1 # 如果需要记录具体的纯质数可以添加到一个列表中 # pure_primes.append(num) # 生成下一个位数的候选数字在当前数字末尾添加一位{2,3,5,7} for digit in [2, 3, 5, 7]: new_num num * 10 digit # 如果新数字没超过上限则加入队列继续探索 if new_num limit: queue.append(new_num) return pure_prime_count # 示例计算1到20210605之间的纯质数个数第十二届蓝桥杯真题范围 if __name__ __main__: limit 20210605 result count_pure_primes(limit) print(f在1到{limit}范围内纯质数的个数为: {result})代码关键点解析质数筛的优化for i in range(2, int(limit**0.5) 1):这个循环边界是优化的核心。因为如果limit有一个大于其平方根的因子那么它必然还有一个小于平方根的对应因子所以只需要筛到平方根即可。BFS队列的初始化队列初始化为[2, 3, 5, 7]。这覆盖了所有一位数的“纯数字”。注意数字1不是质数0也不是我们允许的数字所以不需要。去重与边界控制BFS过程中if num limit: continue这行代码用于剪枝。一旦当前数字超过上限由它生成的所有后续数字num*10digit一定更大所以可以直接跳过不再将其后续数字入队。这是保证算法在遇到大范围时能快速退出的关键。质数判断直接通过预先生成的is_prime列表进行O(1)复杂度的查询这是整个算法高效的基石。结果输出代码返回的是个数。如果需要列出具体的纯质数可以取消注释# pure_primes.append(num)并初始化一个列表来存储。运行这段代码对于上限20210605在我的普通开发机上耗时在0.3秒到0.5秒之间完全满足竞赛的时限要求。作为对比最原始的暴力双重循环方法可能几个小时都跑不完。6. 性能分析与优化空间探讨虽然上述代码已经足够快但我们不妨深入分析一下它的性能瓶颈和可能的优化方向这对于理解算法和应对更极端的数据范围很有帮助。时间复杂度分析埃氏筛部分时间复杂度为 O(N log log N)空间复杂度为 O(N)。这是主要的时间开销尤其是当N很大时比如上亿。对于N2*10^7这个操作是毫秒到秒级别。BFS生成与检查部分时间复杂度取决于生成的“纯数字”数量。对于d位数的上限最多生成 4 4^2 ... 4^d 个数字这是一个等比数列求和数量级是O(4^d)。当N202106058位数d8最大生成数量约为(4^9 - 4)/3 ≈ 349524约35万个数字。对每个数字进行O(1)的查表操作这部分开销极小。所以整体性能瓶颈在于埃氏筛。优化也主要集中于此使用bytearray将is_prime [True] * (limit 1)替换为is_prime bytearray(b\x01) * (limit 1)并将False赋值改为0True判断改为if is_prime[i]:。这能减少内存占用和提高缓存命中率对于非常大的N例如超过1亿效果明显。仅筛奇数可以只申请一半大小的数组is_prime用于表示奇数是否为质数偶数除了2都不是质数。这样内存减半内层循环的步长可以变为2*i标记的个数也减半。但代码复杂度会增加需要编写辅助函数在奇数和索引之间转换。公式为对于奇数num其索引idx num // 2反之索引idx对应的奇数为num 2 * idx 1。分段筛法当N极大例如超过10^9无法一次性在内存中分配大小为N1的数组时需要使用分段筛。将区间分成若干小块每次只筛一块重复利用小数组。这大大降低了内存需求但I/O和逻辑更复杂。对于蓝桥杯的题目范围第一种优化使用bytearray是简单有效的。我们可以将筛法部分改写如下def eratosthenes_sieve_bytearray(limit): # 使用bytearray1表示质数0表示非质数 is_prime bytearray(b\x01) * (limit 1) is_prime[0] is_prime[1] 0 for i in range(2, int(limit**0.5) 1): if is_prime[i]: step i start i * i # 使用切片赋值进行批量标记比循环更快 is_prime[start:limit1:step] b\x00 * ((limit - start) // step 1) return is_prime这里使用了切片赋值is_prime[start:limit1:step] b\x00 * ...来一次性将一段内存区域设置为0。这种批量操作通常比用Python循环逐个赋值要快得多。不过要注意计算重复次数((limit - start) // step 1)的准确性。踩坑记录在使用bytearray切片赋值优化时我最初直接写了is_prime[i*i : limit1 : i] 0结果运行错误。因为切片赋值右侧需要是一个相同长度的可迭代对象不能直接赋一个标量值。必须构造一个等长的bytes或bytearray对象。所以要用b\x00 * 长度来创建。这个细节很容易忽略。7. 常见问题与调试技巧在实际编写和运行这类算法代码时你可能会遇到一些典型问题。下面我总结了一份排查清单问题现象可能原因解决方案程序运行速度极慢像卡死一样1. 埃氏筛的内层循环起始点错误如从2*i开始。2. 使用了未优化的暴力判断法。1. 检查筛法代码确保内层循环从i*i开始。2. 替换为完整的埃氏筛BFS方案。结果个数比预期少1. 质数筛范围不够is_prime数组长度是limit1。2. BFS生成数字时上限判断逻辑有误提前剪枝。3. 忘记处理个位数字2和5它们是纯质数。1. 确认is_prime数组大小为limit1。2. 检查BFS循环中的if num limit: continue和if new_num limit:条件。3. 确认队列初始化为[2,3,5,7]。结果个数比预期多1. 质数筛逻辑错误将合数标记为质数。2. 将数字1错误地当成了质数或纯数字。1. 仔细检查埃氏筛的标记逻辑特别是循环边界和步长。2. 确保is_prime[0]is_prime[1]False。递归实现报错“RecursionError”当上限很大时生成的数字位数多递归深度可能超过Python默认限制约1000层。1. 改用BFS迭代实现。2. 如果坚持用DFS可用sys.setrecursionlimit()提高限制但这不是根本解决办法。内存占用过高MemoryErrorlimit值非常大如超过10^8is_prime列表占用内存过大。1. 使用bytearray替代list。2. 考虑使用“仅筛奇数”优化内存减半。3. 对于极端大的N必须使用分段筛法。调试技巧从小数据开始不要一开始就用20210605测试。先用一个小的上限比如100手动计算出所有纯质数2,3,5,7,23,37,53,73...然后用程序验证结果是否一致。这是验证算法逻辑正确性的最快方法。打印中间结果在BFS循环中可以临时打印出每一步生成的num和判断结果观察生成序列是否正确是否漏数或多数。性能 profiling如果关心性能可以使用Python的cProfile模块。在代码最后添加import cProfile cProfile.run(count_pure_primes(200000))这会输出函数中各个部分的运行时间和调用次数帮你定位是筛法慢还是生成部分慢。验证质数表单独测试埃氏筛函数输出前50个质数与已知的质数表对比确保筛法正确无误。8. 算法思维的延伸与变种解决了这个具体的“纯质数”问题我们获得的不仅仅是一段代码更是一种可迁移的算法思维模式。这种“预处理筛法 条件构造DFS/BFS”的组合拳可以应用到许多类似场景“绝对质数”问题要求质数本身是质数并且其所有数位上的数字重新排列后形成的数也是质数。这时我们依然可以先筛出质数表然后对于每个质数检查其数字集合是否全部由{2,3,5,7}组成因为其他数字如1,4,6,8,9,0组成的数或其排列很可能有非质数。这比纯质数条件稍弱。“双质数”问题要求一个数本身是质数并且其所有前缀从左往右也是质数。例如233是一个“双质数”因为223233都是质数。这非常适合用DFS来构造我们从一位质数2,3,5,7开始每次在末尾添加一位数字0-9并立即检查新数是否为质数如果是则继续递归。这本质上是在质数树上进行深度优先搜索。数位DP的简化版这个问题可以看作数位动态规划Digit DP的一个特例。数位DP常用于解决“在某个区间内满足某些数位条件的数字有多少个”的问题。我们的BFS生成法其实就是一种非常直观的、针对特定数位条件每位属于{2,3,5,7}的“暴力”枚举但由于条件约束强枚举量很小所以高效。对于更复杂的数位条件如包含某些数字、数位和特定等就需要正式的DP状态转移了。我个人在实现这个算法后最大的体会是对问题约束条件的深入理解往往比盲目选择高级算法更有效。“纯质数”的每一位只能是4个质数数字这个约束极其强力直接让我们将需要检查的数字数量从线性级N降到了指数级4^d。许多竞赛题和实际问题的优化都源于发现并利用题目中这种“强约束”或“特殊结构”。在动手编码前多花几分钟去分析数据的特性和问题的限制思考“哪些情况根本不可能发生”往往能带来事半功倍的效果。就像这道题如果你只想到筛质数然后遍历判断已经不错但如果你再进一步想到用BFS直接构造候选数那你的解决方案在效率和优雅度上就高出了一个层次。
返回列表