【算法】牛客网算法初级班(暴力递归到动态规划)

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/ARPOSPF/article/details/82143814

暴力递归到动态规划


介绍递归和动态规划

暴力递归:

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

动态规划:

  1. 从暴力递归中来
  2. 将每一个子问题的解记录下来,避免重复计算
  3. 把暴力递归的过程,抽象成了状态表达
  4. 并且存在化简状态表达,使其更加简洁的可能。

动态规划的前提:无后效性尝试

  1. 列出可变参数组合(表)
  2. base case填简单状态
  3. 最终状态
  4. 普遍位置如何依赖其他位置
  5. 根据第4步的依赖顺序逆着从简单到复杂(填表)

题目一:求n!的结果

/**
 * 求n!的阶乘
 */
public class Factorial {
    
    public static void main(String[] args){
        int n = 5;
        System.out.println(getFactorial1(n));
        System.out.println(getFactorial2(n));
    }

    /**
     * 非递归形式
     * @param n
     * @return
     */
    private static long getFactorial2(int n) {
        long result = 1L;
        for (int i = 1; i <= n ; i++) {
            result*=i;
        }
        return result;
    }

    /**
     * 递归形式
     * @param n
     * @return
     */
    private static long getFactorial1(int n) {
        if (n==1){
            return 1L;
        }
        return (long)n*getFactorial1(n-1);
    }
}

题目二:汉诺塔问题

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

题目:给定一个整数n,代表汉诺塔游戏中从小到大放置的n个圆盘,假设开始时所有的圆盘都放在左边的柱子上,想按照汉诺塔游戏的要求把所有的圆盘都移到右边的柱子上。实现函数打印最优移动轨迹。

例如:

n=1时,打印:

move from left to right

n=2时,打印:

move from left to mid

move from left to right

move from mid to right

进阶题目:给定一个整型数组arr,其中只含有1,2和3,代表所有圆盘目前的状态,1代表左柱,2代表中柱,3代表右柱,arr[i]的值代表第i+1个圆盘的位置。比如,arr=[3,3,2,1],代表第1个圆盘在右石柱上、第2个圆盘在右石柱上、第3个圆盘在中柱上,第4个圆盘在左柱上。如果arr代表的状态是最优移动轨迹过程中出现的状态,返回arr这种状态是最优移动轨迹中的第几个状态。如果arr代表的状态不是最优移动轨迹过程中出现的状态,则返回-1。

例如:

arr=[1,1]。两个圆盘目前都在左柱上,也就是初始状态,所以返回0.

arr=[2,1]。第一个圆盘在中柱上、第二个圆盘也在中柱上,这个状态是2个圆盘的汉诺塔游戏中最优移动轨迹的第1步,所以返回1.

arr=[3,3]。第一个圆盘在右柱上、第二个圆盘也在右柱上,这个状态是2个圆盘的汉诺塔游戏中最优移动轨迹的第3步,所以返回3.

arr=[2,2].第一个圆盘在中柱上,第二个圆盘在中柱上,这个状态是2个圆盘的汉诺塔游戏中最优移动轨迹从来不会出现的状态,所以返回-1.

进阶题目要求:如果arr的长度为N,请实现时间复杂度为O(N)、额外空间复杂度为O(1)的方法。

解答:

原问题:假设有from柱子、mid柱子和to柱子,都在from的圆盘1~i完全移动到to,最优过程为:

步骤1:圆盘1~i-1从from移动到mid

步骤2:单独把圆盘i从from移动到to。

步骤3:把圆盘1~i-1从mid移动到to.如果圆盘只有1个,直接把这个圆盘从from移动到to即可。

public static void main(String[] args) {
        Scanner sc = new Scanner(System.in);
        while (sc.hasNext()) {
            int n = sc.nextInt();
            hanio(n);
        }
        sc.close();
    }

    private static void hanio(int n) {
        if (n > 0) {
            hanioCore(n, "left", "right", "mid");
        }
    }

    private static void hanioCore(int n, String from, String to, String help) {
        if (n == 1) {
            System.out.println("move " + 1 + " from " + from + " to " + to);
        } else {
            hanioCore(n - 1, from, help, to);
            System.out.println("move " + n + " from " + from + " to " + to);
            hanioCore(n - 1, help, to, from);
        }
    }

进阶题目。首先求都在from柱子上的圆盘1~i,如果都移动到to上的最少步骤数,假设为S(i)。根据上面的步骤,S(i)=步骤1的步骤总数+1+步骤3的步骤总数=S(i-1)+1+S(i-1),S(1)=1.所以S(i)+1=2(S(i-1)+1),S(1)+1=2。根据等比数列求和公式得到S(i)+1=2^i,所以S(i)=2^i-1

对于数组arr来说,arr[N-1]表示最大圆盘N在哪个柱子上,情况有以下三种:

  • 圆盘N在左柱上,说明步骤1或者没有完成,或者已经完成,需要考察圆盘1~N-1的状况
  • 圆盘N在右柱上,说明步骤2已经完成,起码走完了2^{N-1}-1步。步骤2也已经完成,起码又走完了1步,所以当前状况起码是最优步骤的2^{N-1}步,剩下的步骤怎么确定还得继续考察圆盘1~N-1的状况
  • 圆盘N在中柱上,这是不可能的,最后步骤不可能让圆盘N处于中柱上,直接返回-1

