变幻

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

通过动态规划与精妙的贪心推导,推断出只需修改候选山谷点自身的局部最优性,利用 O(NK) DP 求解。

OJ: roj

题目 ID: 10016

难度:提高

标签:动态规划贪心

创建: 2026-10-10 07:12

更新: 2026-10-10 07:12

形式化题目

给定一个长度为 nn 的数组 aa。若存在 1<i<n1 < i < n,满足 ai−1>aia_{i-1} > a_i 且 ai<ai+1a_i < a_{i+1},则称位置 ii 为“山谷点”(注意第一个和最后一个元素不能为山谷点)。 一个数组的价值定义为所有山谷点位置的元素值之和。 至多可以进行 kk 次操作,每次操作可以选择一个位置并将其值修改为一个更小的数。 求经过至多 kk 次修改后,该数组能达到的最大价值。

数据范围:1≤k≤n≤20001 \le k \le n \le 2000,1≤ai≤1091 \le a_i \le 10^9。

正解

思路

首先,题意要求最大化“山谷点”的值之和。我们有至多 kk 次机会把某些数变小。为了让某个位置成为山谷点,要么它原本就是山谷点,要么我们可以主动将它改小,使得它严格小于两侧的相邻元素。

关键观察如下:

  1. **只有不相邻的位置才能同时是山谷点。**因为若 ii 和 i+1i+1 均为山谷点,根据定义必然推出 ai<ai+1a_i < a_{i+1} 和 ai>ai+1a_i > a_{i+1} 的矛盾。
  2. 要让某个非原生山谷点 ii 成为山谷点,最优策略必然是直接修改 aia_i。 如果我们去修改 i−1i-1 或 i+1i+1 变小,这不仅消耗了修改次数,反而会让它们更小,导致 ii 更加难以成为山谷点。因此,如果 ii 要成为山谷点,它的相邻元素在最优解中一定不能被修改,保留原值。 既然 i−1i-1 和 i+1i+1 保持原值,要使得 aia_i 成为山谷点并最大化它的值,我们应该将 aia_i 修改为 min⁡(ai−1−1,ai+1−1)\min(a_{i-1}-1, a_{i+1}-1)。
  3. 对于原数组中已经是山谷点的元素,我们绝对不会主动去把它变小。 如果把它变小,只会白白损失它的价值;如果放弃它,试图让其相邻元素成为山谷点,相邻元素修改后能达到的最大值必定 ≤ai−1<ai\le a_i-1 < a_i,收益不增反降且浪费了修改次数。 因此,原生山谷点如果被放弃,唯一的可能原因是为了在它的左右两侧(即 i−1i-1 和 i+1i+1)同时构造出两个新的山谷点,以“一换二”的形式获得更大价值。

基于上述观察,我们可以使用动态规划解决此题: 设 dp[i][j][0] 表示考虑前 ii 个位置,已使用 jj 次修改,且第 ii 个位置不是山谷点时的最大价值。 设 dp[i][j][1] 表示考虑前 ii 个位置,已使用 jj 次修改,且第 ii 个位置是山谷点时的最大价值。

状态转移如下:

  • 若第 ii 个位置在原数组中已经是山谷点(满足 ai<ai−1a_i < a_{i-1} 且 ai<ai+1a_i < a_{i+1}):
    • 它可以直接成为山谷点(无需消耗修改次数),由于相邻不能为山谷点,故从 dp[i-1][j][0] 转移过来: dp[i][j][1] = dp[i-1][j][0] + a[i]
    • 若它不作为山谷点,那么根据前文分析,这必定是因为在第 i−1i-1 处构造了一个山谷点破坏了它(为将来的“一换二”铺路)。因此直接从 dp[i-1][j][1] 转移: dp[i][j][0] = dp[i-1][j][1]
  • 若第 ii 个位置在原数组中不是山谷点:
    • 要让它成为山谷点,我们需要消耗 1 次修改次数,并将其值改为 min⁡(ai−1−1,ai+1−1)\min(a_{i-1}-1, a_{i+1}-1)。前提是 j>0j > 0: dp[i][j][1] = dp[i-1][j-1][0] + \min(a_{i-1}-1, a_{i+1}-1)
    • 若它不成为山谷点,那么它可以承接前一个位置无论是否为山谷点的最大值: dp[i][j][0] = \max(dp[i-1][j][0], dp[i-1][j][1])

