【搞定左神算法初级班】第7节:暴力递归、动态规划

目 录:

一、递归

题目1:求 n! 的结果

题目2:汉诺塔问题

题目3:打印一个字符串的全部子序列,包括空字符串

题目4:打印一个字符串的全部排列

题目5:母牛每年生一只母牛,新出生的母牛成长三年后也能每年生一只母牛,假设不会死。求N年后,母牛的数量

二、动态规划

动态规划的特点

如何把暴力递归套路变为动态规划

题目1:矩阵最小路径和

题目2:(背包问题)从数组任意选择数字,能不能累加得到 aim


一、递归

  • 暴力递归的步骤:

  • 1:把问题转化为规模缩小了的同类问题的子问题;
  • 2:有明确的不需要继续进行递归的条件(base case);
  • 3:有得到了子问题的结果之后的决策过程;
  • 4:不记录每一个子问题的解。

递归其实就是不断的尝试,不知道明确的计算方式,但是明白怎么去试。

题目1:求 n! 的结果

用递归去求解时:很明显求解 n! 其实就是求解 (n - 1)! 的问题,即它的子问题.....

package com.offer.foundation.class6;

/**
 * @author pengcheng
 * @date 2019/3/30 - 21:27
 * @content: n! 问题
 */
public class Factorial {

    // 非递归版本
    public long getFactorial1(int n){
        long res = 1L;
        for (int i = 1; i <= n; i++) {
            res *= i;
        }
        return res;
    }

    // 递归版本
    public long getFactorial2(int n){
        if(n == 1){
            return 1L;
        }
        return (long) n * getFactorial2(n - 1);
    }

    // 测试
    public static void main(String[] args) {
        Factorial factorial = new Factorial();
        System.out.println(factorial.getFactorial1(5));   // 120
        System.out.println(factorial.getFactorial2(5));   // 120
    }
}

题目2:汉诺塔问题

  • 打印 n 层汉诺塔从最左边移动到最右边的全部过程:

题目:在一根柱子上从下往上按照大小顺序摞着 n 片黄金圆盘。把圆盘从下面开始按大小顺序重新摆放在另一根柱子上。并且规定,任何时候,在小圆盘上都不能放大圆盘,且在三根柱子之间一次只能移动一个圆盘。打印出移动次数最少的全过程。

【搞定左神算法初级班】第7节:暴力递归、动态规划_第1张图片

  • 【分析】:给三根柱子分别命名为 “left”、“mid”、“right”,from 代表此次需要移动的圆盘所在的位置,to 代表这些圆盘要去的地方,help 是用于辅助的,分三步走:
    • 1、n-1 个圆盘从 from 到 help;
    • 2、第 n 个圆盘从 from 到 to;
    • 3、把那 n-1个圆盘从 help 移动到 to 上面来。
  • 时间复杂度:f(n) = 2f(n-1) +1,是2(n-1)

把尝试的能力写成代码就是递归的过程。

package com.offer.foundation.class6;

/**
 * @author pengcheng
 * @date 2019/3/30 - 22:18
 * @content: 汉诺塔问题
 */
public class Hanoi {

    public void hanoi(int n){
        if(n > 0){
            hanoi(n, "left", "right", "mid");
        }
    }

    /**
     * @param n :n个数
     * @param from :原位置
     * @param help :辅助位置
     * @param to : 目标位置
     */
    public void hanoi(int n, String from, String to, String help){
        if(n == 1){
            // 只有一个时,直接移到目标位置即可
            System.out.println(n + ":" + from + "->" + to);
            return;
        }
        // 下面是处理这个过程的递归问题,只用考虑当前n问题就行,不用尝试去理解它的子问题
        hanoi(n - 1, from, help, to);  // 第1步:将n-1个圆盘从原位置移动到辅助位置
        System.out.println(n + ":" + from + "->" + to);  // 第2步:将第n个圆盘移到目标位置,即打印即可
        hanoi(n - 1, help, to, from);   // 第3步:将位置上的n-1个元素移到到目标位置
    }
}

题目3:打印一个字符串的全部子序列,包括空字符串

  • 每个结点 i:有 要 和 不要 两种选择,之后的随意选择要或不要。

  • 子序列顺序不能变

输入:

abc