所以整个过程可以总结为:对圆盘1~i来说,如果目标从from到to,那么情况有三种:

  • 圆盘i在from上,需要继续考察圆盘1~i-1的状况,圆盘1~i-1的目标从from到mid
  • 圆盘i在to上,说明起码走完了2^{i-1}步,剩下的步骤怎么确定还得继续考察圆盘1\sim i-1的状况,圆盘1~i-1的目标为从mid到to。
  • 圆盘i在mid上,直接返回-1.
/**
     * step1是递归函数,递归最多调用N次,并且每步的递归函数再调用递归函数的次数最多一次。
     * step1方法的时间复杂度为O(N).
     * 因为递归函数需要函数栈的关系,step1放啊的额外空间复杂度为O(N).
     *
     * @param arr
     * @return
     */
    public static int step1(int[] arr) {
        if (arr == null || arr.length == 0) {
            return -1;
        }
        return process(arr, arr.length - 1, 1, 2, 3);
    }

    private static int process(int[] arr, int i, int from, int mid, int to) {
        if (i == -1) {
            return 0;
        }
        if (arr[i] != from && arr[i] != to) {
            return -1;
        }
        if (arr[i] == from) {
            return process(arr, i - 1, from, to, mid);
        } else {
            int rest = process(arr, i - 1, mid, from, to);
            if (rest == -1) {
                return -1;
            }
            return (1 << i) + rest;
        }
    }

将整个过程改成非递归的方法。

public static int step2(int[] arr) {
        if (arr == null || arr.length == 0) {
            return -1;
        }
        int from = 1;
        int mid = 2;
        int to = 3;
        int i = arr.length - 1;
        int res = 0;
        int tmp = 0;
        while (i >= 0) {
            if (arr[i] != from && arr[i] != to) {
                return -1;
            }
            if (arr[i] == to) {
                res += 1 << i;
                tmp = from;
                from = mid;
            } else {
                tmp = to;
                to = mid;
            }
            mid = tmp;
            i--;
        }
        return res;
    }

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

解答:首先需要明确的是:子序列!=子串 
最长公共子串要求在原字符串中是连续的,而子序列只需要保持相对顺序一致,并不要求连续。

例如:“abc” 
从位置0开始,有两种决策:1,要;2,不要 
向后走,每个位置同样两种决策,递归

递归结束就是走到了字符串最后

代码:

import java.util.Scanner;

/**
 * 打印全部子串
 */
public class AllSub {
    public static void main(String[] args){
        Scanner sc =new Scanner(System.in);
        while (sc.hasNext()){
            String str = sc.nextLine();
            process(str.toCharArray(),0,"");
        }
        sc.close();
    }

    private static void process(char[] chars, int i, String result) {
        if (i==chars.length){
            System.out.println(result);
            return;
        }else {
            //不要下标为i+1的字符
            process(chars,i+1,result);
            //要第i+1个字符
            process(chars,i+1,result+chars[i]);
        }
    }
}

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

题目描述:输入一个字符串,打印这个字符串中字符的全排列。 
例如: 
输入:abc 
输出:abc acb bac bca cab cba 
思路:将求字符串的全排列分解为两步: 
第一步:确定第一个位置的字符,就是第一个位置与后边的所有字符进行交换。 
第二步:对除了第一个位置的后边所有位置的字符进行相同处理;直至剩下一个字符,打印;

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

代码:

import java.util.HashSet;
import java.util.Scanner;

/**
 * 输出一个字符串的全排列
 */
public class AllPermutations {
    public static void main(String[] args) {
        Scanner sc = new Scanner(System.in);
        while (sc.hasNext()) {
            String str = sc.nextLine();
            System.out.println("==未去重==");
            printAllPermutations1(str);
            System.out.println("==去重==");
            printAllPermutations2(str);
            System.out.println("==结束==");
        }
        sc.close();

    }

    /**
     * 将字符串转换为字符数字,并从第0个位置的字符开始
     *
     * @param str
     */
    private static void printAllPermutations1(String str) {
        char[] chars = str.toCharArray();
        process1(chars, 0);
    }

    /**
     * 以chars数组的i号位置作为交换点,向后交换
     * 在交换过程中没有考虑字符重复的情况
     *
     * @param chars
     * @param i
     */
    private static void process1(char[] chars, int i) {
        //递归出口,即只剩一个字符,无法交换,打印输出
        if (i == chars.length) {
            System.out.println(String.valueOf(chars));
        }
        for (int j = i; j < chars.length; j++) {
            //依次交换i与它后面的每一个字符
            swap(chars, i, j);
            process1(chars, i + 1);
        }
    }

    private static void printAllPermutations2(String str) {
        char[] chars = str.toCharArray();
        process2(chars, 0);
    }

