线性 DP
动态规划一直都是面试高频,而动态规划中的线性 DP 更是重中之重。
今天将用 道题,带大家体会下线性 DP 该如何突破。
688. 骑士在棋盘上的概率
在一个 的国际象棋棋盘上,一个骑士从单元格 开始,并尝试进行 次移动。行和列是 从 开始 的,所以左上单元格是 ,右下单元格是 。
象棋骑士有 种可能的走法,如下图所示。每次移动在基本方向上是两个单元格,然后在正交方向上是一个单元格。
每次骑士要移动时,它都会随机从 种可能的移动中选择一种(即使棋子会离开棋盘),然后移动到那里。
骑士继续移动,直到它走了 步或离开了棋盘。
返回 骑士在棋盘停止移动后仍留在棋盘上的概率 。
示例 1:
输入: n = 3, k = 2, row = 0, column = 0
输出: 0.0625
解释: 有两步(到(1,2),(2,1))可以让骑士留在棋盘上。
在每一个位置上,也有两种移动可以让骑士留在棋盘上。
骑士留在棋盘上的总概率是0.0625。
提示:
线性 DP
定义 为从位置 出发,使用步数不超过 步,最后仍在棋盘内的概率。
不失一般性考虑 该如何转移,根据题意,移动规则为「八连通」,对下一步的落点 进行分情况讨论即可:
- 由于计算的是仍在棋盘内的概率,因此对于 在棋盘外的情况,无须考虑;
- 若下一步的落点 在棋盘内,其剩余可用步数为 ,则最后仍在棋盘的概率为 ,则落点 对 的贡献为 ,其中 为事件「从 走到 」的概率(八连通移动等概率发生),该事件与「到达 后进行后续移动并留在棋盘」为相互独立事件。
最终的 为「八连通」落点的概率之和,即有:
代码:
class Solution {
int[][] dirs = new int[][]{{-1,-2},{-1,2},{1,-2},{1,2},{-2,1},{-2,-1},{2,1},{2,-1}};
public double knightProbability(int n, int k, int row, int column) {
double[][][] f = new double[n][n][k + 1];
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
f[i][j][0] = 1;
}
}
for (int p = 1; p <= k; p++) {
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
for (int[] d : dirs) {
int nx = i + d[0], ny = j + d[1];
if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue;
f[i][j][p] += f[nx][ny][p - 1] / 8;
}
}
}
}
return f[row][column][k];
}
}
- 时间复杂度:令某个位置可联通的格子数量 ,复杂度为
- 空间复杂度:
650. 只有两个键的键盘
最初记事本上只有一个字符 'A'
。你每次可以对这个记事本进行两种操作:
Copy All(复制全部)
:复制这个记事本中的所有字符(不允许仅复制部分字符)。Paste(粘贴)
:粘贴 上一次 复制的字符。
给你一个数字 n
,你需要使用最少的操作次数,在记事本上输出 恰好 n
个 'A'
。返回能够打印出 n
个 'A'
的最少操作次数。
示例 1:
输入:3
输出:3
解释:
最初, 只有一个字符 'A'。
第 1 步, 使用 Copy All 操作。
第 2 步, 使用 Paste 操作来获得 'AA'。
第 3 步, 使用 Paste 操作来获得 'AAA'。
提示:
动态规划
定义 为经过最后一次操作后,当前记事本上有 个字符,粘贴板上有 个字符的最小操作次数。
由于我们粘贴板的字符必然是经过 Copy All
操作而来,因此对于一个合法的 而言,必然有 。
不失一般性地考虑 该如何转移:
-
最后一次操作是
Paste
操作:此时粘贴板的字符数量不会发生变化,即有 ; -
最后一次操作是
Copy All
操作:那么此时的粘贴板的字符数与记事本上的字符数相等(满足 ),此时的 。
我们发现最后一个合法的 (满足 )依赖与前面 (满足 )。
因此实现上,我们可以使用一个变量 保存前面转移的最小值,用来更新最后的 。
再进一步,我们发现如果 的最后一次操作是由 Paste
而来,原来粘贴板的字符数不会超过 ,因此在转移 (满足 )时,其实只需要枚举 即可。
代码:
class Solution {
int INF = 0x3f3f3f3f;
public int minSteps(int n) {
int[][] f = new int[n + 1][n + 1];
for (int i = 0; i <= n; i++) {
for (int j = 0; j <= n; j++) {
f[i][j] = INF;
}
}
f[1][0] = 0; f[1][1] = 1;
for (int i = 2; i <= n; i++) {
int min = INF;
for (int j = 0; j <= i / 2; j++) {
f[i][j] = f[i - j][j] + 1;
min = Math.min(min, f[i][j]);
}
f[i][i] = min + 1;
}
int ans = INF;
for (int i = 0; i <= n; i++) ans = Math.min(ans, f[n][i]);
return ans;
}
}
- 时间复杂度:
- 空间复杂度:
数学
如果我们将「 次 Copy All
+ 次 Paste
」看做一次“动作”的话。
那么 一次“动作”所产生的效果就是将原来的字符串变为原来的 倍。
最终的最小操作次数方案可以等价以下操作流程:
- 起始对长度为 的记事本字符进行 次
Copy All
+ 次Paste
操作(消耗次数为 ,得到长度为 的记事本长度); - 对长度为为 的记事本字符进行 次
Copy All
+ 次Paste
操作(消耗次数为 ,得到长度为 的记事本长度) ...
最终经过 次“动作”之后,得到长度为 的记事本长度,即有:
问题转化为:如何对 进行拆分,可以使得 最小。
对于任意一个 (合数)而言,根据定理 可知进一步的拆分必然不会导致结果变差。
因此,我们只需要使用「试除法」对 执行分解质因数操作,累加所有的操作次数,即可得到答案。
代码:
class Solution {
public int minSteps(int n) {
int ans = 0;
for (int i = 2; i * i <= n; i++) {
while (n % i == 0) {
ans += i;
n /= i;
}
}
if (n != 1) ans += n;
return ans;
}
}
- 时间复杂度:
- 空间复杂度:
打表
我们发现,对于某个 而言为定值,且数据范围只有 ,因此考虑使用打表来做。
代码:
class Solution {
static int N = 1010;
static int[] g = new int[N];
static {
for (int k = 2; k < N; k++) {
int cnt = 0, n = k;
for (int i = 2; i * i <= n; i++) {
while (n % i == 0) {
cnt += i;
n /= i;
}
}
if (n != 1) cnt += n;
g[k] = cnt;
}
// System.out.println(Arrays.toString(g)); // 输出打表结果
}
public int minSteps(int n) {
return g[n];
}
}
- 时间复杂度:将打表逻辑配合
static
交给 OJ 执行,复杂度为 , 为常数,固定为 ;将打表逻辑放到本地执行,复杂度为 - 空间复杂度:
978. 最长湍流子数组
当 A
的子数组 满足下列条件时,我们称其为湍流子数组:
- 若 ,当 为奇数时,,且当 为偶数时,;
- 若 ,当 为偶数时, ,且当 为奇数时,。
也就是说,如果比较符号在子数组中的每个相邻元素对之间翻转,则该子数组是湍流子数组。
返回 A
的最大湍流子数组的长度。
示例 1:
输入:[9,4,2,10,7,8,8,1,9]
输出:5
解释:(A[1] > A[2] < A[3] > A[4] < A[5])
提示:
基本思路
本题其实是要我们求最长一段呈 ↗ ↘ ↗ ↘
或者 ↘ ↗ ↘ ↗
形状的数组长度。
看一眼数据范围,有 ,那么枚举起点和终点,然后对划分出来的子数组检查是否为「湍流子数组」的朴素解法就不能过了。
朴素解法的复杂度为 ,直接放弃朴素解法。
复杂度往下优化,其实就 的 DP 解法了。
动态规划
至于 DP 如何分析,通过我们会先考虑一维 DP 能否求解,不行再考虑二维 DP。
对于本题,由于每个位置而言,能否「接着」上一个位置形成「湍流」,取决于上一位置是由什么形状而来。
举个例子,对于样例 [3,4,2]
,从 4 -> 2 已经确定是 ↘
状态,那么对于 2 这个位置能否「接着」4 形成「湍流」,要求 4 必须是由 ↗
而来。
因此我们还需要记录某一位是如何来的(↗
还是 ↘
),需要使二维 DP 来求解 ~
我们定义 代表以位置 为结尾,而结尾状态为 的最长湍流子数组长度(0:上升状态 / 1:下降状态)
PS. 这里的状态定义我是猜的,这其实是个技巧。通常我们做 DP 题,都是先猜一个定义,然后看看这个定义是否能分析出状态转移方程帮助我们「不重不漏」的枚举所有的方案。一般我是直接根据答案来猜定义,这里是求最长子数组长度,所以我猜一个 f(i,j) 代表最长湍流子数组长度
不失一般性考虑 该如何求解,我们知道位置 是如何来是唯一确定的(取决于 和 的大小关系),而只有三种可能性:
- :该点是由上升而来,能够「接着」的条件是 是由下降而来。则有:
- :改点是由下降而来,能够「接着」的条件是 是由上升而来。则有:
- :不考虑,不符合「湍流」的定义
代码:
class Solution {
public int maxTurbulenceSize(int[] arr) {
int n = arr.length, ans = 1;
int[][] f = new int[n][2];
f[0][0] = f[0][1] = 1;
for (int i = 1; i < n; i++) {
f[i][0] = f[i][1] = 1;
if (arr[i] > arr[i - 1]) f[i][0] = f[i - 1][1] + 1;
else if (arr[i] < arr[i - 1]) f[i][1] = f[i - 1][0] + 1;
ans = Math.max(ans, Math.max(f[i][0], f[i][1]));
}
return ans;
}
}
- 时间复杂度:
- 空间复杂度:
空间优化:奇偶滚动
我们发现对于 状态的更新只依赖于 的状态。
因此我们可以使用「奇偶滚动」方式来将第一维从 优化到 。
修改的方式也十分机械,只需要改为「奇偶滚动」的维度直接修改成 ,然后该维度的所有访问方式增加 %2
或者 &1
即可:
代码:
class Solution {
public int maxTurbulenceSize(int[] arr) {
int n = arr.length, ans = 1;
int[][] f = new int[2][2];
f[0][0] = f[0][1] = 1;
for (int i = 1; i < n; i++) {
f[i % 2][0] = f[i % 2][1] = 1;
if (arr[i] > arr[i - 1]) f[i % 2][0] = f[(i - 1) % 2][1] + 1;
else if (arr[i] < arr[i - 1]) f[i % 2][1] = f[(i - 1) % 2][0] + 1;
ans = Math.max(ans, Math.max(f[i % 2][0], f[i % 2][1]));
}
return ans;
}
}
- 时间复杂度:
- 空间复杂度:使用固定
2 * 2
的数组空间。复杂度为
空间优化:维度消除
既然只需要记录上一行状态,能否直接将行的维度消除呢?
答案是可以的,当我们要转移第 行的时候, 装的就已经是 行的结果。
这也是著名「背包问题」的一维通用优手段。
但相比于「奇偶滚动」的空间优化,这种优化手段只是常数级别的优化(空间复杂度与「奇偶滚动」相同),而且优化通常涉及代码改动。
代码:
class Solution {
public int maxTurbulenceSize(int[] arr) {
int n = arr.length, ans = 1;
int[] f = new int[2];
f[0] = f[1] = 1;
for (int i = 1; i < n; i++) {
int a = f[0], b = f[1];
f[0] = arr[i - 1] < arr[i] ? b + 1 : 1;
f[1] = arr[i - 1] > arr[i] ? a + 1 : 1;
ans = Math.max(ans, Math.max(f[0], f[1]));
}
return ans;
}
}
- 时间复杂度:
- 空间复杂度:
1220. 统计元音字母序列的数目
给你一个整数 n
,请你帮忙统计一下我们可以按下述规则形成多少个长度为 n
的字符串:
字符串中的每个字符都应当是小写元音字母('a', 'e', 'i', 'o', 'u')
- 每个元音
'a'
后面都只能跟着'e'
- 每个元音
'e'
后面只能跟着'a'
或者是'i'
- 每个元音
'i'
后面 不能 再跟着另一个'i'
- 每个元音
'o'
后面只能跟着'i'
或者是'u'
- 每个元音
'u'
后面只能跟着'a'
由于答案可能会很大,所以请你返回 模 之后的结果。
示例 1:
输入:n = 1
输出:5
解释:所有可能的字符串分别是:"a", "e", "i" , "o" 和 "u"。
示例 2:
输入:n = 2
输出:10
解释:所有可能的字符串分别是:"ae", "ea", "ei", "ia", "ie", "io", "iu", "oi", "ou" 和 "ua"。
示例 3:
输入:n = 5
输出:68
提示:
线性 DP
定义 为考虑长度为 的字符串,且结尾元素为 的方案数(其中 代表数组 ['a', 'e', 'i', 'o', 'u']
下标)。
不失一般性考虑 该如何计算。
我们可以从题意给定的规则进行出发,从 出发往前更新 ,也可以直接利用对称性进行反向分析。
为了方便大家理解,还是将常规的「从 出发往前更新 」作为主要分析方法吧。
根据条件可以容易写出转移方程:
- 每个元音
'a'
后面都只能跟着'e'
:; - 每个元音
'e'
后面只能跟着'a'
或者是'i'
:、; - 每个元音
'i'
后面 不能 再跟着另一个'i'
:; - 每个元音
'o'
后面只能跟着'i'
或者是'u'
:、; - 每个元音
'u'
后面只能跟着'a'
:。
class Solution {
int MOD = (int)1e9+7;
public int countVowelPermutation(int n) {
long[][] f = new long[n][5];
Arrays.fill(f[0], 1);
for (int i = 0; i < n - 1; i++) {
// 每个元音 'a' 后面都只能跟着 'e'
f[i + 1][1] += f[i][0];
// 每个元音 'e' 后面只能跟着 'a' 或者是 'i'
f[i + 1][0] += f[i][1];
f[i + 1][2] += f[i][1];
// 每个元音 'i' 后面 不能 再跟着另一个 'i'
f[i + 1][0] += f[i][2];
f[i + 1][1] += f[i][2];
f[i + 1][3] += f[i][2];
f[i + 1][4] += f[i][2];
// 每个元音 'o' 后面只能跟着 'i' 或者是 'u'
f[i + 1][2] += f[i][3];
f[i + 1][4] += f[i][3];
// 每个元音 'u' 后面只能跟着 'a'
f[i + 1][0] += f[i][4];
for (int j = 0; j < 5; j++) f[i + 1][j] %= MOD;
}
long ans = 0;
for (int i = 0; i < 5; i++) ans += f[n - 1][i];
return (int)(ans % MOD);
}
}
- 时间复杂度:令 为字符集大小,本题 固定为 。整体复杂度为
- 空间复杂度:
总结
这里简单说下「线性 DP」和「序列 DP」的区别。
线性 DP 通常强调「状态转移所依赖的前驱状态」是由给定数组所提供的,即拓扑序是由原数组直接给出。更大白话来说就是通常有 依赖于 。
这就限定了线性 DP 的复杂度是简单由「状态数量(或者说是维度数)」所决定。
序列 DP 通常需要结合题意来寻找前驱状态,即需要自身寻找拓扑序关系(例如本题,需要自己结合题意来找到可转移的前驱状态 )。
这就限定了序列 DP 的复杂度是由「状态数 + 找前驱」的复杂度所共同决定。也直接导致了序列 DP 有很多玩法,往往能够结合其他知识点出题,来优化找前驱这一操作,通常是利用某些性质,或是利用数据结构进行优化。