1. 先搞懂题目到底在问什么
LC.343 整数拆分,题目描述很短,但第一次见到它的人大概率会懵一下:给定一个正整数 n,把它拆分成至少两个正整数的和,问这些拆分出来的整数乘积最大能到多少。
举个例子,n = 10。你可以拆成 3 + 3 + 4,乘积 3 × 3 × 4 = 36;也可以拆成 2 + 2 + 2 + 2 + 2,乘积是 32;拆成 5 + 5,乘积是 25。显然不同的拆法差别很大,题目要的就是那个最大值。
这个问题看起来是小学算术题,实际上是一道典型的算法题,涉及两个方向:动态规划(DP)和数学贪心推导。很多初学者上来就套 DP 模板,结果初始化边界搞错;也有人凭感觉觉得拆得越碎越好,结果发现 1 分多了反而乘积变小。这篇博文会把两条路线都拆开讲透,包括为什么 dp 数组要那样初始化、数学推导里为什么 3 是最优拆分数、以及刷题时最容易踩的几个坑。
2. DP 解法的设计思路与推导过程
2.1 从“拆与不拆”建立状态转移
动态规划拿到手,第一步定义 dp[i]:正整数 i 拆分后能拿到的最大乘积。n=2 是边界,只能拆成 1+1,乘积是 1,所以 dp[2] = 1。
对于 dp[i],核心思路是枚举第一刀:把它拆成 j 和 i - j 两部分,其中 j 从 1 枚举到 i/2 就行(后半部分对称,枚举到一半足够)。关键是,i - j 这一部分要不要继续拆?
- 不拆:乘积是 j × (i - j)
- 继续拆:乘积是 j × dp[i - j]
为什么是 j × dp[i - j]?因为 dp[i - j] 已经定义成“把 i - j 这个数拆分成若干正整数后的最大乘积”,i - j 本身就是一个待拆分的子问题,符合最优子结构。
于是转移公式就出来了:
dp[i] = max( j * (i - j), j * dp[i - j] )其中 j 从 1 取到 i / 2,每次更新最大值。
2.2 为什么 dp[i] 不是 max(j * dp[i-j], dp[j] * dp[i-j]) 之类的组合
有个常见的困惑:既然 i - j 可以继续拆,那 j 是不是也可以继续拆?比如 dp[j] * dp[i - j] 会不会更大?
答案是:不会。因为我们的枚举方式已经覆盖了所有可能。这里的关键是对称性——如果 j 也要继续拆,那“第一刀”划在哪其实可以移动。反过来看,无论最终拆分结果是怎样的,总能找到一种枚举顺序,让 j 恰好是最终拆出来的某个“不继续拆的碎块”,而其余部分全部放进 i - j 里继续递归处理。
只要 dp[i - j] 的定义是“最大乘积”,而不是“必须拆”,它天然包含了 i - j 只拆成一部分、另一部分不拆的情况。所以不用额外考虑 dp[j] × dp[i - j],那只会增加无意义的枚举,结果还一样。
2.3 初始化细节:为什么 dp[2] = 1 而 dp[1] 可以不设置
拆分的硬性要求是“至少两个正整数”,所以 i = 2 时只能拆成 1 + 1,dp[2] = 1。
i = 3 时,拆法有 1+2 和 1+1+1,最大是 1×2 = 2,也就是 dp[3] = 2。
i = 4 时,第一刀 j=1:1×3 = 3,1×dp[3] = 2,取较大值 3;j=2:2×2 = 4,2×dp[2] = 2,取较大值 4。所以 dp[4] = 4。
很多题解代码里会把 dp 数组提前初始化成 dp[i] = i,这其实是给“不拆”留了个底。比如 dp[3] 设置成 3,表示如果允许“不拆分”时的最大“乘积”是 3 本身,但题目明确要求必须拆,所以 dp[2] 和 dp[3] 必须单独处理成 1 和 2。如果统一初始化成 i,然后靠 max 来收敛,边界一错整个答案就飘。
我实际写的时候更倾向于这样:
- dp[2] = 1
- 从 dp[3] 开始往上推,循环内部第一刀 j 从 1 取到 i/2
- 内层循环保持 max 比较
2.4 DP 代码:Python 和 C++ 两种写法
Python 版本:
def integerBreak(n: int) -> int: if n == 2: return 1 if n == 3: return 2 dp = [0] * (n + 1) dp[2] = 1 for i in range(3, n + 1): # 先给 dp[i] 一个较低的下限 dp[i] = i - 1 # 最差是拆成 1 和 i-1 for j in range(1, i // 2 + 1): dp[i] = max(dp[i], j * (i - j), j * dp[i - j]) return dp[n]C++ 版本:
class Solution { public: int integerBreak(int n) { if (n == 2) return 1; if (n == 3) return 2; vector<int> dp(n + 1, 0); dp[2] = 1; for (int i = 3; i <= n; i++) { dp[i] = i - 1; for (int j = 1; j <= i / 2; j++) { dp[i] = max(dp[i], max(j * (i - j), j * dp[i - j])); } } return dp[n]; } };有同学会问:为什么 dp[i] 先初始化成 i - 1?因为 i 拆成 1 和 i-1 时乘积就是 i-1,这是最容易想到的拆分方案,可以当作保底值。即使后面所有 j 的组合都不如它大,也不会得 0 或者负数。
2.5 手推一遍 DP 过程
以 n = 6 为例:
- dp[2] = 1
- dp[3] = 2
- dp[4] = 4
- dp[5]:
- j=1:1×4=4,1×dp[4]=4
- j=2:2×3=6,2×dp[3]=4
- 取 max = 6
- dp[6]:
- j=1:1×5=5,1×dp[5]=6
- j=2:2×4=8,2×dp[4]=8
- j=3:3×3=9,3×dp[3]=6
- 取 max = 9
所以 n=6 最大乘积是 9,对应拆法 3+3。验算一下:3 × 3 = 9,正确。
这里能看到一层递进关系:dp[5] 时 j=2 提供 2×3=6,也就是“拆成 2 和 3”的想法;dp[6] 时 j=3 提供 3×3=9,是“拆成两个 3”。这个规律再往下观察,会引出数学解法。
3. 数学解法:为什么 3 是最优拆分数
3.1 从乘积增长率理解 2、3、4 的取舍
如果允许拆出任意多个正整数,且不考虑“整数”限制,用微积分可以知道:当所有拆分数尽量接近 e(约 2.718)时乘积最大。可惜这是整数场景,所以只能在 2 和 3 之间选,而且 3 比 2 更能带来乘积增长。
关键观察:6 拆成 3+3 的乘积是 9,拆成 2+2+2 的乘积是 8,拆成 4+2 的乘积是 8。为什么 3+3 更优?
因为对同一个固定和 k,多拆出一个 2 相当于把乘积乘以 2,但同时减少了一个“3”的位置。粗略估算:3 × 3 = 9,2 × 2 × 2 = 8,每多一个 2 都少了一个 3 的配对,收益会下降。所以尽量用 3 填满,只有剩下余数 1 时才会动 2 和 4。
3.2 数学推导与余数分类
把 n 拆成若干个 3 和一个余数 r(r ∈ {1, 2})。由于需要至少两个数,n 不能太小:
- 如果余数 r = 2,直接用 2;
- 如果余数 r = 1,说明拆成若干个 3 后多出一个 1。而单独的 1 放进乘积里没有增益,所以策略是把最后一个 3 拆成 2+1,与余数 1 合并成 2+2,整体乘积相当于把一个 3 换成了两个 2。因为 2×2 = 4 > 3,所以这个替换是赚的。
具体公式:
n % 3 == 0 -> pow(3, n / 3) n % 3 == 1 -> pow(3, n / 3 - 1) * 4 n % 3 == 2 -> pow(3, n / 3) * 2n=5 验证:5 % 3 = 2,结果为 pow(3, 1) * 2 = 6。n=10 验证:10 % 3 = 1,结果为 pow(3, 2) * 4 = 36。都对得上。
3.3 数学解法的代码实现
def integerBreak(n: int) -> int: if n == 2: return 1 if n == 3: return 2 if n % 3 == 0: return 3 ** (n // 3) if n % 3 == 1: return 3 ** (n // 3 - 1) * 4 return 3 ** (n // 3) * 2这个解法的时间复杂度是 O(n)(幂运算一般按 log n 计算),空间复杂度 O(1),明显优于 DP 的 O(n) 空间。
3.4 为什么 n=2 和 n=3 必须单独处理
有人会问:按公式走,n=2 时 n % 3 = 2,结果应该是 3^(0) * 2 = 2,但正确答案是 1。原因在于 2 必须拆成 1+1,乘积是 1,你不能“不拆”直接拿 2 当结果。
n=3 同理,3 % 3 = 0,按公式结果是 3,但 3 必须拆成 1+2,乘积是 2。要理解数学解法的前提是“拆分出的 3 和 2 本身不用再拆”,但题目强制至少拆成两个正整数,所以边界必须单独判断。
3.5 数学解法的严格证明思路
如果想在面试中把数学解法讲清楚,可以用“若存在大于 4 的拆分项,则可以继续拆分获得更大乘积”这条路:
- 拆分数 k >= 5,则 (k - 2) * 2 = 2k - 4 >= k,所以 k 可以替换为 k-2 和 2;
- 拆分数 4 等价于 2+2,因为 4 = 2+2 且乘积不变;
- 因此最终只可能剩下 2 和 3;
- 2 × 2 × 2 < 3 × 3,所以三个 2 应该替换成两个 3,也就是说 2 的数量最多不超过两个;
- 剩余余数分类如上。
这样证明的优势在于每一步都直观,不会用到对数求导,适合面试口述。
4. 两种解法对比与 DP 结果观察
4.1 复杂度与实现难度对照
| 维度 | DP 解法 | 数学解法 |
|---|---|---|
| 时间复杂度 | O(n^2) | O(n)(幂运算按 log n 计) |
| 空间复杂度 | O(n) | O(1) |
| 代码量 | 较长 | 极短 |
| 边界陷阱 | 初始化易错 | 2 和 3 必须特判 |
| 适合场景 | 学习动态规划、扩展变形 | 面试快速求答案 |
如果只为了过题,数学解法显然更快。但如果刷题目标是练 DP,这个题的 DP 递推不复杂,可以用它来理解“枚举第一刀 + 子问题复用”的思路,对后面的切绳子、戳气球、分裂回文串都有帮助。
4.2 从 DP 数组观察规律
把 n=2 到 n=12 的 dp 值列出来:
| n | dp[n] | 最佳拆法 |
|---|---|---|
| 2 | 1 | 1+1 |
| 3 | 2 | 1+2 |
| 4 | 4 | 2+2 |
| 5 | 6 | 2+3 |
| 6 | 9 | 3+3 |
| 7 | 12 | 3+4 |
| 8 | 18 | 3+3+2 |
| 9 | 27 | 3+3+3 |
| 10 | 36 | 3+3+4 |
| 11 | 54 | 3+3+3+2 |
| 12 | 81 | 3+3+3+3 |
能看到两个现象:
- 从 n=5 开始,dp 值就开始被“一堆 3 加上少量 2 或 4”主导;
- 规律跳跃明显:n 越大,比值越接近 ×3,除非遇到余数 1 时需要调整。
拿 11 来说,拆成 3+3+3+2,乘积 3×3×3×2=54;拆成 3+3+3+1+1 是 27;拆成 4+4+3 是 48。可见“把 1 合并成 4”确实很关键。
4.3 什么时候优先用 DP,什么时候优先用数学
面试官问这道题,意图一般不是让你 5 秒秒杀,而是想听你如何从 DP 过渡到数学优化。建议流程:
- 先给出 DP:直观、通用、容易证明正确性;
- 再提出数学观察:从 dp 表中看出 3 的主导地位,进而推导公式;
- 最后写数学解法,并说明 2、3、4 的边界处理。
如果是竞赛或刷题网站里赶时间,直接用数学法 5 行代码收工即可。如果目标是掌握动态规划,DP 过程写一遍比背公式更有价值。
5. 实操经验:调试、边界与扩展思考
5.1 n 很小时的特判是最大坑点
几乎所有提交错误都出在 n = 2 和 n = 3 上。很多人写完 DP 后以为 dp[2] = 1 就够了,结果 n=3 时进入内层循环,j 从 1 到 1,算出来 dp[3] = max(1×2, 1×dp[2]) = 2,其实没问题。但如果初始化 dp[i] = i,且没有特判 3,就会跑出 3 这个错误答案。
还有一种写法是 dp 数组长度开到 n+1 后,把 dp[1] 和 dp[2] 都设置成 1,然后从 i=3 开始。这样也能过,但语义上 dp[1] 其实用不到,建议不要为了省两行代码模糊了定义。
5.2 幂运算的溢出问题
题目在 LeetCode 上通常保证答案在 32 位整数内,但如果你在其他平台上看到 n 范围很大,比如 n 到 100 或更大,直接用 3 ** (n // 3) 可能会溢出。
应对策略:
- 如果要求返回 int,可以直接用 int 运算,因为 3 的幂增长特别快,越界会变成负数或错误值;
- 如果题目要求取模,比如对 1e9+7 取模,需要写快速幂或者循环取模;
- 如果面试时讨论大数场景,要主动提到 BigInteger 或取模方案。
我在实际写代码时,会先确认数据范围,再决定要不要写快速幂。一上来就写快速幂确实显得专业,但如果题目明明只有 n=58,反而画蛇添足。
5.3 数学解法的实现细节:除法取整要小心
计算 3 的幂时,n // 3 是整数除法。但在 “n % 3 == 1” 的分支里,如果 n=4,n // 3 - 1 = 0,所以结果是 3^0 * 4 = 4,正确;n=1 时 n // 3 - 1 = -1,但 n=1 本身不是合法输入,不用管。
还有一个小坑:当 n 很大时,如果写pow(3, n / 3)在 C++ 中会用浮点数,可能因为精度问题在边界出错。正确的写法是用整数版pow(3, n / 3)或者手写循环乘法,不要用pow(3, n / 3.0),否则在超大数场景下可能得到不精确结果。
5.4 从这道题延伸到“切绳子”类问题
LC.343 的本质和“剪绳子”问题一模一样,只是场景不同。很多游戏数值策划和资源分配问题也会遇到类似建模:固定总量,拆成多份让总收益最大。
解决办法是可以抽象成:
- 定义子问题“总量剩余 i 时的最大收益”;
- 枚举第一段分配的份量;
- 子问题复用 dp 结果。
遇到需要“至少切一刀”“必须分成至少 k 份”的变种时,只要在初始化边界和状态定义里加一个维度即可。比如“必须分成至少 k 段”时,dp 需要变成二维:dp[i][k] = max(dp[i - j][k - 1] * j),这类扩展在面试题里也出现过,值得多练。
5.5 面试时怎么把一个简洁解法讲得有层次
如果面试官要求“给出最优解”,建议这样组织回答:
- 第一步:说明从枚举角度,乘法会鼓励更大的拆分数;
- 第二步:说明 3 是最优拆分的核心原因——2×2×2 < 3×3;
- 第三步:讨论余数分类,补全数学公式;
- 第四步:写代码,确认 n = 2、3、4 这几个边界。
其中第二步很关键,你可以画几个数感受一下:用 3 拆出来的乘积曲线上升得更快。面试官一般不会要求证明 e,但会希望看到对 2 和 3 的讨论。
5.6 我在实际练习中的两点体会
刷完这道题,我最大的感受有两点。
第一,数学解法和动态规划不要二选一。前几次刷题我只看 DP,虽然能过,但总觉得少了点“更底层”的理解。后来认真推导数学版本,再回头理解 dp 表,才明白为什么 j 枚举到 i/2、为什么 j=3 附近通常是最大值。两条路相互验证,比单纯背公式牢固很多。
第二,做算法题不要忽略数据范围。LC.343 这种小范围题用 DP 或者纯数学都没问题,但如果换成分治、矩阵快速幂或者大数取模场景,就得重新审视实现。多问自己一句“这个 n 最大能到多少”,能避开很多后续扩展问题。
6. 总结一下我在这次解题中的收获
说句实在话,这题难度本身不高,但它特别适合用来练习“从动态规划抽象到数学规律”的思维过程。很多 DP 题解直接给出 dp 递推式就算了,但这道题你只要停下来多看两眼 dp 表,就会发现一堆 3 的身影,进而推导出 O(1) 空间的最优解。
我建议初学者先手算 n=2 到 n=12 的 dp 值,形成对“乘积增长”的直觉,再去看数学解法的余数分类。这样比直接背公式有效得多。等熟悉了这套流程,以后碰到任何“切分求最大收益”的题,都能从 DP 起步、用数学优化收尾,整个思考路径会非常顺。