    /**
     * 使用HashSet对字符进行去重
     *
     * @param chars
     * @param i
     */
    private static void process2(char[] chars, int i) {
        if (i == chars.length) {
            System.out.println(String.valueOf(chars));
        }
        HashSet<Character> set = new HashSet<>();
        for (int j = i; j < chars.length; j++) {
            if (!set.contains(chars[j])) {
                set.add(chars[j]);
                swap(chars, i, j);
                process2(chars, i + 1);
            }
        }
    }

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

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

思考:N=6,第1年1头成熟母牛记为a;第2年a生了新的小母牛,记为b,总数为2;第3年a生了新的小母牛,记为c,总牛数为3;第4年a生了新的小母牛,记为d,总牛数为4。第5年b成熟了,a和b分别生了新的小母牛,总牛数为6;第6年c也成熟了,a、b、c分别生了新的小母牛,总牛数记为9,返回9.

所以第N-1年的牛会毫无损失地活到第N年。同时所有成熟的牛都会生1头新的牛,那么成熟牛的数量如何估计?就是第N-3年的所有牛,到第N年肯定都是成熟的牛,其间出生的牛肯定都没有成熟。所以C(n)=C(n-1)+C(n-3)。初始项为C(1)==1,C(2)==2,C(3)==3。

要求:请实现时间复杂度为O(logN)的解法

public class Fibonaqie {
/**
     * 求矩阵m的n次方
     *
     * @param m
     * @param p
     * @return
     */
    public static int[][] matrixPower(int[][] m, int p) {
        int[][] res = new int[m.length][m[0].length];
        //先把res设置为单位矩阵,相当于整数中的1
        for (int i = 0; i < res.length; i++) {
            res[i][i] = 1;
        }
        int[][] tmp = m;
        for (; p != 0; p >>= 1) {
            if ((p & 1) != 0) {
                res = muliMatrix(res, tmp);
            }
            tmp = muliMatrix(tmp, tmp);
        }
        return res;
    }

    private static int[][] muliMatrix(int[][] m1, int[][] m2) {
        int[][] res = new int[m1.length][m2[0].length];
        for (int i = 0; i < m1.length; i++) {
            for (int j = 0; j < m2[0].length; j++) {
                for (int k = 0; k < m2.length; k++) {
                    //矩阵的乘法 ,某一行的元素与某一列的元素的乘积之和
                    res[i][j] += m1[i][k] * m2[k][j];
                }
            }
        }
        return res;
    }
/**
     * 母牛数量问题
     * 解法1,时间复杂度为O(2^n)
     *
     * @param n
     * @return
     */
    public static int c1(int n) {
        if (n < 1) {
            return 0;
        }
        if (n == 1 || n == 2 || n == 3) {
            return n;
        }
        return c1(n - 1) + c1(n - 3);
    }

    /**
     * 解法2:时间复杂度为O(N)
     *
     * @param n
     * @return
     */
    public static int c2(int n) {
        if (n < 1) {
            return 0;
        }
        if (n == 1 || n == 2 || n == 3) {
            return n;
        }
        int res = 3;
        int pre = 2;
        int prepre = 1;
        int tmp1 = 0;
        int tmp2 = 0;
        for (int i = 4; i <= n; i++) {
            tmp1 = res;
            tmp2 = pre;
            res = res + prepre;
            pre = tmp1;
            prepre = tmp2;
        }
        return res;
    }

    /**
     * 时间复杂度为O(logN)
     * @param n
     * @return
     */
    public static int c3(int n) {
        if (n < 1) {
            return 0;
        }
        if (n == 1 || n == 2 || n == 3) {
            return n;
        }
        int[][] base = {{1, 1, 0}, {0, 0, 1}, {1, 0, 0}};
        int[][] res = matrixPower(base, n - 3);
        return 3 * res[0][0] + 2 * res[1][0] + res[2][0];
    }
}

备注:如果递归式严格符合F(n) = a*F(n-1)+b*F(n-2)+...+k*F(n-i),那么它就是一个i阶的递归式,必然有与i*i的状态矩阵有关的矩阵乘法的表达。一律可以用加速矩阵乘法的动态规划将时间复杂度降为O(logN)。

题目六:给你一个栈,请你逆序这个栈,不能申请额外的数据结构,只能使用递归函数。如何实现?

题目描述:一个栈依次压入1、2、3、4、5,那么从栈顶到栈底分别为5、4、3、2、1。将这个栈转置后,从栈顶到栈底为1、2、3、4、5,也就是实现栈中元素的逆序,但是只能用递归函数来实现,不能用其他数据结构。

思路:设计两个递归函数:

递归函数1:将栈stack的栈底元素返回并删除

递归函数2:逆序一个栈。

代码:

import java.util.Stack;

/**
 * 逆序一个栈,不使用其他数据结构
 */
public class ReverseStackUsingRecursive {
    /**
     * 递归函数2:逆序一个栈
     * @param stack
     */
    public static void reverse(Stack<Integer> stack){
        if (stack.isEmpty()){
            return;
        }
        int i = getAndRemoveLastElement(stack);
        reverse(stack);
        stack.push(i);
    }

    /**
     * 递归函数1,将栈的栈底元素返回并删除
     * @param stack
     * @return
     */
    public static int getAndRemoveLastElement(Stack<Integer> stack){
        int result = stack.pop();
        if (stack.isEmpty()){
            return result;
        }else {
            int last = getAndRemoveLastElement(stack);
            stack.push(result);
            return last;
        }
    }

    /**
     * 依次压入1、2、3、4、5.
     * @param args
     */
    public static void main(String[] args){
        Stack<Integer> stack = new Stack<>();
        stack.push(1);
        stack.push(2);
        stack.push(3);
        stack.push(4);
        stack.push(5);
    }
}

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

/**
 * 题目七,返回最小路径和
 */
public class Code_07 {
    public static int minPath(int[][] matrix) {
        return process(matrix, 0, 0);
    }

