骑士的最短路径:从暴力递归到终极优化
今天咱们聊一个老朋友,国际象棋里的“马”。它的走法很特别,走“日”字。在棋盘上,从一个点 (0,0) 跳到另一个点 (x, y),最少要跳几步?
这个问题,面试里挺常见的。它不是一个纯粹的模拟题,更像是一个图论问题,但它的解法演进过程,非常清晰地展示了我们解决算法问题时思维提升的路径。
咱们就从最愣头青的解法开始,一步步把它干到最优。
解法一:暴力递归(天真的莽夫)
拿到这个问题,我的第一反应是啥?
一个点有8个方向可以跳。那我就从 (0,0) 开始,对着8个方向,挨个“递归”地去试。哪个方向能最快跳到 (x, y),那不就行了?
这就是最纯粹的暴力搜索,或者叫深度优先搜索(DFS)。
思路很简单:
写一个函数
process(curX, curY),表示从(curX, curY)跳到目标(x, y)还需要多少步。Base Case: 如果
curX == x且curY == y,那说明已经到地方了,一步都不用走了,返回0。递归过程: 从
(curX, curY)遍历8个可以跳的位置(nextX, nextY)。对每个位置,我都去递归调用process(nextX, nextY),然后再加上我自己这一步(+1)。最后,把8个方向返回的结果取个最小值,就是答案。
听起来挺完美的。但是,这里有个巨大的坑。如果我的目标点是 (1,1),我从 (0,0) 可能跳到 (2,1),然后我又可能从 (2,1) 跳回 (0,0)。这不就死循环了?而且,棋盘是无限大的,我这一递归下去,天荒地老,栈都给你干爆了。
就算不考虑无限棋盘,这个递归也会疯狂地重复计算。比如,从 (0,0) 到 (4,4),我可能会经过 (2,1);另一条路线也可能经过 (2,1)。那 process(2,1) 这个子问题就会被重复算无数遍。
所以这个思路,纯属理论,代码写出来也是超时的命,我们就不贴出来丢人了。它只是我们思考的起点。
解法二:记忆化搜索(聪明的莽夫)
既然暴力递归有重复计算和死循环的问题,那就解决它。
解决重复计算:搞个缓存。我用一个
Map<String, Integer>来存已经算过的点的结果。key就是坐标 "x,y",value就是从这个点到终点的最小步数。每次递归前,先查缓存,有就直接返回,没有再算,算完存进去。解决死循环:搞个
Set<String>记录当前这条递归路径上已经访问过的点。递归下去之前,把当前点加进去;回溯的时候,再把它移出来。这样保证在一条路径上不会反复横跳。
这样一改,代码就能跑了,不会超时了。本质上还是DFS,但加上了缓存(Memoization),把一颗巨大的搜索树剪掉了很多重复的枝干。
但是,这真的是最好的方法吗?
DFS的特点是“一条路走到黑”。它会先深入探索某一个分支,直到找到目标或者碰壁。但它找到的第一个解,不一定是最短的。为了找到最短的,它必须把所有可能的路径都走一遍,然后比较。虽然有缓存,但搜索的“形状”是不变的。
这就像你在迷宫里找出口,DFS是见着一条路就闷头往里冲。对于“最短路径”这类问题,我们有更好的模型。
import java.util.HashMap;
import java.util.Map;
// 解法二:记忆化搜索 (DFS + Memoization)
// 注意:这个解法在某些平台仍然可能因为递归深度或搜索范围过大而超时,
// 但它体现了从暴力到优化的重要一步。
public class KnightMovesDFS {
private Map<String, Integer> memo = new HashMap<>();
public int minKnightMoves(int x, int y) {
// 由于棋盘对称性,我们只考虑第一象限,可以加速
return dfs(Math.abs(x), Math.abs(y));
}
private int dfs(int x, int y) {
// Base Case
if (x == 0 && y == 0) {
return 0;
}
if (x + y == 2) { // (1,1) -> 2, (0,2) -> 2, (2,0) -> 2
return 2;
}
String key = x + "," + y;
if (memo.containsKey(key)) {
return memo.get(key);
}
// 递归: 我们是从(x,y)往(0,0)找,这样目标唯一,更容易处理
// 骑士可以跳8个方向,反向跳也是一样的,所以从(x,y)跳到(x-1, y-2)等价于从(x-1, y-2)跳到(x,y)
// 我们只考虑跳向原点的方向,所以x和y都是减小的
int res = Math.min(
dfs(Math.abs(x - 1), Math.abs(y - 2)),
dfs(Math.abs(x - 2), Math.abs(y - 1))
) + 1;
memo.put(key, res);
return res;
}
}
这里的DFS代码做了个小技巧:从 (x, y) 往 (0, 0) 找,因为目标是固定的,更好处理。同时利用了对称性,只在第一象限搜索,并且只考虑让坐标减小的两个方向,这是一种强剪枝,能避免大部分死循环和无效搜索。
解法三:广度优先搜索(BFS,标准解法)
一提到“无权图的最短路径”,我的DNA就动了。这不就是BFS的专属领域吗?
把整个无限棋盘看作一个图,每个坐标点是一个节点。如果两个点可以通过一次骑士跳跃到达,它们之间就有一条边。我们要求 (0,0) 到 (x,y) 的最短路径,就是求这两个节点在图中的最短距离。
BFS的思想,就像往水里扔一块石头,波纹是一圈一圈往外扩散的。
第0圈:就是起点
(0,0)。第1圈:从
(0,0)跳一步能到的所有点。第2圈:从第1圈的点再跳一步能到的所有点。
...
它是一层一层地向外探索。所以,当它第一次到达目标点 (x,y) 的时候,经过的层数,一定就是最短的步数。
BFS的标准套路:
队列(Queue):用来存放待访问的节点。
集合(Set):用来存放已经访问过的节点,防止走回头路和重复处理。
循环:
把起点
(0,0)和步数0放进队列。把起点
(0,0)放进visited集合。当队列不空,就一直循环。
每一轮循环,处理当前队列里的所有节点(也就是同一层的节点)。
从队列里取出一个点,遍历它能跳到的8个新位置。
如果新位置没被访问过:
如果是终点,直接返回
当前步数 + 1。如果不是终点,就把它加入队列和
visited集合。
处理完一层后,总步数加1。
这个方法,逻辑清晰,是这类问题的标准模板,面试写这个,稳了。
代码实现与优化
import java.util.HashSet;
import java.util.LinkedList;
import java.util.Queue;
import java.util.Set;
// 解法三:广度优先搜索 (BFS)
public class KnightMovesBFS {
public int minKnightMoves(int x, int y) {
// 坐标取绝对值,利用对称性,把目标点统一到第一象限
// (1,2) 和 (-1, 2), (1, -2), (-1,-2) ... 步数都一样
x = Math.abs(x);
y = Math.abs(y);
Queue<int[]> queue = new LinkedList<>();
// 队列里存 {x, y}
queue.offer(new int[]{0, 0});
Set<String> visited = new HashSet<>();
// 记录访问过的点 "x,y"
visited.add("0,0");
// 8个方向
int[][] dirs = {{1, 2}, {2, 1}, {2, -1}, {1, -2},
{-1, -2}, {-2, -1}, {-2, 1}, {-1, 2}};
int steps = 0;
while (!queue.isEmpty()) {
int size = queue.size();
// 遍历当前层的所有节点
for (int i = 0; i < size; i++) {
int[] cur = queue.poll();
int curX = cur[0];
int curY = cur[1];
if (curX == x && curY == y) {
return steps;
}
// 探索8个方向
for (int[] dir : dirs) {
int nextX = curX + dir[0];
int nextY = curY + dir[1];
String nextPos = nextX + "," + nextY;
// 剪枝优化:因为目标在第一象限,没必要往负方向探索太远
// 比如目标是(1,1),我跳到(-10,-10)显然是绕远了
// nextX >= -1 和 nextY >= -1 是一个比较有效的经验剪枝
if (!visited.contains(nextPos) && nextX >= -1 && nextY >= -1) {
visited.add(nextPos);
queue.offer(new int[]{nextX, nextY});
}
}
}
steps++;
}
return -1; // 理论上总能到,走不到返回-1
}
}
这个BFS版本已经非常好了,引入了象限对称性和简单的边界剪枝,效率很高。
解法四:双向BFS(究极进化)
如果目标点 (x, y) 离 (0,0) 非常远,比如 (300, 300),那BFS的搜索圈会变得非常大。就像一个大饼,半径越大,面积(搜索的点数)是平方级增长的。
怎么优化?
既然你从起点出发可以找到终点,那从终点出发也一定能找到起点。
双向BFS就是这个思路:
一个BFS从
(0,0)开始,叫forwardQueue。另一个BFS从
(x,y)开始,叫backwardQueue。两个搜索同时进行,一圈一圈地扩大。
forwardQueue每扩展一层,就去backward的visited集合里看看有没有交集。backwardQueue每扩展一层,也去forward的visited集合里看看有没有交集。一旦在某一层扩展时,发现一个点已经被另一方的BFS访问过了,那就说明两军会师了!路径找到了!
总步数就是 forward 走的步数 + backward 走的步数。
为什么快? 假设最短路径是20步。
单向BFS要搜10层,搜索半径是10,面积大概是
π * 10^2。双向BFS,两边各搜5层,半径都是5,总面积大概是
2 * π * 5^2。100π对比50π,搜索空间直接砍半,甚至更多。距离越远,优势越明显。
这个实现起来稍微复杂一点,需要两个队列和两个 visited 集合,并且为了效率,每次都应该从节点数少的那一端开始扩展。但这个思想非常重要,是BFS的终极优化形态。
因为代码比较长,这里就不贴了,重点是理解这个“两头堵”的思想。
总结
回顾一下我们的心路历程:
暴力递归:最直观的想法,但会因重复计算和死循环而崩溃。
记忆化搜索:加上缓存,解决了重复计算,是暴力递归的优化版。但依然是DFS的深度探索模式,不一定最适合找最短路。
BFS:最短路径问题的标准答案。逐层搜索,保证了第一次找到的就是最优解。逻辑清晰,代码稳健。
双向BFS:BFS的进阶优化。当起点和终点距离很远时,能大幅缩减搜索空间。