最终答案为所有状态的最大值。由于判断需要用到 i−1i-1 和 i+1i+1,并且边界点不可能为山谷点,我们只需从 i=2i=2 遍历到 i=n−1i=n-1 即可。

代码

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-10 07:12
# update_at: 2026-10-10 08:23
import sys


def solve() -> None:
    """dp[i][j][s]:前 i 个位置用了 j 次修改、i 是否为山谷点时的最大价值。"""
    data = iter(map(int, sys.stdin.buffer.read().split()))
    try:
        n, k = next(data), next(data)
    except StopIteration:
        return

    a = [0] + [next(data) for _ in range(n)] + [0]   # a[1..n],两端补 0 便于取邻居
    # 转移只依赖 i-1 一行,所以滚动成一维:prev 是 i-1 行,cur 是第 i 行;状态量 O(nk) 降到 O(k)
    prev0, prev1 = [0] * (k + 1), [0] * (k + 1)
    cur0, cur1 = [0] * (k + 1), [0] * (k + 1)
    ans = 0

    for i in range(2, n):                            # 首尾都不可能是山谷点
        left, mid, right = a[i - 1], a[i], a[i + 1]
        is_valley = mid < left and mid < right       # 原本就是山谷点,不花修改次数
        best_lowered = min(left, right) - 1          # 花 1 次修改后 a[i] 能达到的最大值
        for j in range(k + 1):
            if is_valley:
                cur1[j] = prev0[j] + mid             # 保留它:前一个位置不能是山谷点
                cur0[j] = prev1[j]                   # 放弃它:只能因为前一个位置成了山谷点
            else:
                cur1[j] = prev0[j - 1] + best_lowered if j else 0
                cur0[j] = max(prev0[j], prev1[j])
            ans = max(ans, cur0[j], cur1[j])
        prev0, cur0 = cur0, prev0
        prev1, cur1 = cur1, prev1

    print(ans)


if __name__ == "__main__":
    solve()
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 07:12
 * update_at: 2026-10-10 08:23
 */
#include <bits/stdc++.h>
using namespace std;

typedef long long ll;
const int MAXN = 2005;          // n <= 2000

ll a[MAXN];                     // a[i]:第 i 个位置的初始值,下标从 1 开始
ll dp[MAXN][MAXN][2];           // dp[i][j][s]:只看前 i 个位置、用了 j 次修改,
                                // s = 0 表示 i 不是山谷点,s = 1 表示 i 是山谷点

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n, k;
    if (!(cin >> n >> k)) return 0;

    for (int i = 1; i <= n; i++) {
        cin >> a[i];
    }

    ll ans = 0;
    for (int i = 2; i < n; i++) {       // 首尾都不可能是山谷点
        for (int j = 0; j <= k; j++) {
            if (a[i] < a[i - 1] && a[i] < a[i + 1]) {
                // 原本就是山谷点:不修改就能取值;若它不当山谷点,只能是前一个位置成了山谷点
                dp[i][j][1] = dp[i - 1][j][0] + a[i];
                dp[i][j][0] = dp[i - 1][j][1];
            } else {
                // 原本不是山谷点:花 1 次修改把 a[i] 改成 min(a[i-1]-1, a[i+1]-1)
                if (j > 0) {
                    dp[i][j][1] = dp[i - 1][j - 1][0] + min(a[i - 1] - 1, a[i + 1] - 1);
                }
                dp[i][j][0] = max(dp[i - 1][j][0], dp[i - 1][j][1]);
            }
            ans = max(ans, dp[i][j][0]);
            ans = max(ans, dp[i][j][1]);
        }
    }

    cout << ans << "\n";
    return 0;
}

复杂度

  • 时间复杂度:状态数为 O(n⋅k)O(n \cdot k),转移时间为 O(1)O(1),总时间复杂度为 O(n⋅k)O(n \cdot k)。
  • 空间复杂度:O(n⋅k)O(n \cdot k)。可以使用滚动数组优化至 O(k)O(k),但对于 N,K≤2000N,K \le 2000 的数据开普通三维数组仅约 64MB64\text{MB} 左右空间,满足常规内存限制。

