B4533 旅行计划(P5)— 解法全景与讲解

对当前目录下 1092 份 p5.cpp 提交进行算法归类、原理剖析与最优写法推荐。

1. 题意与核心转化

从城市 0 走到城市 n,每条边 i→i+1 要么坐高铁(花 g[i]),要么被某次飞行覆盖(花 f[i])。一次飞行可以连续覆盖若干相邻边,只算一次飞机;飞行次数 ≤ k。求最少总时间。

关键观察:把"被飞行覆盖的边"看作若干段连续区间。每一段连续飞行 = 用掉 1 次飞机,覆盖的边付 f,其余边付 g。于是问题等价于:

在 n 条边里,选出 至多 k 段不相交的连续区间,区间内的边走飞机、区间外的边走高铁,使总时间最小。

d[i] = g[i] − f[i](飞行相对高铁的"节省"),基准 base = Σ g[i]。则
答案 = base − (至多 k 段连续区间内 d[i] 之和的最大值)。这正是经典的"最多 k 段子数组最大和"问题。注意:一段区间内可以包含 d<0 的边——当合并两个正区间能省下一次飞机名额时,这样做反而更优(见样例 3)。

2. 提交分布(1092 份)

161
DP(城市×飞行次数)
14.7% · 正解思路
388
朴素贪心 / 仅 k=1 / 部分分
35.5%
181
暴力 / DFS 枚举
16.6%
67
差分+排序贪心(错)
6.1%
4
贪心+堆/multiset
0.4%
291
空程序 / 打表 / 抄错题
26.6%

绿=DP正解思路 · 黄=部分分贪心 · 橙=暴力 · 红=错误贪心 · 蓝=堆贪心 · 灰=非有效提交。分类基于代码特征自动统计,个别边界会有出入。

3. 一共出现了几类解法?

类别核心思路复杂度正确性 / 预期得分
A. DP(城市×飞行次数) 状态 = (到第 i 城, 已用 j 次飞机, 当前是否正在飞),逐边转移 时间 O(n·k) 完全正确,100 分(前提:用滚动数组控制内存)
B. 前缀和 + 堆/multiset 贪心 对 d 求前缀和,维护"最多 k 段最大和",用堆选最优 O(n·k) 或 O(n log n) 思路正确,但实现复杂、易写挂;本次仅 4 份且多为半成品
C. 暴力 / DFS 枚举 递归枚举每段飞行的起止位置 指数级 / O(n²·…) 正确但超时,约 10%~40% 部分分
D. 仅处理 k=1(最大子段) 只找单个 d 最大连续区间(Kadane 或 O(n²) 枚举) O(n) ~ O(n²) 只能拿 k=1 的两个子任务,约 30%
E. 差分 + 排序贪心 把 d[i] 排序,取最小的 k 个相加 O(n log n) 错误:把"飞 1 段连续边"误当成"飞 1 条边",无视区间合并
F. 逐边 min(g,f) 每条边独立取 min,完全忽略 k 限制 O(n) 错误:当 k < 需要飞的段数时会过量飞行
G. 非有效提交 空程序、读入即结束、打表 14、甚至贴了 P1 的代码 0 分或仅样例分

4. 它们的区别在哪里?

E / F —— 为什么贪心是错的

这是最常见的"看似对"的错误。它们都假设每条边可以独立决定飞或不飞:

样例 3 是专门的反例g=4,6,8 f=1,7,4k=1。虽然 g[1]=6<f[1]=7(中间边飞行更亏),但只有 1 次飞机时,最优是一口气飞 0→3,吞下亏损的中间边,总时间 1+7+4=12,优于任何"只飞划算单边"的贪心。E/F 都答不对。

C / D —— 正确但拿不满

D(仅 k=1)本质是在 d[] 上求最大连续子段和(Kadane):base − max_subarray(d)。它对 k=1 的两档数据(共约 30%)完全正确,但 k≥2 无能为力。
C(DFS 枚举区间)思路无误——枚举每段飞行的起止——但 n≤1e5 时分支爆炸,只能过小数据(n≤200/1000)。

5. 最优解法:O(n·k) DP(推荐)

n·k ≤ 10⁵×10³ = 10⁸ 的约束下,O(n·k) 的 DP 是时间与实现难度的最佳平衡点,可稳定拿满分。状态机定义(直接对"最少时间"做 DP):

逐边转移(第 i 条边高铁费 a[i]、飞机费 b[i]):

