CS-Notes 剑指 Offer 题解:机器人的运动范围——用 DFS 求解可达格子数
本篇基于 CS-Notes 剑指 Offer 题解中的《13. 机器人的运动范围》一文展开,完整讲解这道经典搜索题的题目背景、深度优先搜索(DFS)求解思路与一份带数位和预处理的完整 Java 实现,并补充代码逐段解读、边界正确性细节与时间/空间复杂度分析。读完本文,你应能独立写出该问题的 DFS/BFS 两种解法,并理解"预处理一维数位和"这一优化技巧的由来与适用场景。
题目描述与示例
地上有一个 m 行和 n 列的方格。一个机器人从坐标 (0, 0) 的格子开始移动,每一次只能向左右上下四个方向移动一格,但是不能进入行坐标和列坐标的数位之和大于 k 的格子。
例如,当 k 为 18 时,机器人能够进入方格 (35,37),因为 3+5+3+7=18。但是,它不能进入方格 (35,38),因为 3+5+3+8=19。请问该机器人能够达到多少个格子?
题目给出的示例中,机器人恰好停在"数位和等于阈值"的边界上——等于 k 可以进入,大于 k 才能进入就非法。这一点决定了后续代码中判断条件必须写成 digitSum[r][c] > threshold 才返回,而不是 >= threshold。
这道题在 CS-Notes 的 剑指 Offer 题解 - 目录 中被归入"搜索"分类,与 12. 矩阵中的路径 同属一类网格搜索问题,但区别在于:第 12 题要求从任意格子出发寻找匹配字符串的路径(回溯法),而本题只需统计以 (0, 0) 为起点的连通可达区域大小(普通 DFS),不需要在每次搜索结束后清除局部状态。
解题思路:为什么是 DFS 而不是回溯
原始文档给出的核心思路是:
使用深度优先搜索(Depth First Search,DFS)方法进行求解。回溯是深度优先搜索的一种特例,它在一次搜索过程中需要设置一些本次搜索过程的局部状态,并在本次搜索结束之后清除状态。而普通的深度优先搜索并不需要使用这些局部状态,虽然还是有可能设置一些全局状态。
可以进一步把这句话落到本题的具体结构上:
- 状态空间:m × n 个格子,每个格子要么可达、要么不可达。机器人从 (0, 0) 出发,每次移动一格,因此可达格子集合恰好是"所有数位和 ≤ k 的格子中,与 (0, 0) 四方向连通的连通分量"。
- 为什么不会重复计数:用一个
boolean[][] marked数组记录"是否访问过"。一旦某格子被标记,后续从任何方向进入都会直接剪枝,所以每个格子至多被"处理"一次,统计值cnt不会重复累加。 - DFS 与回溯的差异体现在状态管理上:本题的
marked属于全局状态,整个搜索过程只做一次标记、永不清除;而回溯法(如矩阵中的路径)需要在返回时把本次搜索设置的标记清除,以便同一路径上的其他分支能重新利用这些格子。理解这一差异是区分两类网格搜索题的关键。
完整解法代码
以下是原始文档中给出的完整 Java 实现,原样保留:
private static final int[][] next = {{0, -1}, {0, 1}, {-1, 0}, {1, 0}};
private int cnt = 0;
private int rows;
private int cols;
private int threshold;
private int[][] digitSum;
public int movingCount(int threshold, int rows, int cols) {
this.rows = rows;
this.cols = cols;
this.threshold = threshold;
initDigitSum();
boolean[][] marked = new boolean[rows][cols];
dfs(marked, 0, 0);
return cnt;
}
private void dfs(boolean[][] marked, int r, int c) {
if (r < 0 || r >= rows || c < 0 || c >= cols || marked[r][c])
return;
marked[r][c] = true;
if (this.digitSum[r][c] > this.threshold)
return;
cnt++;
for (int[] n : next)
dfs(marked, r + n[0], c + n[1]);
}
private void initDigitSum() {
int[] digitSumOne = new int[Math.max(rows, cols)];
for (int i = 0; i < digitSumOne.length; i++) {
int n = i;
while (n > 0) {
digitSumOne[i] += n % 10;
n /= 10;
}
}
this.digitSum = new int[rows][cols];
for (int i = 0; i < this.rows; i++)
for (int j = 0; j < this.cols; j++)
this.digitSum[i][j] = digitSumOne[i] + digitSumOne[j];
}
下面按方法逐段解读其设计意图。
movingCount:搜索入口
- 先把
rows、cols、threshold三个参数保存到实例字段,避免在递归中反复传参; - 调用
initDigitSum()预计算所有格子的数位和(见下文); - 分配
marked访问标记数组,从 (0, 0) 启动一次 DFS,最终返回计数器cnt。
dfs:剪枝顺序与递归主体
if (r < 0 || r >= rows || c < 0 || c >= cols || marked[r][c])
return;
第一层剪枝是边界与重访检查:新坐标超出网格、或格子已被标记过,直接返回。注意这里把 marked 检查放在最前面——由于每个格子只会被合法处理一次,这一条同时承担了"防止重复计数"和"终止递归"两个职责。
marked[r][c] = true;
if (this.digitSum[r][c] > this.threshold)
return;
cnt++;
第二层是阈值剪枝:先把格子标记为已访问,再判断数位和是否超标。这个顺序是一个容易忽视但重要的细节:
- 若先判断阈值再标记,一个"数位和超标"的格子会在搜索过程中被相邻格子反复尝试进入,每次都重新计算一次阈值判断,虽然结果仍正确,但产生了冗余检查;
- 先标记再判断,使得每个不合法格子也只会被"探测"一次,其四方向邻居再也不会对它发起新的 DFS 调用。从源码结构看,
marked在这里实际上同时覆盖了"合法可达"和"已确认不可达"两类格子,整个搜索的调用总次数因此被严格控制。
最后 cnt++ 只发生在阈值检查通过之后,即只有真正进入的格子才计入答案;随后按 next 表中的四个方向(左、右、上、下)继续递归。
next 方向表:把四方向移动参数化
{{0, -1}, {0, 1}, {-1, 0}, {1, 0}} 是网格题的标准写法:每一行是一个 (dr, dc) 位移向量,递归时用 r + n[0]、c + n[1] 生成邻居坐标。相比写死四个 if 分支,这种写法把"移动规则"与"搜索逻辑"解耦——如果题目变成八方向移动,只需改这张表。
关键优化:一维数位和预处理
原始文档中 initDigitSum 的实现值得单独展开,因为它是这份解法相比朴素写法(每个格子调用一次 digitSum(r) + digitSum(c))的核心优势所在。
其思路分两步:
- 先算一维表:
digitSumOne[i]存单个数字 i 的各位数字之和,长度取max(rows, cols),用"取余 + 整除"的循环剥离每一位(n % 10取末位、n /= 10去掉末位); - 组合成二维表:格子的数位和 = 行坐标数位和 + 列坐标数位和,因此
digitSum[i][j] = digitSumOne[i] + digitSumOne[j],一次查表即得。
这样设计的好处可以从两个维度理解:
- 时间上:一维表本身长度为 O(max(m, n)),每个元素的剥离循环与坐标位数 d 成正比,开销可忽略;二维表填充是 O(m·n) 的纯加法,总体预处理为 O(m·n)。而朴素写法下,DFS 过程中每次进入格子都要现算两次数位和,常数因子更高,且重复计算。
- 空间换时间的典型形态:额外付出 O(m·n) 的
digitSum数组,换取 DFS 中每格 O(1) 的阈值判断。对于本题"统计全量可达格子"的目标,这个交换是划算的——搜索本身最坏也是 O(m·n),预处理没有让整体复杂度升档。
需要注意其适用前提:digitSumOne 只算到 max(rows, cols) - 1,因此该实现默认行、列坐标都小于该长度上限,这正是数组下标本身所保证的,不存在越界问题。
边界情形与正确性细节
结合代码可以梳理出几个容易出错的边界:
- (0, 0) 本身不合法:若
digitSum[0][0] > threshold(例如 k 很小),DFS 会把 (0, 0) 标记后直接返回,cnt保持 0,函数正确返回 0,不需要特殊处理。 - 等于阈值可进入:判断式是
> threshold时返回,与题目示例中 (35, 37) 数位和恰好 18 可进入一致。 - visited 数组的维度:
marked是rows × cols的全局数组,一次搜索全程共享;这与回溯题中"标记-清除"的用法形成对照,也再次印证了原始文档"普通 DFS 不需要回溯状态"的说法。 - 递归深度的实践限制:最坏情况下可达格子呈一条细长蛇形路径,递归深度可达 O(m·n),在 m、n 较大时有栈溢出风险。若目标环境是超大网格,可改写为显式队列的 BFS(广度优先搜索)版本:入队前判断边界、标记与阈值,逻辑与 DFS 完全同构,仅把递归换成循环,即可消除深度问题。就本题在剑指 Offer 中的常规数据规模而言,DFS 递归版本已经足够。
复杂度分析
- 时间:预处理
initDigitSum为 O(m·n)(一维部分更低阶);DFS 阶段由于每个格子至多被标记一次,dfs的总调用次数上界为 O(4·m·n),即 O(m·n)。整体时间复杂度为 O(m·n)。 - 空间:
marked与digitSum各占 O(m·n),digitSumOne占 O(max(m, n)),另有 O(递归深度) 的调用栈,最坏 O(m·n)。整体空间复杂度为 O(m·n)。 - 最坏情形的直观理解:当 k 大到足够覆盖整个网格时,
cnt恰好等于 m·n,搜索遍历所有格子;当 k 很小时,搜索被阈值剪枝快速限制在 (0, 0) 附近的小区域内,实际运行远低于上界。
小结与相关题目
- 本题的解法骨架是"网格 + 方向表 + visited 标记 + DFS",与 12. 矩阵中的路径 共享同一套框架,区别只在于是否需要回溯清除状态:路径匹配类问题需要回溯,连通区域计数类问题不需要。
- 原始文档中"回溯是深度优先搜索的特例"这一论断,是区分 剑指 Offer 题解 - 目录 中"搜索"分类下各题目的有效判据:先问"搜索结束后要不要还原状态",再决定套用哪种模板。
- 完整的 Java 实现以实例字段承载阈值、行列数与预处理的数位和表,配合
next方向表与marked剪枝,构成了一份可直接提交、可直接阅读的参考解法;如需应对超大网格,可按上文思路改写为 BFS 迭代版本。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00