[NOIP2006-提高] 能量项链

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

最优聚合顺序的最后一次聚合把连续段劈成两半,环拆链复制一倍后做区间 DP,对 N 个长度为 N 的窗口取最大值。

OJ: roj

题目 ID: 3548

难度:普及+/提高-

标签:动态规划区间DP环形DPpython

创建: 2026-10-02 06:54

更新: 2026-10-10 11:30

形式化题目

给定 NN 个正整数 a1,a2,…,aNa_1, a_2, \dots, a_N,把它们看作一个环(即 aN+1=a1a_{N+1} = a_1)。 第 ii 颗珠子是二元组 (ai,ai+1)(a_i, a_{i+1})。

一次聚合取相邻的两颗珠子 (m,r)(m, r) 与 (r,n)(r, n),把它们替换成一颗珠子 (m,n)(m, n), 并释放 m⋅r⋅nm \cdot r \cdot n 单位的能量。做 N−1N - 1 次聚合后只剩一颗珠子, 求总能量的最大值。

也可以换个说法:一次聚合就是消去当前环上的一个标记,代价是它此时两侧邻居的标记 与它自己的乘积;每消去一个标记,环上的珠子就少一颗。做 N−1N-1 次聚合就是把 NN 个标记 中的 N−1N-1 个消去,最后剩下一个标记 asa_s,而那颗最终珠子是 (as,as)(a_s, a_s)—— 头尾都是它自己,因为环已经没有"断口"了。

以样例 N=4N = 4、a=(2,3,5,10)a = (2, 3, 5, 10) 为例,四颗珠子是 (2,3),(3,5),(5,10),(10,2)(2,3), (3,5), (5,10), (10,2)。 题面给出的最优顺序是 ((4⊕1)⊕2)⊕3((4 \oplus 1) \oplus 2) \oplus 3,逐步展开如下:

步骤 聚合操作 释放能量 累计能量
1 (10,2)⊕(2,3)→(10,3)(10,2) \oplus (2,3) \to (10,3) 10⋅2⋅3=6010 \cdot 2 \cdot 3 = 60 60
2 (10,3)⊕(3,5)→(10,5)(10,3) \oplus (3,5) \to (10,5) 10⋅3⋅5=15010 \cdot 3 \cdot 5 = 150 210
3 (10,5)⊕(5,10)→(10,10)(10,5) \oplus (5,10) \to (10,10) 10⋅5⋅10=50010 \cdot 5 \cdot 10 = 500 710

观察重点:每次聚合确实都消去了一个标记(依次是 a1=2a_1 = 2、a2=3a_2 = 3、a3=5a_3 = 5, 最后只剩 a4=10a_4 = 10),代价恰好是"被消去的标记 ×\times 它当时的两侧邻居"。 第 3 步消去 a3a_3 时环上只剩 a3,a4a_3, a_4 两个标记,a3a_3 的两侧都是同一个 a4=10a_4 = 10,所以代价是 10⋅5⋅10=50010 \cdot 5 \cdot 10 = 500,最终珠子正是 (a4,a4)=(10,10)(a_4, a_4) = (10, 10)。

正解

思路

朴素做法与它的瓶颈。 最直接的想法是枚举所有聚合顺序:每一步从当前剩下的相邻珠对中 任选一对合并,递归到只剩一颗。N=100N = 100 时第一层就有 100 种选择、第二层 99 种…… 规模是 N!N! 量级。但真正的瓶颈不是枚举量,而是状态描述选错了:它把"当前项链 长什么样"当作状态,而项链形状有指数多种,其中大量形状其实对应同一个子问题—— 一段连续珠子内部怎么聚合,和它外面的珠子毫无关系(聚合只发生在相邻珠子之间, 段内操作不会改变段两端露出的标记)。

关键观察:盯住最优方案的"最后一次聚合"。 设某段连续珠子 ai,ai+1,…,aja_i, a_{i+1}, \dots, a_j 最终被聚成一颗。它的最后一次聚合必然把这段珠子分成左右两部分

[i,k]与[k+1,j](i⩽k<j), [i, k] \quad \text{与} \quad [k+1, j] \qquad (i \leqslant k < j),

