「Making the Grade」 分级

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

最优 B 只需取 A 中出现过的数,按末位台阶做前缀最小值优化的线性 DP,正反两个方向各跑一次取最小。

OJ: roj

题目 ID: 3137

难度:普及+/提高-

标签:动态规划线性DP离散化python

创建: 2026-10-01 19:44

更新: 2026-10-10 11:30

形式化题目

给定整数序列 A1,…,ANA_1, \dots, A_N。求

min⁡B ∑i=1N∣Ai−Bi∣,\min_{B}\ \sum_{i=1}^{N} |A_i - B_i|,

其中 BB 为整数序列,且满足 B1⩽B2⩽⋯⩽BNB_1 \leqslant B_2 \leqslant \dots \leqslant B_N(非降)或 B1⩾B2⩾⋯⩾BNB_1 \geqslant B_2 \geqslant \dots \geqslant B_N(非升)之一。

N⩽2000N \leqslant 2000,0⩽Ai⩽1090 \leqslant A_i \leqslant 10^9。只需输出最小值,不要求给出 BB。

正解

思路

先说结论:存在一组最优解,使每个 BiB_i 都等于某个 AjA_j;在这个前提下按位置做线性 DP,利用"前缀最小值"把转移压到 O(1)O(1),两个单调方向各算一次取较小值。

第一步:为什么候选取值只需来自 AA

BiB_i 本来是任意整数,看似有无穷多种选择。这里用一个分段线性凸函数的归纳说明:只需考虑 V={A1,…,AN}V = \{A_1, \dots, A_N\} 里的取值。

先把 BiB_i 的取值范围放宽到实数(放宽只会让最小值更小;最后会看到最优解本来就落在 VV 这些整数上,所以放宽不影响答案)。设

Fi(x)=只考虑前 i 个元素、且 Bi=x 时的最小代价,F_i(x) = \text{只考虑前 } i \text{ 个元素、且 } B_i = x \text{ 时的最小代价},

则 F0≡0F_0 \equiv 0,且

Fi(x)=∣Ai−x∣+min⁡y⩽xFi−1(y).F_i(x) = |A_i - x| + \min_{y \leqslant x} F_{i-1}(y).

归纳证明「每个 FiF_i 都是凸的分段线性函数,且所有折点都在 VV 中」:

  • F0≡0F_0 \equiv 0 是常数函数,没有折点。
  • 设 Fi−1F_{i-1} 凸、折点 ⊆V\subseteq V。G(x)=min⁡y⩽xFi−1(y)G(x) = \min_{y \leqslant x} F_{i-1}(y) 就是把 Fi−1F_{i-1} 极小点右侧的部分压平:Fi−1F_{i-1} 凸,所以它在某个极小点 pp 左边单调不增、右边单调不减,于是 G(x)=Fi−1(x)G(x) = F_{i-1}(x)(当 x⩽px \leqslant p)、G(x)=Fi−1(p)G(x) = F_{i-1}(p)(当 x⩾px \geqslant p)。压平后仍然凸,折点集合不增。
  • Fi=G+∣Ai−x∣F_i = G + |A_i - x|:两个凸分段线性函数之和仍凸,折点是二者折点之并再并入 AiA_i 处的新折点,而 Ai∈VA_i \in V,归纳成立。

于是 min⁡xFN(x)\min_x F_N(x) 一定能在某个折点处取到,即最优值可以由 x∈Vx \in V 实现。再顺着转移回溯:在 (−∞,x](-\infty, x] 上取 Fi−1F_{i-1} 的极小点,得到的要么是 xx 本身、要么是 Fi−1F_{i-1} 极小值段的端点,两者都是折点——所以可以取到一组每个 BiB_i 都属于 VV 的最优解。又因为 VV 里全是整数,实数放宽与整数原题的答案相同。

去重排序后记 V=(V1<V2<⋯<Vm)V = (V_1 < V_2 < \dots < V_m),m⩽N⩽2000m \leqslant N \leqslant 2000。候选台阶只有 mm 个,问题从"在数轴上选"变成"在 mm 个台阶里选"。

第二步:按末位台阶做线性 DP

约束只存在于相邻两个位置之间,所以决定 BiB_i 时唯一需要知道的过去信息就是 Bi−1B_{i-1} 取了哪个台阶。定义

dp[j]=处理完前缀 A1..Ai、且 Bi=Vj 时的最小代价。dp[j] = \text{处理完前缀 } A_1..A_i \text{、且 } B_i = V_j \text{ 时的最小代价。}

因为要求非降,Bi−1B_{i-1} 可以取任何不超过 VjV_j 的台阶:

dpnew[j]=∣Ai−Vj∣+min⁡0⩽k⩽jdpold[k].dp_{\text{new}}[j] = |A_i - V_j| + \min_{0 \leqslant k \leqslant j} dp_{\text{old}}[k].

