暴力递归和动态规划

268 阅读8分钟

所有的动态规划都可以使用暴力递归推断出来,做动态规划的题目要分成以下几步

1、分析递归的返回条件

2、先写出来暴力递归版本

3、通过写出来的暴力递归版本分析参数的变化范围然后去做DP

解这类的题目有四个模型分别是:从左到右的尝试模型、范围尝试模型、样本分析模型和业务限制模型

一、最长公共子序列

样本对应模型

首先列可能性,可能性讨论,两个字符串分别为s1、s2。以变量i指向s1的尾巴,以变量j指向s2的尾巴

可能性1:不已 i 位置字符结尾,不已 j 位置字符结尾, 例如:1234d , a123e 最长公共子序列123,不已 d 和 e结尾

可能性2:不已 i 字符串结尾 , 但是有可能以 j 位置字符结尾,例如:a123d , a12e3 最长公共子序列123

可能性3:可能以 i 字符串结尾 , 但是不以 j 位置字符串结尾,例如:a123d ,a12e3de 最长公共子序列 123d

暴力尝试

public static int longCommonSubStr(String s1,String s2){
    if (s1 == null || s2 == null || s1.length() == 0 || s2.length() == 0) {
        return 0;
    }
    char[] str1 = s1.toCharArray();
    char[] str2 = s2.toCharArray();
    return proc(str1,str2,str1.length-1,str2.length-1);
}


private static int proc(char[] str1, char[] str2, int i, int j) {
    if (i==0 && j ==0){
        return str1[i] == str2[j] ? 1:0;
    }else if (i == 0){
        if (str1[i] == str2[j]){
            return 1;
        }else{
            return proc(str1,str2,i,j-1);
        }
    }else if (j == 0){
        if (str1[i] ==  str2[j]){
            return 1;
        }else{
            return proc(str1,str2,i-1,j);
        }
    }else{
        int p1 = proc(str1,str2,i-1,j);
        int p2 = proc(str1,str2,i,j-1);
        int p3 = str1[i] == str2[j] ? (1 + process1(str1, str2, i - 1, j - 1)) : 0;
        return Math.max(Math.max(p1,p2),p3);
    }
}

通过暴力尝试的代码改动态规划

确定参数的变化范围,从而确定DP表的大小。s1 的运动范围是 0 ~ N,s2的运动范围是 0 ~ M,所以使用一张n*m的表就可以存下来所有的的记录

public static int dp(String s1,String s2){
    if (s1 == null || s2 == null || s1.length() == 0 || s2.length() == 0){
        return 0;
    }

    char[] str1 = s1.toCharArray();
    char[] str2 = s2.toCharArray();

    int N = str1.length;
    int M = str2.length;
    int[][] dp = new int[N][M];
    dp[0][0] = str1[0] == str2[0] ? 1:0;
    for (int j = 1 ; j < M  ; j++){
        dp[0][j] = str1[0] == str2[j] ? 1 : dp[0][j-1];
    }
    for (int i = 1 ; i<N ; i++){
        dp[i][0] = str1[i] == str2[0] ? 1 : dp[i-1][0];
    }
    for (int i = 1; i<N ; i++){
        for (int j = 1 ; j<M; j++){
            int p1 = dp[i-1][j];
            int p2 = dp[i][j-1];
            int p3 = str1[i] == str2[j] ? (1+dp[i-1][j-1]) : 0;
            dp[i][j] = Math.max(p1,Math.max(p2,p3));
        }
    }
    return dp[N-1][M-1];
}

二、最长公共回文子序列

给定一个字符串,求这个串的最长公共回文子序列

第一种思路:就是将这个串逆序,然后求逆序串和原来串的最长公共子序列即可求解。这里的代码就不写了

现在使用第二种解题思路:范围尝试模型。

将这个问题一步一步的拆解,先求 这个串的 L ~ R的最长回文子序列 L + 1 ~ R - 1 ..... L + N ~ R - 1 ......