因为它们在这最后一步之前必须各自已经是一颗完整的珠子。设左边聚成了 (ai,ak+1)(a_i, a_{k+1}) (头标记是段首 aia_i,尾标记是分界点的下一个标记 ak+1a_{k+1}),右边聚成了 (ak+1,aj+1)(a_{k+1}, a_{j+1}),那么这一步释放

ai⋅ak+1⋅aj+1. a_i \cdot a_{k+1} \cdot a_{j+1} .

两部分内部的聚合互相不影响,总能量直接相加。于是只要左右两段各自取得内部最优, 整体就最优——这是标准的"最优子结构",可以用区间 DP。

正确性两边都说一遍:任取 [i,j][i, j] 的一个合法顺序,它的最后一步一定对应某个切点 kk, 而这之前的所有聚合都发生在 [i,k][i, k] 与 [k+1,j][k+1, j] 内部,所以它的总能量 ⩽f(i,k)+f(k+1,j)+aiak+1aj+1⩽f(i,j)\leqslant f(i,k) + f(k+1,j) + a_i a_{k+1} a_{j+1} \leqslant f(i,j); 反过来,任取一个 kk,把实现 f(i,k)f(i,k) 与 f(k+1,j)f(k+1,j) 的两个顺序依次做一遍,再做 最后一次聚合,就得到一个真实合法的顺序,能量恰为三项之和。两边夹逼,f(i,j)f(i,j) 就是最优值。

状态与转移。 记

f(i,j)=把连续珠子 ai,…,aj 全部聚成一颗时释放的最大能量, f(i, j) = \text{把连续珠子 } a_i, \dots, a_j \text{ 全部聚成一颗时释放的最大能量},

其中段 ai,…,aja_i, \dots, a_j 一共是 j−i+1j - i + 1 颗珠子、j−ij - i 次聚合,消去的是内部标记 ai+1,…,aja_{i+1}, \dots, a_j,聚成的那颗珠子保留两端标记,即 (ai,  aj+1)(a_i,\; a_{j+1})—— 注意尾标记是 aj+1a_{j+1} 而不是 aja_j(第 jj 颗珠子本来就是 (aj,aj+1)(a_j, a_{j+1}); 若误写成 aja_j,样例恰好仍输出 710,但 N=4N = 4、a=(49,6,43,6)a = (49,6,43,6) 这类数据立刻出错)。 初始 f(i,i)=0f(i, i) = 0,转移为

f(i,j)=max⁡i⩽k<j{f(i,k)+f(k+1,j)+ai⋅ak+1⋅aj+1}. f(i, j) = \max_{i \leqslant k < j}\Bigl\{f(i, k) + f(k+1, j) + a_i \cdot a_{k+1} \cdot a_{j+1}\Bigr\}.

转移只依赖长度更小的区间,所以按区间长度 L=2,3,…,NL = 2, 3, \dots, N 从小到大递推即可。

环拆链并复制一倍。 上面的 DP 处理的是"链"。环上没有天然的起点,但把环从任意位置 剪开并复制一倍,得到长度为 2N2N 的序列。环上任何一种聚合方案都恰好对应一个长度为 NN 的窗口 [i,i+N−1][i, i+N-1](1⩽i⩽N1 \leqslant i \leqslant N);不同起点可能给出不同的最优值,所以答案是

max⁡1⩽i⩽Nf(i,i+N−1). \max_{1 \leqslant i \leqslant N} f(i, i + N - 1).

用样例验证。 复制一倍得 a=(2,3,5,10,2,3,5,10)a = (2,3,5,10,2,3,5,10),小规模 FF 值如下 (行是区间长度 LL,列是区间起点 ii;单元格是 F(i,i+L−1)F(i, i+L-1);— 表示该窗口 超出复制后的序列):

L\iL \backslash i 1 2 3 4 5
1 F(1,1)=0F(1,1)=0 00 00 00 00
2 F(1,2)=30F(1,2)=30 150150 100100 6060 3030
3 F(1,3)=210F(1,3)=210 210210 210210 210210 210210
4 F(1,4)=250F(1,4)=250 300300 460460 710\mathbf{710} —

