P1063 [NOIP 2006 提高组] 能量项链
题目描述
在 Mars 星球上,每个 Mars 人都随身佩带着一串能量项链。在项链上有 N 颗能量珠。能量珠是一颗有头标记与尾标记的珠子,这些标记对应着某个正整数。并且,对于相邻的两颗珠子,前一颗珠子的尾标记一定等于后一颗珠子的头标记。因为只有这样,通过吸盘(吸盘是 Mars 人吸收能量的一种器官)的作用,这两颗珠子才能聚合成一颗珠子,同时释放出可以被吸盘吸收的能量。如果前一颗能量珠的头标记为 m,尾标记为 r,后一颗能量珠的头标记为 r,尾标记为 n,则聚合后释放的能量为 m×r×n(Mars 单位),新产生的珠子的头标记为 m,尾标记为 n。
需要时,Mars 人就用吸盘夹住相邻的两颗珠子,通过聚合得到能量,直到项链上只剩下一颗珠子为止。显然,不同的聚合顺序得到的总能量是不同的,请你设计一个聚合顺序,使一串项链释放出的总能量最大。
例如:设 N=4,4 颗珠子的头标记与尾标记依次为 (2,3)(3,5)(5,10)(10,2)。我们用记号 ⊕ 表示两颗珠子的聚合操作,(j⊕k) 表示第 j,k 两颗珠子聚合后所释放的能量。则第 4,1 两颗珠子聚合后释放的能量为:
(4⊕1)=10×2×3=60。
这一串项链可以得到最优值的一个聚合顺序所释放的总能量为:
(((4⊕1)⊕2)⊕3)=10×2×3+10×3×5+10×5×10=710。
输入格式
第一行是一个正整数 N(4≤N≤100),表示项链上珠子的个数。第二行是 N 个用空格隔开的正整数,所有的数均不超过 1000。第 i 个数为第 i 颗珠子的头标记(1≤i≤N),当 i<N 时,第 i 颗珠子的尾标记应该等于第 i+1 颗珠子的头标记。第 N 颗珠子的尾标记应该等于第 1 颗珠子的头标记。
至于珠子的顺序,你可以这样确定:将项链放到桌面上,不要出现交叉,随意指定第一颗珠子,然后按顺时针方向确定其他珠子的顺序。
输出格式
一个正整数 E(E≤2.1×109),为一个最优聚合顺序所释放的总能量。
输入输出样例
输入 #1复制
4 2 3 5 10
输出 #1复制
710
说明/提示
NOIP 2006 提高组 第一题
题目分析
能量项链问题是一个经典的区间动态规划问题。题目描述有N颗能量珠,每颗珠子有头标记和尾标记,相邻两颗珠子前一颗的尾标记等于后一颗的头标记。通过合并相邻珠子释放能量,求最大能量值。
问题特点
环形结构:项链是环形的,需要处理环形数组
区间合并:每次合并相邻的珠子,释放能量
最优子结构:整体最优解包含子问题最优解
重叠子问题:相同的子问题会被多次计算
算法思路
环形处理技巧
将环形问题转化为线性问题:将原数组复制一份接在后面,然后在线性数组上求解长度为N的区间的最优值。
动态规划定义
设dp[i][j]表示从第i颗珠子到第j颗珠子合并后释放的最大能量值。
状态转移方程
对于区间[i,j],枚举分割点k(i ≤ k < j):
dp[i][j] = max(dp[i][j], dp[i][k] + dp[k+1][j] + energy(i,k,j))
其中energy(i,k,j) = head[i] * tail[k] * tail[j]
数学推导
能量计算公式
当合并区间[i,k]和[k+1,j]时,释放的能量为:
头标记 × 中间标记 × 尾标记 = head[i] × tail[k] × tail[j]
区间长度递推
从小区间开始计算,逐步扩大区间长度:
区间长度len从2到N
对于每个长度,枚举所有起点i
对于每个区间[i,j],枚举所有分割点k
代码实现
cpp
#include <iostream>
#include <algorithm>
#include <cstring>
using namespace std;const int MAXN = 210; // 因为环形扩展,大小翻倍int main() {int n;cin >> n;int bead[MAXN]; // 存储珠子标记int dp[MAXN][MAXN] = {0}; // dp数组,初始化为0// 读入珠子标记for (int i = 1; i <= n; i++) {cin >> bead[i];bead[i + n] = bead[i]; // 环形处理:复制一份}int ans = 0; // 最大能量结果// 区间动态规划// len表示区间长度,从2开始(至少2颗珠子才能合并)for (int len = 2; len <= n; len++) {// i表示区间起点for (int i = 1; i <= 2 * n - len + 1; i++) {// j表示区间终点int j = i + len - 1;// 枚举分割点k,k从i到j-1for (int k = i; k < j; k++) {// 状态转移方程:// dp[i][j] = max(dp[i][j], dp[i][k] + dp[k+1][j] + 能量值)// 能量值 = 头标记 * 中间标记 * 尾标记int energy = bead[i] * bead[k + 1] * bead[j + 1];dp[i][j] = max(dp[i][j], dp[i][k] + dp[k + 1][j] + energy);}}}// 在环形数组中寻找最大值for (int i = 1; i <= n; i++) {ans = max(ans, dp[i][i + n - 1]);}cout << ans << endl;return 0;
}
代码详细解析
头文件说明
cpp
#include <iostream> // 输入输出流
#include <algorithm> // 包含max函数
#include <cstring> // 字符串操作,用于初始化数组
常量定义和变量声明
cpp
const int MAXN = 210; // 定义最大数组大小int main() {int n;cin >> n;int bead[MAXN]; // 存储珠子标记的数组int dp[MAXN][MAXN] = {0}; // 动态规划数组
MAXN设为210是因为环形处理需要将原数组复制一份,所以最大需要2n的空间。bead数组存储每个珠子的标记,dp数组用于存储子问题的解。
输入处理和环形扩展
cpp
// 读入珠子标记
for (int i = 1; i <= n; i++) {cin >> bead[i];bead[i + n] = bead[i]; // 环形处理:复制一份
}
这里的关键技巧是环形处理。由于项链是环形的,我们将原数组复制一份接在后面,这样任何长度为n的连续区间都对应原环形项链的一个起点。
珠子标记存储方式说明
在题目中,每颗珠子有头标记和尾标记,相邻珠子满足前一颗的尾标记等于后一颗的头标记。因此我们可以用一维数组存储:
bead[i] 表示第i颗珠子的头标记
bead[i+1] 表示第i颗珠子的尾标记(也是第i+1颗珠子的头标记)
动态规划核心逻辑
cpp
// 区间动态规划
// len表示区间长度,从2开始(至少2颗珠子才能合并)
for (int len = 2; len <= n; len++) {
区间长度从2开始递增,这是因为单颗珠子无法合并释放能量。这种从小到大的计算顺序确保了在计算大区间时,其包含的小区间已经计算完成。
cpp
// i表示区间起点for (int i = 1; i <= 2 * n - len + 1; i++) {// j表示区间终点int j = i + len - 1;
i是区间起点,j是区间终点。由于数组长度是2n,所以i的取值范围要保证j不超过2n。
cpp
// 枚举分割点k,k从i到j-1for (int k = i; k < j; k++) {// 状态转移方程:// dp[i][j] = max(dp[i][j], dp[i][k] + dp[k+1][j] + 能量值)// 能量值 = 头标记 * 中间标记 * 尾标记int energy = bead[i] * bead[k + 1] * bead[j + 1];dp[i][j] = max(dp[i][j], dp[i][k] + dp[k + 1][j] + energy);}}
}
这是动态规划的核心部分。对于每个区间[i,j],我们枚举所有可能的分割点k,将区间分为[i,k]和[k+1,j]两部分。
能量计算原理:
bead[i]:合并后整个区间的头标记(第一颗珠子的头标记)
bead[k+1]:分割点处珠子的尾标记,也是左右两部分合并时的中间标记
bead[j+1]:合并后整个区间的尾标记(最后一颗珠子的尾标记)
状态转移意义:
dp[i][k]表示左半部分[i,k]的最大能量
dp[k+1][j]表示右半部分[k+1,j]的最大能量
energy表示左右两部分合并时释放的能量
寻找最终结果
cpp
// 在环形数组中寻找最大值
for (int i = 1; i <= n; i++) {ans = max(ans, dp[i][i + n - 1]);
}cout << ans << endl;
由于我们进行了环形扩展,原环形项链的所有可能起点都对应扩展后数组中长度为n的某个区间。我们遍历所有这样的区间,取最大值作为最终结果。
算法优化分析
时间复杂度分析
外层循环:区间长度len从2到n,循环n-1次
中层循环:起点i,最多循环2n次
内层循环:分割点k,最多循环n次
总时间复杂度为O(n^3),对于n ≤ 100的数据规模完全可行。
空间复杂度分析
使用二维数组dp[MAXN][MAXN],空间复杂度为O(n^2)。
算法正确性证明
基础情况:当区间长度为2时,只有一种合并方式,计算正确
归纳假设:假设所有长度小于L的区间都已正确计算
归纳步骤:对于长度为L的区间,枚举所有分割点,取最大值,保证正确性
完整优化版本
下面是更加完整和优化的代码版本:
cpp
#include <iostream>
#include <algorithm>
#include <cstring>
using namespace std;const int MAXN = 210;int main() {int n;cin >> n;int bead[MAXN];int dp[MAXN][MAXN];// 初始化dp数组为0memset(dp, 0, sizeof(dp));// 读入数据并环形扩展for (int i = 1; i <= n; i++) {cin >> bead[i];bead[i + n] = bead[i];}int max_energy = 0;// 动态规划:按区间长度递增计算for (int length = 2; length <= n; length++) {for (int start = 1; start <= 2 * n - length + 1; start++) {int end = start + length - 1;// 枚举所有可能的分割点for (int split = start; split < end; split++) {// 计算合并能量int energy = bead[start] * bead[split + 1] * bead[end + 1];// 更新dp值dp[start][end] = max(dp[start][end], dp[start][split] + dp[split + 1][end] + energy);}}}// 寻找所有可能起点的最大值for (int i = 1; i <= n; i++) {max_energy = max(max_energy, dp[i][i + n - 1]);}cout << max_energy << endl;return 0;
}
测试样例分析
样例1
输入:
text
4 2 3 5 10
输出:
text
710
详细解释:
珠子标记序列:2, 3, 5, 10
对应的珠子为:
珠子1:头=2, 尾=3
珠子2:头=3, 尾=5
珠子3:头=5, 尾=10
珠子4:头=10, 尾=2
最优合并顺序:((4⊕1)⊕2)⊕3)
计算过程:
合并珠子4和1:10×2×3 = 60
合并结果和珠子2:10×3×5 = 150
合并结果和珠子3:10×5×10 = 500
总能量:60 + 150 + 500 = 710
样例2
输入:
text
3 1 2 3
输出:
text
15
详细解释:
珠子标记序列:1, 2, 3
对应的珠子为:
珠子1:头=1, 尾=2
珠子2:头=2, 尾=3
珠子3:头=3, 尾=1
两种合并顺序:
先合并前两个:(1×2×3) + (1×3×3) = 6 + 9 = 15
先合并后两个:(2×3×1) + (1×2×1) = 6 + 2 = 8
最大值为15
常见问题解答
1. 为什么需要环形扩展?
因为项链是环形的,没有明确的起点和终点。通过环形扩展,我们将环形问题转化为线性问题,可以处理所有可能的起点情况。
2. 为什么区间长度从2开始?
因为单颗珠子无法合并释放能量,至少需要两颗珠子才能进行合并操作。
3. 能量计算公式为什么是bead[i] × bead[k+1] × bead[j+1]?
bead[i]:合并后整个区间的头标记(第一颗珠子的头标记)
bead[k+1]:分割点处珠子的尾标记,也是合并时的中间标记
bead[j+1]:合并后整个区间的尾标记(最后一颗珠子的尾标记)
4. 如何理解状态转移方程?
dp[i][j]表示将区间[i,j]内的珠子合并的最大能量。我们枚举所有可能的分割点k,将区间分为[i,k]和[k+1,j]两部分,分别计算这两部分的最大能量,加上合并时释放的能量。
5. 为什么最终结果要在所有长度为n的区间中取最大值?
因为环形扩展后,原环形项链的每个可能起点都对应扩展数组中的一个长度为n的区间。我们需要找到能量释放最大的那种合并方式。
6. 数组下标为什么从1开始?
从1开始可以更方便地处理边界情况,比如bead[j+1]不会越界。
算法扩展
记忆化搜索版本
除了递推的动态规划,还可以使用记忆化搜索(递归+记忆化):
cpp
#include <iostream>
#include <algorithm>
#include <cstring>
using namespace std;const int MAXN = 210;int bead[MAXN];
int dp[MAXN][MAXN];
int n;int dfs(int l, int r) {if (dp[l][r] != -1) return dp[l][r];if (l == r) return 0;int res = 0;for (int k = l; k < r; k++) {int energy = bead[l] * bead[k + 1] * bead[r + 1];res = max(res, dfs(l, k) + dfs(k + 1, r) + energy);}return dp[l][r] = res;
}int main() {cin >> n;for (int i = 1; i <= n; i++) {cin >> bead[i];bead[i + n] = bead[i];}memset(dp, -1, sizeof(dp));int ans = 0;for (int i = 1; i <= n; i++) {ans = max(ans, dfs(i, i + n - 1));}cout << ans << endl;return 0;
}
输出具体合并方案
如果需要输出具体的合并顺序,可以添加路径记录:
cpp
#include <iostream>
#include <algorithm>
#include <cstring>
#include <vector>
using namespace std;const int MAXN = 210;int bead[MAXN];
int dp[MAXN][MAXN];
int path[MAXN][MAXN]; // 记录分割点void print_path(int l, int r) {if (l == r) {cout << l;return;}cout << "(";print_path(l, path[l][r]);cout << "⊕";print_path(path[l][r] + 1, r);cout << ")";
}int main() {int n;cin >> n;memset(dp, 0, sizeof(dp));for (int i = 1; i <= n; i++) {cin >> bead[i];bead[i + n] = bead[i];}int ans = 0, best_start = 1;for (int len = 2; len <= n; len++) {for (int i = 1; i <= 2 * n - len + 1; i++) {int j = i + len - 1;for (int k = i; k < j; k++) {int energy = bead[i] * bead[k + 1] * bead[j + 1];if (dp[i][j] < dp[i][k] + dp[k + 1][j] + energy) {dp[i][j] = dp[i][k] + dp[k + 1][j] + energy;path[i][j] = k;}}}}for (int i = 1; i <= n; i++) {if (ans < dp[i][i + n - 1]) {ans = dp[i][i + n - 1];best_start = i;}}cout << ans << endl;// print_path(best_start, best_start + n - 1); // 输出合并顺序return 0;
}
总结
能量项链问题是一个典型的区间动态规划问题,主要考察点包括:
环形处理技巧:通过数组复制将环形转化为线性
区间DP设计:定义合适的状态和状态转移方程
最优子结构:大问题的最优解包含小问题的最优解
计算顺序:按区间长度从小到大计算
关键思路:
将环形问题通过复制数组转化为线性问题
使用dp[i][j]表示区间[i,j]的最大能量
枚举分割点,将问题分解为子问题
能量计算遵循头×中×尾的规则
算法特点:
时间复杂度:O(n^3)
空间复杂度:O(n^2)
适用场景:环形区间合并问题
掌握这个问题的解法对于理解区间动态规划和环形问题处理具有重要意义。这种解题思路可以推广到其他类似的区间DP问题,如矩阵连乘、石子合并等问题。