    private static int process(int[][] matrix, int i, int j) {
        if (i == matrix.length - 1 && j == matrix[0].length - 1) {
            return matrix[i][j];
        }
        //i和j起码有一个没到终止位置
        if (i == matrix.length - 1) {
            return matrix[i][j] + process(matrix, i, j + 1);
        }
        if (j == matrix[0].length - 1) {
            return matrix[i][j] + process(matrix, i + 1, j);
        }
        return matrix[i][j] + Math.min(process(matrix, i + 1, j), process(matrix, i, j + 1));
    }
}

优化:使用缓存,记录已经计算过的数值,不再暴力展开,将时间复杂度从O(2^{n^2})降到O(n^2)

//代码优化,傻缓存
    static HashMap<String, Integer> cache = new HashMap<>();

    private static int process2(int[][] matrix, int i, int j) {
        int result = 0;
        if (i == matrix.length - 1 && j == matrix[0].length - 1) {
            result = matrix[i][j];
        } else if (i == matrix.length - 1) {
            int next = 0;
            String nextKey = String.valueOf(i) + "_" + String.valueOf(j + 1);
            if (cache.containsKey(nextKey)) {
                next = cache.get(nextKey);
            } else {
                next = process2(matrix, i, j + 1);
            }
            result = matrix[i][j] + next;
        } else if (j == matrix[0].length - 1) {
            int next = 0;
            String nextKey = String.valueOf(i + 1) + "_" + String.valueOf(j);
            if (cache.containsKey(nextKey)) {
                next = cache.get(nextKey);
            } else {
                next = process2(matrix, i + 1, j);
            }
            return matrix[i][j] + next;
        } else {
            int downNext = 0;
            String downNextKey = String.valueOf(i + 1) + "_" + String.valueOf(j);
            if (cache.containsKey(downNextKey)) {
                downNext = cache.get(downNextKey);
            } else {
                downNext = process2(matrix, i + 1, j);
            }
            int rightNext = 0;
            String rightNextKey = String.valueOf(i) + "_" + String.valueOf(j + 1);
            if (cache.containsKey(rightNextKey)) {
                rightNext = cache.get(rightNextKey);
            } else {
                rightNext = process2(matrix, i, j + 1);
            }
            result = matrix[i][j] + Math.min(downNext, rightNext);
        }
        String key = String.valueOf(i) + "_" + String.valueOf(j);
        cache.put(key, result);
        return result;
    }

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

/**
 * 给你一个数组arr,和一个整数aim。如果可以任意选择arr中的数字,能不能累加得到aim,返回true或者false。
 */
public class Code_08 {
    public static boolean money1(int[] arr, int aim) {
        return process1(arr, 0, 0, aim);
    }

    /**
     * 从第i位置开始,判断是否要当前数字,如果要当前数字,累加和是否等于aim,如果不要当前数字,累加和是否等于aim。
     *
     * @param arr
     * @param i
     * @param sum
     * @param aim
     * @return
     */
    public static boolean process1(int[] arr, int i, int sum, int aim) {
        if (i == arr.length) {
            return sum == aim;
        }
        return (process1(arr, i + 1, sum, aim) || process1(arr, i + 1, sum + arr[i], aim));

//        if (sum == aim) {
//            return true;
//        }
//        if (i == arr.length) {
//            return false;
//        }
//        return (process1(arr, i + 1, sum, aim) || process1(arr, i + 1, sum + arr[i], aim));
    }

    /**
     * 动态规划版本
     *
     * @param arr
     * @param i
     * @param sum
     * @param aim
     * @return
     */
    public static boolean money2(int[] arr, int aim) {
        boolean[][] dp = new boolean[arr.length + 1][aim + 1];
        for (int i = 0; i < dp.length; i++) {
            dp[i][aim] = true;
        }
        for (int i = arr.length - 1; i >= 0; i--) {
            for (int j = aim - 1; j >= 0; j--) {
                dp[i][j] = dp[i + 1][j];
                if (j + arr[i] <= aim) {
                    dp[i][j] = dp[i][j] || dp[i + 1][j + arr[i]];
                }
            }
        }
        return dp[0][0];
    }

    public static void main(String[] args) {
        int[] arr = {1, 4, 8};
        int aim = 12;
        System.out.println(money1(arr, aim));
        System.out.println(money2(arr, aim));
    }
}

题目九:

给定数组arr,arr中所有的值都为正数且不重复。每个值代表一种面值的货币,每种面值的货币可以使用任意张,再给定一个整数aim代表要找的钱数,求组成aim的最少货币数。

例如:

arr = [5,2,3],aim=20

4张5元可以组成20元,其他的找钱方案都要使用更多的货币,所以返回4.

arr = [5,2,3],aim=0;

不用任何货币就可以组成0元,返回0.

arr=[3,5],aim=2

根本无法组成2元,钱不能找开的情况下默认返回-1。

解答:

原问题的经典动态规划方法。如果arr的长度为N,生成行数为N,列数为aim+1的动态规划表dp。dp[i][j]的含义是,在可以任意使用arr[0..i]货币的情况下,组成j所需的最小张数。根据这个定义,dp[i][j]的值按如下方式计算:

