从 暴力递归 到 动态规划

动态规划就是暴力尝试减少重复计算的技巧整,而已
这种技巧就是一个大型套路
先写出用尝试的思路解决问题的递归函数,而不用操心时间复杂度
这个过程是无可替代的,没有套路的,只能依靠个人智慧,或者足够多的经验

但是怎么把尝试的版本,优化成动态规划,是有固定套路的,大体步骤如下:

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

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

【题目】
假设有排成一行的 N 个位置,记为 1~N,N 一定大于或等于 2。开始时机器人在其中的 M 位置上(W 一定是 1~N 中的一个),机器人可以往左走或者往右走,如果机器人来到1位置,那么下一步只能往右来到 2 位置;如果机器人来到 N 位置,那么下一步只能往左来到 N-1 位置。规定机器人必须走 K 步,最终能来到 P 位置(P 也一定是 1~N 中的一个)的方法有多少种。给定四个参数 N、M、K、P,返回方法数。

【例一】
N=5,M=2,K=3,P=3
上面的参数代表所有位置为 1 2 3 4 5。机器人最开始在 2 位置上,必须经过3步,最后到达 3 位置。走的方法只有如下3种:

  1. 从 2 到 1,从 1 到 2,从 2 到 3
  2. 从 2 到 3,从 3 到 2,从 2 到 3
  3. 从 2 到 3,从 3 到 4,从 4 到 3

所以返回方法数3。

【例二】
N=3,M=1,K=3,P=3
上面的参数代表所有位置为 1 2 3。机器人最开始在 1 位置上,必须经过3步,最后到达 3 位置。怎么走也不可能,所以返回方法数 0。

递归版本

  1. public class RobotWalk {
  2. static int N = 0;
  3. static int M = 0;
  4. static int K = 0;
  5. static int P = 0;
  6. static int robotWalk(int N, int M, int K, int P) {
  7. RobotWalk.N = N;
  8. RobotWalk.M = M;
  9. RobotWalk.K = K;
  10. RobotWalk.P = P;
  11. return process(M, 0);
  12. }
  13. static int process(int curM, int curK) {
  14. if (curK == K) {
  15. return curM == P ? 1 : 0;
  16. }
  17. int num = 0;
  18. if (curM - 1 >= 0) {
  19. num += process(curM - 1, curK + 1);
  20. }
  21. if (curM + 1 <= N) {
  22. num += process(curM + 1, curK + 1);
  23. }
  24. return num;
  25. }
  26. public static void main(String[] args) {
  27. System.out.println(robotWalk(5, 2, 3, 3));
  28. System.out.println(robotWalk(3, 1, 3, 3));
  29. }
  30. }

结果:

3
0

第一版修改

加缓存数组:将每次得到的结果都保存到数组中,如果已经计算过该情况的数据,就直接返回。
代码如下:

public class RobotWalk1 {

    static int N = 0;
    static int M = 0;
    static int K = 0;
    static int P = 0;

    static int robotWalk(int N, int M, int K, int P) {
        RobotWalk1.N = N;
        RobotWalk1.M = M;
        RobotWalk1.K = K;
        RobotWalk1.P = P;

        // 这里的容量最大就是 N * K,机器人最多走 K 步,范围在 N
        int[][] dp = new int[N + 1][K + 1];

        for (int i = 0; i < dp.length; i++) {
            for (int j = 0; j < dp[i].length; j++) {
                dp[i][j] = -1;
            }
        }

        return process(M, 0, dp);
    }

    static int process(int curM, int curK, int[][] dp) {
        if (curK == K) {
            dp[curM][curK] = curM == P ? 1 : 0;
            return dp[curM][curK];
        }

        // 缓存数组没有命中
        int num = 0;
        if (curM - 1 > 0) {
            num += process(curM - 1, curK + 1, dp);
            dp[curM][curK] = num;
        }
        if (curM + 1 <= N) {
            num += process(curM + 1, curK + 1, dp);
            dp[curM][curK] = num;
        }
        return dp[curM][curK];
    }
}

第二版修改

在第一版修改中使用缓存数组来减少计算,使得空间复杂度从递归版本的 动态规划 - 图1 变成了 动态规划 - 图2。如果需要进一步优化,那就不能只时将每一步的结果保存到缓存数组中了,就必须要对数组中保存的数据进行进一步的思考。如下图就是缓存数组 dp[N+1][K+1]
image.png
K + 1 行 N + 1 列,初始在 M 点,需要走 K 步到 P 点。其中 N=5,M=2,K=3,P=3。