f[j][0] = min(f[j][0], f[j][1]) + a[i]        // 这条边坐高铁;若上一段在飞则飞行就此结束
f[j][1] = min(f[j][1], f[j-1][0]) + b[i]       // 这条边飞:要么续上当前飞行,要么从"没飞"状态新开一次飞行(j-1→j)

关键工程点 —— 内存。朴素三维数组 dp[100010][1010][2] 需要约 1.6 TB(本次有提交真这么开,必然 MLE)。必须滚动掉城市那一维,只保留 f[k][2],并让 j 从大到小循环以正确引用上一城市的 f[j-1][0]。空间降到 O(k)。

参考实现(滚动数组,O(n·k) 时间 / O(k) 空间)

#include <bits/stdc++.h>
using namespace std;
const long long INF = 4e18;
int a[100005], b[100005];
long long f[1005][2];          // f[j][0/1],城市维已滚动

int main(){
    int n, m; cin >> n >> m;
    for(int i=1;i<=n;i++) cin >> a[i];
    for(int i=1;i<=n;i++) cin >> b[i];

    for(int j=0;j<=m;j++) f[j][0]=f[j][1]=INF;
    f[0][0] = a[1];                // 第1条边坐高铁,未飞
    if(m>=1) f[1][1] = b[1];        // 第1条边飞,开启第1次飞行

    for(int i=2;i<=n;i++)
        for(int j=m;j>=0;j--){          // j 倒序:保证 f[j-1][0] 还是上一城市的值
            if(f[j][0]!=INF || f[j][1]!=INF)
                f[j][0] = min(f[j][0], f[j][1]) + a[i];
            if(j && (f[j][1]!=INF || f[j-1][0]!=INF))
                f[j][1] = min(f[j][1], f[j-1][0]) + b[i];
        }

    long long ans = INF;
    for(int j=0;j<=m;j++) ans = min(ans, min(f[j][0], f[j][1]));
    cout << ans;
}

本目录中 A006/p5.cpp 即此写法(滚动数组,正确且省内存),可作满分范例;A005/p5.cpp 思路相同但开了三维大数组会 MLE。

6. 哪种写法"最优"?

结论:第 5 节的 O(n·k) 状态机 DP + 滚动数组,是本题的推荐最优写法。

方案时间空间好写吗评价
DP + 滚动数组O(n·k)≈1e8O(k)容易推荐:1s 内稳过,代码短、不易错
DP + 三维数组O(n·k)O(n·k)容易思路对但 MLE,必须改滚动
前缀和+堆贪心O(n log n)O(n)渐进最快,但 k≤1000 时 DP 已够,且这种写法易出错,性价比低

虽然堆贪心在渐进复杂度上更优(O(n log n)),但本题数据范围下 O(n·k)=10⁸ 在 1 秒内绰绰有余,而 DP 的正确性更易保证、调试更简单。竞赛/教学语境下,O(n·k) DP 是公认的标准满分解

7. 常见失分点小结

8. 最佳范例展示与点评 · A006/p5.cpp

解法最优 风格优秀 在全部 1092 份提交中,这份是正确性、效率、内存、可读性综合最好的范例:标准 O(n·k) 状态机 DP,并且把城市维滚动掉,空间只有 O(k)。

#include <iostream>
#include <cstring> 
using namespace std;
const int MAXN = 1e5 + 5;
const long long INF = 4e18;
int a[MAXN], b[MAXN];
long long f[1005][2];
int main(){
	int n, m;
	cin >> n >> m;
	if (n == 0){
		cout << 0;
		return 0;
	}
	for (int i = 0;i <= m;i++){
		f[i][0] = f[i][1] = INF;
	}
	for (int i = 1;i <= n;i++){
		cin >> a[i];
	}
	for (int i = 1;i <= n;i++){
		cin >> b[i];
	}
	f[0][0] = a[1];
	f[1][1] = b[1];
	for (int i = 2;i <= n;i++){
		for (int j = m;j >= 0;j--){
			if (f[j][0] != INF || f[j][1] != INF)
				f[j][0] = min(f[j][0], f[j][1]) + a[i];
			if (j && (f[j][1] != INF || f[j - 1][0] != INF))
				f[j][1] = min(f[j][1], f[j - 1][0]) + b[i];
		}
	}
	long long minn = 4e18;
	for (int i = 0;i <= m;i++) {
		minn = min(minn, min(f[i][0], f[i][1]));
	}
	cout << minn;
}

