Leetcode刷题之动态规划(Java)

算法解释

可以用局部最优解来推到全局最优解,即动态规划。动态规划在查找有很多重叠子区间问题的最优解时最有效。它将问题重新组合成子问题,为避免多次解决这些子问题,结果都逐渐被计算并保存,从简单的问题直到整个问题都被解决。因此,动态规划保存递归时的结果,因而不会在解决同样的问题时花费时间。

要使用动态规划需要满足两个条件:1. 最优子结构,即局部最优解能决定全局最优解;2. 当前状态是前面状态的完美总结,即现状态不会影响前面的状态。

解决动态规划问题的关键是找到状态转移方程,这样我们可以通过计算和存储子问题的解来求解最终问题。在一些情况下动态规划可以看作带有状态记录的优先搜索。状态记录的意思是如果一个子问题在优先搜索前被计算过一次,我们可以把它的结果存储下来,之后遍历到该子问题的时候可以直接返回存储的结果。动态规划是下而上,搜索是自上而下的。

与贪心算法的差异

贪心算法当前阶段的解用上一阶段直接推导出来。动态规划当前问题的最优解,不能从上一阶段子问题简单得出,需要前面多阶段多层子问题共同计算出,因此需要保留历史上求过的子问题及其最优解。

动态规划的一般过程

  1. 找到过程中变化的量(状态),以及变化的规律(状态转移方程)
  2. 确定一些初始状态,通常需要dp数组来保存
  3. 利用状态转移方程,推出最终答案

动态规划的解法

  1. 自顶向下,即从父问题到子问题,使用递归;如果有重叠子区间,采用记忆性递归进行性能优化。
  2. 自底向上,即先解决子问题,再解决父问题,使用递推。

力扣例题

一维

70. 爬楼梯

分析:

典型的斐波那契数列,DP方程为f(n)=f(n-1)+f(n-2)。由于不需要记录中间量,设置两个提前变量和一个当前变量即可达到求解目的,节约空间。

题解:

class Solution {
    public int climbStairs(int n) {
    	if (n<=2) {
			return n;
		}
    	int pre1 = 1;
    	int pre2 = 2;
    	int cur = 0;
    	//循环求解第n个台阶的登顶方法,并更新提前变量
    	for(int i=2;i<n;++i) {
    		cur = pre1+pre2;
    		pre1 = pre2;
    		pre2 = cur;
    	}
    	
    	return cur;
    }
}

198. 打家劫舍

分析:

假设dp[i]表示抢到第i个房子时获取的最大金额。在碰到第i个房子时我们可以考虑两种情况:一种是抢,那么当前总金额就是nums[i-1]+dp[i-2];如果不抢这个房子,当前金额就是前面i-1个房子的最大金额dp[i-1]。同样不需要保存每一步的结果,因此设置两个提前变量即可。

题解:

class Solution {
    public int rob(int[] nums) {
    	if (nums.length==0) {
			return 0;
		}
    	else if(nums.length==1) {
    		return nums[0];
    	}
    	
    	int pre1 = 0;
    	int pre2 = nums[0];
    	int cur = 0;
    	
    	for(int i=2;i<=nums.length;++i) {
    		cur = Math.max(pre1+nums[i-1], pre2);
    		pre1 = pre2;
    		pre2 = cur;
    	}
    	return cur;
    }
}

413. 等差数列划分

分析:

设dp[i]为以第i个数结尾的等差数列的总数, 等差数列的条件为nums[i]-nums[i-1]==nums[i-1]-nums[i-2]。这里特别需要注意的是,dp[i] = dp[i-1]+1是需要观察或列举演算得到的规律,即等差数列每增加一个元素,新的等差数列以最后一个元素(新增元素)结尾的子数列个数为原来的+1。

题解:

class Solution {
    public int numberOfArithmeticSlices(int[] nums) {
    	int n = nums.length;
    	if (n<3) {
			return 0;
		}
    	
    	int[] dp = new int[n];
    	for(int i=2;i<n;++i) {
    		if (nums[i]-nums[i-1]==nums[i-1]-nums[i-2]) {
				dp[i] = dp[i-1]+1;
			}
    	}

        int sum = 0;
        for(int i=0;i<n;++i){
            sum+=dp[i];
        }
    	
    	return sum;
    }
}

二维