这个二维数组中有哪些情况是直接可以得到答案的呢?
由代码的 base case 可知:K = 3 时,所有已经走完了 3 步,所以只有 dp[3列][3行] = 1 其他的都是 dp[i列][3行] = 0,如下图:
image.png
代码逻辑可知:0 位置不存在,所以机器人一定不能走到 0 ,dp[0列][j行] 都不会使用到 即 dp[0列][j行] = 0
image.png
接着看代码

int num = 0;
if (curM - 1 > 0) {
    num += process(curM - 1, curK + 1, dp);
    dp[curM][curK] = num;
}
if (curM + 1 <= N) {
    num += process(curM + 1, curK + 1, dp);
    dp[curM][curK] = num;
}

代码可以转为

int num = 0;
if (curM == 1) {
    num += process(curM + 1, curK + 1, dp);
    dp[curM][curK] = num;
} else if (curM == N) {
    num += process(curM - 1, curK + 1, dp);
    dp[curM][curK] = num;
} else {
    num =  process(curM - 1, curK + 1, dp) + process(curM + 1, curK + 1, dp);
    dp[curM][curK] = num;
}

逻辑可分解如下

if (走到 1 列) {
    向 右下角 [curM + 1, curK + 1] 要结果
} else if (走到 N 位置) {
    向 左下角 [curM - 1, curK + 1] 要结果
} else {
    向 左下角 + 右下角 要结果
}

最后在数组中填充结果为
image.png
可以看到在 M 点的值为 3。

代码实现为:

static int robotWalk(int N, int M, int K, int P) {
    if (N < 2 || K < 1 || M < 1 || M > N || P < 1 || P > N) {
        return 0;
    }

    int[][] dp = new int[N + 1][K + 1];
    dp[P][K] = 1;

    for (int j = K - 1; j >= 0; j--) {
        for (int i = N; i > 0; i--) {
            if (i == N) {
                // 左下角
                dp[N][j] = dp[N - 1][j + 1];
            } else {
                // 左下角 + 右下角
                dp[i][j] = dp[i - 1][j + 1] + dp[i + 1][j + 1];
            }
        }
    }

    return dp[M][0];
}

当然,还是可以继续优化的,如:每层数组的数都只用一次,那么完全可以用一个以为数组来代替计算:

static int robotWalk(int N, int M, int K, int P) {
    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 k = 0; k < K; k++) {
        int leftDown = 0;
        for (int i = 1; i <= N; i++) {
            int temp = dp[i];
            if (i == N) {
                // 左下角
                dp[N] = dp[N - 1];
            } else {
                // 左下角 + 右下角
                dp[i] = leftDown + dp[i + 1];
            }
            leftDown = temp;
        }
    }

    return dp[M];
}

换钱的最少货币数

给定数组 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

递归版本

import java.util.Arrays;

public class CoinsMin {

    static int getCoinsMin(int[] arr, int aim) {
        if (aim == 0) return 0;
        if (arr == null || arr.length == 0 || !Arrays.stream(arr).anyMatch(coin -> coin < aim)) return -1;

        return process(arr, 0, aim);
    }

    static int process(int[] arr, int i, int aim) {
        if (i == arr.length) return aim == 0 ? 0 : -1;

        int minNum = -1;
        // 第 i 个硬币使用了 k 个
        for (int k = 0; k * arr[i] <= aim; k++) {
            int data = process(arr, i + 1, aim - k * arr[i]);
            if (data == -1) continue;
            minNum = minNum == -1 ? data + k : Math.min(data + k, minNum);
        }
        return minNum;
    }

    public static void main(String[] args) {
        System.out.println(getCoinsMin(new int[]{5, 2, 3}, 20));
        System.out.println(getCoinsMin(new int[]{5, 2, 3}, 0));
        System.out.println(getCoinsMin(new int[]{5, 3}, 2));
        System.out.println(getCoinsMin(new int[]{5, 2, 3}, 11));
    }
}

结果

4
0
-1
3

第一版修改

加数组缓存
fuck~~~,这题不看了,看了好久都没弄出来

动态规划

具体思路请参考 https://blog.csdn.net/sjq_915/article/details/108910004

import java.util.Arrays;

