LeetCode 650: 两个键的键盘

52 阅读6分钟

第一阶段:手动模拟,寻找规律

让我们先忘记算法,像玩游戏一样手动模拟,看看如何用最少的步骤得到 n 个 'A'。

  • 目标 n = 1: 记事本里本来就是 'A'。0 步
  • 目标 n = 2:
    1. Copy All ('A')
    2. Paste -> 'AA'
    • 共 2 步
  • 目标 n = 3:
    1. Copy All ('A')
    2. Paste -> 'AA'
    3. Paste -> 'AAA'
    • 共 3 步
  • 目标 n = 4:
    • 方法一 (从'A'开始粘贴): Copy All ('A') -> Paste -> Paste -> Paste。共 4 步。
    • 方法二 (从'AA'开始粘贴):
      1. Copy All ('A')
      2. Paste -> 'AA' (至此用了 2 步)
      3. Copy All ('AA') (现在剪贴板里是 'AA')
      4. Paste -> 'AAAA'
    • 共 4 步。两种方法都是 4 步。
  • 目标 n = 5: '5' 是个质数。我们无法通过 c * m = 5 (其中 c > 1) 的方式得到它。唯一的来源是从 1 个 'A' 开始。Copy All ('A') -> Paste 4 次。共 5 步
  • 目标 n = 6:
    • 方法一 (从'A'开始粘贴): Copy All -> Paste 5 次。共 6 步。
    • 方法二 (从'AA'得到): 先用 2 步得到 'AA'。然后 Copy All ('AA') -> Paste -> Paste。总步数 = (得到'AA'的2步) + (1次Copy) + (2次Paste) = 2 + 1 + 2 = 5 步
    • 方法三 (从'AAA'得到): 先用 3 步得到 'AAA'。然后 Copy All ('AAA') -> Paste。总步数 = (得到'AAA'的3步) + (1次Copy) + (1次Paste) = 3 + 1 + 1 = 5 步
    • 可以看到,对于 n=6,最短路径是 5 步。

初步规律与关键洞察:

要得到 n 个 'A',必然是在某个时刻,记事本里有 j 个 'A',然后我们执行 Copy All,再执行 Paste 操作 m 次。 此时,最终的 n 会等于 j + m * j = j * (m+1)

这意味着,任何 n 都是由它的某个因数 j,通过 1 次复制和 (n/j - 1) 次粘贴得到的。 从状态 j 到状态 n,需要花费的操作次数是 1 (Copy) + (n/j - 1) (Paste) = n/j 次。

所以,得到 n 个 'A' 的最少操作次数,可以用一个递推关系来表示: ops(n) = ops(j) + n/j,其中 jn 的一个因数。


第二阶段:动态规划 (DP) 的思考

上面的递推关系 ops(n) = ops(j) + n/j 给了我们动态规划的思路。我们想让 ops(n) 最小,就必须选择一个合适的因数 j,使得 ops(j) + n/j 最小。

  • 状态定义: dp[i] 表示得到 i 个 'A' 所需的最少操作次数。
  • 目标: dp[n]
  • 基础状态: dp[1] = 0
  • 状态转移方程: dp[i] = min(dp[j] + i/j),其中 ji 的所有小于 i 的因数。

例如,计算 dp[6]6 的因数有 1, 2, 3

  • j=1 转移: dp[1] + 6/1 = 0 + 6 = 6 步。
  • j=2 转移: dp[2] + 6/2 = 2 + 3 = 5 步。
  • j=3 转移: dp[3] + 6/3 = 3 + 2 = 5 步。 取其中的最小值,dp[6] = 5。这和我们手动模拟的结果一致!

这个 DP 解法是可行的,时间复杂度大约是 O(n^2)(遍历每个 i,再找 i 的所有因数)。对于 n <= 10001000^2 = 100万次操作,可以接受。

但是,我们还能做得更好吗?


第三阶段:揭示问题本质 —— 质因数分解

我们再深入观察一下状态转移方程:dp[i] = dp[j] + i/j

