从‘放苹果’到‘整数拆分’:用C++四种解法搞定洛谷P2386和信息学奥赛经典题

在算法竞赛的进阶之路上,"放苹果"问题堪称一道经典的分水岭。这道看似简单的题目背后,隐藏着整数拆分的数学本质,以及递归、动态规划等核心算法思想的精妙应用。本文将带你从零开始,通过四种截然不同却又内在关联的解法,彻底掌握这一经典问题的求解之道。

1. 问题本质与数学建模

"放苹果"问题的标准描述是:将M个相同的苹果放入N个相同的盘子,允许有的盘子空着不放,问共有多少种不同的分法。这道题在洛谷P2386、OpenJudge以及信息学奥赛系列教材中多次出现,其重要性可见一斑。

从数学角度看,这实际上等价于 正整数拆分 问题——将整数M表示为不超过N个正整数的和,且不考虑顺序。例如,将4个苹果放入3个盘子,对应的拆分方式为:

  • 4 = 4
  • 4 = 3 + 1
  • 4 = 2 + 2
  • 4 = 2 + 1 + 1

关键观察点

  1. 盘子相同意味着顺序不重要(2+1+1和1+2+1是同一种分法)
  2. 允许空盘等价于允许使用少于N个盘子
  3. 每个盘子至少放0个苹果

这个问题与《火影忍者》中的"鸣人影分身"问题(将查克拉分成若干份)完全同构,也与计算机科学中的资源分配、组合数学等问题密切相关。理解这一点,就能举一反三解决一系列相似问题。

2. 递推解法:自底向上的构建艺术

递推是解决这类计数问题的自然思路。我们定义状态 dp[i][j] 表示将i个苹果放入j个盘子的方案数,然后寻找状态转移关系。

2.1 状态转移分析

递推关系的建立需要考虑以下几种情况:

  1. 边界条件

    • 0个苹果:只有一种分法(所有盘子空着)→ dp[0][j] = 1
    • 1个盘子:只有一种分法(所有苹果放入该盘子)→ dp[i][1] = 1
    • 苹果数≤盘子数:等价于每个盘子最多放1个苹果→ dp[i][j] = dp[i][i]
  2. 一般情况 (i>j):

    • 至少有一个盘子空着: dp[i][j-1]
    • 所有盘子都有至少一个苹果:先在每个盘子放1个苹果,剩余i-j个苹果自由分配→ dp[i-j][j]

因此,完整的递推式为:

dp[i][j] = dp[i][j-1] + (i >= j ? dp[i-j][j] : 0);

2.2 代码实现与优化

#include <bits/stdc++.h>
using namespace std;

int main() {
    int t, m, n, dp[15][15] = {};
    
    // 预处理所有可能的状态
    for(int i = 0; i <= 10; ++i) 
        for(int j = 1; j <= 10; ++j) {
            if(i == 0 || j == 1) dp[i][j] = 1;
            else if(i < j) dp[i][j] = dp[i][i];
            else dp[i][j] = dp[i][j-1] + dp[i-j][j];
        }
    
    cin >> t;
    while(t--) {
        cin >> m >> n;
        cout << dp[m][n] << endl;
    }
    return 0;
}

性能分析

  • 时间复杂度:O(M*N)预处理,O(1)查询
  • 空间复杂度:O(M*N)
  • 适用于多组查询场景,是竞赛中的首选方法

3. 递归解法:分而治之的思维训练

递归解法直接模拟问题定义,将大问题分解为小问题,是理解问题本质的最佳途径。

3.1 递归关系设计

递归函数 solve(i,j) 表示将i个苹果放入j个盘子的方案数,其逻辑与递推完全对应:

int solve(int i, int j) {
    if(i == 0 || j == 1) return 1;  // 边界条件
    if(i < j) return solve(i, i);    // 苹果不足
    return solve(i, j-1) + solve(i-j, j); // 一般情况
}

3.2 记忆化优化

原始递归存在大量重复计算,通过记忆化可以大幅提升效率:

