「这是我参与2022首次更文挑战的第19天,活动详情查看:2022首次更文挑战」。
在一个 n x n 的国际象棋棋盘上,一个骑士从单元格 (row, column) 开始,并尝试进行 k 次移动。行和列是 从 0 开始 的,所以左上单元格是 (0,0) ,右下单元格是 (n - 1, n - 1) 。
象棋骑士有8种可能的走法,如下图所示。每次移动在基本方向上是两个单元格,然后在正交方向上是一个单元格。
每次骑士要移动时,它都会随机从8种可能的移动中选择一种(即使棋子会离开棋盘),然后移动到那里。
骑士继续移动,直到它走了 k 步或离开了棋盘。
返回 骑士在棋盘停止移动后仍留在棋盘上的概率 。
记忆化搜索
首先,观察题目给定的数据范围,并不是很大,所以,我们可以先尝试使用暴力搜索来解决。
这里的关键是如何求概率。
通过题意可知,向每个方向前进的概率为 1/8,只要没有走出去都可以增加 1/8 成功的概率,然后,再继续前进即可,把途径的 1/8 的概率相乘就是这一条路径的概率,然后求出所有可行路径的概率相加就是最终的概率。
class Solution {
private static final int[][] DIRS = {{1, 2}, {2, 1}, {-1, 2}, {2, -1}, {1, -2}, {-2, 1}, {-1, -2}, {-2, -1}};
public double knightProbability(int n, int k, int row, int column) {
return dfs(n, k, row, column);
}
public double dfs(int n, int k, int i, int j) {
if (i < 0 || j < 0 || i >= n || j >= n) {
return 0;
}
if (k == 0) {
return 1;
}
// 每一个方向的概率都是 1/8
double ans = 0;
for (int[] dir : DIRS) {
ans += dfs(n, k - 1, i + dir[0], j + dir[1]) / 8.0;
}
return ans;
}
}
这个拿去跑会卡在第 11 个用例,超时了,所以,要想办法优化一下。
优化也很简单,试想一下,当 k 比较大时,棋盘上的同一个位置在剩余 x 次时有可能会重复的到达,所以,我们需要加一个缓存,这也就是记忆化搜索。
还有一种更简单的方法,观察递归方法的签名:public double dfs(int n, int k, int i, int j),里面有三个可变的因素:i、j、k,所以,声明的缓存也需要有这三个维度。
请看代码:
class Solution {
private static final int[][] DIRS = {{1, 2}, {2, 1}, {-1, 2}, {2, -1}, {1, -2}, {-2, 1}, {-1, -2}, {-2, -1}};
public double knightProbability(int n, int k, int row, int column) {
// 记忆化搜索
double[][][] memo = new double[n][n][k + 1];
return dfs(n, k, row, column, memo);
}
public double dfs(int n, int k, int i, int j, double[][][] memo) {
// 走出边界了,这条路不通,概率为0
if (i < 0 || j < 0 || i >= n || j >= n) {
return 0;
}
// k 步走完了还没超出边界,这一步的概率是1,还需要乘上前面的 k 个 1/8
if (k == 0) {
return 1;
}
// 缓存中存在,直接返回
if (memo[i][j][k] != 0) {
return memo[i][j][k];
}
// 每一个方向的概率都是 1/8
double ans = 0;
for (int[] dir : DIRS) {
ans += dfs(n, k - 1, i + dir[0], j + dir[1], memo) / 8.0;
}
memo[i][j][k] = ans;
return ans;
}
}
- 时间复杂度:,最坏情况为走了所有格子,且走了 k 步,虽然每个格子有可能重复到达,但是有缓存可以降低时间复杂度,而暴力的时间复杂度是指数级别的,为 ,最坏情况是走了 k 步,所有的情况都还在棋盘内。
- 空间复杂度:。
动态规划
其实,有了记忆化搜索就很容易写出来动态规划了,对于动态规划不太熟悉的同学,也可以按照我的方法先写记忆化搜索,再转换成动态规划。
我们直接把记忆化搜索的缓存数组改成 dp 数组,再把递归改成迭代,就成了动态规划。
其实,记忆化搜索也是动态规划的一种写法啦。
所以,我们可以这样定义动态规划:
- 定义:
dp[i][j][k]表示走 k 步到坐标 [i, j] 位置的概率。 - 转移方程:要走到 [i, j] 的位置,必须要先走了 [i, j] 向外八个方向中的一个位置,而且是走了 k-1 步,所以,。
- 注意:记忆化搜索中 k 是从大到小的,动态规划中 k 需要从 0 开始慢慢增大。
class Solution {
private static final int[][] DIRS = {{1, 2}, {2, 1}, {-1, 2}, {2, -1}, {1, -2}, {-2, 1}, {-1, -2}, {-2, -1}};
public double knightProbability(int n, int k, int row, int column) {
// 动态规划
double[][][] dp = new double[n][n][k + 1];
// k 从 0 开始变大
for (int kk = 0; kk <= k; kk++) {
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
// 走 0 步就到 [i, j] 的概率为1
if (kk == 0) {
dp[i][j][kk] = 1;
} else {
// 八个方向
for (int[] dir : DIRS) {
// 上一个坐标,这里用 减号 也是可以的
int ni = i + dir[0];
int nj = j + dir[1];
if (ni >= 0 && nj >= 0 && ni < n && nj < n) {
dp[i][j][kk] += dp[ni][nj][kk - 1] / 8.0;
}
}
}
}
}
}
// 返回走 k 步到 [row, column] 坐标的概率
return dp[row][column][k];
}
}
运行结果如下,比记忆化搜索慢是因为动态规划必须要计算所有的 个格子走 步的概率,但是,记忆化搜索不需要,一条路径不满足,直接就返回了: