ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

从质数拆分到0-1背包:动态规划解决蓝桥杯国赛真题

从质数拆分到0-1背包:动态规划解决蓝桥杯国赛真题 1. 从一道国赛真题说起当质数遇上背包最近在整理蓝桥杯历届国赛的真题翻到了2019年第十届国赛的这道“质数拆分”。题目本身描述很简洁将2019拆分为若干个两两不同的质数之和问一共有多少种不同的拆分方法。看到“拆分”和“组合数”这两个词很多人的第一反应可能是回溯或者DFS深度优先搜索去枚举所有可能的质数组合。这思路没错但当你真的去尝试时会发现计算量是个大问题。2019以内的质数有300多个如果暴力枚举所有子集时间复杂度是指数级的根本跑不出来。这道题的精妙之处也是它作为国赛压轴题之一的难度所在在于其本质的转化。它不是一个简单的数学拆分问题而是一个披着质数外衣的动态规划问题更具体地说是一个经典的0-1背包问题的变种。我最初看到这个转化时有种豁然开朗的感觉。原来我们可以把“2019”看作是背包的容量把每一个“不同的质数”看作是一件件物品每件物品的“重量”和“价值”都是这个质数本身。而我们要做的就是计算“恰好”装满这个背包并且“物品价值总和等于背包容量”的方案数。这里的“恰好”和“价值等于重量”是两个关键约束让这个问题比标准的0-1背包求最大价值多了一层计数上的精巧。今天我就来详细拆解这道题。我们不止步于给出AC代码更重要的是把“为什么能用动态规划”、“为什么是0-1背包模型”以及“状态转移方程是怎么推导出来的”这些底层逻辑讲透。同时我会分享在实现过程中如何高效筛选质数、如何设计DP数组、以及如何处理“不同质数”和“恰好装满”这些边界条件。无论你是正在备赛蓝桥杯的同学还是对动态规划感兴趣想深入理解其建模思想的朋友相信这篇都能给你带来实实在在的收获。2. 问题重述与核心转化识别背包模型我们先抛开代码把题目用更严谨的语言描述一遍并完成最重要的第一步问题转化。原问题求将2019拆分为若干个两两不同的质数之和的不同拆分方法数。注意拆分出的质数集合不能有重复且顺序无关即232014和322014算同一种。这听起来像个组合数学问题。但让我们换个视角物品所有小于2019的质数。比如2, 3, 5, 7, 11, ... 我们需要先找出它们。背包容量2019。物品属性每个质数p它的“重量”是p它的“价值”也是p。在经典的0-1背包问题中物品有重量w[i]和价值v[i]我们通常求的是在不超过背包容量的前提下所能装入的最大价值。但在这里我们的目标变了。问题转化从这些“物品”质数中选取若干个每个最多选一次因为质数两两不同使得它们的总重量恰好等于背包容量2019并且因为重量等于价值所以总价值也恰好等于2019。我们需要计算的是满足这个条件的选取方案数。看这样一来问题就清晰地从“质数拆分”映射到了“计数型的0-1背包问题”。这里有几个关键点需要深刻理解为什么是0-1背包而不是完全背包因为题目要求“两两不同的质数”这意味着每个质数最多只能使用一次。这对应着0-1背包中“每件物品要么选0次要么选1次”的特性。如果是完全背包物品无限个那模型就完全错了。“恰好装满”与“方案计数”这是本题与标准0-1背包的两个主要区别。标准0-1背包通常求的是dp[j] max(dp[j], dp[j-w[i]] v[i])表示容量为j的背包能装的最大价值初始时dp[0]0其他为负无穷求最大值时通常为0。而本题中dp[j]需要表示的是容量为j的背包恰好装满的方案数。这是一个计数问题因此我们的状态转移是加法而不是取最大值。状态定义这是动态规划的核心。我们定义dp[i][j]为考虑前i个质数物品恰好凑出总和背包容量为j的方案数。通常我们可以用滚动数组优化到一维定义dp[j]为恰好凑出总和为j的方案数。想通了这一点我们就成功地把一个看似复杂的数论组合问题规约到了一个经典的、有清晰求解框架的动态规划模型上。接下来就是一步步实现这个模型。3. 算法核心动态规划状态转移方程推导确立了背包模型我们来严谨地推导状态转移方程。这是动态规划的灵魂理解了它你就能自己解决一类问题。我们使用最直观的二维DP数组来推导便于理解。定义dp[i][j]考虑前i个质数即质数列表中的第1个到第i个恰好凑出总和j的方案数。假设我们有一个质数列表primes[]其中primes[i]表示第i个质数这里i从1开始计数primes[0]或许空置或作为边界。现在我们面对第i个质数其值为p primes[i]我们需要更新所有j状态下的dp[i][j]。对于每个总和j我们有两种选择不选第i个质数那么方案数完全继承自考虑前i-1个质数时凑出j的方案数。即dp[i][j] dp[i-1][j]。选第i个质数那么要凑出总和j在选了质数p之后剩下的总和需要是j - p并且这部分必须由前i-1个质数来恰好凑出。因此方案数是dp[i-1][j-p]。但是注意前提只有当我们“能选”第i个质数时即j p第二种选择才存在。并且因为我们要求的是“恰好凑出”所以dp[i-1][j-p]必须表示的是恰好凑出j-p的方案数这也是我们状态定义所保证的。因此状态转移方程可以分情况写出当j p时无法选择第i个质数dp[i][j] dp[i-1][j]当j p时可以不选也可以选dp[i][j] dp[i-1][j] dp[i-1][j-p]这个方程和经典的0-1背包求最大价值的方程dp[i][j] max(dp[i-1][j], dp[i-1][j-w[i]] v[i])在形式上高度一致只是把max操作换成了操作。这正是求方案数和求最优值的动态规划在转移方程上的典型区别。初始化这是“恰好装满”类问题的关键。dp[0][0]表示考虑0个质数凑出总和为0的方案数。什么都不选总和就是0这算作1种合法的方案。所以dp[0][0] 1。而对于其他dp[0][j] (j0)考虑0个质数不可能凑出任何正数的总和因此方案数为0。最终我们要求的结果就是dp[n][2019]其中n是小于2019的质数的总个数。一维数组优化滚动数组在实现时我们几乎总是使用一维数组来优化空间因为二维数组在本题规模质数约300个容量2019下虽然也能接受但一维更简洁高效。优化后的状态定义dp[j]恰好凑出总和为j的方案数。状态转移逆序枚举j 对于每一个质数p我们从j 2019倒着枚举到j pdp[j] dp[j] dp[j - p]这个式子就是上面二维转移方程的一维体现。dp[j]对应不选当前质数的方案即上一轮的dp[i-1][j]dp[j-p]对应选了当前质数后剩余部分的方案数即上一轮的dp[i-1][j-p]。必须逆序枚举是为了保证在更新dp[j]时dp[j-p]还是“考虑上一个质数”时的状态避免一个质数被重复使用多次这就变成了完全背包。初始化dp[0] 1,dp[1...2019] 0。至此算法的核心逻辑已经完全清晰。接下来我们进入实现环节看看如何高效地获取质数列表并完成这个DP计算。4. 实现详解质数筛与DP编码理论清晰后我们来动手实现。整个过程可以分为两个清晰的步骤第一步筛选出所有小于2019的质数第二步应用0-1背包的动态规划进行方案计数。4.1 步骤一埃拉托斯特尼筛法获取质数列表我们需要一个包含所有小于2019的质数的列表。自己写判断函数循环试除当然可以但效率不高。这里我强烈推荐使用埃拉托斯特尼筛法它能在接近O(n log log n)的时间复杂度内快速筛选出一定范围内的所有质数代码简洁高效。def get_primes(limit): 使用埃拉托斯特尼筛法返回小于limit的所有质数列表。 is_prime [True] * (limit) is_prime[0] is_prime[1] False # 0和1不是质数 for i in range(2, int(limit ** 0.5) 1): if is_prime[i]: # 将i的倍数标记为非质数 for j in range(i * i, limit, i): is_prime[j] False # 收集所有标记为True的索引即为质数 primes [i for i, flag in enumerate(is_prime) if flag] return primes # 获取所有小于2019的质数 primes get_primes(2019) print(f小于2019的质数个数为: {len(primes)}) # 输出小于2019的质数个数为: 306这段筛法的原理是从2开始将每个质数的所有倍数标记为合数。当所有小于等于sqrt(n)的质数都处理完后剩下的未被标记的数就是质数。对于本题2019这个上限筛法瞬间即可完成。注意这里有一个非常重要的细节直接关系到结果的正确性。我们筛选的是“小于2019”的质数而不是“小于等于2019”。因为题目要求拆分成若干个质数之和等于2019。如果质数列表里包含了2019本身那么就会产生一种“只用一个质数2019”的拆分方案。但2019本身是质数吗2019 3 * 673它是一个合数。所以即使你错误地包含了2019实际上筛法也不会把它当质数它也不会被选中。但概念上必须明确我们的物品池是所有“小于2019”的质数因为如果某个质数等于2019那它单独作为一个拆分方案是成立的但本题中2019不是质数。这是一个关键的审题点。4.2 步骤二0-1背包动态规划实现方案计数有了质数列表primes我们就可以开始DP了。根据第三部分的推导我们使用一维DP数组。def count_prime_splits(target, primes): 计算使用给定质数列表每个质数最多用一次恰好凑出target的方案数。 target: 目标和本题为2019。 primes: 质数列表每个元素小于target。 # dp[j] 表示恰好凑出总和j的方案数 dp [0] * (target 1) # 初始化总和为0的方案数为1什么质数都不选 dp[0] 1 # 遍历每个质数物品 for p in primes: # 逆序枚举背包容量从target到p # 正序枚举会导致完全背包问题物品可重复选 for j in range(target, p - 1, -1): dp[j] dp[j - p] # 这里不需要取模因为Python整数可以处理大数。 # 但如果结果非常大可能需要取模例如dp[j] (dp[j] dp[j-p]) % MOD return dp[target] # 计算并输出结果 target 2019 result count_prime_splits(target, primes) print(f将{target}拆分为两两不同的质数之和共有 {result} 种方法。)让我们仔细分析这段DP代码dp数组大小为target1下标j代表当前考虑的总和。dp[0]1是动态规划正确性的基石。外层循环遍历每个质数p这对应着“逐个考虑每个物品”。内层循环是for j in range(target, p-1, -1)。逆序是0-1背包一维优化的精髓所在。为什么必须逆序假设我们正序枚举for j in range(p, target1)。当更新dp[j]时dp[j-p]可能已经在本轮循环中被更新过了因为j-p可能大于等于p。这意味着dp[j-p]已经包含了**使用当前质数p**的方案。那么dp[j] dp[j-p]实际上相当于在dp[j-p]已经用了p的基础上再加一个p这就允许了同一个质数被多次使用违背了“两两不同”的要求变成了“完全背包”。逆序枚举保证了在更新dp[j]时dp[j-p]存储的是**考虑上一个质数即本轮循环之前**的状态从而确保了每个质数只被考虑一次。状态转移dp[j] dp[j-p]就是方程dp[i][j] dp[i-1][j] dp[i-1][j-p]的一维实现。dp[j]等号右边是上一轮的值不选p的方案dp[j-p]也是上一轮的值选了p之后剩余部分的方案数。运行这段代码我们可以得到最终结果。为了验证我们可以先测试一个小数字。4.3 测试与验证从小规模数据开始在解决复杂问题前先用小例子验证思路是很好的习惯。比如求将10拆分为不同质数之和的方案。 小于10的质数有[2, 3, 5, 7]。 手动枚举235 1037 10 共2种方案。用我们的程序测试一下test_primes get_primes(10) # [2, 3, 5, 7] test_result count_prime_splits(10, test_primes) print(f拆分10的方案数: {test_result}) # 输出应为 2如果程序输出2说明我们的DP逻辑在小数据上是正确的增强了信心再去计算2019这个大数。5. 深入分析与常见误区即使掌握了上面的代码这道题依然有几个容易踩坑的地方和值得深入思考的点。我们来逐一剖析。5.1 关于“不同质数”与“不同拆分”的理解题目要求“两两不同的质数”这意味着在一种拆分方案中同一个质数不能出现两次。我们的0-1背包模型每个物品选或不选完美地保证了这一点。那“不同的拆分方法”如何理解例如235和523是同一种方法因为质数的集合都是{2,3,5}。我们的DP算法是如何自然避免重复计数的呢奥秘在于我们遍历质数的顺序是固定的从小到大并且DP状态dp[j]表示的是“方案数”而不是“排列数”。在转移过程中当我们考虑质数p时我们只关心“选它”或“不选它”对总和的贡献而不关心它被放在序列的哪个位置。最终所有包含相同质数集合的方案都会通过唯一的DP路径被计算一次。例如总和10质数集合{3,7}只有在遍历到3时选择“不选”在遍历到7时选择“选”并在后续状态中从dp[3]转移到dp[10]这一条路径。不会因为先考虑7还是先考虑3而产生不同路径。这就是动态规划用于计数问题时自动处理组合无序而非排列有序的特性。5.2 初始化dp[0] 1的深刻含义这是很多初学者困惑的地方。为什么空集合不选任何质数总和为0要算作1种方案 我们可以从两个角度理解数学归纳的基础当我们要用前i个质数凑出总和p第一个质数时根据转移方程dp[p] dp[p] dp[0]。如果dp[0]0那么dp[p]永远无法从“选取第一个质数”这个操作中得到初始值1即“只选这个质数”这一种方案。dp[0]1为“恰好装满”提供了一个合法的起点。集合论解释在组合数学中空集是任何集合的子集。从空集总和0开始通过不断添加元素质数来构造新的集合。dp[0]1代表了这个初始的空集状态。你可以试着将dp[0]设为0跑一遍程序会发现所有dp[j]最终都是0因为任何方案都无法“从零开始”构建起来。5.3 结果的数据范围与处理本题的答案是一个很大的整数。用Python的int类型可以轻松保存不需要取模操作。但如果你用C或Java等语言可能需要使用long longC或BigIntegerJava来存储结果否则可能会溢出。在蓝桥杯的评测系统中通常会对这种计数问题给出明确的是否取模要求。本题没有要求取模所以直接输出大整数即可。我们可以打印一下结果# 接续前面的代码 print(f计算结果为: {result}) # 输出计算结果为: 55965365465060这个数字有14位验证了其数量级之大也说明了暴力搜索的不可行性。5.4 算法复杂度分析我们来评估一下算法效率时间复杂度主要由两部分构成。筛法求质数埃拉托斯特尼筛法复杂度约为 O(n log log n)其中n2019这部分开销极小。动态规划外层循环遍历质数个数m约306个内层循环遍历背包容量n2019。因此DP部分复杂度为 O(m * n) ≈ 306 * 2019 ≈ 60万次操作。这在现代计算机上几乎是瞬间完成的。空间复杂度我们使用了一个一维DP数组大小为n1即 O(n)。筛法使用的布尔数组也是 O(n)。空间使用非常高效。这充分体现了动态规划将指数级复杂度的搜索问题转化为多项式级复杂度问题的强大能力。6. 举一反三变种问题与思维拓展掌握了“质数拆分”这道题你其实就掌握了一类“子集和计数”问题的通解。我们可以思考几个变种问题来巩固和拓展思维变种1数字拆分不限质数如果题目改为将2019拆分为若干个两两不同的正整数之和求方案数。还能用背包吗 当然可以此时“物品”就是1到2018的所有正整数因为要不同且小于2019。模型完全一样只是物品列表变了。DP方程和代码结构无需改动只需把primes列表换成list(range(1, 2019))即可。这实际上是一个经典的“不同整数拆分”问题。变种2质数拆分可重复如果允许质数重复使用呢例如将2019拆分为若干个质数之和同一个质数可以使用多次。这就是一个完全背包的计数问题了。 状态定义不变dp[j]为恰好凑出j的方案数。 初始化不变dp[0]1。 关键变化在于内层循环的顺序需要正序枚举j。for p in primes: for j in range(p, target1): # 正序 dp[j] dp[j-p]正序枚举使得在更新dp[j]时dp[j-p]可能已经包含了当前质数p从而实现了物品的无限次使用。变种3求具体拆分方案如果题目不仅要求方案数还要求输出所有具体的拆分方案质数组合动态规划还能做吗 动态规划擅长计数和求最优值但输出所有具体方案通常需要结合回溯。我们可以用DP先判断可行性或计算方案数然后用DFS回溯来构造解。不过当方案数极大时如本题的5万亿多种输出所有方案是不现实的。这类问题通常会限制拆分的项数使方案数可控。思维拓展建模能力的训练这道题最大的价值在于训练了“问题转化”和“模型识别”的能力。很多复杂的竞赛题或面试题表面描述千奇百怪但内核往往是几个经典算法模型如背包、DFS、BFS、最短路、最小生成树等。看到“若干元素组合成特定总和/目标”就要联想到“子集和”问题进而思考能否用背包DP特别是计数背包来解决。这种透过现象看本质的能力需要通过大量练习来积累。7. 完整代码与运行检查最后给出一个完整、可运行的Python代码并附上关键注释。def get_primes(limit): 埃拉托斯特尼筛法求小于limit的所有质数 is_prime [True] * limit is_prime[0] is_prime[1] False for i in range(2, int(limit ** 0.5) 1): if is_prime[i]: for j in range(i * i, limit, i): is_prime[j] False return [i for i, flag in enumerate(is_prime) if flag] def count_splits(target): 计算将target拆分为不同质数之和的方案数 # 1. 获取所有小于target的质数 primes get_primes(target) print(f质数个数: {len(primes)}) # 2. 初始化DP数组dp[j]表示恰好凑出j的方案数 dp [0] * (target 1) dp[0] 1 # 空集和为0视为1种方案 # 3. 0-1背包DP过程 for p in primes: # 逆序枚举保证每个质数只被使用一次 for j in range(target, p - 1, -1): dp[j] dp[j - p] return dp[target] if __name__ __main__: TARGET 2019 result count_splits(TARGET) print(f将 {TARGET} 拆分为两两不同的质数之和共有 {result} 种方法。)将这段代码复制到Python环境中运行你会得到如下输出质数个数: 306 将 2019 拆分为两两不同的质数之和共有 55965365465060 种方法。这个数字就是本题的最终答案。回顾整个解题过程我们从最开始的暴力枚举思路通过识别问题本质将其转化为0-1背包计数模型然后严谨推导状态转移方程接着用高效的筛法准备数据最后用简洁的动态规划实现求解。每一步都环环相扣充满了算法设计的美感。解决这类问题的关键不在于记忆代码模板而在于培养这种将实际问题抽象为已知模型的能力。希望这篇详细的拆解能帮助你彻底掌握这个知识点在遇到类似问题时能够游刃有余。
返回列表