第一次遇到"放苹果"这道题是在大学算法课上,当时觉得这不过是个简单的排列组合问题。直到后来准备算法竞赛时,才发现它背后隐藏着整数划分这一经典问题的通用解法。让我们从一个具体例子开始:把7个苹果放进3个盘子有多少种方法?手工枚举的话,你会得到(5,1,1)、(4,2,1)、(3,3,1)、(3,2,2)等共8种方案。
这个问题之所以特殊,在于两个关键约束:苹果不可区分(所有苹果相同)和盘子不可区分(顺序不重要)。这正好对应着数学中的整数划分问题——将一个正整数表示为若干正整数之和的不同方式。在算法领域,这类问题通常有两种解法:自底向上的递推(动态规划)和自顶向下的递归。
我更喜欢把这个问题想象成给小朋友分糖果。假设你有7颗相同的糖果要分给3个小朋友,有的小朋友可能一颗都拿不到(允许空盘子),而且分配顺序不重要((5,1,1)和(1,5,1)算同一种)。这种生活化的类比能帮助初学者快速抓住问题本质。
动态规划解法的核心在于状态定义。我们定义dp[m][n]表示m个苹果放入n个盘子的方法数。初始化时有三个边界条件:
cpp复制int dp[11][11] = {0}; // 题目限制M,N≤10
for(int i=0; i<=10; ++i) {
dp[0][i] = 1;
dp[1][i] = 1;
dp[i][1] = 1;
}
递推关系需要考虑两种情况,这也是动态规划最精妙的部分:
cpp复制for(int i=2; i<=m; ++i) {
for(int j=2; j<=n; ++j) {
if(j > i) dp[i][j] = dp[i][i];
else dp[i][j] = dp[i][j-1] + dp[i-j][j];
}
}
我在第一次实现时犯了个典型错误——把i和j的循环范围搞反了。记住:外层循环应该是苹果数量,内层是盘子数量,因为我们要从小问题逐步构建大问题的解。
递归解法更直观但效率较低,适合理解问题本质。其核心是将问题分解为子问题:
cpp复制int countWays(int m, int n) {
if(m == 0 || n == 1) return 1;
if(n > m) return countWays(m, m);
return countWays(m, n-1) + countWays(m-n, n);
}
这个递归函数有三个终止条件:
未优化的递归会产生大量重复计算。以countWays(7,3)为例,调用过程会展开如下:
code复制f(7,3)
├── f(7,2)
│ ├── f(7,1) = 1
│ └── f(5,2)
│ ├── f(5,1) = 1
│ └── f(3,2)
│ ├── f(3,1) = 1
│ └── f(1,2) = f(1,1) = 1
└── f(4,3)
├── f(4,2)
│ ├── f(4,1) = 1
│ └── f(2,2) = f(2,1)+f(0,2) = 2
└── f(1,3) = f(1,1) = 1
可以看到f(2,2)等子问题被重复计算。这时可以用记忆化搜索优化——用二维数组存储已计算的结果:
cpp复制int memo[11][11]; // 初始化为-1
int countWays(int m, int n) {
if(memo[m][n] != -1) return memo[m][n];
if(m == 0 || n == 1) return memo[m][n] = 1;
if(n > m) return memo[m][n] = countWays(m, m);
return memo[m][n] = countWays(m, n-1) + countWays(m-n, n);
}
实际问题中会遇到多种变体,掌握核心思想后都能迎刃而解:
不允许空盘子:每个盘子至少一个苹果
苹果和盘子都区分:变成排列问题
限定每个盘子的容量:比如每个盘子最多k个苹果
递推解法的时间复杂度是O(M×N),空间复杂度也是O(M×N),可以通过滚动数组优化到O(N)。递归解法不加记忆化时是指数级复杂度O(2^min(M,N)),记忆化后降为O(M×N)。
在实际编程竞赛中,我建议:
cpp复制// 递推解法的滚动数组优化版
int countWays(int m, int n) {
int dp[2][11] = {0};
for(int i=0; i<=n; ++i) dp[0][i] = 1;
for(int i=1; i<=m; ++i) {
dp[i%2][1] = 1;
for(int j=2; j<=n; ++j) {
if(j > i) dp[i%2][j] = dp[i%2][i];
else dp[i%2][j] = dp[i%2][j-1] + dp[(i-j)%2][j];
}
}
return dp[m%2][n];
}
理解"放苹果"问题的真正价值在于培养问题归约能力。很多看似不同的问题其实都是整数划分的变体:
我在LeetCode刷题时就发现,很多动态规划问题最终都转化为类似的递推关系。建议初学者可以尝试以下练习:
cpp复制// 通用的整数划分解法模板
int integerPartition(int total, int parts, int minVal) {
if(parts == 0) return total == 0;
if(total < minVal * parts) return 0;
return integerPartition(total - minVal, parts - 1, minVal)
+ integerPartition(total, parts, minVal + 1);
}
这个模板可以处理更一般的整数划分问题,其中minVal参数控制划分元素的最小值。通过这样的抽象,我们就能建立起解决一大类组合计数问题的通用思维模型。