  1. dp[0...N-1][0]的值(即dp矩阵中第一列的值)表示找的钱数为0时需要的最小张数,钱数为0时,完全不需要任何货币,所以全设为0即可。
  2. dp[0][0...aim]的值(即dp矩阵中第一行的值)表示只能使用arr[0]货币的情况下,找某个钱数的最小张数。比如,arr[0]=2,那么能找开的钱数为2,4,6,8...所以令dp[0][2]=1,dp[0][4]=2,dp[0][6]=3...第一行其他位置所代表的的钱数一律找不开,所以一律设为32位整数的最大值,我们把这个值记为max。
  3. 剩下的位置依次从左到右,再从上到下计算。假设计算到位置(i,j),dp[i][j]的值可能来自下面的情况:
  • 完全不使用当前货币arr[i]情况下的最少张数,即dp[i-1][j]的值。
  • 只使用1张当前货币arr[i]情况下的最少张数,即dp[i-1][j-arr[i]]+1.
  • 只使用2张当前货币arr[i]情况下的最少张数,即dp[i-1][j-a*arr[i]]+2
  • 只使用3张当前货币arr[i]情况下的最少张数,即dp[i-1][j-3*arr[i]]+3

所有的情况下,最终取张数最小的。所以:

dp[i][j] = min{dp[i-1][j-k*arr[i]]+k(0<=k)}

=>dp[i][j] = min{dp[]i-1[]j,min{dp[i-1][j-x*arr[i]]+x(1<=x)}}

=>dp[i][j] = min{dp[i-1][j],min{dp[]i-1][j-arr[i]-y*arr[i]]+y+1(o<=y)}}

又有min{dp[i-1][j-arr[i]-y*arr[i]]+y*arr[i]+y(0<=y)}=>dp[i][j-arr[i]],所以,最终有:dp[i][j] = min{dp[i-1][j],dp[i][j-arr[i]]+1}。如果j-arr[i]<0,即发生越界了,说明arr[i]太大,用一张都会超过钱数j,令dp[i][j]=dp[i-1][j]即可。具体过程参见如下代码,整个过程的时间复杂度和额外空间复杂度都是O(N*aim),N为arr的长度。

代码:

public int minCoins1(int[] arr, int aim) {
        if (arr == null || arr.length == 0 || aim < 0) {
            return -1;
        }
        int n = arr.length;
        int max = Integer.MAX_VALUE;
        int[][] dp = new int[n][aim + 1];
        for (int j = 1; j <= aim; j++) {
            dp[0][j] = max;
            if (j - arr[0] >= 0 && dp[0][j - arr[0]] != max) {
                dp[0][j] = dp[0][j - arr[0]] + 1;
            }
        }
        int left = 0;
        for (int i = 1; i < n; i++) {
            for (int j = 1; j <= aim; j++) {
                left = max;
                if (j - arr[i] >= 0 && dp[i][j - arr[i]] != max) {
                    left = dp[i][j - arr[i]] + i;
                }
                dp[i][j] = Math.min(left, dp[i - 1][j]);
            }
        }
        return dp[n - 1][aim] != max ? dp[n - 1][aim] : -1;
    }

原问题在动态规划基础上的空间压缩方法。选择生成一个长度为aim+1的动态规划一维数组,然后按行来更新dp即可。之所以不选按列更新,是因为根据dp[i][j] = min{dp[i-1][j],dp[i][j-arr[i]]+1}可知,位置(i,j)依赖位置(i-1,j),即往上跳一下,也依赖位置(i,j-arr[i]),即往左跳arr[i]一下的位置,所以按行更新只需要1个一维数组,按列更新需要一个一维数组个数就arr中货币的最大值有关,如最大的货币为a,说明最差情况下要向左侧跳a下,相应地,就要准备a个一维数字不断地滚动复用,这样实现起来很麻烦,所以不采用按列更新的方式。空间压缩之后时间复杂度为O(N×aim),额外空间复杂度为O(aim)。

代码:

public int minCoins2(int[] arr, int aim) {
        if (arr == null || arr.length == 0 || aim < 0) {
            return -1;
        }
        int n = arr.length;
        int max = Integer.MAX_VALUE;
        int[] dp = new int[aim + 1];
        for (int j = 1; j <= aim; j++) {
            dp[j] = max;
            if (j - arr[0] >= 0 && dp[j - arr[0]] != max) {
                dp[j] = dp[j - arr[0]] + 1;
            }
        }
        int left = 0;
        for (int i = 1; i < n; i++) {
            for (int j = 1; j <= aim; j++) {
                left = max;
                if (j - arr[i] >= 0 && dp[j - arr[i]] != max) {
                    left = dp[j - arr[i]] + 1;
                }
                dp[j] = Math.min(left, dp[j]);
            }
        }
        return dp[aim] != max ? dp[aim] : -1;
    }

补充:

题目描述:

给定数组arr,arr中所有的值都是正数,每个值仅代表一张钱的面值,再给定一个正数aim代表要找的钱数,求组成aim的最少货币数。

例如:

arr=[5,2,3],aim=20.

无法组成20元,默认返回-1.

arr=[5,2,5,3],aim = 10

5元的货币有2张,可以组成10元,且该方案所需张数最少,返回2.

arr=[5,2,5,3],aim=0

不用任何货币就可以组成0元,返回0。

