第一种思路
这种思路会很容易理解,但是效率并不高,直接走流程:对于动态规划问题,首先要明白有哪些「状态」,有哪些「选择」。
具体到这个问题,对于每次敲击按键,有哪些「选择」是很明显的:4 种,就是题目中提到的四个按键,分别是 A
、C-A
、C-C
、C-V
(Ctrl
简写为 C
)。
接下来,思考一下对于这个问题有哪些「状态」?或者换句话说,需要知道什么信息,才能将原问题分解为规模更小的子问题?
第一个状态是剩余的按键次数,用 n
表示;
第二个状态是当前屏幕上字符 A 的数量,用 a_num
表示;
第三个状态是剪切板中字符 A 的数量,用 copy
表示。
如此定义「状态」,就可以知道 base case:当剩余次数 n
为 0 时,a_num
就是我们想要的答案。
结合刚才说的 4 种「选择」,可以把这几种选择通过状态转移表示出来:
1 | dp(n - 1, a_num + 1, copy), // A |
这样可以看到问题的规模 n
在不断减小,肯定可以到达 n = 0
的 base case,所以这个思路是正确的:
1 | public int maxA(int N) { |
下面就继续走流程,用备忘录消除一下重叠子问题:
1 | HashMap<String, Integer> map = new HashMap(); |
第二种思路
这种思路稍微有点复杂,但是效率高。继续走流程,「选择」还是那 4 个,但是这次只定义一个「状态」,也就是剩余的敲击次数 n
。
这个算法基于这样一个事实,最优按键序列一定只有两种情况:
要么一直按 A
:A,A,…A(当 N 比较小时)。
要么是这么一个形式:A,A,…C-A,C-C,C-V,C-V,…C-V(当 N 比较大时)。
因为字符数量少(N 比较小)时,C-A C-C C-V
这一套操作的代价相对比较高,可能不如一个个按 A
;而当 N 比较大时,后期 C-V
的收获肯定很大。这种情况下整个操作序列大致是:开头连按几个 A
,然后 C-A C-C
组合再接若干 C-V
,然后再 C-A C-C
接着若干 C-V
,循环下去。
换句话说,最后一次按键要么是 A
要么是 C-V
。明确了这一点,可以通过这两种情况来设计算法:
1 | int[] dp = new int[N + 1]; |
对于「按 A
键」这种情况,就是状态 i - 1
的屏幕上新增了一个 A 而已,很容易得到结果:
1 | // 按 A 键,就比上次多一个 A 而已 |
但是,如果要按 C-V
,还要考虑之前是在哪里 C-A C-C
的。
刚才说了,最优的操作序列一定是 C-A C-C
接着若干 C-V
,所以用一个变量 j
作为若干 C-V
的起点。那么 j
之前的 2 个操作就应该是 C-A C-C
了:
1 | public int maxA(int N) { |
其中 j
变量减 2 是给 C-A C-C
留下操作数