目录

题目描述

688. 骑士在棋盘上的概率

题意分析

一个骑士站在 $n \times n$ 棋盘的指定格子上,要走恰好 k 步。每一步它在八个「日」字方向里等概率地挑一个走,哪怕这一步会踏出棋盘也照走不误;一旦踏出去就永远停在外面,不会再回来。要求的是走完 k 步之后骑士仍然留在棋盘内的概率。

有两点必须从题面里读准,读错了整道题都会跑偏。第一,是「恰好 k 步」而不是「最多 k 步」,中途留在盘上不算数。第二,出界不是被禁止的动作,而是一个概率为 $1/8$ 的合法分支,只不过它把这条路径判了死刑;因此每一步的八个方向都要平摊概率,不能把越界的方向剔除后在剩下的方向里重新均分。

约束是 $n \le 25$、$k \le 100$,格子最多六百多个,步数最多一百,两者相乘只有几万,这个体量提示我们可以把「步数」直接当成一个维度铺开算。另外 k 允许取 0,此时骑士一步没走,概率是 1,实现时不能漏掉这个入口。

解法:动态规划状态转移

核心思路

最直白的做法是把所有可能的走法枚举一遍:每步八个选择,k 步就是 $8^k$ 条路径,统计其中全程不出界的比例。k 取 100 时这个数字大到没有任何意义,连一步都跑不完。

瓶颈在于路径条数是指数级的,但骑士实际能站的位置只有 $n^2$ 种。大量不同的路径最后落在同一个格子上,而从这个格子继续往下走的行为完全不依赖它是怎么来的——只取决于它现在在哪、还要走几步。既然历史无关,就没有必要区分路径,只需要把落在同一格子上的概率合并起来。

由此定义状态:dp[step][i][j] 表示走完 step 步后,骑士全程没有出过界且此刻正停在格子 (i, j) 的概率。这个定义里「全程没出过界」是关键,它保证了出界的概率一旦流失就再也不会回到 dp 里。初始条件是 dp[0][row][column] = 1,其余为 0,对应一步没走时骑士必然在起点。转移是把当前格子的概率均分给八个方向:对每个合法的 (ni, nj),dp[step][ni][nj] += dp[step-1][i][j] / 8;落到界外的那部分概率不写进任何状态,自然就被丢弃了。最终答案是 dp[k] 这一整层所有格子的概率之和。

由于第 step 层只依赖第 step - 1 层,不需要把整个三维数组开出来,用两块 $n \times n$ 的矩阵滚动即可。

解题步骤

  • 开一个 $n \times n$ 的 dp 矩阵,把起点置为 1,其余为 0。这一层代表 step = 0,也就是「还没开始走」的状态,k 为 0 时直接对它求和就得到 1,边界自动成立。
  • 外层循环 step 从 1 到 k,每轮先新建一个全零的 next 矩阵。必须写到新矩阵而不是原地累加,否则同一轮里刚被更新的格子会再次被当成起点参与本轮转移,等于让骑士在一步之内走了多次。
  • 内层遍历所有格子,概率为 0 的格子直接跳过。这不只是性能优化,也让代码意图更清楚:只有可达状态才需要向外扩散。
  • 对每个可达格子枚举八个方向,落在棋盘内的目标格累加 dp[i][j] / 8。除数固定是 8 而不是「合法方向的个数」,因为出界也是一次真实发生的选择,它带走的那 $1/8$ 概率就该消失。
  • 一轮结束后把 next 换成新的 dp,进入下一步。
  • k 轮全部走完后,把 dp 矩阵所有格子求和返回。求和而不是读某一个格子,是因为题目只关心「是否还在盘上」,不关心停在哪里。

n = 3k = 2、起点 (0, 0) 走一遍:初始 dp 里只有 dp[0][0] = 1。第一步从 (0, 0) 出发枚举八个方向,只有 (1, 2) 和 (2, 1) 落在 $3 \times 3$ 的棋盘内,其余六个方向全部出界,因此 next 里 next[1][2] = 1/8next[2][1] = 1/8,这一层的概率总和是 $1/4$,恰好对应「第一步就出界」的概率是 $3/4$。第二步分别处理这两个格子:从 (1, 2) 出发,八个方向里只有 (2, 0) 和 (0, 0) 合法,各得到 $(1/8)/8 = 1/64$;从 (2, 1) 出发,八个方向里只有 (0, 2) 和 (0, 0) 合法,同样各得到 $1/64$。于是第二层中 (2, 0) 是 $1/64$、(0, 2) 是 $1/64$、(0, 0) 是 $1/64 + 1/64 = 2/64$。最后把整层求和得到 $4/64 = 0.0625$,与期望输出一致。

代码实现