public class CoinsMin2 {

    static int getCoinsMin(int[] arr, int aim) {
        if (aim == 0) return 0;
        if (arr == null || arr.length == 0 || !Arrays.stream(arr).anyMatch(coin -> coin < aim)) return -1;

        int[][] dp = new int[arr.length][aim + 1];

        for (int i = 0; i < dp.length; i++) {
            // 当前的硬币面值
            int curCoin = arr[i];
            int[] numArr = dp[i];
            for (int j = 1; j < numArr.length; j++) {
                if (i == 0) {
                    // 第一行要计算出 j 能被兑换的数量
                    numArr[j] = j % curCoin == 0 ? j / curCoin : Integer.MAX_VALUE;
                } else {
                    // 上一行的值
                    int preNum = dp[i - 1][j];
                    /**
                     * 当前数 j 能不能被 curCoin 面值 兑换:
                     * 如果能被兑换那么 dp[i][j] - curCoin = dp[i][j-curCoin] 也能被兑换
                     * 即:能被 dp[i][j] = dp[i][j-curCoin] + 1 个硬币兑换
                     *
                     * 如果上一行(dp[i][j])有数说明有另一种兑换方法,取小值即可
                     */
                    int preIndex = j - curCoin;
                    if (preIndex >= 0 && dp[i][preIndex] != Integer.MAX_VALUE) {
                        dp[i][j] = Math.min(dp[i][preIndex] + 1, preNum);
                    } else {
                        dp[i][j] = preNum;
                    }
                }
            }
        }
        return dp[arr.length - 1][aim];
    }
}

纸牌博弈

给定一个整型数组 arr,代表数值不同的纸牌排成一条线。玩家 A 和玩家 B 依次拿走每张纸牌,规定玩家 A 先拿,玩家B后拿,但是每个玩家每次只能拿走最左或最右的纸牌,玩家 A 和玩家 B 都绝顶聪明。请返回最后获胜者的分数。
【举例】
arr=[1, 2, 100, 4]
开始时,玩家 A 只能拿走 1 或 4。
如果玩家 A 拿走1,则排列变为 [2,100.4],接下来玩家 B 可以拿走 2 或 4,然后继续轮到玩家A。
如果开始时玩家 A 拿走 4,则排列变为 [1,2,100],接下来玩家 B 可以拿走 1 或 100,然后继续轮到玩家 A。
玩家 A 作为绝顶聪明的人不会先拿 4,因为拿 4 之后,玩家 B 将拿走 100。所以玩家 A 会先拿1,让排列变为 [2,100,4],接下来玩家 6 不管怎么选,100 都会被玩家 A 拿走。玩家 A 会获胜,分数为101。所以返回101。

arr=[1, 100, 2]
开始时,玩家 A 不管拿 1 还是 2,玩家 B 作为绝顶聪明的人,都会把100拿走。玩家 B 会获胜,分数为 100。所以返回 100。

该题的递归解法已经在之前 暴力递归 - 拿纸牌 中完成

直接看代码逻辑来改动态规划:
不论是先手函数还是后手函数,可变参数都是 两个边界 left、right,范围都在 [0, arr.length-1] 上,所以二维数组应该是个正方形
而主函数需要的是 Math.max(first(arr, 0, arr.length - 1), second(arr, 0, arr.length - 1)),也就是需要两个数组中的 [0, arr.length-1] 上的值,即下图中绿色位置上的值
image.png
而在数组范围内有个共同的特点:左下边界是无效的,因为 left <= right 不存在类似 [3, 0] 的范围
image.png
在 first 函数中,这里假定 arr = [19, 92, 12, 17, 56]

if (left == right) return arr[left];

在 second 函数中

if (left == right) return 0;

image.png
first 函数需要 两个值来判断获取到 second(arr, left + 1, right)second(arr, left, right - 1) ,也就是左边数组的 [i][j] 位置依赖右边数组 [i+1][j][i][j-1] 的值,[i][j] = Math.max(arr[i] + secondArr[i + 1, j], arr[j] + secondArr[i, j - 1])

second 函数需要 两个值来判断获取到 first(arr, left + 1, right)first(arr, left, right - 1) ,也就是右边数组的 [i][j] 位置依赖左边数组 [i+1][j][i][j-1] 的值,[i][j] = Math.min(firstArr[i + 1, j], firstArr[i, j - 1])
填写完成后:
image.png
结果为 109。