解法:如果arr的长度为N,生成行数为N、列数为aim+1的动态规划表dp。dp[i][j]的含义是,在可以任意使用arr[0...i]货币的情况下(每个值仅代表一张货币),组成j所需的最小张数。根据这个定义,dp[i][j]的值按如下方式计算:

1.dp[0...N-1][0]的值(即dp矩阵中第一列的值)表示找的钱数为0时需要的最少张数。钱数为0时完全不需要任何货币,所以全设为0即可。

2.dp[0][0...aim]的值(即dp矩阵中第一行的值)表示只能使用一张arr[0]货币的情况下,找某个钱数的最小张数。比如arr[0]=2,那么能找开的钱数仅为2,所以令dp[0][2]=1。因为只有一张钱,所以其他位置所代表的钱数一律找不开,一律设为32位整数的最大值。

3.剩下的位置依次从左到右,再从上到下计算。假设计算到位置(i,j),dp[i][j]的值可能来自下面两种情况。

1)dp[i-1][j]的值代表在可以任意使用arr[0...i-1]货币的情况下,组成j所需的最小张数。可以任意使用arr[0...j]货币的情况当然包括不使用这一面值为arr[i]的货币,而只任意的使用arr[0...i-1]货币的情况,所以dp[i][j]的值可能等于dp[i-1][j]。

2)因为arr[i]只有一张不能重复使用,所以我们考虑dp[i-1][j-arr[i]]的值,这个值代表在可以任意使用arr[0...i-1]货币的情况下,组成j-arr[i]所需的最小张数。从钱数为j-arr[i]到钱数j,只用再加上当前的这张arr[i]即可。所以dp[i][j]的值可能等于dp[i-1][j-arr[i]]+1

4.如果dp[i-1][j-arr[i]]中i-arr[i]<0,也就是位置越界了,说明arr[j]太大了,只用一张都会超过钱数j,令dp[i][j] = dp[i-1][j]即可。否则dp[i][j] = min{dp[i-1][j],dp[i-1][j-arr[i]]+1}。

整个霍城的时间复杂度与额外空间复杂度都为O(N*aim),N为arr的长度。

public int minCoins3(int[] arr, int aim) {
        if (arr == null || arr.length == 0 || aim < 0) {
            return -1;
        }
        int n = arr.length;
        int max = Integer.MAX_VALUE;
        int[][] dp = new int[n][aim + 1];
        for (int j = 1; j <= aim; j++) {
            dp[0][j] = max;
        }
        if (arr[0] <= aim) {
            dp[0][arr[0]] = 1;
        }
        int leftup = 0;//左上角某个位置的值
        for (int i = 1; i < n; i++) {
            for (int j = 1; j <= aim; j++) {
                leftup = max;
                if (j - arr[i] >= 0 && dp[i - 1][j - arr[i]] != max) {
                    leftup = dp[i - 1][j - arr[i]] + 1;
                }
                dp[i][j] = Math.min(leftup, dp[i - 1][j]);
            }
        }
        return dp[n - 1][aim] != max ? dp[n - 1][aim] : -1;
    }

进阶问题在动态规划基础上的空间压缩方法。选择生成一个长度为aim+1的动态规划一维数组dp,然后按行来更新dp即可。空间压缩后的时间复杂度为O(N*aim),额外空间复杂度为O(aim)。

public int minCoins4(int[] arr, int aim) {
        if (arr == null || arr.length == 0 || aim < 0) {
            return -1;
        }
        int n = arr.length;
        int max = Integer.MAX_VALUE;
        int[] dp = new int[aim + 1];
        for (int j = 1; j <= aim; j++) {
            dp[j] = max;
        }
        if (arr[0] <= aim) {
            dp[arr[0]] = 1;
        }
        int leftup = 0;//左上角某个位置的值
        for (int i = 1; i < n; i++) {
            for (int j = aim; j > 0; j--) {
                leftup = max;
                if (j - arr[i] >= 0 && dp[j - arr[i]] != max) {
                    leftup = dp[j - arr[i]] + 1;
                }
                dp[j] = Math.min(leftup, dp[j]);
            }
        }
        return dp[aim] != max ? dp[aim] : -1;
    }

测试:

public static void main(String[] args){
        Scanner sc = new Scanner(System.in);
        while (sc.hasNext()){
            String[] str = sc.nextLine().split(",");
            int[] arr = new int[str.length];
            for (int i = 0; i < str.length; i++) {
                arr[i] = Integer.parseInt(str[i]);
            }
            int aim = Integer.parseInt(sc.nextLine());
            System.out.println(minCoins1(arr,aim));
            System.out.println(minCoins2(arr,aim));
            System.out.println(minCoins3(arr,aim));
            System.out.println(minCoins4(arr,aim));
        }
        sc.close();
    }

题目十:换钱的方法数

题目:给定数组arr,arr中所有的值都为正数且不重复。每个值代表一种面值的货币,每种面值的货币可以使用任意张,再给定一个整数aim代表要找的钱数,求换钱有多少种方法。

例如

arr=[5,10,25,1],aim=0

组成0元的方法只有1中,就是所有面值的货币都不用,所以返回-1;

arr=[5,10,25,1],aim=15

组成15元的方法有6种,分别为3张5元,1张10元+1张5元,1张10元+5张1元,10张1元+1张5元,2张5元+5张1元和15张1元,所以返回6.

