骑士拨号器 图解题解
这道题到底在问什么
- 输入
- n = 1 → 输出:10(光站着不跳,10 个数字各算一个)
- 输入
- n = 2 → 输出:20
- 输入
- n = 3 → 输出:46
最优解:为什么这么做
一句话答案:LeetCode 935 骑士拨号器:骑士在电话键盘走「日」字,用计数 DP 的 dp[step][d] 记末位是 d 的号码数,按键盘邻接表逐步累加取模,答案是最后一行求和,时间 O(n),空间 O(1)(滚动数组)。
骑士拨号器到底在数多少个号码
国际象棋的骑士(也就是「马」)站在电话键盘上,每步按「日」字跳——直走两格再垂直拐一格。键盘排着 0 到 9,左下右下角的 * 和 # 不能落。骑士从任意数字起步、共跳 n-1 步拼成长度 n 的号码,问能拨多少个不同号码,对 1e9+7 取模(保留余数防溢出)。
n=1 哪都不跳、各数字算一个是 10;n=2 是 20,n=3 是 46。要数的是号码个数。
把所有号码都跳一遍,为什么行不通
每步在当前数字的邻居里挑一个落脚,一路走到底就是一个号码,可每多跳一步分叉翻几倍,n 稍大路径数就指数爆炸、逐条枚举跑不完。好在只问个数、不看具体号码。既然子问题反复撞见,算一次存起来复用——这就是动态规划(DP,落在某数字、还剩几步的方案数与怎么跳来的无关,算一次存 dp[step][d] 复用)。
邻接表怎么来,dp[step][d] 又存什么
先搭地基:把每个数字「一步能跳到谁」列出来,就是邻接表(记录谁与谁相邻的表)。比如 1 只能到 6、8,4 能到 0、3、9,0 只能到 4、6;而中间的 5 一跳就出键盘、没有邻居,是孤岛。这张表还对称:1 能到 6,6 也跳回 1。
再定义 dp[step][d]:已按 step+1 个键、末位停在 d 的号码个数(跳了 step 步落在 d 的号码数)。step 从 0 数起,dp[0][d] 是长度 1。答案是最后一行各 d 的 dp 值相加,因为末位可任取。
落在 d 的方案,为什么把来源全加起来
填表就靠一条转移:要让末位停在 d,上一步必在能一步跳到 d 的数字上,把这些来源在上一行的方案数全加起来就是 dp[step][d],即能跳到 d 的所有 prev 的 dp[step-1][prev] 之和。各来源不重不漏,每加一次对 1e9+7 取模防溢出。
拿 n=3 把三行表亲手填出来
第 0 步是长度 1 的号码,骑士哪都不跳、停哪都算一个,dp[0] 十格全是 1,求和 10,正是 n=1 的答案。
跳第 1 步逐格看来源:末位 0 从 4、6 来是 2,末位 4 由 0、3、9 来是 3,末位 5 谁都跳不到是 0。整行 [2,2,2,2,3,0,3,2,2,2],求和 20,对上 n=2。
再跳第 2 步只改取上一行 step1 的值:末位 0 来自 4、6 是 3+3=6,末位 1 来自 6、8 是 5,末位 4 来自 0、3、9 是 6。整行 [6,5,4,5,6,0,6,5,4,5],求和 46,与示例对上。
n=1 还跳了一步,答案为什么凭空翻倍
最容易踩的坑是循环次数:dp 初始化全 1 时号码已有 1 个数字,故只需再跳 n-1 步。n=1 一步都不该跳、答案是 dp[0] 求和的 10;多跑 1 步就把 10 错算成 20,正好翻倍。取模也别漏,否则大 n 撑爆整型。
复杂度上,数字只有 10 个、邻居数固定,每步常数工作,跳 n-1 步共 O(n)(大 O 记号,描述规模变大时操作数怎么涨)。每行只依赖上一行,用两个长 10 的数组轮换(滚动数组,只留最近几行覆盖旧值),空间 O(1)。
▶ 动画逐步走查(共 28 步)——想跟着动画一帧帧对照就展开
- 34 行 3 列,含两个空角这是电话键盘:数字 0~9 排成这样。左下角和右下角是 * 和 #(这里画成「·」),骑士不能落在上面。
- 4走「日」字 = 两格直 + 一格拐象棋里马走「日」:往一个方向直走两格、再垂直拐一格。在键盘上,这决定了从某个数字一步能跳到哪几个数字——下面一个个看。
- 51 → {6, 8}骑士站在 1(橙色)。走「日」字,它只能跳到 6 和 8(橙黄高亮)。所以从 1 出发,下一个数字只有这两种选择。
- 64 → {0, 3, 9}换到 4,它能跳到 0、3、9 三个。位置不同,邻居数量也不同——这张「能跳到谁」的表是这题的地基。
- 76 → {0, 1, 7}6 能跳到 0、1、7。注意 1 能跳到 6、6 也能跳回 1——这种「跳跃关系」是双向对称的。
- 80 → {4, 6}底下的 0 只能跳到 4 和 6。0 位置偏,所以邻居少。
- 95 → {}(孤岛)中间的 5 很特殊:骑士从 5 走「日」字会跳出键盘,所以它没有任何邻居。一旦跳出,5 就只能当「起点的第一个数字」,之后再也回不来。
- 10邻接表 moves[d]把每个数字「一步能跳到谁」全列出来,就是这张邻接表。它对称:a 在 b 的列表里,b 也在 a 的列表里。有了它,剩下就是数路径。
- 11把所有路径一条条走出来最直白:从 10 个起点分别出发,每步在邻居里挑一个跳,把所有可能的路径都走一遍。n 大了路径会指数爆炸,太慢。
- 12不在乎具体路径,只数条数题目只问有多少个号码,不要具体号码。而「落在某数字、还要再跳几步」的方案数是固定的,与历史无关——这正是动态规划能省掉重复计算的根。
- 13dp[step][d] = 长度 step+1、末位是 d 的号码数我们换个角度:dp[step][d] 表示已经按了 step+1 个键、最后一个键是 d 的号码有多少个。求出最后一行全部加起来就是答案。
- 14从能跳到 d 的格汇总要落在 d,上一步必然站在某个「能跳到 d」的数字 prev 上。把这些 prev 的方案数全加起来,就是这一步落在 d 的方案数。下面用 n=3 把表填出来。
- 15dp[step][digit]这是要填的 DP 表:列是末位数字 0~9,行是跳的步数。step0 是号码长度 1,step2 是长度 3。一行填完再填下一行。
- 16长度 1:每个数字算 1 个号码第 0 步(号码长度 1):骑士哪都不跳,停在任一数字都算一个号码,所以 dp[0][d] 全是 1。这一行加起来 = 10,正是 n=1 的答案。
- 17谁能跳到 0? {4, 6}要让末位是 0,上一步得站在能跳到 0 的数字上:4 或 6。把它们 step0 的值加起来 1+1=2,填入 dp[1][0]。
- 18谁能跳到 1? {6, 8}末位是 1:上一步在 6 或 8,加起来 = 2。dp[1][0] 已填好(灰)。一格一格往右推。
- 19谁能跳到 4? {0, 3, 9}末位是 4:能跳到 4 的有三个数字 0、3、9,所以 dp[1][4] = 3。邻居多的格子,方案数就更大。2、3 也已填好(灰)。
- 20谁能跳到 5? 没有!末位是 5:没有任何数字能跳到 5(它是孤岛),所以 dp[1][5] = 0。一旦跳了步,就再也停不到 5 了。
- 21其余数字同理填好把 6、7、8、9 也照样填好,step1 这行是 [2,2,2,2,3,0,3,2,2,2]。整行加起来 = 20,正好是 n=2 的答案。
- 22用 step1 的值再汇总一次进入第 2 步。规则不变——末位 0 来自 4 和 6,但这次取的是 step1 的值:3 + 3 = 6。每一步都建立在上一步之上。
- 23末位 1 来自 {6, 8}末位 1:来自 step1 的 6(=3) 和 8(=2),加起来 = 5。dp[2][0] 已落定(灰)。
- 24末位 4 来自 {0, 3, 9}末位 4:来自 step1 的 0、3、9(都是 2),加起来 = 6。2、3 也按同法填好了(灰)。
- 25末位 6 来自 {0, 1, 7}末位 6:来自 step1 的 0、1、7(都是 2),加起来 = 6。中间的 5 仍是 0(灰),因为没人能跳到它。继续把剩下几格填完。
- 26长度 3 的号码按末位分组把 step2 这行全部填完:[6,5,4,5,6,0,6,5,4,5]。每个格子 = 长度 3、末位是该数字的号码个数。还差最后一步——把它们加起来。
- 27最后一行整行相加n=3 的答案 = step2 整行之和 = 6+5+4+5+6+0+6+5+4+5 = 46,和示例完全对上。最后一行求和就是最终答案,因为号码末位可以是任意数字。
- 28step 行只依赖 step-1 行每行只依赖紧挨着的上一行,更早的行用完就丢。所以空间能从「n×10」压到「两个长度 10 的数组」——这就是滚动数组优化。
- 31记住这一句凡是「求有多少种走法 / 多少条路径」的题,别傻乎乎一条条走。按「当前在哪、还剩几步」分组,把方案数累加,是这类计数 DP 的通用思路。
- 33n=1 却还跳了一步如果 n=1 还多跳了一步,就把答案从正确的 10(step0 之和)错算成 20。所以循环次数必须是 n-1,n=1 时一步都不跳。
⚠️ 容易写错的地方
✗ 错:忘了对 1e9+7 取模
✓ 对:每次累加后立刻取模
n 大时方案数爆炸,不取模会溢出,C++/Java 还要先用 long
✗ 错:把 5 当普通数字给它配邻居
✓ 对:moves[5] 是空表
骑士从 5 走日字会跳出键盘,5 没有任何可达邻居
✗ 错:循环跳了 n 步
✓ 对:只跳 n-1 步
step0 已是长度 1 的号码,再跳 n-1 次才凑满 n 个数字
完整代码(Python / C++ / Java)
Python
class Solution:
def knightDialer(self, n):
MOD = 10 ** 9 + 7
moves = [[4, 6], [6, 8], [7, 9], [4, 8], [0, 3, 9],
[], [0, 1, 7], [2, 6], [1, 3], [2, 4]]
dp = [1] * 10 # step0: 长度1 都是1
for _ in range(n - 1): # 再跳 n-1 步
ndp = [0] * 10
for d in range(10):
for nb in moves[d]: # d 能跳到 nb
ndp[nb] = (ndp[nb] + dp[d]) % MOD
dp = ndp
return sum(dp) % MOD # 最后一行求和C++
class Solution {
public:
int knightDialer(int n) {
const int MOD = 1e9 + 7;
vector<vector<int>> moves = {{4,6},{6,8},{7,9},{4,8},
{0,3,9},{},{0,1,7},{2,6},{1,3},{2,4}};
vector<long long> dp(10, 1); // step0
for (int s = 0; s < n - 1; s++) { // 跳 n-1 步
vector<long long> ndp(10, 0);
for (int d = 0; d < 10; d++)
for (int nb : moves[d]) // d -> nb
ndp[nb] = (ndp[nb] + dp[d]) % MOD;
dp = ndp;
}
long long ans = 0;
for (long long x : dp) ans = (ans + x) % MOD;
return (int)ans;
}
};Java
class Solution {
public int knightDialer(int n) {
final int MOD = 1000000007;
int[][] moves = {{4,6},{6,8},{7,9},{4,8},{0,3,9},
{}, {0,1,7}, {2,6}, {1,3}, {2,4}};
long[] dp = new long[10];
for (int d = 0; d < 10; d++) dp[d] = 1; // step0
for (int s = 0; s < n - 1; s++) { // 跳 n-1 步
long[] ndp = new long[10];
for (int d = 0; d < 10; d++) {
for (int nb : moves[d]) { // d -> nb
ndp[nb] = (ndp[nb] + dp[d]) % MOD;
}
}
dp = ndp;
}
long ans = 0;
for (long x : dp) ans = (ans + x) % MOD;
return (int) ans;
}
}复杂度
时间
O(n)
跳 n-1 步,每步处理 10 个数字 + 各自固定几个邻居,常数工作
空间
O(1)
邻接表固定 10 个数字,dp 只用两个长度 10 的数组滚动
看不够?换成动画再走一遍
上面的推演每一步都对应一帧动画。点开交互动画版,能一步步看着 骑士拨号器 的数据怎么变、指针怎么走,还能切 Python / Java / C++ 跟着练。
面试官可能追问
骑士拨号器和爬楼梯这类计数 DP 有什么共通套路?+
骨架都是「按最后一步的落点分组,把来源的方案数累加」。爬楼梯里 dp[i]=dp[i-1]+dp[i-2],是「到第 i 阶的最后一步从 i-1 或 i-2 迈来」;本题 dp[step][d] 把「能跳到 d」的所有来源累加,只是来源由邻接表决定、不止两个。凡是问「有多少种走法 / 多少条路径」,都可以按「当前在哪、还剩几步」分组把方案数相加,而不是把路径一条条走出来。
为什么 dp[step][5] 永远是 0,5 却还能出现在号码里?+
邻接表里没有任何数字能一步跳到 5(5 自己走「日」字也会跳出键盘),所以「跳了至少一步后停在 5」的方案数恒为 0,dp[step][5] 从 step=1 起都是 0。但 dp[0][5]=1——5 可以当号码的第一个数字,起点不受邻接表约束,只是之后骑士就跳走、再也回不到 5。所以 5 能作起点、不能作后续落点。
n 特别大(比如上亿)时这个 O(n) 还够快吗?+
n 到几千、几万时 O(n) 完全够用。但 n 大到上亿甚至更多,线性扫一遍也会慢。这时可以把「一步转移」写成一个 10×10 的矩阵,用矩阵快速幂(把多次相同的转移用幂运算成对折叠加速)把跳 n-1 步压成 O(log n) 次矩阵乘法。思路仍是同一个转移关系,只是加速了「重复跳很多步」这件事。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄把 骑士拨号器 一步步讲透(全站已上线 905 份讲透,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
把这道题真正拿下
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。