百度百科里对于动态规划问题是这样解释的:

在现实生活中,有一类活动的过程,由于它的特殊性,可将过程分成若干个互相联系的阶段,在它的每一阶段都需要作出决策,从而使整个过程达到最好的活动效果。因此各个阶段决策的选取不能任意确定,它依赖于当前面临的状态,又影响以后的发展。当各个阶段决策确定后,就组成一个决策序列,因而也就确定了整个过程的一条活动路线.这种把一个问题看作是一个前后关联具有链状结构的多阶段过程就称为多阶段决策过程,这种问题称为多阶段决策问题。

在多阶段决策问题中,各个阶段采取的决策,一般来说是与时间有关的,决策依赖于当前状态,又随即引起状态的转移,一个决策序列就是在变化的状态中产生出来的,故有“动态”的含义,称这种解决多阶段决策最优化的过程为动态规划方法

基本思想:

动态规划算法通常用于求解具有某种最优性质的问题。在这类问题中,可能会有许多可行解。每一个解都对应于一个值,我们希望找到具有最优值的解。动态规划算法与分治法类似,其基本思想也是将待求解问题分解成若干个子问题,先求解子问题,然后从这些子问题的解得到原问题的解。

与分治法不同的是,适合于用动态规划求解的问题,经分解得到子问题往往不是互相独立的。若用分治法来解这类问题,则分解得到的子问题数目太多,有些子问题被重复计算了很多次。如果我们能够保存已解决的子问题的答案,而在需要时再找出已求得的答案,这样就可以避免大量的重复计算,节省时间。我们可以用一个表来记录所有已解的子问题的答案。不管该子问题以后是否被用到,只要它被计算过,就将其结果填入表中。这就是动态规划法的基本思路。具体的动态规划算法多种多样,但它们具有相同的填表格式

解题步骤:

  1. 确定dp数组,以及dp[i]的含义
  2. 确定递推公式
  3. dp数组初始化
  4. 确定遍历顺序
  5. 举例推导dp数组

注:本文的内容大部分来自对代码随想录公众号的文章的学习总结,方便自己后续复习回顾。

目录

  • 基础问题
    • 力扣509. 斐波那契数
    • 力扣 70. 爬楼梯
    • 力扣 746. 使用最小花费爬楼梯
    • 力扣 62. 不同路径
    • 力扣 63. 不同路径 II
    • 力扣 343. 整数拆分
    • 力扣 96. 不同的二叉搜索树
  • 背包问题
    • 0-1背包
      • 力扣 416. 分割等和子集
      • 力扣 1049. 最后一块石头的重量 II
      • 力扣 494. 目标和
      • 力扣 474. 一和零
    • 完全背包
      • 力扣 518. 零钱兑换 II
      • 力扣 377. 组合总和 Ⅳ
      • 力扣 70. 爬楼梯
      • 力扣 322. 零钱兑换
      • 力扣 279. 完全平方数
      • 力扣 139. 单词拆分
    • 多重背包
    • 背包问题总结
  • 打家劫舍
    • 力扣 198. 打家劫舍
    • 力扣 213. 打家劫舍 II
    • 力扣 337. 打家劫舍 III
  • 股票问题
    • 力扣 121. 买卖股票的最佳时机
    • 力扣 122. 买卖股票的最佳时机 II
    • 力扣 123. 买卖股票的最佳时机 III
    • 力扣 188. 买卖股票的最佳时机 IV
    • 力扣 309. 最佳买卖股票时机含冷冻期
    • 力扣 714. 买卖股票的最佳时机含手续费
  • 子序列问题
    • 不连续子序列
      • 力扣 300. 最长递增子序列
      • 力扣 1143. 最长公共子序列
      • 力扣 1035. 不相交的线
    • 连续子序列
      • 力扣 674. 最长连续递增序列
      • 力扣 718. 最长重复子数组
      • 力扣 53. 最大子数组和
    • 编辑距离
      • 力扣 392. 判断子序列
      • 力扣 115. 不同的子序列
      • 力扣 583. 两个字符串的删除操作
      • 力扣 72. 编辑距离
    • 回文
      • 力扣 647. 回文子串
      • 力扣 516. 最长回文子序列

基础问题

力扣509. 斐波那契数

原题链接

相信这道题是很多人学习递归函数的入门题目,不过这个同时也是介绍动态规划很经典的一道题。直接进入动态规划五步走:

  1. 确定dp数组,以及dp[i]的含义

dp[i]的含义是:第i个数的斐波那契数值是dp[i]

  1. 确定递推公式(状态转移方程)

题目已经给出了:dp[i]=dp[i-1]+dp[i-2];

  1. dp数组初始化

也是题目明确的:dp[0]=0;dp[1]=1;

  1. 确定遍历顺序

由于每个dp[i]是依赖其前两个数值进行确定的,所以应当从前往后遍历。

  1. 举例推导dp数组

n=8时:0 1 1 2 3 5 8 13
如果提交代码后发现答案不对,可以打印dp数组,看看是否和推导的不一样;

代码:

时间O(n),空间O(n):

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

这题也可以使用更少空间,因为只需要记录按顺序推导,推导过程中更新前两个数就行了,并不需要记住整个序列。

代码:

时间O(n),空间O(1):

class Solution {public int fib(int n) {if(n<=1)return n;int[] dp=new int[2];dp[0]=0;dp[1]=1;for(int i=2;i<=n;i++){int fn=dp[0]+dp[1];dp[0]=dp[1];dp[1]=fn;}return dp[1];}
}

还有就是很经典的递归代码:

时间O(2n),空间O(n):

class Solution {public int fib(int n) {if(n<2)return n;return fib(n-1)+fib(n-2);}
}

力扣 70. 爬楼梯

原题链接
这题可以这样看:爬一层楼梯,有1种方法;爬两层楼梯,有2种方法;爬3层的时候,可以从第一层两步到第三层,也可以从第二层一步到第三层;爬4层的时候,可以从第二层两步到第四层,也可以从第三层一步到第四层;以此类推……

可以看出,爬第i层的楼梯时,爬它的方法种数=爬i-1层种数+爬i-2层种数,下面就可以进行动态规划5步走了。

  1. 确定dp数组,以及dp[i]的含义

dp[i]:爬到第i层时,有dp[i]种方法。

  1. 确定递推公式

从前面的分析可以知道dp[i]取决于其前面两层的方法种数,即dp[i]=dp[i-1]+dp[i-2],也就是从i-1层爬1层到第i层,或者从i-2层爬2层到第i层。

  1. dp数组初始化

dp[1]=1,dp[2]=2

  1. 确定遍历顺序

从i=3开始,从小到大

  1. 举例推导dp数组

n=8时,dp=1,2,3,5,8,13,21,34

从以上可以看出,这还是斐波那契数列的问题,只是不用讨论dp[0]为多少

代码:

时间复杂度O(n),空间复杂度O(n)

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

时间复杂度O(n),空间复杂度O(1)

class Solution {public int climbStairs(int n) {if(n<=1)return n;int[] dp=new int[3];dp[1]=1;dp[2]=2;int sum=0;for(int i=3;i<=n;i++){sum=dp[1]+dp[2];dp[1]=dp[2];dp[2]=sum;}return dp[2];}
}

力扣 746. 使用最小花费爬楼梯

原题链接

  1. 确定dp数组,以及dp[i]的含义

dp[i]:爬到第i层楼梯需要的最低花费。

  1. 确定递推公式

可以从dp[i-1]或者dp[i-2]得到dp[i],但是题目要求的是最低花费,所以需要的是min(dp[i-1],dp[i-2])+cost[i];这里可能会有问题:为什么是加cost[i],而不是加cost[i-1]或者cost[i-2]呢?因为题目是说在第i层支付cost[i]之后就可以获得向上爬1或2层的机会,从题目的示例可以看出,到达最后一层/二层之后,还是需要支付最后一层/二层的花费,才能到达顶层。

  1. dp数组初始化

题目也是给出,可以从第1、2层开始,dp[0]=cost[0],dp[1]=cost[1];

  1. 确定遍历顺序

从前到后遍历cost数组,从i=3开始

  1. 举例推导dp数组

cost=[1,100,1,1,1,100,1,1,100,1]
dp=[1,100,2,3,3,103,4,5,104,6]

代码:

class Solution {public int minCostClimbingStairs(int[] cost) {int[] dp=new int[cost.length];dp[0]=cost[0];dp[1]=cost[1];for(int i=2;i<cost.length;i++){dp[i]=Math.min(dp[i-1],dp[i-2])+cost[i];}//从倒数第一层走1层台阶或者倒数第二层走两层台阶return Math.min(dp[cost.length-1],dp[cost.length-2]);}
}

力扣 62. 不同路径

原题链接

因为题目规定机器人每次只能向下或者向右边移动一步,这样可以将路径抽象为一棵二叉树,机器人走过的路径就可以抽象为从根节点到达叶子结点(深度最大)的不同路径,这样自然会想到深度优先搜索遍历整棵二叉树,但是这棵抽象二叉树深度是m+n-1,二叉树节点是2^(m+n-1)-1,这样时间复杂度就是O(2的m+n-1次方-1),按照题目的数据范围,这个时间复杂度是会超时的。

再换个想法,到每个位置的路径数,都是由它上面或者左边的位置的路径数相加,这样当前的状态取决于它前面的状态,容易联想到动态规划的解法,所以进行动规五步走:

  1. 确定dp数组,以及dp[i]的含义

dp[i][j]:从(0,0)出发到(i,j)有dp[i][j]种路径

  1. 确定递推公式

dp[i][j]的值一定是从它上方和左方推出,即dp[i][j]=dp[i-1][j]+dp[i][j-1]

  1. dp数组初始化

第一行和第一列初始肯定都是1,因为从(0,0)到他们的位置上都只有一直向左或者向右这样的一条路,即dp[0][j]=0,dp[i][0]=0;

  1. 确定遍历顺序

dp[i][j]的值都是从上方和左方推出,所以从左到右,从上到下一层层遍历就行,这样可以保证推导dp[i][j]的时候,其上方或者左方一定有数值。

  1. 举例推导dp数组

m=3,n=7
dp=
[ 1, 1, 1, 1, 1, 1, 1 ]
[ 1, 2, 3, 4, 5, 6, 7]
[ 1, 3, 6,10,15,21,28]

代码如下:

//动态规划解法,时间复杂度O(mn),空间复杂度O(mn)
class Solution {public int uniquePaths(int m, int n) {int[][] dp=new int[m][n];for(int i=0;i<m;i++)dp[i][0]=1;for(int j=0;j<n;j++)dp[0][j]=1;for(int i=1;i<m;i++)for(int j=1;j<n;j++){dp[i][j]=dp[i-1][j]+dp[i][j-1];}return dp[m-1][n-1];}
}//节省空间写法,空间复杂度O(n)
class Solution {public int uniquePaths(int m, int n) {int[] dp=new int[n];for(int i=0;i<n;i++)dp[i]=1;for(int j=1;j<m;i++)for(int i=1;i<n;j++){//相当于把上一层拷贝下来,这一层直接用上一层的dp[i]//dp[i-1]属于这一层已经更新过的,dp[i]是正要更新的dp[i]+=dp[i-1];}return dp[n-1];}
}

这题也有另一种效率更高的组合数解法:

因为在移动过程中,会尽力m-1次向下移动,和n-1次向右移动,本质上就是在m+m-2次移动中,合理安排这m-1次向下移动发生的位置,只要计算出从 m+n−2次移动中选择 m−1次向下移动的方案数,就可以得出总路径数了,即:
代码:

//写法1,在计算过程中遇到可以整除的分子分母,先除掉,使分子更小
class Solution {public int uniquePaths(int m, int n) {long numerator=1;//分子(m+n-2*……*n),总共m-1项,同时存储答案int denominator=m-1;//分母(m-1)!,也是m-1项int t=m+n-2;//用于控制分子-1int count=m-1;//控制计算的次数,共m-1项while(count-->0){numerator*=(t--);//分子乘//分母不为0,并且分子可被分母整除,就先除掉分母,避免乘法溢出while(denominator!=0&&numerator%denominator==0){numerator/=denominator;denominator--;}}return (int)numerator;}
}//解法2,先用long long类型,最后强制转换
class Solution {public int uniquePaths(int m, int n) {long ans = 1;for (int x = n, y = 1; y < m; ++x, ++y) {ans = ans * x / y;}return (int) ans;}
}

力扣 63. 不同路径 II

原题链接

  1. 确定dp数组,以及dp[i]的含义

dp[i][j]:从(0,0)到(i,j)有多少条路径

  1. 确定递推公式

还是dp[i][j]=dp[i-1][j]+dp[i][j-1],不过如果当前位置有障碍物的时候,dp[i][j]=0;

  1. dp数组初始化

第一行和第一列初始化为1,但是注意,遍历过程中如果碰到障碍物,后续的位置初始值应当为0,因为碰到障碍物代表第一行和第一列之后的位置没办法走到

  1. 确定遍历顺序

从左到右一层一层遍历,从(1,1)开始

  1. 举例推导dp数组

[0,0,0]
[0,1,0]
[0,0,0]

对应的dp:
[1,1,1]
[1,0,1]
[1,1,2]

代码如下:

//时间复杂度O(mn),空间复杂度O(mn)
class Solution {public int uniquePathsWithObstacles(int[][] obstacleGrid) {int m=obstacleGrid.length,n=obstacleGrid[0].length;int[][] dp=new int[m][n];//初值为0//给第一行赋初值1,如果碰到障碍,之后的都不能走到for(int j=0;j<n&&obstacleGrid[0][j]==0;j++)dp[0][j]=1;//同上for(int i=0;i<m&&obstacleGrid[i][0]==0;i++)dp[i][0]=1;for(int i=1;i<m;i++)for(int j=1;j<n;j++){if(obstacleGrid[i][j]==1)continue;//碰到障碍,直接跳过dp[i][j]=dp[i-1][j]+dp[i][j-1];}return dp[m-1][n-1];}
}

这题同样可以用滚动数组优化空间:

//优化空间写法,空间复杂度O(m)
class Solution {public int uniquePathsWithObstacles(int[][] obstacleGrid) {int m=obstacleGrid.length,n=obstacleGrid[0].length;int[] dp=new int[n];//给第一行赋初值1,如果碰到障碍,之后的都不能走到for(int j=0;j<n&&obstacleGrid[0][j]==0;j++)dp[j]=1;for(int i=1;i<m;i++)//从第二行开始for(int j=0;j<n;j++){//一行行从头开始遍历if(obstacleGrid[i][j]==1)dp[j]=0;//else保证当前位置没有障碍物,if保证j-1不会小于循环界限else if(j-1>=0) dp[j]=dp[j-1]+dp[j];//对于j=0的情况:若初始化时dp[0][0]=1,则当后面几行若第一列没有障碍物,直接沿用dp[0],当后面几行第一列有障碍物,直接置0(dp[j]=0);若初始化时dp[0][0]=0,后续沿用也是为0}return dp[n-1];}
}

力扣 343. 整数拆分

原题链接

  1. 确定dp数组,以及dp[i]的含义

dp[i]:拆分正整数i能得到的最大乘积

  1. 确定递推公式

设j是正整数i拆出的第一个正整数,则i-j是剩余部分,可以继续拆分,也可以不拆分(取决于哪个大)。当i>=2时,有两种拆分方案:

①将 i 拆分成 j和 i−jj 的和,且 i−j不再拆分成多个正整数,此时的乘积是 j×(i−j);

②将 i拆分成 j和 i−j的和,且 i−j 继续拆分成多个正整数,此时的乘积是 j×dp[i−j]j 。

所以当j固定的时候,dp[i]=max(j*(i-j),jdp[i-j]);但是对于同一个i,需要遍历所有的j才可以求出最大的dp[i],所以状态转移方程:dp[i]=Math.max(dp[i],Math.max(j(i-j),j*dp[i-j]));

