P2800 又上锁妖塔 题解复盘
模块:线性动态规划 / 状态机 DP
目标:在仙术有冷却限制的情况下,求到达第 n 层的最短时间
基本信息
| 项目 | 内容 |
|---|---|
| 题目编号、来源 | P2800 洛谷 / 又上锁妖塔 |
| 训练层级 | B 变形题 |
| 知识版块 | 线性 DP、状态机 DP、最短时间 |
解题前 · 关键信号识别
| 维度 | 分析 |
|---|---|
| 目标、约束、底层结构 | 目标 :到达第 n 层时总耗时最小。 约束 :可以爬一层,也可以用仙术跳一层或两层;仙术免费,但每次跳完后必须至少爬一层才能再次使用。 底层结构:仅知道"当前在第几层"不够,因为还需要知道"当前是否能继续使用仙术",因此需要额外记录状态。 |
| 数据规模 | n≤10^6,必须使用接近 O(n) 的算法。 |
| 候选算法和依据 | 算法:状态机 DP。 依据:同样到达第 i 层,因为"能不能继续跳"不同,后续决策不同,因此定义 dp[i][0/1]。 |
| 复杂度预判 | 时间复杂度 O(n),空间复杂度 O(n),还可以进一步压缩空间。 |
解题后 · 外化复盘
| 维度 | 内容 |
|---|---|
| 实现结构 / 核心思路 | 定义两个状态:dp[i][0] 表示到第 i 层后可以继续使用仙术的最小时间;dp[i][1] 表示到第 i 层后不能继续使用仙术的最小时间。爬一层后一定恢复体力,因此转移到状态 0;使用仙术跳一层或两层后进入状态 1。 |
| 错因回溯 | 1. 一开始只想到 dp[i],没有记录能否使用仙术。 2. 容易把跳跃也加上 v[i],但题目明确跳跃不耗时。 3. i=1 时直接访问 dp[i-2] 会越界,需要单独判断。 4. DP 初始状态不能默认全为 0,否则 min 会把不存在的状态当成合法状态。 |
| 边界和易错点 | 1. 初始在地面,即第 0 层,并且可以使用仙术,所以 dp[0][0]=0。 2. 其他状态初始化为 INF。 3. 跳一层来自 dp[i-1][0],跳两层来自 dp[i-2][0]。 4. 最后到达第 n 层时,无论能不能继续跳都已经完成任务,因此答案取两个状态最小值。 |
| 下次看到什么信号,我应该想到这个方法 | 看到"同一个位置存在不同内部状态""技能冷却""是否有体力""是否持有某种东西"等条件,想到:状态机 DP,使用 dp[i][state] 记录额外状态。 |
AC 完整代码
cpp
#include<iostream>
#include<queue>
#include<algorithm>
#include<cmath>
#include<iomanip>
using namespace std;
int dp[1000005][2];
const int INF=1e9;
int main(){
int n;
cin>>n;
int v[n+1];
for(int i=1;i<=n;i++){
cin>>v[i];
}
for(int i=0;i<=n;i++){
dp[i][0]=INF;
dp[i][1]=INF;
}
dp[0][0]=0;
for(int i=1;i<=n;i++){
// 跳一层到第 i 层
dp[i][1]=min(
dp[i][1],
dp[i-1][0]
);
// 爬一层到第 i 层
dp[i][0]=min(
dp[i-1][1],
dp[i-1][0]
)+v[i];
// 跳两层到第 i 层
if(i>=2){
dp[i][1]=min(
dp[i][1],
dp[i-2][0]
);
}
}
cout<<min(dp[n][0],dp[n][1]);
return 0;
}
本题知识点总结
1. 为什么不能只定义 dp[i]
如果只写:
cpp
dp[i]
表示到达第 i 层的最短时间,会丢失一个非常重要的信息:
text
当前还能不能使用仙术?
例如,同样到达第 5 层:
text
情况1:刚刚是爬上来的
→ 可以继续跳
情况2:刚刚是跳上来的
→ 不能继续跳
后续决策不同,因此这两个状态不能合并。
2. 状态定义
cpp
dp[i][0]
表示:
到达第
i层,并且当前可以使用仙术时的最短时间。
cpp
dp[i][1]
表示:
到达第
i层,并且当前不能使用仙术时的最短时间。
3. 爬一层的转移
无论之前:
text
能跳
还是:
text
不能跳
只要爬过一层,就满足了"休息至少一层"的要求。
所以:
cpp
dp[i][0]
=
min(
dp[i-1][0],
dp[i-1][1]
)
+
v[i];
这里的:
cpp
+v[i]
表示爬第 i 层需要花费的时间。
4. 跳一层的转移
仙术只有在当前"可以跳"的状态下才能使用。
所以:
cpp
dp[i][1]
=
min(
dp[i][1],
dp[i-1][0]
);
跳跃免费,因此不需要:
cpp
+v[i]
5. 跳两层的转移
同样必须从能使用仙术的状态过来:
cpp
dp[i][1]
=
min(
dp[i][1],
dp[i-2][0]
);
但是只有:
text
i>=2
时才存在 i-2 层,因此需要判断边界。
6. 为什么 DP 要初始化为 INF
如果数组默认都是 0:
cpp
dp[i][0]=0;
dp[i][1]=0;
那么:
cpp
min(...)
会错误地认为某些根本不存在的路径耗时为 0。
所以:
cpp
dp[i][0]=INF;
dp[i][1]=INF;
表示这些状态一开始不可达。
唯一合法初始状态:
cpp
dp[0][0]=0;
表示:
初始在地面,并且仙术可以使用。
7. 为什么最后取两个状态最小值
到达第 n 层时:
text
可能是爬上去的
对应:
cpp
dp[n][0]
也可能:
text
是跳上去的
对应:
cpp
dp[n][1]
无论是哪种情况,都已经到达塔顶。
所以答案:
cpp
min(dp[n][0],dp[n][1])
与普通线性 DP 对比
| 模型 | 状态 |
|---|---|
| 最大子段和 | dp[i] |
| 普通爬楼梯 | dp[i] |
| P2800 又上锁妖塔 | dp[i][0/1] |
区别在于:
P2800 到达同一个位置后,还有"能否使用仙术"这个额外状态,因此需要多开一维。
状态机 DP 识别信号
以后看到:
text
有没有钥匙
有没有体力
能不能使用技能
技能是否冷却
当前持有什么货币
当前是否持股
今天工作还是休息
都要考虑:
cpp
dp[i][state]
一句话总结
当"当前位置"不足以决定后续转移时,就把影响未来的额外信息加入 DP 状态;本题就是用
0/1记录当前能不能继续使用仙术。