动态规划(Dynamic Programming)
一个简单题目
爬楼梯
来源:力扣
难度: 简单
AC时间:<10 min
题目:
假设你正在爬楼梯。需要 n 阶你才能到达楼顶。每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?
这个题目应该大部分人很熟悉,应该是一道小学的数学题,小学的题目给了一个固定的 n n n,比如10级台阶,当时的解法是从第三阶开始,爬楼梯的方法等于前两阶的和,实际上就是下面要讲的动态规划。
当然,我们先从一个更简单的递归思路出发,如果我们需要知道第 n n n阶台阶的爬法数目,我们又知道爬到第 n − 1 n-1 n−1级的爬法数目,所有爬到第 n − 1 n-1 n−1级的爬法再往上爬一级都能爬到第 n n n级;同样的,我们如果知道爬到第 n − 2 n-2 n−2级的爬法数目,这些爬法再往上爬2级都能爬到第 n n n级。
同时从第 n − 1 n-1 n−1级爬到第 n n n级的最后一步是爬一级台阶,从第 n − 2 n-2 n−2级爬到第 n n n级的最后一步是爬两级台阶,所以这两种爬法不会重复,因而:爬第 n n n级的爬法数量 = 爬第 n − 1 n-1 n−1级的爬法数量 + + + 爬第 n − 2 n-2 n−2级的爬法数量。
递归表达式有了,我们只需要一个递归的终止条件,显然:爬一级台阶只有一种爬法,爬二级台阶有两种爬法,这就是递归的终止条件。
递归代码如下:
class Solution {
public int climbStairs(int n) {
if(n == 1 || n == 2 )
return n;
else
return climbStairs(n-1) + climbStairs(n-2);
}
}
不足:
如果稍微对这段代码进行分析,就会发现,代码重复计算了很多东西:
例如当 n = 20 n=20 n=20 时,第一次递归时计算了 n = 19 n=19 n=19 和 n = 18 n=18 n=18 的爬法数量。但是当我们计算 n = 19 n=19 n=19 的爬法数量时,又会再计算一次 n = 18 n=18 n=18 的爬法数量。这样算法会耗费很多时间来计算重复的项,显然还有优化的空间。
改进1:
一个比较直观的想法是,每次我计算出了爬到第
k
k
k 级台阶的爬法数量的时候,我拿个小本本记录下来,等到下次再计算到相同的台阶爬法数量的时候,我就可以从小本本里把这个结果拿出来,不用再费力地去计算了。这其实就是优化递归的一种 备忘录 方法。
改进2:
另外一种想法是,这题目好像很像斐波那契数列,那我可不可以把从第
1
1
1 级开始,把每一项都计算出来呢?显然是可以的!我们可以使用一个数组 dp,将第0项和第1项初始化,根据斐波那契数列的递推式,计算并记录每一项的值。
改进3:
我们发现改进2还是需要使用数组来储存结果,有没有办法不储存这么多结果呢?有!既然每一项只依赖与它之前的两项,那么我们每次就记录最后的两项,再用它去递推下一项,并且用递推的结果来更新我们的记录即可。
class Solution {
public int climbStairs(int n) {
int begin1 = 1;
int begin2 = 2;
int num = 2;
while(num<n)
{
begin2 += begin1;
begin1 = begin2- begin1;
num++;
}
if( n == 1 )
return 1;
else
return begin2;
}
}
这改进2和改进3都可以称之为动态规划。
一个稍微复杂一丁点的题目
看了上面的解题,是不是觉得动态规划也不是很复杂,下面可以稍微尝试一下复杂一丁点的题目。
最长回文子串
来源:力扣
难度:中等
AC时间:45-60分钟
给定一个字符串 s,找到 s 中最长的回文子串。你可以假设 s 的最大长度为 1000。
分析
显而易见,任何一个长度为 1 1 1的字符串都是回文串,而任何一个长度为2的字符串是回文串的条件是两个字符是相同的。
而对于任何长度超过2(即 ≥ 3 \ge3 ≥3)的回文串,如果把其第一个字符和最后一个字符同时删除,剩下的字符串也必然是一个回文串;同样地,如果一个回文串,在它的头部和尾部同时添加一个相同的字符,也能构成一个新的回文串。
上面的结论结合起来,已经可以构成所有的回文子串了(想一想为什么?),接下来就是找一个合适的方法来表达上面的结论了。
方法
我们使用一个二维数组dp[i][j],来储存字符串中由第 i 个字符到第 j 个字符构成的子串是不是回文子串,如果不是,数组值置0,如果是,数组值置子串长度(即 j − i + 1 j-i+1 j−i+1)。
显然每一个dp[i][i]的值都为1,dp[i][i+1]的值我们也可以通过遍历来进行 0 0 0 或者 2 2 2 的赋值。
接着,对于每一个dp[i][j],我们需要首先看一下dp[i+1][j-1]的值是否不为0,如果为0则将dp[i][j]也置0,如果不为0,再看一下第i个字符和第j个字符是否相同,不相同则置0,相同则置 dp[i+1][j-1]+2。
看起来很简单是不是,但是我们仔细想一想,如果要确保这个算法成功,我们需要保证在计算每一个dp[i][j]之前,dp[i+1][j-1]就已经被计算出来了。而如果用简单的两层循环遍历i和j的话,会出现dp[i+1][j-1]没有被计算出来的情况。
例如当 i = 5,j = 5时,即字符串中有5个字符时,我们有dp[0][0], dp[1][1],dp[2][2],dp[3][3],dp[4][4],dp[0][1], dp[1][2], dp[2][3], dp[3][4]。
如果用遍历i和j的两层循环时,我们首先计算dp[0][2]的时候,会用到dp[1][1],这没问题,因为dp[1][1]已经被计算出来了。我们计算dp[0][3]的时候,我们需要dp[1][2],也ok。但是下一步我们计算dp[0][4]的时候,发现dp[1][3]好像并没有被计算出来。
所以回到思路再想一想,发现我们应该根据字符串长度来进行一步一步计算。即首先初始化了长度为1和2的子串的值,再从长度 n = 3 n=3 n=3 开始,一步一步将所有长度为 n n n 的子串对于的数组中的值全部计算出来,才能计算下一长度为 n + 1 n+1 n+1 的子串对应的值。
代码
class Solution {
public String longestPalindrome(String s) {
int length = s.length();
if(length == 0)
return "";
int[][] dp = new int[length][length];
for(int i = 0 ; i<length ; i++){
for(int j = 0 ; j< length ; j++){
dp[i][j] = 0;
}
}
for(int i = 0 ; i<length ; i++){
dp[i][i] = 1;
}
for(int i = 0 ; i < length-1 ; i++){
if(s.charAt(i) == s.charAt(i+1))
dp[i][i+1] = 1;
}
for(int gap = 2 ; gap <length ; gap++){
for(int i = 0 ; i < length ; i++){
if(i+gap<length && dp[i+1][i+gap-1] != 0 && s.charAt(i) == s.charAt(i+gap))
dp[i][i+gap] = 1;
}
}
String answer = "";
int len = 0;
for(int i = 0 ; i<length ; i++){
for(int j = 0 ; j< length ; j++){
if(dp[i][j] != 0 && j-i+1 > len){
len = j-i+1;
answer = s.substring(i,j+1);
}
}
}
return answer;
}
}
其实这段代码有很多冗余,可以精简精简。使得逻辑更加清晰,代码更加简洁!