记录 LeetCode 刷题时遇到的动态规划相关题目,第三篇
dp[i] 表示凑成 i 块钱时所需的最少的硬币个数。每个硬币的面额为 coins[j],那么 dp[i] 就是凑成 i - coins[j] 块钱时所需要的最少的硬币个数加 1,加的这个 “1” 就是面额为 coins[j] 的那枚硬币
public int coinChange(int[] coins, int amount) {
int[] dp = new int[amount + 1];
//后续需要Math.min来选较小数,所以dp数组每个元素先初始化为一个较大值。
//注意到下面有“dp[i - coins[j]] + 1”,
//所以为了防止溢出,不能直接使用Integer.MAX_VALUE,需要小一点,这里只减一即可
Arrays.fill(dp, Integer.MAX_VALUE - 1);
dp[0] = 0; //边界,凑0块钱需要0个硬币
for (int i = 1; i <= amount; i++) {
for (int j = 0; j < coins.length; j++) {
if (coins[j] <= i) { //coins[j]小于等于i才能尝试用coins[j]来凑出i块钱
dp[i] = Math.min(dp[i], dp[i - coins[j]] + 1);
}
}
}
//如果dp[amount]没有改变值说明无法凑出amount,返回-1
return dp[amount] == Integer.MAX_VALUE - 1 ? -1 : dp[amount];
}
dp[i] 表示金额 i 的兑换方式数。对一个金额 i,枚举每一个面值比它小或相等的硬币 coin,那么 i 就可以用这个硬币跟 i 减去这个硬币剩下的金额来兑换,所以 dp[i] += dp[i - coin]。基于这个思路可以写出下面的代码
public int change(int amount, int[] coins) {
int[] dp = new int[amount + 1];
dp[0] = 1;
for(int i = 1;i <= amount;i++){
for(int j = 0;j < coins.length;j++){
if(i - coins[j] >= 0) dp[i] += dp[i - coins[j]];
}
}
return dp[amount];
}
这个代码是错的。举个例子:amount = 5, coins = [1, 2, 5],金额 1 有一种兑换方式 1;金额 2 有两种兑换方式 1 + 1,2;那么计算到金额 3 时,按照上面的算法会得到 3 种兑换方式,1 + 1 + 1,1 + 2,2 + 1。可以发现有重复的兑换方式,即有的硬币被使用了不同的兑换方式中使用了多次。
那么怎么让一个硬币只会被使用一次?只需交换一下两层 for 循环的顺序,先遍历硬币,再遍历金额即可:
public int change(int amount, int[] coins) {
int[] dp = new int[amount + 1];
dp[0] = 1;
for(int coin : coins){
for(int i = coin;i <= amount;i++){
dp[i] += dp[i - coin];
}
}
return dp[amount];
}
参考官方题解
状态定义:dp[i][j] 表示 s1 中前 i 个字符跟 s2 中前 j 个字符 (注意下标表示的是字符个数而不是字符在原字符串中的索引) 能否组成 s3 中前 i + j 个字符
状态转移:如果 s1 中前 i - 1 个字符跟 s2 中前 j 个字符能组成 s3 中前 i + j - 1 个字符,那么如果 s1 中第 i - 1 (按照下标从 0 开始) 个字符跟 s3 中第 i + j - 1 个字符相同,就能说明 s1 中前 i 个字符跟 s2 中前 j 个字符能组成 s3 中前 i + j 个字符,也即 dp[i][j] |= dp[i - 1][j] && c1[i - 1] == c3[i + j - 1]
同理可得 dp[i][j] |= dp[i][j - 1] && c2[j - 1] == c3[i + j - 1]
边界:dp[0][0] = true,表示两个空串能交错形成一个空串
public boolean isInterleave(String s1, String s2, String s3) {
char[] c1 = s1.toCharArray();
char[] c2 = s2.toCharArray();
char[] c3 = s3.toCharArray();
int l1 = c1.length;
int l2 = c2.length;
//s1跟s2的长度和不等于s3的长度的话肯定就不能交错形成s3
if(l1 + l2 != c3.length) return false;
/*
boolean[][] dp = new boolean[l1 + 1][l2 + 1];
dp[0][0] = true;
for(int i = 0;i <= l1;i++){
for(int j = 0;j <= l2;j++){
//根据状态转移方程,可以使用状态压缩
if(i > 0){
//这里的dp[i][j]一定是false,所以没必要使用|=
dp[i][j] = dp[i - 1][j] && c1[i - 1] == c3[i + j - 1];
}
//原本dp[i][j]都是false(除了(0,0)),在判断判断j>0之前先判断了i>0,所以在
//此处dp[i][j]可能已经被修改了,所以要使用|=
if(j > 0){
dp[i][j] |= dp[i][j - 1] && c2[j - 1] == c3[i + j - 1];
}
}
}
return dp[l1][l2];
*/
boolean[] dp = new boolean[l2 + 1];
dp[0] = true;
for(int i = 0;i <= l1;i++){
for(int j = 0;j <= l2;j++){
if(i > 0){
dp[j] = dp[j] && c1[i - 1] == c3[i + j - 1];
}
if(j > 0){
dp[j] |= dp[j - 1] && c2[j - 1] == c3[i + j - 1];
}
}
}
return dp[l2];
}
状态:dp[i][j] 表示以 matrix[i][j] 为右下角的只包含 1 的正方形的边长的最大值
状态转移:如果 matrix[i][j] 为 0,那么不可能存在以 matrix[i][j] 为右下角的只包含 1 的正方形,dp[i][j] 为 0;如果 matrix[i][j] 为 1,状态转移方程应为 dp[i][j] = Math.min(Math.min(dp[i - 1][j], dp[i][j - 1]), dp[i - 1][j - 1]) + 1,即以左边的格子为右下角,以上边的格子为右下角,以左上边的格子为右下角的只包含 1 的正方形的边长的最小值中的最小值。为何要这么选?
假设当前要推导 dp[3][3],如果以左边的格子 matrix[3][2] 为右下角的只包含 1 的正方形的边长的最大值为 2,如果要能得到 dp[i][j] 为 3,就要求 matrix[1][1],matrix[1][2],matrix[1][3],matrix[2][3] 的值都为 1,也即,dp[2][2],dp[2][3] 必须大于等于 2。同理可以分析以上边的格子和以左上边的格子为右下角的情况。那么最终可以发现, 以 matrix[i][j] 为右下角的只包含 1 的正方形的边长的最大值必须取 dp[i - 1][j], dp[i][j - 1],dp[i - 1][j - 1] 三者中的最小值再加一
边界:以第一行跟第一列的格子作为右下角的正方形边长最大只能为 1,所以 dp[i][j] = 1 (i == 0 || j == 0)
public int maximalSquare(char[][] matrix) {
int maxSide = 0;
if (matrix == null || matrix.length == 0 || matrix[0].length == 0) {
return maxSide;
}
int rows = matrix.length, columns = matrix[0].length;
int[][] dp = new int[rows][columns];
for (int i = 0; i < rows; i++) {
for (int j = 0; j < columns; j++) {
if (matrix[i][j] == '1') {
if (i == 0 || j == 0) {
//边界上的点的最大边长只能为1
dp[i][j] = 1;
} else {
dp[i][j] = Math.min(Math.min(dp[i - 1][j], dp[i][j - 1]), dp[i - 1][j - 1]) + 1;
}
maxSide = Math.max(maxSide, dp[i][j]);
}
}
}
return maxSide * maxSide;
}
根据题解简单总结:
首先,当天如果买了 n 天的票,那肯定是 n 天后买下一张票才不会浪费钱。
所以如果用 状态 dp[i] 表示推导到第 i 天时需要的最低花费,那么对于第 i 天,有三种决策:买为期一天的通行证 / 买为期七天的通行证 / 买为期三十天的通行证,dp[i] 应该等于这三种决策得到的票价的最小值
如果买为期一天的通行证,dp[i] 就等于 cost[0] 加上 1 天后的最低花费 dp[i + 1],即 d p [ i + 1 ] + c o s t s [ 0 ] dp[i + 1] + costs[0] dp[i+1]+costs[0]
同理,如果使用决策 2, d p [ i ] = d p [ i + 7 ] + c o s t [ 1 ] dp[i] = dp[i + 7] + cost[1] dp[i]=dp[i+7]+cost[1]
如果使用决策 3, d p [ i ] = d p [ i + 30 ] + c o s t [ 2 ] dp[i] = dp[i + 30] + cost[2] dp[i]=dp[i+30]+cost[2]
所以需要从后往前推导
在实际推导过程中,i 从 days 中的最大天数开始递减到最小天数,当遇到不是 days 中的天数时,dp[i] 就直接等于后一天的花费 dp[i + 1]
public int mincostTickets(int[] days, int[] costs) {
int len = days.length, maxDay = days[len - 1], minDay = days[0];
//在后续推导时需要对 cur+30,可以先判断cur的范围,也可以像下面这样直接把数组调大
int[] dp = new int[maxDay + 31];
int index = len - 1;
for (int cur = maxDay;cur >= minDay;cur--) {
if (cur == days[index]) {
dp[cur] = Math.min(dp[cur + 1] + costs[0], dp[cur + 7] + costs[1]);
dp[cur] = Math.min(dp[cur], dp[cur + 30] + costs[2]);
index--;
} else {
dp[cur] = dp[cur + 1];
}
}
return dp[minDay];
}
像这种数组,字符串类型的动态规划题,如果只有一个数组或字符串,那么可以使用一维数组 dp,其中 dp[i] 表示区间 [0,i];如果有两个数组或字符串,那么使用二维数组,dp[i][j] 表示 数组1/字符串1 中的区间 [0,i] 以及 数组2/字符串2 中的区间 [0,j],然后再考虑能否降维
本道题中,状态 dp[i][j] 表示 text1 中前 i 个字母跟 text2 中前 j 个字母中最长公共子序列的长度,那么状态转移方程就为
dp[i][j] = dp[i - 1][j - 1] + 1,text1[i - 1] == text2[j - 1]
dp[i][j] = max(dp[i][j - 1],dp[i - 1][j]),其他情况
最终答案就是 dp[len1][len2]
public int longestCommonSubsequence(String text1, String text2) {
char[] cs1 = text1.toCharArray();
char[] cs2 = text2.toCharArray();
int len1 = cs1.length;
int len2 = cs2.length;
int[][] dp = new int[len1 + 1][len2 + 1];
for(int i = 1;i <= len1;i++){
for(int j = 1;j <= len2;j++){
if(cs1[i - 1] == cs2[j - 1]) dp[i][j] = dp[i - 1][j - 1] + 1;
else{
dp[i][j] = Math.max(dp[i - 1][j],dp[i][j - 1]);
}
}
}
return dp[len1][len2];
}
使用动态规划,二维 dp 数组中 dp[i][j] 表示走到 grid[i][j] 的最小路径和
想走到一个点 grid[i][j] 只能从 grid[i - 1][j] 向下走一步或者从 grid[i][j - 1] 向右走一步到达。所以走到 grid[i][j] 的最小路径和,自然就是 min(dp[i - 1][j],dp[i][j - 1]) + grid[i][j],至于之前的路线是怎么样,我们并不关心,这就是动态规划的强大之处
public int minPathSum(int[][] grid) {
int m = grid.length,n = grid[0].length;
int[][] dp = new int[m][n];
dp[0][0] = grid[0][0];
//对于第一列的点只能从上一个点向下走一步到达
for(int i = 1;i < m;i++){
dp[i][0] = dp[i - 1][0] + grid[i][0];
}
//对于第一行的点只能从左边的点向右走一步到达
for(int i = 1;i < n;i++){
dp[0][i] = dp[0][i - 1] + grid[0][i];
}
//开始状态转移
for(int i = 1;i < m;i++){
for(int j = 1;j < n;j++){
dp[i][j] = Math.min(dp[i - 1][j],dp[i][j - 1]) + grid[i][j];
}
}
//最后返回走到右下角即grid[m - 1][n - 1]的最小路径和
return dp[m - 1][n - 1];
}
老规矩,二维 dp,根据状态转移方程发现可以降为一维 dp
public int minPathSum(int[][] grid) {
int m = grid.length,n = grid[0].length;
int[] dp = new int[n];
dp[0] = grid[0][0];
for(int i = 1;i < n;i++){
dp[i] = dp[i - 1] + grid[0][i];
}
for(int i = 1;i < m;i++){
for(int j = 0;j < n;j++){
if(j == 0) dp[j] = dp[j] + grid[i][j];
else dp[j] = Math.min(dp[j],dp[j - 1]) + grid[i][j];
}
}
return dp[n - 1];
}
状态 dp[i][0] 表示以 nums[i] 作为最后一个元素的子数组所能得到的最大乘积,dp[i][1] 则表示能得到的最小乘积。恒有 dp[i][0] >= dp[i][1]
边界即为第一个元素,dp[0][0] = dp[0][1] = nums[0]
对于 nums[i]:
public int maxProduct(int[] nums) {
int len = nums.length;
int max = nums[0];
int[][] dp = new int[len][2];
dp[0][0] = nums[0];
dp[0][1] = nums[0];
for(int i = 1;i < len;i++){
if(nums[i] > 0){
dp[i][0] = dp[i - 1][0] > 0 ? dp[i - 1][0] * nums[i] : nums[i];
max = Math.max(max,dp[i][0]);
dp[i][1] = dp[i - 1][1] < 0 ? dp[i - 1][1] * nums[i] : nums[i];
}else if(nums[i] == 0){
max = Math.max(max,0);
dp[i][0] = 0;
dp[i][1] = 0;
}else{
dp[i][0] = dp[i - 1][1] < 0 ? dp[i - 1][1] * nums[i] : nums[i];
max = Math.max(max,dp[i][0]);
dp[i][1] = dp[i - 1][0] > 0 ? dp[i - 1][0] * nums[i] : nums[i];
}
}
return max;
}