每日算法 — 使用java实现石子合并:区间动态规划与最优合并策略

石子合并是区间动态规划(Interval DP)的经典问题。给定一排 $n$ 堆石子,每堆石子有一定数量,要求通过 $n-1$ 次合并将它们合并成一堆。每次合并只能合并相邻的两堆,合并代价为两堆石子数之和,求最小(或最大)的总合并代价。本文用Java实现基于区间DP的解法,并探讨四边形不等式优化。

问题分析

问题描述:$n$ 堆石子排成一排,第 $i$ 堆有 $a_i$ 个石子。每次合并选择相邻的两堆合并为一堆,新堆的石子数为两堆之和,代价也为两堆之和。经过 $n-1$ 次合并后成为一堆,求最小总代价。

关键性质:无论以何种顺序合并,最终所有石子的总和都是 $\sum a_i$。不同的是合并的顺序会影响中间代价的累加次数——越早合并的石子,其值被累加的次数越多。

算法核心思想

区间DP状态定义

设 $dp[i][j]$ 表示将第 $i$ 堆到第 $j$ 堆石子合并成一堆的最小代价。前缀和 $sum[i]$ 表示前 $i$ 堆石子的总数。

状态转移方程:
$$dp[i][j] = \min_{i \le k < j} { dp[i][k] + dp[k+1][j] } + sum[j] – sum[i-1]$$

其中 $k$ 是分割点,表示先在 $k$ 处将区间 $[i,j]$ 分成两部分分别合并,最后再将两部分合并。

四边形不等式优化

当石子合并问题满足四边形不等式时,可以使用Knuth优化将时间复杂度从 $O(n^3)$ 降低到 $O(n^2)$。

设 $opt[i][j]$ 为区间 $[i,j]$ 的最优分割点,四边形不等式保证了:
$$opt[i][j-1] \le opt[i][j] \le opt[i+1][j]$$

这意味着在枚举分割点 $k$ 时,只需在 $[opt[i][j-1], opt[i+1][j]]$ 范围内搜索,大大减少了搜索空间。

Java完整实现

import java.util.Arrays;

/**
 * 石子合并问题 - 区间动态规划实现
 * 求将n堆石子合并成一堆的最小/最大代价
 */
public class StoneMerge {

    /**
     * 基础区间DP解法:O(n^3)
     * @param stones 每堆石子的数量
     * @return 最小总合并代价
     */
    public static int minCostBasic(int[] stones) {
        int n = stones.length;
        if (n <= 1) {
            return 0;
        }

        // 前缀和:sum[i] 表示前i堆石子的总数(1-based)
        int[] prefixSum = new int[n + 1];
        for (int i = 1; i <= n; i++) {
            prefixSum[i] = prefixSum[i - 1] + stones[i - 1];
        }

        // dp[i][j]:将第i堆到第j堆合并的最小代价
        int[][] dp = new int[n + 1][n + 1];

        // 初始化:单个石子的合并代价为0
        for (int i = 1; i <= n; i++) {
            Arrays.fill(dp[i], Integer.MAX_VALUE / 2);
            dp[i][i] = 0;
        }

        // 按区间长度从小到大枚举
        for (int len = 2; len <= n; len++) {
            for (int i = 1; i + len - 1 <= n; i++) {
                int j = i + len - 1;
                // 枚举分割点k
                for (int k = i; k < j; k++) {
                    int cost = dp[i][k] + dp[k + 1][j] + prefixSum[j] - prefixSum[i - 1];
                    dp[i][j] = Math.min(dp[i][j], cost);
                }
            }
        }

        return dp[1][n];
    }

    /**
     * 四边形不等式优化:O(n^2)
     * 适用于满足四边形不等式的石子合并问题
     */
    public static int minCostOptimized(int[] stones) {
        int n = stones.length;
        if (n <= 1) {
            return 0;
        }

        int[] prefixSum = new int[n + 1];
        for (int i = 1; i <= n; i++) {
            prefixSum[i] = prefixSum[i - 1] + stones[i - 1];
        }

        int[][] dp = new int[n + 2][n + 2];
        // opt[i][j] 记录区间[i,j]的最优分割点
        int[][] opt = new int[n + 2][n + 2];

        for (int i = 1; i <= n; i++) {
            Arrays.fill(dp[i], Integer.MAX_VALUE / 2);
            dp[i][i] = 0;
            opt[i][i] = i; // 单个元素的分割点就是自身
        }

        for (int len = 2; len <= n; len++) {
            for (int i = 1; i + len - 1 <= n; i++) {
                int j = i + len - 1;
                // 利用四边形不等式缩小搜索范围
                int start = opt[i][j - 1];
                int end = opt[i + 1][j];

                for (int k = start; k <= end && k < j; k++) {
                    int cost = dp[i][k] + dp[k + 1][j] + prefixSum[j] - prefixSum[i - 1];
                    if (cost < dp[i][j]) {
                        dp[i][j] = cost;
                        opt[i][j] = k;
                    }
                }
            }
        }

        return dp[1][n];
    }