总结

  1. 本题在设计 DP 状态与转移时,很容易误以为原生山谷点可以随便作为“非山谷点”处理而继承任意状态。事实上,“放弃”一个原生山谷点的代价必须被严格约束在“前一个位置被迫成为山谷点”这一特殊情况下,通过 dp[i][j][0] = dp[i-1][j][1] 完美地做到了这种状态转移,否则可能会因违背贪心最优性而算入错误情况。
  2. 挖掘出**“要想把某点变成山谷点,只需要且只能修改它自身,其余相邻点维持原值”**是解题的核心,这保证了每次修改后的最佳取值可以极其简单地用 min⁡(ai−1−1,ai+1−1)\min(a_{i-1}-1, a_{i+1}-1) 求得,无需记录每一个元素实际被修改到了何值。

验证记录(R2,与官方 std.cpp 三方对拍)

⚠ 本题无本地 data/ ⇒ check_new_analysis 报「无 data/(跳过)」= 空转, 不构成证据(T1 / T65)。证据来源是官方 std.cpp(证据等级 ★★★★)。

结论

与官方 std.cpp 三方一致(不写「验证通过」): main.cpp ≡ std.cpp ≡ main.py,在 186 组合成用例(含 n=k=2000 极值)上 逐字节 186/186 无分歧;另在 84,915 组穷举小数据上与独立 brute 三方无分歧。

std.cpp 语义核对(T67)

核对项 结论
★ 三维 dp 是否属实 ✅ 属实:ll dp[maxn][maxn][2](maxn=2005,64.3MB 全局数组)⇒ 34 行确实装得下
循环结构 单组(cin>>n>>k 一次,无 while/EOF);拼两组只答第一组,三方一致
「变幻」是什么 任选位置改小;非原生山谷点花 1 次改成 min(a[i-1]-1, a[i+1]-1)
输入变量是否都参与 n ✅(i<n 上界)、k ✅(j<=k 上界)
std 分支冗余 第 2 支 else if(a[i]<a[i-1]) 与第 3 支 else 函数体逐字相同 ⇒ 合并为一支,语义等价
std 的 UB 风险 空输入时 int n,k; 未初始化即使用(UB);main.cpp 用 if(!(cin>>n>>k)) return 0; 规避

V1 反事实表(真进程 sed 变异,186 用例)

变体 失败点数 判定
删掉非原生山谷点分支 123/186 承重
min(a-1,b-1) → min(a,b) 139/186 承重
dp[i-1][j-1][0] → dp[i-1][j][0] 34/186 承重
ans 不计 dp[..][1] 63/186 承重
循环 j<=k-1 48/186 承重(k 承重)
ans 初值 1 46/186 承重(答案可为 0)
★ dp[i][j][0]=dp[i-1][j][1] → max(...) 0/186 ★ 不承重(如实报告)
★ 循环从 i=1 起 0/186 ★ 不承重(a[0]=0 补零使端点贡献恒 min(-1,·)=-1<0,结构性 no-op)

★ 另在 6015/30948 组穷举 + 20000 组随机上做 V1..V7 全表,结论一致。

边界候选不可分辨(V8)

「修改后的值可为 0」 vs 「必须 ≥1」两种读法分别 brute: 失败点集合都是 ∅(66,481 组)⇒ ★ 数据无法区分,如实报告(std 允许 0)。

V9 UBSan + 对照组

  • 主测:-fsanitize=undefined -fno-sanitize-recover=undefined 跑全部 186 用例 ⇒ stderr 0 行;-ftrapv 全过
  • ★ 对照组:强制 -INT_MIN 的对照程序在同一命令下报 negation of -2147483648 cannot be represented in type 'int' + Abort trap: 6 ⇒ 证明该 UBSan 命令确实有分辨力(T30 闸门分辨力)

修改内容(算法未改)

  • main.cpp:补标准文件头;long long → typedef long long ll;全局数组加中文语义注释
  • main.py:补 shebang + 作者头;solve() -> None 类型标注; iter(map(int, sys.stdin.buffer.read().split())) + next() 顺序消费; ★ 3D 表 → 滚动数组(同算法):峰值 562MB → 9.4MB、2.35s → 0.76s, 输出 186/186 逐字节不变