// step=0 时起点概率为 1,其余为 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];
        dp[row][column] = 1.0;

        for (int step = 1; step <= k; step++) {
            double[][] next = new double[n][n];
            for (int i = 0; i < n; i++) {
                for (int j = 0; j < n; j++) {
                    if (dp[i][j] == 0) {
                        continue;
                    }

                    for (int[] d : DIRS) {
                        int ni = i + d[0];
                        int nj = j + d[1];
                        if (ni >= 0 && ni < n && nj >= 0 && nj < n) {
                            next[ni][nj] += dp[i][j] / 8.0;
                        }
                    }
                }
            }
            dp = next;
        }

        double total = 0.0;
        for (int i = 0; i < n; i++) {
            for (int j = 0; j < n; j++) {
                total += dp[i][j];
            }
        }

        return total;
    }
}
// step=0 时起点概率为 1,其余为 0。
func knightProbability(n int, k int, row int, column int) float64 {
    dirs := make([][2]int, 0, 8)
    dirs = append(dirs, [2]int{1, 2}, [2]int{2, 1}, [2]int{-1, 2}, [2]int{-2, 1})
    dirs = append(dirs, [2]int{1, -2}, [2]int{2, -1}, [2]int{-1, -2}, [2]int{-2, -1})
    dp := make([][]float64, n)
    for i := 0; i < n; i++ {
        dp[i] = make([]float64, n)
    }
    dp[row][column] = 1.0

    for step := 1; step <= k; step++ {
        next := make([][]float64, n)
        for i := 0; i < n; i++ {
            next[i] = make([]float64, n)
        }

        for i := 0; i < n; i++ {
            for j := 0; j < n; j++ {
                if dp[i][j] == 0 {
                    continue
                }
                for _, d := range dirs {
                    ni := i + d[0]
                    nj := j + d[1]
                    if ni >= 0 && ni < n && nj >= 0 && nj < n {
                        next[ni][nj] += dp[i][j] / 8.0
                    }
                }
            }
        }
        dp = next
    }

    total := 0.0
    for i := 0; i < n; i++ {
        for j := 0; j < n; j++ {
            total += dp[i][j]
        }
    }

    return total
}

复杂度分析

  • 时间复杂度:$O(k \cdot n^2)$,共 k 轮,每轮遍历 $n^2$ 个格子,每个格子固定枚举 8 个方向,方向数是常数。在 $n = 25$、$k = 100$ 的上界下约为五十万次基本操作。
  • 空间复杂度:$O(n^2)$,只保留当前层和下一层两块 $n \times n$ 的概率矩阵,不随 k 增长。

关键点总结

  • 判断一个随机过程能不能上 DP,看的是「未来只依赖当前状态、与到达路径无关」这条性质;本题里骑士接下来怎么走只和位置与剩余步数有关,这就是把指数级路径压成 $O(k n^2)$ 状态的全部理由。
  • 概率型 DP 的状态定义里必须写清「全程未出界」这类隐含前提,否则转移时会不自觉地把已经死掉的路径又捡回来。
  • 每步固定除以 8 而不是除以合法方向数,是本题最核心的语义判断:出界是一个真实发生的分支,它带走的概率就该从系统里消失。
  • 分层转移必须写到新数组,原地累加会让同一轮内的更新互相污染;凡是「按步数分层」的 DP 都要条件反射检查这一点。
  • 面试视角:面试官常会用「为什么不能只在合法方向里均分」来试探你有没有真的理解概率模型,这是本题最高频的追问;能一句话解释清楚,基本就通过了。
  • 面试视角:写完滚动数组后主动补一句「三维写法更好推导、二维滚动更省内存,两者转移完全一致」,能表明你是先想清楚状态再做的优化,而不是背下来一个二维模板。

易错点总结

  • 错误写法:最后返回 dp[row][column] 而不是整层求和:n = 3k = 2、起点 (0, 0) → 只读到回到起点的 $2/64 = 0.03125$,而正确答案是整层之和 0.0625。
  • 错误写法:把除数写成当前格子合法方向的个数:n = 3k = 2、起点 (0, 0) → 第一步的两个合法方向各分到 $1/2$,概率永远不流失,最终返回 1。
  • 错误写法:不新建 next 矩阵,直接在 dp 上原地累加:n = 3k = 2 → 本轮刚写入的格子又被当成起点再扩散一次,等价于允许骑士在一步里连走多次,返回值偏大。
  • 错误写法:把 dp 初始化成全零后从 step = 1 开始填,而不是先把起点置 1:k = 0 → 整个矩阵都是 0,返回 0,但一步没走时骑士必然在盘上,正确答案是 1。
  • 错误写法:越界判断写成 ni > nnj > nn = 3 且目标落在下标 3 上 → 下标等于 n 被当成合法,直接数组越界;正确的比较是 ni >= n
  • 错误写法:方向数组少写几项,或误用国王、车的走法:n = 3k = 1、起点 (0, 0) → 合法落点从 2 个变成别的数量,第一步的存活概率就已经不是 $1/4$。
  • 错误写法:用整型累计合法路径条数,最后再除以 $8^k$:k = 100 → $8^{100}$ 远超 64 位整型范围,计数一路溢出,结果完全无意义;概率题应当全程用浮点直接累加。
  • 错误写法:把「恰好 k 步」理解成「最多 k 步」,一旦某层概率之和不再变化就提前返回:任意 k 较大的输入 → 提前退出时留在盘上的概率远高于真实值,因为后续步数还会继续让骑士出界。

相似题目

题目 难度 考察点
62. 不同路径 中等 只能右和下两个方向,求方案数,转移是两项相加
63. 不同路径 II 中等 在 62 上加障碍格,障碍处状态强制置零
64. 最小路径和 中等 同样的网格但求最小代价,转移由求和改成取最小
120. 三角形最小路径和 中等 非矩形网格,自底向上推导能省掉边界判断
174. 地下城游戏 困难 必须倒着从终点推起,正推会因为血量下界失去最优子结构
931. 下降路径最小和 中等 按行滚动,每格从上一行的三个相邻列转移
1289. 下降路径最小和 II 困难 禁止同列,需要预处理上一行的最小值与次小值把转移降到常数
1301. 最大得分的路径数目 困难 同时维护最大得分与达到该得分的方案数两个 DP
1594. 矩阵的最大非负积 中等 因为有负数,必须同时维护最大积与最小积
LCR 098. 不同路径 中等 与 62 同题,可用于练习一维滚动数组写法
LCR 099. 最小路径和 中等 与 64 同题,重点是首行首列的初始化
LCR 100. 三角形最小路径和 中等 与 120 同题,练习原地修改输入的空间优化
剑指 Offer 47. 礼物的最大价值 中等 求路径最大和,可直接在原矩阵上滚动一行