PS:如今这到题好想变成会员题目了?我当时作的时候仍是免费的。git
四键键盘问题颇有意思,并且能够明显感觉到:对 dp 数组的不一样定义须要彻底不一样的逻辑,从而产生彻底不一样的解法。算法
首先看一下题目:数组
如何在 N 次敲击按钮后获得最多的 A?咱们穷举呗,每次有对于每次按键,咱们能够穷举四种可能,很明显就是一个动态规划问题。app
这种思路会很容易理解,可是效率并不高,咱们直接走流程:对于动态规划问题,首先要明白有哪些「状态」,有哪些「选择」。框架
具体到这个问题,对于每次敲击按键,有哪些「选择」是很明显的:4 种,就是题目中提到的四个按键,分别是 A
、C-A
、C-C
、C-V
(Ctrl
简写为 C
)。ide
接下来,思考一下对于这个问题有哪些「状态」?或者换句话说,咱们须要知道什么信息,才能将原问题分解为规模更小的子问题?函数
你看我这样定义三个状态行不行:第一个状态是剩余的按键次数,用 n
表示;第二个状态是当前屏幕上字符 A 的数量,用 a_num
表示;第三个状态是剪切板中字符 A 的数量,用 copy
表示。优化
如此定义「状态」,就能够知道 base case:当剩余次数 n
为 0 时,a_num
就是咱们想要的答案。spa
结合刚才说的 4 种「选择」,咱们能够把这几种选择经过状态转移表示出来:设计
dp(n - 1, a_num + 1, copy), # A 解释:按下 A 键,屏幕上加一个字符 同时消耗 1 个操做数 dp(n - 1, a_num + copy, copy), # C-V 解释:按下 C-V 粘贴,剪切板中的字符加入屏幕 同时消耗 1 个操做数 dp(n - 2, a_num, a_num) # C-A C-C 解释:全选和复制必然是联合使用的, 剪切板中 A 的数量变为屏幕上 A 的数量 同时消耗 2 个操做数
PS:我认真写了 100 多篇原创,手把手刷 200 道力扣题目,所有发布在 labuladong的算法小抄,持续更新。建议收藏,按照个人文章顺序刷题,掌握各类算法套路后投再入题海就如鱼得水了。
这样能够看到问题的规模 n
在不断减少,确定能够到达 n = 0
的 base case,因此这个思路是正确的:
def maxA(N: int) -> int: # 对于 (n, a_num, copy) 这个状态, # 屏幕上能最终最多能有 dp(n, a_num, copy) 个 A def dp(n, a_num, copy): # base case if n <= 0: return a_num; # 几种选择全试一遍,选择最大的结果 return max( dp(n - 1, a_num + 1, copy), # A dp(n - 1, a_num + copy, copy), # C-V dp(n - 2, a_num, a_num) # C-A C-C ) # 能够按 N 次按键,屏幕和剪切板里都尚未 A return dp(N, 0, 0)
这个解法应该很好理解,由于语义明确。下面就继续走流程,用备忘录消除一下重叠子问题:
def maxA(N: int) -> int: # 备忘录 memo = dict() def dp(n, a_num, copy): if n <= 0: return a_num; # 避免计算重叠子问题 if (n, a_num, copy) in memo: return memo[(n, a_num, copy)] memo[(n, a_num, copy)] = max( # 几种选择仍是同样的 ) return memo[(n, a_num, copy)] return dp(N, 0, 0)
这样优化代码以后,子问题虽然没有重复了,但数目仍然不少,在 LeetCode 提交会超时的。
咱们尝试分析一下这个算法的时间复杂度,就会发现不容易分析。咱们能够把这个 dp 函数写成 dp 数组:
dp[n][a_num][copy] # 状态的总数(时空复杂度)就是这个三维数组的体积
咱们知道变量 n
最多为 N
,可是 a_num
和 copy
最多为多少咱们很难计算,复杂度起码也有 O(N^3) 把。因此这个算法并很差,复杂度过高,且已经没法优化了。
这也就说明,咱们这样定义「状态」是不太优秀的,下面咱们换一种定义 dp 的思路。
PS:我认真写了 100 多篇原创,手把手刷 200 道力扣题目,所有发布在 labuladong的算法小抄,持续更新。建议收藏,按照个人文章顺序刷题,掌握各类算法套路后投再入题海就如鱼得水了。
这种思路稍微有点复杂,可是效率高。继续走流程,「选择」仍是那 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
。明确了这一点,能够经过这两种状况来设计算法:
int[] dp = new int[N + 1]; // 定义:dp[i] 表示 i 次操做后最多能显示多少个 A for (int i = 0; i <= N; i++) dp[i] = max( 此次按 A 键, 此次按 C-V )
对于「按 A
键」这种状况,就是状态 i - 1
的屏幕上新增了一个 A 而已,很容易获得结果:
// 按 A 键,就比上次多一个 A 而已 dp[i] = dp[i - 1] + 1;
可是,若是要按 C-V
,还要考虑以前是在哪里 C-A C-C
的。
刚才说了,最优的操做序列必定是 C-A C-C
接着若干 C-V
,因此咱们用一个变量 j
做为若干 C-V
的起点。那么 j
以前的 2 个操做就应该是 C-A C-C
了:
public int maxA(int N) { int[] dp = new int[N + 1]; dp[0] = 0; for (int i = 1; i <= N; i++) { // 按 A 键 dp[i] = dp[i - 1] + 1; for (int j = 2; j < i; j++) { // 全选 & 复制 dp[j-2],连续粘贴 i - j 次 // 屏幕上共 dp[j - 2] * (i - j + 1) 个 A dp[i] = Math.max(dp[i], dp[j - 2] * (i - j + 1)); } } // N 次按键以后最多有几个 A? return dp[N]; }
其中 j
变量减 2 是给 C-A C-C
留下操做数,看个图就明白了:
这样,此算法就完成了,时间复杂度 O(N^2),空间复杂度 O(N),这种解法应该是比较高效的了。
动态规划难就难在寻找状态转移,不一样的定义能够产生不一样的状态转移逻辑,虽然最后都能获得正确的结果,可是效率可能有巨大的差别。
回顾第一种解法,重叠子问题已经消除了,可是效率仍是低,到底低在哪里呢?抽象出递归框架:
def dp(n, a_num, copy): dp(n - 1, a_num + 1, copy), # A dp(n - 1, a_num + copy, copy), # C-V dp(n - 2, a_num, a_num) # C-A C-C
看这个穷举逻辑,是有可能出现这样的操做序列 C-A C-C,C-A C-C...
或者 C-V,C-V,...
。然这种操做序列的结果不是最优的,可是咱们并无想办法规避这些状况的发生,从而增长了不少不必的子问题计算。
回顾第二种解法,咱们稍加思考就能想到,最优的序列应该是这种形式:A,A..C-A,C-C,C-V,C-V..C-A,C-C,C-V..
。
根据这个事实,咱们从新定义了状态,从新寻找了状态转移,从逻辑上减小了无效的子问题个数,从而提升了算法的效率。