列可能性:

可能性1:字符串在 L .... R 范围上 不以 L开头 也不以R结尾 例如 ‘a12321b’

可能性2:字符串在L .... R 范围上 以 L开头 不以R结尾 例如 ‘12321b’

可能性3:字符串在L .... R 范围上 以不 L开头 以R结尾 例如 ‘b12321’

可能性4:字符串在L .... R 范围上 以L开头 以R结尾 例如 ‘a12321a’

分析完成以后写尝试

public static int longestPalindromeSubseq1(String s) {
    if (s == null || s.length() == 0){
        return 0;
    }

    char[] str = s.toCharArray();

    int N = str.length;

    return process(str,0,N-1);

}

/**
 * 暴力递归
 * @param str
 * @param L
 * @return
 */
public static int process(char[] str,int L , int R){
    if (L == R) {
        return 1;
    }

    /**
     * aa 返回2
     * ab 返回1 a是回文,或者b是回文,所以返回的是1
     */
    if (L == R - 1){
        return str[L] == str[R] ? 2 : 1;
    }

    /**
     * 列可能性
     * 字符串在L .... R 范围上 不以 L开头  也不以R结尾
     * a12321b
     * 字符串在L .... R 范围上  以 L开头   不以R结尾
     * 12321b
     * 字符串在L .... R 范围上  以不 L开头  以R结尾
     * a12321
     * 字符串在L .... R 范围上  以L开头     以R结尾
     * a12321a
     */
    int p1 = process(str,L+1,R-1);
    int p2 = process(str, L, R-1);
    int p3 = process(str, L+1, R);
    int p4 = str[L] == str[R]?(2+process(str,L+1,R-1)):0;
    return Math.max(Math.max(p1,p2),Math.max(p3,p4));
}

改DP

/**
 * DP
 * @param s
 * @return
 */
public static int longestPalindromeSubseq2(String s) {
    if (s == null || s.length() == 0){
        return 0;
    }
    /**
     * 首先确定参数的变化范围
     */
    char[] str = s.toCharArray();
    int N = str.length;

    int[][] dp = new int[N][N];
    dp[N-1][N-1] = 1;
    for (int i=0;i<N-1;i++){
        dp[i][i] = 1;
        dp[i][i+1] = str[i] == str[i+1] ? 2 : 1;
    }

    for (int L=N-3;L>=0;L--){
        for (int R = L+2 ;R<N; R++){
            int p1 = dp[L+1][R-1];
            int p2 = dp[L][R-1];
            int p3 = dp[L+1][R];
            int p4 = str[L] == str[R] ? (2 + dp[L+1][R-1]) : 0;
            int max = Math.max(Math.max(p1, p2), Math.max(p3, p4));
            dp[L][R] = max;
        }
    }
    return dp[0][N-1];
}

三、纸牌博弈问题

给定一个整型数组arr,代表数值不同的纸牌排成一条线,玩家A和玩家B一次拿牌。规定玩家A先拿,玩家B后拿。但是两个玩家只能拿走左边或者右边的牌,玩家A和玩家B都决定聪明。请返回最后获胜者的分数。

这个问题是一个典型的范围尝试模型(范围尝试模型非常注重讨论开头和结尾)

范围讨论

首先排成一条线的纸牌范围是从 0 ~ N 的,玩家A,可以从左边拿牌 拿完以后整个纸牌的范围变成 L+1 ~ N,当然玩家A也可以从右边拿牌,拿完以后纸牌的范围就变成 L ~ R-1。这两边的值谁大就选谁。如果纸牌就一张,那么玩家A直接拿走即可。

玩家B,如果只剩下一张牌,A又是先拿,所以B获得0。分析完成以后开始写尝试函数

尝试版本