输出:
// 第一个是空串
c
b
bc
a
ac
ab
abc

【搞定左神算法初级班】第7节:暴力递归、动态规划_第2张图片

package com.offer.foundation.class6;

/**
 * @author pengcheng
 * @date 2019/3/31 - 15:19
 * @content: 打印一个字符串的全部子序列,包括空字符串
 */
public class PrintAllSubString {

    public void printAllSub(String str){
        if(str == null){
            return;
        }
        char[] chars = str.toCharArray();
        if(chars.length > 0){
            String pre = new String("");   // pre:表示从0到i-1位置上形成的结果
            printAllSub(0, pre, chars);
        }else{
            System.out.println("");          // 输入空字符串也会打印空
        }
    }

    public void printAllSub(int i, String pre, char[] chars){
        // 已经到数组最后一个字符了,所有的选择都做完了,该返回了
        if(i == chars.length){
            System.out.println(pre);
            return;
        }

        // 如果没有到最后一个字符,那么当前字符两种选择:选择要或者选择不要
        printAllSub(i + 1, pre, chars);                                  // 不要当前字符
        printAllSub(i + 1, pre + String.valueOf(chars[i]), chars);   // 要当前字符
    }

    // 测试
    public static void main(String[] args) {
        PrintAllSubString p = new PrintAllSubString();
        String str = "abc";
        p.printAllSub(str);
    }
}

题目4:打印一个字符串的全部排列

4.1 打印一个字符串的全部排列【每个结点i:有i~n-1种选择,之后的随意排序】

  • 你也可以同题目三一样用pre,思想是一样的,这里的i有 n-i 总选择,而题目三因为求的是子序列,只有 2 种选择【要或者不要】。
    • 差别:题目三不是所有字母都在,而且字母建不能乱序,所以不能用打印chars这种方法,而要用额外的pre来记录。
package com.offer.foundation.class6;

/**
 * @author pengcheng
 * @date 2019/4/4 - 16:00
 * @content:
 */
public class PrintAllSort {

    public static void printAllSort(String string){
        if(string == null){
            return;
        }
        char[] chars = string.toCharArray();
        if(chars.length > 0){
            func(0, chars);
        }
    }

    // 对i及i以后的字符进行全排序
    public static void func(int i, char[] chars){
        if(i == chars.length){
            System.out.println(String.valueOf(chars));
        }

        for(int j = i; j < chars.length; j++){
            swap(i, j, chars);     // 第i个位置有i~n-1这些选择
            func(i + 1, chars);  // 搞第i+1的位置
            swap(i, j, chars);
        }
    }

    public static void swap(int i, int j, char[] chars){
        char temp = chars[i];
        chars[i] = chars[j];
        chars[j] = temp;
    }

    // 测试
    public static void main(String[] args) {
        printAllSort("abc");
    }
}

4.2 进阶:打印一个字符串的全部排列,要求不要出现重复的排列

什么是不重复的字符串全排列,如果是普通字符串全排列,那么

输入:

acc

输出:【即认为后面两个c是不一样的,4.1的做法】

acc
acc
cac
cca
cca
cac
要求写出的去重的,也就是会输出:

acc
cac
cca
【即认为后面两个c是一样的】

  • 【分析】:和4.1基本一样,只是增加了一个hashset,用于保证重复字符不会被再次交换。
package com.offer.foundation.class6;

import java.util.HashSet;

/**
 * @author pengcheng
 * @date 2019/4/4 - 16:00
 * @content:
 */
public class PrintAllSort {

    public static void printAllSort(String string){
        if(string == null){
            return;
        }
        char[] chars = string.toCharArray();
        if(chars.length > 0){
            func2(0, chars);
        }
    }

    // 对i及i以后的字符进行全排序
    public static void func2(int i, char[] chars){
        if(i == chars.length){
            System.out.println(String.valueOf(chars));
        }

        // 用于保证每次交换的字符不存在重复字符
        HashSet set = new HashSet<>();
        for(int j = i; j < chars.length; j++){
            // 只有之前没有交换过这个字符才会交换
            if(!set.contains(chars[j])) {
                set.add(chars[j]);
                swap(i, j, chars);      // 第i个位置有i~n-1这些选择
                func2(i + 1, chars);  // 搞第i+1的位置
                swap(i, j, chars);
            }
        }
    }

