动态规划
这类题目有许多共同的特点,我们需要理解问题的含义,并且加以梳理和分解,我们先来看一道题目,大家都比较熟悉的爬楼梯的问题。
爬楼梯
题目描述:
假设你正在爬楼梯。需要 n 阶你才能到达楼顶。每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼梯顶呢?
这个时候我们就会想,如果只有2个台阶,我们就可以先爬一级再爬一级或者一次爬2级,所以有2种方法,但是如果台阶再多一些呢,100级?200级?显然我们不能从第一级开始推理,反过来,当我们还有最后一次机会时,会是以什么样的方式成功登顶呢,很容易想到:1,跨一级登顶 2,跨2级登顶。那么我们最后登顶的方法(N)就可以写成:(N-1)+(N-2)。同理,我们到N-1级的方法可以是:(N-2)+(N-3),而到N-2级的方法是:(N-3)+(N-4),这样我们可以推理出一个公式:
这时很容易想到使用递归来实现这个算法:
public int climb(int n) {
if(n == 1) return 1;//为1级台阶
if(n == 2) return 2;//2级台阶
return climb(n-1) + climb(n-2);//n级台阶=(n-1)级+(n-2)级
}
但是这个写法不建议使用,因为做了太多的重复计算,如下为计算n级台阶需要计算的其他的台阶数:
这个时候我们就可以用数据结构储存我们已经计算过的台阶,因此很容易想到1维数组:
class Solution {
public int climbStairs(int n) {
if(n<=2) return n;
int[] num=new int[n+1];//我们从1下标开始,所以要初始化比n大1
num[1]=1;//初始化1级台阶
num[2]=2;//初始化2级台阶
climb(num,n);
return num[n];
}
void climb(int[] num, int n){
if(n-1>2 && num[n-1]==0) climb(num, n-1);
if(n-2>2 && num[n-2]==0) climb(num, n-2);
num[n] = num[n-1]+num[n-2];
}
}
通过这种方法,我们的算法在效率上会高很多。
其实,这就是一种动态规划的算法思想。
下面我来说一说动态规划具体是怎么解决这类问题的:
在这之前现需要介绍一下动态规划;动态规划(dynamic programming)是运筹学的一个分支,是求解决策过程(decision process)最优化的数学方法。20世纪50年代初美国数学家R.E.Bellman等人在研究多阶段决策过程(multistep decision process)的优化问题时,提出了著名的最优化原理(principle of optimality),把多阶段过程转化为一系列单阶段问题,利用各阶段之间的关系,逐个求解,创立了解决这类过程优化问题的新方法——动态规划。1957年出版了他的名著《Dynamic Programming》,这是该领域的第一本著作。
其实当我们这类问题遇到多了之后,会发现它有一些共性:
- 最优化原理:如果问题的最优解所包含的子问题的解也是最优的,就称该问题具有最优子结构,即满足最优化原理。
- 无后效性:即某阶段状态一旦确定,就不受这个状态以后决策的影响。也就是说,某状态以后的过程不会影响以前的状态,只与当前状态有关。
- 有重叠子问题:即子问题之间是不独立的,一个子问题在下一阶段决策中可能被多次使用到。
通常我们需要创建一个一维数组或者二维数组来储存中间数据。然后通过将问题拆分成多个类似的小问题,定义问题状态和状态之间的关系,从而使得问题能够以递推(或者分治)的方式去解决;动态规划算法的基本思想与分治法类似,也是将待求解的问题分解为若干个子问题(阶段),按顺序求解子阶段,前一子问题的解,为后一子问题的求解提供了有用的信息。在求解任一子问题时,列出各种可能的局部解,通过决策保留那些有可能达到最优的局部解,丢弃其他局部解。依次解决各子问题,最后一个子问题就是初始问题的解。
接下来给一些具体的例子:
机器人迷宫
题目描述:
一个机器人位于一个 m x n 网格的左上角 。
机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角。
问总共有多少条不同的路径?
这种题目分析之后,发现属于动态规划的这类问题,因此先得出需要一个2维数组储存数据int[m][n]。机器人走的每一步可以由下走一步或者右走一步得来,因此反过来思考最后一步的得来,是由END格子上面的加上左边的方法次数相加,即int[m][n]=in[m-1][n]+int[m][n-1],可写出代码逻辑:
class Solution {
public int robotLoad(int m, int n) {
int[][] load=new int[m][n];
for(int i=0;i<m;i++){
for(int j=0;j<n;j++){
if(i==0) load[0][j]=1;
else if(j==0) load[i][0]=1;
else load[i][j] = load[i-1][j] +load[i][j-1];
}
}
return load[m-1][n-1];
}
}
但是,这样写算法的时间复杂度为O(n2),空间复杂度为O(n2),不是特别的理想。通过观察:
因此,我们可以得出结论:
可以将2维数组的int[m][n]=in[m-1][n]+int[m][n-1]公式用1维数组代替:int[n]=int[n]+int[n-1]
这样我们可以考虑把2维数组用1维数组代替,算法将变为:
class Solution {
public int robotLoad(int m, int n) {
int[] load=new int[n+1];
for(int i=0;i<m;i++){
for(int j=0;j<n;j++){
if(j==0 || i==0) load[j]=1;//边上的步数都为1
else load[j]+=load[j-1];
}
}
return load[n-1];
}
}
其实这个问题还有一种更简单的解法。
现在我们来仔细观察一下这个m*n的网格图,机器人要从最左上角走到最右下角,那么机器人的竖直方向上一定要走(n-1)步,在水平方向上一定会走(m-1)步,总共的步数一定是(m+n-2)步,这样我们就可以用排列组合来解决这个问题,这个是无顺序的组合,所以我们可以先在(m+n-2)中选出无序个m-1,代表着哪一步向水平方向走一步,然后剩下的就是竖直方向的步数了,因此公式可以写成:
接着用代码实现了一下:
class Solution {
public int robotLoad(int m, int n) {
return molecule(m+n-2, m-1)/denominator(m-1);
}
//分子
public int molecule(int a, int b) {
int result = 1;
for(int i=0;i<b;i++){
result*=a--;
}
return result;
}
//分母
public int denominator(int a) {
int result = 1;
while(a>1){
result*=a--;
}
return result;
}
}
但是运行的时候总是报如下错误:
java.lang.ArithmeticException: / by zero
找了许久,发现当阶乘的数有点大的时候,result会超过了int的上限231-1, 相当于左移34位超过了int的位数32位,所以就变成0了。
进一步加大难度,如果在线路中间加上一个障碍物,机器人不能通过,即:
输入:
[
[0,0,0],
[0,1,0],
[0,0,0]
]
输出: 2
其实不难,因为障碍物不能通过,那么存在障碍物的位置的代表到达方法路径和为0即可,因此可得:
写代码时,判断是否是障碍物,是的话设置为0即可:
class Solution {
public int robotLoad(int[][] obstacleLoad) {
int n=obstacleLoad.length;//m*n二维数组
int m=obstacleLoad[0].length;
int[] dp=new int[n];
dp[0]=1;
for(int i=0;i<m;i++){
for(int j=0;j<n;j++){
if(obstacleGrid[j][i] == 1) dp[j]=0;
else dp[j]=dp[j]+dp[j-1];
}
}
return dp[n-1];
}
}
接着下一题:
- 给定一个字符串 s,找到 s 中最长的回文子串。你可以假设 s 的最大长度为 1000。(回文串是指这个字符串无论从左读还是从右读,所读的顺序是一样的;简而言之,回文串是左右对称的。所谓最长回文子串问题,是指对于一个给定的母串)
例子:
给出:‘ecbcw’
答案:‘cbc’
思路:
将字符串s倒置为s*,然后与s进行对比,有相同的最长部分即位答案;因此,申请一个二维的数组arr初始化为 0,然后判断s与s*对应的字符是否相等,相等的话arr [ i ][ j ] = arr [ i - 1 ][ j - 1] + 1 。当 i = 0 或者 j = 0 的时候单独分析,字符相等的话 arr [ i ][ j ] 就赋为 1 。arr [ i ][ j ] 保存的就是公共子串的长度。
写代码如下:
public String longestHw(String s) {
String str= s;
String reverse = new StringBuffer(s).reverse().toString(); //字符串倒置
int length = s.length();
int[][] arr = new int[length][length];
int maxLen = 0;//最大长度
int maxEnd = 0;//最大长度的结束下标
for (int i = 0; i < length; i++)
for (int j = 0; j < length; j++) {
if (str.charAt(i) == reverse.charAt(j)) {
if (i == 0 || j == 0) {
arr[i][j] = 1;//i或者j=0时,元素相同赋1
} else {
arr[i][j] = arr[i - 1][j - 1] + 1;
}
}
/*判定当前是否为最长的*/
if (arr[i][j] > maxLen) {
maxLen = arr[i][j];
maxEnd = i; //以 i 位置结尾的字符
}
}
}
return s.substring(maxEnd - maxLen + 1, maxEnd + 1);
}
当然上面的是一种动态规划的解法,这里给出另外一种解法。
回文串有一个特点,它是从中间位置对称的,我们可以利用这一点来解题;即用2个指针,从中间开始向左右分别扩散,每一次都判断当前扩散位置的元素是否相同,并且每次扩散到最大时,2个指针重新右移再开始扩散,进而找到最长解。图解如下:
回文串也分为2种,一种是以元素对称,一种是长度对称
因此写代码时要注意:
public String longestHw(String s) {
int start = 0, end = 0;//定义2个指针
for (int i = 0; i < s.length(); i++) {
int len1 = expandAroundCenter(s, i, i);//以元素对称
int len2 = expandAroundCenter(s, i, i + 1);//以长度对称
int len = Math.max(len1, len2);
if (len > end - start) {//判断是否是最长长度
start = i - (len - 1) / 2;
end = i + len / 2;
}
}
return s.substring(start, end + 1);
}
private int expandAroundCenter(String s, int left, int right) {
int L = left, R = right;
while (L >= 0 && R < s.length() && s.charAt(L) == s.charAt(R)) {//当2个下标元素相同时继续扩散
L--;
R++;
}
return R - L - 1;
}