public static int win1(int[] arr){
    if (arr == null || arr.length == 0){
        return 0;
    }
    int fVal = f(arr,0,arr.length-1);
    int gVal = g(arr,0,arr.length-1);
    return Math.max(fVal,gVal);
}
//先手函数
public static int f(int[] arr,int L , int R){
    if (L == R){
        return arr[L];
    }
    //后手函数拿左边 , 后手函数拿后边
    return Math.max(arr[L] + g(arr,L+1,R),arr[R] + g(arr,L,R-1));
}
//后手函数
public static int g(int[] arr,int L,int R){
    if (L == R){
        return 0;
    }
    //先拿牌的人肯定会给你留下最小的
    return Math.min(f(arr,L+1,R),f(arr,L,R-1));
}

在暴力递归的过程中会出现重复的解,所以我们可以进一步的进行优化,使用缓存,如果在递归的过程中这条递归跑过了,那么就直接拿值。

public static int win2(int[] arr){
    if (arr == null || arr.length == 0){
        return 0;
    }
    int N = arr.length;
    int[][] fmap = new int[N][N];
    int[][] gmap = new int[N][N];

    for (int i=0;i<N;i++){
        for (int j=0;j<N;j++){
            fmap[i][j] = -1;
            gmap[i][j] = -1;
        }
    }
    int fVal = f2(arr,0,arr.length-1,fmap,gmap);
    int gVal = g2(arr,0,arr.length-1,fmap,gmap);
    return Math.max(fVal,gVal);
}


public static int f2(int[] arr,int L , int R,int[][] fmap , int[][] gmap){
    int ans = 0;
    if (L == R){
        ans = arr[L];
    }
    ans = Math.max(arr[L] + g(arr,L+1,R),arr[R] + g(arr,L,R-1));
    fmap[L][R] = ans;
    return ans;
}

public static int g2(int[] arr,int L,int R ,int[][] fmap , int[][] gmap){
    int ans = 0;
    if (L == R){
        ans = 0;
    }
    ans = Math.min(f(arr,L+1,R),f(arr,L,R-1));
    gmap[L][R] = ans;
    return ans;
}

通过暴力解改出来的动态规划版本,分析先手函数的参数变化范围 0 ~ N,分析后手函数的参数变化范围0 ~ N

public static int win3(int[] arr){
   if (arr == null || arr.length == 0){
       return 0;
   }
   int N = arr.length;

   int[][] fmap = new int[N][N];
   int[][] gmap = new int[N][N];

   for (int i = 0;i<N; i++){
       fmap[i][i] = arr[i];
       gmap[i][i] = 0;
   }


   for (int col = 1;col < N; col++){
       int L = 0;
       int R = col;
       while(R<N){
           fmap[L][R] = Math.max(arr[L]+gmap[L+1][R],arr[R]+gmap[L][R-1]);
           gmap[L][R] = Math.min(fmap[L+1][R],fmap[L][R-1]);
           L++;
           R++;
       }
   }
   return Math.max(fmap[0][N-1],gmap[0][N-1]);
}

四、机器人的移动范围

假设有排成一排的N个位置,记作 1 ~ N , N一定大于或者等于2 开始时机器人在其中的M位置上(M 一定是 1 ~ N中的一个) 如果机器人来到1的位置,那么下一步只能来到右边2的位置 如果机器人来到N的位置,那么下一步只能来到左边N-1的位置 如果机器人来到中间的位置,那么下一步可以向左边或者向右边走 规定机器人必须走k步, 最终能来到P位置(p也是1~N中的一个)的方法有多少种。 给定四个参数N,M,K,P

从左到右的尝试模型

可能性分析

可能性分析1:机器人来到1位置,那么机器人下一步只能走向2

可能性分析2:机器人来到N位置,那么机器人下一步只能走向N-1

可能性分析3:机器人在中间,那么机器人可能走向当前位置+1,或者走向当前位置-1

递归退出条件分析

剩余的步数为 0 ,当前位置 和 目标位置相等 返回 1 ,否则返回 0

