本文为刷leecode的一点心得,由浅入深学习动态规划。
1.先来个经典而简单的:
我们都知道斐波拉契数列的典型算法:
f(1)=1;
f(2)=1;
f(n)=f(n-1)+f(n-2);
开始学习时,我们是使用递归算法来解决的
public int f(int n){
if (n==1)return 1;
if (n==2)return 1;
return f(n-1)+f(n-2);
}
写起来非常简单,但是它的执行过程是这样的:假设n=5
可见:其中的3,2,1结点都重复计算了,效率没有达到最高。
于是,科学家便想出了一种方法来记录已经算出来的数据,使得后续部分不用重复计算。
斐波拉契的每一项都是前两项相加,对于第n项,我们只需知道n-1项和n-2项即可知道。以此类推,我们创建一个数组dp,dp[i]用来表示第i项的值。
public int fdp(int n){
int[] dp=new int[n];
dp[0]=1;dp[1]=1;//初始化
for (int i=2;i<n;i++){
dp[i]=dp[i-1]+dp[i-2];
}
return dp[n-1];
}
2.爬楼梯问题:有n阶楼梯,每次只能爬1阶或者两阶,问有多少爬上最高的走法。
类比问题一,每次我们只有两种选择,爬一阶或者两阶。反过来看,在第n阶时,只能从第n-2或者n-1阶爬上来。所以到第n阶的方法数为到第n-2阶和到第n-1阶的方法数之和。
递归:
public int stairs(int n){
if (n==1)return 1;
if (n==2)return 2;
return stairs(n-1)+stairs(n-2);
}
与1中的递归大同小异,类似的,用dp数组来记录到第i阶的走法数:
public int stairsDp(int n){
int[] dp=new int[n+1];//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];
}
3.不同路径
此题目来源为leecode
同样的,要走到终点,每次只面临两种:向右或者向下。
在终点时,只能由上面的节点或者左边的节点走过来。所以,到终点的路径数为与到其相邻的上,左节点的路径数之和。
使用递归:
public int uniquePaths(int m,int n){
if (m<1||n<1)return 0;
if (m==1&&n==1)return 1;
return uniquePaths(m-1,n)+uniquePaths(m,n-1);
}
显然,有着大量的重复计算
想到用dp[i][j]来记录走到第i列第j行的路径数;
则dp[m][n]=dp[m-1][n]+dp[m][n-1];
这里,我们需要考虑一个问题:当机器人在最上面一行或者最左边一列时,该如何计算走到当前位置的路径数?
由图可知,在最上行时没一节点只能由左边节点走过来,所以最上行上面的节点只有一种路径可以走到。同理最左列的节点也只能由一种走法走到。
所以当初始化dp数组时应该将最上行和最左列置1.
public int uniquePathsDp(int m,int n){
int[][] dp=new int[m+1][n+1];//同样,使数组索引和第i,j对应起来
Arrays.fill(dp[1],1);//初始化第一行;
for (int i=1;i<=m;i++){
dp[i][1]=1;
}//初始化第一列
for (int i=2;i<=m;i++){//第一行和第一列都已经初始化了,所以直接从第二行、列开始。
for (int j=2;j<=n;j++){
dp[i][j]=dp[i-1][j]+dp[i][j-1];
}
}
return dp[m][n];
}
省去了重复节点的计算,效率也是显而易见的
未完待续哦!有时间在写后续的。
我们接着来:
4.凑钱问题:先描述一下问题:给你一组硬币,硬币的数值同数组表示coin[n],如2块,三块,五块,7块{2,5,7},求刚好凑够n元所需硬币的最少个数。例子:2,5,7凑27元,答案:5
按照我们之前的思路,我们依然可以用递归的方法写出来:拿2,5,7组合做例子。设置一个最小计数器min,每次我们选择硬币只有3种选择,将已选择的硬币加起来,达到27或者大于27就停止递归,找出所有结果中使用硬币最少数。
public int coinsp(int[] coins,int n){
return backstack(coins,n,n)-1;//多了一层递归所以减一
}
private static int backstack(int[] coins,int n,int last){
int min=n+1;
if (last==0)return 1;
if (last<0)return n+1;
for (int i=0;i<coins.length;i++){
min=Math.min(backstack(coins,n,last-coins[i]),min);
}
return min+1;
}
我们不难发现,这个问题同样的有最优子解,即组成n元的最少硬币数等于组成n元减去最后一个硬币数组的最少硬币数加一,即用2 5 7组成27元的最少硬币组合为5 5 5 5 7五个,则组成20元的最少硬币组合为5 5 5 5 四个。
所以动态方程为:dp[n]=dp[n-coins[i]]+1;其中coins[i]为coins里的一个硬币数值。dp[n]则表示组成n元需要的最少硬币数。
现在想一下初始条件和边界情况以{2,5,7}为例:dp[0]当然为0,dp[1]=min{dp[1-2],dp[1-5],dp[1-7]}+1,这里出现了负数怎么判定呢?我们可以把为负数的先置-1,dp[1]=-1代表0不存在,dp[2]=min{dp[2-2],dp[2-5],dp[2-7]}+1负的去掉 得到dp[2]=1,dp[3]=min{dp[3-2],dp[3-5],dp[3-7]}+1,dp[3]=0,以此类推,最终得到dp[27]=min{dp[25],dp[22],dp[20]}
private int coinDp(int[] coins,int n){
int[] dp=new int[n+1];//注意n+1可以 dp[27]
dp[0]=0;
for (int i=1;i<=n;i++){
int min=n+1;//n+1 即使给的都是1元 也要n个
for (int coin:coins){
if (i-coin>=0&&dp[i-coin]>=0){//注意 dp[i-coin]为负数,不能取用
min=Math.min(dp[i-coin],min);
}
}
if (min==n+1)dp[i]=-1;//min没变说明i-coin小于0
else dp[i]=min+1;
}
for (int n1:dp){
System.out.println(n1);
}
return dp[n];
}
这是自低向上的方式 ,即由子问题得到大问题,还有一种使用递归的自顶向下的方式由大家自己探索吧!
5.跳跃游戏
和上题是不是很类似!所以我们在学习的过程中,不是要掌握每一道题目,而是要掌握每一类题目。
6.最长回文子串
其实这个问题的解法多种多样,动态规划也不是效率最高的,但却是动态规划的典型应用。
回顾一下,一般有着最优子解的问题(求最大,最少,最长等等)都可以用动态规划来解题。
对于这个问题,假设第i个到第j个字符组成的字符串为最长回文子串,那么第i+1个到j-1个字符组成的子串也为回文子串。
设dp[i][j]表示子串s(i,j)是否为回文子串,如果是,置dp[i][j]=1。
得到状态转移方程 if(s.charAt(i)==s.charAt(j))dp[i][j]=dp[i+1][j-1],
设置一个起始位置start 最大长度maxlength 如果dp[i][j]==1的话 说明s(i,j)是新的回文子串。此时比较maxlength与新回文子串的长度,如果大于maxlength就更新 maxlength与start的值。
**初始化条件与边界:**状态转移方程表示不出来的情况需要初始化,比 如
baabaa中最长回文子串aabaa是由b->aba->aabaa扩充出来的。 aabbaa
是由 bb->abba->aabbaa扩充出来的,所以初始情况有一个字符和两个相同字符两种情况。我们将这两种情况作为初始化操作填到dp数组里。
public String longestPalindrome(String s) {
if (s.isEmpty())return s;
int length=s.length();
int[][] dp=new int[length][length];
int start=0;
int maxlength=1;//最小回文子串长度为1 如 a
for(int i=0;i<length;i++){//初始化操作
dp[i][i]=1;//如baaa dp[1][1]即为a 为回文子串 置1
if (i+1<length&&s.charAt(i)==s.charAt(i+1)){//dp[i][i+1]即baaad dp[1][2]为aa回文子串 置1
dp[i][i+1]=1;
start=i;
maxlength=2;
}
}
for (int j=1;j<length;j++){//i代表子串起始位置 j代表子串末且从1开始
for (int i=0;i<length;i++){
if (s.charAt(i)==s.charAt(j)&&j-i>1){//转移方程
dp[i][j]=dp[i+1][j-1];
}
if (dp[i][j]==1&&maxlength<j-i+1){//更新maxlength与start条件
start=i;
maxlength=j-i+1;
}
}
}
return s.substring(start,start+maxlength);
}
7.正则表达式匹配,这是一道有难度的题,话不多说,上题;
面对此题,一开始我们会觉得无从下手,感觉怎么也和动态规划扯不到一块去。来一步一步的分析一下这个题目:
text为文本 pattern为表达式
我们从字符串的第一个字符开始往后匹配
1.当pattern(j)为普通字符串时,只需考虑text(i)与pattern(j)是否相等即可。如aaaa与aaab得到false。匹配一次就将第一个元素减去留下剩余的串,直到全部匹配完。
2.当pattern(j)为‘.’时,直接和s(i)匹配
3.当pattern(j)为时,这个时候要考虑的情况就复杂了。同样的我们把难题拆解逐步击破
①text(i)==pattern(j),匹配一次,减去text(i),pattern不变。如果是text=aaab和pattern=ab这样就能得到true,但如果是aaab和aab呢?a会把text中的所有的a匹配完,pattern中剩下的a无法匹配从而得到false。
②text(i)==pattern(j)或者不相等,匹配零次,text不变,pattern减去a*。
我们只能把①和②都试一下,判断①||②哪个能得出本该true的结果,运行过程如下图:
现在,我们已经把这个复杂的问题分析完毕了,显然匹配字符串是个按字符串索引连续的过程,儿每一步都会面临几种情况,我们自然而然的想到了用递归解决这个问题:
public boolean isMatch(String text, String pattern) {
if(pattern.isEmpty())return text.isEmpty();//最终的结果是pattern和text都为空返回true,否则返回false
boolean headMatched=!text.isEmpty()&&(text.charAt(0)==pattern.charAt(0)||pattern.charAt(0)=='.');
if (pattern.length()>=2&&pattern.charAt(1)=='*'){
//如果当前pattern的第二个字符为* 有两种匹配机制
//text不变,匹配0次pattern截去*和*之前的字符 pattern不变,text匹配一个字符并截去
return isMatch(text,pattern.substring(2))||(headMatched&&isMatch(text.substring(1),pattern));
}else{
//若pattern的第二个字符不为*,第一个字符匹配,则text,pattern各截去首字符
return headMatched&&isMatch(text.substring(1),pattern.substring(1));
}
}
这个算法本质并不难,边界处理一定要细心‘!
提交ok
呃。。。。。。。只击败了21.83%的用户。作为一个精益求精的程序员,我们应该做到完美高效。
我们思考一下效率低下的原因:①程序中用了大量的substring()方法,这个是比较耗时的②有冗余计算,呃。。鉴于个人原因,想不出有哪个地方有重复计算。
为了避免使用substring()我们用dp[i][j]来保存已经计算好的结果,由问题不难看出如果text和pattern匹配,那么他们相应规则的子串也匹配。通过子问题求解的方式我们自然而然的想到了动态规划。dp[i][j]中储存的是Boolean值,代表text的0到i的串和pattern的0到j的串是否匹配。由每个子问题,一步步求得母问题,这便是动态规划的核心。
我们回顾一下动态规划的几个步骤:
一、确定状态方程:dp[i][j]
二、找出状态转移方程:这个是核心中的核心:通过递归算法的分析,我们要知道dp[i][j]的值必须面临两种情况:
①dp[i][j]’’,又可按匹配机制分为两种情况:I.匹配零个字符串,此时pattern的匹配子串为pattern(0,j-2)text不变(0,i)所以dp[i+1][j+1]=dp[i+1][j-1](注意,dp的下标比字符串的下标大一个)。II.匹配一个字符串,所以text减一(0,i-1),pattern不变(0,j)。此时text(i)==pattern(j),即dp[i+1][j+1]=text(i)==pattern(j)&&dp[i][j+1].总得就是:dp[i+1][j+1]=dp[i+1][j-1]||(text(i)==pattern(j)&&dp[i][j+1])(可使用下图辅助理解,得到正解的过程)
②dp[i][j]!=’’,只需dp[i+1][j+1]=text(i)pattern(j)&&dp[i][j];
三、状态转移方程找好了,接下来就是找出初始条件和边界情况。
所谓初始条件就是状态方程算不出来,需要手工定义的条件。上面分析的只是针对dp[i+1][j+1]的情况,所以dp[i][j] i0或者j0的时候,状态转移方程算不出来。我们对其初始化:dp[0][0]:空串和空串为true,dp[i][0]非空串和空串默认为false,不用理会。dp[0][j]空串和非空串,只能像这种情况才能匹配:“ ” 和 a*。即当dp[0][j]==’'且dp[0][i-2]==true时,当前串才能匹配。
好了,分析的这么透彻,接下来就可以愉快的实现代码拉!
(此图以aaaab和aaab为例)
public boolean isMatch(String text, String pattern) {
boolean[][] dp=new boolean[text.length()+1][pattern.length()+1];
dp[0][0]=true;
for (int k=2;k<=pattern.length();k++){//细节:k<=length;
dp[0][k]=pattern.charAt(k-1)=='*'&&dp[0][k-2];
}
for (int i=0;i<text.length();i++){
for (int j=0;j<pattern.length();j++){
if (pattern.charAt(j)=='*'){
dp[i+1][j+1]=dp[i+1][j-1]||(headMatched(i,j-1,text,pattern)&&dp[i][j+1]);//注意,细节headMatched(i,j-1,text,pattern)&&dp[i][j+1],j-1为*之前的字符
}else {
dp[i+1][j+1]=headMatched(i,j,text,pattern)&&dp[i][j];
}
}
}
return dp[text.length()][pattern.length()];
}
private boolean headMatched(int i,int j,String text,String pattern){//判断当前字符是否匹配
return text.charAt(i)==pattern.charAt(j)||pattern.charAt(j)=='.';
}
是不是效率很good了呢!
8.编辑距离,据说这是是腾讯的一道笔试题目:
这题与上题也是相似的,用dp[i][j]表示word1(0,i)到word2(0,j)的最少变换次数,初始化的也是空串的情况(即第一行和第一列)。
如下表所示
有三种操作形式:
①替换字符
例如 good 与 poor (3,3) 将d与r替换需要一步 则需要知道goo变成poo(2,2)需要的最少步数,所以状态转移方程wei:dp[i][j]=dp[i-1][j-1]+1;
②添加字符
例如:goo 变成 poor(2,3)添加 r 需要一步 则需要知道goo与poo之间最小的编辑距离(2,2)所以状态转移方程为:dp[i][j]=dp[i][j-1]+1;
③删除字符
例如:goods 变成glad(4,3) 删除s需要一步 则需要知道good与glad之间的最小编辑距离(3,3) 所以状态转移方程为:dp[i][j]=dp[i-1][j]+1;
public int minDistance(String word1, String word2) {
int length1=word1.length();
int length2=word2.length();
int[][] dp=new int[length1+1][length2+1];
dp[0][0]=0;
for (int j=0;j<length2;j++){
dp[0][j+1]=j+1;
}//初始化word1=""的情况
for (int i=0;i<length1;i++){
dp[i+1][0]=i+1;
}//初始化word2=""的情况
for (int i=0;i<length1;i++){
for (int j=0;j<length2;j++){
if (word1.charAt(i)==word2.charAt(j))dp[i+1][j+1]=dp[i][j];//当前字符相等不需要操作
else dp[i+1][j+1]=Math.min(dp[i][j],Math.min(dp[i][j+1],dp[i+1][j]))+1;//比较找出子串最小编辑距离
}
}
return dp[length1][length2];
}
附:单调栈问题,里面有接雨水和最长的有效括号动态规划与栈的解法。