题目描述

✅ 1000. 合并石头的最低成本

image-20260928225326299

image-20260928225326301

题意分析

石头按顺序排列成若干堆,每次只能把当前相邻的恰好 k 堆合成一堆,费用等于这 k 堆的石头总数。要求最终只剩一堆,并使所有操作费用之和最小;不能完成时返回 -1。

合并后的堆可以继续参与后续操作,所以同一批石头可能在多次合并中产生费用。限制是相邻且每次恰好 k 堆,不能任意选堆,也不能只计算一次全体石头总和。

解法:区间动态规划

核心思路

[!blue]

每次操作把 k 堆变成一堆,堆数减少 k - 1。从 n 堆变成一堆,必须满足 (n - 1) % (k - 1) == 0。这也足以保证可合并:当剩余堆数还大于一时,它仍是“一加上若干个 k - 1”,至少还有 k 堆,可以继续选相邻的 k 堆合并。

用区间动态规划描述每段石头的最小费用,但不能把所有区间都定义为合成一堆,因为某些区间长度做不到。长度为 len 的区间反复合并后,能达到的最少堆数为 1 + (len - 1) % (k - 1),它处于一到 k - 1 之间;dp[left][right] 就表示合并到这个最少堆数所需的最低费用。

相邻合并保证每一堆都来自原区间中一段连续石头。若目标保留多堆,就观察最终的这些堆;若目标是一堆,就先观察最后一次合并之前的 k 堆。其中第一堆覆盖的左段必须能独立合成一堆,所以左段长度只能是一、再加若干个 k - 1。因此枚举分界 mid 时,从 left 开始,每次增加 k - 1,而不是任意切分。

对每个合法分界,先取 dp[left][mid] + dp[mid + 1][right]。若整段的目标剩余堆数大于一,左段的一堆与右段的最少堆数相加,恰好达到整段目标,直接比较这些费用即可。

若整段能够合成一堆,最后一次操作之前必然还有 k 堆。上述切分会先让左段得到一堆、右段得到 k - 1 堆,正好凑齐最后一次合并的输入。此时还要支付一次整个区间的石头总数,所以取最小分界费用后,再加区间和。

任意最优合并过程都能按“最终第一堆的边界”这样划分,因此枚举没有遗漏;子区间已经取最小费用,组合后也得到当前区间的最小费用。长度小于 k 时无法进行任何操作,目标就是保持原有堆数,初始费用为零。按长度递增计算,所需的更短区间都已完成;区间和用前缀和常数时间取得。

解题步骤

  1. 检查 (n - 1) % (k - 1),不为零则直接返回 -1。
  2. 建立石头数量的前缀和,创建初始为零的区间状态表。
  3. 从长度 k 开始递增枚举区间,先将当前费用设为足够大的值。
  4. 分界从区间左端开始,每次跳过 k - 1,用两个子区间费用之和更新最小值。
  5. 当前长度能合成一堆时,再加上整个区间的石头数量,表示最后一次实际合并。
  6. 返回整个区间的费用;原本只有一堆时无需操作,答案自然为零。

代码实现

class Solution {
    public int mergeStones(int[] stones, int k) {
        int n = stones.length;

        if ((n - 1) % (k - 1) != 0) {
            return -1;
        }

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

        for (int i = 0; i < n; i++) {
            prefix[i + 1] = prefix[i] + stones[i];
        }

        int[][] dp = new int[n][n];
        int inf = Integer.MAX_VALUE / 4;

        for (int len = k; len <= n; len++) {
            for (int left = 0; left + len <= n; left++) {
                int right = left + len - 1;

                dp[left][right] = inf;

                // 左段必须能压成一堆,因此分界每次跨过若干个整组。
                for (int mid = left; mid < right; mid += k - 1) {
                    dp[left][right] = Math.min(dp[left][right], dp[left][mid] + dp[mid + 1][right]);
                }

                // 整段可压成一堆时,额外支付最后一次合并的区间和。
                if ((len - 1) % (k - 1) == 0) {
                    dp[left][right] += prefix[right + 1] - prefix[left];
                }
            }
        }

        return dp[0][n - 1];
    }
}
func mergeStones(stones []int, k int) int {
    n := len(stones)
    if (n-1)%(k-1) != 0 {
        return -1
    }

    prefix := make([]int, n+1)
    for i := 0; i < n; i++ {
        prefix[i+1] = prefix[i] + stones[i]
    }

    dp := make([][]int, n)
    for i := range dp {
        dp[i] = make([]int, n)
    }

    inf := int(^uint(0)>>1) / 4
    for length := k; length <= n; length++ {
        for left := 0; left+length <= n; left++ {
            right := left + length - 1
            dp[left][right] = inf

            // 左段必须能压成一堆,因此分界每次跨过若干个整组。
            for mid := left; mid < right; mid += k - 1 {
                cost := dp[left][mid] + dp[mid+1][right]
                if cost < dp[left][right] {
                    dp[left][right] = cost
                }
            }

            // 整段可压成一堆时,额外支付最后一次合并的区间和。
            if (length-1)%(k-1) == 0 {
                dp[left][right] += prefix[right+1] - prefix[left]
            }
        }
    }

    return dp[0][n-1]
}

复杂度分析

设初始堆数为 $n$。

  • 时间复杂度:上界为 $O(n^2+n^3/(k-1))$。二维状态初始化需要 $O(n^2)$,每个区间按步长 $k-1$ 枚举分界,区间和查询为常数时间。
  • 辅助空间复杂度:$O(n^2)$,用于区间状态表,前缀和另占 $O(n)$。

关键点总结

[!green]

  • 剩余堆数由区间长度模 k - 1 决定,因此二维状态就能表达所需目标。
  • 分界步长保证左段能合成一堆,右段再提供其余目标堆。
  • 只有整段实际进行最后一次合并时才加区间和。
  • 短于 k 的区间不做操作,零费用是合法状态,不是未计算标记。

易错点总结

[!yellow]

  • 把每个 dp 都解释为合成一堆,会让许多本来有意义的中间状态被误判为不可达。
  • 分界逐个移动而不限制左段堆数,可能拼出多余的堆,却漏算继续合并的费用。
  • 每个区间都加总和,会给尚未合成一堆的区间凭空增加一次操作费用。
  • 能合成一堆时忘记加区间和,又会漏掉最后一次合并。
  • 将所有短区间初始化为无穷大,会破坏“保持原有堆数、费用为零”的递推起点。
  • 必须先按长度从短到长计算,不能在子区间尚未得到最优值时使用它。

相似题目

题目 难度 关联与区别
1547. 切棍子的最小成本 困难 同样选择分割结构并累计区间总成本,原题切开木棍,本题合并相邻石堆。
转载与许可
作者
链接 https://hgnulb.github.io/blog/2026/26804473
许可 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处!