arr=[3,5],aim=2

任何方法都无法组成2元,所以返回0

解法

首先用暴力递归的方法,如果arr=[5,10,25,1],aim=1000,分析过程如下:

  1. 用0张5元的货币,让[10,25,1]组成剩下的1000,最终的方法数记为res1.
  2. 用1张5元的货币,让[10,25,1]组成剩下的995,最终的方法数记为res2.
  3. 用2张5元的货币,让[10,25,1]组成剩下的990,最终的方法数记为res3.
  4. ...
  5. 用200张5元的货币,让[10,25,1]组成剩下的0,最终的方法数记为res201.

那么res1+res2+...+res201的值就是总的方法数。

代码:

/**
     * 如果用arr[index...N-1]这些面值的钱组成aim,返回总的方法数
     * 暴力递归方法,时间复杂度与arr中钱的面值有关,最差情况下为O(aim^N)。
     * @param arr
     * @param index
     * @param aim
     * @return
     */
    private static int process1(int[] arr, int index, int aim) {
        int res = 0;
        if (index == arr.length) {
            res = aim == 0 ? 1 : 0;
        } else {
            //用arr[index]货币i张,剩下aim-arr[index]*i。
            for (int i = 0; arr[index] * i <= aim; i++) {
                res += process1(arr, index + 1, aim - arr[index] * i);
            }
        }
        return res;
    }

记忆化搜索的优化方式。process1(arr,index,aim)中arr是始终不变的,变化的只有index和aim,所以可以用p(index,aim)表示一个递归过程。重复计算之所以大量发生,是因为每一个递归过程的结果都没记下来,所以下次还要重复去求。所以可以事先准备好一个map,每计算完一个递归过程,都将结果记录到map中。当下次进行同样的递归过程之前,先在map中查询这个递归过程是否已经计算过。

/**
     * 记忆搜索优化,准备全局变量map,记录已经计算过的递归过程的结果
     * map是一张二维表,map[i][j]表示递归过程p(i,j)的返回值。另外有一些特别值,map[i][j]==0表示递归过程p(i,j)从来没有计算过。
     * map[i][j]==-1表示递归过程p(i,j)计算过,但是返回是0.
     * 如果map[i][j]的值既不等于0,也不等于-1,记为a,则表示递归过程p(i,j)的返回值为a
     * 记忆化搜索的时间复杂度为O(N*aim^2)
     * @param arr
     * @param aim
     * @return
     */
    private static int coin2(int[] arr, int aim) {
        if (arr == null || arr.length == 0 || aim < 0) {
            return 0;
        }
        int[][] map = new int[arr.length + 1][aim + 1];
        return process2(arr, 0, aim, map);
    }

    private static int process2(int[] arr, int index, int aim, int[][] map) {
        int res = 0;
        if (index == arr.length) {
            res = aim == 0 ? 1 : 0;
        } else {
            int mapValue = 0;
            for (int i = 0; arr[index] * i <= aim; i++) {
                mapValue = map[index + 1][aim - arr[index] * i];
                if (mapValue != 0) {
                    res += mapValue == -1 ? 0 : mapValue;
                } else {
                    res += process2(arr, index + 1, aim - arr[index] * i, map);
                }
            }
        }
        map[index][aim] = res == 0 ? -1 : res;
        return res;
    }

动态规划方法。生成生成行数为N,列数为aim+1的矩阵dp,dp[i][j]的含义是在使用arr[0...i]货币的情况下,组成钱数j有多少种方法。dp[i][j]的值求法如下:

1.对于矩阵dp第一列的值dp[...][0],表示组成钱数为0的方法数,很明显是1种,也就是不使用任何货币,所以dp的第一列的值统一设置为1.

2.对于矩阵dp的第一行的值dp[0][...],表示只能使用arr[0]这一种货币的情况下,组成钱的方法数,比如,arr[0]=5时,能组成的钱数只有0,5,10,15...所以,令dp[0][k*arr[0]]=1(0<=k*arr[0]<=aim,k为非负整数)

3.除第一行和第一列的其他位置,记为位置(i,j)。dp[i,j]的值是以下几个值的累加。

  • 完全不用arr[i]货币,只用arr[0...i-1]货币时,方法数为dp[i-1][j];
  • 用1张arr[i]货币,剩下的钱用arr[0...i-1]货币组成时,方法数为dp[i-1][j-arr[i]];
  • 用2张arr[i]货币,剩下的钱用arr[0...i-1]货币组成时,方法数为dp[i-1][j-2*arr[i]];
  • ...
  • 用k张arr[i]货币,剩下的钱用arr[0...i-1]货币组成时,方法数为dp[i-1][j-k*arr[i]],j-k*arr[i]>=0,k为非负整数.

4.最终dp[N-1][aim]的值就是最终结果。

最差情况下,对于位置(i,j)来说,求解dp[i][j]的计算过程需要枚举dp[i-1][0...j]上的所有值,dp一共有N*aim个位置,所以总体的时间复杂度为O(N*aim^2)。