64. 最小路径和

分析:

对于二维的题目,我们同样可以定义一个二维的dp数组,dp[i][j]表示走到当前点的最短路径长度,由于之内向下或向右走,因此dp[i][j] = min(dp[i-1][j],dp[i][j-1])+grid[i][j],这里需要注意边界的dp方程有所调整。

题解:

class Solution {
    public int minPathSum(int[][] grid) {
    	int m = grid.length;
    	int n = grid[0].length;
    	int[][] dp = new int[m][n];
    	
    	for(int i=0;i<m;++i) {
    		for(int j=0;j<n;++j) {
    			if(i==0 && j==0) {
    				dp[0][0] = grid[0][0];
    			}
    			else if(i==0) {
    				//该位置的上一步只能是从右边过来的
    				dp[i][j] = dp[i][j-1]+grid[i][j];
    			}
    			else if(j==0) {
    				//该位置的上一步只能是上边过来的
    				dp[i][j] = dp[i-1][j]+grid[i][j];
    			}
    			else {
    				dp[i][j] = Math.min(dp[i-1][j], dp[i][j-1])+grid[i][j];
    			}
    		}
    	}
    	
    	return dp[m-1][n-1];
    }
}

221. 最大正方形

分析:

定义一个二维dp数组,其中dp[i][j]表示面积最大的正方形右下角元素。如果当前位置是0,dp[i][j]=0;如果当前位置是1,dp[i][j]=k2,其充分条件为dp[i-1][j-1]、dp[i][j-1]、dp[i-1][j]必须都不小于(k-1)2,否则(i,j)位置不可以构成边长为k的正方形。同理,如果这三个值中的最小值为k-1,则(i,j)位置一定且最大可以构成一个边长为k的正方形。(画图即可理解)

dp方程即为:dp[i][j] = min(dp[i-1][j-1], dp[i][j-1], dp[i-1][j] )+1

题解:

class Solution {
    public int maximalSquare(char[][] matrix) {
    	int m = matrix.length;
    	int n = matrix[0].length;
    	int[][] dp = new int[m][n];
    	int maxSide = 0;
    	
    	for(int i=0;i<m;++i) {
    		for(int j=0;j<n;++j) {
    			//遇到1的时候开始构建dp方程
    			if (matrix[i][j]=='1') {
	          if(i==0 || j==0){
	              dp[i][j] = 1;
	          }
	          else{
	              dp[i][j] = Math.min(dp[i-1][j-1], Math.min(dp[i][j-1], dp[i-1][j]))+1;
	          }
	          //保存最大的面积
						maxSide = Math.max(maxSide, dp[i][j]);
					}
    		}
    	}
    	return maxSide*maxSide;
    }
}

分割类型

279. 完全平方数

分析:

对于分割型题目,动态规划的状态转移方程通常不依赖相邻的位置,而是以来满足分割条件的位置。定义一个一维数组dp,其中dp[i]表示i最少可以由几个完全平方数相加构成。本题中i只依赖i-k^2的位置。因此dp方程为:dp[i] = min(dp[i-1], dp[i-4], dp[i-9], …)

题解:

class Solution {
    public int numSquares(int n) {
    	//dp[i]表示组成i的最小平方数个数
    	int[] dp = new int[n];
    	//初始除0位置以外赋给最大值
    	Arrays.fill(dp, Integer.MAX_VALUE);
    	dp[0] = 0;
    	for(int i=0;i<=n;++i) {
    		for(int j=1;j*j<=i;++j) {
    			//加上一个平方数或自己本身
    			dp[i] = Math.min(dp[i-j*j]+1, dp[i]);
    		}
    	}
    	return dp[n];
    }
}

91. 解码方法

分析:

设dp[i]表示前i个字母的解码方法数,则dp[i]有两种情况:在位置i使用一个字符,dp[i] = dp[i-1];如果使用两个字符即i和i-1,dp[i] = dp[i-2]。字符串为空的时候只有一种空编码的情况。

题解:

class Solution {
    public int numDecodings(String s) {
    	char[] chs = s.toCharArray();
    	int[] dp = new int[s.length()+1];
    	dp[0] = 1;
    	
    	for(int i=1;i<=s.length();++i) {
    		if(chs[i-1]!='0') {
    			dp[i] += dp[i-1];
    		}
    		
    		if(i>=2 && (chs[i-2]-'0')*10+(chs[i-1]-'0')<=26 && chs[i-2]!='0') {
    			dp[i] += dp[i-2];
    		}
    	}
    	
    	return dp[s.length()];
    }
}