每个格子由上一行(更短的区间)拼出,例如 F(4,5)=a4a5a6=10⋅2⋅3=60F(4,5) = a_4 a_5 a_6 = 10 \cdot 2 \cdot 3 = 60。

再单独展开目标格 F(4,7)F(4,7)(区间 (10,2,3,5)(10, 2, 3, 5),尾标记 a8=10a_8 = 10),它有三个候选切点:

kk f(4,k)f(4,k) f(k+1,7)f(k+1,7) a4⋅ak+1⋅a8a_4 \cdot a_{k+1} \cdot a_8 合计
4 00 f(5,7)=210f(5,7)=210 10⋅2⋅10=20010 \cdot 2 \cdot 10 = 200 410
5 f(4,5)=60f(4,5)=60 f(6,7)=150f(6,7)=150 10⋅3⋅10=30010 \cdot 3 \cdot 10 = 300 510
6 f(4,6)=210f(4,6)=210 00 10⋅5⋅10=50010 \cdot 5 \cdot 10 = 500 710

取 k=6k = 6,含义是"先聚出左珠 (10,5)(10,5)、右珠 (5,10)(5,10),最后合并",正是题面的 ((4⊕1)⊕2)⊕3((4 \oplus 1) \oplus 2) \oplus 3。最后,四个窗口的答案是 250,300,460,710250, 300, 460, 710, 取最大得 710710——这也说明必须枚举起点:只算 f(1,N)=250f(1, N) = 250 会得到错误答案。

实现选择。 状态数 O(N2)O(N^2)、每个状态转移 O(N)O(N),总复杂度 O(N3)O(N^3),N⩽100N \leqslant 100 时转移约 6.6×1056.6 \times 10^5 次。代码用显式双层循环按长度递推,而不是 @cache 记忆化搜索: 迭代版没有函数调用和哈希开销,N=100N = 100 时实测约 0.05 s,相对 1000 ms 的限时余量充足。

代码

cpp
/**
 * Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
 * rbook: -> https://rbook.roj.ac.cn  https://rbook2.roj.ac.cn
 * rainboy的学习导航网站: https://idx.roj.ac.cn
 * create_at: 2026-10-10 11:30
 * update_at: 2026-10-10 11:30
 */
#include <bits/stdc++.h>
using namespace std;

typedef long long ll;

int n;
ll a[205];             // marks 复制一倍
ll f[205][205];

ll best_energy() {
    int size = 2 * n;
    for (int i = 0; i < size; i++)
        for (int j = 0; j < size; j++)
            f[i][j] = 0;
    for (int length = 2; length <= n; length++) {
        for (int i = 0; i < size - length; i++) {
            int j = i + length - 1;
            ll head = a[i], tail = a[j + 1];
            for (int k = i; k < j; k++) {
                ll energy = f[i][k] + f[k + 1][j] + head * a[k + 1] * tail;
                if (energy > f[i][j]) f[i][j] = energy;
            }
        }
    }
    ll ans = 0;
    for (int i = 0; i < n; i++)
        if (f[i][i + n - 1] > ans) ans = f[i][i + n - 1];
    return ans;
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    cin >> n;
    for (int i = 0; i < n; i++) {
        cin >> a[i];
        a[i + n] = a[i];
    }
    cout << best_energy() << "\n";
    return 0;
}
python
#!/usr/bin/env python3
# Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
# rbook: -> https://rbook.roj.ac.cn  https://rbook2.roj.ac.cn
# rainboy的学习导航网站: https://idx.roj.ac.cn
# create_at: 2026-10-02 06:54
# update_at: 2026-10-02 06:54

import sys


