[NOI1995] 石子合并

GitHub跳转原题关系图返回列表

把环断成长度为 n 的所有链段,做区间 DP,同时维护最小合并代价和最大合并代价。

OJ: luogu

题目 ID: P1880

难度:普及+/提高

标签:动态规划区间dp环形处理

日期: 2026-06-21 12:27

题意

n 堆石子围成一个环。

每次只能选相邻两堆合并,得分等于这两堆石子数之和。

问把所有石子最终合成一堆时:

  • 最小总得分是多少
  • 最大总得分是多少

思路

先看一个可以直接验证想法的朴素解:

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

// brute.cpp:小数据暴力解,用来帮助理解题意并辅助对拍。

const int INF = 0x3f3f3f3f;

int n;
vector<int> stones;
map<vector<int>, pair<int, int> > memo;

// 暴力递归:当前 stones 表示环上剩余的石子堆。
pair<int, int> dfs(vector<int> cur) {
    int m = (int) cur.size();
    if (m == 1) {
        return make_pair(0, 0);
    }

    map<vector<int>, pair<int, int> >::iterator it = memo.find(cur);
    if (it != memo.end()) {
        return it->second;
    }

    int best_min = INF;
    int best_max = -INF;

    for (int i = 0; i < m; i++) {
        int j = (i + 1) % m;
        int merged = cur[i] + cur[j];

        vector<int> nxt;
        nxt.push_back(merged);
        // 合并掉 i 和 j 后,剩余元素按环上的相对顺序接在 merged 后面。
        for (int step = 1; step <= m - 2; step++) {
            int idx = (j + step) % m;
            nxt.push_back(cur[idx]);
        }

        pair<int, int> sub = dfs(nxt);
        best_min = min(best_min, sub.first + merged);
        best_max = max(best_max, sub.second + merged);
    }

    pair<int, int> ans = make_pair(best_min, best_max);
    memo[cur] = ans;
    return ans;
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> n;
    stones.resize(n);
    for (int i = 0; i < n; i++) {
        cin >> stones[i];
    }

    memo.clear();
    pair<int, int> ans = dfs(stones);
    cout << ans.first << '\n';
    cout << ans.second << '\n';
    return 0;
}

暴力做法就是递归模拟:

  • 当前环上有多少堆石子
  • 枚举把哪一对相邻石子合并
  • 继续递归

这样可以帮助理解题目,但复杂度是指数级,只能做很小的数据。

这题的标准做法是区间 DP。

如果石子是排成一条链,那么设:

  • dp_min[l][r]:把区间 [l, r] 合成一堆的最小代价
  • dp_max[l][r]:把区间 [l, r] 合成一堆的最大代价

转移时枚举最后一次合并的位置 k

  • 左边先合成一堆
  • 右边先合成一堆
  • 最后再把这两堆合并

所以有:

  • dp_min[l][r] = min(dp_min[l][k] + dp_min[k+1][r] + sum(l,r))
  • dp_max[l][r] = max(dp_max[l][k] + dp_max[k+1][r] + sum(l,r))

其中 sum(l,r) 表示区间石子总数,因为最后这一次把两大堆合起来,得分就是整个区间总和。

但原题是一个环,不是链。

处理环形区间 DP 的常见办法是:

  1. 把数组复制一遍,变成长度 2n
  2. 这样所有“断环成链”的方案,都能对应成一个长度为 n 的连续区间
  3. 枚举每个起点 l,看区间 [l, l+n-1] 的答案

最后:

  • 所有这些长度为 n 的区间里,最小值的最小者就是答案
  • 最大值的最大者就是答案

DP 转移方程

核心状态:

dp_min[l][r]dp_max[l][r]

核心转移:

dp[l][r]=opt_k(dp[l][k]+dp[k+1][r]+sum(l,r))

答案收束:

长度 n 的所有区间取最小/最大

代码

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

const int MAXN = 205;
const int INF = 0x3f3f3f3f;

int n;
int a[MAXN];
int sumv[MAXN];
int dp_min[MAXN][MAXN];
int dp_max[MAXN][MAXN];

// 计算区间 [l, r] 的石子总数。
int range_sum(int l, int r) {
    return sumv[r] - sumv[l - 1];
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> n;
    for (int i = 1; i <= n; i++) {
        cin >> a[i];
        a[i + n] = a[i];
    }

    for (int i = 1; i <= 2 * n; i++) {
        sumv[i] = sumv[i - 1] + a[i];
    }

    for (int i = 1; i <= 2 * n; i++) {
        for (int j = 1; j <= 2 * n; j++) {
            dp_min[i][j] = INF;
            dp_max[i][j] = -INF;
        }
        dp_min[i][i] = 0;
        dp_max[i][i] = 0;
    }

    // 区间 DP:先枚举长度,再枚举左端点。
    for (int len = 2; len <= n; len++) {
        for (int l = 1; l + len - 1 <= 2 * n; l++) {
            int r = l + len - 1;
            int seg_sum = range_sum(l, r);
            for (int k = l; k < r; k++) {
                dp_min[l][r] = min(dp_min[l][r], dp_min[l][k] + dp_min[k + 1][r] + seg_sum);
                dp_max[l][r] = max(dp_max[l][r], dp_max[l][k] + dp_max[k + 1][r] + seg_sum);
            }
        }
    }

    int ans_min = INF;
    int ans_max = -INF;
    for (int l = 1; l <= n; l++) {
        int r = l + n - 1;
        ans_min = min(ans_min, dp_min[l][r]);
        ans_max = max(ans_max, dp_max[l][r]);
    }

    cout << ans_min << '\n';
    cout << ans_max << '\n';
    return 0;
}

复杂度

区间 DP 一共有 O(n2)O(n^2) 个区间,每个区间再枚举一个分割点,所以总复杂度是 O(n3)O(n^3)

空间复杂度是 O(n2)O(n^2)

总结

这题是非常典型的“环形区间 DP”。

关键步骤只有两件事:

  1. 先写好链上的区间 DP
  2. 再用“复制数组”的方式把环断成链

一旦想清楚最后一次合并一定会把某个区间 [l,r] 的左右两部分拼起来,转移就很自然了。

一图流解析

这张图把本题的建模、关键转移、实现检查和训练方法压缩到一页,适合读完正文后复盘。

一图流解析