Loading

动态规划套路题:机器人达到指定位置方法数 & 换钱最少的货币数 & 换钱的方法数 & 打气球的最大分数

机器人达到指定位置方法数

题目:机器人达到指定位置方法数

《程序员代码面试指南》第59题 P192 难度:尉★★☆☆

书上题目顺序安排极不合理,这题应该放在上一题的前面。上一题没做出来,大致看了下解析,对这题有一定启发,很快想出来了。

主要介绍一下本题提出的“用暴力递归解决的方法如何优化成动态规划”的套路

首先是暴力递归方法:

// N : 位置为1 ~ N,固定参数
// cur : 当前在cur位置,可变参数
// rest : 还剩res步没有走,可变参数
// P : 最终目标位置是P,固定参数
// 该函数的含义:只能在1~N这些位置上移动,当前在cur位置,走完rest步之后,停在P位置的方法数作为返回值返回
public int walk(int N, int cur, int rest, int P) {
    // 如果没有剩余步数了,当前的cur位置就是最后的位置
    // 如果最后的位置停在P上,那么之前做的移动是有效的
    // 如果最后的位置没在P上,那么之前做的移动是无效的
    if (rest == 0) {
        return cur == P ? 1 : 0;
    }
    // 如果还有rest步要走,而当前的cur位置在1位置上,那么当前这步只能从1走向2
    // 后续的过程就是,来到2位置上,还剩rest-1步要走
    if (cur == 1) {
        return walk(N, 2, rest - 1, P);
    }
    // 如果还有rest步要走,而当前的cur位置在N位置上,那么当前这步只能从N走向N-1
    // 后续的过程就是,来到N-1位置上,还剩rest-1步要走
    if (cur == N) {
        return walk(N, N - 1, rest - 1, P);
    }
    // 如果还有rest步要走,而当前的cur位置在中间位置上,那么当前这步可以走向左,也可以走向右
    // 走向左之后,后续的过程就是,来到cur-1位置上,还剩rest-1步要走
    // 走向右之后,后续的过程就是,来到cur+1位置上,还剩rest-1步要走
    // 走向左、走向右是截然不同的方法,所以总方法数要都算上
    return walk(N, cur + 1, rest - 1, P) + walk(N, cur - 1, rest - 1, P);
}

public int ways1(int N, int M, int K, int P) {
    // 参数无效直接返回0
    if (N < 2 || K < 1 || M < 1 || M > N || P < 1 || P > N) {
        return 0;
    }
    // 总共N个位置,从M点出发,还剩K步,返回最终能达到P的方法数
    return walk(N, M, K, P);
}

套路大体步骤如下:

前提:你的尝试过程是无后效性的。所谓无后效性,是指一个递归状态的返回值与怎么到达这个状态的路径无关。某个无后效性的递归过程尝试过程一旦确定,怎么优化成动态规划是有固定套路的。

  1. 找到什么可变参数可以代表一个递归状态,也就是哪些参数一旦确定返回值就确定了
  2. 把可变参数的所有组合映射成一张表,有1个可变参数就是一维表,2个可变参数就是二维表……
  3. 最终答案要的是表中的哪个位置,在表中标出。
  4. 根据递归过程的base case,把这张表最简单不需要依赖其他位置的那些位置填好值
  5. 根据递归过程非base case的部分,也就是分析表中的普遍位置需要怎么计算得到,那么这张表的填写顺序也就确定了。
  6. 填好表,返回最终答案在表中位置的值

本题是满足前提——无后效性的,本题运用该套路的详细流程见书P195-197。

填写每一个位置的值都是O(1)的时间复杂度,所以总的时间复杂度O(N×K)

public int ways2(int N, int M, int K, int P) {
    // 参数无效直接返回0
    if (N < 2 || K < 1 || M < 1 || M > N || P < 1 || P > N) {
        return 0;
    }
    int[][] dp = new int[K + 1][N + 1];
    dp[0][P] = 1;
    for (int i = 1; i <= K; i++) {
        for (int j = 1; j <= N; j++) {
            if (j == 1) {
                dp[i][j] = dp[i - 1][2];
            } else if (j == N) {
                dp[i][j] = dp[i - 1][N - 1];
            } else {
                dp[i][j] = dp[i - 1][j - 1] + dp[i - 1][j + 1];
            }
        }
    }
    return dp[K][M];
}

