1. 问题背景与核心挑战
LeetCode 818赛车问题是一个典型的指令优化问题。题目要求我们控制一辆初始位置为0、速度为+1的赛车,通过最少的指令序列到达目标位置target。可用的指令只有两种:
- A(加速):position += speed,speed *= 2
- R(倒车):speed = -1(如果当前speed > 0)或 speed = 1(如果当前speed < 0)
这个问题的难点在于如何高效地找到最优指令序列。乍一看可能觉得可以用广度优先搜索(BFS)来暴力求解,但实际上面临着状态空间爆炸的问题——因为速度是指数级增长的,导致状态数量会急剧增加。
关键观察:连续执行k次'A'指令后,赛车会到达位置S_k = 2^k - 1,此时速度为2^k。这个数学性质是动态规划解法的基础。
2. 动态规划解法思路拆解
2.1 基础情况分析
当target正好等于2^k - 1时,解决方案显而易见:直接连续执行k次'A'指令即可。例如:
- target=3(2^2-1):AA(2步)
- target=7(2^3-1):AAA(3步)
但大多数情况下target不会这么"完美",这时就需要考虑更复杂的策略。
2.2 两种核心策略
对于一般的target,我们主要考虑两种策略:
策略一:冲过头再回头
- 执行n次'A',到达位置2^n - 1(刚好超过target)
- 执行1次'R'掉头
- 剩余距离:(2^n - 1) - target
- 递归求解剩余距离的最优解
总步数:n(加速) + 1(掉头) + dp[(2^n - 1) - target]
策略二:提前掉头试探
- 执行n-1次'A',到达位置2^(n-1) - 1(还未到target)
- 执行1次'R'掉头
- 执行m次'A'向后走(m从0到n-2)
- 再执行1次'R'转回正向
- 计算净前进距离:2^(n-1) - 2^m
- 剩余距离:target - (2^(n-1) - 2^m)
- 递归求解剩余距离
总步数:(n-1) + 1 + m + 1 + dp[剩余距离] = n + m + 1 + dp[...]
3. Java实现详解
java复制class Solution {
public int racecar(int target) {
int[] dp = new int[target + 1];
for (int t = 1; t <= target; t++) {
int n = 32 - Integer.numberOfLeadingZeros(t); // 等价于floor(log2(t)) + 1
int full = (1 << n) - 1;
// 情况1:正好是2^n-1
if (full == t) {
dp[t] = n;
continue;
}
// 策略1:冲过头再回来
dp[t] = n + 1 + dp[full - t];
// 策略2:提前掉头试探
int prev = (1 << (n - 1)) - 1;
for (int m = 0; m < n - 1; m++) {
int back = (1 << m) - 1;
int remain = t - (prev - back);
dp[t] = Math.min(dp[t], (n - 1) + 1 + m + 1 + dp[remain]);
}
}
return dp[target];
}
}
3.1 关键代码解析
-
Integer.numberOfLeadingZeros(t):这个方法是计算t的二进制表示中前导零的个数。32 - numberOfLeadingZeros(t)实际上就是计算大于t的最小二次幂的指数。 -
(1 << n) - 1:这是计算2^n - 1的快速方法,利用了位运算的高效性。 -
策略二的循环中,m的取值范围是0到n-2。这是因为如果m取n-1,就相当于完全退回到起点,这样效率太低。
4. 复杂度分析与优化
4.1 时间复杂度
对于每个target t,我们需要:
- 计算n(O(1))
- 处理策略1(O(1))
- 处理策略2(循环n次)
因此总时间复杂度是O(T log T),其中T是target的值。这是因为对于每个t,内层循环最多执行log t次。
4.2 空间复杂度
我们需要一个大小为target+1的数组来存储dp值,所以空间复杂度是O(T)。
4.3 可能的优化方向
- 记忆化搜索:可以用递归+记忆化的方式实现,可能在某些情况下更直观。
- 数学优化:对于某些特定模式的target,可能存在更优的数学解法。
- 预处理:可以预先计算一些常见target的解,减少运行时计算量。
5. 示例验证与调试技巧
5.1 典型测试用例
-
target=3:
- 2^2-1=3 → 直接返回2(AA)
-
target=6:
- n=3(2^3-1=7)
- 策略1:3+1+dp[1]=5
- 策略2:
- m=0:2+1+0+1+dp[3]=2+1+0+1+2=6
- m=1:2+1+1+1+dp[2]=5
- 最终取最小值5(AAARA)
5.2 调试技巧
- 打印中间结果:在计算每个t时,打印n、full、prev等关键变量。
- 边界检查:特别注意target=0和target=1的情况。
- 可视化:可以画出指令序列和位置变化图,帮助理解。
6. 常见问题与解决方案
6.1 为什么不用BFS?
虽然BFS直观(状态=(位置,速度)),但状态空间太大:
- 速度会指数增长(1,2,4,8,...)
- 即使剪枝,效率也不高
- 时间复杂度难以控制
6.2 如何处理大target?
对于特别大的target(比如10^4以上):
- 确保使用动态规划而不是BFS
- 注意Java的整数溢出问题
- 可以考虑使用更高效的语言实现
6.3 如何验证解的正确性?
- 手动计算小target的预期结果
- 编写一个模拟函数,实际执行指令序列验证是否到达目标
- 使用LeetCode的测试用例进行验证
7. 实际应用与扩展
虽然这个问题看起来像纯理论练习,但它实际上涉及了几个重要的算法思想:
- 最优子结构:大问题的最优解包含子问题的最优解
- 状态表示:如何有效地表示和存储子问题的解
- 策略分析:如何系统地考虑所有可能的策略
这类问题在机器人路径规划、游戏AI等领域都有实际应用。例如在自动驾驶中,如何规划最短路径到达目的地,同时考虑加速、减速等操作。