  1. dp数组初始化

0和1没有讨论的意义,从2开始,可以拆成1+1,dp[2]=1;

  1. 确定遍历顺序

从i=2开始,到i=n;对于每个i,需要将其拆分为j和其他部分,j和i-j,j从1开始到i-1;

  1. 举例推导dp数组

n = 10

dp=[0,0,1,2,4,6,9,12,18,27,36](下标从i=0开始,到下标为10结束)

代码如下:

//动态规划解法:时间复杂度O(n^2),空间复杂度O(n)
class Solution {public int integerBreak(int n) {int[] dp=new int[n+1];dp[2]=1;for(int i=3;i<=n;i++)for(int j=1;j<i;j++){dp[i]=Math.max(dp[i],Math.max(j*(i-j),j*dp[i-j]));}return dp[n];}
}

这题官方题解还有数学方法可以在O(n)时间复杂度和O(1)空间复杂度求解,但是需要数学证明,证明过程较为繁琐,所以暂时不写。


力扣 96. 不同的二叉搜索树

原题链接

思路:

如果整数 1 - n 中的 k 作为根节点值,则 1到k-1会去构建左子树,k+1到n会去构建右子树。左子树出来的形态有 a种,右子树出来的形态有 b 种,则整个树的形态有a∗b种。

以 k为根节点的BST种类数 = 左子树BST种类数 * 右子树BST种类数

不管子树中的数字为多少,只要子树的数字数量相同,其能组成的二叉搜索树种类数目就一样,如123 4 567,4为根节点时,123能组成5种形态二叉搜索树,同样的567也是5种。

dp[i] : 1到i为节点组成的二叉搜索树的个数为dp[i]
dp[3],就是元素1为头结点搜索树的数量+元素2为头结点搜索树的数量+元素3为头结点搜索树的数量
元素1为头结点搜索树的数量 = 右子树有2个元素的搜索树数量 * 左子树有0个元素的搜索树数量
元素2为头结点搜索树的数量 = 右子树有1个元素的搜索树数量 * 左子树有1个元素的搜索树数量
元素3为头结点搜索树的数量 = 右子树有0个元素的搜索树数量 * 左子树有2个元素的搜索树数量
有2个元素的搜索树数量就是dp[2]。
有1个元素的搜索树数量就是dp[1]。
有0个元素的搜索树数量就是dp[0]。
所以dp[3] = dp[2] * dp[0] + dp[1] * dp[1] + dp[0] * dp[2]

  1. 确定dp数组,以及dp[i]的含义

dp[i]:编号1到i为结点,组成的二叉搜索树的数量

  1. 确定递推公式

遍历每个元素,分别作为头结点,计算其左右子树结点数量,不同结点数量对应不同左右子树种数,也就是dp[i]+=dp[j-1]*dp[i-j],j是头结点元素,j-1 为j为头结点左子树节点数量,i-j 为以j为头结点右子树节点数量,j从1到i

  1. dp数组初始化

dp[0]=1;

  1. 确定遍历顺序

从递推公式dp[i]+=dp[j-1]*dp[i-j]可知,结点数为i的状态是依靠i之前的节点数的状态,所以需要用i遍历1-n的每一个数,计算每个数作为头结点的二叉搜索树种类,用j遍历1-i,求出对应i的二叉搜索树种类数;

  1. 举例推导dp数组

dp[i]:{1,1,2,5,14,42}(i从0开始)

代码如下:

class Solution {public int numTrees(int n) {int[] dp=new int[n+1];//答案数组(0-n)dp[0]=1;for(int i=1;i<=n;i++){for(int j=1;j<=i;j++){dp[i]+=dp[j-1]*dp[i-j];//dp[i] += dp[j-1] * dp[i-j]; ,j-1 为j为头结点左子树节点数量,i-j 为以j为头结点右子树节点数量}}return dp[n];}
}

背包问题

背包问题大体可以分为01背包、完全背包、多重背包、分组背包,其中找工作笔试面试比较常遇到的是01和完全背包问题。

0-1背包

问题一般是这样的:有n个物品,第i个物品的重量是weight[i],它的价值是value[i],背包容量是w,每个物品只能用一次,求解将哪些物品装入背包里物品价值总和最大。

一般题目中不会出现这样标准的描述,而是需要自己将具体问题抽象成这样的背包问题。

下面以一个很简单的例子作为01背包(二维数组)示例讲解:
背包最多能装重量为4的物品:

物品编号 重量 价值
物品0 1 15
物品1 3 20
物品2 4 30

问:背包能装的最大价值是多少?

关于01背包问题,总的来说就是每个物品的放置策略的组合,因为每个物品只有两个状态:放入和不放入,所以可以从物品0开始讨论放置策略。

继续动规五步走(先从二维的dp数组开始)。

  1. 确定dp数组,以及dp[i][j]的含义

dp[i][j]表示从编号为0-i的物品中不重复的任意选取,放进容量为j的背包中,其最大价值为dp[i][j];

  1. 确定递推公式

dp[i][j]有两个方向推导(第i件物品放置的策略):

①不放第i件物品,问题就转化为只和前i-1件物品有关的问题:“前i-1件物品放入容量为j的背包中,价值为dp[i-1][j]”。

②放第i件物品,问题转化为:“前i-1件物品放入容量为j-weight[i]的背包中,能获得的最大价值是dp[i-1][j-weight[i]],再加上放入i的价值,最终最大价值为:dp[i-1][j-weight[i]]+value[i]”。

物品i选择放入时,背包容量为j-weight[i]的原因是要给物品i留下足够大的空间。

所以最终的递推公式就是:dp[i][j]=max(dp[i-1][j],dp[i-1][j-weight[i]]+value[i]);
只要保证每次对物品放置的策略都是最优的,这样遍历到最后一个物品的时候,背包中的价值就是最大价值。

  1. dp数组初始化

背包容量j为0的时候(dp[i][0]):背包什么都放不下,里面的价值一定是0,所以j=0的那一列全初始化为0;

i为0的时候(dp[0][j]):存放物品0,背包容量为j的时候,背包能存放的最大价值。从递推公式出发,在只有物品0可以选择是否放入时,在背包容量足够放入物品0的前提下,物品0一定选择放入,那么此时dp[0][j]=dp[0][j-weight[0]]+value[0];背包容量j小于weight[0]时,就不选择放入,dp[0][j]=0。

不过对于i=0的初始化有个细节:对j的遍历必须是倒序,即j要从最大背包容量bagWeight开始,递减至weight[0]。

for(int j=bagWeight;j>=weight[0];j--){dp[0][j]=dp[0][j-weight[0]]+value[0];
}

那么为什么要倒序遍历呢?以前面的示例数据为例:如果正序遍历:

for(int j=weight[0];j<=bagWeight;j++){dp[0][j]=dp[0][j-weight[0]]+value[0];
}

dp[0][1]=15,到了dp[0][2]就为30了,会发现后面每次遍历都会重复把物品0再放入背包进行计算,而倒序遍历的话,可以保证物品0只被放入1次。

对于其他的数组位置的初始化,也要分情况来看:
价值都是正数时:非0下标初始化均为0,这样不会影响取最大值的结果;
价值存在负数时:初始化为负无穷,同样也是不影响取最大值。

