514. 自由之路

NOTE

本文带有 todo 标签,表示尚未完成本人复刷。代码与推导已按公开题目和参考实现整理;刷完后可删除 frontmatter 中的 todo

  • LeetCode:原题1
  • 难度:困难
  • 归类:二叉树+BFS(深度优先搜索、广度优先搜索、字符串、动态规划)
  • 主解法:动态规划

先给结论

这道题的核心是把原问题拆成有依赖关系的子问题,保存子问题答案以避免重复计算。面试时先说清楚“状态表示什么、为什么能排除其他候选、何时更新答案”,再写代码;只报出“用 动态规划”还不足以证明理解。

题目描述

题目要求完成“自由之路”。输入包括 ring(string)、key(string),需要返回 integer。完整限制以原题为准,解题时重点利用这些特征:深度优先搜索、广度优先搜索、字符串、动态规划。

示例:

输入:: ring = "godding", key = "gd"
输出:: 4
解释::
对于 key 的第一个字符 'g',已经在正确的位置, 我们只需要1步来拼写这个字符。
对于 key 的第二个字符 'd',我们需要逆时针旋转 ring "godding" 2步使它变成 "ddinggo"。
当然, 我们还需要1步进行拼写。
因此最终的输出是 4。

问题本质

把原问题拆成有依赖关系的子问题,保存子问题答案以避免重复计算。需要解决的不是某个 API 的用法,而是如何用最少状态描述“已经处理什么、还剩什么”,并证明状态推进不会漏解。

数据结构与因果链

  1. 输入进入算法后,先建立 动态规划 所需的状态。
  2. 状态表示处理到某个位置、区间或选择状态时能够得到的最优值或方案数。
  3. 枚举当前选择,由已知状态通过转移方程更新下一状态或当前最优值。
  4. 满足终止条件后,把已经验证的状态转换成输出。

始终保持:计算当前状态时,它依赖的更小状态已经按遍历顺序计算完成。

示例推演

输入:: ring = "godding", key = "gd"
输出:: 4
解释::
对于 key 的第一个字符 'g',已经在正确的位置, 我们只需要1步来拼写这个字符。
对于 key 的第二个字符 'd',我们需要逆时针旋转 ring "godding" 2步使它变成 "ddinggo"。
当然, 我们还需要1步进行拼写。
因此最终的输出是 4。
阶段要检查的内容
初始化边界、辅助结构与默认答案是否符合定义
推进当前动作是否只依赖已知正确状态
更新当前状态何时有资格成为答案
结束是否覆盖空输入、无解和极端规模

边界与陷阱

  • 空输入、单元素、重复元素以及不存在合法答案时,要有明确返回值。
  • 最容易错在状态定义含糊、初始化与定义不一致,以及一维压缩后的遍历方向错误。
  • 如果实现会修改输入,面试时要主动说明;如果不能修改,则复制数据或改用额外结构。

代码实现

参考实现来源:JoshCrozier/leetcode-javascript,按本文结构重新整理;原项目采用 MIT License

JavaScript 实现

/**
 * @param {string} ring
 * @param {string} key
 * @return {number}
 */
var findRotateSteps = function(ring, key) {
  const map = new Map();
  return dp(0, 0);

  function dp(ringIndex, keyIndex) {
    if (keyIndex === key.length) return 0;
    const state = `${ringIndex},${keyIndex}`;
    if (map.has(state)) return map.get(state);
    let minSteps = Infinity;
    for (let i = 0; i < ring.length; i++) {
      if (ring[i] === key[keyIndex]) {
        const distance = Math.abs(i - ringIndex);
        const steps = Math.min(distance, ring.length - distance);
        minSteps = Math.min(minSteps, steps + 1 + dp(i, keyIndex + 1));
      }
    }
    map.set(state, minSteps);
    return minSteps;
  }
};

代码与思路对照

阶段对应代码作用
入口findRotateSteps接收题目输入,入口签名与 LeetCode 元数据一致
初始化var findRotateSteps = function(ring, key)const map = new Map();const state = \${ringIndex},${keyIndex}`;<br />let minSteps = Infinity;`建立后续推进所需的边界、缓存或答案变量
核心推进if (keyIndex === key.length) return 0;if (map.has(state)) return map.get(state);for (let i = 0; i < ring.length; i++)if (ring[i] === key[keyIndex])落实“枚举当前选择,由已知状态通过转移方程更新下一状态或当前最优值”
输出return minSteps只返回已经满足状态定义的最终结果