    public static void swap(int i, int j, char[] chars){
        char temp = chars[i];
        chars[i] = chars[j];
        chars[j] = temp;
    }

    // 测试
    public static void main(String[] args) {
        printAllSort("acc");
    }
}

题目5:母牛每年生一只母牛,新出生的母牛成长三年后也能每年生一只母牛,假设不会死。求N年后,母牛的数量

  • F(n) = F(n-1) + F(n-3);即今年的牛等于去年的牛加上三年前的牛(因为三年前的牛能够生新牛了)
  • 对于递归而言,如果找不到规律,可以先分析下小规模问题,看能不能找到规律。本地分析过程如下:
第1年 第2年 第3年 第4年 第5年 第6年
1 2 3 4 6 9
A A A A A A
  B(A生) B(A生) B(A生) B(A生) B(A生)
    C(A生) C(A生) C(A生) C(A生)
      D(A生) D(A生) D(A生)
        E(A生) E(A生)
        F(B生) F(B生)
          G(A生)
          H(B生)
          I(C生)
  • 从上面的表格中可以很明显的看出规律:F(n) = F(n-1) + F(n-3),再稍加分析,也能直观的感受到这个公示的正确性。
  • 第n年牛的数目等于第n-1年牛的数目和n-3年牛的数目,因为第n年相比较第n-1年增长的就是第n-3年对应牛的数目,因为第n-3年的牛到了第n年都会生一只小牛。
  • 该方法的时间复杂度是:O(N),但是这种公式的递推式都存在O(logN)的解法,这里不再讲了。
package com.offer.foundation.class6;

/**
 * @author pengcheng
 * @date 2019/3/31 - 17:09
 * @content: 母牛数量问题
 */
public class CowNum {

    // 求第n年的牛的数量
    public static int cowNum(int n){
        if(n == 1){
            return 1;
        }

        if(n == 2){
            return 2;
        }

        if(n == 3){
            return 3;
        }
        return cowNum(n - 1) + cowNum(n - 3);
    }

    // 测试
    public static void main(String[] args) {
        int num = cowNum(5);
        System.out.println(num);
    }
}

进阶:如果每只母牛只能活10年,求N年后,母牛的数量。

  • 【分析】 cowNum(n) = cowNum(n-1) + cowNum(n-3) -cowNum(n-10);即今年的牛等于去年的牛加上三年前的牛(因为三年前的牛能够生新牛了),然后再减去十年前的牛。
public static int cowNum2(int n){
	if(n <= 3){
		return n;
	}else if(n <= 10){
		return cowNum2(n - 1) + cowNum2(n - 3);
	}else{
		return cowNum2(n - 1) + cowNum2(n - 3) + cowNum2(n - 10);
	}
}

 

二、动态规划

动态规划是从basecase往上推得到 n ,而递归是从 n 推到basecase再一个一个的返回来得到 n 的结果)

  • 动态规划的特点:

  • 1,从暴力递归中来
  • 2,将每一个子问题的解记录下来,避免重复计算【记录每个子问题的解】
  • 3,把暴力递归的过程,抽象成了状态表达
  • 4,并且存在化简状态表达,使其更加简洁的可能
  • 如何把暴力递归套路变为动态规划

  • 【前提】:问题必须是无后效性问题,即我怎么到达子状态的路径不影响子状态的返回值
  • 套路化步骤:
    • 1)分析可变参数(解空间)【可变参数就是,当参数固定了,返回值(状态)就固定了】,可变参数是几维的就是几维状态表;
    • 2)确定最终状态(即目标状态);
    • 3)根据basecase确定确定初始状态;
    • 4)分析一个普遍位置依赖哪些位置;
    • 5)根据依赖顺序逆序求整个表。

题目1:矩阵最小路径和

给你一个二维数组,二维数组中的每个数都是正数,要求从左上角走到右下角,每一步只能向右或者向下。沿途经过的数字要累加起来。返回最小的路径和。

1、递归版本

  • 如果矩阵为 n x n,那么时间复杂度为:O(2^{n^{2}})。
package com.offer.foundation.class6;

/**
 * @author pengcheng
 * @date 2019/4/4 - 16:41
 * @content: 矩阵最小路径和问题
 */
public class MinPath {

