1. 概述

钢条切割问题是一个经典的优化问题,核心目标是找到切割钢条的最佳方式,以实现总收益最大化。

本教程将深入理解钢条切割问题,并探索多种Java解决方案。

2. 问题解析

假设有一根长度为 n 的钢条。我们可以将其切割成不同长度的小段出售,并拥有一个价格表记录不同长度钢条的价格。我们的目标是通过最优切割方案获得最大收益。

以长度 n=4 的钢条为例,价格数组 Pi = [1,5,8,9]。Pi 数组表示长度为 i 的钢条价格:

  • P1 = 1:长度1的钢条价格1单位
  • P2 = 5:长度2的钢条价格5单位
  • P3 = 8:长度3的钢条价格8单位
  • P4 = 9:长度4的钢条价格9单位

对于长度4的钢条,可能的切割方案及收益如下:

  • 切成4段1米 [1,1,1,1],收益 = 1+1+1+1 = $4
  • 切成2段2米 [2,2],收益 = 5+5 = $10
  • 不切割 [4],收益 = $9
  • 切成1米和2米组合 [1,1,2],收益 = 1+1+5 = $7
  • 切成1米和2米组合 [1,2,1],收益 = 1+5+1 = $7
  • 切成1米和2米组合 [2,1,1],收益 = 5+1+1 = $7

显然,切成两段2米的方案收益最高($10),其他组合均低于此值,这就是最优解。

3. 递归解法

递归解法通过分解子问题并递归求解。其递推关系为 *Rn = max₁≤ᵢ≤ₙ(Pᵢ + Rₙ₋ᵢ)*,其中:

  • Rn:长度 n 钢条的最大收益
  • Pi:长度 i 钢条的价格
int usingRecursion(int[] prices, int n) {
    if (n <= 0) {
        return 0;
    }
    int maxRevenue = Integer.MIN_VALUE;
    for (int i = 1; i <= n; i++) {
        maxRevenue = Math.max(maxRevenue, prices[i - 1] + usingRecursion(prices, n - i));
    }
    return maxRevenue;
}

代码逻辑:

  1. 基线条件:钢条长度≤0时收益为0
  2. 遍历所有可能的切割长度
  3. 递归计算每种切割方案的收益并取最大值

测试用例:

@Test
void givenARod_whenUsingRecursion_thenFindMaxRevenue() {
    int[] prices = {1, 5, 8, 9};
    int rodLength = 4;
    int maxRevenue = RodCuttingProblem.usingRecursion(prices, rodLength);
    assertEquals(10, maxRevenue);
}

⚠️ 性能瓶颈:虽然递归解法直观易懂,但其指数级时间复杂度 O(2ⁿ) 使其难以处理大规模问题。例如4米钢条需要 2⁴=16 次迭代,而10米钢条将需要 1024 次迭代!

主要缺陷

  • 重复计算相同子问题
  • 可能导致栈溢出
  • 无显式状态存储(依赖调用栈)

4. 记忆化递归解法

通过记忆化技术优化递归解法,使用备忘录表存储子问题解:

int usingMemoizedRecursion(int[] prices, int n) {
    int[] memo = new int[n + 1];
    Arrays.fill(memo, -1);

    return memoizedHelper(prices, n, memo);
}

int memoizedHelper(int[] prices, int n, int[] memo) {
    if (n <= 0) {
        return 0;
    }

    if (memo[n] != -1) {
        return memo[n];
    }
    int maxRevenue = Integer.MIN_VALUE;

    for (int i = 1; i <= n; i++) {
        maxRevenue = Math.max(maxRevenue, prices[i - 1] + memoizedHelper(prices, n - i, memo));
    }

    memo[n] = maxRevenue;
    return maxRevenue;
}

关键改进:

  • 备忘录数组 memo 存储已计算结果
  • 避免重复计算子问题

测试用例:

@Test 
void givenARod_whenUsingMemoizedRecursion_thenFindMaxRevenue() {
    int[] prices = {1, 5, 8, 9};
    int rodLength = 4;
    int maxRevenue = RodCuttingProblem.usingMemoizedRecursion(prices, rodLength);
    assertEquals(10, maxRevenue);
}

性能提升

  • 时间复杂度降至 O(n²)(相比之前的指数级)
  • 空间复杂度 O(n) 用于存储备忘录

⚠️ 空间代价:需要额外空间存储子问题解,但相比时间优化是值得的。

5. 动态规划解法

动态规划通过分解子问题并只求解一次来优化,与分治法类似但避免重复计算。

5.1. 迭代(自底向上)法

int usingDynamicProgramming(int[] prices, int n) {
    int[] dp = new int[n + 1];
    for (int i = 1; i <= n; i++) {
        int maxRevenue = Integer.MIN_VALUE;

        for (int j = 1; j <= i; j++) {
            maxRevenue = Math.max(maxRevenue, prices[j - 1] + dp[i - j]);
        }
        dp[i] = maxRevenue;
    }
    return dp[n];
}

核心思想:

  • dp 数组存储各长度最优解
  • 从小到大逐步构建解

测试用例:

@Test 
void givenARod_whenUsingDynamicProgramming_thenFindMaxRevenue() {
    int[] prices = {1, 5, 8, 9};
    int rodLength = 4;
    int maxRevenue = RodCuttingProblem.usingDynamicProgramming(prices, rodLength);
    assertEquals(10, maxRevenue);
}

优势

  • 消除递归调用开销
  • 无需额外备忘录表
  • 时间复杂度 O(n²),空间复杂度 O(n)

5.2. 无界背包解法

无界背包问题允许重复选择物品,对应钢条切割中可重复使用同一切割长度:

int usingUnboundedKnapsack(int[] prices, int n) {
    int[] dp = new int[n + 1];

    for (int i = 1; i <= n; i++) {
        for (int j = 0; j < prices.length; j++) {
            if (j + 1 <= i) {
                dp[i] = Math.max(dp[i], dp[i - (j + 1)] + prices[j]);
            }
        }
    }

    return dp[n];
}

实现特点:

  • 外层循环遍历钢条长度
  • 内层循环遍历价格表
  • 通过 dp[i - (j+1)] 实现重复选择

测试用例:

@Test 
void givenARod_whenUsingGreedy_thenFindMaxRevenue() {
    int[] prices = {1, 5, 8, 9};
    int rodLength = 4;
    int maxRevenue = RodCuttingProblem.usingUnboundedKnapsack(prices, rodLength);
    assertEquals(10, maxRevenue);
}

⚠️ 潜在问题:空间复杂度可能增加,但时间复杂度仍为 O(n²)。

6. 总结

本文探讨了钢条切割问题的多种Java解法:

解法 时间复杂度 空间复杂度 适用场景
递归 O(2ⁿ) O(n) 小规模问题,教学演示
记忆化递归 O(n²) O(n) 中等规模,需要快速实现
动态规划(迭代) O(n²) O(n) 大规模问题,生产环境
无界背包 O(n²) O(n) 需要重复选择切割方案

核心权衡点

  • ✅ 递归:实现简单但性能差
  • ✅ 记忆化:平衡时间与空间
  • ✅ 动态规划:最优性能但实现稍复杂

实际选择需根据具体场景权衡时间、空间和实现复杂度。对于生产环境,推荐使用动态规划解法。

本文所有示例代码可在 GitHub 获取。