B4533 信息与未来 2026 动态规划 最多 k 段最大子段和

旅行计划

5-10 分钟讲清楚:先把时间最小化改成节省最大化,再用 `best[j] / endv[j]` 维护最多选 k 段连续飞行。

一、先回答问题

直接结论

先假设全程坐高铁,时间是 base = sum(g)。如果第 i 条路改坐飞机,节省量是 w[i] = g[i] - f[i]

答案 = base - 最多 k 段不相交连续子段的最大总和
课堂路线

不要一开始就想“从城市 i 飞到城市 j”。一次连续飞行正好对应数组 w 里的一段连续子段。我们只需要决定:每个 w[i] 是延长当前飞行段,还是新开一段飞行。

base=sum(g) w=g-f 一飞=一段

二、把旅行翻译成选区间

为什么是连续段

从城市 i 一直飞到城市 j,经过的是 i 到 j-1 的连续路段。题目说这只算一次飞机,所以它就是 w 数组中的一段连续子段。

为什么可以选负数

某些路段飞机更慢,w 会是负数。但如果它夹在两段很赚的飞行之间,连续飞过去可能仍然更优,例如样例三。

常见误解

不能只挑最大的 k 个单点。题目限制的是“飞行次数”,每一次可以覆盖一整段连续路程。

三、从 k=1 到 k=2:把维度推出来

k=1 学过(最大子段和),一句话带过;真正要想清楚的是 k=2。想通 k=2,任意 k 都只是照抄一层。下面分 4 步。

第 1 步 | k=1 复习:最大子段和模板

飞 1 次 = 在数组里选 一段 连续子段求最大和,就是经典的 最大子段和。两种写法,递推式完全相同

① 标准 DP(数组版)

// dp[i]: 以 a[i] 结尾的最大子段和
int dp[N], ans = a[0];
dp[0] = a[0];
for (int i = 1; i < n; i++) {
    dp[i] = max(dp[i-1] + a[i], a[i]);
    ans = max(ans, dp[i]);
}

② Kadane(滚动版)

// cur: 以当前元素结尾的最大子段和
int cur = 0, ans = INT_MIN;
for (int i = 0; i < n; i++) {
    cur = max(cur + a[i], a[i]);
    ans = max(ans, cur);
}

两者本质一样:递推都是 max(接着前面 + a[i], 新开 a[i])。Kadane 看出 dp[i] 只用到 dp[i-1],于是把数组滚动成一个变量 cur,空间从 O(n) 降到 O(1)。中文一般就叫 「Kadane 算法」(也称最大子段和的线性扫描 / 滚动 DP)。

为什么 Kadane 能从 i=0 开始?标准 DP 要先写 dp[0]=a[0],因为没有 dp[-1];而滚动版故意令 cur=0,第一次循环得到 cur=max(0+a[0], a[0])=a[0]。所以 cur 其实就是当前的 dp[i],Kadane 只是把“第一个元素的特判”藏进了循环。

本题用的就是滚动版:把 cur 加一维变成 endv[j]ans 变成 best[j]。样例 w=[3,−5,4] 最优只选 [4],省 4 → 18−4=14

第 2 步 | k=2:第 2 段从哪儿接?

飞 2 次 = 选 2 段互不相交 的连续子段。唯一的新问题:新开的第 2 段,前面那一截从哪算起?

答案:接在 「到目前为止、最多用 1 段能省的最大值」 后面,也就是 best[1]。为此先把 k=1 改个名字看清楚——那个孤立的 w 其实是 best[0] + w(前面空着、省 0 再起飞,best[0]≡0):

// k=1 改名:cur→endv[1],best→best[1]
endv[1] = max(endv[1] + w,  best[0] + w);
best[1] = max(best[1], endv[1]);

k=2 就是照抄一层,只把 [1]→[2]best[0]→best[1]

endv[2] = max(endv[2] + w,  best[1] + w);
best[2] = max(best[2], endv[2]);
第 3 步 | 推广到任意 k + 两个状态
状态含义
endv[j]用了 j 段,且第 j 段 必须以当前路段结尾 时的最大节省。
best[j]处理过的前缀里,最多 用 j 段时的最大节省。
endv[j] = max(endv[j] + w,  best[j-1] + w);
best[j] = max(best[j], endv[j]);
endv[j]+w:延长第 j 段 best[j-1]+w:新开第 j 段