点评

结论:思路是标准满分解,工程细节(滚动数组 + 溢出守卫)处理到位,可直接作为标程

9. 学生案例点评 · E230 与 E118

E230/p5.cpp 仅 k=1 含致命 bug

#include<bits/stdc++.h>
using namespace std;
const int N=1e5+5;
int a[N],b[N],d[N];
int main() {
	int n,k;
	cin>>n>>k;
	for(int i=1; i<=n; i++) {
		cin>>a[i];
	}
	for(int i=1; i<=n; i++) {
		cin>>b[i];
		d[i]=a[i]-b[i];
	}
	int ma=0;
	int s,e;
	int sum=0;
	for(int i=1; i<=n; i++) {
		sum=0;
		for(int j=i; j<=n; j++) {
			sum+=d[j];
			if(sum>ma) {
				s=i,e=j,ma=sum;
			}
		}
	}
	long long ans=0;
	for(int i=1; i<=n; i++) {

		if(i<s&&i>e)
		{
			ans+=a[i];
		}

	}
	for(int i=s;i<=e;i++)
	{
		ans+=b[i];
	}
	cout<<ans;
	return 0;
}

思路:d=g−f 的最大连续子段(O(n²) 枚举),即把问题按 k=1 处理,再用区间内走飞机、区间外走高铁求和。

不足:① 完全忽略 kk≥2 拿不到更多分。② 致命 bugif(i<s&&i>e) 恒为假(不可能既 i<si>e),导致区间外的高铁费 a[i] 一条都没加上,答案只剩区间内 f 之和——连 k=1 也算错;应为 i<s||i>e。③ ma 初始为 0,全负 d 时会取空区间。

如何提高:先把 k=1 写对(用 Kadane O(n) 求最大子段,并正确累加"区间外高铁 + 区间内飞机"),再升级到"至多 k 段"的 O(n·k) 状态机 DP(见第 5 节)。调试时打印中间区间 s,e 能立刻发现求和漏项。

E118/p5.cpp 状态设计正确 MLE/TLE + bug

#include<bits/stdc++.h>
using namespace std;
int g[100005],f[100005],n,k,dp[100005][1003];
int t(int i,int time){
	if(dp[i][time]!=0){
		return dp[i][time];
	}
	if(i>n){
		return 0;
	}
	int s=t(i+1,time)+g[i];
	//cout<<i<<' '<<time<<' '<<s<<endl;
	if(time>0){
		time--;
		for(int j=i;j<=n;j++){
			int c=0;
			for(int k=i;k<=j;k++){
				c+=f[k];	
			}
			s=min(s,t(j,time)+c);
			//cout<<c<<endl;
			//cout<<i<<' '<<time<<' '<<s<<endl;
		}
	}
	dp[i][time]=s;
	//cout<<i<<' '<<time<<' '<<s<<endl; 
	return s;
}
int main(){
	cin>>n>>k;
	for(int i=1;i<=n;i++){
		cin>>g[i];
	}
	for(int i=1;i<=n;i++){
		cin>>f[i];
	}
	t(1,k);
	int minid=2100000000;
	for(int i=1;i<=n;i++){
		for(int j=0;j<=k;j++){
			cout<<dp[i][j]<<' '<<i<<' '<<j<<endl;
			if(dp[i][j]!=0)
			minid=min(dp[i][j],minid);
		}
	}
	cout<<minid;
	return 0;
} 

思路:记忆化搜索,状态 (城市 i, 剩余飞行次数 time)——方向是对的,已经接近正解的状态设计。

不足:① 转移里再套一层 for j 累加 f 是 O(n),总复杂度 O(n²·k),n=1e5 必超时。② dp[100005][1003] ≈ 4×10⁸ 字节,MLE。③ time-- 改了变量后又拿它当记忆化下标,状态键被写乱。④ 用 dp!=0 当"已算过"标志不可靠(0 是合法值)。⑤ 留了调试 cout 打印整张表,输出格式直接错;最后对所有 dp[i][j] 取 min 也不是答案。

如何提高:保留"状态正确"的优点,把"枚举飞行终点"换成"当前是否正在飞"的二状态 O(1) 转移(O(n·k));滚动数组省内存;用独立 vis[][] 标记记忆化;删掉调试输出;答案取 dp[1][*] 而非全表最小。