#include <bits/stdc++.h>
using namespace std;

int memo[15][15]; // 记忆化数组

int solve(int i, int j) {
    if(memo[i][j]) return memo[i][j];
    if(i == 0 || j == 1) return memo[i][j] = 1;
    if(i < j) return memo[i][j] = solve(i, i);
    return memo[i][j] = solve(i, j-1) + solve(i-j, j);
}

int main() {
    int t, m, n;
    cin >> t;
    while(t--) {
        cin >> m >> n;
        cout << solve(m, n) << endl;
    }
    return 0;
}

对比分析

方法 时间复杂度 空间复杂度 适用场景
普通递归 O(2^(M+N)) O(M+N) 教学理解
记忆化递归 O(M*N) O(M*N) 单次查询
递推 O(M*N)预处理 O(M*N) 多次查询

4. DFS解法:暴力搜索的优雅实践

深度优先搜索(DFS)提供了一种直观的暴力解法,虽然效率不高,但对训练搜索思维很有帮助。

4.1 搜索策略设计

关键点在于避免重复计数。我们规定拆分序列为非递减排列,即后一个数不小于前一个数:

void dfs(int remain, int start, int depth) {
    if(depth == n) {
        if(remain == 0) ans++;
        return;
    }
    for(int i = start; i <= remain; ++i)
        dfs(remain - i, i, depth + 1);
}

4.2 完整实现与剪枝

#include <bits/stdc++.h>
using namespace std;

int m, n, ans;

void dfs(int remain, int start, int depth) {
    if(remain == 0 && depth <= n) {
        ans++;
        return;
    }
    if(depth >= n) return;
    
    for(int i = start; i <= remain; ++i)
        dfs(remain - i, i, depth + 1);
}

int main() {
    int t;
    cin >> t;
    while(t--) {
        cin >> m >> n;
        ans = 0;
        dfs(m, 1, 0);
        cout << ans << endl;
    }
    return 0;
}

优化技巧

  1. 提前终止:当剩余苹果为0时立即计数
  2. 层级控制:确保拆分次数不超过n次
  3. 起始值递增:保证序列非递减

5. 动态规划:通用框架的经典应用

虽然递推解法已经体现了DP思想,但用标准的DP框架重新分析,可以加深对状态设计的理解。

5.1 状态定义与转移

定义 dp[k][i][j] 表示前k个盘子放i个苹果且第k个盘子至少放j个的方案数。经过优化可简化为二维:

dp[i][j] = dp[i][j-1] + dp[i-j][j]

这与递推式一致,但思考角度不同:将问题看作 完全背包问题 ,其中盘子是"物品",苹果是"容量",每个盘子可以重复使用。

5.2 空间优化技巧

可以进一步优化为一维数组:

int dp[15] = {1}; // dp[0] = 1

for(int j = 1; j <= n; ++j)
    for(int i = j; i <= m; ++i)
        dp[i] += dp[i-j];

不过对于竞赛题目,二维数组通常已经足够。

6. 实战对比与选择指南

四种解法各有特点,适用于不同场景:

  1. 教学演示 :递归 > DFS > 递推 > DP
  2. 竞赛编程 :递推 > 记忆化递归 > DP > DFS
  3. 思维训练 :DFS > 递归 > DP > 递推

重要参数对比

方法 代码复杂度 时间复杂度 空间复杂度 扩展性
递推 ★★☆☆☆ O(M*N) O(M*N) 易于优化
递归 ★★★☆☆ O(2^N) O(N) 不易并行
记忆化递归 ★★★★☆ O(M*N) O(M*N) 需管理状态
DFS ★★★★★ O(指数级) O(N) 剪枝灵活
DP ★★☆☆☆ O(M*N) O(M*N) 框架通用

在实际比赛中,推荐优先掌握递推解法,它兼具效率和简洁性。当问题约束变大时(如M,N≤1000),需要考虑滚动数组优化等进阶技巧。

更多推荐