读代码时应把每个判断还原成“它排除了什么状态或完成了哪次转移”,而不是只记变量名。

复杂度分析

  • 时间复杂度:O(S × T),S 为状态数量,T 为单次转移成本。
  • 空间复杂度:O(n),用于辅助状态或递归栈。

复杂度必须按“状态数量 × 每个状态处理成本”分析;如果存在排序、堆操作、递归深度或结果数组,需要单独计入,不能只看最外层循环。

面试官递进追问

1. 为什么本题适合 动态规划?

因为把原问题拆成有依赖关系的子问题,保存子问题答案以避免重复计算。 为什么问: 检查你是在识别性质,还是只凭题号背模板。回答要抓住可被利用的单调性、重复子问题或数据结构约束。

2. 代码维护的核心状态是什么?

状态表示处理到某个位置、区间或选择状态时能够得到的最优值或方案数。 为什么问: 状态定义决定代码是否可证明。回答时要让每个变量都能对应到题意。

3. 这个实现依赖什么不变量?

计算当前状态时,它依赖的更小状态已经按遍历顺序计算完成。 为什么问: 不变量比逐行复述代码更能证明理解,回答要说明它在初始化、推进和结束时都成立。

4. 为什么这样推进不会漏掉答案?

枚举当前选择,由已知状态通过转移方程更新下一状态或当前最优值,被跳过的状态已经由题目性质证明不可能更优或已经处理完成。 为什么问: 检查正确性证明,重点不是“指针这样写”,而是“为什么可以排除”。

5. 最危险的边界是什么?

最容易错在状态定义含糊、初始化与定义不一致,以及一维压缩后的遍历方向错误。 为什么问: 检查代码能否一次通过,回答要给出具体失败场景而不是只说“注意边界”。

6. 复杂度还能优化吗?

先看瓶颈来自状态数量、每次转移成本还是辅助结构操作;只有其中一项能被减少时,复杂度才可能继续下降。 为什么问: 检查你能否从成本组成出发优化,而不是机械背最优复杂度。

7. 如果输入规模扩大或数据改成流式,方案怎么变?

要判断当前算法是否需要随机访问全部输入;若只依赖有限历史状态,可以做滚动或在线维护,否则需要分块、外存或调整数据结构。 为什么问: 检查算法理解能否迁移到工程约束。

8. 有哪些替代解法,如何取舍?

可以从暴力枚举开始,再比较排序、哈希、搜索或动态规划等方案;取舍标准是时间、空间、是否修改输入和实现复杂度。 为什么问: 检查横向比较能力。回答要说明替代方案为什么更慢或在什么条件下更合适。

常见错误回答

  • “这题套 动态规划 模板即可”:只有结论,没有说明题目性质和排除依据。
  • 逐行朗读代码:没有建立状态、不变量和正确性因果链。
  • 只报 O(n)O(n²):没有解释每个状态被访问多少次,也容易漏掉排序或辅助结构。
  • 把示例能跑通当成证明:示例只能帮助检查,不能覆盖重复值、空输入和极端边界。

可迁移总结

  • 核心关键词:动态规划、状态定义、不变量、推进规则、边界。
  • 一句话本质:把原问题拆成有依赖关系的子问题,保存子问题答案以避免重复计算。
  • 思考链路:识别题目性质 → 定义状态 → 证明推进安全 → 确定更新时机 → 检查边界与复杂度。
  • 1 分钟回答:说清题型、核心状态、推进规则和复杂度。
  • 3 分钟回答:补上正确性依据、示例和两个关键边界。
  • 10 分钟回答:写出代码,并比较替代方案、空间优化和工程限制。

刷题后自测

先只回答第 1 题,再展开后续问题:

  1. 不看代码,你能用一句话说出本题的不变量吗?
  1. 如果把示例中的重复值、空输入或边界值换掉,哪一行代码最先受到影响?
  1. 不改变正确性的前提下,你能写出一种替代方案并比较复杂度吗?