什么是动态规划
动态规划就是将原问题拆解成若干个子问题,同时保存子问题的答案,使得每个子问题只求解一次,最终获得原问题的答案。
1. 斐波那契数列
写一个函数,输入 n ,求斐波那契(Fibonacci)数列的第 n 项。斐波那契数列的定义如下:
F(0) = 0, F(1) = 1 F(N) = F(N - 1) + F(N - 2), 其中 N > 1. 斐波那契数列由 0 和 1 开始,之后的斐波那契数就是由之前的两数相加而得出。
答案需要取模 1e9+7(1000000007),如计算初始结果为:1000000008,请返回 1。
最原始的递归问题,在递归解法中,就是从求F(N)转为求F(N-1)和F(N-2)……是一个自上而下的过程,而动态规划就是一个自下而上的过程。不需要递归,只需要一次迭代即可。
class Solution {
public int fib(int n) {
if(n==0) return 0;
int[] dp = new int[n+1];
dp[0] = 0;
dp[1] = 1;
for(int i=2;i<n+1;i++){
dp[i] = dp[i-1] + dp[i-2];
if(dp[i]>=1000000007){
dp[i] = dp[i]%1000000007;
}
}
return dp[n];
}
}
题目要求取模,至于为什么是边算边取模,不清楚。
爬楼梯
1.爬楼梯
假设你正在爬楼梯。需要 n 阶你才能到达楼顶。
每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?
注意:给定 n 是一个正整数。
对于第n阶楼梯,要么是从n-1一步跨上来,要么是从n-2一步跨上来。所以F(n) = F(n-1)+F(n-2),其实就是斐波那契数列。
class Solution {
public int climbStairs(int n) {
int[] dp = new int[n + 1];
dp[0] = 1;
dp[1] = 1;
for (int i = 2; i < n + 1; i++) {
dp[i] = dp[i - 1] + dp[i - 2];
dp[i] = dp[i];
}
return dp[n];
}
}
2. 三角形最小路径和
思路:如果逆向思维,不是自顶向下,而是自下而上。那么就只需要求到达最顶端节点的最小路径和即可。
所以使用一个长度为triangle.size的数组,数组中的元素是当前这一层每个节点到达最底端的最小路径和。最后遍历到第一层后,dp[0]就是要求的最小路径和。
小细节:因为单独处理最后一层很麻烦,所以将数组长度设为triangle.size+1即可
状态转移方程:
当前节点最小路径和 = 下一层相邻两个节点最小路径和+当前节点值
class Solution {
public int minimumTotal(List<List<Integer>> triangle) {
int length = triangle.size();
int[] dp = new int[length+1];
for(int i=length-1;i>=0;i--){
for(int j=0;j<=i;j++){
dp[j] = Math.min(dp[j],dp[j+1]) + triangle.get(i).get(j);
}
}
return dp[0];
}
}
3.最小路径和
给定一个包含非负整数的 m x n 网格,请找出一条从左上角到右下角的路径,使得路径上的数字总和为最小。
说明:每次只能向下或者向右移动一步。
示例:
输入:
[
[1,3,1],
[1,5,1],
[4,2,1]
]
输出: 7
解释: 因为路径 1→3→1→1→1 的总和最小。
感觉和上个题差不多,只不过上题是找下一层相邻的两个元素,这个题是找左边和上边的元素。
从左上角开始找,状态定义为:从左上角到达该点的最小路径和。
状态转移方程为:
当前节点最小路径和 = 左边和上边相邻两个节点最小路径和+当前节点值
鉴于这个的情况比上个题目复杂点,所以小细节不太好用了。在内部多加几次判断即可。
class Solution {
public int minPathSum(int[][] grid) {
int nr = grid.length;
if(nr==0) return 0;
int nc = grid[0].length;
int[] dp = new int[nc+1];
for(int i=0;i<nr;i++){
for(int j=1;j<nc+1;j++){
if(i==0){
dp[j] = dp[j-1] + grid[i][j-1];
}else if(j==1){
dp[j] = dp[j] + grid[i][j-1];
}else{
dp[j] = Math.min(dp[j-1],dp[j]) + grid[i][j-1];
}
}
}
return dp[nc];
}
}
发现重叠子问题
1.整数拆分
给定一个正整数 n,将其拆分为至少两个正整数的和,并使这些整数的乘积最大化。 返回你可以获得的最大乘积。
以分割4为例,其递归树如下:
可以发现,结果可分为:
- 1×(分割3的最大乘积)
- 2×(分割2的最大乘积)
- 3×(分割1的最大乘积)
然后从其中选一个最大值作为结果。其中在分割3的时候又会涉及到分割2和1,这就出现了重叠子问题。对于普通的递归解法,这样的计算会被重复执行,非常耗时。此时如果使用记忆化搜索,存储分割n的最大乘积,即可减少时间。
那么用动态规划怎么做呢?既然递归是从4到3,2,1。那么自下而上就是先计算1,再计算2,3,4。
class Solution {
public int integerBreak(int n) {
//dp[n]表示拆分n的最大乘积
int[] dp = new int[n+1];
dp[1] = 1;
//自底向上的确定结果
for(int i=2;i<=n;i++){
//这一层循环是此次拆分数字的遍历
for(int j=1;j<i;j++){
//三者取最大:之前dp[i]的值,只拆分一次的乘积,继续拆分的乘积
dp[i] = Math.max(dp[i],Math.max(j*(i-j),j*dp[i-j]));
}
}
return dp[n];
}
}
2.完全平方数
给定正整数 n,找到若干个完全平方数(比如 1, 4, 9, 16, …)使得它们的和等于 n。你需要让组成和的完全平方数的个数最少。
以7为例,找组成7的完全平方数最少个数:
- 1 + 组成6的全平方数最少个数(7-1)
- 1 + 组成3的全平方数最少个数(7-4)
然后从中选取最小的作为最终结果。
如果采用动态规划的做法,应该是自底向上的。
class Solution {
public int numSquares(int n) {
//dp[n]表示组成n的完全平方数最少个数
int[] dp = new int[n+1];
for(int i=1;i<n+1;i++){
dp[i] = Integer.MAX_VALUE;
for(int j=1;i-j*j>=0;j++){
dp[i] = Math.min(dp[i],dp[i-j*j]+1);
}
}
return dp[n];
}
}
3.解码方法
一条包含字母 A-Z 的消息通过以下方式进行了编码:
‘A’ -> 1
‘B’ -> 2
…
‘Z’ -> 26
给定一个只包含数字的非空字符串,请计算解码方法的总数。
示例:
输入: "226"
输出: 3
解释: 它可以解码为 "BZ" (2 26), "VF" (22 6), 或者 "BBF" (2 2 6) 。
所有数字只有两种解码方式,一种就是自己就可以编码,一种是只能和前面的数字一起编码。所以:
class Solution {
public int numDecodings(String s) {
int length = s.length();
int[] dp = new int[length+1]; //dp[i]表示以i结尾的字符串解码方法
dp[0] = 1;
for (int i = 1; i < length+1; i++) {
//要么自己作为一个字符,要么和前一个数字组成一个字符
int g = s.charAt(i - 1) - '0';
if(g>0 && g<10){
dp[i] += dp[i-1];
}
if(i>1){
int shi = s.charAt(i-2)-'0';
int num = shi*10+g;
if(num>9 && num<27){
dp[i] += dp[i-2];
}
}
}
return dp[length];
}
}
4.不同路径
一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为“Start” )。
机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为“Finish”)。
问总共有多少条不同的路径?
想想当年,这个题死活也做不出来。结果现在,一眼就能看出来。
对于每一个点,到达这个点的方法无非就是两种:从上边or从左边。
假设dp[n]的含义是:到达最后一行的第n个点的路径总数,那么dp[n] = dp[n-1] + dp[n]
。至于二位是如何转化为一维的,那是因为每次其实只需要上面一行的数据就可以。
class Solution {
public int uniquePaths(int m, int n) {
int[] dp = new int[m+1];
dp[1] = 1;
for(int i=0;i<n;i++){
for(int j=2;j<m+1;j++){
dp[j] = dp[j] + dp[j-1];
}
}
return dp[m];
}
}
5.不同路径II
一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为“Start” )。
机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为“Finish”)。
现在考虑网格中有障碍物。那么从左上角到右下角将会有多少条不同的路径?
网格中的障碍物和空位置分别用 1 和 0 来表示。
说明:m 和 n 的值均不超过 100。
初步思路:只需要遍历到的点的值为1,那么到达这个点的路径数设置为0即可。
原本以为过于简单肯定有没考虑到的,不过竟然一遍过了。
class Solution {
public int uniquePathsWithObstacles(int[][] obstacleGrid) {
int nr = obstacleGrid.length;
if(nr==0) return 0;
int nc = obstacleGrid[0].length;
int[] dp = new int[nc+1];
dp[1] = 1;
for(int i=0;i<nr;i++){
for(int j=1;j<nc+1;j++){
if(obstacleGrid[i][j-1]==1){
dp[j] = 0;
}else {
dp[j] = dp[j-1] + dp[j];
}
}
}
return dp[nc];
}
}
状态的定义和状态转移
1.打家劫舍
你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。
给定一个代表每个房屋存放金额的非负整数数组,计算你在不触动警报装置的情况下,能够偷窃到的最高金额。
首先自顶向下思考,如果要偷n家房子,那么第n家能偷到的最大金额就是:
- 第n-1家不考虑偷,偷第n家
- 考虑偷第n-1家,不偷第n家
然后从其中选一个更大的。转为状态转移方程表示就是:
dp[n]表示“前n家房子偷到的最大金额”
则dp[n] = Math.max(dp[n-2]+num[n],dp[n-1]);
其实这里存在一个问题,如果不偷第n家,那么最大金额dp[n] = dp[n-1]好理解。但是如果偷第n家,但是在dp[n-1]的情况下,第n-1家并没有被偷,那岂不是也可以dp[n] = dp[n-1]+nums[n]了?
仔细想想,其实会发现如果n-1家没被偷,则dp[n-1] = dp[n-2],结果是一样的。
class Solution {
public int rob(int[] nums) {
int length = nums.length;
if(length==0) return 0;
int[] dp = new int[length+1];
dp[1] = nums[0];
for(int i=2;i<=length;i++){
dp[i] = Math.max(dp[i-2]+nums[i-1],dp[i-1]);
}
return dp[length];
}
}
2.打家劫舍II
你是一个专业的小偷,计划偷窃沿街的房屋,每间房内都藏有一定的现金。这个地方所有的房屋都围成一圈,这意味着第一个房屋和最后一个房屋是紧挨着的。同时,相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。
给定一个代表每个房屋存放金额的非负整数数组,计算你在不触动警报装置的情况下,能够偷窃到的最高金额。
和上一题的区别在于,这个题所有的房子是连起来的。
也就是说,第一家和最后一家不能同时被偷。那是不是可以将这个题转为两个第一题?
一个不包含最后一个房子,一个不包含第一个房子。
class Solution {
public int rob(int[] nums) {
if(nums.length==0) return 0;
if(nums.length==1) return nums[0];
int[] nums1 = Arrays.copyOfRange(nums,0,nums.length-1);
int[] nums2 = Arrays.copyOfRange(nums,1,nums.length);
return Math.max(rob1(nums1),rob1(nums2));
}
public int rob1(int[] nums) {
int length = nums.length;
if(length==0) return 0;
int[] dp = new int[length+1];
dp[1] = nums[0];
for(int i=2;i<=length;i++){
dp[i] = Math.max(dp[i-2]+nums[i-1],dp[i-1]);
}
return dp[length];
}
}
3.打家劫舍III
在上次打劫完一条街道之后和一圈房屋后,小偷又发现了一个新的可行窃的地区。这个地区只有一个入口,我们称之为“根”。
除了“根”之外,每栋房子有且只有一个“父“房子与之相连。一番侦察之后,聪明的小偷意识到“这个地方的所有房屋的排列类似于一棵二叉树”。
如果两个直接相连的房子在同一天晚上被打劫,房屋将自动报警。计算在不触动警报的情况下,小偷一晚能够盗取的最高金额。
这个题更离谱了,直接将数组变成了一棵二叉树。那么树形结构的动态规划怎么做?没有思路,看看题解。
发现这个题的状态转移还是差不多的:对于节点root
- 偷root,那么最大金额为
root.val + 不偷root.left的最大金额 + 不偷root.right的最大金额
- 不偷root,那么最大金额就是
root.left的最大金额 + root.right的最大金额
(不一定偷)
那么对于每一个节点,就存在两种值,一种是偷,一种是不偷。这里我们用一个数组result表示,
result[0]表示不偷的最大金额,result[1]表示偷的最大金额。
class Solution {
public int rob(TreeNode root) {
int[] result = robThink(root);
return Math.max(result[0],result[1]);
}
/**
* 得到对应节点两种状态的最大金额,[0]是不偷的最大金额,[1]是偷的最大金额
* @param root
* @return
*/
private int[] robThink(TreeNode root) {
if(root==null) return new int[2];
int[] result = new int[2];
int[] left = robThink(root.left);
int[] right = robThink(root.right);
result[0] = Math.max(left[0],left[1]) + Math.max(right[0],right[1]);
result[1] = root.val + left[0] + right[0];
return result;
}
}
4.最佳买卖股票时机含冷冻期
给定一个整数数组,其中第 i 个元素代表了第 i 天的股票价格 。
设计一个算法计算出最大利润。在满足以下约束条件下,你可以尽可能地完成更多的交易(多次买卖一支股票):
你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。 卖出股票后,你无法在第二天买入股票 (即冷冻期为 1 天)。
这个题和之前的小偷问题还是存在区别的,因为如果你要在第n天有最大利润,那你手里肯定不能有股票,所以这一天一定会卖出去 or 选择不买入。
- 如果选择卖出去,说明手里肯定有股票。也就是说在n-1天手里还是持股的,那此时的最大利润就是
当第n-1天持股时的最大利润+第n天的股价
。 - 如果选择不买入,说明第n-1天要么卖出去,要么不持股。所以此时的最大利润等于
第n-1天不持股的最大利润
。
这么一观察,似乎还需要一种状态,那就是第n天持股时的最大利润。(上面讨论的就是不持股时的最大利润)
那第n天持股,说明这一天也有两种选择:买入 or 不卖
- 如果是买入,那么最大利润就是
第n-2天不持股最大利润-当天股价
。 - 如果是不卖,那么最大利润就是
第n-1天持股最大利润
。
这样一来,似乎答案也出现了,我们要维护一个二维数组:dp[n][0]表示第n天不持股的最大利润,dp[n][1]表示第n天持股的最大利润。
class Solution {
public int maxProfit(int[] prices) {
int n = prices.length;
if(n==0) return 0;
//dp[n][0]表示第n天不持股的最大利润,dp[n][1]表示第n天持股的最大利润。
int[][] dp = new int[n+1][2];
dp[1][0] = 0;
dp[1][1] = -prices[0];
for(int i=2;i<n+1;i++){
dp[i][0] = Math.max(dp[i-1][1] + prices[i-1],dp[i-1][0]);
dp[i][1] = Math.max(dp[i-2][0]-prices[i-1],dp[i-1][1]);
}
return dp[n][0];
}
}