  1. 确定遍历顺序

先遍历物品还是先遍历背包容量呢?是都可以的,但是从正常逻辑来看,先遍历物品更好理解。对于物品的遍历,从i=1开始,到i=weight.length结束;对于背包容量的遍历,从j=1开始,到j=bagWeight(最大背包容量)结束。

for(int i=1;i<weight.length;i++)for(int j=1;j<=bagWeight;j++){//容量如果不够装物品i,那么就只和前面的状态有关if(j<weight[i])dp[i][j]=dp[i-1][j];//装得下i,也要看是否要装物品ielse dp[i][j]=max(dp[i-1][j],dp[i-1][j-weight[i]]+value[i]);
}
  1. 举例推导dp数组
物品编号i/背包容量j 0 1 2 3 4
物品0 0 15 15 15 15
物品1 0 15 15 20 35
物品2 0 15 15 20 35

讲完了二维dp数组的01背包,接下来就可以对二维数组进行空间优化了。
从递推公式:

dp[i][j]=max(dp[i-1][j],dp[i-1][j-weight[i]]+value[i];

可以看出,把第i-1层的数据拷贝到第i层使用,这样递推公式就变成:

dp[i][j]=max(dp[i][j],dp[i][j-weight[i]]+value[i];

再进一步,与其将数据拷贝,不如只用一个一维数组,这样只需要对一维数组进行操作,就可以了。

接下来继续动规五步走:

  1. 确定dp数组,以及dp[i]的含义

dp[j]:容量j的背包,所放物品的价值最大可以为d[j];

  1. 确定递推公式

同二维dp数组,只需要将i那个维度去掉即可:

dp[j]=max(dp[j],dp[j-weight[i]]+value[i];
  1. dp数组初始化

dp[j]表示容量为j的背包,所背物品价值最大可以为dp[j],当j=0时,容量为0,价值为0,即dp[j]=0。其他位置的初始化同二维dp的初始化,价值均为正,则初始0,若存在负数,则初始化为负无穷。

  1. 确定遍历顺序
for(int i=0;i<weight.length;i++)for(int j=bagWeight;j>=weight[i];j--){dp[j]=max(dp[j],dp[j-weight[i]]+value[i];}

这里面对j的遍历为逆序,理由和二维dp里的关于第0行dp数组的初始化一样,这样可以保证物品i只被放入一次;而二维dp对j的遍历不用倒序是因为它能存储上一层的数据情况,不会在这一层被覆盖。

  1. 举例推导dp数组

同二维效果。


力扣 416. 分割等和子集

原题链接

要求判断能否将正整数数组分成两个元素和相等的子集,其实不用真的去分出两个子集,只要能找出一个元素和为sum/2的子集即可。

这题里面的子集符合01背包的规则,即数组中的元素只能使用一次。接下来开始套入01背包:
①背包容量为sum/2;
②背包放入的物品为nums数组的元素;
③物品的价值为元素的值;
④背包恰好装满的时候,就说明找到了总和为sum/2的一个子集,那么另一个子集也一定为sum/2;

注意:这题其实是求背包是否能刚好装满!

  1. 确定dp数组,以及dp[i]的含义

dp[i]:背包容量是i,可以凑成i的子集元素总和是dp[i];

  1. 确定递推公式

01背包递推公式:

dp[j]=max(dp[j],dp[j-weight[i]]+value[i];

在这题中,相当于背包里面放入数值,物品的重量就是nums[i],物品的价值也是nums[i];

所以递推公式:

dp[j]=max(dp[j],dp[j-nums[i]]+nums[i];

  1. dp数组初始化

因为nums数组元素只包含正整数,dp[0]=0,其他非0下标初始化都为0;

  1. 确定遍历顺序
for(int i=0;i<nums.length;i++)for(int j=sum/2;j>=nums[i];j--){dp[j]=max(dp[j],dp[j-nums[i]]+nums[i];}
  1. 举例推导dp数组

nums=[1,5,11,5]---->sum=22;

下标 0 1 2 3 4 5 6 7 8 9 10 11
dp[j] 0 1 1 1 1 5 6 6 6 6 10 11

代码如下:

class Solution {public boolean canPartition(int[] nums) {int sum=0;for(int i=0;i<nums.length;i++)sum+=nums[i];if(sum%2==1)return false;int[] dp=new int[sum/2+1];for(int i=0;i<nums.length;i++)for(int j=sum/2;j>=nums[i];j--)dp[j]=Math.max(dp[j],dp[j-nums[i]]+nums[i]);return dp[sum/2]==sum/2;}
}

注意dp数组的大小需要+1,因为最终dp[sum/2]==sum/2就说明可以划分成两个元素和相等的子集,所以数组的位置要比正常多1,否则会超限报错。


力扣 1049. 最后一块石头的重量 II

原题链接

题目要求从石头数组中每次随机选取两块石头,质量相同时,两块都粉碎消失,质量不同时,两块抵消成为一个新石头,新石头质量为两块石头的差值。需要我们返回最后剩下的石头的最小的可能重量。

其实不必每次取两块进行抵消,我们可以将石头分成两个大堆,并且让两个石头堆的质量尽可能接近(其中一堆尽可能逼近sum/2),这样两堆石头进行抵消,最后剩下的一块石头的质量就会最小。这样就和力扣 416. 分割等和子集差不多。

所以,套入01背包问题:
①背包容量为sum/2;
②背包装入的物品为石头;
③石头的价值就是石头的质量stone[i];
④每个石头占用的背包容量也是石头质量stone[i];
⑤需要让背包尽可能多装石头。

相比力扣 416. 分割等和子集转化为求背包是否能够恰好装满(恰好为sum/2),此题其实是求背包最多可以装多少(最接近sum/2)。

  1. 确定dp数组,以及dp[i]的含义

dp[j]:容量为j的背包,最多可以装入质量为dp[j]的石头;

  1. 确定递推公式

01背包递推公式:

dp[j]=max(dp[j],dp[j-weight[i]]+value[i];

stone[i]既是物品重量,也是物品价值:

dp[j]=max(dp[j],dp[j-stone[i]]+stone[i]);

  1. dp数组初始化

dp数组大小为背包容量j的最大值—>sum/2(向下取整),即石头重量总和的一半;

背包容量为0时,最多可以装入的石头容量为0,所以dp[0]=0;因为石头质量均为正整数,所以其它非0下标初值为0;

  1. 确定遍历顺序
for(int i=0;i<stone.length;i++)for(int j=sum/2;j>=stones[i];j--){dp[j]=Math.max(dp[j],dp[j-stones[i]]+stones[i]);}
  1. 举例推导dp数组

stone=[2,4,1,1]—>sum=8;

用stone[0]遍历背包:

下标 0 1 2 3 4
dp 0 0 2 2 2

用stone[1]遍历背包:

下标 0 1 2 3 4
dp 0 0 2 2 4

用stone[2]遍历背包:

下标 0 1 2 3 4
dp 0 0 2 2 4

用stone[3]遍历背包:

下标 0 1 2 3 4
dp 0 1 2 3 4

代码如下:

class Solution {public int lastStoneWeightII(int[] stones) {int sum=0;for(int i=0;i<stones.length;i++)sum+=stones[i];int[] dp=new int[sum/2+1];for(int i=0;i<stones.length;i++)for(int j=sum/2;j>=stones[i];j--){dp[j]=Math.max(dp[j],dp[j-stones[i]]+stones[i]);}return sum-dp[sum/2]-dp[sum/2];}
}

最后dp[sum/2]是容量为sum/2的背包,能装入的最大石头质量,所以可以分成两堆,一堆石头质量为dp[sum/2],另一堆就是sum-dp[sum/2]

因为sum/2是向下取整的,所以dp[sum/2]一定小于等于sum/2,所以sum-dp[sum/2]一定大于等于dp[sum/2],所以最后返回的差值就是sum-dp[sum/2]-dp[sum/2]。


力扣 494. 目标和

原题链接

这题看上去和组合总和问题很像,看起来可以用回溯法解决,不过使用回溯算法的时间复杂度就是O(2^n),会超时。

那么如何思考这道题?

在数组元素前加上正负号,让其最终算术总和等于target,也就是①left组合-right组合=target,将数组元素分为加法组合left,还有减法组合right(该组合内元素选取自nums,均为非负整数,只是它们最后要带负号的)。

此外,②left+right=sum(元素总和),由①+②可得left=(target+sum)/2,而target和sum都是固定的,所以只要求出加法总和left的组合种数,就可以得到组成target的种数了。

所以此题就可以转化成01背包问题:
①nums数组元素就是背包物品,每个元素只能被装入一次;
②元素数值大小就是物品重量,物品价值也是数值大小;
③背包大小
是(target+sum)/2;
④要找出恰好装满背包的方法种数;

这样只需要遍历一遍nums数组,决定每一个元素是否进入加法总和left的组合中即可。

所以进行五步走:

  1. 确定dp数组,以及dp[j]的含义

dp[j]:容量为j的背包恰好装满,有dp[j]种方法。

  1. 确定递推公式

dp[j]有两个方向:

①当遍历到nums[i]的时候,若装入nums[i]后背包恰好装满成j,这样的情况是由由背包容量为j-nums[i]而来,所以这个方向有dp[j-nums[i]]种方法;

②当遍历到nums[i]时,若不装入nums[i],背包恰好装满成j,这样的的情况是背包此时容量就是j而来的,所以这个方向有dp[j]种方法;

所以递推公式:

dp[j]+=dp[j-nums[i]];

  1. dp数组初始化

dp的定义是:容量为j的背包恰好装满,有dp[j]种方法。当容量j为0时,可以选择的方案就是1种,所以dp[0]=1;

  1. 确定遍历顺序

同前面

  1. 举例推导dp数组

nums=[1,1,1,1,1],target=3—>sum=5,bagWeight=4;

用nums[0]遍历背包:

下标 0 1 2 3 4
dp 1 1 0 0 0

用nums[1]遍历背包:

下标 0 1 2 3 4
dp 1 2 1 0 0

用nums[2]遍历背包:

下标 0 1 2 3 4
dp 1 3 3 1 1

用nums[3]遍历背包:

下标 0 1 2 3 4
dp 1 4 6 4 1

用nums[4]遍历背包:

下标 0 1 2 3 4
dp 1 5 10 10 5

代码如下:

class Solution {public int findTargetSumWays(int[] nums, int target) {int sum=0;for(int i=0;i<nums.length;i++)sum+=nums[i];//防止出现target+sum为负数情况,否则创建dp数组会报错int bagWeight=Math.abs((target+sum)/2);if((target+sum)%2==1)return 0;//'/'是向下取整,如果不能被2整除,就无解int[] dp=new int[bagWeight+1];dp[0]=1;//初始化for(int num:nums)for(int j=bagWeight;j>=num;j--){dp[j]+=dp[j-num];}return dp[bagWeight];}
}

力扣 474. 一和零

原题链接

官方题解的三维dp数组解释更容易理解:官方题解

下面展示的是压缩成二维dp数组之后的代码:

这题要从01背包解释的话,将strs字符数组的每个元素看做装入背包的物品,物品的重量就是0和1的个数(所以dp数组要有两个维度,i为0的个数,j为1的个数),物品的价值就是背包里子集的长度(字符串的个数),每个字符串价值都是1。

这题的思想就是遍历strs字符串数组的每个元素,去判断并决定每个字符串是否应该加入最终的子集中。

  1. 确定dp数组,以及dp[i][j]的含义

dp[i][j]:最多有i个0和j个1的 strs的最大子集 的长度为dp[i][j](子集中的每个元素都strs字符数组中的一个字符串)。

  1. 确定递推公式

有两个方向可能可以推出dp[i][j],即当前遍历到的字符串是否加入背包中:

zeroCount为当前遍历到的字符串中0的个数,oneCount为当前遍历到的字符串中1的个数。

①当前遍历到的字符串加入背包:那么就要找到这个字符串还没加入背包的那个状态,即dp[i-zeroCount][j-oneCount],如果当前字符串加入背包后得到“有i个0和j个1”的最大子集,那么“有i个0和j个1”的最大子集长度就+1,即dp[i-zeroCount][j-oneCount]+1。

②当前遍历到的字符串不加入背包:加入这个字符串可能导致最终的子集长度更小,比如加入一个“0011”,肯定没有加入两个“0”和两个“1”最终得来的子集长度长,那么dp[i][j]就保持原样。

所以递推公式:

dp[i][j]=max(dp[i][j],dp[i-zeroCount][j-oneCount]+1);

  1. dp数组初始化

物品价值即子集的字符串的个数,不会是负数,所以初始化为0

  1. 确定遍历顺序

在前面关于一维dp的讲解中,知道外层遍历物品的时候是正循环,内层遍历容量的时候需要倒序遍历(这样不会重复加入物品);这题里面的zeroCount和oneCount(字符串的0和1的个数)就是容量,所以这两个需要倒序遍历。

for(String str:strs){int zeroCount=0,oneCount=0;for(int i=0;i<str.length();i++){char c=str.charAt(i);if(c=='0')zeroCount++;if(c=='1')oneCount++;}for(int i=m;i>=zeroCount;i--)for(int j=n;j>=oneCount;j--){dp[i][j]=Math.max(dp[i][j],dp[i-zeroCount][j-oneCount]+1);}
}
  1. 举例推导dp数组
    strs = [“10”, “0001”, “111001”, “1”, “0”], m = 3, n = 3

代码如下:

class Solution {public int findMaxForm(String[] strs, int m, int n) {int[][] dp=new int[m+1][n+1];for(String str:strs){int zeroCount=0,oneCount=0;for(int i=0;i<str.length();i++){char c=str.charAt(i);if(c=='0')zeroCount++;if(c=='1')oneCount++;}for(int i=m;i>=zeroCount;i--)for(int j=n;j>=oneCount;j--){dp[i][j]=Math.max(dp[i][j],dp[i-zeroCount][j-oneCount]+1);}}return dp[m][n];}
}

完全背包

完全背包可以归纳成这样:有N种物品和一个容量最大为W的背包,第i件物品的重量是weight[i],得到的价值是value[i],每种物品的数量是无限的(可以多次放入背包),求解将哪些物品装入背包获得的价值最大。

它和01背包的区别在于完全背包的每种物品是无限数量的,下面仍然用之前的例子讲解:

背包最多能装重量为4的物品:

物品编号 重量 价值
物品0 1 15
物品1 3 20
物品2 4 30

每个物品都有无限个,求背包能装入的最大价值是多少?

完全背包和01背包代码的不同在于遍历顺序上,回顾01背包的核心遍历代码:

for(int i=0;i<weight.length;i++)//遍历物品for(int j=bagWeight;j>=weight[i];j--){//遍历容量dp[j]=max(dp[j],dp[j-weight[i]]+value[i]);}

01背包为了不重复装入物品,对内层循环对于背包容量的遍历是采用倒序的,但是完全背包是可以重复加入的,所以内层循环对背包容量的遍历是正序的,即:

for(int i=0;i<weight.length;i++)//遍历物品for(int j=weight[i];j<=bagWeight;j++){//遍历容量dp[j]=max(dp[j],dp[j-weight[i]]+value[i]);}

这样dp的状态如下:

物品编号i/背包容量j 0 1 2 3 4
物品0 0 15 30 45 60
物品1 0 15 30 45 60
物品2 0 15 30 45 60

值得一提的是,对物品和背包容量的遍历顺序,哪个先,哪个后,在完全背包问题中是都可以的,也就是先遍历物品和先遍历背包容量,效果是一样的,只是如果先遍历背包的话,为了避免超出dp数组的下标范围,需要在里面加一点限制:

for(int j=weight[i];j<=bagWeight;j++)//遍历物品for(int i=0;i<weight.length;i++){//遍历容量if(j-weight[i]>=0)dp[j]=max(dp[j],dp[j-weight[i]]+value[i]);}

另外,二维dp数组的01背包也是顺序都行,但是一维dp数组的01背包必须先遍历物品,再遍历容量。


力扣 518. 零钱兑换 II

原题链接


必须要注意的是,题目要求的是硬币组合数,而不是排列数。组合数中,认为{1,2,2}与{2,1,2}是同一个组合,而排列数会区分顺序,认为它们是两个组合。

还有就是转化成背包问题的话,是要求装满背包的情况

  1. 确定dp数组,以及dp[j]的含义

dp[j]:可以凑成金额j的硬币组合数。

  1. 确定递推公式

dp[j]代表的是可以凑成金额j 的硬币组合数,它的上一个状态就是dp[j-coins[i]],也就是恰好差coins[i]金额的那个状态,所以递推公式:dp[j] += dp[j - coins[i]];

求装满背包有几种方法,一般公式都是:dp[j] += dp[j - nums[i]];

  1. dp数组初始化

dp[0]:金额为0的时候,就是一种硬币都不加入,所以只有这一种组合,dp[0]=1.
其它非0下标的dp元素初始化0,这样计算dp[j-coins[i]]的时候不会影响dp[j]。

  1. 确定遍历顺序
    (转自代码随想录)

是外层for循环遍历物品(钱币),内层for遍历背包(金钱总额),还是外层for遍历背包(金钱总额),内层for循环遍历物品(钱币)呢?

前面提到的完全背包的内外层for循环先后顺序都可以,但这题不适用,这是因为纯完全背包求得是能否凑成总和,和凑成总和的元素有没有顺序没关系,即:有顺序也行,没有顺序也行!

而本题要求凑成总和的组合数,元素之间要求没有顺序。

所以纯完全背包是能凑成总和就行,不用管怎么凑的。

本题是求凑出来的方案个数,且每个方案个数是为组合数。

可以先看下两种遍历方式的区别:

外层for循环遍历物品(钱币),内层for遍历背包(金钱总额)的情况

for (int i = 0; i < coins.length; i++) { // 遍历物品for (int j = coins[i]; j <= amount; j++) { // 遍历背包容量dp[j] += dp[j - coins[i]];}
}

假设:coins[0] = 1,coins[1] = 5。

那么就是先把1加入计算,然后再把5加入计算,得到的方法数量只有{1, 5}这种情况。而不会出现{5, 1}的情况。

所以这种遍历顺序中dp[j]里计算的是组合数!

外层for循环遍历背包(金钱总额),内层for遍历物品(钱币)的情况:

for (int j = 0; j <= amount; j++) { // 遍历背包容量for (int i = 0; i < coins.size(); i++) { // 遍历物品if (j - coins[i] >= 0) dp[j] += dp[j - coins[i]];}
}

背包容量的每一个值,都是经过 1 和 5 的计算,包含了{1, 5} 和 {5, 1}两种情况。此时dp[j]里算出来的就是排列数!

  1. 举例推导dp数组


代码如下:

class Solution {public int change(int amount, int[] coins) {int[] dp=new int[amount+1];dp[0]=1;//初始化dp数组,表示金额为0时只有一种情况,也就是什么都不装for(int i=0;i<coins.length;i++)for(int j=coins[i];j<=amount;j++){dp[j]+=dp[j-coins[i]];}return dp[amount];}
}

请注意!!!

在求装满背包有几种方案的时候,认清遍历顺序是非常关键的。

如果求组合数就是外层for循环遍历物品,内层for遍历背包
如果求排列数就是外层for遍历背包,内层for循环遍历物品


力扣 377. 组合总和 Ⅳ

原题链接


注意到题目中说顺序不同的序列视作不同的组合,其实这就是求排列问题。这题的前置题目是要求我们把排列都列出来,这样的情况是要用回溯算法的,但是本题要求的是排列的个数,不用列出所有排列,而且由例子可以看出,每个元素可以重复使用,所以这题可以转化为完全背包问题。

物品是数组的元素nums[i],背包容量就是目标总和j,

  1. 确定dp数组,以及dp[j]的含义

dp[j]:总和为j的排列组合有dp[j]种。

  1. 确定递推公式

dp[j]可以从上一个状态:dp[j-nums[i]]推出,也就是nums[i]不在排列组合时候的排列种数,也可以从自身dp[j]推出,即:

dp[j]+=dp[j-nums[i]];

  1. dp数组初始化

目标总和为0的排列组合只有一种,就是什么都不选,即dp[0]=1。因为数组元素均为正整数,所以其它非0下标dp数组元素初始化为0.

  1. 确定遍历顺序

在力扣 518. 零钱兑换 II中讲到过:

如果求组合数就是外层for循环遍历物品,内层for遍历背包
如果求排列数就是外层for遍历背包,内层for循环遍历物品

这题求的是排列数,所以外层for循环应该遍历背包容量,内层循环遍历物品,如果把物品(数组元素)放在外循环,那么nums[i]永远都会在nums[i+1]前面,就没有排列的更多种组合了。

for(int j=0;j<=target;j++)for(int i=0;i<nums.length;i++){if(j-nums[i]>=0)dp[j]+=dp[j-nums[i]];}
  1. 举例推导dp数组

代码如下:

class Solution {public int combinationSum4(int[] nums, int target) {int[] dp=new int[target+1];dp[0]=1;for(int j=0;j<=target;j++)//先遍历背包for(int i=0;i<nums.length;i++){//再遍历物品if(j-nums[i]>=0)dp[j]+=dp[j-nums[i]];}return dp[target];}
}

力扣 70. 爬楼梯

原题链接


这题在前面已经讲过了一种思路,也是动态规划思想,但是那时候的递推公式是这样的:dp[i]=dp[i-1]+dp[i-2],也就是从i-1层爬1层到第i层,或者从i-2层爬2层到第i层。

但是这题还可以进阶一下,也就是改成每次可以爬升台阶数的范围是1~m,那这时有多少种方法可以爬到楼顶呢?

这样一改,题目就可以转化为完全背包问题了,物品就是1到m的整数,背包容量就是总的台阶数,相当于每次从编号1到m的物品中选择(每种物品可以重复选择),问最后有多少种方案可以将容量为n的背包装满,并且这还是一个排列问题(先遍历背包容量,再遍历物品),这时候就发现,这样改完之后,和上一题力扣 377. 组合总和 Ⅳ几乎一样

  1. 确定dp数组,以及dp[j]的含义

dp[j]:爬到台阶数为j的楼顶,有dp[j]种方法;

  1. 确定递推公式

dp[j]+=dp[j-i],i从1到m。

  1. dp数组初始化

dp[0]=1,dp[0]是推导的基础,不能等于0,否则无法累加。

  1. 确定遍历顺序

完全背包排列问题,外层for循环正序遍历背包(总台阶数1-n),内层for循环遍历物品(1-m台阶)

  1. 举例推导dp数组
    和上题几乎一样

代码如下:

class Solution {public int climbStairs(int n, int m) {int[] dp=new int[n+1];dp[0]=1;for(int j=0;j<=n;j++)//先遍历背包for(int i=1;i<=m;i++){//再遍历物品if(j-i>=0)dp[j]+=dp[j-i];}return dp[n];}
}

将代码里的m改成2,传参int m去掉,就可以套入爬楼梯这题了。


力扣 322. 零钱兑换

原题链接
这题和前面做过的518. 零钱兑换 II很像:
但是518题求的是可以凑成总金额的硬币的组合数,本题求的是可以凑成总金额的最少的硬币个数,因此,在dp的定义和递推公式方面会有所不同。

  1. 确定dp数组,以及dp[j]的含义

dp[j]:可以凑成金额j的最少硬币个数为dp[j]。

  1. 确定递推公式

dp[j]可以由其上一个状态dp[j-coins[i]推出,只要在上一个状态基础上,加上当前遍历到的coins[i]数量为1,既可以得到dp[j];同时题目要求的是最少的硬币个数,所以最终的递推公式为:

dp[j]=min(dp[j],dp[j-coins[i]]+1);

  1. dp数组初始化

可以凑成金额0的硬币个数为0,此外,题目要求的是最小的硬币数,其他非0下标的dp数组元素值必须初始化为int的最大值,否则递推公式中的dp[j]=min(dp[j],dp[j-coins[i]]+1)可能会被覆盖。

  1. 确定遍历顺序

求的是组合数,所以内外层循环先后顺序都可以,采用外层遍历物品(硬币),内层遍历背包(总金额);每种硬币数量没有限制,是完全背包问题,内循环正序;

  1. 举例推导dp数组

dp[11]为最终答案。

代码如下:

class Solution {public int coinChange(int[] coins, int amount) {int[] dp=new int[amount+1];for(int i=1;i<=amount;i++)dp[i]=Integer.MAX_VALUE;for(int i=0;i<coins.length;i++)for(int j=coins[i];j<=amount;j++){//这是为了防止dp[j-coins[i]]=int最大值,如果这时候+1,会超限if(dp[j-coins[i]]!=Integer.MAX_VALUE)dp[j]=Math.min(dp[j],dp[j-coins[i]]+1);}if(dp[amount]==Integer.MAX_VALUE)return -1;return dp[amount];}
}

力扣 279. 完全平方数

原题链接

这题可以把目标总和n视为背包最大容量,完全平方数(数量无限)就是物品,需要凑满正整数n的背包,求凑满这个背包最少可以用多少个物品?

这样一描述,就很像上一题322.零钱兑换差不多了,也都是求最少需要用多少个物品凑满背包。

  1. 确定dp数组,以及dp[j]的含义

dp[j]:凑成总和为j的完全平方数最少可以有dp[j]个。

  1. 确定递推公式

同样的,从dp[j]的前一个状态推出,也就是还没装入完全平方数ii的时候–>dp[j-ii],这个状态+1就可以凑成dp[j]。

因为要选择最小的dp[j],所以递推公式dp[j]=min(dp[j-i*i]+1,dp[j]);

  1. dp数组初始化

dp[0]:凑成金额0的完全平方数最少可以有0个(强行解释,一切为了递推,因为n是从1开始的),其他非0下标元素初始化为最大值。

  1. 确定遍历顺序

物品数量不限–>完全背包;

如果求组合数就是外层for循环遍历物品,内层遍历背包;
如果求排列数就是外层遍历背包,内层遍历物品;

但是这题只是求最少的数量,所以这两种遍历都可以,

  1. 举例推导dp数组

代码如下:

class Solution {public int numSquares(int n) {int[] dp=new int[n+1];dp[0]=0;for(int i=1;i<n+1;i++)dp[i]=Integer.MAX_VALUE;for(int i=1;i*i<=n;i++)//遍历物品for(int j=i*i;j<=n;j++){//遍历背包容量dp[j]=Math.min(dp[j-i*i]+1,dp[j]);}return dp[n];}
}

力扣 139. 单词拆分

原题链接


这题可以把字符串s看做背包,字典中的单词看做物品,并且这个单词还是可重复的,所以可以视为完全背包问题,转化后其实就是问能否用字典中的单词去填满/匹配字符串。

  1. 确定dp数组,以及dp[j]的含义

dp[i]:表示字符串s的下标从0到i-1即s[0…i-1]这一段(前i个字符组成的字符串)能否被拆分为若干词典上的单词。为true则表示可以被拆分。

  1. 确定递推公式

利用j作为分割点,如果dp[j]=true(字符串s的下标0到j-1这一段可以被拆分为若干字典上的单词),那么只要保证字符串s的下标的j到i-1这一段(s[j…i-1])出现在字典里,那么这两段拼接后也一定合法。

所以递推公式:

if(dp[j]==true&&s[j…i-1]在字典wordDict中出现过)dp[i]=true;

至于判断某一段字符串s[j…i-1]是否在字典wordDict中出现过,可以先把这一段子串取出,再用哈希表来快速判断该子串其是否出现在字典中:

Set<String> wordDictSet =new HashSet(wordDict);
if(dp[j]&&wordDictSet.contains(s.substring(j,i))){dp[i] = true;break;
}

这题里面字典中的单词是存放在List里的,list也有contions方法即:wordDict.contains(s.substring(j,i))也行,不过鉴于HashSet的查找效率要快得多,所以先建一个HashSet再去查找会更合适,只是占用空间相对多了一些;

  1. dp数组初始化

从递推公式可以知道,dp[i]的状态依赖于dp[j]是否为true,dp[0]就是递推的根基,它必须为true(强行解释:默认空字符串出现在字典里)。

其它非0下标初始化为false;

  1. 确定遍历顺序

前面已经确定这是一个完全背包问题,现在需要讨论两层for循环的顺序问题。

外层for循环用i遍历整个字符串s,内层for循环用j作为切割点,扫一遍字符串s从0到i-1下标进行切割。

for (int i = 1; i <= s.length(); i++)for (int j = 0; j < i; j++) {if (dp[j] && wordDictSet.contains(s.substring(j, i))) {dp[i] = true;break;}}
  1. 举例推导dp数组


代码如下:

public class Solution {public boolean wordBreak(String s, List<String> wordDict) {Set<String> wordDictSet = new HashSet(wordDict);boolean[] dp = new boolean[s.length() + 1];dp[0] = true;for (int i = 1; i <= s.length(); i++)for (int j = 0; j < i; j++) {if (dp[j] && wordDictSet.contains(s.substring(j, i))) {dp[i] = true;break;}}return dp[s.length()];}
}

多重背包

好像力扣上没有多重背包的题?

多重背包问题抽象出来就是这样描述:有N种物品,背包容量为W,第i种物品有nums[i]件,占用容量为weight[i],价值为value[i],求将哪些物品装入背包可以让背包总价值最大?

例如背包容量为10

重量 价值 数量
物品0 1 15 2
物品1 3 20 3
物品2 4 30 2
求背包最大价值可以是多少?

其实这个问题也可以转化,就将每个物品数量平摊开:

重量 价值 数量
物品0 1 15 1
物品0 1 15 1
物品1 3 20 1
物品1 3 20 1
物品1 3 20 1
物品2 4 30 1
物品2 4 30 1

这样就可以转化成01背包问题了,每个物品只用一次。

public void testMultiPack1(){// 版本一:改变物品数量为01背包格式//时间复杂度:O(m × n × k),m:物品种类个数,n背包容量,k单类物品数量List<Integer> weight = new ArrayList<>(Arrays.asList(1, 3, 4));List<Integer> value = new ArrayList<>(Arrays.asList(15, 20, 30));List<Integer> nums = new ArrayList<>(Arrays.asList(2, 3, 2));int bagWeight = 10;for (int i = 0; i < nums.size(); i++) {while (nums.get(i) > 1) { // 把物品展开为iweight.add(weight.get(i));value.add(value.get(i));nums.set(i, nums.get(i) - 1);}}int[] dp = new int[bagWeight + 1];for(int i = 0; i < weight.size(); i++) { // 遍历物品for(int j = bagWeight; j >= weight.get(i); j--) { // 遍历背包容量dp[j] = Math.max(dp[j], dp[j - weight.get(i)] + value.get(i));}System.out.println(Arrays.toString(dp));}
}public void testMultiPack2(){// 版本二:改变遍历个数//时间复杂度:O(m × n × k),m:物品种类个数,n背包容量,k单类物品数量int[] weight = new int[] {1, 3, 4};int[] value = new int[] {15, 20, 30};int[] nums = new int[] {2, 3, 2};int bagWeight = 10;int[] dp = new int[bagWeight + 1];for(int i = 0; i < weight.length; i++) { // 遍历物品for(int j = bagWeight; j >= weight[i]; j--) { // 遍历背包容量// 以上为01背包,然后加一个遍历个数for (int k = 1; k <= nums[i] && (j - k * weight[i]) >= 0; k++) { // 遍历个数dp[j] = Math.max(dp[j], dp[j - k * weight[i]] + k * value[i]);}System.out.println(Arrays.toString(dp));}}
}

背包问题总结

转自代码随想录

问能否能装满背包(或者最多装多少):dp[j] = max(dp[j], dp[j - nums[i]] + nums[i]); ,对应题目如下:

416. 分割等和子集
1049. 最后一块石头的重量 II

问装满背包有几种方法:dp[j] += dp[j - nums[i]] ,对应题目如下:

494. 目标和
518. 零钱兑换 II
377. 组合总和 Ⅳ
70. 爬楼梯

问背包装满最大价值:dp[j] = max(dp[j], dp[j - weight[i]] + value[i]); ,对应题目如下:

474. 一和零

问装满背包所有物品的最小个数:dp[j] = min(dp[j - coins[i]] + 1, dp[j]); ,对应题目如下:

322. 零钱兑换
279. 完全平方数

关于01背包:

  • 二维dp数组01背包先遍历物品还是先遍历背包都是可以的,且第二层for循环是从小到大遍历。
  • 一维dp数组01背包只能先遍历物品再遍历背包容量,且第二层for循环是从大到小遍历。

关于完全背包:

  • 纯完全背包的一维dp数组实现,先遍历物品还是先遍历背包都是可以的,且第二层for循环是从小到大遍历。
  • 但是仅仅是纯完全背包的遍历顺序是这样的,题目稍有变化,两个for循环的先后顺序就不一样了。

如果求组合数就是外层for循环遍历物品,内层for遍历背包。
如果求排列数就是外层for遍历背包,内层for循环遍历物品。

  • 相关题目如下

求组合数:518.零钱兑换II

求排列数:377. 组合总和 Ⅳ 、70. 爬楼梯进阶版(完全背包)

  • 如果求最小数,那么两层for循环的先后顺序就无所谓了,相关题目如下:

求最小数:322. 零钱兑换 、279.完全平方数

最后是一个思维导图:


打家劫舍

力扣 198. 打家劫舍

原题链接打家劫舍三连问是动态规划经典问题:

  1. 确定dp数组,以及下标的含义

dp[i]:下标i之内的房屋,最多可以偷到的金额为dp[i];

  1. 确定递推公式(状态转移方程)

显然,第i间房屋有两个选择,即偷或不偷:

如果选择偷,那么i-1房子肯定不能偷,dp[i]=dp[i-2]+nums[i],即最多可以偷窃的金额是下标i-2范围内能偷到的最多的金额加上第i间房屋的金额;

如果不偷,那么dp[i]就和dp[i-1]一样了,即dp[i]=dp[i-1];

所以递推公式:dp[i]=max(dp[i-1],dp[i-2]+nums[i]);

  1. dp数组初始化

由递推公式可以看出,需要初始化dp[0]和dp[1]:
do[0]:只有0号房屋的时候,最多金额就是nums[0];

dp[1]:选择0和1号房屋金额更高的那一个,即dp[1]=max(nums[0].nums[1]);

  1. 确定遍历顺序

从0号房屋开始遍历到最后一个房屋i-1;

  1. 举例推导dp数组

    代码如下:
class Solution {public int rob(int[] nums) {if(nums.length==1)return nums[0];int[] dp=new int[nums.length];dp[0]=nums[0];dp[1]=Math.max(nums[0],nums[1]);for(int i=2;i<nums.length;i++){dp[i]=Math.max(dp[i-1],dp[i-2]+nums[i]);}return dp[nums.length-1];}
}

力扣 213. 打家劫舍 II

原题链接
这题相比于上一题,多了一个条件,就是房子是成环首尾相连的,也就是如果选了第0间,那么第i-1间就不能选,如果选了第i-1间,那么第0间就不能选。

所以可以分两种情况:
①不考虑最后一间房子,打家劫舍范围规定在nums[0…i-2]

②不考虑首间房子,打家劫舍范围规定在nums[1…i-1]

这样运用两次上一题的逻辑,从①和②的返回值中选取最大的就是最终结果了。

注意:对于数组成环一般有三种情况:
①考虑不包含首尾元素:下标从1到i-2;
②考虑首元素,不包含尾元素:下标从0到i-2;
③考虑尾元素,不包含首元素:下标从1到i-1;

在这题里面情况②③已经包括了情况①,所以只考虑情况②③就行了。

代码如下:

class Solution {public int rob(int[] nums) {if(nums.length==1)return nums[0];int sum1=robRange(nums,0,nums.length-2);int sum2=robRange(nums,1,nums.length-1);return Math.max(sum1,sum2);}//同打家劫舍第一题一样public int robRange(int[] nums,int startIndex,int endIndex){if(startIndex==endIndex)return nums[startIndex];int[] dp=new int[nums.length];dp[startIndex]=nums[startIndex];dp[startIndex+1]=Math.max(nums[startIndex],nums[startIndex+1]);for(int i=startIndex+2;i<=endIndex;i++){dp[i]=Math.max(dp[i-1],dp[i-2]+nums[i]);}return dp[endIndex];}}

力扣 337. 打家劫舍 III

原题链接


对这道题解释一下:一棵二叉树上每个结点都有自己的权值,每个结点都可以选择要这个权值和不要这个权值,但是整棵树中不能同时父子结点都选择要权值,求解如何选择结点的权值,使这棵二叉树被选择的结点总权值最大。

想要做这道题,就先要弄清楚这题的遍历方式,二叉树的遍历方式有层序遍历,以及前中后序遍历。这题如果想用动态规划的思想解决的话,显然根节点
是否被选中不能在一开始就确定(不能用层序,前中序遍历),因为二叉树的结构导致根节点一旦被选中或不选中,在后续无法根据当前的总金额回去调整它的大小。所以要使用后序遍历,在对孩子结点处理好后,根据孩子结点的情况,决定当前的根节点是否选择。

动态规划最重要的就是有一个数组,能够对遍历过程中的状态进行记录,而这题每个结点就是偷与不偷两种,所以可以用一个长度为2的数组res用来记录当前结点偷与不偷所能获得的最大金额。

因为后序遍历涉及到递归,所以用递归三部曲:

1. 确定递归函数的参数和返回值:

很显然,递归函数的返回值就是记录了当前结点偷或不偷能获得的最大金额的数组res,这样res通过递归一层层传递上去,可以让上一层的结点进行判断。

而res数组其实就是dp数组,res[0]表示不偷当前结点所能获得最大的金额,res[1]表示偷当前结点所能获得的最大金额。

递归函数的参数就是当前的结点root。

public int[] robMoney(TreeNode root){int[] res=new int[2];……return res;
}

2. 确定终止条件:

显然碰到空节点的时候就直接返回res={0,0};

if(root==null)return res;

3. 确定遍历顺序:

后序遍历,先遍历左右孩子,再处理自己,注意需要用两个长度为2的数组用来接住左右孩子返回的res:

int[] left=robMoney(root.left);
int[] right=robMoney(root.right);

4. 确定单层递归逻辑:

left和right接住左右孩子返回值之后,就需要处理当前结点,算出res[0]和res[1]的大小了。

  • 不偷当前结点的话,那么所能获得的最大金额显然来自于左右孩子所能获得的最大金额总和,左孩子的最大金额,就是left[0]和left[1]的最大值,右孩子同理:
res[0]=Math.max(left[0],left[1])+Math.max(right[0],right[1]);
  • 偷当前结点的话,它的孩子结点肯定不能偷,所以所能获得的最大金额就是当前结点的值加上left[0]+right[0]:
res[1]=root.val+left[0]+right[0];

5. 举例推导:


代码如下:

/*** Definition for a binary tree node.* public class TreeNode {*     int val;*     TreeNode left;*     TreeNode right;*     TreeNode() {}*     TreeNode(int val) { this.val = val; }*     TreeNode(int val, TreeNode left, TreeNode right) {*         this.val = val;*         this.left = left;*         this.right = right;*     }* }*/
class Solution {public int rob(TreeNode root) {//结果数组,存放当前结点选择偷或不偷的两种金额结果;//res[0]表示不偷当前结点所能获得的最高金额//res[1]表示偷当前结点所能获得最高金额int[] res=new int[2];res=robMoney(root);return Math.max(res[0],res[1]);}//返回值public int[] robMoney(TreeNode root){int[] res=new int[2];if(root==null)return res;//后序遍历,先遍历左右孩子int[] left=robMoney(root.left);int[] right=robMoney(root.right);//再处理当前结点res[0]=Math.max(left[0],left[1])+Math.max(right[0],right[1]);res[1]=root.val+left[0]+right[0];return res;}
}

股票问题

力扣 121. 买卖股票的最佳时机

原题链接
一、暴力解法
最容易想到的肯定是暴力解法,就是双重for循环,把所有买卖情况能获得的利润全都算出,找出最大的就行,但是时间复杂度O(n^2),会超时。

//暴力解法(O(n^2),超时)
class Solution {public int maxProfit(int[] prices) {int max=0;for(int i=0;i<prices.length-1;i++)for(int j=i+1;j<prices.length;j++){max=Math.max(max,prices[j]-prices[i]);}return max;}
}

二、贪心思想
只允许买卖一次,那么就是要在最低点买入,最高点卖出。可以在遍历的过程中,用min记录遍历过的最低价格,然后在遍历到当前价格的时候,求取利润,用max记录最大利润,随着遍历不断更新min和max。

//贪心思想:时间O(n),空间O(1)
class Solution {public int maxProfit(int[] prices) {int res=0;int min=10001;for(int i=0;i<prices.length;i++){min=Math.min(min,prices[i]);res=Math.max(res,prices[i]-min);}return res;}
}

三、动态规划

其实贪心思想也就是动态规划,只是表现形式不一样:

未优化的动态规划:

class Solution {public int maxProfit(int[] prices) {//前i-1天的最大收益,第i天的价格-前i-1天中的最小价格//所以dp[i]保存两个状态//dp[i][0]第i天的最大收益//dp[i][1]前i天中的最小价格int[][] dp=new int[prices.length][2];dp[0][0]=0;dp[0][1]=prices[0];for(int i=1;i<prices.length;i++){dp[i][0]=Math.max(prices[i]-dp[i-1][1],dp[i-1][0]);dp[i][1]=Math.min(dp[i-1][1],prices[i]);}return dp[prices.length-1][0];}
}

优化空间后的动态规划:

class Solution {public int maxProfit(int[] prices) {//前i-1天的最大收益,第i天的价格-前i-1天中的最小价格//所以dp[i]保存两个状态//dp[0]第i天的最大收益//dp[1]前i天中的最小价格int[] dp=new int[2];dp[0]=0;dp[1]=prices[0];for(int i=1;i<prices.length;i++){dp[0]=Math.max(prices[i]-dp[1],dp[0]);dp[1]=Math.min(dp[1],prices[i]);}return dp[0];}
}

这里面的dp[0]就相当于贪心代码里的res,dp[1]相当于min。


力扣 122. 买卖股票的最佳时机 II

原题链接

这题在贪心专题的时候已经做过一次了,附上贪心代码:

//贪心思想
class Solution {public int maxProfit(int[] prices) {int res=0;for(int i=1;i<prices.length;i++)res+=Math.max(prices[i]-prices[i-1],0);return res;}
}

也就是把正利润拆分成每一天都进行买入卖出,但是最后效果等同于在买入之后,间隔多天再卖出。

动态规划

这题允许多次交易,但是手里同时最多只有一支股票,每一天手里只会有两种状态,也就是持有股票或不持有股票。因此可以用一个二维数组记录每天这两种状态能收获的最大收益。

1.确定dp数组,以及下标的含义:

dp[i][0]:第i天交易完成后未持有股票的最大利润;
dp[i][1]:第i天交易完成后持有股票的最大利润;

2.确定递推公式(状态转移方程)

dp[i][0]可以由两种情况推出:
①前一天(第i-1天)手里就没有股票,到了第i天仍然没有购买股票,此时收益就是dp[i][0]=dp[i-1][0];
②前一天手里有股票,第i天卖出去了,获得了利润prices[i],所以dp[i][0]=dp[i-1][1]+prices[i];

dp[i][1]同理:
①前一天手里就有股票,第i天没有卖出去,dp[i][1]=dp[i-1][1];
②前一天手里没有股票,第i天买入了,支出prices[i],所以dp[i][1]=dp[i-1][0]-prices[i];

综上:
dp[i][0]=max(dp[i-1][0],dp[i-1][1]+prices[i]);
dp[i][1]=max(dp[i-1][1],dp[i-1][0]-prices[i]);

3.dp数组初始化

第0天手里未持有股票收益dp[0][0]=0;
第0天持有股票收益dp[0][1]=-prices[0];

4.确定遍历顺序

i从0到prices.length-1,遍历完prices数组。

5.举例推导dp数组

略……

代码如下:

//动态规划(未优化空间)
class Solution {public int maxProfit(int[] prices) {int[][] dp=new int[prices.length][2];dp[0][0]=0;dp[0][1]=-prices[0];for(int i=1;i<prices.length;i++){dp[i][0]=Math.max(dp[i-1][0],dp[i-1][1]+prices[i]);dp[i][1]=Math.max(dp[i-1][1],dp[i-1][0]-prices[i]);}//因为最后一天不持有股票肯定比持有股票收益高,所以直接返回它return dp[prices.length-1][0];}
}

可以观察到,每一天的状态都只和前一天有关,所以不用记录全部的状态,只要在遍历中用两个变量记住dp[i−1][0] 和 dp[i−1][1]的情况,并且用在计算第i天的状态就可以了。

代码如下:

//动态规划(空间优化)
class Solution {public int maxProfit(int[] prices) {int dp0=0,dp1=-prices[0];for(int i=1;i<prices.length;i++){dp0=Math.max(dp0,dp1+prices[i]);dp1=Math.max(dp1,dp0-prices[i]);}return dp0;}
}

力扣 123. 买卖股票的最佳时机 III

原题链接


这题是在上一题的基础上,对交易次数做了限制,规定最多只能交易两次

因为最多只能交易两次,所以在每一天的最后,都有五种可能的状态:
0.未操作过
1.进行第一次买操作;
2.进行第一次卖操作(完成第一次交易)
3.进行第二次买操作(第一次交易完成的前提下)
4.进行第二次卖操作(完成第二次交易)

1.确定dp数组,以及下标的含义

dp[i][j]:第i天,是状态j(j为0-4)所能获得的最大收益;

2.确定递推公式(状态转移方程)

  • dp[i][0]不进行操作的话,收益一定是0

  • dp[i][1]:如果第i天是第一次买入操作状态(并不是说第i天进行买入操作,而是说第i天还卡在第一次买入操作的状态,所以有可能前几天就进行了第一次买入,后面没有进行别的操作),那么有两种情况会导致它:

①第i天其实没有进行操作,它只是保持了前面的状态,那么dp[i][1]=dp[i-1][1];

②第i天进行第一次买入操作(以prices[i]价格买入),说明前面没有进行过买入操作,那么第i天利润为-prices[i];

综上:dp[i][1]=max(dp[i-1][1],-prices[i]);

  • dp[i][2]:第i天是第一次卖出操作的状态,也是有两种情况:

①第i天没有进行过操作,只是保持着前面发生的第一次卖出的状态:dp[i][2]=dp[i-1][2];

②第i天进行第一次卖出操作(以prices[i]价格卖出),它是建立在前面的第一次买入操作的前提下的,所以dp[i][2]=dp[i-1][1]+prices[i];

综上,dp[i][2]=max(dp[i-1][2],dp[i-1][1]+prices[i]);

  • dp[i][3]:第i天是第2次买入状态,同理也有两种情况可能导致它:

①第i天只是保持着前面的第二次买入状态,没有进行操作,那么dp[i][3]=dp[i-1][3];

②第i天进行了第二次买入操作,那么它是建立在前面的第一次卖出的前提下,以prices[i]价格买入,所以dp[i][3]=dp[i-1][2]-prices[i];

综上:dp[i][3]=max(dp[i-1][3],dp[i-1][2]-prices[i]);

  • dp[i][4]:第i天是第2次卖出状态,可能导致它的两种情况:

①第i天只是保持着前面的第二次卖出状态,没有进行操作,dp[i][4]=dp[i-1][4];

②第i天进行了第二次卖出操作,它是建立在前面的第二次买入操作的前提下的,并且以prices[i]卖出,所以dp[i][4]=dp[i-1][3]+prices[i];

综上,dp[i][4]=max(dp[i-1][4],dp[i-1][3]+prices[i]);

3.dp数组初始化

  • 第0天,不作操作,收益就是0,dp[0][0]=0;
  • 第0天,第一次买入,dp[0][1]=-prices[0];
  • 第0天,第一次卖出,相当于在第0天先买入再卖出,收益为0,dp[0][2]=0;
  • 第0天,第二次买入,相当于第一次买入的状态,dp[0][3]=-prices[0];
  • 第0天,第二次卖出,相当于没有收益,dp[0][4]=0;

4.确定遍历顺序

正序遍历,i从1到prices.length-1,利润最大的状态一定是第二次卖出的状态,所以dp[price.length-1][4]是最大利润;

5.举例推导dp数组

prices = [1,2,3,4,5]
代码如下:

//动态规划(未优化空间)
class Solution {public int maxProfit(int[] prices) {int[][] dp=new int[prices.length][5];dp[0][1]=-prices[0];dp[0][3]=-prices[0];for(int i=1;i<prices.length;i++){dp[i][1]=Math.max(dp[i-1][1],-prices[i]);dp[i][2]=Math.max(dp[i-1][2],dp[i-1][1]+prices[i]);dp[i][3]=Math.max(dp[i-1][3],dp[i-1][2]-prices[i]);dp[i][4]=Math.max(dp[i-1][4],dp[i-1][3]+prices[i]);}return dp[prices.length-1][4];}
}

与上一题一样,这里的每种状态都只和它对应的上一个状态相关,而且状态0都保持为0的状态,不参与状态变化。所以可以用4个变量来记住当前的状态,并且参与到下一轮的状态转变计算中。

代码如下:

//空间优化
class Solution {public int maxProfit(int[] prices) {//从1-4,分别表示第1次买入,第1次卖出,第2次买入,第2次卖出状态int dp1=-prices[0],dp2=0,dp3=-prices[0],dp4=0;for(int i=1;i<prices.length;i++){dp1=Math.max(dp1,-prices[i]);dp2=Math.max(dp2,dp1+prices[i]);dp3=Math.max(dp3,dp2-prices[i]);dp4=Math.max(dp4,dp3+prices[i]);}return dp4;}
}

力扣 188. 买卖股票的最佳时机 IV

原题链接


这题相比123. 买卖股票的最佳时机 III,就是把最多进行两次交易改成了最多进行k次交易,解题的思想还是差不多的,可以用二维数组dp[i][j]表示在第i天状态为j的情况下,最大收益是dp[i][j]。状态有这些:
状态0:不操作
状态1:第一次买入
状态2:第一次卖出
状态3:第二次买入
状态4:第二次卖出
……
状态2k-1:第k次买入
状态2k:第k次卖出

所以一共有2k+1种状态,除0之外,奇数都是买入状态,偶数都是卖出状态。所以dp数组就是prices.length行,2k+1列。

1.确定dp数组,以及下标的含义

dp[i][j]表示在第i天状态为j的情况下,最大收益是dp[i][j];

2.确定递推公式(状态转移方程)

先看一下最多交易两次的转移方程:

dp[i][1]=Math.max(dp[i-1][1],-prices[i]);
dp[i][2]=Math.max(dp[i-1][2],dp[i-1][1]+prices[i]);
dp[i][3]=Math.max(dp[i-1][3],dp[i-1][2]-prices[i]);
dp[i][4]=Math.max(dp[i-1][4],dp[i-1][3]+prices[i]);

从上一题可以知道,无论i等于多少,只要处在状态0,dp[i][0]=0,所以为了让代码规整一些,dp[i][1]=Math.max(dp[i-1][1],dp[i-1][0]-prices[i]),即:

dp[i][1]=Math.max(dp[i-1][1],dp[i-1][0]-prices[i]);
dp[i][2]=Math.max(dp[i-1][2],dp[i-1][1]+prices[i]);
dp[i][3]=Math.max(dp[i-1][3],dp[i-1][2]-prices[i]);
dp[i][4]=Math.max(dp[i-1][4],dp[i-1][3]+prices[i]);

可以知道,无论是买入还是卖出状态,它们都有一种情况是保持前一天的状态,此外就要区别买入和卖出状态了,买入状态是依赖于它前一天的前置状态(比如第二次买入的前置状态就是第一次卖出,第二次卖出的前置状态就是第二次买入),再减去当天的股票价格;卖出状态是依赖于它前一天的前置状态,再加上当天的股票价格。

前面得出j从0到2k表示2k+1种状态,j为奇数表示买入状态,j为偶数表示卖出状态,所以状态转移可以这样:

for(int i=1;i<prices.length;i++)for(int j=1;j<=2*k-1;j+=2){dp[i][j]=Math.max(dp[i-1][j],dp[i-1][j-1]-prices[i]);dp[i][j+1]=Math.max(dp[i-1][j+1],dp[i-1][j]+prices[i]);}

3.dp数组初始化

从递推公式可以看出,dp[i][j]都来自于第i-1的状态,所以,第0天的状态是基础,第0天的状态0,收益一定是0,因为没有进行任何操作;第0天的状态1,收益是-prices[0],因为进行了买入操作;第0天的状态1,收益一定也是0,因为在买入后又进行了卖出;……所以第0天的奇数状态,收益都是-prices[0],偶数状态收益都是0;

for(int j=1;j<=2*k-1;j+=2)dp[0][j]=-prices[0];

4.确定遍历顺序

都是正序的,双重for循环,外层i从1到prices.length-1;内层j从1 到2k-1(因为偶数用j+1表示就行,所以不用遍历完整,每次j+2);

5.举例推导dp数组

k=2,prices = [1,2,3,4,5]

代码如下:

class Solution {public int maxProfit(int k, int[] prices) {if(prices.length==0)return 0;int[][] dp=new int[prices.length][2*k+1];for(int j=1;j<=2*k-1;j+=2)dp[0][j]=-prices[0];for(int i=1;i<prices.length;i++)for(int j=1;j<=2*k-1;j+=2){dp[i][j]=Math.max(dp[i-1][j],dp[i-1][j-1]-prices[i]);dp[i][j+1]=Math.max(dp[i-1][j+1],dp[i-1][j]+prices[i]);}return dp[prices.length-1][2*k];}
}

还有一种空间复杂度只要O(n)的:

这里面的buy[j]表示到了第i天,处在第j次交易的买入状态;sell[j]表示到了第i天,处在第j次交易的卖出状态,其他的可以类比二维dp的写法进行理解。

class Solution {public int maxProfit(int k, int[] prices) {int n = prices.length;if (n == 0) return 0;int[] buy = new int[k + 1];int[] sell = new int[k + 1];Arrays.fill(buy, -prices[0]);for (int i = 1; i < n; i++) {for (int j = 1; j <= k; j++) {buy[j] = Math.max(buy[j], sell[j - 1] - prices[i]);sell[j] = Math.max(sell[j], buy[j] + prices[i]);}}return sell[k];}
}

力扣 309. 最佳买卖股票时机含冷冻期

原题链接

这题是在122. 买卖股票的最佳时机 II的基础上,在每次交易之后设置了一个冷冻期,也就是交易完成的后一天,不能进行交易。

在122这题中的dp数组是用第i天交易完成后,手里是否持有股票来区分状态,dp[i][0/1]是表示第i天交易完成后,手里未持有/持有股票所获得的最大收益;而这题多了冷冻期,比122题的状态会更多。

以下状态划分来自题解中的这位同学。

1.确定dp数组,以及下标的含义

dp[i][j]表示在第i天,状态为j的情况下,最大收益是dp[i][j];

dp状态可以分为如下3个状态:

  • dp[i][0]–状态0:持有股票
    ······前一天就持有股票,今日无操作(前一天是状态1);
    ······是今天刚买入的,那么就要保证前一天是未持有状态,并且前一天没有卖出(否则今天就是冷冻期,无法买入),也就是前一天必须是状态2;

  • dp[i][1]–状态1:不持有股票,原因是之前持有,本日卖出(下一天就是冷冻期,不能买入)
    ······前一天就持有股票(前一天是状态1);

  • dp[i][2]–状态2:不持有股票,原因是之前就不持有,本日无操作(下一天不是冷冻期,可以买入)
    ······前一天也是状态2,今天继续保持;
    ······前一天卖出,并且没操作(前一天是状态1);

2.确定递推公式(状态转移方程)

在1中就把可能的情况都写出来了,对它们进行代码翻译:

//持有股票:1.前一日也持有,今日保持;2.前一日未持有,今日买入(前一日为状态2)
//情况2前一日必须为状态2是因为不持有股票的状态只有状态2和3,如果是3的话今日就是冷冻期,不能买入
dp[i][0]=Math.max(dp[i-1][0],dp[i-1][2]-prices[i]);//未持有股票(前面持有,今日卖出):1.前一日持有状态,今日卖出
dp[i][1]=dp[i-1][0]+prices;//未持有股票(前面就不持有,今日无操作):1.前一天也是状态2,今日继续保持
//2.前面一天卖出,今日为冷冻期,无操作(前一天是状态1)
dp[i][2]=Math.max(dp[i-1][2],dp[i-1][1]);

3.dp数组初始化

dp[0][0]:第0天持有股票,收益只能是-prices[0];
dp[0][1]:第0天不持有股票,是本日卖出所致,收益为0;
dp[0][2]:第0天不持有股票,是原来就不持有,本日无操作所致,收益也是0;

4.确定遍历顺序

for(int i=1;i<prices.length;i++){}

代码如下:

//未优化空间
class Solution {public int maxProfit(int[] prices) {if (prices.length==0) return 0;int[][] dp=new int[prices.length][3];dp[0][0]=-prices[0];for(int i=1;i<prices.length;i++){dp[i][0]=Math.max(dp[i-1][0],dp[i-1][2]-prices[i]);dp[i][1]=dp[i-1][0]+prices[i];dp[i][2]=Math.max(dp[i-1][2],dp[i-1][1]);}// 最后一天还持有股票是没有意义的,肯定是不持有的收益高,不用对比 dp[len-1][0]return Math.max(dp[prices.length-1][1], dp[prices.length-1][2]);}
}

上面的状态转移方程中,f[i][…]只与 f[i−1][…]有关,而与 f[i−2][…] 及之前的所有状态都无关,因此我们不必存储这些无关的状态。也就是说,我们只需要将 f[i−1][0],f[i−1][1],f[i−1][2]存放在三个变量中,通过它们计算出 f[i][0],f[i][1],f[i][2]并存回对应的变量,以便于第 i+1 天的状态转移即可。

代码如下:

//优化空间
class Solution {public int maxProfit(int[] prices) {if (prices.length==0) return 0;int dp0=-prices[0];int dp1=0,dp2=0;for(int i=1;i<prices.length;i++){int newdp0=Math.max(dp0,dp2-prices[i]);int newdp1=dp0+prices[i];int newdp2=Math.max(dp2,dp1);dp0=newdp0;dp1=newdp1;dp2=newdp2;}return Math.max(dp1, dp2);}
}

注意,优化代码不能简单修改,因为这题的状态转移方程中间会用到彼此,所以在每一轮循环中都要设定三个新的中间变量来承接结果,然后再将结果转移到三个dp变量中去。


力扣 714. 买卖股票的最佳时机含手续费

原题链接

这题最先是通过贪心思想做的,在贪心算法那一篇文章里已经写了具体的求解方法。

在动态规划方面,这题其实也只是在122. 买卖股票的最佳时机 II的基础上添加了一个手续费操作,所以大体状态设置和122差不多,只是在不持有股票的状态里,卖出股票的时候需要扣掉手续费。

1.确定dp数组,以及下标的含义

dp[i][0]:第i天交易完成后未持有股票的最大利润;
dp[i][1]:第i天交易完成后持有股票的最大利润;

2.确定递推公式(状态转移方程)

dp[i][0]可以由两种情况推出:
①前一天(第i-1天)手里就没有股票,到了第i天仍然没有购买股票,此时收益就是dp[i][0]=dp[i-1][0];
②前一天手里有股票,第i天卖出去了,需要扣掉手续费,并且获得了利润prices[i],所以dp[i][0]=dp[i-1][1]-fee+prices[i];

dp[i][1]同理:
①前一天手里就有股票,第i天没有卖出去,dp[i][1]=dp[i-1][1];
②前一天手里没有股票,第i天买入了,支出prices[i],所以dp[i][1]=dp[i-1][0]-prices[i];

综上:
dp[i][0]=max(dp[i-1][0],dp[i-1][1]-fee+prices[i]);
dp[i][1]=max(dp[i-1][1],dp[i-1][0]-prices[i]);

3.dp数组初始化

第0天手里未持有股票收益dp[0][0]=0;
第0天持有股票收益dp[0][1]=-prices[0];

4.确定遍历顺序

i从0到prices.length-1,遍历完prices数组。

5.举例推导dp数组

略……

代码如下:

//未优化空间
class Solution {public int maxProfit(int[] prices, int fee) {int[][] dp=new int[prices.length][2];dp[0][0]=0;dp[0][1]=-prices[0];for(int i=1;i<prices.length;i++){dp[i][0]=Math.max(dp[i-1][0],dp[i-1][1]-fee+prices[i]);dp[i][1]=Math.max(dp[i-1][1],dp[i-1][0]-prices[i]);}return dp[prices.length-1][0];}
}

同样的,这题里的i的状态也是之和i-1状态有关,可以使用更少的空间:

//优化空间
class Solution {public int maxProfit(int[] prices, int fee) {int dp0=0,dp1=-prices[0];for(int i=1;i<prices.length;i++){int newdp0=Math.max(dp0,dp1-fee+prices[i]);int newdp1=Math.max(dp1,dp0-prices[i]);dp0=newdp0;dp1=newdp1;}return dp0;}
}

子序列问题

不连续子序列

力扣 300. 最长递增子序列

原题链接


1.确定dp数组,以及下标的含义

dp[i]表示nums数组中,以nums[i]结尾的最长上升子序列长度为nums[i];

2.确定递推公式(状态转移方程)

位置i的最长递增子序列等于j从0遍历到i-1各个位置的最长递增子序列 + 1 的最大值,因为:
①如果nums[i]>nums[j],说明nums[i]可以放在nums[j]后面,那么最长子序列长度就为dp[j]+1;

②如果nums[i]<=nums[j],则nums[i]不能放在nums[j]后面,不构成上升子序列,跳过(j++);

3.dp数组初始化

dp[i] 所有元素置 1,含义是每个元素都至少可以单独成为子序列,此时长度都为 1。

4.确定遍历顺序

dp[i]由0到i-1各个位置的最长升序子序列推导而来,所以从前向后遍历。

5.举例推导dp数组

代码如下:

class Solution {public int lengthOfLIS(int[] nums) {int[] dp=new int[nums.length];int res=1;for(int i=0;i<nums.length;i++)dp[i]=1;for(int i=1;i<nums.length;i++)for(int j=0;j<i;j++){if(nums[j]<nums[i])dp[i]=Math.max(dp[i],dp[j]+1);res=Math.max(res,dp[i]);}return res;}
}

力扣 1143. 最长公共子序列

原题链接

这题和718的区别在于,公共子序列是可以不连续的,所以在状态转移方程方面就有些不同,也就是在text1.charAt(i-1)和text2.charAt(j-1)不相同的时候,不是直接为0,而是要找text1或text2回退一个下标位置,所能获得的最长公共子序列的值。

1.确定dp数组,以及下标的含义

dp[i][j]:text1[0:i-1] 和 text2[0:j-1] 的最长公共子序列。 (注:text1[0:i-1] 表示的是 text1 的 第 0 个元素到第 i - 1 个元素,两端都包含)

之所以 dp[i][j] 的定义不是 text1[0:i] 和 text2[0:j] ,是为了方便当 i = 0 或者 j = 0 的时候,dp[i][j]表示的为空字符串和另外一个字符串的匹配,这样 dp[i][j] 可以初始化为 0.

2.确定递推公式(状态转移方程)

主要就是判断的text1[i-1]和text2[j-1]是否相等。

①text1[i-1]==text2[j-1]时,说明两个子字符串的最后一位相等,所以最长公共子序列又增加了 1,所以 dp[i][j] = dp[i - 1][j - 1] + 1;

②text1[i - 1] != text2[j - 1] 时,说明两个子字符串的最后一位不相等,因为这一位两字符不相等,所以要么是text1考虑全,text2这一位不考虑;要么text2考虑全,text1这一位不考虑;二者情况取最大值,那么此时的状态 dp[i][j] 应该是 dp[i - 1][j] 和 dp[i][j - 1] 的最大值。

3.dp数组初始化

当 i = 0 时,dp[0][j] 表示的是 text1中取空字符串 跟 text2 的最长公共子序列,结果肯定为 0.

当 j = 0 时,dp[i][0] 表示的是 text2中取空字符串 跟 text1 的最长公共子序列,结果肯定为 0.

4.确定遍历顺序

i 和 j 的遍历顺序肯定是从小到大的

5.举例推导dp数组
代码如下:

class Solution {public int longestCommonSubsequence(String text1, String text2) {int[][] dp=new int[text1.length()+1][text2.length()+1];for(int i=1;i<=text1.length();i++){char c1=text1.charAt(i-1);for(int j=1;j<=text2.length();j++){char c2=text2.charAt(j-1);if(c1==c2)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[text1.length()][text2.length()];}
}

可以从递推关系看出,dp[i][j]只和dp[i-1][j-1]、dp[i-1][j]、dp[i][j-1]有关系,也就是二维数组的左上、正上方、左方三个数据相关;

其中上方数据在滚动数组中是可以直接使用的,并且左方的数据由于j是从小到大按顺序遍历,在一维滚动数组中不会产生被新数据覆盖的问题,也可以直接使用;

唯一要注意的就是左上方的数据!!!

举例:在当前行i=2遍历的时候,比如此时遍历到j=3,dp[3]的值通过递推公式已经确定了,在j+1=4的时候,如果直接使用一维数据的dp[4]=dp[j-1]+1=dp[3]+1,这里使用的dp[3],就不是逻辑上的“左上方”了,而是在这一层遍历中刚刚确定了值的“左方”的数据;

因此,在每一层遍历中,需要设置一个中间变量,用于暂存“左上方”的数据,以便后面递推公式的时候可以直接使用。

代码如下:

//优化空间(O(n))
class Solution {public int longestCommonSubsequence(String text1, String text2) {int[] dp=new int[text2.length()+1];for(int i=1;i<=text1.length();i++){//在每一层中,upLeft扮演dp[i-1][j-1](左上)的角色//每一层的开始初始化为dp[0](最左边的值)int upLeft=dp[0];for(int j=1;j<=text2.length();j++){//在这一层中,用cur记录当前的dp[j](相当于记住dp[i-1][j])//用于更新这一层遍历中,dp[j+1]会用到的“左上”的值int cur=dp[j];if(text1.charAt(i-1)==text2.charAt(j-1))//这里千万不能是dp[j-1]+1,因为dp[j-1]就是上一次的dp[j],值可能被修改过了//所以用了upLeft来暂存//upLeft相当于dp[i-1][j-1]dp[j]=upLeft+1;else//这里可以直接压缩,而不用中间变量暂存//是因为左边和上方的不会被覆盖掉,所以直接用//括号里的dp[j]相当于dp[i-1][j];dp[j-1]相当于dp[i][j-1]  dp[j]=Math.max(dp[j],dp[j-1]);//更新dp[i-1][j-1],为本层循环遍历中的下一个j(j+1)做准备upLeft=cur;}}return dp[text2.length()];}
}

此外,还通过他人的题解发现,先将字符串转字符数组,再进行遍历,在力扣中效率会快得多,代码如下:

//先转字符数组
class Solution {public int longestCommonSubsequence(String text1, String text2) {char[] char1=text1.toCharArray();char[] char2=text2.toCharArray();int[] dp=new int[text2.length()+1];for(int i=1;i<=char1.length;i++){int upLeft=dp[0];for(int j=1;j<=char2.length;j++){int cur=dp[j];if(char1[i-1]==char2[j-1])dp[j]=upLeft+1;elsedp[j]=Math.max(dp[j],dp[j-1]);upLeft=cur;}}return dp[text2.length()];}
}

力扣 1035. 不相交的线

原题链接


这题其实和1143. 最长公共子序列差不多,要在nums1和nums2里面找一样的数字连线,又要求连线不能相交,其实就是找两个数组的公共子序列(不需要连续),只要找出最长的公共子序列,那么它的长度就是最多的连线数。

1.确定dp数组,以及下标的含义

dp[i][j]:nums1[0:i-1] 和nums2[0:j-1] 的最长公共子序列。 (注:nums1[0:i-1] 表示的是 nums1 的 第 0 个元素到第 i - 1 个元素,两端都包含)

之所以 dp[i][j] 的定义不是nums1[0:i] 和nums2[0:j] ,是为了方便当 i = 0 或者 j = 0 的时候,dp[i][j]表示的为空数组和另外一个数组的匹配,这样 dp[i][j] 可以初始化为 0.

2.确定递推公式(状态转移方程)

主要就是判断的text1[i-1]和text2[j-1]是否相等。

①nums1[i-1]==nums2[j-1]时,说明两个子字符串的最后一位相等,所以最长公共子序列又增加了 1,所以 dp[i][j] = dp[i - 1][j - 1] + 1;

②nums1[i - 1] !=nums2[j - 1] 时,说明两个子字符串的最后一位不相等,那么此时的状态 dp[i][j] 应该是 dp[i - 1][j] 和 dp[i][j - 1] 的最大值。

3.dp数组初始化

当 i = 0 时,dp[0][j] 表示的是 nums1 中取空数组 跟nums2的最长公共子序列,结果肯定为 0.

当 j = 0 时,dp[i][0] 表示的是nums2中取空数组 跟nums1的最长公共子序列,结果肯定为 0.

4.确定遍历顺序

i 和 j 的遍历顺序肯定是从小到大的

5.举例推导dp数组
略……

代码如下:

class Solution {public int maxUncrossedLines(int[] nums1, int[] nums2) {int[][] dp=new int[nums1.length+1][nums2.length+1];for(int i=1;i<=nums1.length;i++)for(int j=1;j<=nums2.length;j++){if(nums1[i-1]==nums2[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[nums1.length][nums2.length];}
}

连续子序列

力扣 674. 最长连续递增序列

原题链接


这题只比上一题多了一个子序列必须是连续的限制,加了这个限制,对求解其实是简化了。

动态规划解法:

1.确定dp数组,以及下标的含义

dp[i]表示以nums[i]结尾的最长连续递增子序列的长度为dp[i];

2.确定递推公式(状态转移方程)

当nums[i]>nums[i-1]的时候,说明nums[i]可以接在nums[i-1]后面,成为连续递增子序列,此时dp[i]=dp[i-1]+1;

3.dp数组初始化

dps数组初始时应该都为1,表示每个nums[i]自身都为一个长度为1的递增子序列。

4.确定遍历顺序

这题因为是求连续的递增子序列,一次正向遍历就足够遍历完所有状态。

5.举例推导dp数组

代码如下:

//动规
class Solution {public int findLengthOfLCIS(int[] nums) {int[] dp=new int[nums.length];int res=1;for(int i=0;i<nums.length;i++)dp[i]=1;for(int i=1;i<nums.length;i++){if(nums[i]>nums[i-1])dp[i]=dp[i-1]+1;res=Math.max(res,dp[i]);}return res;}
}

此外,这题用贪心也可以快速求解,只要设置count在遍历过程中记录递增子序列长度,并用max记录最大的count,中间碰到不连续的,把count重新置1再继续遍历即可。

代码如下:

//贪心
class Solution {public int findLengthOfLCIS(int[] nums) {int count=1;int max=1;for(int i=1;i<nums.length;i++){if(nums[i]>nums[i-1]){count++;max=Math.max(max,count);}else count=1;} return max;}
}

力扣 718. 最长重复子数组

原题链接

这题最容易想到的暴力解法就是第一重for循环用i遍历A数组,第二重for循环用j遍历B数组,在二重循环里面再用一个k记录每一对i和j的最长重复子数组,这样的时间复杂度是O(n3),这样明显时间会超限。

这时候就可以尝试用动态规划了。

1.确定dp数组,以及下标的含义

dp[i][j]:以下标i-1结尾的数组A和以下标j-1结尾的数组B的最长的重复子数组长度为dp[i][j]。

为啥是i-1和j-1,而不是i和j呢,因为递推的时候,是需要判断nums1[i-1]和nums2[j-1]是否相等的,如果相等,那么dp[i][j]=dp[i-1][j-1]+1。这样只是为了方便递推

2.确定递推公式(状态转移方程)

从1 的解释可以得出dp[i][j]=dp[i-1][j-1]+1(建立在nums1[i-1]==nums2[j-1]的基础上)。

注意,遍历的时候i和j要从1开始(否则i-1和j-1超限)

3.dp数组初始化

从递推公式和dp数组含义出发,i或j为0的时候,显然没有意义,但是为了递推公式顺利进行,dp[i][0]和dp[0][j]就初始化为0。

4.确定遍历顺序

从dp数组含义来说,i和j都是从1开始,到数组AB末尾结束的

5.举例推导dp数组

代码如下:

class Solution {public int findLength(int[] nums1, int[] nums2) {int res=0;int[][] dp=new int[nums1.length+1][nums2.length+1];for(int i=1;i<=nums1.length;i++)for(int j=1;j<=nums2.length;j++){if(nums1[i-1]==nums2[j-1])dp[i][j]=dp[i-1][j-1]+1;res=Math.max(res,dp[i][j]);}return res;}
}

从前面的dp状态推导可以看出,每一个dp[i][j]都是dp[i-1][j-1]推出,那么就可以压缩成一维数组,即dp[j]由d[j-1]推出,这样就相当于把上一层的数据拷贝到下一层使用。

需要注意的是,内部for循环遍历数组B 的时候,要从后往前遍历,否则会重复覆盖数值,影响下一轮的赋值。

另外,由于数据只有一维,不像二维那样可以记录每一层的情况,遇到nums1[i-1]!=nums2[j-1]的时候,要将dp[j]置0,如果不置0,再往下几层的时候,这一层可能就用成了前面几层的非0数据。

//空间优化
class Solution {public int findLength(int[] nums1, int[] nums2) {int res=0;int[] dp=new int[nums2.length+1];for(int i=1;i<=nums1.length;i++)for(int j=nums2.length;j>=1;j--){if(nums1[i-1]==nums2[j-1])dp[j]=dp[j-1]+1;else dp[j]=0;res=Math.max(res,dp[j]);}return res;}
}

力扣 53. 最大子数组和

原题链接

贪心思想

这题之前已经用贪心思想做过了,大体就是在for循环遍历过程的时候,用一个count记录累加和,再用一个maxSum记录count的最大值,当count<0的时候,说明加上这个nums[i]会拖累前面的整体,那这个肯定不能加,那就要从nums[i+1]重新累加计算count,最后返回maxSum即可。

动态规划:

1.确定dp数组,以及下标的含义

dp[i]:以nums[i]结尾的最大连续子序列和为dp[i];

2.确定递推公式(状态转移方程)

其实只有两种情况,就是nums[i]是加入连续子数组,还是nums[i]另起炉灶,重新开始累加。前面的dp[i-1]和0的关系,有两个方面可以得到dp[i]:

①如果dp[i-1]>0,那么把nums[i]加入前面的连续子数组,dp[i]=dp[i-1]+nums[i]
②如果dp[i-1]<=0,那么nums[i]会被前面拖累,它直接另起炉灶,重新累加;

即dp[i]=max(nums[i],dp[i-1]+nums[i]);

3.dp数组初始化

dp[0]=nums[0];

4.确定遍历顺序

从前往后

5.举例推导dp数组

代码如下:

class Solution {public int maxSubArray(int[] nums) {int[] dp=new int[nums.length];dp[0]=nums[0];int res=dp[0];for(int i=1;i<nums.length;i++){dp[i]=Math.max(dp[i-1]+nums[i],nums[i]);res=Math.max(res,dp[i]);}return res;}
}

这里的每个dp[i]只和dp[i-1]有关系,可以只用一个变量 pre 来维护对于当前 f(i)的 f(i−1) 的值是多少,从而让空间复杂度降低到 O(1):
代码如下:

//动规:优化空间O(1)
class Solution {public int maxSubArray(int[] nums) {int pre = nums[0], maxSum = nums[0];for (int i=1;i<nums.length;i++) {//pre相当于f(i),不过此题计算f(i)只需要知道f(i-1)与nums[i]即可//所以不用把所有的f(i)记录,只需记录前一个,然后更新覆盖即可pre = Math.max(pre + nums[i], nums[i]);maxSum = Math.max(maxSum, pre);}return maxSum;}
}

编辑距离

力扣 392. 判断子序列

原题链接

双指针
可以设置sIndex和tIndex两个指针,分别指向字符串s和t的起始位置,只有当s和t的指针指向的字符是一样的时候,sIndex才向后移动一步,而tIndex是固定每次移动一步。
代码如下:

//双指针
class Solution {public boolean isSubsequence(String s, String t) {int sIndex=0,tIndex=0;while(sIndex<s.length()&&tIndex<t.length()){if(s.charAt(sIndex)==t.charAt(tIndex))++sIndex;++tIndex;}return sIndex==s.length();}
}

动态规划

1.确定dp数组,以及下标的含义

dp[i][j]:以下标i-1结尾的字符串s和以下标j-1结尾的字符串t的公共子序列长度为dp[i][j];

2.确定递推公式(状态转移方程)

只需要考虑s[i-1]与t[j-1]是否相等即可:

①s[i-1]=t[j-1],意味着s与t的公共子序列长度在原来的基础上+1,即dp[i][j]=dp[i-1][j-1]+1;

②s[i-1]!=t[j-1],当前遍历到的字符不相同,那么公共子序列长度其实和s的索引不变,t的索引回退一步,结果是一样的,所以此时的dp[i][j]=dp[i][j-1];

3.dp数组初始化

从递推公式可知,dp[0][0]和dp[i][0]都需要初始化,当i或j为0时,dp数组的值是没有实际意义的,不过为了递推公式的进行,都初始化为0;

4.确定遍历顺序

递推公式中,dp[i][j]都和dp[i-1][j-1]以及dp[i][j-1]有关,所以需要从上到下,从左到右遍历二维数组;

5.举例推导dp数组

代码如下:

//动态规划
class Solution {public boolean isSubsequence(String s, String t) {int[][] dp=new int[s.length()+1][t.length()+1];for(int i=1;i<=s.length();i++)for(int j=1;j<=t.length();j++){if(s.charAt(i-1)==t.charAt(j-1))dp[i][j]=dp[i-1][j-1]+1;else dp[i][j]=dp[i][j-1];}return dp[s.length()][t.length()]==s.length();}
}

力扣 115. 不同的子序列

原题链接

1.确定dp数组,以及下标的含义

dp[i][j]:以s[i-1]结尾的s的子序列(可以不连续)中,出现以t[j-1]结尾的t的子序列的个数

注意这题说的是个数,而不是长度,这个在递推公式里面会有所体现。

2.确定递推公式(状态转移方程)

还是分为两种情况,只看s[i-1]能否和t[j-1]匹配上:

s[i-1]==t[j-1],一定要注意,这里面其实也有两种情况而且两种情况是相加 的关系,例如: s:bagg 和 t:bag ,s[3]=g 和 t[2]=g是相同的,但是字符串s也可以不用s[3]来匹配,即用s[0]s[1]s[2]组成的bag。

当然也可以用s[3]来匹配,即:s[0]s[1]s[3]组成的bag。

······s[i-1]用于匹配t[j-1]:个数为dp[i - 1][j - 1](因为dp数组值表示的是出现的个数,就算匹配上,但是双方是同时匹配上的,所以匹配上的个数还是没变,等于上一个状态的dp[i-1][j-1])

······s[i-1]不用于匹配t[j-1]:个数为dp[i - 1][j](因为不用s[i-1]进行匹配,这种情况的个数,等效于s的索引为i-2,j的索引不变的个数)

所以dp[i][j] = dp[i - 1][j - 1] + dp[i - 1][j];(是否用于匹配,s和t的这一位字符都已经相等了,只是多了可能性,所以个数是相加)

s[i-1]!=t[j-1]:s和t的i-1位字符不相同,是无法用于匹配的,那么s的这一位就不参与匹配,等效于i回退一位到i-2,j不用回退,保持j-1,最终个数为dp[i - 1][j]

3.dp数组初始化

从递推公式看出dp[i][0]和dp[0][j]都需要初始化

dp[i][0]:s中以s[i-1]为结尾的子序列中,出现空字符串的个数,显然是1,因为只有空字符串=空字符串这个一种。

dp[0][j]:s中取空字符串,出现t中以t[j-1]结尾的子序列的个数,显然是0个,因为空字符串怎么都取不出别的子序列。

还需要特别注意dp[0][0],s的空字符串中出现t中的空字符串的个数,显然是1。

4.确定遍历顺序

dp数组从上到下,从左到右;

5.举例推导dp数组

代码如下:

//动态规划(时间O(mn),空间O(mn))
class Solution {public int numDistinct(String s, String t) {int[][] dp=new int[s.length()+1][t.length()+1];for(int i=0;i<=s.length();i++)dp[i][0]=1;for(int i=1;i<=s.length();i++)for(int j=1;j<=t.length();j++){if(s.charAt(i-1)==t.charAt(j-1))dp[i][j]=dp[i-1][j-1]+dp[i-1][j];else dp[i][j]=dp[i-1][j];}return dp[s.length()][t.length()];}
}

当然,dp数组状态是可以利用滚动数组压缩的,定义一个长度为t.length()+1的dp一维数组,如果s[i-1]=t[j-1],dp[i][j]=dp[i-1][j-1]+dp[i-1][j];就可以优化为dp[j]+=dp[j-1]; ,如果不相等,原来是:dp[i][j]=dp[i-1][j];,也就是等于上一行同一列的值,换成一维dp数组,就相当于保留原来的值,无需加语句。

不过需要注意的是原来双重for循环内层对t的遍历是正向的,改成一维dp后,需要逆向遍历t,这是为什么呢?

核心原因是:dp[j]+=dp[j-1];这一句,原来二维dp中,当前行的值是通过上一行数据相加的,正序不会影响当前行数据正确性,但是用了一维dp后,dp[j]在自身基础上,还需要加上dp[j-1],但是要注意的是,此时的dp[j],在j++后,就相当于下一个j的j-1了,也就是下一个dp[j]会被当前已经改变了值的dp[j]影响,这在二维dp中是不会出现的。

代码如下:

//空间优化O(s.length())
class Solution {public int numDistinct(String s, String t) {int[] dp=new int[t.length()+1];dp[0] = 1;for(int i=1;i<=s.length();i++){ for(int j=t.length();j>0;j--){if(s.charAt(i-1)==t.charAt(j-1)) dp[j]+=dp[j-1];}}return dp[t.length()];}
}

力扣 583. 两个字符串的删除操作

原题链接

这题有两种解法:

一、最长公共子序列

这题要使两个字符串经过删除后相同,并且删除的步数最少。这也就是要求删除后剩余的子串是它们两个的最长的公共子序列,这样剩下的字符尽可能多,删除的步数也就最少,所以问题就回到了1143. 最长公共子序列这里,用1143的方法求出最长公共子序列的长度后,再将word1和word2的长度分别减去最长公共子序列长度,相加就是最终所求最少步数。

1143. 最长公共子序列的具体求解在本篇目录可找到,不再做赘述。

代码如下:

//最长公共子序列(时间O(mn),空间O(mn))
class Solution {public int minDistance(String word1, String word2) {int[][] dp=new int[word1.length()+1][word2.length()+1];for(int i=1;i<=word1.length();i++)for(int j=1;j<=word2.length();j++){if(word1.charAt(i-1)==word2.charAt(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 word1.length()+word2.length()-2*dp[word1.length()][word2.length()];}
}

当然,把字符串先转成数组再遍历、二维dp降一维优化都是可以做的,优化思路也是参考本篇1143题的解法:

//最长公共子序列(优化版)
class Solution {public int minDistance(String word1, String word2) {char[] char1=word1.toCharArray();char[] char2=word2.toCharArray();int len1=word1.length();int len2=word2.length();int[] dp=new int[len2+1];for(int i=1;i<=len1;i++){int upLeft=dp[0];for(int j=1;j<=len2;j++){int cur=dp[j];if(char1[i-1]==char2[j-1])dp[j]=upLeft+1;else dp[j]=Math.max(dp[j],dp[j-1]);upLeft=cur;}}return len1+len2-2*dp[len2];}
}

其实这题用最长子序列的优化版本已经可以做到时间O(mn),空间O(n),m为word1长度,n为word2长度,效率也很不错,那为什么还要再做一种针对这题的动态规划解法呢?

因为后面有一题编辑距离的题目,属于这种题目的进阶版,所以先弄懂这题会对后面有帮助。

二、直接动态规划

1.确定dp数组,以及下标的含义

dp[i][j]:表示以word1[i-1]结尾的字符串word1,和以word2[j-1]结尾的字符串word2,想要相等,所需要的最少删除操作次数。

之所以dp[i][j]表示i-1和j-1结尾,是为了给空字符串留下dp[i][0]和dp[0][j]方便初始化;

2.确定递推公式(状态转移方程)

这类题目基本都有一个共同点,那就是考虑递推公式的时候,基本都是根据当前遍历到的两个字符/数字是否相等进行状态区分:

  • word1[i-1]==word2[j-1],当它们相同的时候,说明是公共字符,不用进行删除,所以最少删除操作次数可以不变,保持dp[i-1][j-1]就行了,即dp[i][j]=dp[i-1][j-1];

  • word1[i-1]!=word2[j-1],当它们不同的时候,有两种情况:
    ①删除word1[i-1],那么相当于在“使i-2和j-1结尾的字符串相等状态,所需最少删除操作次数”的基础上+1次删除word1[i-1]的操作,即dp[i-1][j]+1;
    ②删除word2[j-1],相当于在“使i-1和j-2结尾的字符串相等状态,所需最少删除操作次数”的基础上+1次删除word2[j-1]的操作,即dp[i][j-1]+1;

两种情况取最小值,即dp[i][j]=Math.min(dp[i-1][j],dp[i][j-1])+1;

3.dp数组初始化

dp[i][0]:word2为空串时,word1需要删除的字符数量就是i

dp[0][j]:同理,word1为空串,word2需要删除字符数量是j

4.确定遍历顺序

递推公式得:从上到下,从左到右

5.举例推导dp数组

代码如下:

//直接动态规划
class Solution {public int minDistance(String word1, String word2) {int len1=word1.length();int len2=word2.length();char[] char1=word1.toCharArray();char[] char2=word2.toCharArray(); int[][] dp=new int[len1+1][len2+1];//初始化for(int i=0;i<=len1;i++)dp[i][0]=i;for(int j=0;j<=len2;j++)dp[0][j]=j;//遍历dp数组for(int i=1;i<=len1;i++)for(int j=1;j<=len2;j++){if(char1[i-1]==char2[j-1])dp[i][j]=dp[i-1][j-1];else dp[i][j]=Math.min(dp[i-1][j]+1,dp[i][j-1]+1);}return dp[len1][len2];}
}

这个解法同样也可以把二维压缩成一维进行空间优化,优化思想参考前面的1143题的优化方法:

//直接动态规划(优化空间版本O(n),n为word2长度)
class Solution {public int minDistance(String word1, String word2) {int len1=word1.length();int len2=word2.length();char[] char1=word1.toCharArray();char[] char2=word2.toCharArray(); int[] dp=new int[len2+1];//初始化第一行for(int j=0;j<=len2;j++)dp[j]=j;//遍历dp数组for(int i=1;i<=len1;i++){int upLeft=dp[0];dp[0]=i;//相当于dp[i][0](每一行的第一个)for(int j=1;j<=len2;j++){int cur=dp[j];if(char1[i-1]==char2[j-1])dp[j]=upLeft;else dp[j]=Math.min(dp[j],dp[j-1])+1;upLeft=cur;}}return dp[len2];}
}

不过有一点不一样,就是第一层for循环下的dp[0]=i;,因为这题对dp[i][0]和dp[0][j]都需要初始化,第0行的初始化已经在for循环前完成了。但是第0列—每行的第1个值也需要初始化,因此在每一行遍历开始时初始化:dp[0]=i;


力扣 72. 编辑距离

原题链接

这题是583. 两个字符串的删除操作的进阶版本,在只能通过删除操作使两个字符串一样的基础上,增加了插入和替换操作,同样是求最少的操作次数。

1.确定dp数组,以及下标的含义

dp[i][j]:以word1[i-1]为结尾的字符串word1,以word2[j-1]为结尾的字符串word2,想要成为相等的字符串,最少的操作次数为dp[i][j];

2.确定递推公式(状态转移方程)

同样是以word1[i-1]与word2[j-1]是否相等作为状态区分:

  • word1[i-1]==word2[j-1]:要使操作尽可能少,那么字符相同的情况下就不操作;这种时候dp[i][j]=dp[i-1][j-1];

  • word1[i-1]!=word2[j-1]:字符不相同时有三种操作可以选择:增(插入)、删、换;
    下面对这三种操作的理解来自力扣题解:原作者


所以,当word1[i-1]!=word2[j-1]时,有三种如下操作可能:

    • ①对word1进行插入操作:dp[i][j]=dp[i][j-1]+1; dp[i][j-1]表示word1前i个字符与word2前j-1个字符相等,最少需要dp[i][j-1]次操作,那么要使word2的前j个字符与word1前i个字符相等,只需要在原来基础上,在word1的末尾增加一个与word2第j个字符相同的字符即可;
    • ②对word2进行插入操作:dp[i][j]=dp[i-1][j]+1; 对word2的增加操作其实等价于对word1的删除操作。dp[i-1][j]表示word1前i-1个字符与word2前j个字符相等,最少需要dp[i-1][j]次操作,那么要使word1的前i个字符与word2前j个字符相等,只需要在原来基础上,在word2的末尾增加一个与word1第i个字符相同的字符即可;
    • ③对word1进行替换操作:dp[i][j]=dp[i-1][j-1]+1; dp[i-1][j-1]表示word1前i-1个字符与word2前j-1个字符相等,最少需要dp[i-1][j-1]次操作,那么要使word1的前i个字符与word2前j个字符相等,只需要在原来基础上将word1的第i个字符替换成word2的第j个字符;

所以,dp[i][j]=Math.min(dp[i-1][j-1],Math.min(dp[i][j-1],dp[i-1][j]))+1;

3.dp数组初始化

dp[0][j]:word1为空串,word2前j个字符,两者想要相等,最少需要经过j次操作(word1增加j个与word2相同的字符)。

dp[i][0]:word1前j个字符,word2为空串,两者想要相等,最少需要经过i次操作(word2增加i个与word1相同的字符)。

4.确定遍历顺序

dp[i][j]由dp[i-1][j-1]、dp[i][j-1]、dp[i-1][j]推出,所以从上到下,从左到右遍历二维数组。

5.举例推导dp数组

代码如下:

//动态规划(未优化空间O(mn))
class Solution {public int minDistance(String word1, String word2) {int len1=word1.length(),len2=word2.length();char[] char1=word1.toCharArray(),char2=word2.toCharArray();int[][] dp=new int[len1+1][len2+1];for(int i=0;i<=len1;i++)dp[i][0]=i;for(int j=0;j<=len2;j++)dp[0][j]=j;for(int i=1;i<=len1;i++)for(int j=1;j<=len2;j++){if(char1[i-1]==char2[j-1])dp[i][j]=dp[i-1][j-1];else dp[i][j]=Math.min(dp[i-1][j-1],Math.min(dp[i-1][j],dp[i][j-1]))+1;}return dp[len1][len2];}
}

这题同样可以进行一维化,原理和上一题相同,主要针对“左上”的数据进行保存,防止覆盖,代码如下:

//动态规划(优化空间O(n),n为word2长度)
class Solution {public int minDistance(String word1, String word2) {int len1=word1.length(),len2=word2.length();char[] char1=word1.toCharArray(),char2=word2.toCharArray();int[] dp=new int[len2+1];for(int j=0;j<=len2;j++)dp[j]=j;for(int i=1;i<=len1;i++){//先给upLeft赋值再给dp[0]初始化,因为upLeft指代的是左上方数据int upLeft=dp[0];dp[0]=i;for(int j=1;j<=len2;j++){int cur=dp[j];if(char1[i-1]==char2[j-1])dp[j]=upLeft;else dp[j]=Math.min(upLeft,Math.min(dp[j],dp[j-1]))+1;upLeft=cur;}}return dp[len2];}
}

同时发现,网上大部分观点认为在for循环中遍历string字符串时,使用charAt(i)比先用str.toCharArray()转字符数组,再遍历字符数组会更快,但是在力扣提交时结果是相反的。以及尽量不要在for循环中使用方法(如nums.length这样的),这样开销会更小。


回文

力扣 647. 回文子串

原题链接
首先明确题目要求的子串是连续的

双指针解法
这题用双指针会更快一些,双指针的思想大致是:枚举每一个可能的回文中心,然后用两个指针分别向左右两边拓展,当两个指针指向的元素相同的时候就拓展,否则停止拓展

需要注意的是,一个元素可以作为中心点,两个元素也可以:

//双指针(时间O(n^2),空间O(1))
class Solution {public int countSubstrings(String s) {int len=s.length();char[] str=s.toCharArray();int result=0;for(int i=0;i<len;i++){result+=extend(str,i,i,len);//中心点为一个result+=extend(str,i,i+1,len);//中心点为两个}return result;}//计算从每个位置向两端扩散可以public int extend(char[] str,int i,int j,int n){int res=0;while(i>=0&&j<n&&str[i]==str[j]){//字符相同就向两边扩展++res;--i;++j;}return res;}
}

当然也可以把两种情况合在一起,具体如何合在一起的,参考力扣官方题解:链接在此,代码如下:

class Solution {public int countSubstrings(String s) {int n = s.length(), ans = 0;for (int i = 0; i < 2 * n - 1; ++i) {int l = i / 2, r = i / 2 + i % 2;while (l >= 0 && r < n && s.charAt(l) == s.charAt(r)) {--l;++r;++ans;}}return ans;}
}

动态规划解法

1.确定dp数组,以及下标的含义

dp[i][j]:表示从下标i到下标j闭区间范围内的连续子串是否是回文串,如果是,dp[i][j]为true,否则为false;

2.确定递推公式(状态转移方程)

核心还是借用中心扩展的思想,即利用回文中心向两侧扩展,外侧的dp[i][j]利用内侧是否为回文串进行判断。

还是和之前差不多,用s[i]和s[j]是否相等进行区分:

  • s[i]!=s[j]:dp[i][j]=false;
  • s[i]==s[j]:有三种情形需要进行判断:
    • ①i=j,即下标相同,那么肯定是回文串,dp[i][j]=true;
    • ②i+1=j,即i与j相邻时字符相同,例如ss,肯定是回文串,dp[i][j]=true;
    • ③下标i与j相差大于1,如cabac,外侧的c相同,要判断它是否为回文串,就要看内侧的aba是不是回文串,也就是看i+1到j-1区间是不是回文串,也就是if(dp[i+1][j-1])dp[i][j]=true;

所以递推关系:

if(str[i]==str[j]){if(j-i<=1){dp[i][j]=true;++res;}else if(dp[i+1][j-1]){dp[i][j]=true;++res;}
}

3.dp数组初始化

初始全为false

4.确定遍历顺序

dp[i][j]依赖于dp[i+1][j-1],即依赖于二维数组当前位置“左下方”的数据,所以要从左下开始遍历完全部。i从s.length()开始,递减到0,而j要从i开始(因为要从中心向两边扩展)。

for(int i=len-1;i>=0;i--)for(int j=i;j<len;j++){}

5.举例推导dp数组

输入“aaa”

true的数量就是回文子串的数量。

代码如下:

class Solution {public int countSubstrings(String s) {int len=s.length();char[] str=s.toCharArray();boolean[][] dp=new boolean[len][len];int res=0;for(int i=len-1;i>=0;i--)for(int j=i;j<len;j++){if(str[i]==str[j]){if(j-i<=1){dp[i][j]=true;++res;}else if(dp[i+1][j-1]){dp[i][j]=true;++res;}}}return res; }
}

中间的判断语句可以整合到一起,更加简洁:

//动规简洁写法(时间O(n^2),空间O(n^2))
class Solution {public int countSubstrings(String s) {int len=s.length();char[] str=s.toCharArray();boolean[][] dp=new boolean[len][len];int res=0;for(int i=len-1;i>=0;i--)for(int j=i;j<len;j++){if((j-i<=1||dp[i+1][j-1])&&str[i]==str[j]){dp[i][j]=true;++res;}}return res; }
}

力扣 516. 最长回文子序列

原题链接

这题求的是回文子序列,和647. 回文子串是不一样的,647的回文子串是连续的,这题的子序列可以不连续。

直接动态规划解法

1. 确定dp数组,以及下标的含义

dp[i][j]

力扣刷题记录-动态规划问题总结相关推荐

  1. 力扣刷题记录--哈希表相关题目

    当遇到需要快速判断一个元素是否出现在集合里面的时候,可以考虑哈希法,牺牲一定的空间换取查找的时间. java常用的哈希表有HashMap.HashSet以及用数组去模拟哈希,这几种方法各有优劣. 数组 ...

  2. 力扣刷题记录-单调栈相关题目

    单调栈是指栈里的元素保持升序或者降序. 判别是否需要使用单调栈:通常是一维数组里面,需要寻找一个元素左边或者右边第一个比自己大或者小的元素的位置,则可以考虑使用单调栈:这样的时间复杂度一般为O(n). ...

  3. 力扣刷题记录-回溯算法相关题目

    首先介绍一下回溯算法 回溯通常在递归函数中体现,本质也是一种暴力的搜索方法,但可以解决一些用for循环暴力解决不了的问题,其应用有: 1.组合问题: 例:1 2 3 4这些数中找出组合为2的组合,有1 ...

  4. 力扣刷题记录--位运算问题

    这里写目录标题 一.n&(n-1) 1. 求一个数的二进制表示中的1的个数 力扣 191. 位1的个数 AcWing 801. 二进制中1的个数 2. 判断一个数是否是2的方幂 二.n& ...

  5. 力扣刷题记录_字符串(自学)

    字符串 一.字符串 1.反转字符串(力扣344) 2.反转字符串 II(力扣541) 3.替换空格(剑指 Offer 05) 4.翻转字符串里的单词(力扣151) 5.左旋转字符串(剑指 Offer ...

  6. python力扣刷题记录——204. 计数质数

    题目: 统计所有小于非负整数 n 的质数的数量. 方法一: 暴力法 class Solution:def countPrimes(self, n: int) -> int:count = 0if ...

  7. 力扣刷题记录---二分法

    一般的二分查找应用的地方都是在一个单调有序序列里面进行值的搜索,,用中间点进行区域划分,当中间值大于目标值target,说明目标值在左区域,反之则在右区域.这样不断缩小区域,每次搜索区域都只要当前范围 ...

  8. 力扣刷题记录---快排算法

    AcWing 785. 快速排序 对快排算法思想就不描述了,针对快排递归过程中边界的取值做了总结: x为每次递归中,选取的基准数(枢轴) 如果x = q[i]或者x = q[l + r >> ...

  9. 力扣刷题记录---归并排序

    AcWing 787. 归并排序 归并排序代码模板如下: /* 归并排序 时间O(nlogn),空间O(n) */import java.util.*; public class Main{publi ...

最新文章

  1. 21张让你代码能力突飞猛进的速查表(神经网络、线性代数、可视化等)
  2. 网站图片优化的小技巧分享
  3. Python 内建函数 max/min的高级用法
  4. [Android] 底部菜单布局+PopupWindows实现弹出菜单功能(初级篇)
  5. 每天一道LeetCode-----计算从二维数组的左上角到达右下角的所有路径数及最短的那条,如果存在障碍物时又是多少
  6. swagger 修改dto注解_Swagger 详解
  7. 背后的故事之 - 快乐的Lambda表达式(二)
  8. 喜马拉雅 Apache RocketMQ 消息治理实践
  9. break后面的语句还执行吗_12.python之配合循环的四种语句
  10. 张近东发致家乐福中国员工内部信:唯有坚持、坚守才能取得更大的成功
  11. 在cell中自定义分割线的小技巧
  12. 【渝粤教育】 广东开放大学21秋期末考试法律文书10684k2
  13. 关于计算机病毒的试题,计算机病毒测试题.doc
  14. 2018 Google 开发者大会.md
  15. 基于双语数据集搭建seq2seq模型
  16. 销售凭证、客户主数据
  17. 【转载】OceanBase架构介绍
  18. spark企业级电商分析平台项目实践(一)项目介绍和需求分析
  19. 如果你有个程序员男友,那么送这12 款键盘绝对不会错
  20. 安卓的app在所有应用商店上架方法整理

热门文章

  1. Ubuntu下终端进行移动文件的方法
  2. WiFi(Wireless Fidelity)基础(二)
  3. 程序员的职业规划(2)
  4. 无血清培养基的优缺点概述
  5. 野火STM32F103——Fat文件系统及Flash芯片W25Q64学习记录
  6. SQLServer之创建存储过程
  7. ctf--网络信息安全攻防实验室之基础关writeup
  8. STM32F207串口实验记录和接口
  9. C6678 DDR3 PLL
  10. Java基础:Java的记事本编写代码,使用cmd运行