以及动态规划+空间压缩的解法:

public int ways3(int N, int M, int K, int P) {
    // 参数无效直接返回0
    if (N < 2 || K < 1 || M < 1 || M > N || P < 1 || P > N) {
        return 0;
    }
    int[] dp = new int[N + 1];
    dp[P] = 1;
    for (int i = 1; i <= K; i++) {
        int leftUp = dp[1];// 左上角的值
        for (int j = 1; j <= N; j++) {
            int tmp = dp[j];
            if (j == 1) {
                dp[j] = dp[j + 1];
            } else if (j == N) {
                dp[j] = leftUp;
            } else {
                dp[j] = leftUp + dp[j + 1];
            }
            leftUp = tmp;
        }
    }
    return dp[M];
}

另外需要注意,对于有后效性的尝试过程,本套路是失效的,不过这类题目在面试中出现的概率极低。

换钱最少的货币数

题目:换钱最少的货币数

《程序员代码面试指南》第58题 P189 难度:尉★★☆☆

此题为“暴力递归优化成动态规划”套路的第2题

先想暴力尝试的方法,然后优化成动态规划。只有想出尝试方法是最难、最重要的。

暴力尝试过程如下,每一种面值都尝试不同的张数从arr[0]开始依次往右考虑所有面值

public int minCoins1(int[] arr, int aim) {
    if (arr == null || arr.length == 0 || aim < 0) {
        return -1;
    }
    return process(arr, 0, aim);
}

// 当前考虑的面值是arr[i],还剩rest的钱需要找零
// 如果返回-1说明自由使用arr[i..N-1]面值的情况下,无论如何也无法找零rest
// 如果返回不是-1,代表自由使用arr[i..N-1]面值的情况下,找零rest需要的最少张数
public int process(int[] arr, int i, int rest) {
    // base case:
    // 已经没有面值能够考虑了
    // 如果此时剩余的钱为0,返回0张
    // 如果此时剩余的钱不是0,返回-1
    if (i == arr.length) {
        return rest == 0 ? 0 : -1;
    }
    // 最少张数,初始时为-1,因为还没找到有效解
    int res = -1;
    // 依次尝试使用当前面值(arr[i])0张、1张、k张,但不能超过rest
    for (int k = 0; k * arr[i] <= rest; k++) {
        // 使用了k张arr[i],剩下的钱为rest - k * arr[i]
        // 交给剩下的面值去搞定(arr[i+1..N-1])
        int next = process(arr, i + 1, rest - k * arr[i]);
        if (next != -1) { // 说明这个后续过程有效
            res = res == -1 ? next + k : Math.min(res, next + k);
        }
    }
    return res;
}

优化套路过程如下:

前提尝试过程是无后效性的。(使用2张5元、0张2元和使用0张5元、5张2元,后续过程process(arr,2,90),这个状态返回值是一样的,说明一个状态最终的返回值与怎么达到这个状态的过程无关)。

  1. 可变参数i和rest
  2. 组合成一张二维表N行aim列i范围[0,N]rest范围[0,aim]
  3. 最终状态是process(arr,0,aim),即dp[0][aim],位于dp表0行最后一列
  4. 填写初始位置最后一行只有dp[N][0]是0其它位置都是-1
  5. 填写普遍位置。因为dp[i][rest]的值为:dp[i+1][rest-0*arr[i]]+0、dp[i+1][rest-1*arr[i]]+1、dp[i+1][rest-2*arr[i]]+2、……dp[i+1][rest-k*arr[i]]+k、……直到越界,而在求dp[i][rest]之前,dp[i][rest-arr[i]]已经求过了,为:dp[i+1][rest-1*arr[i]]+0、dp[i+1][rest-2*arr[i]]+1、……dp[i+1][rest-k*arr[i]]+k-1、……直到越界。可以得到:dp[i][rest]=min{dp[i][rest-arr[i]]+1, dp[i+1][rest]}。所以,dp[i][rest]只依赖下面一个位置dp[i+1][rest]和左边一个位置dp[i][rest-arr[i]]+1)。最后一排的值已经有了,再从左往右求出倒数第二排、倒数第三排……直到第一排即可。
  6. 最后返回dp[0][aim]位置的值就是答案。