    public static int minPath(int[][] matrix){
        if(matrix == null || matrix.length == 0 || matrix[0] == null || matrix[0].length == 0){
            return 0;
        }
        // 从左上角走到右下角
        return walk(matrix, 0, 0);
    }

    // 从[i,j]位置走到右下角
    public static int walk(int[][] matrix, int i, int j){
        if(i == matrix.length - 1 && j == matrix[0].length - 1){
            // [i,j]位置已经在右下角了
            return 0;
        }

        if(i == matrix.length - 1){
            // [i,j]在矩阵的最后一行,所以只能往右走了
            return matrix[i][j] + walk(matrix, i, j + 1);
        }

        if(j == matrix[0].length - 1){
            // [i,j]在矩阵的最后一列,所以只能往下走了
            return matrix[i][j] + walk(matrix, i + 1, j);
        }

        int right = walk(matrix, i, j + 1);
        int down = walk(matrix, i + 1, j);

        return matrix[i][j] + Math.min(right,down);
    }
}

2、动态规划版本

递归版本虽然简单,但是时间复杂度过高,显然是不行的。通过分析发现,在递归过程中,会有很多重复的计算,如下图所示:

【搞定左神算法初级班】第7节:暴力递归、动态规划_第3张图片

在计算(1,0)位置的右元素和计算(0,1)位置的下元素时,发生了重复计算:都是计算(1,1)位置到右下角的最小距离和。这里只是分析了两步,如果继续分析,会出现很多类似的重复计算过程。

1、无后效性:无论(1,1)位置是从(1,0)位置来的还是(0,1)位置来的,都不影响(1,1)位置到右下角的最小距离的结果,这就叫做无后效性,反之则是有后效性。

2、无后效性一定可以改成递归版本。

3、汉诺塔问题:每步需要打印出轨迹,所以是有后效性的。

4、八皇后问题:前一步的选择会影响后一步的结果,是有后效性的。

那么我们是不是可以利用缓存将每次的计算结果存储起来,下一次再碰到相同元素计算的时候先去缓存中查找看是否已经计算过了,如果存在则直接使用,在没有计算过的时候再去计算,并将结果存储到缓存中。很明显这样的缓存可以用map实现,元素对应key,结果对应value。

  • 改递归思路:

【搞定左神算法初级班】第7节:暴力递归、动态规划_第4张图片

利用basecase(即:i == matrix.length - 1 && j == matrix[0].length - 1)可以直接得出图中状态表右下角的位置为6,然后再由6推出最后一行和最右一列的状态值,然后又可以利用刚才推出的值进行新的一轮推到.....最终将整个表的每个位置都填上其对应的状态值。如上图所示:左上角位置状态值为17,即代表从左上角到右下角位置最短路径值为:17。

这个过程就盖楼一样,从地基开始,上层依赖下层。下层盖好了,上层就可以盖了。

package com.offer.foundation.class6;

import java.util.HashMap;

/**
 * @author pengcheng
 * @date 2019/4/4 - 16:41
 * @content: 矩阵最小路径和问题
 */
public class MinPath {

    // 动态规划版本
    public static int walkDynamic(int[][] matrix){
        if(matrix == null || matrix.length == 0 || matrix[0] == null || matrix[0].length == 0){
            return 0;
        }
        int lastRow = matrix.length - 1;
        int lastCol = matrix[0].length - 1;
        int[][] dp = new int[lastRow][lastCol];  // 状态表

        dp[lastRow][lastCol] = matrix[lastRow][lastCol];   // basecase:右下角到右下角的距离为其本身大小

        // 填充最后一行其他位置处的状态值
        for(int i = lastRow, j = lastCol - 1; j >= 0; j--){
            // 左边位置的值等于右边位置值加上它自身的值
            dp[i][j] = matrix[i][j] + dp[i][j + 1];
        }

        // 填充最后一列其他位置处的状态值
        for(int  j = lastCol, i = lastRow - 1; i >= 0; i--){
            // 上面的位置等于下面的位置值加上它本身的值
            dp[i][j] = matrix[i][j] + dp[i + 1][j];
        }

        // 填充一般位置(除最后一行和最右一列的位置)
        for(int i = lastRow - 1; i >=0; i--){
            for(int j = lastCol - 1; j >= 0; j--){
                // 一般位置:当前位置值 + min(下面位置值,右面位置值)
                dp[i][j] = matrix[i][j] + Math.min(dp[i + 1][j],dp[i][j + 1]);
            }
        }
        return dp[0][0];   // 返回目标值
    }
}