j 必须从大到小:算 endv[j] 要用 best[j-1] 加入当前路段 之前 的旧值,倒序才不会被本轮提前覆盖。

第 4 步 | 跟着 w=[3,−5,4] 把 k=2 走一遍
处理best[1]best[2]这一步在想什么
w[0]=333两段都先吃下 +3。
w[1]=−533−5 太亏,best 保持不变(宁可不选这段)。
w[2]=447关键一格:新开第 2 段 = best[1] + 4 = 3 + 4 = 7。best[1]=3 正好记着前面 [3] 那段的成果。

k=2 省 7 → 时间 11(飞 [3] 和 [4]);k=1 只能省 4。多给一次飞机,第 2 段就靠 best[j-1] 把前一段成果接过来——这就是 k 加大时的全部思维。

四、互动 DP 实验台:w=[3,-5,4]

当前路段 i准备开始
当前 w[i]-
best[1]0
endv[1]-∞
best[2]0
endv[2]-∞
点击“处理下一条路”。每一步都按 j 从大到小更新:先算 endv[j],再用它刷新 best[j]。

五、对照验证:三组样例

把第三节推出来的转移跑在三组数据上,结果都对得上:

最大节省 → 最短时间
限制最大节省原始 base最短时间对应理解
k=141814只飞最后一段,比连续飞三段更划算。
k=271811飞第 1 段和第 3 段,两次飞机。
样例三 k=161812w=[3,-1,4],连飞三段节省 6。

六、自己选答案

问题 1

w[i] = g[i] - f[i] 表示什么?

问题 2

为什么内层 j 要从 k 到 1 倒序?

问题 3

best[k] 最后代表什么?

七、C++14 参考代码

本地 C++ 高亮
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

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

    int n, k;
    cin >> n >> k;
    vector<ll> g(n), f(n);
    ll base = 0;
    for (int i = 0; i < n; i++) {
        cin >> g[i];
        base += g[i];
    }
    for (int i = 0; i < n; i++) cin >> f[i];

    const ll NEG = -(1LL << 60);
    vector<ll> best(k + 1, 0), endv(k + 1, NEG);

    for (int i = 0; i < n; i++) {
        ll w = g[i] - f[i];
        for (int j = k; j >= 1; j--) {
            endv[j] = max(endv[j] + w, best[j - 1] + w);
            best[j] = max(best[j], endv[j]);
        }
    }

    cout << base - best[k] << '\n';
    return 0;
}
  • base 是全程高铁时间,最后减掉最大节省。
  • endv[j] 负责“当前这一段飞行必须接到 i”;best[j] 负责保存全局最优。
  • j 倒序 是滚动数组 DP 的关键,防止同一条路在本轮被重复利用。

八、对比:城市维度 DP(含一个常见错误版本)

上面的 “节省最大化 + 最多 k 段子段和” 是一种聪明的转化。很多同学会更直接地按城市做 DP:dp[j][i] 表示走到城市 j、用了 i 次飞机的最短时间。这个思路本身可行,但下面这份代码写错了,正好拿来对比。

❌ 错误版本
const int N = 1e5 + 5, K = 1005;
int g[N], f[N], dp[N][K];   // dp[j][i]: 到城市 j、用 i 次飞机的最短时间

int main() {
    int n, k;
    cin >> n >> k;
    for (int i = 1; i <= n; i++) cin >> g[i];
    for (int i = 1; i <= n; i++) cin >> f[i];

    memset(dp, 0x3f, sizeof(dp));
    dp[0][0] = 0;
    for (int i = 1; i <= n; i++)
        dp[i][0] = dp[i - 1][0] + g[i];        // 全程高铁

    for (int i = 1; i <= k; i++) {             // i = 飞机次数
        dp[0][i] = 0;
        for (int j = 1; j <= n; j++)
            dp[j][i] = min(dp[j-1][i-1] + f[i], // ← 两个错误都在这一行
                           dp[j-1][i] + g[j]);
    }
    cout << dp[n][k];
}
错在哪
错误说明
下标笔误 f[i] 应是 f[j]f 按“路段”编号,i 却是飞机次数,两者无关。
模型错了 题目规定连续飞行只算 1 次,但这份转移把每飞一个路段都 i-1 → i 记一次。飞 3 段连续路程被算成 3 次,无法合并成一次乘机。

