最近连着好几个朋友面完某大厂回来跟我复盘,都不约而同提到了一道骰子题。我在整理面试真题记忆库的时候,发现这个“旋转骰子”出现的频率比想象中高很多,而且不同公司的考法还很接近。这道题表面上是道模拟题,实际上坑特别多,方向定义、滚动映射、坐标约定,稍有含糊就容易写崩。这篇把我在准备这道题时踩过的坑、最终沉淀下来的状态建模方法,以及面试官延伸追问的思路全部整理出来,希望对最近在准备算法面试的人有帮助。
1. 题目是什么:旋转骰子的完整题干与考点拆解
1.1 我整理的回忆版题目描述
这道题在面试中出现时,通常是这样描述的(以下为回忆整理版,细节在不同公司可能略有出入):
有一个标准六面骰子,初始放在二维网格的坐标 (0, 0) 位置。骰子的初始状态为:顶面数字是 1,朝北(前方)的数字是 2,朝东(右方)的数字是 3,底面数字是 6,朝南(后方)的数字是 5,朝西(左方)的数字是 4。满足对面相加为 7 的经典关系。
现在给定一个操作序列,只包含四种字符:
- 'F':骰子向北滚动一格
- 'B':骰子向南滚动一格
- 'L':骰子向西滚动一格
- 'R':骰子向东滚动一格
每次滚动,骰子都会移动到相邻格子,同时六个面的朝向会改变。要求输出执行完所有操作后,骰子顶面的数字是多少,以及最终停留的网格坐标。
有的版本还会在后面追加一个问题:计算骰子在整个过程中所有经过格子时底面数字的总和。这个追加问题我在后文会专门展开。
这道题原题在不少在线题库里都收录过,但面试官通常不会直接把原题念出来,而是会加一层实际场景的包装,比如“一个机器人在棋盘上搬运骰子”“一个角色在迷宫里滚动一个方块”之类。包装千变万化,核心本质就是状态机模拟。
1.2 这道题到底在考什么
很多人在准备面试时容易陷入一个误区:看到“旋转骰子”四个字,觉得太简单,不就是照着操作一步步模拟嘛。确实,最简单的版本不涉及任何高级算法,但它能成为大厂高频面试题,绝不是因为简单,而是因为它精准地考察了下面几件事:
第一,抽象状态的能力。骰子不是一个点,它有六个面,每滚动一次,所有面的朝向都在变化。能不能用一个清晰的数据结构表达这个状态,直接决定代码的成败。
第二,方向感的准确性。前后左右滚动时,哪些面发生变化、哪些面保持不变,需要在大脑里对骰子做三维旋转的推演。这个推演过程极其容易出错,尤其是'F'和'B'、'L'和'R'两组操作容易搞反。
第三,边界条件的严谨性。操作序列为空怎么办?坐标是二维平面,往北是 x 减小还是 y 增加?这个约定如果没在开篇定义清楚,后面全乱。
第四,面试官真正想看的,是你在一个看似简单的问题上,能不能做到结构化思考、一次写对。模拟题的容错率极低,因为逻辑简单,几乎没有调试空间,写错了自己都很难快速定位。
所以我的建议是:这道题复习的重点不是“会做”,而是“能在白板上一次写对”。
2. 骰子状态建模:为什么要用六元组而不是类
2.1 给骰子建立一个坐标系
很多第一次接触这题的人,第一反应是给骰子定义一个类,里面有 top、front、right、back、left、bottom 六个属性。这种方案本身没问题,但在面试场景下,类会让代码显得臃肿。更重要的是,滚动操作需要同时修改多个属性,很容易出现“改了 top 忘了改 bottom”的遗漏。
我实际测试下来,最简单的建模方式是使用一个固定顺序的六元组数组。先约定索引含义,从 0 到 5 依次表示:
- index 0:顶面
- index 1:前面(朝北)
- index 2:右面(朝东)
- index 3:后面(朝南)
- index 4:左面(朝西)
- index 5:底面
初始状态下,这个数组就是:
dice = [1, 2, 3, 5, 4, 6]这里有个关键点:数组的顺序一旦定下来,所有滚动映射都必须围绕这个顺序来写。我见过很多人写着写着,把“前面”和“后面”的位置搞反,因为定义顺序时用的是“前、右、后、左、上、下”,又混进了一套顺序。所以建议在代码注释里写清楚顺序,并在函数开头校验对面关系。
对面关系是:index 0 和 index 5 相对,index 1 和 index 3 相对,index 2 和 index 4 相对。这三对的和应该始终是 7。这个特性后面可以用来做自检,非常实用。
2.2 四种滚动方向的状态迁移表
定义好数组后,接下来就是核心部分:四种滚动操作的状态转移。我最初是死记硬背映射表,结果面试时一紧张就写反。后来我找到一个更不容易出错的推演方法,就是在脑子里想象“滚动后哪个面朝上”。
先看'F',向北滚动。骰子绕左右方向的轴向前翻转:
- 原来的顶面会变成前面,也就是 index 0 的值跑到 index 1 的位置。
- 原来的前面会变成底面,也就是 index 1 的值跑到 index 5 的位置。
- 原来的底面会变成后面,也就是 index 5 的值跑到 index 3 的位置。
- 原来的后面会变成顶面,也就是 index 3 的值跑到 index 0 的位置。
- 右面和左面保持不变。
所以 F 的状态转移就是:
dice = [dice[3], dice[0], dice[2], dice[5], dice[4], dice[1]]再看'B',向后(向南)滚动。注意这是 F 的逆操作,但写的时候很容易直接反着写错。根据推演:
- 原来的顶面变成后面,index 0 到 index 3。
- 原来的后面变成底面,index 3 到 index 5。
- 原来的底面变成前面,index 5 到 index 1。
- 原来的前面变成顶面,index 1 到 index 0。
所以:
dice = [dice[1], dice[5], dice[2], dice[0], dice[4], dice[3]]接着看'R',向东滚动。骰子绕前后方向的轴向右翻转:
- 原来的顶面变成右面,index 0 到 index 2。
- 原来的右面变成底面,index 2 到 index 5。
- 原来的底面变成左面,index 5 到 index 4。
- 原来的左面变成顶面,index 4 到 index 0。
- 前面和后面保持不变。
所以:
dice = [dice[4], dice[1], dice[0], dice[3], dice[5], dice[2]]最后是'L',向西滚动:
- 原来的顶面变成左面,index 0 到 index 4。
- 原来的左面变成底面,index 4 到 index 5。
- 原来的底面变成右面,index 5 到 index 2。
- 原来的右面变成顶面,index 2 到 index 0。
所以:
dice = [dice[2], dice[1], dice[5], dice[3], dice[0], dice[4]]这四张表看起来很简单,但实际默写时非常容易混。我的建议是不要只靠记忆,每次写完后用一组真实骰子滚动一下验证,或者在心里做一次完整推演。比如初始骰子 [1,2,3,5,4,6],执行一次 F 后,结果应该是 [5,1,3,6,4,2]。这意味着顶面从 1 变成 5,底面从 6 变成 2,前面从 2 变成 1。这个结果和真实骰子向北滚动的现象完全一致。
2.3 一个小技巧:利用“对面和为 7”来做自检
这是我强烈推荐的一个技巧,尤其适合面试时验证代码正确性。
骰子任何时候都满足以下三条约束:
- dice[0] + dice[5] == 7
- dice[1] + dice[3] == 7
- dice[2] + dice[4] == 7
你可以在模拟函数最前面加一个 assert,或者在每一步滚动后检查一遍。一旦状态转移写错,这个约束会立刻被打破,帮你在面试官发现之前就定位到问题。
我在实际练习中,还会用这个约束反向推断:如果某个映射写完后,发现 dice[0] + dice[5] 不等于 7,那一定是顶面和底面的转移顺序搞错了。这个自检方法在 debug 时的效率非常高。
3. 完整实现:模拟题的代码怎么写才不容易错
3.1 基础版本的代码实现(Python)
先把完整代码贴出来。这段代码按我前述的六元组模型实现,同时维护骰子位置坐标:
def roll_dice(operations): # 顺序:顶面、前面、右面、后面、左面、底面 dice = [1, 2, 3, 5, 4, 6] x = 0 y = 0 # 坐标约定:x 表示行号,北方向为负;y 表示列号,东方向为正 dx = {'F': -1, 'B': 1, 'L': 0, 'R': 0} dy = {'F': 0, 'B': 0, 'L': -1, 'R': 1} for op in operations: if op == 'F': dice = [dice[3], dice[0], dice[2], dice[5], dice[4], dice[1]] elif op == 'B': dice = [dice[1], dice[5], dice[2], dice[0], dice[4], dice[3]] elif op == 'R': dice = [dice[4], dice[1], dice[0], dice[3], dice[5], dice[2]] elif op == 'L': dice = [dice[2], dice[1], dice[5], dice[3], dice[0], dice[4]] else: continue # 面试时可以问清楚输入是否保证合法 x += dx[op] y += dy[op] return dice[0], x, y这个代码的核心就三件事:初始状态、状态转移、位置更新。面试时能写出这样简洁的结构,是加分项。
3.2 关键步骤逐行拆解
我逐个部分解释我当时为什么这么写,以及有什么容易忽略的点。
先看初始状态。我定义的是 dice = [1, 2, 3, 5, 4, 6],这里面 index 1 放的是 2,index 3 放的是 5,构成一对;index 2 放的是 3,index 4 放的是 4,构成一对。很多人在这个初始定义时不够敏感,随便填了顺序,结果后面的映射表也跟着乱。我的经验是,初始数组的顺序必须和映射表里的索引语义完全绑定,最好在注释里直接写明“index 0 是顶面”,这样面试官看代码时也能快速理解。
再看坐标更新。这里我特意把坐标约定写清楚:x 是行号,向北减;y 是列号,向东加。这样 'F' 对应 dx=-1 而不是 dy=-1。很多错误源于坐标轴定义和操作方向不匹配。假如你心里想的坐标是 x 向东、y 向北,但代码里按 x 向北写,那最终返回的位置坐标就完全是反的。
为了减少混乱,我建议在面试时先写一行注释:
# 坐标约定:(x, y),x 为行号,北为 -1;y 为列号,东为 +1这行注释看着不起眼,但它能帮你在后续推导位置时保持清醒,也能让面试官看到你有良好的工程习惯。
最后是循环内部的结构。我用了 if/elif 而不是字典映射,原因是状态转移本身需要重排整个数组,用字典存 lambda 反而增加阅读成本。面试时优先保证代码逻辑直观。
3.3 复杂度与边界条件
时间复杂度和空间复杂度都要能说清楚:操作序列长度为 n 时,时间复杂度是 O(n),因为每个字符只需要常数时间的数组重排;空间复杂度是 O(1),除了固定大小的 dice 数组和几个变量外没有额外分配。
边界条件方面,至少要考虑到以下三个:
操作序列为空。这时应该直接返回初始顶面 1 和坐标 (0, 0)。我的代码天然支持,因为循环不执行,直接走 return。
操作序列里出现非法字符。真实面试场景中,面试官可能故意不告诉你输入保证合法,这时候你可以选择忽略非法字符,也可以直接抛异常。更好的做法是开头就问清楚;如果面试官说“默认合法”,那用 else 分支跳过即可。我在代码里用了一个 continue,属于稳妥防御。
操作序列特别长。比如长度是 10^9 这种级别,单纯的 O(n) 模拟就不够了。这个在后面的延伸部分会重点讲。
4. 面试官最爱追问的四个延伸问题
4.1 如果要求最小路径代价怎么办
这是我在某个大厂面试复盘时听到最多的追问。题目会改成这样:骰子从起点出发,可以朝四个方向滚动,每滚到一个新格子,记录当前底面数字作为花费,求从起点到某个目标位置的最小总花费。
如果直接对每个位置做动态规划,会忽略一个核心问题:骰子到达同一个格子时,不同朝向的开销是不同的。你不能只看位置,还要看骰子状态。
好在骰子状态数量是有限的。一个骰子总共有 24 种朝向(6 个面可以朝上,每个面朝上时有 4 种旋转角度),所以可以构建分层图。每个节点表示为 (x, y, state),其中 state 是 0 到 23 的一个编号,代表骰子的完整朝向。转移边就是向四个方向滚动,边权是滚动后(或滚动前)的底面数字。然后用优先队列跑 Dijkstra。
节点总数是网格格子数乘以 24。如果网格是 n 乘 m,那么状态总数是 24nm,每个状态有 4 条边,复杂度大概是 O(24nm log(24nm))。这个规模在小网格下完全可行。面试时能把“状态上界是 24”这个点说出来,面试官就会知道你理解了这个问题的本质。
我建议提前把骰子 24 种状态的编号方式准备好,最常见的是:先用顶面和前面两个面的组合唯一确定状态。因为顶面有 6 种选择,顶面确定后前面可以是剩余的 4 个相邻面,所以 6 乘 4 等于 24。这个编号方法要能随口说出来。
4.2 如果操作序列特别长怎么办
另一个常见变体是:操作序列本身不是直接给出的,而是由某个长度很大的重复单元构成,比如“FRR”重复 10 的 9 次方次。这时候绝不能硬模拟。
核心思路是利用状态转移的周期性。骰子在固定操作序列下,每执行一轮,整体状态会从一个朝向变成另一个朝向。骰子总共只有 24 种朝向,所以如果这个固定序列一直重复,最多 24 轮之后必然回到某个之前出现过的状态,形成循环。
具体做法是:先用模拟法执行操作序列,同时记录每一轮结束后的骰子状态和位置偏移。等到发现某个状态重复出现时,就找到了循环节。循环节前后期的偏移量可以累乘,用取模跳过整个循环部分。这样即使操作次数是天文数字,也能在 O(24 * 序列长度) 的时间内完成计算。
这个方案在面试里属于加分项。即使面试官没问,你也可以主动提一句,“如果操作序列有循环特征,可以用状态周期加速”,会让面试官觉得你考虑问题有深度。不过要注意节奏,前面基础模拟还没写完时不要主动延伸,先保证基础版本正确。
4.3 如何设计有效的自测用例
很多人面试时写完代码就干等面试官提问,其实主动自测是加分的。
我总结了三个针对旋转骰子的关键自测用例:
第一个是空操作。执行空序列后顶面还是 1,位置还在原点。这个用例最简单,但是能快速验证初始状态初始化有没有问题。
第二个是往返操作。执行一次 F 再执行一次 B,骰子应该完全恢复初始状态,顶面回到 1,位置回到 (0, 0)。这个用例非常强,因为 F 和 B 是互逆操作,如果映射表里任一个写错,这个用例立刻就能暴露。
第三个是四面绕一圈。执行 R、B、L、F(或者任意顺时针绕一圈),最终位置回到原点,骰子状态也恢复初始。这个用例能同时验证四个方向映射的一致性。
我在实际练习时还用过“旋转验证法”:先执行 R,再执行 L,顶面应该恢复 1。这是一个更小的往返测试,能快速定位是哪一组映射出错。我在白板上写的顺序通常是:空测、单步、往返、四方向闭环,这样覆盖度最高。
4.4 能不能用位运算压缩状态
如果面试官追问代码优化,一个方向是用位运算表示骰子状态。因为每个面的数字范围是 1 到 6,需要 3 个 bit,六个面一共 18 bit,可以用一个整数表示整个骰子状态。滚动操作就变成了对这个整数做位掩码重排。
这种做法的好处是状态压缩后,可以作为哈希表的 key 或者状态数组的下标,配合动态规划使用。比如前面提到的最短路径问题,如果把 state 编码成 18 位整数,整个分层图的节点可以紧凑存储,缓存命中率更高。
不过我在面试中通常不会主动写位运算版本,除非面试官明确要求。主要原因有两个:第一,位运算代码的可读性差,面试官需要花额外时间去理解;第二,对这道题来说,六元组数组的时间复杂度已经是最优常数级,位运算的性能提升在面试环境里无关紧要。我会在口头分析时提一句“可以用位压缩来节省空间”,然后根据面试官反应决定是否深入。
5. 现场实录:我见过和踩过的坑与排查技巧
5.1 最高频的三类翻车现场
我在模拟面练习和真实面复盘里,整理出这道题最常见的三个错误类型,这里集中说一下。
第一类:方向定义和映射表错位。有人定义数组顺序是“上、前、右、下、后、左”,但写映射时还是按“上、前、右、后、左、下”来操作。顺序不一致是这种问题的根源。比如写 F 操作时,本来应该把 index 3 的后面放到 index 0 的顶面,但因为定义里 index 3 是底面,结果整个转移全错了。
第二类:F 和 B 的映射写反。这在紧张时非常容易发生,因为从视觉上,向北滚动和向南滚动的映射看起来就是“反着来”的。很多人会想当然地认为 F 的映射是“top 到 front”,但忘记 front 到 bottom 这一环。我建议在脑子里固定一条链:front -> bottom -> back -> top -> front,这是向前滚动时的顶面循环链。记住这条链,F 操作就不会写错。
第三类:坐标轴的度量和操作方向约定冲突。有的人用 (x, y) 表示平面坐标,x 向右、y 向上,但是代码里把 'F' 操作映射成 x += 1,结果实际骰子向北移动,坐标却向东增长。这种 bug 特别隐蔽,因为单看骰子顶面结果是正确的,只有最终打印坐标时才发现全反了。
5.2 Debug 技巧:用“往返操作”和“三对面约束”定位错误
针对上面这些错误,我总结了两条快速定位的方法。
第一条是往返操作法。如果你执行 F 再执行 B,顶面应该回到原来的值。如果回不去,说明 F 和 B 至少有一个写错。这时可以把 F 映射逐条拿出来,用对面约束验证。比如执行 F 后,如果 dice 里出现两个相同的数字,或者任意两个相对面相加不是 7,那一定是映射表里某个索引挪错了。
第二条是三对面约束法。我前面提到过 dice[0]+dice[5]、dice[1]+dice[3]、dice[2]+dice[4] 都应该等于 7。我在 debug 时会对每一步都打印这三个加式的结果,一旦某个不是 7,就能立刻定位是哪一对面在滚动时被破坏了。比如如果你发现 dice[0]+dice[5] != 7,那基本可以断定是顶面和底面的转移写反了,不用去看前后面和左右面。
这条技巧真的救过我很多次。有一次我在练习时连续写了三版映射,每次都靠这个约束发现问题,最后五分钟才修正到完全正确。
5.3 大厂面试中的答题节奏建议
最后聊聊面试时的策略,这个比写法本身更影响结果。
这道题在白板上的时间不宜超过 15 分钟。我建议按这个节奏分配:前 3 分钟确认题目细节,包括骰子初始状态、坐标系定义、输入是否合法;接下来 2 分钟在白板上写出六元组模型和对面关系,并给面试官讲清楚你要用什么数据结构;然后 7 分钟写代码;最后 3 分钟做自测。
写代码前,先跟面试官对齐状态顺序。我会说,“我准备用一个长度为 6 的数组表示骰子,索引含义分别是顶、前、右、后、左、底,初始值是 1,2,3,5,4,6,对面和为 7。接下来我按这个顺序写滚动映射。”这句话非常重要,它能让面试官跟上你的思路,即使后面你写错一步,面试官也知道你是思路对但手误,而不是直接零分。
如果面试官要求你用 Java 写,逻辑完全相同,只是把数组初始化换成 new int[]{1,2,3,5,4,6},外加一个静态常量定义。我建议两种语言都提前练习一遍,避免现场语法卡壳。
我个人在实际操作中的体会是:旋转骰子这道题,考的就是把复杂三维运动拆成固定状态机的能力。它让我养成了一个习惯,拿到任何模拟题先定义状态空间和转移规则,再写循环。这个习惯在后来做很多复杂场景题时都很受益。如果你也在准备面试,建议把这题当作模拟类的标准模板来练,配合“对面和为 7”自检,反复写几遍到能闭眼输出为止。