从暴力递归到动态规划(二)

题目一、 0-1背包问题

题目:

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

为了大家更好理解,该题的数值都是大于等于0的,当然不是大于等于0的也可以解。

这道题就牵扯出上面文字一个很重要的内容,就是尝试,从尝试入手,可以改出一个动态规划版本,只要搞定尝试,我们的动态规划就是从尝试来的,那么这个尝试,有一个很常见的尝试模型,叫做从左往右依次尝试。

这是一个从左往右的模型,这种模型可以解决很多的题目,背包题目只是其中之一。

那什么是尝试呢?,举个例子 有三个货 0  1  2  号货,有分别的重量和价值,从左往右开始,我要货是一种状态,我不要又是另一种状态,每一个分支都走,毫无疑问,最大价值就在其中,因为我们暴力枚举了,所以我们要做的,就是把自己的暴力尝试想法写出一个递归版本。

分析: 从左往右尝试,我们需要一个index去标识我们考虑到index号货物,在index之前,是我已经做过决定了,index之后我可以自由选择的。我们先想一下baseCase,当index走到数组最后,说明没货了,返回0,当我们选择的重量超过背包容量之后,肯定也要结束,返回0。如果没中这两个baseCase,那么我们就还能继续考虑做选择。最后选择要或者不要该货物中最大的。

//所有的货,重量和价值都在w 和 v 数组中,
    //为了方便,其中没有负数
    //bag背包容量,  不能超过这个载重
    //返回:不超重的情况下,能够得到的最大价值
    public static int  maxValue(int[] w,int[] v,int bag){
        if (w==null || v==null || w.length != v.length || w.length == 0){
            return 0;
        }
        return process(w,v,0,bag);
    }

    //当前考虑到了index号货物,index ... 所有的货物你可以自由选择
    //做的选择不能超过背包容量
    //返回最大价值
    private static int process(int[] w, int[] v,int index, int bag) {
//        if (bag < 0){
//            return 0;
//        }

        if (bag < 0){
            return -1;
        }
        if (index == w.length){
            return 0;
        }
        //有货  index位置的货
        //bag有空间, 0
        //不要当前的货
        int p1 = process(w,v,index+1,bag);
        // p2直接这么写有问题
        //例如 bag = 6, w[7] v[15]
        //在往下走的时候,发现bag被减为-1 了但是返回的是0 就会影响上游做决策
        //怎么解决?
        //我们可以把bag < 0 时返回 -1
        int p2 = 0;
        int next = process(w,v,index+1,bag - w[index]);
        if (next != -1){
            p2 = v[index] + next;
        }

        return Math.max(p1,p2);
    }

根据起初分析,写出的代码是有一些问题的,例如注释中写的例子,如果按之前bag小于0返回0的条件,那么在做完选择后bag变为-1了,但是我返回0,一点也没耽误之前当前价值加上之前价值的决策,对当前决策没有任何影响,其实该决策已经是无效的,那么怎么解决呢,如上述代码,我们把bag小于0改为返回-1,然后我们提前执行一下,如果该返回值为有效的值,才把该价值加上,如果是无效的,就不会把该值给加上。

那么如何改成动态规划版本呢?

我们注意到上述的递归,只有两个可变参数,所以这两个参数代表递归的状态,然后我们再看有没有重复调用,如下图,要了0要了1,没要2和没要0没要1要了2,在下个位置都是3位置剩10重复值。可以改为动态规划。

接下来我们看index取值范围为0 - N,bag取值返回为 负 - bag ,所以我们在设置动态规划表的时候,准备 N + 1 * bag + 1 的表。

这是这张表最初始的形态,行是index,列时bag,怎么看出来的,从暴力递归中看出来的,如果bag < 0 return -1,我们可以把0列之前的位置全部当做-1的海洋,如果index == w.length,return 0,该例子的的length 为 4 ,如果等于4,return 0,所以最后一行全是 0,但是每一行怎么依赖呢,我们看递归函数,他们都依赖于index + 1 位置,我们可以看出他们都是依赖于下一行的,我们就可以根据第四行填出第三行,根据第三行填出第二行,最后把这张表填满,找出我们需要的位置(0,bag)就是答案,代码如下:

public static int  dp(int[] w,int[] v,int bag){
        if (w==null || v==null || w.length != v.length || w.length == 0){
            return 0;
        }
        int N = w.length;
        //index 表示行
        int[][] dp = new int[N + 1][bag + 1];
        for (int index = N - 1; index >= 0; index--) {
            for (int rest = 0; rest <=  bag; rest++) {
                int p1 = dp[index + 1][rest];
                int p2 = 0;
                int next = rest - w[index] < 0 ? -1:dp[index + 1][rest - w[index]];
                if (next != -1){
                    p2 = v[index] +next;
                }
                dp[index][rest] = Math.max(p1,p2);
            }
        }


        return dp[0][bag];
    }