def best_energy(marks: list[int], n: int) -> int:
    """项链上 n 颗珠子全部聚合时能释放的最大总能量。"""
    a = marks * 2  # 环拆成链再复制一倍:任何长度为 n 的窗口都是环上的一种起点
    size = 2 * n
    # f[i][j]:把 a[i..j] 这串连续珠子聚成一颗时释放的最大能量;长度为 1 时无需聚合,值为 0
    f = [[0] * size for _ in range(size)]

    for length in range(2, n + 1):      # 长度超过 n 的区间不是环上的连续段,不必计算
        for i in range(size - length):  # 右端留一个 a[j+1] 当聚合后珠子的尾标记
            j = i + length - 1
            head, tail = a[i], a[j + 1]  # [i,j] 全部聚合后得到珠子 (head, tail)
            row = f[i]
            for k in range(i, j):        # 最后一步:[i,k] 与 [k+1,j] 各自的珠子再聚合
                energy = row[k] + f[k + 1][j] + head * a[k + 1] * tail
                if energy > row[j]:
                    row[j] = energy

    # 环上每种起点都对应一个长度为 n 的区间,取最大者
    return max(f[i][i + n - 1] for i in range(n))


def solve() -> None:
    data = iter(map(int, sys.stdin.buffer.read().split()))
    n = next(data)
    marks = [next(data) for _ in range(n)]
    print(best_energy(marks, n))


if __name__ == "__main__":
    solve()

代码中 a = marks * 2 完成"环拆链并复制一倍";f[i][j] 就是 f(i+1,j+1)f(i+1, j+1) (0 起下标);for length in range(2, n + 1) 保证递推顺序,head * a[k + 1] * tail 对应 ai⋅ak+1⋅aj+1a_i \cdot a_{k+1} \cdot a_{j+1}。读入处用 data[1 : n + 1] 严格只取 NN 个标记,而不是把整行都当成标记:这样即使评测数据尾部多出 token 也按 NN 截断,更稳。

复杂度

  • 时间复杂度:状态 O(N2)O(N^2),每个状态枚举 kk 转移 O(N)O(N),合计 O(N3)O(N^3)。 朴素的顺序枚举是 N!N! 量级。
  • 空间复杂度:ff 是 2N×2N2N \times 2N 的整数矩阵,O(N2)O(N^2)。
  • 实测 N=100N = 100 随机数据 0.052 s、峰值约 15 MB(OJ 限时 1000 ms)。

总结

本题做了两次关键转换:

  1. 把"枚举聚合顺序"换成"枚举最后一步的切点"。前者是 N!N! 量级的搜索,后者只需要 记录"哪一段连续珠子",因为最后一次聚合必然把这段珠子劈成两半、两半互不影响。 写转移时务必记住长度为 LL 的区间 [i,j][i,j] 聚成的是 (ai,aj+1)(a_i, a_{j+1}),尾标记是段尾的 下一个标记。
  2. 把环拆成链并复制一倍。DP 只处理链,而环上共有 NN 种起点,复制后每种起点对应一个 长度 NN 的窗口,答案取这些窗口的最大值;只算 f(1,N)f(1, N) 在样例上就会少算。

图示解析

这张图串起本题从建模到得到答案的主线:

text
输入:环上 N 颗珠子,第 i 颗为 (a_i, a_{i+1}),下标模 N
`- 建模:聚合 = 消去环上的一个中间标记 a_r,累加 左*中*右
   `- 关键观察:最优方案的"最后一次聚合"把连续段 [i, j] 劈成 [i, k] 与 [k+1, j]
      |- 两段内部聚合互不影响,可分别取最优(最优子结构)
      |- 本步释放 a_i * a_(k+1) * a_(j+1)  ← 尾标记是段尾的"下一个"
      `- 状态 f(i, j):把 a_i..a_j 聚成一颗的最大能量,按区间长度从小到大递推
         `- 环拆链复制一倍:环上 N 种起点 = 长度 N 的窗口
            `- 答案 = max_i f(i, i+N-1)  —— 样例中窗口 250 / 300 / 460 / 710 取 710

先看"消去中间标记"这一层建模:它把题面的物理动作翻译成了一个纯组合问题。 再沿着"最后一次聚合"这一条分支往下走,每一步都只用到上一步的结论: 分离性给出状态定义,状态定义给出递推式,递推式加上"环拆链复制一倍"给出答案位置。 图中只保留这三层关系,样例的具体数值推演见前文的聚合过程表与 f(4,7)f(4,7) 候选表。