139. 单词拆分

分析:

dp[i]表示字符串s前i个字符组成的字符串s’是否能被拆分为字典中的单词。如果设j为中间的一个分割点,dp[j]=true表示前j个字母已经能被字典表示,则[j+1,i]是一个单词的话,那么dp[i]也为true。

题解:

class Solution {
    public boolean wordBreak(String s, List<String> wordDict) {
    	boolean[] dp = new boolean[s.length()+1];
    	dp[0] = true;
    	
    	for(int i=0;i<=s.length();++i) {
    		//这里特别要注意i是字符在串中的位置
    		//j是字符的下标,因此substring方法包含的位置是下标[j,i-1]
    		//实际表示串中的[j+1,i]位置
    		for(int j=0;j<i;++j) {
    			if(dp[j] && wordDict.contains(s.substring(j, i))) {
    				dp[i] = true;
    				break;
    			}
    		}
    	}
    	
    	return dp[s.length()];
    }
}

子序列问题

子序列问题通常有两种动态规划的方法:

  1. 定义一个dp数组,dp[i]表示以i结尾的子序列的性质,在处理好每个位置后,遍历一遍dp数组可以得到结果。
  2. 定义一个dp数组,dp[i]表示到位置i为止的子序列的性质,这样dp数组的最后一位即为题目所求。

300. 最长递增子序列

分析:

设dp[i]为以i结尾的最长严格递增子序列的长度。如果已有dp[j]成立,则nums[i]>dp[j]代表dp[i]=dp[j]+1。

题解:

class Solution {
    public int lengthOfLIS(int[] nums) {
    	int n = nums.length;
    	int max = 0;
    	int[] dp = new int[n];
    	Arrays.fill(dp, 1);
    	
    	for(int i=0;i<n;++i) {
    		for(int j=0;j<i;++j) {
    			if(nums[j]<nums[i]) {
    				//这里本质是找出最大的dp[j]
    				//然后再+1赋值为dp[i]
    				dp[i] = Math.max(dp[i], dp[j]+1);
    			}
    		}
    		max = Math.max(max, dp[i]);
    	}
    	
    	return max;
    }
}

1143. 最长公共子序列

分析:

设置一个二维dp数组,dp[i][j]表示text1中前 i 个元素和text2中前 j 个的最长公共子序列的长度。设置两个指针分别指向两串的字母,如果相等:dp[i][j] = dp[i-1][j-1]+1;如果不相等,那么两个指针中的一个需要回退,留下那个更长的公共子串。

题解:

class Solution {
    public int longestCommonSubsequence(String text1, String text2) {
    	int m = text1.length();
    	int n = text2.length();
    	int[][] dp = new int[m+1][n+1];
    	
    	for(int i=1;i<=m;++i) {
    		for(int j=1;j<=n;++j) {
    			if(text1.charAt(i-1)==text2.charAt(j-1)) {
    				dp[i][j] = dp[i-1][j-1]+1;
    			}
    			else {
    				dp[i][j] = Math.max(dp[i][j-1], dp[i-1][j]);
    			}
    		}
    	}
    	
    	return dp[m][n];
    }
}

背包问题

通常题目形式如:有N个物品和容量为W的背包,每个物品都有自己的体积w和价值v,求拿哪些物品可以使得背包装下的物品的总价值最大。如果限定每种物品只能选择0个或1个,则称01背包问题;如果不限制每种物品个数,则为完全背包问题。

可以使用动态规划来解决背包问题,以01背包为例:可以定义一个二维数组dp存储最大价值,dp[i][j]表示前i件物品体积不超过j的情况下的最大价值。我们在遍历到第i件物品时,在当前背包总容量为j的情况下,如果不将i物品放入背包,那么dp[i][j] = dp[i-1][j];如果物品i放入背包,那么dp[i][j] = dp[i-1][j-w]+v。我们只需要在遍历过程中对这两种情况取最大值即可。

在完全背包问题中,一个物品可以拿多次。状态转移方程为:dp[i][j] = max(dp[i-1][j], dp[i][j-w]+v)。

