十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

整数划分算法精讲:从递归到动态规划的完全背包解法

整数划分算法精讲:从递归到动态规划的完全背包解法 1. 从“分苹果”到“整数划分”一个经典问题的引入想象一下你手头有5个一模一样的苹果要全部分给几个小朋友。你可以选择给一个小朋友5个也可以给两个小朋友比如一个3个一个2个或者给五个小朋友每人1个。不考虑小朋友的顺序只关心“怎么分”这件事本身。这种“把一堆东西分成几小堆”的抽象就是整数划分问题的核心。在计算机科学尤其是算法设计与分析的领域里整数划分是一个极具代表性的组合数学问题。它的定义非常简洁对于一个给定的正整数n求其被表示为若干个正整数之和的所有不同方式的数量。这里的“不同”指的是不考虑加数的顺序即32和23被视为同一种划分。这个看似简单的定义背后却隐藏着深刻的数学内涵和算法挑战。它不仅是算法课程中的经典例题也是动态规划、递归、生成函数等核心思想的绝佳练兵场甚至在数论、统计物理如统计粒子能级分布中都有重要应用。很多人初次接触时会觉得这不就是个“凑数”问题吗但当你真正动手去实现尤其是当n增大到几十、上百时就会立刻感受到组合爆炸的威力。一个n100的整数划分其方案数是一个高达190569292的庞大数字。如何高效地计算或枚举这些划分就成了算法设计需要直面的核心问题。本文将从一个算法实践者的角度深入剖析整数划分问题的几种经典求解思路重点不仅在于“怎么做”更在于“为什么这么做”以及“不同做法之间的权衡”。2. 问题定义与递归关系拆解问题的两种视角在动手写代码之前我们必须把问题定义得更精确并找到其内在的递归结构。这是所有算法设计的起点。设P(n, m)表示将整数n划分为最大加数不超过m的划分方式总数。这个定义引入了一个参数m是理解递归关系的关键。为什么需要m因为直接思考P(n)n的所有划分总数的递推关系比较困难。通过引入最大加数的限制我们可以将大问题分解为结构相似的、参数更小的子问题。这里通常有两种经典的分解思路代表了两种不同的递归视角。2.1 视角一根据划分中是否包含m本身进行分解这是最直观的一种思路。对于P(n, m)情况一划分中包含至少一个m。那么我们可以从n中先拿出一个m剩下的部分是n-m并且剩下的部分其最大加数仍然可以不超过m因为我们已经用了一个m剩下的部分里完全可以再有m。因此这种情况对应的划分数是P(n-m, m)。情况二划分中不包含m。那么整个划分的所有加数都严格小于m即最大加数不超过m-1。因此这种情况对应的划分数是P(n, m-1)。由此我们得到第一个递归关系式P(n, m) P(n, m-1) P(n-m, m) 其中n m 1。这个式子的边界条件需要仔细确定P(n, 1) 1。因为最大加数不超过1那么划分只能是11...1这一种形式。P(0, m) 1。这是一个关键且容易出错的边界。当n0时我们认为存在一种划分方式即“什么都不加”。这在情况一P(n-m, m)中当nm时会用到表示拿出一个m后剩下0这是一种合法的完成状态。当n m时P(n, m) P(n, n)。因为最大加数m已经超过了n本身实际的划分不可能出现m所以问题退化为最大加数不超过n的情况。2.2 视角二根据划分中加数的个数或最小加数进行分解另一种思路是关注划分的“形状”。但一个更实用的、在动态规划中效率更高的定义是设dp[n][k]表示将n划分为恰好k个正整数之和的划分方式数。这个定义的递推关系可以从考虑这k个数中的最小值入手情况一最小加数等于1。那么我们可以先拿出这个1剩下的问题是将n-1划分为k-1个正整数。即dp[n-1][k-1]。情况二最小加数大于1。那么我们可以给这k个加数每个都先减去1。这样总和就变成了n-k并且这k个数仍然都是正整数。因此这种情况等价于将n-k划分为k个正整数。即dp[n-k][k]。由此得到递推式dp[n][k] dp[n-1][k-1] dp[n-k][k] 其中n k 2。边界条件为dp[n][1] 1。划分为1个正整数只有n本身这一种。dp[n][k] 0当n k时。因为不可能用比n更多的正数加起来等于n。最终n的总划分数P(n) dp[n][1] dp[n][2] ... dp[n][n]。这两种视角各有优劣。视角一基于最大加数的递归关系更直接但直接递归实现效率低下视角二基于划分个数的递推关系是许多高效动态规划解法的基础。理解这两种视角就掌握了整数划分问题的“命门”。3. 从递归到动态规划跨越效率的鸿沟有了清晰的递归定义初学者最自然的想法就是直接编写递归函数。我们以视角一为例实现一个计算P(n, m)的递归函数。def partition_recursive(n, m): 返回将整数n划分为最大加数不超过m的划分数递归版本。 # 边界条件处理 if n 0 or m 1: return 0 if n 0: return 1 # 一种划分空划分 if m 1: return 1 # 一种划分全1 if n m: return partition_recursive(n, n) # 递归关系P(n,m) P(n, m-1) P(n-m, m) return partition_recursive(n, m-1) partition_recursive(n-m, m) def total_partitions_recursive(n): return partition_recursive(n, n)这段代码简洁地反映了我们的数学推导。然而如果你尝试计算total_partitions_recursive(50)就会明显感受到延迟计算n100甚至会导致递归深度过大或超时。原因在于重叠子问题。例如计算P(10,5)时会计算P(10,4)和P(5,5)而计算P(10,4)时又会计算P(10,3)和P(6,4)…… 这些子问题被反复计算造成了指数级的时间复杂度。注意递归树在这里会非常庞大。n每增加一点计算量可能增长数倍。这是递归解法在解决此类问题时的典型瓶颈。为了解决重叠子问题动态规划Dynamic Programming, DP闪亮登场。其核心思想是“以空间换时间”将已经计算过的子问题的结果存储起来避免重复计算。我们通常使用一个二维数组dp来存储P(i, j)的结果。3.1 基于“最大加数”视角的动态规划实现我们使用一个(n1) x (n1)的二维数组dp其中dp[i][j]表示将整数i划分为最大加数不超过j的划分数。初始化是关键dp[0][j] 1for allj。表示总和为0有一种划分空划分。dp[i][0] 0fori 0。最大加数不能超过0除了0本身对于正数i是不可能的。然后我们按行i从 1 到n或按列进行递推。根据公式P(i, j) P(i, j-1) P(i-j, j)但需要注意i和j的大小关系。def total_partitions_dp_max(n): 动态规划计算整数n的划分数基于最大加数视角。 if n 0: return 0 # 创建DP表维度 (n1) x (n1) dp [[0] * (n 1) for _ in range(n 1)] # 初始化边界条件 for j in range(n 1): dp[0][j] 1 # 总和为0只有一种划分空 # dp[i][0] for i0 已经初始化为0符合逻辑 # 递推填充DP表 for i in range(1, n 1): for j in range(1, n 1): if j i: # 最大加数j超过当前总和i等同于P(i, i) dp[i][j] dp[i][i] else: # 核心递推公式 dp[i][j] dp[i][j-1] dp[i-j][j] # 最终结果P(n, n) return dp[n][n]这个算法的时间复杂度是O(n^2)空间复杂度也是O(n^2)。对于n1000它可以在可接受的时间内完成计算而递归版本则完全不可能。3.2 基于“划分个数”视角的动态规划实现视角二的动态规划通常更高效尤其是在只需要计算总数且n较大时。我们定义dp[i][k]为将i划分为恰好k个正整数的划分数。递推公式dp[i][k] dp[i-1][k-1] dp[i-k][k]。def total_partitions_dp_count(n): 动态规划计算整数n的划分数基于划分个数视角。 if n 0: return 0 # 创建DP表维度 (n1) x (n1)但第二维最多到n dp [[0] * (n 1) for _ in range(n 1)] # 初始化 for i in range(n 1): dp[i][1] 1 # 划分为1个数只有一种方式 dp[i][i] 1 # 划分为i个1也只有一种方式当i1时 # dp[0][k] for k0 应为0已初始化 # 递推填充 for i in range(2, n 1): # k不能超过i for k in range(2, i): # k从2到i-1 if i - k 0: dp[i][k] dp[i-1][k-1] dp[i-k][k] # 当 i k 时dp[i][k]保持为0 # 总划分数 sum(dp[n][k] for k in 1..n) total 0 for k in range(1, n 1): total dp[n][k] return total这个实现同样是O(n^2)的时间复杂度。但在一些优化和变种问题如限制划分个数上这个模型更直观。实操心得在解决整数划分问题时我通常会先写一个简单的递归版本用于验证小规模数据的正确性因为它最直观地反映了问题定义。一旦逻辑正确立刻转向动态规划版本。动态规划的初始化步骤最容易出错务必用n0,1,2,3这样的小例子手动模拟确保dp表的初始状态符合数学定义。4. 空间优化与一维DP挑战与技巧二维DP表在n很大时比如上万会消耗大量内存O(n^2)。我们能否优化空间对于基于“最大加数”的递推式dp[i][j] dp[i][j-1] dp[i-j][j]观察发现在计算第i行时似乎只依赖本行前面的元素 (dp[i][j-1]) 和上一行 (i-j行) 的元素。但这并不完全是经典的滚动数组优化因为i-j可能比i小很多不一定是上一行。实际上有一个非常经典且高效的一维DP解法其状态定义需要转换思路。我们定义dp[x]表示整数x的划分数。那么如何递推呢考虑x的所有划分。我们可以按划分中最小的加数来分类吗或者有更巧妙的方法经典的完全背包问题模型在这里提供了绝佳的视角将整数n的划分看作是用面值为1, 2, 3, ..., n的硬币恰好凑出总金额n的方案数并且硬币数量无限。在这个模型下dp[x]表示凑出金额x的方案数。初始化dp[0] 1凑出0元有一种方案什么都不选。我们依次考虑每一种“硬币”即每一个正整数coin。对于每个coin我们更新所有x coin的dp[x]dp[x] dp[x - coin]。这个更新的含义是对于当前金额x所有凑出x-coin的方案加上一枚coin面值的硬币就得到了一个新的凑出x的方案。由于我们按顺序遍历硬币并且对每个硬币都正序更新dp[x]这保证了在考虑coin时dp[x-coin]已经包含了使用coin的方案可能多次从而实现了“硬币无限使用”。同时遍历硬币的顺序也隐式地规定了划分中加数的顺序非递减从而避免了32和23被重复计算。def total_partitions_dp_1d(n): 使用一维DP完全背包模型计算整数n的划分数。 时间复杂度 O(n^2)空间复杂度 O(n)。 if n 0: return 0 dp [0] * (n 1) dp[0] 1 # 基础情况 # 遍历所有“硬币”正整数 for coin in range(1, n 1): # 正序更新允许硬币重复使用 for x in range(coin, n 1): dp[x] dp[x - coin] return dp[n]这个解法极其简洁优美是面试或竞赛中的首选写法。它的时间复杂度依然是O(n^2)但空间复杂度降到了O(n)。对于n10000二维DP需要约400MB内存假设4字节整数而一维DP只需要40KB优势巨大。踩坑警示这里最关键的细节是循环的顺序。必须是外层循环遍历“物品”硬币/加数内层循环遍历“容量”目标整数x并且内层循环要正序。如果内外层循环颠倒或者内层用了倒序得到的结果将是“排列数”而非“组合数”即考虑顺序的划分这与整数划分问题的定义不符。我曾在一次调试中因为交换了循环顺序而耗费了半小时务必牢记。5. 算法分析理解时间与空间的代价我们已经有了几种算法现在从理论角度分析其性能。这是“算法分析”的核心环节。朴素递归算法其时间复杂度是指数级的O(2^n)量级甚至更差因为递归树几乎会探索所有可能的划分。空间复杂度为递归调用栈的深度O(n)。仅适用于n 30的教学演示。二维动态规划两种视角时间复杂度都是O(n^2)因为需要填充一个n x n的表格。空间复杂度也是O(n^2)。适用于n在几千以内的场景。一维动态规划完全背包时间复杂度O(n^2)空间复杂度O(n)。是解决此问题最常用的高效算法。当n非常大例如10^5时O(n^2)的时间也变得不可接受。此时我们需要更高级的数学工具。整数划分的计数公式涉及五边形数定理和生成函数存在O(n sqrt(n))的算法例如基于欧拉五边形数定理的递推。但这已远超一般算法课程的范围属于组合数学的深水区。对于绝大多数工程和面试场景掌握O(n^2)的动态规划解法已经足够。分析算法复杂度时不仅要会看循环层数更要理解其背后的原因。例如一维DP的双重循环其总操作次数是n (n-1) ... 1 n(n1)/2因此是O(n^2)。6. 变种问题与实战演练纯粹的计数问题可能有些抽象。整数划分有很多有趣的变种能更好地锻炼算法设计能力。变种一限制划分的个数问题计算将n划分为恰好k个正整数之和的划分数。 这正是我们“视角二”动态规划中dp[n][k]直接给出的答案。解法就是前面total_partitions_dp_count函数中计算dp[n][k]的部分。变种二限制划分中加数的范围问题计算将n划分为若干正整数之和且每个加数都在集合S中的划分数例如只能用1,3,5。 这可以看作是完全背包问题的变种硬币的种类不是1..n而是给定的集合S。一维DP解法稍作修改即可def partition_with_set(n, coin_set): dp [0] * (n 1) dp[0] 1 for coin in coin_set: if coin n: continue for x in range(coin, n 1): dp[x] dp[x - coin] return dp[n]变种三枚举所有具体的划分方案问题不仅计数还要输出所有具体的划分方式如511111,51112, ...。 这是一个典型的回溯DFS问题。我们需要在递归过程中记录当前的划分路径。def enumerate_partitions(n, max_val, current_path, result): 回溯法枚举所有划分。 n: 剩余需要划分的数。 max_val: 当前允许的最大加数为保证非递增顺序避免重复。 current_path: 当前已选择的加数列表。 result: 存储所有划分结果的列表。 if n 0: # 找到一种划分 result.append(current_path.copy()) return # 从大到小尝试加数保证划分是非递增的避免顺序重复 for i in range(min(max_val, n), 0, -1): current_path.append(i) enumerate_partitions(n - i, i, current_path, result) # 注意max_val更新为i current_path.pop() # 回溯 def get_all_partitions(n): all_results [] enumerate_partitions(n, n, [], all_results) return all_results这个枚举算法的时间复杂度是输出敏感的即与划分数P(n)本身成正比。对于n30划分数已有几千种输出会非常庞大。实战技巧在面试或竞赛中如果遇到需要枚举具体划分的题目一定要注意去重。通常要求划分是“非递增”或“非递减”序列以确保32和23不会同时出现。上面的代码通过max_val参数强制后续选择的数不大于前一个数实现了非递增顺序的输出这是解决枚举去重问题的关键技巧。7. 性能实测与对比用数据说话理论分析需要实际测试来验证。我们用一个简单的测试来对比不同算法在n50和n100时的表现枚举算法由于输出爆炸仅测试n20。算法描述n50 (结果: 204226)n100 (结果: 190569292)适用场景朴素递归约2-3秒递归调用次数巨大无法在合理时间内完成教学理解n30二维DP (最大加数)0.01秒0.01秒通用易于理解递推二维DP (划分个数)0.01秒0.01秒需要计算固定部分数时一维DP (完全背包)0.01秒0.01秒首选空间最优回溯枚举 (n20)输出931种划分瞬间完成n30时输出已有5604种耗时增长需要具体方案时从测试可以看出动态规划将不可行的问题变成了瞬间可解的问题。一维DP在空间上的优势使其成为解决大规模计数问题的标准答案。在实际编码中还需要注意整数溢出问题。P(100)的结果已经接近2亿P(200)的结果是一个超过3万亿的大数远超32位整型范围。在Python中这不是问题但在C/Java等语言中需要使用long long或大数库。8. 总结与延伸思考整数划分问题就像算法世界里的一个“麻雀”虽然小但五脏俱全。它串联起了递归、动态规划、回溯、完全背包等多个核心知识点。通过它我们可以深刻理解定义决定解法对问题不同的形式化定义最大加数、划分个数、背包模型会引向不同的递归关系和最终算法。选择最贴合问题本质且易于实现的状态定义是设计高效算法的第一步。重叠子问题是DP的入场券一旦发现递归调用树中存在大量重复计算动态规划就是最自然的优化方向。空间优化是进阶关键从二维DP到一维DP的优化不仅仅是节省内存更是对问题依赖关系的深刻洞察。理解“完全背包”的正序更新逻辑是掌握此类问题优化的钥匙。从计数到枚举计数问题往往有高效的数学或DP解法而枚举问题则通常需要回溯搜索两者在复杂度上有天壤之别。明确问题要求是计数还是枚举至关重要。我个人在学习和教授这个问题的过程中最大的体会是不要满足于AC通过代码。一定要亲手画出n4或n5时二维DP表的填充过程并和一维DP的更新过程做对比。这个手动模拟的过程能让你真正理解状态转移的每一个细节从而在遇到变种问题时能够灵活应对。例如如果问题变成“将n划分为若干不同正整数之和的划分数”你能否迅速反应过来这对应着背包模型中的“01背包”问题从而将内层循环改为倒序更新这种举一反三的能力才是算法学习的最终目标。
返回列表