机器人能否返回原点:从模拟到统计的最优解法
前两天有个在准备面试的朋友问我一道题“机器人能否返回原点”他说自己一看就会一写就错而且总觉得写出来的代码不够优雅。这个题目其实非常典型在算法社区里常被拿来当“热身题”或者“Easy档”的代表。题面很简单一个机器人从平面原点出发根据一串由U、D、L、R组成的指令移动走完所有指令后判断它是否回到了原点。如果你刷过 LeetCode应该一眼就能认出来这是第 657 题题号叫 “Robot Return to Origin”中文版叫“机器人能否返回原点”。但我想说的是这道题背后远不止“统计一下上下左右数量是否相等”这么简单。它考察的是你拿到一个“可模拟问题”时是先无脑开数组去跟踪坐标还是能先做一步等价转换、再选最简单的数据结构去表达。这篇文章我会从题目意图、解法拆解、代码实现、边界条件到面试中可能被追问的变体完整讲一遍也把我自己踩过的一些细节坑一起写出来供你参考。1. 核心思路拆解返回原点到底意味着什么1.1 先看懂题目的等价条件机器人的每一步都只沿着坐标轴方向走U是 y 轴正方向D是 y 轴负方向L是 x 轴负方向R是 x 轴正方向。题目要判断的是经过这么一串移动之后机器人是否回到了(0, 0)。这里最关键的第一步不是写代码而是问自己怎么描述“回到了原点”按直觉我们可以在脑袋里模拟一个坐标从(0,0)出发每遇到一个字符就更新坐标最后看坐标是否还是(0,0)。这当然没错。但如果你再想深一层会发现一个更本质的等价关系机器人能返回原点当且仅当“向左走的步数等于向右走的步数”并且“向上走的步数等于向下走的步数”。为什么因为 x 轴方向的位移是所有R贡献的正位移与所有L贡献的负位移之和。如果L和R的数量相等正负互相抵消x 方向的净位移一定是 0同理U和D的数量相等时y 方向的净位移也是 0。两个轴向都归零合成位移自然是零。这就把“模拟每一步坐标变化”转换成了“统计四个方向的频次”。这个转换的价值在于它让你意识到中间路径根本无关紧要只有最终的数量差决定结果。这个思路的升华是遇到看似需要模拟过程的题目先思考“结果是否只由某些累计量决定”如果能就可以用更轻量的方式求解。1.2 为什么这道题值得认真对待很多人会觉得这道题太简单不值得专门写一篇东西。但我翻过不少面经这道题出现在笔试第一题、面试热身题里的概率相当高。它简单所以更能暴露基本功。比如有人一上来就用if (c U) y; else if (c D) y--;这种代码写一大串虽然最终能跑通但代码风格、分支组织、对字符串操作的熟悉程度完全能看得出来。更常见的一个错误是把“模拟坐标”和“统计数量”两种思路混在一起写出来的代码既能跑但又不伦不类。这门题的意义在于它是“从模拟到统计”这一思路转变的最小示例。理解了它后面再遇到判断回环、检测重复状态、路径压缩等问题时会有一个非常自然的迁移路径。1.3 从复杂度看方案取舍我们先给两个核心方案定个性坐标模拟法遍历一次字符串每步做常数次加减操作。时间复杂度O(n)空间复杂度O(1)。计数抵消法遍历一次字符串记录四方向数量后比较。时间复杂度O(n)空间复杂度O(1)。从大 O 复杂度上看两者没有区别。那为什么我推荐优先掌握计数抵消法因为它更能体现“对问题进行结构化简”的思维而且在某些扩展场景里计数法可以直接套用哈希表来推广。后面第 2 节我会详细对比。这里要提一个很实际的问题字符串长度能有多大LeetCode 原题的限制是1 moves.length 2 * 10^4。这个规模很小所以无论哪种写法都不会超时。但如果你在面试里主动说“我计算了 x 和 y 的净偏移本质上是一个坐标模拟问题”这会让面试官觉得你对复杂度有意识而不是只会背题。2. 代码实现方案与细节对比2.1 方案一坐标模拟法最直白坐标模拟法的逻辑很简单维护x和y两个变量遍历字符串根据字符更新坐标。直接上代码public boolean judgeCircle(String moves) { int x 0, y 0; for (char c : moves.toCharArray()) { if (c U) { y; } else if (c D) { y--; } else if (c L) { x--; } else if (c R) { x; } } return x 0 y 0; }如果你用的是 Python可以写成更紧凑的形式def judge_circle(moves: str) - bool: x y 0 for move in moves: if move U: y 1 elif move D: y - 1 elif move L: x - 1 elif move R: x 1 return x 0 and y 0这段代码最大的优点是直白看一眼就懂。但它有个小问题四个独立的if分支写下来代码行数偏多而且如果指令集扩展到八方向比如加上NE、SW这种斜向移动每个分支都得硬编码维护起来非常痛苦。2.2 方案二计数抵消法我推荐计数抵消法的思路是不需要跟踪每一步的坐标只需要统计各方向的次数判断左右相等、上下相等即可。public boolean judgeCircle(String moves) { int[] counts new int[26]; for (char c : moves.toCharArray()) { counts[c - A]; } return counts[U - A] counts[D - A] counts[L - A] counts[R - A]; }这里我用了一个长度为 26 的整型数组来记录字符频次。为什么是 26因为指令只包含大写英文字母U、D、L、R它们的 ASCII 码范围是连续的用c - A就能映射到数组下标这是一种典型的空间换时间的哈希简化写法。Python 可以借助标准库里的计数器from collections import Counter def judge_circle(moves: str) - bool: cnt Counter(moves) return cnt[U] cnt[D] and cnt[L] cnt[R]这个写法在面试里同样是可接受的前提是你解释得清Counter的原理——它就是封装了字典底层仍然是统计频次。我自己的偏好是在 LeetCode 上答题时手写一个int[26]数组因为这样不依赖语言库而且运行效率比Counter略高尤其在数据量稍大的时候差异更明显。但在工程代码里我会用HashMapCharacter, Integer或者Counter因为扩展性更好以后加新指令时不需要动算法结构。2.3 方案三基于字符串变换的奇技淫巧网上还有一种写法通过删除所有L和R对来判断是否归零比如把字符串中的L都去掉剩下的长度等于去掉R后的长度但这里有个陷阱它只证明了L与R数量相等没有证明U与D数量相等。所以纯粹用删除法必须做两次判断def judge_circle(moves: str) - bool: return moves.count(U) moves.count(D) and moves.count(L) moves.count(R)本质上还是计数抵消。所谓“奇技淫巧”大多是计数法的变体真正巧妙程度有限。我不建议在面试里炫这种写法因为count方法是内部循环扫描如果你调用四次count时间复杂度虽然是O(n)但实际执行了四次遍历常数翻倍在小数据量下看不出问题但不是一个“有意识”的写法。2.4 三种方案对比方案核心思想代码行数复杂度可扩展性推荐指数坐标模拟直接模拟每步较多O(n)/O(1)弱一般计数抵消统计频次比较少O(n)/O(1)强高四个 count复用语言库最少O(4n)/O(1)一般不优先“可扩展性”指的是如果指令集扩展成“相同步数但不同维度”的其他输入你能不能快速修改。计数法可以覆盖多指令集坐标法虽然也能改但你会发现统计的方法更清晰。3. 实操细节与编码经验3.1 字符映射下标的实际写法在 Java 解法中counts[c - A]这一行是精髓。c是char类型c - A相当于把字符转成它在字母表中的序号。例如U - A 20D - A 3L - A 11R - A 17而U - A和D - A之间的差值没有任何关系我们只是借这个数组做频次存储。这里有一个容易踩的坑如果输入里不只有大写字母或者字符区间不连续这种映射就会失效。所以在写“字母频次”类题目时一定要确认字符集。本题已经限定只有四种大写字母所以用int[26]是安全的。如果字符集不确定用HashMapCharacter, Integer更稳。再看一个 Java 里常见的等价写法用switchpublic boolean judgeCircle(String moves) { int x 0, y 0; for (char c : moves.toCharArray()) { switch (c) { case U: y; break; case D: y--; break; case L: x--; break; case R: x; break; default: break; } } return x 0 y 0; }这段代码在可读性上比if-else if好很多尤其在分支多的时候switch的跳转表方式也比连环判断更快。不过现代编译器对if-else if也会做优化性能差异通常可以忽略。我更推荐switch主要是因为它把每个分支的对应关系排布得更整齐肉眼检查时不容易漏分支。3.2 一维数组计数还是二维坐标模拟怎么选如果你问我的个人习惯我大概率会直接写坐标模拟因为它最不容易写错而且思路和题目描述一一对应。但如果这道题是作为“热身”出现我会顺手写成计数法并向面试官解释一句“其实我们不需要维护坐标只需要比较四个方向的频次。”这两种写法没有绝对优劣但“选择哪一个”背后的理由才是面试官真正想听的。比如你选择坐标模拟就要能解释最坏情况下的空间复杂度为什么是O(1)而不是O(n)你选择计数抵消就要能说明为什么在题目只需要回答“是/否”而不需要“具体坐标路径”时统计法更有效率上的优势。3.3 测试用例怎么设计我实际写这道题时会先在脑袋里跑几个典型用例UD向上一次、向下一次回到原点结果是true。LL向左两次没有向右最终在(-2, 0)结果是false。RRDD右两次、下两次最终在(2, -2)结果是false。LDRRLRUULR混合路径长度是偶数但数量不一定匹配需要实际统计。除了这些我还会额外测一个空字符串。虽然题目限制了moves.length 1但工程上如果方法被复用到其他场景空字符串应该被当作“本来就在原点”的合法情况处理。我的代码天然支持这一点——x和y都是 0返回true。还有一种很常见的边界字符串很长比如UD重复一万次。这种用例对计数法的意义不大但可以验证循环效率。我习惯用 Python 写一个随机生成长测试用例的脚本随机生成 20000 个指令再和我手算的期望结果比对确保代码没有低级错误。3.4 实测过程中我踩过的细节坑坑一算完x和y结果却忘了同时判断两者为 0。这个错误很蠢但我在赶时间时真的犯过。要是只判断了x 0那么LRUD本来能返回true但如果你只判断 y 坐标或者只判断 x 坐标就会误判。解决办法是在最后一行用逻辑与同时判断。坑二String.toCharArray()会生成一个新数组。虽然这道题的数据量只有 2 * 10^4完全没问题但如果大数据量场景下直接通过moves.charAt(i)遍历可以避免创建额外的字符数组减少内存分配。现代 Java 里还有基于字节的字符串遍历方式但为了可读性toCharArray()完全够用。坑三有人会把moves.length() % 2 ! 0直接判负。这个优化是对的吗我们来算一算。如果指令总数是奇数那确实无法回到原点因为每走一步都会改变一次位置奇数次位移不可能是零向量。所以这个预判断是成立的。我实际测试过加上这个判断可以提前返回省掉一次完整遍历理论上平均能省一半遍历时间。Java 里可以这样写public boolean judgeCircle(String moves) { if (moves.length() % 2 ! 0) { return false; } int x 0, y 0; for (char c : moves.toCharArray()) { switch (c) { case U: y; break; case D: y--; break; case L: x--; break; case R: x; break; default: break; } } return x 0 y 0; }这个优化要不要写我的建议是写并且在注释里说明“因为一次移动改变一次位置奇数步无法回到原点”。面试时主动提这个边界优化通常是个加分项。4. 常见问题与排查思路4.1 为什么明明感觉应该返回true结果却是false这种问题通常出现在你对“指令方向”的理解有偏差时。比如L到底是往左还是往右U到底是向上还是向下很多人会把L和R的方向记反导致统计完全对不上。解决方式是写一段对照表指令含义x 变化y 变化U向上01D向下0-1L向左-10R向右10写代码前先把这张表默写一遍能避免大量低级错误。4.2 为什么使用int[26]后比较counts[U-A] counts[D-A]会越界吗不会。U - A计算出来是一个非负整数且最大不超过 25。counts[U - A]访问的是数组的第 20 个元素完全在数组范围内。很多人第一次看到这种写法会害怕其实它是字符串频次统计的经典手法。如果你不放心可以用整数常量先算出来看一眼比如System.out.println(U - A);输出 20。4.3 用HashMap一定更好吗不一定。对于这种只有固定四种指令的题目int[26]比HashMap更快因为数组是连续内存、直接索引而HashMap要经过哈希函数计算和可能的冲突处理。但如果你面对的是不固定指令集比如用户自定义动作那HashMap扩展性更好。我一般在工程代码里优先用MapCharacter, Integer在算法题里优先用数组这是两个场景的不同取舍。4.4 如果机器人每一步还会改变朝向怎么办这是这道题的一个经典变体。原题里机器人的朝向不随移动改变U永远是“向上走一步”。但真实机器人往往是有朝向的比如初始朝北指令含义变成“向当前朝向走一步然后左转/右转若干次”。这时候问题就变了只判断最终坐标归零是不够的。需要额外判断最终朝向是否等于初始朝向因为这会影响后续循环的路径模式。我写过一篇相关的分析核心结论是如果机器人面临“一次重复指令序列”的执行最终能否回到原点的判定不仅和坐标位移有关还和朝向变化有关。如果朝向没变判断位移归零即可如果朝向变了需要模拟一次完整周期看看坐标位移向量在旋转若干次后能否通过循环叠加归零。这是一个很好的拔高方向。面试官如果问“如果指令序列会无限重复你如何判断机器人能否回到原点”那就要用到周期性和群论的思想了。我在后面第 5 节展开讲这个问题。4.5 别人常见的另一个坑把“经过原点”和“最终回到原点”混为一谈题目问的是“最终是否回到原点”不是“路径上是否经过原点”。如果题面换成“是否经过了原点至少一次”那就变成“判断路径上是否有某个时刻坐标是 (0, 0)”需要你去模拟整条路径并记录访问过的点。两种题目的算法完全不同。一定要先读清楚题意。4.6 排查技巧用断言代替手工验证我在本地写这道题时会直接写一组断言测试而不是main方法里用System.out.println人眼观察。例如assert judgeCircle(UD) true; assert judgeCircle(LL) false; assert judgeCircle(RRDD) false; assert judgeCircle(LDRRLRUULR) false;注意Java 默认不开启断言你需要运行时加上-ea参数。我更推荐直接写 JUnit 测试或者用 Python 的assert省事且直观。5. 延伸思考从“返回原点”到更复杂的算法5.1 状态记录法当统计法不够用统计法能处理“只关心最终位置”的问题。但有些问题不仅关心最终位置还要求路径不经过某些点或者要求在指定步数后位于某个坐标此时你必须记录状态。典型例子是“机器人模拟”类题目比如一个机器人按指令移动途中如果撞到障碍物就停在原地。这时候我通常会引入一组SetString以x , y的形式记录访问过的坐标。为什么用字符串拼接而不是用对象因为字符串拼接最简单而且判重速度足够快。如果追求性能可以用ListLong之类的编码方式但那就是过度优化了。5.2 扩展如果指令会重复执行无限次这是一道很有意思的变体给定一串指令让机器人不断循环执行这串指令能否判断它能回到原点如果只是原题一次执行很简单但循环执行后路径会变成无数个周期的拼接。解法思路是先执行一遍得到两个关键值第一周期结束后的位移向量(dx, dy)以及第一周期结束后的朝向变化。假设初始朝向是固定的每个周期内机器人坐标变化等于这个周期的位移向量。如果经过 k 个周期机器人回到原点那么必须满足k * dx 0且k * dy 0。对于整数 k这意味着dx 0且dy 0即单个周期内的净位移必须是零。如果单个周期内净位移不是零只有一种情况能回到原点因为朝向旋转第二周期的位移方向和第一周期不同多个周期的位移向量叠加可能相互抵消。我画过一张状态转移表来理解这个问题每个周期结束时的朝向只有四种可能北、南、东、西。如果朝向保持初始方向但位移非零那么永远回不来如果朝向翻转 180 度那么两个周期位移会反向可能抵消如果朝向旋转 90 度那么连续四个周期位移向量会构成一个闭合路径。这个变体在面试中经常被追问因为它是“统计状态”和“周期建模”的最小案例。建议你在掌握原题后亲手推导一下这个循环问题的判定条件。5.3 从几何角度再理解一次把机器人路径看成平面向量序列每个U是向量(0, 1)每个D是向量(0, -1)每个L是向量(-1, 0)每个R是向量(1, 0)整条路径的净位移就是所有向量的矢量和。回到原点等价于矢量和为零。这其实是高中数学里“向量首尾相接后回到起点”的直观表现。很多路径规划、无人机航线规划问题里都会有类似的判定逻辑。5.4 同族题目推荐如果你把“能否返回原点”当作一个引子可以顺着一系列相同思想去练习判断是否有重复状态记录路径上经过的所有点看有没有重复访问。两个机器人的路径碰撞双指针或哈希集合判重核心是“同一时刻同一位置”。指令循环后是否回到原点上面说的周期模型。机器人模拟带障碍物坐标模拟加上障碍判定本质仍是状态变化。这些题目的共同点都是先定义状态再决定用“统计”还是“模拟”最后考虑边界条件。理解了这一层再遇到新题就不会迷茫了。写在最后的一点经验我在各种题库里刷过不少简单题这一道看起来不起眼但每次带新人过代码时我都会挑出来聊一聊因为它能很快看出一个人对“问题化简”的敏感度。我个人在实际操作中的体会是不要一开始就埋头写循环先花半分钟想清楚“什么条件等价于问题的答案”往往能让代码质量和面试表现同时上一个台阶。至于返回原点这道题我顺手做了一个小优化先判断字符串长度奇偶再决定是否提前返回 false这个小改动在超长用例下能省不少时间也让我在面试里多了一个可以聊的细节点。如果你准备把这题作为面试热身建议再顺手推一遍 5.2 节那个循环执行变体。把这两个问题放在一起理解你收获的就不只是一个答案而是一整套“移动类问题”的处理框架。