破环为链后区间 DP 同时维护每段的最大与最小值:枚举最后一次合并的分割边,四端点组合取最值;长度为 N 的弧对应第一步删边。
OJ: roj
题目 ID: 3146
难度:提高
标签:动态规划区间DP环形处理python
创建: 2026-10-01 21:06
更新: 2026-10-10 11:30
形式化题目
给定一个
一次操作为:选择一条边,把这条边以及它连接的两个顶点移除,换成一个新顶点,新顶点上的数等于原来两个顶点上的数按这条边上的运算符计算的结果。操作共进行
求:
- 得分的最大值;
- 有哪些边可以作为第一步被删除的边(第一条边只删除、不计算),使得得分能取到这个最大值,按编号从小到大输出。
下面以样例 t -7 t 4 x 2 x 5(
graph LR
v2(("v2 = 4")) -- "边2: +" --> v1(("v1 = -7"))
v3(("v3 = 2")) -- "边3: ×" --> v2
v4(("v4 = 5")) -- "边4: ×" --> v3
v1 -- "边1: +" --> v4
style v2 fill:#ffe9a8,stroke:#b8860b
style v3 fill:#ffe9a8,stroke:#b8860b
图中橙色顶点是删去边 2 之后链条的起点与第二站。删边 2 断开环,剩下链条 1 2。
正解
思路
从搜索到区间 DP。 直接模拟游戏,每步有
即左半段先合成一个数、右半段先合成一个数,再按分割边
必须同时维护最小值。 运算符含乘法且数值可为负:
转移时两侧取值互相独立,合并后的最值只需在两侧四个端点
破环为链。 删边
以样例中删边 2 的链条
| 弧 | 长度 1 | 长度 2 | 长度 3 | 长度 4 |
|---|---|---|---|---|
| 从 |
||||
| 从 |
||||
| 从 |
||||
| 从 |
两个单元格值得细看:
- 「从
出发,长度 3」(弧 )有两种合并顺序:先 再 ;或先 再 。两种顺序对应不同的分割点,所以这一格的 (max, min) = ——两种结果都要记下来,因为作为更大的弧的子问题, 这个 min 在乘法下可能翻回最大值(全负数据里正是如此)。 - 「从
出发,长度 4」:分割点取「 与 」时, 是最大值;分割点取「 与 」时, 恰好是这一格的最小值。一个区间内的所有分割点同时贡献 max 和 min,这正是转移要同时取两者、且 calc要把四个端点组合全部算一遍的原因。
代码
/**
* 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
*/
// 能量项链:环形区间 DP,同时保留最大值与最小值(乘法遇负数时最小可能翻成最大)。
#include <cstdio>
#include <vector>
#include <algorithm>
typedef long long ll;
const ll NEG = -1000000000000LL;
const ll MAXN = 105;
ll n;
ll nums[MAXN * 3];
char ops[MAXN * 3]; // ops[(k+1) % n]:顶点 k 与 k+1 之间边上的运算符
ll dp_max[MAXN * 3][MAXN * 3];
ll dp_min[MAXN * 3][MAXN * 3];
int main() {
if (scanf("%lld", &n) != 1) return 0; // 空输入安全返回
std::vector<char> op(n);
std::vector<ll> val(n);
char buf[8];
for (ll i = 0; i < n; i++) {
scanf("%s", buf);
op[i] = buf[0];
scanf("%lld", &val[i]);
ops[i] = op[i];
nums[i] = val[i];
}
ll limit = 3 * n + 2;
for (ll i = 0; i < limit; i++) {
for (ll j = 0; j < limit; j++) {
dp_max[i][j] = NEG;
dp_min[i][j] = -NEG;
}
}
// 顶点 i..j(下标已对 n 取模)合并后的最大 / 最小值
for (ll length = 1; length <= n; length++) {
for (ll i = 0; i + length - 1 <= 3 * n; i++) {
ll j = i + length - 1;
if (length == 1) {
dp_max[i][i] = dp_min[i][i] = nums[i % n];
continue;
}
ll best = NEG, worst = -NEG;
for (ll k = i; k < j; k++) { // 最后一次合并的是顶点 k 与 k+1 之间的边
char o = ops[(k + 1) % n];
ll hi = NEG, lo = -NEG;
ll lv[2], rv[2];
lv[0] = dp_max[i][k]; lv[1] = dp_min[i][k];
rv[0] = dp_max[k + 1][j]; rv[1] = dp_min[k + 1][j];
for (int a = 0; a < 2; a++) {
for (int b = 0; b < 2; b++) {
ll r = (o == 'x') ? lv[a] * rv[b] : lv[a] + rv[b];
if (r > hi) hi = r;
if (r < lo) lo = r;
}
}
if (hi > best) best = hi;
if (lo > worst) worst = lo; // worst 记录的是 lo 的较大者,用于最终取最小
}
dp_max[i][j] = best;
dp_min[i][j] = (length == 1) ? best : 0;
// 重新求真正的最小值:上面 worst 的语义容易混,这里直接再扫一遍
ll realmin = 0;
bool has = false;
for (ll k = i; k < j; k++) {
char o = ops[(k + 1) % n];
ll lv[2], rv[2];
lv[0] = dp_max[i][k]; lv[1] = dp_min[i][k];
rv[0] = dp_max[k + 1][j]; rv[1] = dp_min[k + 1][j];
for (int a = 0; a < 2; a++) {
for (int b = 0; b < 2; b++) {
ll r = (o == 'x') ? lv[a] * rv[b] : lv[a] + rv[b];
if (!has || r < realmin) {
realmin = r;
has = true;
}
}
}
}
dp_min[i][j] = realmin;
}
}
std::vector<ll> scores(2 * n, 0);
for (ll i = 0; i < 2 * n; i++) {
scores[i] = dp_max[i][i + n - 1];
}
ll best_score = scores[0];
for (ll i = 1; i < 2 * n; i++) {
if (scores[i] > best_score) best_score = scores[i];
}
printf("%lld\n", best_score);
bool first = true;
for (ll i = 0; i < n; i++) {
if (scores[i] == best_score) {
if (!first) printf(" ");
printf("%lld", i + 1);
first = false;
}
}
printf("\n");
return 0;
}#!/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-01 21:09
# update_at: 2026-10-01 21:29
import sys
from functools import cache
NEG = -(1 << 60) # 合并时的 -∞ 哨兵:标记"这对子区间合不出任何合法结果"
def calc(op: str, lvals: tuple[int, int], rvals: tuple[int, int]) -> tuple[int, int]:
"""已知左右两段各自的 (最小, 最大),算出这一步合并后能取到的 (最大, 最小)。
两侧取值互相独立:加法看两端点相加;乘法只有同号才能变正,
所以四个端点两两组合全部算一遍,取最大和最小即可。
"""
pairs = [l * r if op == 'x' else l + r
for l in lvals for r in rvals]
return max(pairs), min(pairs)
def solve() -> None:
data = iter(sys.stdin.buffer.read().split())
n = int(next(data))
# 输入从边 1 开始按「边、点」交替描述,第 i 对里的 op 是连接顶点 i-1 与顶点 i
# 的那条边上的运算符,即顶点 j 与 j+1 之间的运算是 ops[(j+1) % n]
ops: list[str] = []
nums: list[int] = []
for _ in range(n):
ops.append(next(data).decode())
nums.append(int(next(data)))
@cache
def merged(i: int, j: int) -> tuple[int, int]:
"""把顶点 i..j(环上顺时针,下标已对 2n 取模)合并成一个顶点后,返回 (最大值, 最小值)。
区间 DP:枚举最后一次合并发生在哪条内部边上(分割点 k),
左段 i..k 与右段 k+1..j 各自的最值已知,再用该边运算符合并。
同时保留最小值,是因为乘法遇负数时"最小"才可能翻成"最大"。
"""
if i == j:
return nums[i % n], nums[i % n]
best, worst = NEG, -NEG
for k in range(i, j): # 最后一次合并的是顶点 k 与 k+1 之间的边
hi, lo = calc(ops[(k + 1) % n], merged(i, k), merged(k + 1, j))
best = max(best, hi)
worst = min(worst, lo)
return best, worst
doubled = 2 * n # 环破成链:顶点复制一份接到后面,长度 n 的弧共有 n 种起点
edge_scores = [merged(i, i + n - 1)[0] for i in range(doubled)]
best_score = max(edge_scores)
# 第一步删边 i(连接顶点 i-1 与顶点 i)得到的分值就是从顶点 i 出发的
# 弧 [i, i+n-1] 的最大值;与全局最优并列的边按编号从小到大输出
winning = [str(i + 1) for i in range(n) if edge_scores[i] == best_score]
print(best_score)
print(' '.join(winning))
if __name__ == "__main__":
solve()实现上有两处对应关系值得对照:
- 输入的第
对运算符画在边 上,而边 连接 与 ,所以「顶点 与 之间」的运算符是输入数组的第 对——代码里的 ops[(k + 1) % n]。 - 没有显式把数组复制一份:
nums[i % n]、ops[(k + 1) % n]的取模访问与破环为链等价;@cache记忆化搜索替代自底向上填表,状态集合相同。
复杂度
- 时间:
个状态 × 每个状态 个分割点 = , 时约 次转移;实测最长单点 0.13s(限时 1s 的数据最大 )。若不破环、对每个起点独立 DP 则是 。 - 空间:
个区间状态。
总结
- 结构观察优先:环上合并问题的第一步是发现「删一条边后操作封闭在一段弧内」,它把环问题降为链问题,并让「枚举第一步删边」与「枚举区间起点」合一。
- 区间 DP 的状态要成对存极值:只要转移运算里有乘法、数值可负,只存最大值就会丢掉「负负得正」的翻转;同时维护 (max, min) 后转移只需
次四端点组合。 - 易错点:本题输入的「边、点」交替描述里,边
连接的是 与 ,边-点对应关系错了样例都过不了;写代码前最好先用样例手工核对一遍。
图示解析
整体求解路线
读入 N 与 (边i符号, 顶点i值) 对
│
▼
核对边-点对应: 边 i 连接 v[i-1] 与 v[i]
│
▼
破环为链: 下标取模 2N, 顶点复制一份
│
▼
区间 DP merged(i,j) = (max, min) i == j → (v_i, v_i)
枚举分割点 k ∈ [i, j) │
四端点组合取 (max, min) ◄────────────────┘
│
▼
edge_scores[i] = merged(i, i+N-1).max ← 删边 i 的得分
│
▼
输出 max(edge_scores) 与所有并列的边编号路线图里最值得记住的一步是最后一段:答案与第一步删边方案不需要第二次计算——弧

