第一阶段:手动模拟,寻找规律
让我们先忘记算法,像玩游戏一样手动模拟,看看如何用最少的步骤得到 n 个 'A'。
- 目标 n = 1: 记事本里本来就是 'A'。0 步。
- 目标 n = 2:
Copy All('A')Paste-> 'AA'
- 共 2 步。
- 目标 n = 3:
Copy All('A')Paste-> 'AA'Paste-> 'AAA'
- 共 3 步。
- 目标 n = 4:
- 方法一 (从'A'开始粘贴):
Copy All('A') ->Paste->Paste->Paste。共 4 步。 - 方法二 (从'AA'开始粘贴):
Copy All('A')Paste-> 'AA' (至此用了 2 步)Copy All('AA') (现在剪贴板里是 'AA')Paste-> 'AAAA'
- 共 4 步。两种方法都是 4 步。
- 方法一 (从'A'开始粘贴):
- 目标 n = 5: '5' 是个质数。我们无法通过
c * m = 5(其中c > 1) 的方式得到它。唯一的来源是从 1 个 'A' 开始。Copy All('A') ->Paste4 次。共 5 步。 - 目标 n = 6:
- 方法一 (从'A'开始粘贴):
Copy All->Paste5 次。共 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 步。
- 方法一 (从'A'开始粘贴):
初步规律与关键洞察:
要得到 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,其中 j 是 n 的一个因数。
第二阶段:动态规划 (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),其中j是i的所有小于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 <= 1000,1000^2 = 100万次操作,可以接受。
但是,我们还能做得更好吗?
第三阶段:揭示问题本质 —— 质因数分解
我们再深入观察一下状态转移方程:dp[i] = dp[j] + i/j。
为了使 dp[j] + i/j 最小,我们应该让 j 尽可能大,这样 i/j 就会尽可能小(重点!任何将 i 分解为 j * k 并通过 dp[j] + k 来计算总成本的策略,其成本都大于或等于 i 的所有质因数之和。而选择 j 为 i 的最大因数(即 i/p),其成本恰好就等于 i 的所有质因数之和。因此,这种选择是所有可能选择中成本最小的)。最大的因数 j 是多少呢?如果 p 是 i 的最小质因数,那么 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) + p1dp(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)) 的时间复杂度解决。
算法步骤:
- 初始化总操作数
steps = 0。 - 从最小的质数 2 开始尝试分解
n。 - 循环
d从 2 到sqrt(n):- 当
n还能被d整除时,说明d是n的一个质因数。 - 将
d加入steps。 - 将
n更新为n / d。 - 重复此过程,直到
n不能再被d整除。
- 当
- 循环结束后,如果
n > 1,说明剩下的n本身就是一个大于sqrt(原n)的质数。将这个最后的质因数也加入steps。 - 返回
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;
}
}