连续分段 DP 展开后每个切点 j 变成一条直线 l_j(u)=S_j·u+Y_j,a<0 与 x_i≥1 使查询点与斜率双单调,单调队列维护上包络把 O(n²) 降到 O(n)。
OJ: roj
题目 ID: 1612
难度:提高+/省选-
标签:动态规划斜率优化凸包优化单调队列前缀和python一本通
创建: 2026-09-30 21:52
更新: 2026-10-07 13:50
形式化题目
给定序列
选择一组下标
最大,输出这个最大值。
约束:
正解
思路
第一步:写出划分 DP。
"同一支特别行动队编号连续"这句话把问题完全定死了:任何编队方案就是序列的一个划分,一支队伍对应一段连续区间。于是设
答案就是
以样例
| 候选 |
最优 |
|||
|---|---|---|---|---|
| 1 | 2 | -4 | 0 | |
| 2 | 4 | 4 | 0 | |
| 3 | 7 | 5 | 2 | |
| 4 | 11 | 9 | 3 |
读表可以看出
第二步:瓶颈在哪里。 (1) 式状态数
第三步:把转移线性化。 展开
最后那个括号与
得到等价的转移:
第四步:看成"直线族的上包络"。 每个候选
第五步:双单调性让上包络退化成单调队列。 两条单调性在本数据范围内同时成立:
- 新直线的斜率单调增:
使 随 严格递增。上包络上直线的斜率从左到右递增,所以新直线只会接在包络的右端; - 查询点单调增:
且 递增 严格递增。查询点只会向右走,最优直线在包络上也只会越来越靠右。
于是双端队列(单调队列)就能维护整条上包络:
- 查询:设队头为
、次队头为 。若在 处 已经不劣于 ,由于后续要用的查询点只会更大, 不可能再赢,直接弹出;反复执行直到队头严格最优; - 插入:新直线
入队前,检查队尾两条直线 。设 是 的交点横坐标( )。上包络要求交点位置递增: 。若 ,说明 还没等到超过 ,就已经被 超过,它的领先区间为空,不再出现在包络上,弹出;反复执行后把 接到队尾; - 判定
时用整数交叉相乘代替除法(两边分母 、 均正,不等号不翻转): 代码里的slope_pq >= slope_qi正是左边这个不等式(取等号时三线共点,丢中间那条不影响答案);整数运算在 Python 中没有精度问题。
下图把样例的单调队列执行过程逐步列出来(
| 查询用 |
查询时弹出队头 | 队头(最优 |
插入前弹出队尾 | 入队后上包络(各直线的 |
|
|---|---|---|---|---|---|
| 1 | -4 | 无 | 无 | (0,0) → (2,-28) | |
| 2 | -8 | 无 | (0,0) → (4,-52) | ||
| 3 | -14 | 无 | (4,-52) → (7,-114) | ||
| 4 | -22 | 无 | (7,-114) → (11,-222) |
表中括号里的两个数是直线的参数
代码
max_power() 就是上面第四、五步的实现:hull 存直线编号,第一个 while 是查询前弹队头,第二个 while 是插入前弹队尾,y[i] 就是截距 k = 2*a*s 是 solve() 只负责读入、算前缀和、输出;pre、f、y 用 array('q') 存放(
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-09-30 21:53
# update_at: 2026-09-30 22:05
import sys
from array import array
from collections import deque
from itertools import accumulate
def max_power(a: int, b: int, c: int, pre: array) -> int:
"""前 n 个士兵编队的最大修正战斗力和(斜率优化,O(n))。
设 f[i] 为前 i 人的答案,最后一队是 (j, i],则
f[i] = max_j { f[j] + g(S[i] - S[j]) }, g(u) = a·u² + b·u + c。
展开 g,把只与 j 有关的项收成 Y[j] = f[j] + a·S[j]² - b·S[j]:
f[i] = max_j { Y[j] + u[i]·S[j] } + a·S[i]² + b·S[i] + c, u[i] = -2a·S[i]。
每个候选切点 j 于是是一条直线 l_j(u) = S[j]·u + Y[j](斜率 S[j]、截距 Y[j]),
转移变成"在直线族的上包络上查询 u = u[i] 处的最大值"。
a < 0 ⇒ 查询点 u[i] 随 i 递增;x[i] ≥ 1 ⇒ 直线斜率 S[j] 随 j 递增。
新直线只接在包络右端、查询点又只向右走,两条单调 ⇒ 单调队列:
队头弹出已被超过的直线,队尾弹出被新直线压得不再可能是最优的直线;
每条直线进出队各一次,摊还 O(1)。
"""
n = len(pre) - 1
f = array('q', [0]) * (n + 1) # f[i]:前 i 人的最优值,f[0] = 0
y = array('q', [0]) * (n + 1) # 直线截距 Y[i] = f[i] + a·S[i]² - b·S[i]
hull = deque([0]) # 上包络上的直线编号,斜率 S[j] 随入队递增
for i in range(1, n + 1):
s = pre[i]
k = 2 * a * s # k = -u[i]:两边同乘 -1,把 max l_j(u) 写成 max(Y[j] - k·S[j])
while len(hull) >= 2:
head, second = hull[0], hull[1]
if y[head] - k * pre[head] <= y[second] - k * pre[second]:
hull.popleft() # 次队头已不比队头差,u[i] 只会更大,队头此后再也用不到
else:
break
j = hull[0] # 最优直线:最后一段的左边界
f[i] = f[j] + a * (s - pre[j]) ** 2 + b * (s - pre[j]) + c
y[i] = f[i] + a * s * s - b * s
while len(hull) >= 2:
p, q = hull[-2], hull[-1]
# 判据即交点位置 x[p][q] ≥ x[q][i]:直线 q 的领先区间为空,可丢;交叉相乘免去除法
slope_pq = (y[q] - y[p]) * (s - pre[q])
slope_qi = (y[i] - y[q]) * (pre[q] - pre[p])
if slope_pq <= slope_qi:
hull.pop() # q 被直线 p 与 i 夹在包络之下,不再出现在上包络上
else:
break
hull.append(i)
return f[n]
def solve() -> None:
data = iter(map(int, sys.stdin.buffer.read().split()))
n = next(data) # 士兵数
a, b, c = next(data), next(data), next(data) # 经验公式系数
pre = array('q', accumulate((next(data) for _ in range(n)), initial=0)) # S[0..n]
print(max_power(a, b, c, pre))
if __name__ == "__main__":
solve()C++ 版(同一算法):
/**
* 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-05 12:53
* update_at: 2026-10-05 12:53
*/
// main.cpp:一本通 5.6 练习 3「特别行动队」(APIO 2010 特别行动队)。
// 编号必须连续,所以任何编队方案就是把序列切成若干连续段的划分,
// 设 f[i] 为前 i 名士兵的最大修正战斗力和,枚举最后一支队伍的起点 j:
// f[i] = max_{j<i} { f[j] + g(S_i - S_j) },g(u) = a*u^2 + b*u + c。
// 展开 g 后,只与 j 有关的项收成截距 Y_j = f[j] + a*S_j^2 - b*S_j,
// 与 i 有关的项提到 max 外面,转移变成「在直线族 l_j(u) = S_j*u + Y_j 的
// 上包络上查询 u_i = -2*a*S_i」:
// f[i] = max_j { S_j*u_i + Y_j } + a*S_i^2 + b*S_i + c。
// x_i >= 1 使直线斜率 S_j 随 j 递增,a < 0 使查询点 u_i 随 i 递增,
// 两条单调同向,于是用单调队列维护上包络即可:队头弹出已被超过的直线,
// 队尾弹出被新直线压得不再可能最优的直线,摊还 O(1),总时间 O(n)。
#include <cstdio>
typedef long long ll;
typedef __int128 lll;
const int MAXN = 1000005; // n 的上限
ll n; // 士兵总数
ll a, b, c; // 经验公式系数,满足 a < 0
ll pre[MAXN]; // pre[i] = 前 i 名士兵的初始战斗力之和 S_i,S_0 = 0
ll f[MAXN]; // f[i] = 前 i 名士兵编队后能得到的最大修正战斗力和
ll y[MAXN]; // y[i] = 直线 l_i 的截距 Y_i = f[i] + a*S_i^2 - b*S_i
int hull[MAXN]; // 上包络上的直线编号队列,斜率 S_j 随入队递增
int head; // 队列头下标,head 指向当前最优直线
int tail; // 队列尾下标
// 快速读入,n 最大 10^6,避免 cin / scanf 的额外开销
ll read_ll() {
int ch = getchar();
while (ch != '-' && (ch < '0' || ch > '9')) {
ch = getchar();
}
ll sign = 1;
if (ch == '-') {
sign = -1;
ch = getchar();
}
ll value = 0;
while (ch >= '0' && ch <= '9') {
value = value * 10 + (ch - '0');
ch = getchar();
}
return sign * value;
}
// 两个数相乘,用 __int128 承接结果:叉积可达 10^25,超出 long long
lll product(lll x, lll y) {
return x * y;
}
// 直线 l_j 在查询点 u 处的取值 l_j(u) = S_j * u + Y_j
lll value_at(int j, ll u) {
return y[j] + product(u, pre[j]);
}
// 直线 q 是否被直线 p 与 i 夹在上包络之下(q 的领先区间为空,可以丢掉)。
// 等价于两交点位置比较 x[p][q] >= x[q][i],即
// (Y_q - Y_p) * (S_i - S_q) <= (Y_i - Y_q) * (S_q - S_p),
// 交叉相乘代替除法,既避免浮点误差也不需要判分母符号。
// 取等号时三线共点,丢掉中间那条不影响答案。
bool is_hidden(int p, int q, int i) {
return product(y[q] - y[p], pre[i] - pre[q])
<= product(y[i] - y[q], pre[q] - pre[p]);
}
void solve() {
n = read_ll();
a = read_ll();
b = read_ll();
c = read_ll();
for (int i = 1; i <= n; i++) {
pre[i] = pre[i - 1] + read_ll(); // 前缀和
}
head = 0;
tail = 0;
hull[0] = 0; // 只有 j = 0 可选,f[0] = 0、S_0 = 0、Y_0 = 0
for (int i = 1; i <= n; i++) {
ll s = pre[i];
ll u = -2 * a * s; // 查询点 u_i = -2*a*S_i,随 i 严格递增
// 插入前先查询:次队头在 u_i 处已不劣于队头,说明队头的领先区间已经过去,
// 而后续查询点只会更大,队头此后永远用不到,直接弹出
while (tail > head) {
int first = hull[head];
int second = hull[head + 1];
if (value_at(first, u) <= value_at(second, u)) {
head++;
} else {
break;
}
}
int j = hull[head]; // 最优切点:最后一支队伍的起点是 j+1
ll len = s - pre[j]; // 最后一支队伍的初始战斗力
f[i] = f[j] + a * len * len + b * len + c;
y[i] = f[i] + a * s * s - b * s; // 新直线的截距
// 新直线斜率 S_i 最大,只会接在包络右端;入队前检查队尾两条直线,
// 若 q 还没超过 p 就被 i 超过,q 的领先区间为空,弹出
while (tail > head) {
int p = hull[tail - 1];
int q = hull[tail];
if (is_hidden(p, q, i)) {
tail--;
} else {
break;
}
}
tail++;
hull[tail] = i;
}
printf("%lld\n", f[n]);
}
int main() {
solve();
return 0;
}复杂度
- 时间复杂度:读入、前缀和、主循环都是
,主循环的两次 while摊还(每条直线最多入队一次、出队一次),总计 ,与标准 C++ 斜率优化解法同阶。 - 空间复杂度:
,即前缀和、 、 三张表和队列; 时实测峰值约 74 MB。 - 数值范围:
可达 量级,叉积比较约 ,Python int任意精度不会溢出(C++ 需要__int128)。 - Python 侧风险:本地 10 组真实数据
用时约 0.9 s,若 OJ 解释器更慢存在 TLE 风险(按任务约定允许)。
总结
- 建模:编号连续 ⇒ 问题就是"把序列切成若干连续段、段费用由段和决定"的划分 DP,
。 - 线性化:展开
后,只与 有关的项收成截距 ,与 有关的项提到 外,转移变成"在直线族 的上包络上查询 "。 - 上包络:只有上包络上的直线可能成为最大值,被压在下方的直线可以永久丢弃。
- 双单调:
使查询点 单调增, 使直线斜率 单调增,两者同向 ⇒ 双端队列摊还 维护上包络,总复杂度 。 - 易错点:交点判据必须用交叉相乘(浮点除法会在大数下失准);插入用
<=弹出可顺带处理三线共点;严格递增是"分母恒正、不等号不翻转"的前提,若 可能为 0 需要改写。
图示解析
从输入到答案的完整推导链路,每一步只做一件事:
flowchart LR A["输入 n, a, b, c, x"] --> B["前缀和 S_i"] B --> C["划分 DP<br/>f[i] = max_j f[j]+g(S_i-S_j)"] C --> D["展开 g<br/>切点 j 变成直线 l_j(u)=S_j·u+Y_j"] D --> E["在 u_i = -2aS_i 处查询<br/>直线族的上包络"] E --> F["双单调性<br/>u_i 递增、直线斜率 S_j 递增"] F --> G["单调队列维护上包络<br/>队头弹出 + 队尾弹出"] G --> H["f[n] 即答案"]
观察重点:C→D 是分水岭,它把"枚举切点"换成"在直线族的上包络上查询";D→E→F 说明为什么可以只用双端队列而不是三分查找或李超树——查询点与直线斜率同向单调,最优直线只向右移,队头可以直接弹掉;G 的两个 while 分别对应"旧直线过期"与"新直线让旧直线失去包络地位"。