动态规划专题四
解码方法(中等)
注意一下base case即可。
class Solution {
public int numDecodings(String s) {
int n = s.length();
int[] dp = new int[n + 1];
dp[0] = 1; // base case类似于走楼梯
if (s.charAt(0) != '0') {
dp[1] = 1;
}
for (int i = 2; i <= n; i++) {
// 新编码可以由当前一位i产生,也可以由当前一位i和i-1位两位共同产生
int numi = s.charAt(i - 1) - '0';
int numii = (s.charAt(i - 2) - '0') * 10 + numi;
if (numi != 0) dp[i] += dp[i - 1]; // 只要一位不等于0就可以统计
if (numii >= 10 && numii <= 26) dp[i] += dp[i - 2];
}
return dp[n];
}
}
交错字符串(中等)
注意:s1 = abc s2 = def s3 = abcdef也是满足的,也就是说s1 + s2 或者 s2 + s1都是满足题意的。
双指针为啥不行?
考虑上面的情况,第一个a匹配之后,不知道是用s1的a,还是用s2的a,这其实是两个分支,并不能由双指针直接确定下来,所以用双指针是不行的。
DFS记忆化搜索
class Solution {
Boolean[][] table;
public boolean isInterleave(String s1, String s2, String s3) {
int n1 = s1.length(), n2 = s2.length(), n3 = s3.length();
if (n1 + n2 != n3) return false; // s1 + s2的长度必须=s3
table = new Boolean[n1 + 1][n2 + 1];
return dfs(s1, s2, s3, 0, 0, 0);
}
// i,j,k分别代表遍历s1,s2,s3三个串的下标位置
boolean dfs(String s1, String s2, String s3, int i, int j, int k) {
if (k == s3.length()) return true;
// 注意用的数组是Boolean对象数组
if (table[i][j] != null) return table[i][j];
if (i != s1.length() && s1.charAt(i) == s3.charAt(k) && dfs(s1, s2, s3, i + 1, j, k + 1)) {
table[i + 1][j] = true;
return true;
}
if (j != s2.length() && s2.charAt(j) == s3.charAt(k) && dfs(s1, s2, s3, i, j + 1, k + 1)) {
table[i][j + 1] = true;
return true;
}
// 到达这里,说明s1 s2都不能匹配s3
table[i][j] = false;
return false;
}
}
用搜索的原因是因为有多种选择方案,加上记忆化是为了加快搜索速度,用到了记忆化搜索,那一定可以转成DP模型。
DP解法
考虑s3的当前字符可以跟s1或者s2匹配,匹配的前提是s1 s2还有剩余长度。
class Solution {
public boolean isInterleave(String s1, String s2, String s3) {
int n1 = s1.length(), n2 = s2.length(), n3 = s3.length();
if (n1 + n2 != n3) return false;
boolean[][] dp = new boolean[n1 + 1][n2 + 1];
// 考虑s1 = "" s2 = "a" s3 = "a",也是可以匹配的
// dp[0][1] = dp[0][1] || dp[0][0]
// so, base case:dp[0][0] = true
dp[0][0] = true;
for (int i = 0; i <= n1; i++) {
for (int j = 0; j <= n2; j++) {
// i + j的值就是获取s3的坐标值
int p = i + j;
if (i > 0) {
if (s1.charAt(i - 1) == s3.charAt(p - 1)) {
dp[i][j] = dp[i][j] || dp[i - 1][j];
}
}
if (j > 0) {
if (s2.charAt(j - 1) == s3.charAt(p - 1)) {
dp[i][j] = dp[i][j] || dp[i][j - 1];
}
}
}
}
return dp[n1][n2];
}
}
三个无重复子数组的最大和(困难)
站在最后一个子数组的角度去思考问题,就会很easy。很多时候,dp问题都是需要站在某一个位置去具体思考问题,看这个位置可以由什么状态转移过来。
本题的难点在于如何找到字典序最小的路径!
class Solution {
public int[] maxSumOfThreeSubarrays(int[] nums, int k) {
int n = nums.length;
int[] preSum = new int[n + 1];
for (int i = 0; i < n; i++) {
preSum[i + 1] = preSum[i] + nums[i];
}
// dp[i][j]:前i个数字,前j个子数组的最大和
int[][] dp = new int[n + 1][4];
// dp[k][1] = preSum[k];
for (int i = k; i <= n; i++) {
for (int j = 1; j <= 3; j++) {
// 站在最后一个子数组的角度去考虑
// 可以是前面已经有j个子数组、也可能是加上现在这个子数组才有j个子数组
dp[i][j] = Math.max(dp[i - 1][j], dp[i - k][j - 1] + preSum[i] - preSum[i - k]);
}
}
// 最大和
// System.out.println(dp[n][3]);
// 求路径
int[] ans = new int[3];
int i = n, j = 3, index = 2;
while (j > 0) {
// 这里一定要把 = 也排除掉,因为需要选择字典序最小,只有完全的大于,才能保证字典序最小
if (dp[i - 1][j] >= dp[i - k][j - 1] + preSum[i] - preSum[i - k]) {
i--;
} else {
// 选择当前下标
i = i - k;
j--;
ans[index--] = i;
}
}
return ans;
}
}
买卖股票的最佳时机(简单)
对于具体某一天,只要买在它之前的天数里面股票价格最低的,就可以保证这一天的收益最大(当然可能小于0)。
class Solution {
public int maxProfit(int[] prices) {
int n = prices.length;
int min = prices[0];
int ans = 0;
for (int i = 1; i < n; i++) {
ans = Math.max(ans, prices[i] - min);
min = Math.min(min, prices[i]);
}
return ans;
}
}
买卖股票的最佳时机Ⅱ(中等)
上面一题要求在整个过程中只能进行一次交易,本题可以进行多次交易,手中最多持有一只股票。
针对每一天,具有两种状态:持有现金、持有股票,在这个基础上进行考虑具体某一天在持有现金、持有股票的情况下的状态转移方程(无非就是从前一天的持有现金、持有股票的两种状态转移过来)
class Solution {
public int maxProfit(int[] prices) {
int n = prices.length;
int[][] dp = new int[n + 1][2];
// dp[i][0/1],第i天,有0\1两种状态
// 0:持有现金、1:持有股票
dp[1][0] = 0;
dp[1][1] = -prices[0];
// 默认手上的金钱数目=0
// 所以第一天持有股票后的金钱数目=-prices[0]
for (int i = 2; i <= n; i++) {
// 第i天的持有现金可以等于前一天持有的现金
// 也可以是之前买了股票,然后今天卖出去就是+prices[i]
dp[i][0] = Math.max(dp[i - 1][0], dp[i - 1][1] + prices[i - 1]);
// 第i天的持有股票时的状态可以等于前一天持有股票的状态
// 也可以是之前没有买股票,今天买股票的状态
dp[i][1] = Math.max(dp[i - 1][1], dp[i - 1][0] - prices[i - 1]);
}
// 返回最后一天的持有现金,也即最大利益
return dp[n][0];
}
}
买卖股票的最佳时机Ⅲ(困难)
上一题中没有限制交易次数,只是说任意时刻手上只能持有现金或持有股票,本题限制了交易次数:最多完成两笔交易。
一定要注意,只有买的时候,才会影响k值!对于base case,只需要考虑第一天持有、不持有股票的状态即可(当然需要遍历每一种交易上限次数)
class Solution {
public int maxProfit(int[] prices) {
int n = prices.length;
int[][][] dp = new int[n + 1][3][3];
for (int i = 1; i <= n; i++) {
for (int j = 2; j >= 1; j--) { // 遍历交易上限次数,不是已经进行的交易次数!!!
if (i == 1) {
// 针对第一天,无论交易次数限制是多少
// 只考虑持有、未持有股票的情况
dp[i][j][0] = 0;
dp[i][j][1] = -prices[i - 1];
continue;
}
// 当天未持有股票:之前也未持有、之前持有今天卖了
// 交易次数是说之前买了几次,今天卖并不影响
dp[i][j][0] = Math.max(dp[i - 1][j][0], dp[i - 1][j][1] + prices[i - 1]);
// 当天持有股票:之前也持有、今天才持有(那么之前的交易次数上限=k-1)
dp[i][j][1] = Math.max(dp[i - 1][j][1], dp[i - 1][j - 1][0] - prices[i - 1]);
}
}
return dp[n][2][0];
}
}
买卖股票的最佳时机Ⅳ(困难)
和上一题一样,区别在于,最大交易上限次数为k,那就修改下对k的值即可。
class Solution {
public int maxProfit(int k, int[] prices) {
int n = prices.length;
int[][][] dp = new int[n + 1][k + 1][2];
for (int i = 1; i <= n; i++) {
for (int j = k; j >= 1; j--) {
// 特判第一天
if (i == 1) {
// 无论交易上限是多少,只要未持有股票,利润=0
dp[i][j][0] = 0;
// 持有股票的话,就需要花钱买
dp[i][j][1] = -prices[i - 1];
continue;
}
// 未持有股票:之前未持有、之前持有今天才卖
dp[i][j][0] = Math.max(dp[i - 1][j][0], dp[i - 1][j][1] + prices[i - 1]);
// 持有股票:之前持有、之前未持有今天才买(会影响交易上限)
dp[i][j][1] = Math.max(dp[i - 1][j][1], dp[i - 1][j - 1][0] - prices[i - 1]);
}
}
// 返回的结果一定是考虑所有天数、最大的交易上限次数、并且手上不持股
return dp[n][k][0];
}
}
最佳买卖股票时机含冷冻期(中等)
冷冻期只影响买入,不影响卖出,你可以买入后立马卖出;但不能卖出后立马买入。
所以,需要改变的地方在于买入股票时的状态:如果是今天买入股票,那么就必须要在两天前卖出,这样到今天才能够买入。
class Solution {
public int maxProfit(int[] prices) {
int n = prices.length;
int[][] dp = new int[n + 1][2];
// dp[i][0/1],第i天不持有股票\持有股票
for (int i = 1; i <= n; i++) {
if (i == 1) {
dp[i][0] = 0;
dp[i][1] = -prices[i - 1];
continue;
}
// 只是卖出股票后,不能第二天马上买入股票
// 但是可以买入股票后,立马卖出
if (i == 2) { // 特判第二天
dp[i][0] = Math.max(dp[i - 1][0], dp[i - 1][1] + prices[i - 1]);
// 如果是今天持有,那么可能是之前买入,也可以是今天买入
dp[i][1] = Math.max(dp[i - 1][1], -prices[i - 1]);
continue;
}
// 今天不持有股票,可能是之前就没有持有,也可能是今天才卖
dp[i][0] = Math.max(dp[i - 1][0], dp[i - 1][1] + prices[i - 1]);
// 今天持有股票,可能是之前就持有,也可能是今天才持有
// 如果是今天才持有,由于存在冷冻期,必须要在两天前卖出后才能在今天购入
dp[i][1] = Math.max(dp[i - 1][1], dp[i - 2][0] - prices[i - 1]);
}
return dp[n][0];
}
}
买卖股票的最佳时机含手续费(中等)
手续费在股票买入、卖出的过程中,只算一次,为方便考虑,可以把手续费算到卖出时,这样只需要更改部分代码即可。
class Solution {
public int maxProfit(int[] prices, int fee) {
int n = prices.length;
int[][] dp = new int[n + 1][2];
// 把手续费算到卖出股票的时候
for (int i = 1; i <= n; i++) {
if (i == 1) {
// 未持有股票
dp[i][0] = 0;
// 持有股票
dp[i][1] = -prices[i - 1];
continue;
}
// 当前未持有股票:之前就未持有、之前持有今天卖掉
// 卖掉需要算一次手续费
dp[i][0] = Math.max(dp[i - 1][0], dp[i - 1][1] + prices[i - 1] - fee);
// 当前持有股票:之前就持有、之前未持有今天才买入
dp[i][1] = Math.max(dp[i - 1][1], dp[i - 1][0] - prices[i - 1]);
}
return dp[n][0];
}
}
戳气球(困难)
直接在nums数组上求值是困难的,不妨考虑添加一个个气球进去,看怎样添加能使得硬币数最多。
DFS记忆化搜索
class Solution {
int[] score;
int[][] table;
public int maxCoins(int[] nums) {
int n = nums.length;
score = new int[n + 2];
for (int i = 0; i < n; i++) {
score[i + 1] = nums[i];
}
// 为方便计算,将左右端点的值设置为1
score[0] = 1;
score[n + 1] = 1;
table = new int[n + 2][n + 2];
for (int i = 0; i < n + 2; i++) {
Arrays.fill(table[i], -1);
}
return dfs(0, n + 1);
}
int dfs(int left, int right) {
// 区间已经不足以插入一个气球,只能返回0
if (left >= right - 1) return 0;
if (table[left][right] != -1) return table[left][right];
// 枚举可能的添加气球的位置,左右端点不能添加
for (int i = left + 1; i < right; i++) {
int sum = score[left] * score[i] * score[right];
sum += dfs(left, i) + dfs(i, right);
table[left][right] = Math.max(table[left][right], sum);
}
return table[left][right];
}
}
能用DFS记忆化搜索,肯定可以转化为DP问题:
class Solution {
public int maxCoins(int[] nums) {
int n = nums.length;
int[][] dp = new int[n + 2][n + 2];
// dp[0][n + 1]
int[] score = new int[n + 2];
for (int i = 0; i < n; i++) {
score[i + 1] = nums[i];
}
score[0] = 1;
score[n + 1] = 1;
for (int i = n - 1; i >= 0; i--) { // 枚举左端点
for (int j = i + 2; j <= n + 1; j++) { // 枚举右端点
for (int k = i + 1; k < j; k++) { // 枚举放气球的位置
int sum = score[i] * score[j] * score[k];
sum += dp[i][k] + dp[k][j];
dp[i][j] = Math.max(dp[i][j], sum);
}
}
}
return dp[0][n + 1];
}
}
摆动序列(中等)
dp[i][0/1],考虑前 i 个数,0代表当前差值为正数,1代表当前差值为负数,最终答案:max(dp[n][0], dp[n][1])
考虑差值的变换,如果当前差为正数,要求上一个差必须为负数;如果当前差为负数,要求上一个差必须为正数。同时,要注意只有一个数的情况,它们的长度都=1。
class Solution {
public int wiggleMaxLength(int[] nums) {
// 仅有一个元素或者含两个不等元素的序列也视作摆动序列
int n = nums.length;
int[][] dp = new int[n + 1][2];
for (int i = 0; i < n + 1; i++) {
// 单独一个元素就可以视作摆动序列
Arrays.fill(dp[i], 1);
}
// dp[i][0/1]:0,本次差为正数;1,本次差为负数
for (int i = 1; i <= n; i++) {
for (int j = 1; j < i; j++) {
if (nums[j - 1] > nums[i - 1]) {
// 本次的差为负数,那么上一次的差必须为正数
dp[i][1] = Math.max(dp[i][1], dp[j][0] + 1);
}
if (nums[j - 1] < nums[i - 1]) {
// 本次的差为正数,那么上一次的差必须为负数
dp[i][0] = Math.max(dp[i][0], dp[j][1] + 1);
}
}
}
// 看最后的差是正数,还是负数的长度最大
return Math.max(dp[n][0], dp[n][1]);
}
}
粉刷房子(中等)
考虑每个房间,有3种颜色选择,dp[i][0/1/2],第 i 号房间选择 0、1、2颜色,所需的最小花费,最后答案:min(dp[n][0], dp[n][1], dp[n][2])
考虑当前某个房间,如果选择颜色0,则前一个房间只能选择1、2颜色;如果选择颜色1,则前一个房间只能选择0、2颜色;如果选择颜色2,则前一个房间只能选择0、1颜色。
import java.util.*;
import java.io.*;
public class Main {
static BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(System.out));
static BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));
public static void main(String[] args) throws IOException {
int[][] cost = new int[][] {
{17,2,17},
{16,16,5},
{14,3,19}
};
// 房子个数
int n = cost.length;
int[][] dp = new int[n + 1][3];
for (int i = 0; i < n + 1; i++) Arrays.fill(dp[i], 0x3f3f3f3f);
// dp[i][j],第i号房子,粉刷颜色为colors[j]的最小花费
// 第一个房间的粉刷方案是确定的
dp[1][0] = cost[0][0];
dp[1][1] = cost[0][1];
dp[1][2] = cost[0][2];
// 第一个房间无需考虑
for (int i = 2; i <= n; i++) { // 枚举房间号
// 当前房间刷颜色0,则要求前面的房间不能刷颜色0
dp[i][0] = Math.min(dp[i][0], dp[i - 1][1] + cost[i - 1][0]);
dp[i][0] = Math.min(dp[i][0], dp[i - 1][2] + cost[i - 1][0]);
dp[i][1] = Math.min(dp[i][1], dp[i - 1][0] + cost[i - 1][1]);
dp[i][1] = Math.min(dp[i][1], dp[i - 1][2] + cost[i - 1][1]);
dp[i][2] = Math.min(dp[i][2], dp[i - 1][0] + cost[i - 1][2]);
dp[i][2] = Math.min(dp[i][2], dp[i - 1][1] + cost[i - 1][2]);
}
System.out.println(Math.min(Math.min(dp[n][0], dp[n][1]), dp[n][2]));
}
}
粉刷房子Ⅱ
差别在于:颜色种类不固定,是 k 种颜色,那其实和上一题没区别,还是需要枚举 k 种颜色。
import java.util.*;
import java.io.*;
public class Main {
static BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(System.out));
static BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));
public static void main(String[] args) throws IOException {
int[][] cost = new int[][] {
{1,5,3},
{2,9,4}
};
// 房子个数
int n = cost.length;
// 颜色种类
int k = cost[0].length;
int[][] dp = new int[n + 1][k];
for (int i = 0; i < n + 1; i++) Arrays.fill(dp[i], 0x3f3f3f3f);
// dp[i][j],第i号房子,粉刷颜色为colors[j]的最小花费
// 第一个房间可以粉刷任意颜色
for (int i = 0; i < k; i++) {
dp[1][i] = cost[0][i];
}
for (int i = 2; i <= n; i++) { // 枚举房间号
for (int j = 0; j < k; j++) { // 枚举当前房间的颜色
for (int l = 0; l < k; l++) { // 枚举上一间房间的颜色
if (l == j) continue; // 不能和上一个房子选择的颜色一致
dp[i][j] = Math.min(dp[i][j], dp[i - 1][l] + cost[i - 1][j]);
}
}
}
int ans = Integer.MAX_VALUE;
for (int i = 0; i < k; i++) {
ans = Math.min(ans, dp[n][i]);
}
System.out.println(ans);
}
}
粉刷栅栏
给出一种,跟粉刷房子类似的解决思路,但不是本题的最优解。
import java.util.*;
import java.io.*;
public class Main {
static BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(System.out));
static BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));
public static void main(String[] args) throws IOException {
// n个栅栏
int n = 3;
// k种颜色
int k = 2;
int[][][] dp = new int[n + 1][k + 1][3];
// dp[i][j][k],k代表当前有几个相同的颜色,k=0没有相同/1和前面一个相同
// 对于第一个栅栏,不同颜色的涂法都只有1种,累加的种类数=k种
for (int i = 1; i <= k; i++) dp[1][i][0] = 1;
for (int i = 2; i <= n; i++) { // 枚举当前栅栏
for (int j = 1; j <= k; j++) { // 枚举当前栅栏所选颜色
for (int l = 1; l <= k; l++) { // 枚举前一个栅栏所选颜色
if (j == l) {
// 如果当前颜色和上一个颜色相同
dp[i][j][1] = dp[i - 1][l][0];
} else {
// 当前颜色和上一个颜色不同
dp[i][j][0] += dp[i - 1][l][0] + dp[i - 1][l][1];
}
}
}
}
int sum = 0;
for (int i = 1; i <= k; i++) {
sum += dp[n][i][0] + dp[n][i][1];
}
System.out.println(sum);
}
}
最优解
dp[i],表示粉刷前 i 个栅栏的方案数,有 k 个颜色,那么dp[1] = k,dp[2] = k * k,因为第二个栅栏可以选择与第一个栅栏相同的颜色,也可以不同,k个颜色每个颜色又有k个选择,所以为k * k。关键是dp[3]之后的怎么推?
考虑:当前栅栏颜色 与 上一个栅栏颜色相同,那么就需要考虑上上个栅栏的颜色,必须要与后面栅栏的颜色不同,不同的话也就有 k - 1种选择,即:dp[i - 2] * (k - 1)
当前栅栏颜色 与 上一个栅栏颜色不同,那么只用考虑上一个栅栏的颜色即可,只需要与上一个栅栏的颜色不同,那么就有k - 1种选择,即:dp[i - 1] * (k - 1)
综合上述两种情况:dp[i] = dp[i - 2] * (k - 1) + dp[i - 1] * (k - 1)
import java.util.*;
import java.io.*;
public class Main {
static BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(System.out));
static BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));
public static void main(String[] args) throws IOException {
// n个栅栏
int n = 3;
// k种颜色
int k = 2;
int[] dp = new int[n + 1];
// dp[i]前i个栅栏的方案数
dp[1] = k;
dp[2] = k * k;
for (int i = 3; i <= n; i++) {
dp[i] = dp[i - 2] * (k - 1) + dp[i -1] * (k - 1);
}
System.out.println(dp[n]);
}
}