代码实现:

public class CardsInLine1 {
    static int cardsInLine(int[] arr) {
        if (arr == null || arr.length == 0) return 0;

        int[][] fArr = new int[arr.length][arr.length];
        int[][] sArr = new int[arr.length][arr.length];

        for (int i = 0; i < arr.length; i++) {
            fArr[i][i] = arr[i];
        }

        for (int i = arr.length - 2; i >= 0; i--) {
            for (int j = i + 1; j < arr.length; j++) {
                fArr[i][j] = Math.max(arr[i] + sArr[i + 1][j], arr[j] + sArr[i][j - 1]);
                sArr[i][j] = Math.min(fArr[i + 1][j], fArr[i][j - 1]);
            }
        }

        return Math.max(fArr[0][arr.length - 1], sArr[0][arr.length - 1]);
    }

    public static void main(String[] args) {
        System.out.println(cardsInLine(new int[]{1, 2, 100, 4}));
        System.out.println(cardsInLine(new int[]{1, 100, 2}));
        System.out.println(cardsInLine(new int[]{19, 92, 12, 17, 56}));
    }
}

结果

101
100
109

象棋中马的跳法

请同学们自行搜索或者想象一个象棋的棋盘,然后把整个棋盘放入第一象限,棋盘的最左下角是 (0,0) 位置。那么整个棋盘就是横坐标上 9 条线、纵坐标上10条线的一个区域。给你三个参数:x、y、k,返回如果 (0,0) 位置出发,必须走 k 步,最后落在 (x, y) 上的方法数有多少种?

递归实现

public class HorseJump {
    static int horseJump(int x, int y, int k) {
        return process(x, y, k, 0, 0);
    }

    /**
     * @param k    还能走几步
     * @param curX 当前走到的 x 坐标
     * @param curY 当前走到的 y 坐标
     * @return
     */
    static int process(int x, int y, int k, int curX, int curY) {
        if (k == 0) return x == curX && y == curY ? 1 : 0;

        if (curX < 0 || curY < 0 || curX > 9 || curY > 10) return 0;

        return process(x, y, k - 1, curX + 1, curY + 2)
                + process(x, y, k - 1, curX + 1, curY - 2)
                + process(x, y, k - 1, curX - 1, curY + 2)
                + process(x, y, k - 1, curX - 1, curY - 2)
                + process(x, y, k - 1, curX + 2, curY + 1)
                + process(x, y, k - 1, curX + 2, curY - 1)
                + process(x, y, k - 1, curX - 2, curY + 1)
                + process(x, y, k - 1, curX - 2, curY - 1);
    }

    public static void main(String[] args) {
        System.out.println(horseJump(1, 2, 3));
        System.out.println(horseJump(1, 2, 5));
        System.out.println(horseJump(0, 0, 2));
    }
}

结果:

8
132
2

另一个思路:
(0,0) 位置出发,必须走 k 步,最后落在 (x, y) 上。实际上和 从 (x, y) 位置出发,必须走 k 步,最后落在 (0, 0) 上一样。

代码实现:

public static int process(int x, int y, int step) {
    if (x < 0 || x > 8 || y < 0 || y > 9) {
        return 0;
    }
    if (step == 0) {
        return (x == 0 && y == 0) ? 1 : 0;
    }
    return process(x - 1, y + 2, step - 1)
        + process(x + 1, y + 2, step - 1)
        + process(x + 2, y + 1, step - 1)
        + process(x + 2, y - 1, step - 1)
        + process(x + 1, y - 2, step - 1)
        + process(x - 1, y - 2, step - 1)
        + process(x - 2, y - 1, step - 1)
        + process(x - 2, y + 1, step - 1);
}

动态规划

思路:
影响结果的因素有 3 个变量:x、y、k,也就是说需要一个三维数组 int[9][10][k+1]
根据递归代码逻辑:只有 [0][0][0] 时值为 1,其他 [x][y][0] 都为 0。
请在脑海中想象一个长为 9,宽为 10,高为 k+1 的立方体。第一层只有 [0][0][0] 值为 1,其他 [x][y][0] 都为 0。
根据代码逻辑第二层就相当于第一层的 [x+1][y+2][0] + [x+1][y-2][0] + [x-1][y+2][0] + [x-1][y-2][0] + [x+2][y+1][0] + [x-2][y+1][0] + [x+2][y-1][0] + [x-2][y-1][0] 的结果
这样其他层的结果就都得到了,只需要返回 [x][y][k] 的值即可