样例三(k=1,应飞 0→3 连飞三段,答案 12):这份代码跑出 11;就算把 f[i] 改成 f[j] 修掉笔误,也只会得到 14——因为它根本飞不了“一次连飞三段”。所以光改下标不够,模型必须改。

✅ 正确版本(城市维度 + 是否在飞行中)

关键是补一维状态:到达城市时是否正处在一段连续飞行中。只有“新开一段飞行”才让次数 +1,“延续飞行”次数不变——这样连续飞行自然合并成一次乘机。

本地 C++ 高亮
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

const int N = 1e5 + 5, K = 1005;
const ll INF = 1e18;

int n, k;
ll g[N], f[N];
// dp[j][0/1]: 已乘机 j 次, 0=不在飞行中(刚下高铁或起点), 1=正处在一段连续飞行中
ll dp[K][2], nd[K][2];

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

    cin >> n >> k;
    for (int i = 1; i <= n; i++) cin >> g[i];
    for (int i = 1; i <= n; i++) cin >> f[i];

    for (int j = 0; j <= k; j++) dp[j][0] = dp[j][1] = INF;
    dp[0][0] = 0;

    for (int i = 1; i <= n; i++) {                 // 路段 i: 城市 i-1 -> i
        for (int j = 0; j <= k; j++) nd[j][0] = nd[j][1] = INF;
        for (int j = 0; j <= k; j++) {
            ll land = min(dp[j][0], dp[j][1]);
            if (land < INF)                         // 坐高铁: 落地
                nd[j][0] = min(nd[j][0], land + g[i]);
            if (dp[j][1] < INF)                     // 延续当前飞行: 次数不变
                nd[j][1] = min(nd[j][1], dp[j][1] + f[i]);
            if (dp[j][0] < INF && j + 1 <= k)        // 新开一次飞行: 次数 +1
                nd[j + 1][1] = min(nd[j + 1][1], dp[j][0] + f[i]);
        }
        for (int j = 0; j <= k; j++) {             // 滚动: 把 nd 拷回 dp
            dp[j][0] = nd[j][0];
            dp[j][1] = nd[j][1];
        }
    }

    ll ans = INF;
    for (int j = 0; j <= k; j++) ans = min(ans, min(dp[j][0], dp[j][1]));
    cout << ans << '\n';
    return 0;
}
  • 新开飞行dp[j][0] → nd[j+1][1])才 +1 次;延续飞行dp[j][1] → nd[j][1])次数不变,这正是“连飞算一次”的体现。
  • 全局数组 + 滚动:城市这一维用滚动省掉。若开 dp[N][K][2] 会爆到约 1.6 GB(MLE),所以只留 dp[K][2]nd[K][2] 两层来回拷。
  • 三组样例均通过:14 / 11 / 12
两种正确解法对比
角度节省最大化(第七节)城市维度 DP(本节)
状态best[j] / endv[j]:最多 j 段的最大节省dp[j][0/1]:到城市、j 次、是否在飞行
核心技巧把问题转成“最多 k 段最大子段和”直接模拟,多记一维“是否在飞行中”
“连飞算一次”如何体现一段连续子段 = 一次飞行延续飞行不加次数,只有新开才加
复杂度O(n·k),空间 O(k)O(n·k),空间 O(k)
思维难度需要想到转化,较巧直观好懂,适合先掌握

两种都是 O(n·k) 的正确解法,本质等价:子段和里的“一段”就对应城市 DP 里的“一次连续飞行”。建议先用直观的城市 DP 理解题意,再体会第七节的转化之美。

九、下课前提醒

坑 1

不要输出 best[k]。题目要的是最少时间,所以输出 base - best[k]

坑 2

数值范围很大,base 和 DP 值都用 long long

坑 3

最多 k 段不是恰好 k 段。best 初值为 0,表示可以一段都不选。