题目二

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

我们同样需要有一个index 表示从0到index位置你无需过问,从index 到str.length 你有多少种转化方法。在上一题中可能性的枚举策略是要和不要,而这一题可能性的枚举策略就不是要和不要了。那这一题采用什么策略呢?我们先来尝试,如果index 来到 str.length()返回什么?是返回0吗?0中方法   不对,当我字符串结束的时候,我能不能转化,能 ,转化成什么 空字符串, 返回 1,还有一种说法,0 到 index - 1 位置已经转化完了,无需过问,当index到达终止位置的时候,找到了一种方法,这种方法叫做之前做的决定,返回 1,比如说  1 1 1,0位置我假设让1变成A,1位置假设让1变成A,2位置假设让1变成A,到了3位置,我返回1为什么,到了三位置,我做了一个决定,把111变成AAA,终止位置我只收集一个点数,之前做的所有决定,共同构成这一种转化方法。

分析:如果index到最后了,返回1,说明做了一个决定,如果index没有到最后,说明有字符,如果当前字符是0字符,return 0  。为啥,因为26个字母中没有和0对应的,如果你让index位置单枪匹马的面对0字符,说明你做的决定错了,返回0。如果可以继续进行,说明index位置不是0字符,我是不是用永远可以做一个决定,就是我让index位置一个字符单转。还有一种可能是第index位置和第index+1位置共同构成一个字母,直接去index+2位置,第二种决定不一定都有,最后返回ways。

public static int number(String str){
        if (str == null || str.length() == 0){
            return 0;
        }
        return process(str.toCharArray(),0);
    }

    // str[0...index] 无需过问
    // str[i...]去转化,返回有多少种转化方法
    private static int process(char[] strs, int index) {
        if (index == strs.length){
            return 1;
        }
        // i 没到最后,说明有字符
        if (strs[index] == '0'){
            return 0;
        }
        //str[i] != '0'
        //可能性一 单转
        int ways = process(strs,index + 1);
        if (index + 1 < strs.length && (strs[index] - '0') * 10 + strs[index + 1] - '0' < 27){
            ways += process(strs,index + 2);
        }
        return ways;
    }

接下来开始改动态规划,我们发现调用的方法除了固定参数strs时,竟然只有一个参数index,这说明什么,说明改动态规划是一个一维数组,非常的好改,

public static int dp(String str){
        if (str == null || str.length() == 0){
            return 0;
        }
        char[] chars = str.toCharArray();
        int N = chars.length;
        int[] dp = new int[N + 1];
        dp[N] = 1;
        for (int i = N - 1; i >= 0; i--) {
            if (chars[i] != '0'){
                dp[i] = dp[i+1];
                if (i + 1 < str.length() && (chars[i] - '0') * 10 + chars[i + 1] - '0' < 27){
                    dp[i] += dp[i+2];
                }
            }
        }
        return dp[0];
    }

题目三

给定一个字符串str,给定一个字符串类型的数组arr,出现的字符都是小写英文 arr每一个字符串,代表一张贴纸,你可以把单个字符剪开使用,目的是拼出str来 返回需要至少多少张贴纸可以完成这个任务。 例子:str= "babac",arr = {"ba","c","abcd"}至少需要两种贴纸“ba”和“abcd”,因为使用这两张贴纸,把每一个字符单独剪开,含有两个a,两个b,一个c,是可以拼出str的。所以返回2分析:

首先我们注意到贴纸这件事跟顺序没有一毛钱关系,我都给你剪开,你想要拼出的字符顺序重要吗  不重要,他问的就是至少几张贴纸,能把需要的字符包含全。既然跟顺序无关,举个栗子,有三张贴纸,“abc”,“bba”,“cck”,假设想要拼出的东西是“bbbbaca”,我们可以先把要拼的东西排个序,当然不排也可以,拍完序之后我们怎么试,我就第一张用abc看后续最少能有几张,我就第一张用bba我看后续能用几张,我就第一张用cck我看后续能用几张,答案必定在其中。