暴力尝试版本

public static int walk(int cur , int rest , int aim ,int N){
    if (rest == 0){
        return cur == aim ? 1:0;
    }
    if (cur ==1){
        return walk(2,rest-1,aim,N);
    }
    if (cur == N){
        return walk(N-1,rest-1,aim , N);
    }
    return walk(cur+1,rest-1,aim,N) + walk(cur-1,rest-1,aim,N);
}

记忆化搜索版本

public static int walk2(int N , int cur , int rest , int aim){
    //分析:需要多大的一张缓存表,范围 cur 的移动范围 1 - N ,rest的移动范围 0 - rest。所以需要一张 (N+1)* rest 大小的一张表
    int[][] dp = new int[N+1][rest+1];
    //初始化这张缓存表
    for (int i=0;i<=N;i++){
        for (int j=0;j<=rest;j++){
            dp[i][j] = -1;
        }
    }
    return process2(N,cur,rest,aim,dp);

}
public static int process2(int N,int cur , int rest ,int aim,int[][] dp){
    if (dp[cur][rest]!=-1){
        return dp[cur][rest];
    }
    int ans = 0;
    if (rest == 0){
        ans = cur == aim ? 1 : 0;
    }else if (cur == 1){
        ans = process2(N,2,rest-1,aim,dp);
    }else if (cur == N){
        ans = process2(N,N-1,rest-1,aim,dp);
    }else{
        ans = process2(N,cur+1,rest-1,aim,dp) + process2(N,cur-1,rest-1,aim,dp);
    }
    dp[cur][rest] = ans;
    return ans;
}

标准版本的动态规划

public static int walk3(int N , int start , int K , int aim){
    int[][] dp = new int[N+1][K+1];
    dp[aim][0] = 1;
    for (int rest = 1;rest <= K ; rest++){
        dp[1][rest] = dp[2][rest - 1];
        for (int cur = 2 ;cur < N; cur++){
            dp[cur][rest] = dp[cur-1][rest-1] + dp[cur + 1][rest-1];
        }
        dp[N][rest] = dp[N-1][rest-1];
    }
    return dp[start][K];
}

五、背包问题

给定两个长度都为N的数组weights和values,weigths[i] 和 values[i]分别代表i号货物的重量和价值,给定一个正数bag,表示一个载重bag的袋子,你装的物品不能超过这个重量,返回你能装下最多的价值是多少

从左到右的尝试模型

六、字符串组合问题

规定1和A对应,2和B对应,3和c对应 .... 26 和 z对应,那么一个数字字符串比如 “111”,就可以转换为 “AAA” , “KA” , “AK”,给定一个只有数字字符组成的字符串str,返回有多少种转换的结果。

从左到右的尝试模型

七、象棋问题

脑海里补充一个象棋棋盘,然后吧整个棋盘放在第一象限,棋盘的最左下角是(0,0)的位置,那么整个棋盘就是横坐标上9条线,纵坐标上10条线的区域,给你三个参数x,y,z。 返回马从(0,0)位置出发,必须走k步,最后落在(x,y)上的方法数有多少种

八、贴纸问题

给定一个字符串str,给定一个字符串类型的数组arr , 出现的字符都是小写英文arr每一个字符串,代表一张贴纸,你可以吧单个字符剪开使用,目的是为了拼出来str,返回需要至少使用多少张贴纸可以完成这个任务。

例子: str = "babac" , arr = {"ba","c","abcd"};

至少需要两张贴纸 "ba" 和 "abcd" ,因为使用这两张贴纸 , 把每一个字符单独的剪开,含有两个a,2个b,1个c。是可以拼出来str的。所以返回2。

九、路径问题

给定一个二维数组matrix , 一个人必须从左上角出发 , 最后到右下角 沿途只可以向下或者向右,沿途的数字都累加就是距离累加和,返回最小距离累加和