    /**
     * 求最大合并代价
     */
    public static int maxCost(int[] stones) {
        int n = stones.length;
        if (n <= 1) {
            return 0;
        }

        int[] prefixSum = new int[n + 1];
        for (int i = 1; i <= n; i++) {
            prefixSum[i] = prefixSum[i - 1] + stones[i - 1];
        }

        int[][] dp = new int[n + 1][n + 1];

        for (int len = 2; len <= n; len++) {
            for (int i = 1; i + len - 1 <= n; i++) {
                int j = i + len - 1;
                dp[i][j] = Integer.MIN_VALUE;
                for (int k = i; k < j; k++) {
                    int cost = dp[i][k] + dp[k + 1][j] + prefixSum[j] - prefixSum[i - 1];
                    dp[i][j] = Math.max(dp[i][j], cost);
                }
            }
        }

        return dp[1][n];
    }

    /**
     * 打印最优合并方案(回溯)
     */
    public static void printOptimalMerge(int[] stones) {
        int n = stones.length;
        int[] prefixSum = new int[n + 1];
        for (int i = 1; i <= n; i++) {
            prefixSum[i] = prefixSum[i - 1] + stones[i - 1];
        }

        int[][] dp = new int[n + 1][n + 1];
        int[][] split = new int[n + 1][n + 1];

        for (int i = 1; i <= n; i++) {
            Arrays.fill(dp[i], Integer.MAX_VALUE / 2);
            dp[i][i] = 0;
        }

        for (int len = 2; len <= n; len++) {
            for (int i = 1; i + len - 1 <= n; i++) {
                int j = i + len - 1;
                for (int k = i; k < j; k++) {
                    int cost = dp[i][k] + dp[k + 1][j] + prefixSum[j] - prefixSum[i - 1];
                    if (cost < dp[i][j]) {
                        dp[i][j] = cost;
                        split[i][j] = k;
                    }
                }
            }
        }

        System.out.println("最小总代价: " + dp[1][n]);
        System.out.println("最优合并顺序:");
        printMergeOrder(split, 1, n, 1);
    }

    private static void printMergeOrder(int[][] split, int i, int j, int depth) {
        if (i == j) {
            System.out.println("  ".repeat(depth) + "石子[" + i + "]");
            return;
        }
        int k = split[i][j];
        System.out.println("  ".repeat(depth) + "合并区间 [" + i + "," + j + "] 在 " + k + " 处分割");
        printMergeOrder(split, i, k, depth + 1);
        printMergeOrder(split, k + 1, j, depth + 1);
    }

    /**
     * 环形石子合并(首尾相连)
     */
    public static int circularMinCost(int[] stones) {
        int n = stones.length;
        // 将数组复制一倍,破环成链
        int[] doubled = new int[2 * n];
        for (int i = 0; i < 2 * n; i++) {
            doubled[i] = stones[i % n];
        }

        int[] prefixSum = new int[2 * n + 1];
        for (int i = 1; i <= 2 * n; i++) {
            prefixSum[i] = prefixSum[i - 1] + doubled[i - 1];
        }

        int[][] dp = new int[2 * n + 1][2 * n + 1];
        for (int i = 1; i <= 2 * n; i++) {
            Arrays.fill(dp[i], Integer.MAX_VALUE / 2);
            dp[i][i] = 0;
        }

        for (int len = 2; len <= n; len++) {
            for (int i = 1; i + len - 1 <= 2 * n; i++) {
                int j = i + len - 1;
                for (int k = i; k < j; k++) {
                    int cost = dp[i][k] + dp[k + 1][j] + prefixSum[j] - prefixSum[i - 1];
                    dp[i][j] = Math.min(dp[i][j], cost);
                }
            }
        }

        // 在2n个位置中找到最小值
        int minCost = Integer.MAX_VALUE;
        for (int i = 1; i <= n; i++) {
            minCost = Math.min(minCost, dp[i][i + n - 1]);
        }

        return minCost;
    }

    public static void main(String[] args) {
        int[] stones = {3, 2, 4, 1, 5};

        System.out.println("=== 石子合并问题 ===");
        System.out.println("石子数量: " + Arrays.toString(stones));
        System.out.println("石子总数: " + Arrays.stream(stones).sum());
        System.out.println();

        System.out.println("基础DP最小代价: " + minCostBasic(stones));
        System.out.println("优化DP最小代价: " + minCostOptimized(stones));
        System.out.println("最大合并代价: " + maxCost(stones));
        System.out.println();

        printOptimalMerge(stones);

        System.out.println("\n环形石子合并最小代价: " + circularMinCost(stones));
    }
}

时间复杂度分析

解法 时间复杂度 空间复杂度 适用条件
基础区间DP $O(n^3)$ $O(n^2)$ 通用
四边形不等式优化 $O(n^2)$ $O(n^2)$ 满足四边形不等式
环形扩展 $O((2n)^3) = O(n^3)$ $O(n^2)$ 环形排列

四边形不等式优化的核心在于:对于区间DP,若代价函数满足 $w(a,c) + w(b,d) \le w(a,d) + w(b,c)$(四边形不等式)和 $w(b,c) \le w(a,d)$(区间单调性),则最优决策点具有单调性,可以将内层枚举范围缩小。

算法拓展

石子合并问题的核心思想可推广到:
– 矩阵链乘:最优括号化方案(完全相同的DP框架)
– 最优二叉搜索树:构建访问代价最小的BST
– 凸多边形三角剖分:最小权重三角剖分
– 文件最优合并:哈夫曼编码的变体
– 字符串折叠:将重复子串折叠表示的最小长度