public static int minSticks(String[] sticks,String target){
        int ans = process1(sticks,target);
        // -1 是什么,怎么都搞不定

        return ans == Integer.MAX_VALUE ? -1:ans;
    }

    //所有贴纸sticks
    //要组成目标target
    //返回最小张数
    private static int process1(String[] sticks, String target) {
        if (target.length() == 0){
            return 0;
        }
        int min = Integer.MAX_VALUE;
        for (String stick : sticks) {
            String rest = minus(target,stick);
            if (rest.length() != target.length()){
                min = Math.min(min,process1(sticks,rest));
            }
        }
        return min + (min == Integer.MAX_VALUE ? 0 : 1);
    }

    private static String minus(String target, String stick) {
        char[] str1 = target.toCharArray();
        char[] str2 = stick.toCharArray();
        int[] count = new int[26];
        for (char cha : str1) {
            count[cha - 'a']++;
        }
        for (char cha : str2) {
            count[cha - 'a']--;
        }
        StringBuilder stringBuilder = new StringBuilder();
        for (int i = 0; i < 26; i++) {
            if (count[i] > 0){
                for (int j = 0; j < count[i]; j++) {
                    stringBuilder.append((char)(i+'a'));
                }
            }
        }
        return stringBuilder.toString();
    }

但是该版本不够高效,下面看一个比较高效的方法:我们可以把贴纸用一个二维数组保存如下图:

例如 “acc”,"bbc","aaa", 在二维数组中,0位置表示 字符串 “acc”的词频, a在0位置出现1次,b在1位置出现0次,c在2位置出现两次,其余都是0次,而 1位置表示 “bbc”,2位置表示“aaa”同理。所以二维数组就可以表示所有贴纸,因为我们只要每种字符出现的次数,帮着减就完了,二维数组代替贴纸数组,词频都给做好,减起来快。同时还需要剪枝,我就在所有贴纸中选必须含有我第一个字符的贴纸去跑分支,剩下分支没试,不管当初消的是什么,但肯定会有消除第一个字符的时刻,那么我把该时刻提前并不会影响最少张数。 

public static int minSticks2(String[] sticks,String target){
        int N = sticks.length;
        //关键优化  用词频表代替贴纸数组
        int[][] count = new int[N][26];
        for (int i = 0; i < N; i++) {
            char[] chars = sticks[i].toCharArray();
            for (char cha : chars) {
                count[i][cha - 'a'] ++;
            }
        }
        int ans = process2(count,target);
        return ans == Integer.MAX_VALUE ? -1:ans;
    }

    //所有贴纸sticks
    //要组成目标target
    //返回最小张数
    //sticks[i]数组,当初i号贴纸的字符统计   sticks  -- >所有贴纸
    private static int process2(int[][] sticks, String target) {
        if (target.length() == 0){
            return 0;
        }
        //target做词频统计,
        //target   aabbcc
        // 0位置代表a  1位置代表 b  2位置代表c 他们的值代表出现的次数
        char[] chars = target.toCharArray();
        int[] charsCount = new int[26];
        for (char cha : chars) {
            charsCount[cha - 'a'] ++;
        }
        int N = sticks.length;
        int min = Integer.MAX_VALUE;
        for (int i = 0; i < N; i++) {
            //尝试第一张贴纸是谁
            int[] stick = sticks[i];
            //最关键的优化,(重要的剪枝,这一步也是贪心)
            if (stick[chars[0]-'a'] > 0){
                StringBuilder builder = new StringBuilder();
                for (int j = 0; j < 26; j++) {
                    if (charsCount[j]>0){
                        int num = charsCount[j] - stick[j];
                        for (int k = 0; k < num; k++) {
                            builder.append((char) (j + 'a') );
                        }
                    }

                }
                String rest = builder.toString();
                min = Math.min(min,process2(sticks,rest));
            }
        }
        return min + (min == Integer.MAX_VALUE ? 0:1);

    }

该方法最大的优化是增加了剪枝,并且词频表直接相减,会快很多。

接下来我们需要改成动态规划的形式,但是,我们惊奇的发现,该递归形式的可变参数竟然是一个字符串,只通过字符串,我们没办法改成严格表结构的方式,我们先看一下有没有必要改。

通过下图我们发现,其实还是有必要改的,还是有重复的值的,但是它的可变参数是一个字符串,我们没办法摸清它的变化范围,那么我们就没办法用表结构改,那该怎么办呢?还记得之前介绍的记忆化搜索吗?没错,就是记忆化搜索 。我们可以把出现过的放在一个缓存中记下来,下回再遇到直接取。