为了使 dp[j] + i/j 最小,我们应该让 j 尽可能大,这样 i/j 就会尽可能小(重点!任何将 i 分解为 j * k 并通过 dp[j] + k 来计算总成本的策略,其成本都大于或等于 i 的所有质因数之和。而选择 ji 的最大因数(即 i/p),其成本恰好就等于 i 的所有质因数之和。因此,这种选择是所有可能选择中成本最小的)。最大的因数 j 是多少呢?如果 pi最小质因数,那么 j = i/p 就是 i最大因数

代入我们的状态转移方程: dp[i] = dp(i/p) + i/(i/p) = dp(i/p) + p

这个公式非常优美!它告诉我们,得到 i 个 'A' 的最少步数,等于“得到 i 的最大因数 i/p 所需的步数” 加上“i 的最小质因数 p”。

让我们用这个新公式来展开 dp[n]: 假设 n 的质因数分解是 n = p1 * p2 * p3 * ... * pk(这里 p1 <= p2 <= ...

  • dp(n) = dp(n / p1) + p1
  • dp(n / p1) = dp(n / p1 / p2) + p2
  • 将第二式代入第一式:dp(n) = dp(n / p1 / p2) + p2 + p1
  • ... 如此反复展开,直到最后 ...
  • dp(n) = dp(1) + pk + ... + p2 + p1
  • 因为 dp(1) = 0,所以我们得到了最终的结论:

dp(n) = p1 + p2 + p3 + ... + pk

本质: 得到 n 个 'A' 所需的最少操作次数,竟然就是 n所有质因数的和


第四阶段:最终的高效算法

问题现在转化成了“求一个数 n 的所有质因数之和”。这是一个经典的数论问题,可以用 O(sqrt(n)) 的时间复杂度解决。

算法步骤:

  1. 初始化总操作数 steps = 0
  2. 从最小的质数 2 开始尝试分解 n
  3. 循环 d 从 2 到 sqrt(n)
    • n 还能被 d 整除时,说明 dn 的一个质因数。
    • d 加入 steps
    • n 更新为 n / d
    • 重复此过程,直到 n 不能再被 d 整除。
  4. 循环结束后,如果 n > 1,说明剩下的 n 本身就是一个大于 sqrt(原n) 的质数。将这个最后的质因数也加入 steps
  5. 返回 steps

Java 代码实现:

import java.util.Scanner;

public class Main { // ACM 模式类名通常为 Main

    public static void main(String[] args) {
        Scanner scanner = new Scanner(System.in);
        // System.out.println("请输入 n:"); // ACM 模式不打印提示
        int n = scanner.nextInt();
        scanner.close();

        // 调用核心逻辑函数
        System.out.println(minSteps(n));
    }

    /**
     * LeetCode 650: 两个键的键盘
     * 计算得到 n 个 'A' 所需的最少操作次数。
     *
     * @param n 目标字符 'A' 的数量
     * @return 最少操作次数
     */
    public static int minSteps(int n) {
        // 1. 处理边界情况:n=1 时,初始状态就是 'A',不需要任何操作。
        if (n == 1) {
            return 0;
        }

        // 2. 最终的解法:计算 n 的所有质因数之和。
        int steps = 0; // 初始化总步数为 0

        // 3. 从最小的质数 2 开始尝试分解 n
        for (int d = 2; d * d <= n; d++) {
            // 当 n 能被 d 整除时
            while (n % d == 0) {
                // d 就是 n 的一个质因数,将其加入总步数
                steps += d;
                // 更新 n,除掉这个因子
                n /= d;
            }
            // 如果 n 不能被 d 整除,尝试下一个 d
        }

        // 4. 处理最后一个可能的质因数
        // 经过上面的循环,如果 n > 1,说明剩下的 n 本身就是一个
        // 大于 sqrt(原n) 的质数。它也是一个质因数,需要加到总步数中。
        if (n > 1) {
            steps += n;
        }

        // 5. 返回总步数
        return steps;
    }
}