1. 题目解析与算法选择
这道题目来自AtCoder Beginner Contest 246的E题,题目名为"Bishop 2"。题目描述了一个棋盘上的象移动问题:给定一个N×N的棋盘,其中某些格子是障碍物(用'#'表示),空白格子用'.'表示。我们需要计算象从起点(si, sj)到终点(ti, tj)所需的最少移动次数,如果无法到达则输出-1。
1.1 题目特点分析
象在国际象棋中的移动规则是沿对角线方向任意格数移动。这道题的特殊之处在于:
- 棋盘上存在障碍物,象不能穿过障碍物
- 每次改变移动方向算作一次新的移动
- 沿着同一方向连续移动不增加移动次数
这些特点使得传统的BFS算法不能直接应用,因为:
- 普通BFS会将每次移动都视为一步,无法处理"同一方向连续移动不算新步数"的规则
- 需要记录移动方向信息来判断是否改变方向
1.2 算法选择思路
针对这个问题,我们考虑以下几种算法方案:
-
普通BFS:
- 无法区分方向变化,会错误计算步数
- 时间复杂度O(n²),但无法得到正确结果
-
Dijkstra算法:
- 可以处理不同权重的边
- 但时间复杂度O(n² log n),对于n≤1500来说可能不够高效
-
分层图+01BFS:
- 将每个格子拆分为4个状态,代表4个移动方向
- 同方向移动边权为0,改变方向边权为1
- 时间复杂度O(4n²),空间复杂度O(4n²)
- 完美匹配题目特性
经过比较,我们选择分层图+01BFS的方案,因为它:
- 准确反映了题目中的移动规则
- 保持了较高的效率
- 实现相对简洁
2. 分层图设计与01BFS原理
2.1 分层图设计
分层图的核心思想是将原问题中的每个物理位置拆分为多个逻辑状态。在这个问题中,我们为每个格子(i,j)创建4个状态,对应4个对角线移动方向:
- 左上方向(dx=-1, dy=-1)
- 右上方向(dx=-1, dy=1)
- 左下方向(dx=1, dy=-1)
- 右下方向(dx=1, dy=1)
这样,整个图就变成了一个四层的结构,每个物理位置对应4个节点。
2.2 状态转移规则
在分层图中,我们定义两种转移:
-
同方向移动:
- 边权为0(不算作新的一步)
- 例如:从(i,j,左上)可以移动到(i-1,j-1,左上),代价0
-
改变方向移动:
- 边权为1(算作新的一步)
- 例如:从(i,j,左上)可以移动到(i-1,j+1,右上),代价1
这种设计完美捕捉了题目中"同一方向连续移动不算新步数"的规则。
2.3 01BFS算法原理
01BFS是BFS的一种变体,专门处理边权只有0和1的图。其核心思想是:
- 使用双端队列(deque)来维护待处理的节点
- 遇到边权为0的转移,将节点加入队列前端(类似BFS的同层处理)
- 遇到边权为1的转移,将节点加入队列末尾(类似BFS的下一层处理)
- 保证队列中的节点按照距离从小到大排列
这种处理方式的时间复杂度仍然是O(V+E),与普通BFS相同,但能正确处理0/1边权的图。
提示:01BFS可以看作是Dijkstra算法在边权仅为0和1时的特化优化版本,去掉了优先队列的开销。
3. 代码实现详解
3.1 数据结构定义
cpp复制#include <bits/stdc++.h>
namespace ranges = std::ranges;
namespace views = std::views;
using i64 = long long;
using u64 = unsigned long long;
constexpr int inf = 1E9;
int dx[]{-1, -1, 1, 1}; // 四个对角线方向
int dy[]{-1, 1, -1, 1};
这里定义了:
- 四个对角线方向的位移数组dx和dy
- 无穷大常量inf,用于初始化距离
- 使用了C++20的ranges和views命名空间别名
3.2 输入处理和初始化
cpp复制int main() {
std::ios::sync_with_stdio(false);
std::cin.tie(nullptr);
int n;
std::cin >> n;
int si, sj, ti, tj;
std::cin >> si >> sj >> ti >> tj;
--si, --sj, --ti, --tj; // 转换为0-based索引
std::vector<std::string> s(n);
for (int i = 0; i < n; ++i) {
std::cin >> s[i];
}
处理输入数据:
- 读取棋盘大小n
- 读取起点和终点坐标,并转换为0-based
- 读取棋盘数据,存储在字符串向量s中
3.3 距离数组和队列初始化
cpp复制std::vector dis(n, std::vector<std::array<int, 4>>(n, {inf, inf, inf, inf}));
dis[si][sj].fill(0);
std::deque<std::array<int, 3>> q;
- dis数组是一个三维数组:dis[i][j][k]表示到达(i,j)点且最后移动方向为k的最小步数
- 起点四个方向的距离初始化为0
- 使用双端队列q来存储待处理的状态
3.4 初始状态处理
cpp复制for (int i = 0; i < 4; ++i) {
int x = si + dx[i], y = sj + dy[i];
if (x < 0 || x >= n || y < 0 || y >= n || s[x][y] == '#') {
continue;
}
q.push_back({x, y, i});
dis[x][y][i] = 1;
}
处理起点的四个可能移动方向:
- 检查移动后的位置是否合法(在棋盘内且不是障碍物)
- 合法的移动加入队列,距离设为1(因为改变了初始静止状态)
3.5 01BFS主循环
cpp复制while (!q.empty()) {
auto [x, y, i] = q.front();
q.pop_front();
// 同方向移动处理
int nx = x + dx[i], ny = y + dy[i];
if (!(nx < 0 || nx >= n || ny < 0 || ny >= n || s[nx][ny] == '#')) {
if (dis[nx][ny][i] > dis[x][y][i]) {
dis[nx][ny][i] = dis[x][y][i];
q.push_front({nx, ny, i}); // 边权0,加入队列前端
}
}
// 改变方向处理
for (int j = 0; j < 4; ++j) {
if (i == j) continue; // 同方向已处理
nx = x + dx[j], ny = y + dy[j];
if (!(nx < 0 || nx >= n || ny < 0 || ny >= n || s[nx][ny] == '#')) {
if (dis[nx][ny][j] > dis[x][y][i] + 1) {
dis[nx][ny][j] = dis[x][y][i] + 1;
q.push_back({nx, ny, j}); // 边权1,加入队列末尾
}
}
}
}
主循环处理:
- 从队列取出一个状态(x,y,i)
- 尝试同方向移动:
- 如果移动合法且可以改善距离,更新距离并加入队列前端(边权0)
- 尝试改变方向移动:
- 对其他三个方向,如果移动合法且可以改善距离,更新距离并加入队列末尾(边权1)
3.6 结果输出
cpp复制int ans = ranges::min(dis[ti][tj]);
if (ans == inf) {
ans = -1;
}
std::cout << ans << '\n';
- 取终点四个方向中的最小距离作为答案
- 如果仍然是inf,说明不可达,输出-1
4. 算法优化与注意事项
4.1 时间复杂度分析
- 每个格子有4个状态,总共4n²个节点
- 每个节点最多处理4次(四个方向)
- 因此时间复杂度确实是O(4n²),即O(n²)
- 空间复杂度也是O(4n²)
4.2 实现细节注意事项
-
坐标转换:
- 题目输入是1-based坐标,需要转换为0-based
- 忘记转换会导致数组越界或错误结果
-
障碍物检查:
- 每次移动前必须检查目标格子是否是障碍物
- 也要检查是否超出棋盘边界
-
距离更新条件:
- 只有能改善当前距离时才更新并加入队列
- 这是保证效率的关键
-
队列操作:
- 同方向移动(边权0)加入队列前端
- 改变方向(边权1)加入队列末尾
- 混淆两者会导致错误结果
4.3 常见错误与调试技巧
-
WA(错误答案):
- 检查坐标转换是否正确
- 验证障碍物判断逻辑
- 确保距离更新条件正确(严格大于时才更新)
-
TLE(时间限制 exceeded):
- 确保没有不必要的重复计算
- 检查队列操作是否正确(前端/后端)
- 使用更快的IO方法(如代码中的sync_with_stdio)
-
RE(运行时错误):
- 检查数组边界
- 确保没有访问越界
- 检查棋盘读取是否正确
提示:在竞赛中,可以添加一些调试输出来验证算法的中间状态,比如打印队列内容或距离数组的部分值。
5. 算法扩展与应用
这种分层图+01BFS的技术可以应用于许多类似的问题场景:
-
其他棋盘问题:
- 车(rook)的移动问题(横向/纵向移动)
- 骑士(knight)的移动问题(L型移动)
-
方向相关的路径规划:
- 机器人导航中考虑转向代价
- 车辆路径规划中考虑方向变化成本
-
其他边权为0/1的图问题:
- 某些状态转移问题中,部分转移无代价,部分有代价
- 可以建模为0/1边权图的问题
对于更一般的情况,如果边权不只是0和1,而是有更多种小整数权重,可以考虑使用" Dial's algorithm"(一种使用多个桶的Dijkstra优化算法)。
在实际应用中,这种技术的关键在于:
- 正确识别问题中的状态维度(如此题中的方向)
- 合理设计状态转移的边权
- 选择合适的最短路径算法(普通BFS、01BFS、Dijkstra等)
我曾在一次机器人路径规划项目中应用类似技术,处理机器人转向需要额外时间的情况。通过将位置和方向组合为状态,并使用优先级队列处理不同转向时间,成功优化了路径规划效率。这种从竞赛算法到实际应用的转换,往往需要对问题有深入理解并灵活调整算法细节。