public static int minSticks3(String[] sticks,String target){
        int N = sticks.length;
        //关键优化  用词频表代替贴纸数组
        int[][] count = new int[N][26];
        for (int i = 0; i < N; i++) {
            char[] chars = sticks[i].toCharArray();
            for (char cha : chars) {
                count[i][cha - 'a']++;
            }
        }
        HashMap<String,Integer> dp = new HashMap<>();
        dp.put("",0);
        int ans = process3(count,target,dp);
        return ans == Integer.MAX_VALUE ? -1:ans;
    }

    //所有贴纸sticks
    //要组成目标target
    //返回最小张数
    //sticks[i]数组,当初i号贴纸的字符统计   sticks  -- >所有贴纸
    private static int process3(int[][] sticks, String t,HashMap<String,Integer> dp) {
        if (dp.containsKey(t)){
            return dp.get(t);
        }
        //target做词频统计,
        //target   aabbcc
        // 0位置代表a  1位置代表 b  2位置代表c 他们的值代表出现的次数
        char[] target = t.toCharArray();
        int[] tCounts = new int[26];
        for (char cha : target) {
            tCounts[cha - 'a']++;
        }
        int N = sticks.length;
        int min = Integer.MAX_VALUE;
        for (int i = 0; i < N; i++) {
            //尝试第一张贴纸是谁
            int[] stick = sticks[i];
            //最关键的优化,(重要的剪枝,这一步也是贪心)
            if (stick[target[0]-'a'] > 0){
                StringBuilder builder = new StringBuilder();
                for (int j = 0; j < 26; j++) {
                    if (tCounts[j] > 0){
                        int num = tCounts[j] - stick[j];
                        for (int k = 0; k < num; k++) {
                            builder.append((char)(j + 'a'));
                        }
                    }
                }
                String rest = builder.toString();
                min = Math.min(min,process3(sticks,rest,dp));
            }
        }
        int ans =  min + (min == Integer.MAX_VALUE ? 0:1);
        dp.put(t,ans);
        return ans;
    }

题目三、最长公共子序列问题

给定两个字符串str1和str2, 返回这两个字符串的最长公共子序列长度 比如 : str1 = “a12b3c456d”,str2 = “1ef23ghi4j56k” 最长公共子序列是“123456”,所以返回长度6

分析:我们假设str1 [0...i]位置 str2[0...j]位置,我就关心str1  从0到i位置和str2从0到j位置最长子序列是什么,主函数怎么调呢?我们肯定关心整体啊,参数直接传str1.length - 1 ,str2.length - 1

那么规定好递归的含义了,接下来要怎么写递归函数呢?如果 i 位置 为 0 时 那str1就剩一个字符的时候,怎么返回呢,那str2 [0...j]这一段最长就只有1啊,因为str1就只有一个字符,如果str2[j]和str[i]字符一样,直接返回1,如果不一样,str2[0...j-1]上继续。同理 j位置也是一样。还有一种可能就是str1和str2都不是0的位置,我们就要分情况讨论了,第一张可能性,完全不考虑 i 位置字符,但有可能考虑j位置字符,第二种可能 有可能考虑i位置,完全不考虑j位置字符,还有第三种,我即考虑i也考虑j,在第三种情况下,只有i和j同时以相同字符结尾,才会有公共子串,如果i和j字符一样,我们得出一个公共子串1 + 在[i-1][j-1]上继续递归。

public static int longestCommonSubsequence1(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 process1(str1,str2,s1.length()-1,s2.length() -1);
    }

    //只关心str1[0....i]  和 str2[0...j] 公共子序列多长
    public static int process1(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 process1(str1,str2,i,j - 1);
            }
        }else if (j == 0){
            if (str1[i] == str2[j]){
                return 1;
            }else {
                return process1(str1,str2,i - 1,j);
            }
        }else {
            int p1 = process1(str1,str2,i,j-1);
            int p2 = process1(str1,str2,i-1,j);
            int p3 = str1[i] == str2[j] ? 1+process1(str1,str2,i-1,j-1):0;
            return Math.max(p1,Math.max(p2,p3));
        }
    }

当然该过程过于暴力,可能会超时,所以下面我们根据该暴力方法改为动态规划版本,我们发现该题目的递归调用可变参数为 i 和 j,发现是两个下标,我们是可以知道i 和 j 的取值范围的,i的范围是啥,0到str1的长度呗,j的范围是啥0到str2的长度呗,我们发现可以改成严格的表的形式,我们可以准备一个二维数组,然后在分析依赖,发现一个普通位置,可能会依赖左上角的值或者左边的值或者上边的值,根据baseCase我们可以把这些值填出来,从而完成整张表的填写,最后返回需要位置的值即可。

public static int longestCommonSubsequence2(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];
    }

  • 15
    点赞
  • 17
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值