代码实现:

public class HorseJump1 {
    static int horseJump(int x, int y, int k) {
        int[][][] dp = new int[9][10][k + 1];

        dp[0][0][0] = 1;

        for (int step = 1; step < k + 1; step++) {
            for (int i = 0; i < 9; i++) {
                for (int j = 0; j < 10; j++) {
                    dp[i][j][step] = getValue(dp, i + 1, j + 2, step - 1)
                            + getValue(dp, i - 1, j + 2, step - 1)
                            + getValue(dp, i + 1, j - 2, step - 1)
                            + getValue(dp, i - 1, j - 2, step - 1)
                            + getValue(dp, i + 2, j + 1, step - 1)
                            + getValue(dp, i - 2, j + 1, step - 1)
                            + getValue(dp, i + 2, j - 1, step - 1)
                            + getValue(dp, i - 2, j - 1, step - 1);
                }
            }
        }

        return dp[x][y][k];
    }

    private static int getValue(int[][][] dp, int x, int y, int k) {
        if (x < 0 || x >= 9 || y < 0 || y >= 10) return 0;
        return dp[x][y][k];
    }

    public static void main(String[] args) {
        System.out.println(horseJump(1, 2, 3));
        System.out.println(horseJump(1, 2, 5));
        System.out.println(horseJump(0, 0, 2));
    }
}

Bob的生存概率

给定五个参数 n、m、i、j、k。表示在一个 n * m 的区域,Bob 处在 (i, j) 点,每次 Bob 等概率的向上、下、左、右四个方向移动一步,Bob 必须走 K 步。如果走完之后,Bob 还停留在这个区域上,就算 Bob 存活,否则就算 Bob 死亡。请求解 Bob 的生存概率,返回字符串表示分数的方式。

递归实现

public class BobDie {
    static void bobDie(int m, int n, int i, int j, int k) {
        Data data = process(m, n, i, j, k);
        System.out.println(data.life + "/" + (data.life + data.death));
    }

    static class Data {
        int life;
        int death;

        public Data() {
        }

        public Data(int life, int death) {
            this.life = life;
            this.death = death;
        }
    }

    static Data process(int m, int n, int i, int j, int k) {
        if (k == 0) {
            Data data = new Data();
            if (i - m > 0 || m - i > m || j - n > 0 || n - j > n) {
                data.death = 1;
            } else {
                data.life = 1;
            }
            return data;
        }

        Data d1 = process(m, n, i + 1, j, k - 1);
        Data d2 = process(m, n, i - 1, j, k - 1);
        Data d3 = process(m, n, i, j + 1, k - 1);
        Data d4 = process(m, n, i, j - 1, k - 1);

        return new Data(d1.life + d2.life + d3.life + d4.life, d1.death + d2.death + d3.death + d4.death);
    }

    public static void main(String[] args) {
        bobDie(2, 2, 0, 0, 2);
        bobDie(2, 2, 1, 1, 2);
    }
}

动态规划

还可以根据数学知识,Bob 走 k 步,范围为,也就是在

public class BobDie1 {
    static void bobDie(int m, int n, int i, int j, int k) {
        int[][][] dp = new int[m + 1][n + 1][k + 1];

        dp[i][j][0] = 1;

        int life = 0;
        for (int step = 1; step <= k; step++) {
            for (int a = 0; a <= m; a++) {
                for (int b = 0; b <= n; b++) {
                    dp[a][b][step] = getValue(m, n, step - 1, a, b + 1, dp)
                            + getValue(m, n, step - 1, a, b - 1, dp)
                            + getValue(m, n, step - 1, a + 1, b, dp)
                            + getValue(m, n, step - 1, a - 1, b, dp);
                    if (step == k) life += dp[a][b][step];
                }
            }
        }

        System.out.println(life + "/" + (int) Math.pow(4, k));
    }

    private static int getValue(int m, int n, int step, int a, int b, int[][][] dp) {
        if (a > m || a < 0 || b > n || b < 0) {
            return 0;
        }
        return dp[a][b][step];
    }

    public static void main(String[] args) {
        bobDie(10, 10, 3, 2, 5);
        bobDie(2, 2, 1, 1, 2);
    }
}