先假设全程坐高铁,时间是 base = sum(g)。如果第 i 条路改坐飞机,节省量是 w[i] = g[i] - f[i]。
旅行计划
5-10 分钟讲清楚:先把时间最小化改成节省最大化,再用 `best[j] / endv[j]` 维护最多选 k 段连续飞行。
一、先回答问题
不要一开始就想“从城市 i 飞到城市 j”。一次连续飞行正好对应数组 w 里的一段连续子段。我们只需要决定:每个 w[i] 是延长当前飞行段,还是新开一段飞行。
二、把旅行翻译成选区间
从城市 i 一直飞到城市 j,经过的是 i 到 j-1 的连续路段。题目说这只算一次飞机,所以它就是 w 数组中的一段连续子段。
某些路段飞机更慢,w 会是负数。但如果它夹在两段很赚的飞行之间,连续飞过去可能仍然更优,例如样例三。
不能只挑最大的 k 个单点。题目限制的是“飞行次数”,每一次可以覆盖一整段连续路程。
三、从 k=1 到 k=2:把维度推出来
k=1 学过(最大子段和),一句话带过;真正要想清楚的是 k=2。想通 k=2,任意 k 都只是照抄一层。下面分 4 步。
飞 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)。
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 次 = 选 2 段互不相交 的连续子段。唯一的新问题:新开的第 2 段,前面那一截从哪算起?
答案:接在 「到目前为止、最多用 1 段能省的最大值」 后面,也就是 best[1]。为此先把 k=1 改个名字看清楚——那个孤立的 w 其实是 best[0] + w(前面空着、省 0 再起飞,best[0]≡0):
endv[1] = max(endv[1] + w, best[0] + w);
best[1] = max(best[1], endv[1]);
k=2 就是照抄一层,只把 [1]→[2]、best[0]→best[1]:
best[2] = max(best[2], endv[2]);
| 状态 | 含义 |
|---|---|
| endv[j] | 用了 j 段,且第 j 段 必须以当前路段结尾 时的最大节省。 |
| best[j] | 处理过的前缀里,最多 用 j 段时的最大节省。 |
best[j] = max(best[j], endv[j]);
j 必须从大到小:算 endv[j] 要用 best[j-1] 加入当前路段 之前 的旧值,倒序才不会被本轮提前覆盖。
| 处理 | best[1] | best[2] | 这一步在想什么 |
|---|---|---|---|
| w[0]=3 | 3 | 3 | 两段都先吃下 +3。 |
| w[1]=−5 | 3 | 3 | −5 太亏,best 保持不变(宁可不选这段)。 |
| w[2]=4 | 4 | 7 | 关键一格:新开第 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]
五、对照验证:三组样例
把第三节推出来的转移跑在三组数据上,结果都对得上:
| 限制 | 最大节省 | 原始 base | 最短时间 | 对应理解 |
|---|---|---|---|---|
| k=1 | 4 | 18 | 14 | 只飞最后一段,比连续飞三段更划算。 |
| k=2 | 7 | 18 | 11 | 飞第 1 段和第 3 段,两次飞机。 |
| 样例三 k=1 | 6 | 18 | 12 | w=[3,-1,4],连飞三段节省 6。 |
六、自己选答案
w[i] = g[i] - f[i] 表示什么?
为什么内层 j 要从 k 到 1 倒序?
best[k] 最后代表什么?
七、C++14 参考代码
#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,“延续飞行”次数不变——这样连续飞行自然合并成一次乘机。
#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 理解题意,再体会第七节的转化之美。
九、下课前提醒
不要输出 best[k]。题目要的是最少时间,所以输出 base - best[k]。
数值范围很大,base 和 DP 值都用 long long。
最多 k 段不是恰好 k 段。best 初值为 0,表示可以一段都不选。