/**
     * 动态规划方法
     * 生成生成行数为N,列数为aim+1的矩阵dp,dp[i][j]的含义是在使用arr[0...i]货币的情况下,组成钱数j有多少种方法。
     * 时间复杂度为O(N*aim^2)。
     *
     * @param arr
     * @param aim
     * @return
     */
    private static int coin3(int[] arr, int aim) {
        if (arr == null || arr.length == 0 || aim < 0) {
            return 0;
        }
        int[][] dp = new int[arr.length][aim + 1];
        for (int i = 0; i < arr.length; i++) {
            dp[i][0] = 1;
        }
        for (int j = 1; arr[0] * j <= aim; j++) {
            dp[0][arr[0] * j] = 1;
        }
        int num = 0;
        for (int i = 1; i < arr.length; i++) {
            for (int j = 1; j <= aim; j++) {
                num = 0;
                for (int k = 0; j - arr[i] * k >= 0; k++) {
                    num += dp[i - 1][j - arr[i] * k];
                }
                dp[i][j] = num;
            }
        }
        return dp[arr.length - 1][aim];
    }

记忆化搜索的方法就是不关注到达某一个递归过程的路径,只是单纯地对计算过的递归过程进行记录,避免重复的递归过程,而动态规划的方法则是规定好每一个递归过程的计算顺序,依次进行计算,后计算的过程严格依赖前面计算的过程。两者都是空间换时间的方法,也都有枚举的过程,区别就在于动态规划规定了计算顺序,而记忆搜索不用规定。所以记忆搜索方法的时间复杂度也是O(N*aim^2)。两者各有优缺点,如果对暴力递归过程简单地优化为记忆搜索的方法,递归函数依然在使用,这在工程上的开销较大。而动态规划方法严格规定了计算顺序,可以将递归计算编程顺序计算,这是动态规划方法具有的优势。

进一步优化。在动态规划第三步中,第1种情况的方法数为dp[i-1][j],而第2种情况一直到第k种情况的方法数累加值其实就是dp[i][j-arr[i]]。所以步骤3可以简化为dp[i][j] = dp[i-1][j]+dp[i][j-arr[i]]。一下省去了枚举的过程,时间复杂度也减少至O(n*aim)。代码如下:

/**
     * 时间复杂度度为O(N*aim)
     *
     * @param arr
     * @param aim
     * @return
     */
    public static int coin4(int[] arr, int aim) {
        if (arr == null || arr.length == 0 || aim < 0) {
            return 0;
        }
        int[][] dp = new int[arr.length][aim + 1];
        for (int i = 0; i < arr.length; i++) {
            dp[i][0] = 1;
        }
        for (int i = 1; arr[0] * i <= aim; i++) {
            dp[0][arr[0] * i] = 1;
        }
        for (int i = 1; i < arr.length; i++) {
            for (int j = 1; j <= aim; j++) {
                dp[i][j] = dp[i - 1][j];
                dp[i][j] += j - arr[i] >= 0 ? dp[i][j - arr[i]] : 0;
            }
        }
        return dp[arr.length - 1][aim];
    }

时间复杂度为O(n*aim)的动态规划方法再结合空间压缩的技巧。代码就可以进一步压缩为:

/**
     * 空间压缩
     * @param arr
     * @param aim
     * @return
     */
    public static int coin5(int[] arr, int aim) {
        if (arr == null || arr.length == 0 || aim < 0) {
            return 0;
        }
        int[] dp = new int[aim + 1];
        for (int j = 0; j * arr[0] <= aim; j++) {
            dp[arr[0] * j] = 1;
        }
        for (int i = 1; i < arr.length; i++) {
            for (int j = 1; j <= aim; j++) {
                dp[j] += j - arr[i] >= 0 ? dp[j - arr[i]] : 0;
            }
        }
        return dp[aim];
    }

题目十一:

给定两个数组w和v,两个数组长度相等,w[i]表示第i件商品的终点,v[i]表示第i件商品的价值。再给定一个整数bag,要求你挑选商品的重量加起来一定不能超过bag,返回满足这个条件下,你能获得的最大价值。

/**
 * 背包问题
 */
public class Knapsack {
    public static int maxValue1(int[] c, int[] p, int bag) {
        return process1(c, p, 0, 0, bag);
    }

    private static int process1(int[] weights, int[] values, int i, int alreadyweight, int bag) {
        if (alreadyweight > bag) {
            return 0;
        }
        if (i == weights.length) {
            return 0;
        }
        return Math.max(
                process1(weights, values, i + 1, alreadyweight, bag), //需要第i号商品
                //不需要i号商品
                values[i] + process1(weights, values, i + 1, alreadyweight + weights[i], bag));
    }

    public static int maxValue2(int[] c, int[] p, int bag) {
        int[][] dp = new int[c.length + 1][bag + 1];
        for (int i = c.length - 1; i >= 0; i--) {
            for (int j = bag; j >= 0; j--) {
                dp[i][j] = dp[i + 1][j];
                if (j + c[i] <= bag) {
                    dp[i][j] = Math.max(dp[i][j], p[i] + dp[i + 1][j + c[i]]);
                }
            }
        }
        return dp[0][0];
    }

    public static void main(String[] args) {
        int[] c = {3, 2, 4, 7};
        int[] p = {5, 6, 3, 19};
        int bag = 11;
        System.out.println(maxValue1(c, p, bag));
        System.out.println(maxValue2(c, p, bag));
    }
}

猜你喜欢

转载自blog.csdn.net/ARPOSPF/article/details/82143814