题目2:(背包问题)从数组任意选择数字,能不能累加得到 aim

给你一个数组 arr,和一个整数 aim。如果可以任意选择 arr 中的数字,能不能累加得到 aim,返回 true 或者 false。

1、递归版本

  • 【分析】:每个位置 i 有 要和不要 两种选择;叶节点会看自己这里的结果是不是 aim,从而向父结点返回 true 或 false,父结点比较子节点的结果,有一个为 true 就一直返回 true,否则返回 false。

【搞定左神算法初级班】第7节:暴力递归、动态规划_第5张图片

如上图所示:数组 arr = {3, 2, 5} ,aim = 7:

f(0, 0):代表0位置处状态值为0的点;

f(2, 5):代表2位置处状态值为5的点。

只要有叶节点的值等于 aim 的值,则会返回 true。

package com.offer.foundation.class6;

/**
 * @author pengcheng
 * @date 2019/4/4 - 19:27
 * @content:
 */
public class SumToAim {
    public static boolean IsSumToAim(int[] arr, int aim){
        if(arr == null){
            return false;
        }
        return process(arr, 0, 0, aim);
    }

    // pre:是 0 ~ (i - 1)随意相加产生的结果
    // 用于判断pre+i及其后面的数字随意相加,是否能够得到aim
    public static boolean process(int[] arr, int i, int pre, int aim){
        if(i == arr.length){
            return pre == aim;
        }
        // 位置i有两种选择:要或不要,有一个等于aim,即返回true
        return process(arr, i + 1, pre, aim) || process(arr, i + 1, pre + arr[i], aim);
    }
}

2、动态规划版本

  1. 判断是否为无后效性:是无后效性的;
  2. 确定可变参数:i 值,sum 值,aim 值是固定的;
  3. 确定二维状态表(两个可变参数)。

【搞定左神算法初级班】第7节:暴力递归、动态规划_第6张图片

状态表如上图所示,横坐标为 m 的值,纵坐标为 i 的值。从 basecase 可以看出最后一行的状态值是可以确定的,所以从最后一行往上推导,一直推导到左上角的位置处,如果为 True,则返回 True(图中空白处都为false)。

怎么通过下面一行的状态值得出上面一行的状态值呢?看递归的代码:

process(arr, i + 1, pre, aim) || process(arr, i + 1, pre + arr[i], aim)

因此:

  1. i 行为 True 的位置,其对应 i - 1 行正上方位置也为 True;
  2. i 行为 True 的位置处的值减去 i - 1 行对应的值,得到的在 sum 范围内的值对应的位置处为 True。
package com.offer.foundation.class6;

/**
 * @author pengcheng
 * @date 2019/4/4 - 19:27
 * @content:
 */
public class SumToAim {
   
    // 递归版本
    public static boolean isSumToAim2(int[] arr, int aim){
        if(arr == null || arr.length == 0){
            return false;
        }

        // 状态表:需要注意到底需要几行
        boolean[][] dp = new boolean[arr.length + 1][aim + 1];

        // 填好最后一行:i为横坐标,pre为纵坐标
        for(int i = arr.length, sum = 0; sum <= aim; sum++){
            if(sum == aim){
                dp[i][sum] = true;   // 目标值处设置为true
            }else{
                dp[i][sum] = false;
            }
        }

        // 按照递归填好状态表中的每一个位置:从下一行推导出上一行的状态值
        for(int i = arr.length - 1; i >= 0; i--){
            for(int sum = aim; sum >= 0; sum--){
                if(sum + arr[i] > aim){
                    dp[i][sum] = dp[i + 1][sum];
                }else{
                    // dp[i][sum]值为true的两种情况:正下方值为true || dp[i+1][sum+arr[i]]的值为true,有一个为ture就行
                    dp[i][sum] = dp[i + 1][sum] || dp[i + 1][sum + arr[i]];
                }
            }
        }
        return dp[0][0];
    }
}

 

 

 

你可能感兴趣的:(左神算法)