前言
记录 LeetCode 刷题时遇到的动态规划相关题目,第二篇
115.不同的子序列
思路基于 官方题解
与常规思路不同,这次 从后往前进行匹配
设 s 长度为 m,t 长度为 n。构建一个二维 dp 数组,其中 dp[i][j] 表示从 s 中 i 下标开始 (包括 i 下标) 往后的子串 (即 s[i…m - 1] ) 中,能找到多少个子序列,与 t 中 j 下标开始 (包括 j 下标) 往后的子串 (即 t[j…n - 1] ) 相同
那么 为了找到能与 t[j…n - 1] 相同的子序列,当 s[i] == t[j] 时,dp[i][j] 可以等于 dp[i + 1][j + 1],即在 s[i + 1…m - 1] 中找到的和 t[j + 1…n - 1] 相同子序列的基础上,在左边加上 s[i],就能跟 t[j…n - 1] 相同
不过 s[i] == t[j] 也不一定就要选上 s[i] 才能找到跟 t[j…n - 1] 相同的子序列,也可以直接在 s[i + 1…m - 1] 中找与 t[j…n - 1] 相同的子序列,即 dp[i + 1][j]
而如果 s[i] != t[j],那么为了能与 t[j…n - 1] 相同,s[i] 就不能选,只能在 s[i + 1…m - 1] 中找与 t[j…n - 1] 相同的子序列 (只能往后找,因为是从后往前匹配的) ,故此时 dp[i][j] = dp[i + 1][j]
那么状态转移方程就出来了,接下来就是状态边界,当 i == m 时,表示 s 中的空串,此时肯定找不到其有子序列与 t 相同,故 dp[m][0…n] = 0;当 j == n 时,表示 t 中的空串,由于空串一定是任何字符串的子串,所以 dp[0…m][n] = 1
边界也处理完毕了,就可以得到代码了:
public int numDistinct(String s, String t) {
int m = s.length(), n = t.length();
if (m < n) {
return 0;
}
char[] sc = s.toCharArray();
char[] tc = t.toCharArray();
int[][] dp = new int[m + 1][n + 1];
for (int i = 0; i <= m; i++) {
dp[i][n] = 1;
}
for (int i = m - 1; i >= 0; i--) {
for (int j = n - 1; j >= 0; j--) {
if (sc[i] == tc[j]) {
dp[i][j] = dp[i + 1][j + 1] + dp[i + 1][j];
} else {
dp[i][j] = dp[i + 1][j];
}
}
}
return dp[0][0];
}
以上代码耗时 5 ms,如果没有提前将字符串转化为字符数组,在后面获取字符时使用 charAt() 方法的话,耗时为 10 ms,所以需要频繁获取字符串上某一位字符的话,应转化到字符数组来操作
使用二维dp就一定要考虑一下一维dp,观察状态转移方程,dp[i][j] 的值是来自下一行的下标 j 以及下标 j + 1 这两个元素,所以是可以转化为一维 dp 的,只不过 dp 数组遍历方向应该是从左到右而不是从右到左,否则值会被覆盖,因为左边的值来自右边的值,如果从右到左遍历,右边的值先被修改了,左边得到的值就会不同
public int numDistinct(String s, String t) {
int m = s.length(), n = t.length();
if (m < n) {
return 0;
}
char[] sc = s.toCharArray();
char[] tc = t.toCharArray();
int[] dp = new int[n + 1];
dp[n] = 1;
for (int i = m - 1; i >= 0; i--) {
char sChar = sc[i];
//从左到右遍历
for (int j = 0; j < n; j++) {
if (sChar == tc[j]) {
dp[j] = dp[j + 1] + dp[j];
}
}
}
return dp[0];
}
718. 最长重复子数组
第一种做法是暴力,两层 for 循环枚举 nums1 数组中的一个数以及 nums2 数组中的一个数,如果这两个数相等,就一起往前枚遍历前面的数字是否相等,记录长度,直到遍历到两个不相等的数字时间复杂度为 O(n3)
第二种做法就是动态规划,动态规划可以看成是暴力做法加上了 记忆化,二维数组 dp,dp[i][j] 表示 nums1 数组中以 nums1[i] 为子数组最后一个数字,nums2 数组中以 nums2[j] 为子数组最后一个数字,所得到的子数组的重复长度,那么状态方程就是
d
p
[
i
]
[
j
]
=
d
p
[
i
−
1
]
[
j
−
1
]
+
1
,
n
u
m
s
1
[
i
]
=
=
n
u
m
s
2
[
j
]
dp[i][j] = dp[i - 1][j - 1] + 1,nums1[i] == nums2[j]
dp[i][j]=dp[i−1][j−1]+1,nums1[i]==nums2[j]
d
p
[
i
]
[
j
]
=
0
,
n
u
m
s
1
[
i
]
≠
n
u
m
s
2
[
j
]
dp[i][j] = 0,nums1[i] ≠ nums2[j]
dp[i][j]=0,nums1[i]=nums2[j]
这里直接做降维处理:
public int findLength(int[] nums1, int[] nums2) {
int len1 = nums1.length;
int len2 = nums2.length;
int[] dp = new int[len2 + 1];
int res = 0;
for(int i = 1;i <= len1;i++){
for(int j = len2;j > 0;j--){
dp[j] = nums1[i - 1] == nums2[j - 1] ? 1 + dp[j - 1] : 0;
res = Math.max(res,dp[j]);
}
}
return res;
}
139. 单词拆分
布尔数组 dp,dp[i] 表示字符串 s 中前 i 个字符组成的子字符串是否可以利用字典中出现的单词拼接出来
那么,如果可以将这个子串分成两部分,[0,k),以及 [k,i),且 [0,k) 这部分子串可以利用字典中出现的单词拼接出来,即 dp[k] == true,以及 [k,i) 这部分子串是字典中出现的单词,那么整个 [0,i) 子串就可以用字典中的单词拼接出来
枚举长度 i 从 1 到 len,依次计算 dp[i] 的值,如何计算 dp[i]?对每个长度,再枚举一个长度 j 从 0 到 i - 1,只要能找到一个 j 满足 dp[j] 为 true 且剩下的 i - j 个字符组成的串为字典中的单词,就说明 dp[i] 为 true,复杂度为 O(n2)
public boolean wordBreak(String s, List<String> wordDict) {
Set<String> wordDictSet = new HashSet(wordDict);
int len = s.length();
boolean[] dp = new boolean[len + 1];
dp[0] = true; //前 0 个字符,表示空串
for(int i = 1;i <= len;i++){
for(int j = 0;j <= i - 1;j++){
//枚举前 j 个字符构成的子串以及对应的剩余的 i - j 个字符构成的单词
//只要有一种情况满足就可以判定 dp[i] 为 true
if(dp[j] && wordDictSet.contains(s.substring(j,i))){
dp[i] = true;
break;
}
}
}
return dp[len];
}
140. 单词拆分 II
先使用 139 题的方法找到哪些部分可以拆分成单词,再使用回溯找到所有能得到句子的组合
public class Solution {
public List<String> wordBreak(String s, List<String> wordDict) {
//先找出可以拆分的子串,方法跟139题一样
Set<String> wordSet = new HashSet<>(wordDict);
int len = s.length();
boolean[] dp = new boolean[len + 1];
dp[0] = true;
for (int right = 1; right <= len; right++) {
for (int left = right - 1; left >= 0; left--) {
if (wordSet.contains(s.substring(left, right)) && dp[left]) {
dp[right] = true;
break;
}
}
}
List<String> res = new ArrayList<>();
//如果整个字符串不能拆分就不用继续算了
if (dp[len]) {
LinkedList<String> cur = new LinkedList<>();
backTrack(s, len, wordSet, dp, cur, res);
return res;
}
return res;
}
//回溯方法,将s中[0,len)拆分出单词,并将不同的拆分方案加到res中
private void backTrack(String s, int len, Set<String> wordSet, boolean[] dp, Deque<String> cur, List<String> res) {
if (len == 0) {
//String.join() 方法用于在数组/集合/多个字符串中每个字符串之间添加一个符号,然后将最后得到的整个字符串返回
res.add(String.join(" ",cur));
return;
}
//从后往前找是否有能拆分出来的单词
for (int i = len - 1; i >= 0; i--) {
String suffix = s.substring(i, len);
if (wordSet.contains(suffix) && dp[i]) {
//找到能拆分出来的单词,就加到 cur 最前面的位置。然后对前面的长度i的字串继续找
cur.addFirst(suffix);
backTrack(s, i, wordSet, dp, cur, res);
cur.removeFirst();//回退
}
}
}
}
546.移除盒子
结合题解总结:
dp(l,r,k) 表示移除 区间 [l, r] 的元素和该区间右边 等于 boxes[r] 的 k 个元素 组成的这个序列 的最大积分
那么对这个序列所采取的策略有如下几种:
- 将 boxes[r] 跟右边等于 boxes[r] 的 k 个元素放在一起消除,剩下的 [l,r - 1] 再一起消除,得到的解为 dp[l][r - 1][0] + (k + 1)²
- 考虑将 [l,r] 拆成两部分分别进行移除:取 i ∈ [l,r),将 [l,r] 分为 [l,i] 以及 (i,r]
class Solution {
int[][][] dp;
public int removeBoxes(int[] boxes) {
int len = boxes.length;
dp = new int[len][len][len];
return calculatePoints(boxes,0,len - 1,0);
}
public int calculatePoints(int[] boxes,int l,int r,int k) {
if (l > r) {
return 0;
}
if (dp[l][r][k] == 0) { //不为 0 说明已经计算过了,避免重复计算
dp[l][r][k] = calculatePoints(boxes,l,r - 1,0) + (k + 1) * (k + 1); //将boxes[r]跟区间右边那 k 个元素一起消除
for (int i = l;i < r;i++) {
if (boxes[i] == boxes[r]) {
//boxes[i]==boxes[r],就可以先移除[i + 1,r - 1],让boxes[i]跟boxes[r]跟boxes[r]右边那k个元素连续,
//这样boxes[i]右边就有k + 1个相同的连续元素,就可以进行dp[l,i,k + 1]的移除了
dp[l][r][k] = Math.max(dp[l][r][k],calculatePoints(boxes,i + 1,r - 1,0) + calculatePoints(boxes,l,i,k + 1));
}else{
//boxes[i]!=boxes[r],就不需要让boxes[i]跟boxes[r]连续了,
//直接移除[l,i],再进行dp[i+1,r,k]的移除
dp[l][r][k] = Math.max(dp[l][r][k],calculatePoints(boxes,l,i,0) + calculatePoints(boxes,i + 1,r,k));
}
}
}
return dp[l][r][k];
}
}
688. 骑士在棋盘上的概率
在棋盘上某点移动 k 次后能留在棋盘上的概率有一部分贡献来自于在移动到棋盘上另外一个点后继续移动 k - 1 次后能留在棋盘上的概率,这种可以拆解为子问题的问题可以考虑动态规划
class Solution {
//八种移动方式
int[][] move = 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]; //f[i][j][k] 表示在 (i,j) 处移动 k 次后能留在棋盘内的概率
//边界,在棋盘上任意位置,移动 0 次后都一定留在棋盘内
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++) { //移动p次时
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) { //在(i,j)点开始移动
for (int[] m : move) {
int nx = i + m[0], ny = j + m[1];
//尝试某种移动方式并判断是否可行
if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue;
//从(i,j)移动到(nx,ny)是可行的,反过来在(nx,ny)跳第p次到达(i,j)是可行的
//而要到达(i.j)有1/8的可能是从(nx,ny)到达
//所以最终(nx,ny)对能否在第p次到达(i,j)的概率的贡献为 f[nx][ny][p - 1] * 1/8
f[i][j][p] += f[nx][ny][p - 1] / 8;
}
}
}
}
//状态推导完毕,最后就是返回从(row,column)移动k次后能留在棋盘上的概率
return f[row][column][k];
}
}