路径 - 题解
标签与难度
标签: 动态规划, DP优化, 排序, 扫描线, 前缀和思想, 计算几何 难度: 1900
题目大意喵~
你好呀,指挥官!这道题是这样的:
在一个二维平面上,散落着 n 个可爱的点,每个点都有自己的 (x, y) 坐标。我们还有一个由 'L', 'R', 'U', 'D' 组成的指令字符串 s,长度为 m。
我们的任务是,判断是否存在一个由 m+1 个点组成的序列 p_0, p_1, ..., p_m(这些点都必须从给定的 n 个点中选取,但可以重复哦!),这个序列需要满足 m 个移动条件。具体来说,对于每一步 i (从 0 到 m-1),从点 p_i 移动到点 p_{i+1} 必须严格遵守指令 s[i] 的规则:
L(左):p_{i+1}必须在p_i的正左方,也就是说x_{p_{i+1}} < x_{p_i}并且y_{p_{i+1}} = y_{p_i}。R(右):p_{i+1}必须在p_i的正右方,x_{p_{i+1}} > x_{p_i}且y_{p_{i+1}} = y_{p_i}。U(上):p_{i+1}必须在p_i的正上方,x_{p_{i+1}} = x_{p_i}且y_{p_{i+1}} > y_{p_i}。D(下):p_{i+1}必须在p_i的正下方,x_{p_{i+1}} = x_{p_i}且y_{p_{i+1}} < y_{p_i}。
如果能找到至少一条这样的路径,我们就开心地输出 "YES",否则就只好遗憾地输出 "NO" 啦,喵~
解题思路分析
这道题要求我们判断一个满足特定条件的“路径”是否存在。当我们看到这种“经过一系列步骤,判断某种状态是否可达”的问题时,我的直觉就会告诉我:这很可能是动态规划 (Dynamic Programming) 的舞台,喵!
1. 朴素的DP想法
首先,我们来定义一下DP的状态。一个很自然的想法是: dp[i][j] 表示:在执行了 i 步移动指令后,路径有没有可能正好结束在第 j 个点上呢?
- 状态定义:
dp[i][j]是一个布尔值,true代表可能,false代表不可能。 - 路径长度: 我们的路径有
m+1个点,p_0, p_1, ..., p_m,这对应了m步移动。所以i的范围是0到m,j的范围是0到n-1。 - 基础状态 (Base Case): 当
i=0时,我们还没有开始移动。路径的第一个点p_0可以是任意一个给定的点。所以,dp[0][j] = true对所有的j都成立。 - 状态转移: 这是最核心的部分!我们怎么计算
dp[i][j]呢?dp[i][j]为true的条件是:存在至少一个点k,它在第i-1步是可达的 (dp[i-1][k] == true),并且从点k到点j的移动符合第i-1个指令s[i-1]。
用公式来表达就是:
其中 can_move(k, j, command) 函数判断从点 k 到点 j 是否符合 command 的要求。\bigvee 是逻辑或(OR)的意思。
这个思路很直接,但我们来分析一下它的复杂度。DP表的大小是 (m+1) x n。计算每个 dp[i][j] 都需要遍历 n 个可能的上一步终点 k。所以总的时间复杂度是 。 题目中 n 和 m 最大可以是 2000,2000 * 2000^2 = 8 \times 10^9,这速度比我的尾巴摇得还慢,肯定会超时的说!必须想办法优化!
2. 优化的DP思路,喵!
我们再仔细看看状态转移的瓶颈:为了确定 dp[i][j],我们检查了所有的 k。但真的有必要吗?
比如说,当指令是 U (上) 时,从点 k 移动到点 j 的条件是 x_k = x_j 且 y_j > y_k。 看呐!这个条件告诉我们,点 k 和点 j 必须在同一条竖直线上!我们根本不需要检查那些 x 坐标和 j 不同的点!
这给了我们一个绝妙的优化思路:把点按坐标分组处理!
预处理:
- 我们创建两个数据结构。一个用来存放所有在同一竖直线上的点,另一个用来存放所有在同一水平线上的点。
std::map就很适合这个任务,喵~ points_on_vertical[x]:一个列表,包含所有 x 坐标为x的点。points_on_horizontal[y]:一个列表,包含所有 y 坐标为y的点。- 为了方便处理,我们把每个列表里的点按另一个坐标排序。比如,
points_on_vertical[x]里的点按y坐标从小到大排序;points_on_horizontal[y]里的点按x坐标从小到大排序。
- 我们创建两个数据结构。一个用来存放所有在同一竖直线上的点,另一个用来存放所有在同一水平线上的点。
优化后的状态转移: 现在,我们来重新审视状态转移的过程。我们依然是按步骤
i从 1 到m来计算dp[i]。当指令
s[i-1]是 'U' (上) 时:- 我们遍历所有竖直线(即
points_on_vertical里的每一个x坐标分组)。 - 对于某条竖直线上的点(已经按
y升序排好),我们从下往上扫描。 - 我们用一个
bool变量prev_possible来记录,在这条线上,当前点下方是否存在一个在第i-1步可达的点。 - 扫描开始时,
prev_possible = false。 - 对于线上的每个点
p:dp[i][p]的值就等于prev_possible。因为要到达p,必须从它下面的某个点k过来,而prev_possible正好告诉我们是否存在这样的k。- 然后,我们更新
prev_possible:prev_possible = prev_possible | dp[i-1][p]。这样,点p自己也可能成为后续更高点的“上一步”了。
- 我们遍历所有竖直线(即
当指令
s[i-1]是 'D' (下) 时:- 逻辑类似,但这次我们要从上往下扫描(
y坐标从大到小)。prev_possible记录的是当前点上方是否存在可达点。
- 逻辑类似,但这次我们要从上往下扫描(
当指令
s[i-1]是 'R' (右) 时:- 我们遍历所有水平线,按
x坐标从小到大扫描。prev_possible记录的是当前点左方是否存在可达点。
- 我们遍历所有水平线,按
当指令
s[i-1]是 'L' (左) 时:- 我们遍历所有水平线,按
x坐标从大到小扫描。prev_possible记录的是当前点右方是否存在可达点。
- 我们遍历所有水平线,按
通过这种方式,对于每一步 i,我们只需要遍历所有点一次(按分组后的顺序)。所以每一步的计算复杂度从 降到了 !
3. 最终的方案
- 预处理:
O(n \log n),主要是排序。 - DP计算:
m步,每步O(n)。总共O(m \cdot n)。 - 最终答案: 在计算完所有
dp表后,我们检查dp[m]这一行。只要有任何一个dp[m][j]为true,就说明存在一条合法路径,输出 "YES"。如果dp[m]全是false,就输出 "NO"。
这个 O(n \log n + m \cdot n) 的方案就快多啦,完全可以通过!这种利用问题约束(如坐标关系)来优化DP转移的方法,就像猫咪找到最快的路径抓老鼠一样,高效又优雅,喵~
代码实现
下面是我根据上面的思路,精心重构的一份代码,希望能帮到你,喵~
#include <iostream>
#include <vector>
#include <string>
#include <map>
#include <algorithm>
// 为了方便,我们定义一个点的结构体
struct Point {
int id;
int x, y;
};
void solve() {
int n, m;
std::cin >> n >> m;
std::string s;
std::cin >> s;
std::vector<Point> points(n);
// 使用 map 来按坐标分组,喵~
// map 的 key 是 x/y 坐标,value 是这条线上所有点的列表
// 列表里存 pair<坐标, 点的原始id>,方便排序
std::map<int, std::vector<std::pair<int, int>>> vertical_lines;
std::map<int, std::vector<std::pair<int, int>>> horizontal_lines;
for (int i = 0; i < n; ++i) {
points[i].id = i;
std::cin >> points[i].x >> points[i].y;
vertical_lines[points[i].x].push_back({points[i].y, i});
horizontal_lines[points[i].y].push_back({points[i].x, i});
}
// 对每个分组内的点进行排序
for (auto& pair : vertical_lines) {
std::sort(pair.second.begin(), pair.second.end());
}
for (auto& pair : horizontal_lines) {
std::sort(pair.second.begin(), pair.second.end());
}
// dp[i][j]: 走 i 步后,是否可能停在点 j
// 我们用两个 vector 来滚动优化空间,一个存上一步状态,一个存当前步状态
std::vector<bool> prev_dp(n, true); // 第0步,任何点都可以是起点
std::vector<bool> curr_dp(n, false);
for (int i = 0; i < m; ++i) {
std::fill(curr_dp.begin(), curr_dp.end(), false); // 重置当前步的状态
char move = s[i];
if (move == 'U') {
for (auto const& [x_coord, line_points] : vertical_lines) {
bool possible_prev = false;
// 从下往上扫描
for (auto const& p : line_points) {
int point_id = p.second;
curr_dp[point_id] = possible_prev;
possible_prev |= prev_dp[point_id];
}
}
} else if (move == 'D') {
for (auto const& [x_coord, line_points] : vertical_lines) {
bool possible_prev = false;
// 从上往下扫描
for (auto it = line_points.rbegin(); it != line_points.rend(); ++it) {
int point_id = it->second;
curr_dp[point_id] = possible_prev;
possible_prev |= prev_dp[point_id];
}
}
} else if (move == 'R') {
for (auto const& [y_coord, line_points] : horizontal_lines) {
bool possible_prev = false;
// 从左往右扫描
for (auto const& p : line_points) {
int point_id = p.second;
curr_dp[point_id] = possible_prev;
possible_prev |= prev_dp[point_id];
}
}
} else if (move == 'L') {
for (auto const& [y_coord, line_points] : horizontal_lines) {
bool possible_prev = false;
// 从右往左扫描
for (auto it = line_points.rbegin(); it != line_points.rend(); ++it) {
int point_id = it->second;
curr_dp[point_id] = possible_prev;
possible_prev |= prev_dp[point_id];
}
}
}
prev_dp = curr_dp; // 滚动到下一步
}
bool possible_path = false;
for (int i = 0; i < n; ++i) {
if (curr_dp[i]) {
possible_path = true;
break;
}
}
if (possible_path) {
std::cout << "YES\n";
} else {
std::cout << "NO\n";
}
}
int main() {
// 加速输入输出,像猫一样敏捷!
std::ios_base::sync_with_stdio(false);
std::cin.tie(NULL);
int t;
std::cin >> t;
while (t--) {
solve();
}
return 0;
}复杂度分析
时间复杂度:
- 对于每组测试数据
T: - 读取输入和分组点需要 。
- 对
map中的每个vector进行排序,所有点合起来总共被排序一次,所以是 。 - DP的主循环执行
M次。在每次循环中,我们遍历所有分组,而每个点恰好属于一个分组,所以内层操作的总和是 。因此,DP部分是 。 - 总和起来就是 啦。
- 对于每组测试数据
空间复杂度: 或
- 存储
n个点和分组后的点需要 空间。 - DP表如果完整存储是 。但在我的代码实现中,我使用了滚动数组的技巧(用
prev_dp和curr_dp),只保留了上一步和当前步的状态,所以DP部分的空间复杂度降到了 。因此,整体空间复杂度是 ,非常节省内存的说!
- 存储
知识点总结
这道题真是一次有趣的冒险,让我们来总结一下学到的东西吧,喵~
- 动态规划 (DP): 它是解决这类多阶段决策问题的基石。准确定义
dp状态是成功的第一步。 - DP状态优化: 从 到 的优化是本题的精髓。关键在于利用题目给出的约束条件(移动必须在同一直线上)来减少不必要的计算。
- 按坐标分组与排序: 这是计算几何和相关问题中非常常用的技巧。将看似杂乱的点按坐标组织起来,能极大地简化问题结构。
- 前缀/后缀思想 (扫描线): 在排好序的序列上,用一个变量(
possible_prev)来维护“到目前为止”的信息,这本质上是一种前缀和(或后缀和)的思想。在这里,我们求的是“逻辑或”的前缀和。 - 滚动数组: 当DP状态只依赖于前一(或前几)个状态时,可以使用滚动数组来优化空间复杂度,这在
M或N很大时尤其重要。
希望这篇题解能让你对DP优化有更深的理解!继续加油哦,指挥官,编程的世界就像一个大大的毛线球,充满了探索的乐趣!喵~