行只能单调向下,所以逐行决策;再证明走完一行后最优解必停在该行线段的端点,把每行状态压缩成左、右两个,得到 O(n) 线性 DP。
OJ: luogu
题目 ID: P3842
难度:普及
标签:动态规划线性DP思维
创建: 2026-10-23 16:38
更新: 2026-10-23 17:02
形式化题目
从
要求路径在第
因为不能向上走,行只能单调递增,所以「覆盖完第
暴力解法
思路
不做任何结构性假设,直接把「人在哪、这一行走到什么程度」当成状态搜最短路:
- 状态
:在第 行第 列, 是 2 位掩码,记录这一行的 、 是否已经踩过; - 移动左 / 右:行不变,列
,同时点亮新位置的端点(如果它是端点); - 移动向下:只有
(两端都踩过)时允许,行 ,列不变; - 起点
,终点 ,BFS 求最短路。
其中
这个做法把所有可能路线都搜过了,显然正确。但状态数是
代码
/**
* 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-23 16:38
* update_at: 2026-10-23 16:38
*/
// brute.cpp:小数据暴力解,用来帮助理解题意并辅助对拍。
// brute.cpp:小数据暴力解,用 BFS 在「网格位置 + 本行已覆盖左右端点」的状态图上搜最短路。
// 不做任何结构性假设(不比 main.cpp 少搜任何一种走法),所以能真正检验 main 的正确性。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 12; // 暴力只处理小数据:状态数 = n * n * 4,BFS 规模 n^2
int n;
int L[MAXN], R[MAXN];
// dist[row][col][mask]:到达 (row, col) 且第 row 行的覆盖情况为 mask 时的最少步数
// mask 的第 1 位表示左端点 L[row] 已走过,第 2 位表示右端点 R[row] 已走过
int dist[MAXN][MAXN][4];
bool visited[MAXN][MAXN][4];
// 计算站在 (row, col) 时本行的覆盖情况
int mask_at(int row, int col) {
int mask = 0;
if (col == L[row]) {
mask |= 1;
}
if (col == R[row]) {
mask |= 2;
}
// 左端点必须被真正“踩过”,L == R 时一个位置同时代表两端
return mask;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n;
for (int i = 1; i <= n; i++) {
cin >> L[i] >> R[i];
}
memset(visited, 0, sizeof(visited));
memset(dist, 0x3f, sizeof(dist));
queue<int> q_row, q_col, q_mask;
// 起点 (1,1)
int start_mask = mask_at(1, 1);
dist[1][1][start_mask] = 0;
visited[1][1][start_mask] = true;
q_row.push(1);
q_col.push(1);
q_mask.push(start_mask);
int answer = 1 << 30;
while (!q_row.empty()) {
int row = q_row.front(); q_row.pop();
int col = q_col.front(); q_col.pop();
int mask = q_mask.front(); q_mask.pop();
int d = dist[row][col][mask];
// 到达 (n,n) 且最后一行已覆盖完,就是一个合法终点
if (row == n && col == n && mask == 3) {
answer = min(answer, d);
}
// 三种移动之一:向左
if (col - 1 >= 1) {
int ncol = col - 1;
int nmask = mask | mask_at(row, ncol);
if (!visited[row][ncol][nmask]) {
visited[row][ncol][nmask] = true;
dist[row][ncol][nmask] = d + 1;
q_row.push(row); q_col.push(ncol); q_mask.push(nmask);
}
}
// 向右
if (col + 1 <= n) {
int ncol = col + 1;
int nmask = mask | mask_at(row, ncol);
if (!visited[row][ncol][nmask]) {
visited[row][ncol][nmask] = true;
dist[row][ncol][nmask] = d + 1;
q_row.push(row); q_col.push(ncol); q_mask.push(nmask);
}
}
// 向下:只有本行线段走完了才允许
if (row + 1 <= n && mask == 3) {
int nrow = row + 1;
int nmask = mask_at(nrow, col);
if (!visited[nrow][col][nmask]) {
visited[nrow][col][nmask] = true;
dist[nrow][col][nmask] = d + 1;
q_row.push(nrow); q_col.push(col); q_mask.push(nmask);
}
}
}
cout << answer << "\n";
return 0;
}复杂度
状态数
瓶颈
瓶颈在于:状态里塞了太多「本行已经走到哪」的信息。
所以关键问题是:
走完某一行时,人可能停在哪一列?这个集合能不能缩得很小?
正解
关键观察
观察一:行只能单调向下,所以逐行决策。
不能向上走,于是第
观察二:走完一行后,最优解一定停在这行的端点上。
这是整个状态压缩的根基,值得认真证一遍。
先记一个事实。设
理由是:若已经有一条从
现在取一条最优路径,看它在第
下面分三种情况,把本行的结束列
(1)
(2)
(3)
三种情况都有同一条尾巴:从「最后一次踩到端点
- 第
行的代价至少减少 ; - 第
行的进入列从 变成 ,由 1-Lipschitz 性质,后续代价最多增加 。
一减一增互相抵消,总代价不增加,而新的结束列是端点。
这里要小心一个错误说法:「从端点出发去任何地方都不会更远」是假的——端点离区间另一侧的点确实更远(如
最后一行是唯一的例外:它没有「下一行」来吸收这
所以存在一条最优解,使它在每一行结束时都停在该行的端点上。于是「本行覆盖到哪一列」这块内部状态可以整体消掉,每行只保留 2 个状态。
观察三:每行的最优走法只有两种。
从进入列
| 走法 | 结束列 | 水平代价 |
|---|---|---|
| 先到 |
||
| 先到 |
而且这两种都是各自的最优:要结束在
思路
定义两个状态(
:走完第 行、最后停在第 行左端点 的最短路程; :走完第 行、最后停在第 行右端点 的最短路程。
第 1 行从
(停到左端点
第
注意
收尾:从第
用样例验证一遍(粗体是该行的最优选择):
| 行 | 线段 |
最优停靠点 | ||
|---|---|---|---|---|
| 1 | 6 | 9 | 5 | |
| 2 | 3 | 9 | 10 | |
| 3 | 1 | 12 | 14 | |
| 4 | 2 | 15 | 14 | |
| 5 | 6 | 22 | 19 | |
| 6 | 4 | 22 | 23 |
(粗体是该行较小的那个状态。)第 6 行更小的是
代码
/**
* 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-23 16:38
* update_at: 2026-10-23 16:38
*/
// main.cpp:线扫一遍,每行只保留「停在线段左端点 / 右端点」两个状态
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int MAXN = 20005;
ll n;
ll L[MAXN], R[MAXN];
// dp_left[i]:走完第 1..i 行的线段,最后停在第 i 行线段的左端点 L[i] 时的最短路程
// dp_right[i]:同上,最后停在第 i 行线段的右端点 R[i]
ll dp_left[MAXN], dp_right[MAXN];
ll abs_dist(ll a, ll b) {
if (a > b) {
return a - b;
}
return b - a;
}
void read_input() {
cin >> n;
for (ll i = 1; i <= n; i++) {
cin >> L[i] >> R[i];
}
}
void solve() {
// 第 1 行从 (1,1) 出发。
// 想停到 L[1]:先走到 R[1] 再折回 L[1];想停到 R[1]:直接走到 L[1] 再一路往右。
ll len1 = R[1] - L[1];
dp_left[1] = abs_dist(1, R[1]) + len1;
dp_right[1] = abs_dist(1, L[1]) + len1;
for (ll i = 2; i <= n; i++) {
ll len = R[i] - L[i];
// 上一行无论停在哪,都要先竖直向下走到本行的同一列(代价 1),
// 然后在本行从这一列出发,覆盖 L[i] 与 R[i],最后停到目标端点。
// 停到 L[i]:必须先到 R[i] 再折回,水平代价 = |入口列 - R[i]| + len
dp_left[i] = min(dp_left[i - 1] + abs_dist(L[i - 1], R[i]),
dp_right[i - 1] + abs_dist(R[i - 1], R[i]))
+ len + 1;
// 停到 R[i]:必须先到 L[i] 再一路往右,水平代价 = |入口列 - L[i]| + len
dp_right[i] = min(dp_left[i - 1] + abs_dist(L[i - 1], L[i]),
dp_right[i - 1] + abs_dist(R[i - 1], L[i]))
+ len + 1;
}
// 最后还要从第 n 行的停靠点走到 (n,n),只差水平距离
ll answer = min(dp_left[n] + abs_dist(L[n], n), dp_right[n] + abs_dist(R[n], n));
cout << answer << "\n";
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
read_input();
solve();
return 0;
}复杂度
每行常数次运算,时间
总结
这题的两步都很典型:
- 结构观察:不能向上走 ⇒ 行单调 ⇒ 逐行决策,把图搜索变成序列决策。
- 状态压缩:走完一行后停在区间内部是纯浪费(之后不会回来补),所以最优解必停在端点 ⇒ 每行只剩两个状态。
可以浓缩成一句口诀:
行不能回头,所以逐行做;行内不折返,所以只留两端。
实现上有三个容易写错的细节:
是「停在左端点」,它的进入列是 右端点 ,绝对值里别写反; - 每行转移要额外
(向下走一步),容易漏; - 第 1 行从
出发,也要按同样两种走法初始化;最后还要补上从停靠点到 的水平距离。