416. 分割等和子集

分析:

该问题等价于01背包问题,只需要选取一部分物品使得他们的值的总和为sum/2,因此只需要通过一个布尔值矩阵来表示状态转移方程。dp[i][j]表示在下标[0,i]区间内的所有整数,当前能否选出一些数使得他们之和恰好为j。

题解:

class Solution {
    public boolean canPartition(int[] nums) {
    	int sum = 0;
    	for(int i:nums) {
    		sum+=i;
    	}
    	//如果和不为偶数,直接返回false
    	if(sum % 2!=0) {
    		return false;
    	}
    	
    	int n = nums.length;
    	int target = sum/2;
    	boolean[][] dp = new boolean[n][target+1];
    	
    	//第一行只能填满容量为nums[0]的背包
    	if(nums[0] <= target) {
    		dp[0][nums[0]] = true;
    	}
    	
    	//从第二行开始填表
    	for(int i=1;i<n;++i) {
    		for(int j=0;j<=target;++j) {
    			//不考虑将当前位置加入背包
    			dp[i][j] = dp[i-1][j];
    			
    			//当前位置刚好独自可以填满背包
    			if (nums[i]==j) {
    				dp[i][j] = true;
					continue;
				}
    			
    			//如果当前物品小于j, 可以不加入也可以加入背包
    			if (nums[i]<j) {
    				dp[i][j] = dp[i-1][j] || dp[i-1][j-nums[i]];
				}
    		}
    	}
    	return dp[n-1][target];
    }
}

474. 一和零

分析:

该题中的物品容量有两种,一种是1的容量,一种是0的容量。因此定义的dp数组为三位数组。dp[i][j][k]表示前i个子串集合中有j个1,k个0的最大子串长度。当i为0,即没有字串可用是,dp[i][j][k]=0。

对于strs[i],首先要统计其中0和1的个数。如果0或者1的个数大于j和k,则不能选择第i个字符串,dp[i][j][k]=dp[i-1][j][k];如果0和1的个数都小于等于j和k,dp[i][j][k]=dp[i-1][j-one][k-zero]+1。

题解:

class Solution {
    public int findMaxForm(String[] strs, int m, int n) {
    	int len = strs.length;
    	int[][][] dp = new int[len+1][m+1][n+1];
    	
    	//三层循环遍历
    	for(int i=1;i<=len;++i) {
    		for(int j=0;j<=m;++j) {
    			for(int k=0;k<=n;++k) {
    				int[] zeroOne = getZeroOne(strs[i-1]);
    				//背包有容量装下该串
    				//选择子串长度更大的情况
    				if(zeroOne[0]<=j && zeroOne[1]<=k) {
    					dp[i][j][k] = Math.max(dp[i-1][j][k], dp[i-1][j-zeroOne[0]][k-zeroOne[1]]+1);
    				}
    				else {
    					//装不下跟上一层情况相同
    					dp[i][j][k] = dp[i-1][j][k];
    				}
    			}
    		}
    	}
    	
    
    	return dp[len][m][n];
    }
    
    //获取子串中0和1的个数
    private int[] getZeroOne(String str) {
    	int zero = 0;
    	int one = 0;
    	for(char c:str.toCharArray()) {
    		if(c=='0') {
    			zero++;
    		}
    		else if(c=='1') {
    			one++;
    		}
    	}
    	
    	return new int[] {zero,one};
    }
}

322. 零钱兑换

分析:

dp[i]表示组成金额i所需最少的硬币数量,则状态转移方程为:dp[i] = dp[i-c]+1。

题解:

class Solution {
    public int coinChange(int[] coins, int amount) {
    	int[] dp = new int[amount+1];
			//首先要将dp数组填充为比amount大的数
			//因为就算全由1元硬币组成的amount需要的数量也不会超过amount
			//采用小于amount的数可能被当作中间结果返回
    	Arrays.fill(dp, Integer.MAX_VALUE-1);
    	dp[0] = 0;
    	
    	for(int i=1;i<=amount;++i) {
    		for(int j=0;j<coins.length;++j) {
    			if(coins[j] <= i) {
    				dp[i] = Math.min(dp[i], dp[i-coins[j]]+1);
    			}
    		}
    	}
    	
    	return dp[amount] > amount ? -1 :dp[amount];
    }
}
  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值