public int minCoins2(int[] arr, int aim) {
    if (arr == null || arr.length == 0 || aim < 0) {
        return -1;
    }
    int N = arr.length;
    int[][] dp = new int[N + 1][aim + 1];
    // 设置最后一排的值,除了dp[N][0]为0之外,其他都是-1
    for (int col = 1; col <= aim; col++) {
        dp[N][col] = -1;
    }
    for (int i = N - 1; i >= 0; i--) { // 从底往上计算每一行
        for (int rest = 0; rest <= aim; rest++) { // 每一行都从左往右
            dp[i][rest] = -1; // 初始时先设置dp[i][rest]的值无效
            if (dp[i + 1][rest] != -1) { // 下面的值如果有效
                dp[i][rest] = dp[i + 1][rest]; // dp[i][rest]的值先设置成下面的值
            }
            // 左边的位置不越界并且有效
            if (rest - arr[i] >= 0 && dp[i][rest - arr[i]] != -1) {
                if (dp[i][rest] == -1) { // 如果之前下面的值无效
                    dp[i][rest] = dp[i][rest - arr[i]] + 1;
                } else { // 说明下面和左边的值都有效,取最小的
                    dp[i][rest] = Math.min(dp[i][rest],
                                           dp[i][rest - arr[i]] + 1);
                }
            }
        }
    }
    return dp[0][aim];
}

因为省掉了枚举过程,所以每个位置的值都在O(1)时间内得到,时间复杂度就为O(N×aim)。同样可以参照“矩阵的最小路径和”使用空间压缩

本题感觉是比上面一题更难一点,主要是这个二维表的行和列的意义很难想到,所以还是得先想出暴力递归的尝试过程,然后再优化成动态规划。

换钱的方法数

题目:换钱的方法数

《程序员代码面试指南》第60题 P199 难度:尉★★☆☆

本题和上题十分类似,不再详述具体的从暴力递归到优化的动态规划(时间复杂度O(N×aim)+空间压缩)的优化过程,详见书P199-204。

暴力递归

public int coins1(int[] arr, int aim) {
    if (arr == null || arr.length == 0 || aim < 0) {
        return 0;
    }
    return process1(arr, 0, aim);
}

public int process1(int[] arr, int index, int aim) {
    int res = 0;
    if (index == arr.length) {
        res = aim == 0 ? 1 : 0;
    } else {
        for (int i = 0; arr[index] * i <= aim; i++) {
            res += process1(arr, index + 1, aim - arr[index] * i);
        }
    }
    return res;
}

记忆化搜索

public int coins2(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);
}

public 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;
}

普通的动态规划

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

优化的动态规划

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

进一步空间压缩

public int coins5(int[] arr, int aim) {
    if (arr == null || arr.length == 0 || aim < 0) {
        return 0;
    }
    int[] dp = new int[aim + 1];
    for (int j = 0; arr[0] * j <= 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];
}

总结:(摘自书上P204)通过本题目的优化过程,可以梳理出暴力递归通用的优化过程。对于在面试中遇到的具体题目,面试者一旦想到暴力递归的过程,其实之后的优化过程是水到渠成的。首先看写出来的暴力递归函数找出有哪些参数是不发生变化的忽略这些变量只看那些变化并且可以表示递归过程的参数,找出这些参数之后,记忆搜索的方法其实可以很轻易地写出来,因为只是简单的修改,计算完就记录到map中,并在下次直接拿来使用,没计算过则依然进行递归计算。接下来观察记忆搜索过程中使用的map结构,看看该结构某一个具体位置的值是通过哪些位置的值求出的被依赖的位置先求,就能改出动态规划的方法,也就是本书“机器人达到指定位置方法数”问题提到的套路。改出的动态规划方法中,如果有枚举的过程看看枚举过程是否可以继续优化常规的方法既有本题所实现的通过表达式来化简枚举状态的方式,也有本书的“丢棋子问题”、“画匠问题”和“邮局选址问题”所涉及的四边形不等式的相关内容

