最优 B 只需取 A 中出现过的数,按末位台阶做前缀最小值优化的线性 DP,正反两个方向各跑一次取最小。
OJ: roj
题目 ID: 3137
难度:普及+/提高-
标签:动态规划线性DP离散化python
创建: 2026-10-01 19:44
更新: 2026-10-10 11:30
形式化题目
给定整数序列
其中
正解
思路
先说结论:存在一组最优解,使每个
第一步:为什么候选取值只需来自
先把
则
归纳证明「每个
是常数函数,没有折点。 - 设
凸、折点 。 就是把 极小点右侧的部分压平: 凸,所以它在某个极小点 左边单调不增、右边单调不减,于是 (当 )、 (当 )。压平后仍然凸,折点集合不增。 :两个凸分段线性函数之和仍凸,折点是二者折点之并再并入 处的新折点,而 ,归纳成立。
于是
去重排序后记
第二步:按末位台阶做线性 DP
约束只存在于相邻两个位置之间,所以决定
因为要求非降,
关键在于
这一段在 main.py 里就是 level_cost() 循环体的那一行:accumulate(dp, min) 给出旧表的前缀最小,map(add, (abs(a - v) for v in values), ...) 把 min(dp) 就是该方向的答案。
第三步:两个方向
非升只是非降的镜像:反转序列后非升约束变成非降约束,而代价
先验证一下方向确实都要算:样例
样例的 DP 表推演
对样例取 dp,列是末位取的台阶;加粗的是该行的最小值(初值行全为
| 已处理 | |||||||
|---|---|---|---|---|---|---|---|
| (无) | — | 0 | 0 | 0 | 0 | 0 | 0 |
| 1 | 0 | 1 | 2 | 3 | 4 | 8 | |
| 3 | 2 | 1 | 0 | 1 | 2 | 6 | |
| 2 | 3 | 1 | 1 | 2 | 3 | 7 | |
| 4 | 6 | 3 | 2 | 1 | 2 | 6 | |
| 5 | 10 | 6 | 4 | 2 | 1 | 5 | |
| 3 | 12 | 7 | 4 | 3 | 3 | 7 | |
| 9 | 20 | 14 | 10 | 8 | 7 | 3 |
读表要点:每一格 = 同列
代码
/**
* 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 <cstdio>
#include <vector>
#include <algorithm>
typedef long long ll;
// 把 seq 改成非降序列的最小代价,values 是允许使用的台阶高度(升序去重)
ll level_cost(std::vector<ll>& seq, std::vector<ll>& values) {
ll m = (ll)values.size();
std::vector<ll> dp(m, 0); // 未处理任何元素时取任何结尾都不花钱
std::vector<ll> ndp(m, 0);
for (size_t t = 0; t < seq.size(); t++) {
ll a = seq[t];
ll running = dp[0]; // 结尾不超过 values[j] 的最优前缀
for (ll j = 0; j < m; j++) {
if (dp[j] < running) running = dp[j];
ll d = a - values[j];
if (d < 0) d = -d;
ndp[j] = d + running;
}
dp = ndp;
}
ll best = dp[0];
for (ll j = 1; j < m; j++) {
if (dp[j] < best) best = dp[j];
}
return best;
}
int main() {
ll n;
if (scanf("%lld", &n) != 1) return 0; // 空输入安全返回
std::vector<ll> seq(n);
for (ll i = 0; i < n; i++) {
scanf("%lld", &seq[i]);
}
std::vector<ll> values = seq;
std::sort(values.begin(), values.end());
values.erase(std::unique(values.begin(), values.end()), values.end());
ll best_nondecreasing = level_cost(seq, values);
std::vector<ll> rev(seq.rbegin(), seq.rend()); // 倒过来做非降 = 原序列非升
ll best_nonincreasing = level_cost(rev, values);
printf("%lld\n", best_nondecreasing < best_nonincreasing ? best_nondecreasing : best_nonincreasing);
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 19:44
# update_at: 2026-10-01 19:49
import sys
from itertools import accumulate
from operator import add
def level_cost(seq: list[int], values: list[int]) -> int:
"""把 seq 改成非降序列的最小代价,values 是允许使用的台阶高度(升序、已去重)。
dp[j] = 处理完已扫过的前缀、且最后一个数恰好落在 values[j] 时的最小代价。
扫描值域做前缀最小即可得到"结尾不超过 values[j] 的最优前缀",无需 O(m) 内层循环。
"""
dp = [0] * len(values) # 未处理任何元素时取任何结尾都不花钱
for a in seq:
# accumulate(dp, min) 给出"结尾不超过 values[j]"的最优前缀;它和 |a - values[j]|
# 逐列相加得到新一行。两个序列在 list(...) 里同时求值,读到的都是本行开始时的旧 dp。
dp = list(map(add, (abs(a - v) for v in values), accumulate(dp, min)))
return min(dp) # 最后一个数允许落在任意台阶上
def solve() -> None:
data = iter(map(int, sys.stdin.buffer.read().split()))
n = next(data)
seq = [next(data) for _ in range(n)]
values = sorted(set(seq)) # 关键性质:最优 B 的取值只需来自 A 中出现过的数
best_nondecreasing = level_cost(seq, values)
best_nonincreasing = level_cost(seq[::-1], values) # 倒过来做非降 = 原序列非升
print(min(best_nondecreasing, best_nonincreasing))
if __name__ == "__main__":
solve()复杂度
- 设
。离散化排序 。 - 单方向 DP:
轮 × 每轮 ,即 ;两个方向常数因子 2。 - 总计
时间、 额外空间( dp与两个长度的临时表)。 时约 次逐元素运算。
边界:grading.7 即这一情形);long long 存代价。
总结
- 本题的第一个关键不是"优化枚举",而是证明最优解的取值只需来自
:把 放宽到实数后,逐位最小代价 是凸分段线性函数,其折点只会出现在 上,因此最小值可在 的某个折点取到。 - 离散化之后问题变成链式约束 DP:状态只记"末位台阶",转移是
。 dp[k]的前缀最小与无关,一次扫描即可求出,把 压到 。 - 非升 = 反转后的非降,复用同一函数跑两遍取
,不用写第二份 DP。 - 同样的算法在 C++ 中就是
sort + unique离散化、for每行扫一遍更新前缀最小值,long long存代价(答案上界, int会溢出)。
图示解析
解法路线
下图给出从题面到答案的三步:离散化确定候选台阶、非降方向跑一次线性 DP、反转序列复用同一 DP 得到非升方向,最后取较小值。
graph LR
A["题面序列 A<br/>N ≤ 2000"] --> B["离散化<br/>V = sorted(set(A))<br/>m ≤ N 个候选台阶"]
B --> C["非降 DP<br/>dp[j] = abs(A[i]-V[j]) + min(dp[0..j])"]
C --> D["非降最优 = min(dp)"]
B --> E["反转序列后跑同一个 DP<br/>等价于非升约束"]
E --> F["非升最优 = min(dp)"]
D --> G["答案 = min(两个方向)"]
F --> G
观察要点:两条分支共享同一个 level_cost,唯一区别是传入 seq 还是 seq[::-1];表