关键在于 min⁡k⩽jdpold[k]\min_{k \leqslant j} dp_{\text{old}}[k] 与 AiA_i 无关,可以整表一次前缀最小算好,于是单行转移是 O(m)O(m) 而不是 O(m2)O(m^2)。初值 dp≡0dp \equiv 0:还没有元素时不存在"末位"约束,从任何台阶起步都是免费的。最后答案取 min⁡jdp[j]\min_j dp[j]——题面没有约束 BNB_N 的大小,末位可以落在任何台阶上。

这一段在 main.py 里就是 level_cost() 循环体的那一行:accumulate(dp, min) 给出旧表的前缀最小,map(add, (abs(a - v) for v in values), ...) 把 ∣Ai−Vj∣|A_i - V_j| 逐列加上去,min(dp) 就是该方向的答案。

第三步:两个方向

非升只是非降的镜像:反转序列后非升约束变成非降约束,而代价 ∑∣Ai−Bi∣\sum |A_i - B_i| 在反转下逐项不变。所以同一份代码跑两遍,取较小值。

先验证一下方向确实都要算:样例 A=(1,3,2,4,5,3,9)A = (1,3,2,4,5,3,9) 在非降方向上是 33,在非升方向上是 1212,取 min⁡\min 才是答案 33。

样例的 DP 表推演

对样例取 V=(1,2,3,4,5,9)V = (1,2,3,4,5,9)。下表第 ii 行是处理完 A1..AiA_1..A_i 后的 dp,列是末位取的台阶;加粗的是该行的最小值(初值行全为 00,不标注)。

已处理 AiA_i B=1B=1 B=2B=2 B=3B=3 B=4B=4 B=5B=5 B=9B=9
(无) — 0 0 0 0 0 0
A1A_1 1 0 1 2 3 4 8
A2A_2 3 2 1 0 1 2 6
A3A_3 2 3 1 1 2 3 7
A4A_4 4 6 3 2 1 2 6
A5A_5 5 10 6 4 2 1 5
A6A_6 3 12 7 4 3 3 7
A7A_7 9 20 14 10 8 7 3

读表要点:每一格 = 同列 ∣Ai−Vj∣|A_i - V_j| + 上一行左侧(含本列)最小值。最后一行最小值 33 落在 BN=9B_N = 9,对应方案 B=(1,3,3,4,5,5,9)B = (1,3,3,4,5,5,9),代价 0+0+1+0+0+2+0=30+0+1+0+0+2+0 = 3。再看第 A6A_6 行:该行最小值 33 出现在末位 44 和 55,而末位 99 时是 77;若贪心地在每一行只保留最小值、把其余末位剪掉,末位 99 这条线就消失了,而最后一行恰恰只有从末位 99 出发才能得到 33。这就是必须把 mm 个末位全部保留到下一行、直到最后才取最小值的理由。

代码

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 <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;
}
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-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()

复杂度

  • 设 m=∣set⁡(A)∣⩽Nm = |\operatorname{set}(A)| \leqslant N。离散化排序 O(Nlog⁡N)O(N \log N)。
  • 单方向 DP:NN 轮 × 每轮 O(m)O(m),即 O(Nm)⩽O(N2)O(Nm) \leqslant O(N^2);两个方向常数因子 2。
  • 总计 O(N2)O(N^2) 时间、O(N)O(N) 额外空间(dp 与两个长度 mm 的临时表)。N=2000N = 2000 时约 8×1068 \times 10^6 次逐元素运算。

边界:N=1N = 1 或 AA 已经非降时答案就是 00(数据 grading.7 即这一情形);AA 全相等时 m=1m = 1,表宽为 11;AiA_i 取到 10910^9 时答案上界 2×10122 \times 10^{12},Python 无需处理溢出,C++ 实现必须用 long long 存代价。

总结

  • 本题的第一个关键不是"优化枚举",而是证明最优解的取值只需来自 AA:把 BiB_i 放宽到实数后,逐位最小代价 Fi(x)F_i(x) 是凸分段线性函数,其折点只会出现在 AiA_i 上,因此最小值可在 VV 的某个折点取到。
  • 离散化之后问题变成链式约束 DP:状态只记"末位台阶",转移是 ∣Ai−Vj∣+min⁡k⩽jdp[k]|A_i - V_j| + \min_{k \leqslant j} dp[k]。
  • dp[k] 的前缀最小与 AiA_i 无关,一次扫描即可求出,把 O(N3)O(N^3) 压到 O(N2)O(N^2)。
  • 非升 = 反转后的非降,复用同一函数跑两遍取 min⁡\min,不用写第二份 DP。
  • 同样的算法在 C++ 中就是 sort + unique 离散化、for 每行扫一遍更新前缀最小值,long long 存代价(答案上界 2×10122 \times 10^{12},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];表 VV 对两个方向完全相同(反转不改变元素集合),所以离散化只需做一次。真正把搜索空间压下来的是最左边那一步——没有它,右边两个 DP 的表宽是值域规模而不是 m⩽2000m \leqslant 2000。