打气球的最大分数

题目:打气球的最大分数

《程序员代码面试指南》第61题 P204 难度:校★★★☆

同样的套路,难点还是在于最开始的好的尝试方法。(我自己做的暴力递归的尝试方法不好,导致没办法优化成动态规划)。

假设要打爆arr[L..R]这个范围上所有的气球,并且假设arr[L-1]和arr[R+1]的气球都没有被打爆尝试的过程为process函数,最后获得的最大分数为process(L,R)依次尝试:如果arr[L] / arr[L+1] / …… / arr[i] / …… / arr[R]最后被打爆的,总分各为多少,最后答案就是所有方案中总分的最大值

暴力递归方法:

public int maxCoins1(int[] arr) {
    if (arr == null || arr.length == 0) {
        return 0;
    }
    if (arr.length == 1) {
        return arr[0];
    }
    int N = arr.length;
    int[] help = new int[N + 2];
    help[0] = 1;
    help[N + 1] = 1;
    for (int i = 0; i < N; i++) {
        help[i + 1] = arr[i];
    }
    return process(help, 1, N);
}

// 打爆arr[L..R]范围上的所有气球,返回最大的分数
// 假设arr[L-1]和arr[R+1]一定没有被打爆
public int process(int[] arr, int L, int R) {
    if (L == R) {// 如果arr[L..R]范围上只有一个气球,直接打爆即可
        return arr[L - 1] * arr[L] * arr[R + 1];
    }
    // 最后打爆arr[L]的方案,和最后打爆arr[R]的方案,先比较一下
    int max = Math.max(
        arr[L - 1] * arr[L] * arr[R + 1] + process(arr, L + 1, R),
        arr[L - 1] * arr[R] * arr[R + 1] + process(arr, L, R - 1));
    // 尝试中间位置的气球最后被打爆的每一种方案
    for (int i = L + 1; i < R; i++) {
        max = Math.max(max,
                       arr[L - 1] * arr[i] * arr[R + 1] + process(arr, L, i - 1)
                       + process(arr, i + 1, R));
    }
    return max;
}

其中arr的开头和结尾补上1,可以避免判断越界所带来的的编程烦恼。

暴力递归改动态规划

public int maxCoins2(int[] arr) {
    if (arr == null || arr.length == 0) {
        return 0;
    }
    if (arr.length == 1) {
        return arr[0];
    }
    int N = arr.length;
    int[] help = new int[N + 2];
    help[0] = 1;
    help[N + 1] = 1;
    for (int i = 0; i < N; i++) {
        help[i + 1] = arr[i];
    }
    int[][] dp = new int[N + 2][N + 2];
    for (int i = 1; i <= N; i++) {
        dp[i][i] = help[i - 1] * help[i] * help[i + 1];
        System.out.println(dp[i][i]);
    }
    for (int L = N; L >= 1; L--) {
        for (int R = L + 1; R <= N; R++) {
            // 求解dp[L][R],表示help[L..R]上打爆所有气球的最大分数
            // 最后打爆help[L]的方案
            int finalL = help[L - 1] * help[L] * help[R + 1] + dp[L + 1][R];
            // 最后打爆help[R]的方案
            int finalR = help[L - 1] * help[R] * help[R + 1] + dp[L][R - 1];
            // 最后打爆help[L]的方案,和最后打爆help[R]的方案,先比较一下
            dp[L][R] = Math.max(finalL, finalR);
            // 尝试中间位置的气球最后被打爆的每一种方案
            for (int i = L + 1; i < R; i++) {
                dp[L][R] = Math.max(dp[L][R], help[L - 1] * help[i]
                                    * help[R + 1] + dp[L][i - 1] + dp[i + 1][R]);
            }
        }
    }
    return dp[1][N];
}

前提无后效性也是满足的,具体步骤和解析见书P207-209。

posted @ 2022-03-02 11:16  幻梦翱翔  阅读(64)  评论(0)    收藏  举报