每日算法总结——动态规划练习:预测赢家、象棋马走日、零钱兑换、动态规划总结

题目回顾——预测赢家

题目描述:给定一个整型数组arr,代表数值不同的纸牌排成一条线。 玩家A和玩家B依次拿走每张纸牌,规定玩家A先拿,玩家B后拿,但是每个玩家每次只能拿走最左或最右的纸牌,玩家A和玩家B都绝顶聪明。请返回最后获胜者的分数。

暴力递归尝试

思路分析:俩玩家都绝顶聪明,整个游戏可以分成先手后手两个过程

  • 先手:先手玩家拿完一张牌后就变成后手了,所以该玩家肯定会拿对自己最有利的牌
  • 后手:后手玩家等对方拿完牌就变成先手了,但对方留下的肯定是最差的情况
public class CardsInLine {
    public static int win1(int[] arr){
        if (arr == null || arr.length == 0) {
            return 0;
        }
        return Math.max(f(arr, 0, arr.length - 1), s(arr, 0, arr.length - 1));
    }

    /**
     * 先手,在[i, j]范围上开始拿牌,返回最终的分数
     */
    public static int f(int[] arr, int i, int j) {
        // Base case: 只剩下一张牌了,我先手,必须拿
        if (i == j) {
            return arr[i];
        }
        // 拿走最左边或最右边的牌,我变为后手,取最大值
        return Math.max(arr[i] + s(arr, i + 1, j), arr[j] + s(arr, i, j - 1));
    }

    /**
     * 后手,在[i, j]范围上拿牌,返回最终的分数
     */
    public static int s(int[] arr, int i, int j) {
        // Base case: 只剩下一张牌了,我后手,不能拿,返回0
        if (i == j) {
            return 0;
        }
        // 对方拿走最左或最右边的牌,我变为先手,取最小值(因为对方会留给我最不利的情况)
        return Math.min(f(arr, i + 1, j), f(arr, i, j - 1));
    }
}
记忆化搜索

显然我们需要两张表,分别记录先手和后手在对应范围上得到的分数

/**
 * 记忆化搜索
 */
public static int win2(int[] arr) {
    if (arr == null || arr.length == 0) {
        return 0;
    }
    // i 和 j 的范围都是0 ~ arr.length
    int[][][] dp = new int[arr.length][arr.length][2];
    for (int i = 0; i < dp.length; i++) {
        for (int j = 0; j < dp[i].length; j++) {
            dp[i][j][0] = -1;
            dp[i][j][1] = -2;
        }
    }
    return Math.max(f1(arr, 0, arr.length - 1), s1(arr, 0, arr.length - 1));
}

public static int f2(int[] arr, int i, int j, int[][][] dp) {
    if (dp[i][j][0] != -1) {
        return dp[i][j][0];
    }
    if (i == j) {
        dp[i][j][0] = arr[i];
    } else {
        dp[i][j][0] = Math.max(arr[i] + s2(arr, i + 1, j, dp), arr[j] + s2(arr, i, j - 1, dp));
    }
    return dp[i][j][0];
}

public static int s2(int[] arr, int i, int j, int[][][] dp) {
    if (dp[i][j][1] != -1) {
        return dp[i][j][1];
    }
    if (i == j) {
        dp[i][j][1] = 0;
    } else {
        dp[i][j][1] = Math.min(f2(arr, i + 1, j, dp), f2(arr, i, j - 1, dp));
    }
    return dp[i][j][1];
}
严格表结构动态规划

表格依赖关系分析:

  • 首先一定有 i <= j,所以表格的左下半部分用不到,可以不用管

  • 对于先手:

    • i == j时,先手表格f[i][j]对应位置的值就是arr[i]
    • 否则,f[i][j] = min(arr[i] + s[i + 1, j], arr[j] + s[i, j - 1]),可以看到f[i][j]依赖**后手表**下边格子s[i + 1, j]和左边格子s[i, j - 1],所以需要按照左下到右上的填写方式
  • 对于后手:

    • i == j时,后手表格s[i][j]对应位置的值就是0
    • 否则,s[i][j] = min(arr[i] + f[i + 1, j], arr[j] + f[i, j - 1]),可以看到s[i][j]依赖先手表下边格子f[i + 1, j]和左边格子f[i, j - 1],所以需要按照左下到右上的填写方式
  • 由上可知,先手表和后手表是相互依赖的,但是两张表的对角线都是可以直接填的,所以可以从对角线开始向右上角填。

/**
 * 严格表结构动态规划
 */
public static int win3(int[] arr) {
    if (arr == null || arr.length == 0) {
        return 0;
    }
    int n = arr.length;
    int[][][] dp = new int[n][n][2];
    for (int i = 0; i < n; i++) {
        dp[i][i][0] = arr[i];
        dp[i][i][1] = 0;
    }
    int col = 1;
    // 对角线开始位置row行col列
    while (col < n) {
        int i = 0;
        int j = col;
        while (i < n && j < n) {
            dp[i][j][0] = Math.max(arr[i] + dp[i + 1][j][1], arr[j] + dp[i][j - 1][1]);
            dp[i][j][1] = Math.min(dp[i + 1][j][0], dp[i][j - 1][0]);
            i++;
            j++;
        }
        col++;
    }
    return Math.max(dp[0][n - 1][0], dp[0][n - 1][1]);
}

2.8.3 三变量DP题目——象棋马走日

题目描述:在中国象棋棋盘(10 × 9)上建立坐标系(x9 y10),马从[0, 0]出发去往[x, y],但马一定要走k步,一共有多少种方法?

暴力递归
  • 首先马到达终点[x, y]的前一步,其实已经确定了,就是[x, y]周围的八个点
    在这里插入图片描述

  • 所以我们需要知道🐴通过跳k - 1步到达这8个点各有多少方法,然后进一步相加就是问题的答案。

  • 当然,这8个点也有可能在棋盘外,这样我们视为不能到达,即方法数为0

  • 在代码实现过程中,我们是从[x, y][0, 0],这样的话就不用一直记录目标位置了,统一都是[0, 0]

public static int getWays(int x, int y, int k) {
    return process(x, y, k);
}

public static int process(int x, int y, int rest) {
    if (x < 0 || x > 8 || y < 0 || y > 9) {
        // 边界外,无法到达目标点
        return 0;
    }
    if (rest == 0) {
        // 已经走完 k 步了,不能再动了
        return (x == 0 && y == 0) ? 1 : 0;
    }
    // 要到达的位置不越界,也有步数可以跳
    return process(x - 1, y + 2, rest - 1)
        + process(x + 1, y + 2, rest - 1)
        + process(x + 2, y + 1, rest - 1)
        + process(x + 2, y - 1, rest - 1)
        + process(x + 1, y - 2, rest - 1)
        + process(x - 1, y - 2, rest - 1)
        + process(x - 2, y - 1, rest - 1)
        + process(x - 2, y + 1, rest - 1);
}

从暴力递归可以看到,变量有三个,要到达的位置x, y,剩余的步数rest,所以可以使用一个三维数组来存储对应x, y, rest的值,记忆化搜索比较简单,我们直接来看严格表结构的动态规划

严格表结构的动态规划
  • 首先,对于超过三维数组范围的点,肯定无法到达目标点,所以方法为0(可以理解为一个长方体浸泡在0的海洋里)
  • 对于rest = 0的层,已经不能再移动了,所以只有点[0, 0]处的值为1,其他都是0
  • 对于rest > 0且不越界的点,上面我们已经分析过,它依赖8个可能去的点,而且这八个点都在rest - 1层上,也就是rest层依赖rest - 1层的点,而rest = 0的层已经确定了,所以我们可以从低到高依次填写,最终要得到[x, y, rest]的值
/**
 * 严格表结构动态规划
 */
public static int dpWays(int x, int y, int step) {
    if (x < 0 || x > 8 || y < 0 || y > 9 || step < 0) {
        // 边界外,无法到达目标点
        return 0;
    }
    int[][][] dp = new int[9][10][step + 1];
    dp[0][0][0] = 1;
    for (int h = 1; h <= step; h++) {
        for (int r = 0; r < 9; r++) {
            for (int c = 0; c < 10; c++) {
                dp[r][c][h] += getValue(dp, r - 1, c + 2, h - 1);
                dp[r][c][h] += getValue(dp, r + 1, c + 2, h - 1);
                dp[r][c][h] += getValue(dp, r + 2, c + 1, h - 1);
                dp[r][c][h] += getValue(dp, r + 2, c - 1, h - 1);
                dp[r][c][h] += getValue(dp, r + 1, c - 2, h - 1);
                dp[r][c][h] += getValue(dp, r - 1, c - 2, h - 1);
                dp[r][c][h] += getValue(dp, r - 2, c - 1, h - 1);
                dp[r][c][h] += getValue(dp, r - 2, c + 1, h - 1);
            }
        }
    }
    return dp[x][y][step];
}

/**
 * 越界判断
 */
public static int getValue(int[][][] dp, int row, int col, int step) {
    if (row < 0 || row > 8 || col < 0 || col > 9) {
        // 边界外,无法到达目标点
        return 0;
    }
    return dp[row][col][step];
}

2.8.4 零钱兑换

题目描述:给你一个整数数组 coins ,表示不同面额的硬币;以及一个整数 amount ,表示总金额。计算并返回可以凑够总金额的方法总数。(可以认为每种硬币的数量是无限的)

解题思路:

暴力递归
  • 可以利用从左往右的套路进行尝试,设立两个变量:从index硬币开始,利用index之后的硬币凑够rest块钱,返回总方法数
  • 由于每个硬币数量是无限的,所以我们需要考虑index硬币使用多少个,即要计算[index, rest - coins[index] * num],但要保证rest - coins[index] * num >= 0
  • 可以发现即使我们考虑的变量有三个,但实际上硬币个数的不同转化为了rest的不同
/**
 * 暴力递归方法
 * @param coins 所有硬币的面值,无重复
 * @param amount 要凑够的金额
 * @return 总方法数
 */
public static int ways1(int[] coins, int amount) {
    return process(coins, 0, amount);
}

/**
 * 从index硬币开始,利用index之后的硬币凑够rest块钱,返回总方法数
 */
public static int process(int[] coins, int index, int rest) {
    if (index == coins.length) {
        return rest == 0 ? 1 : 0;
    }
    // coin[index] 0枚、1枚 ... 不要超过 rest 的钱数
    int ways = 0;
    // num 是coin[index]使用的枚数
    for (int num = 0; coins[index] * num <= rest; num++) {
        ways += process(coins, index + 1, rest - coins[index] * num);
    }
    return ways;
}

可以看到递归函数的变量一种有两个index, rest,记忆化搜索的版本就比较容易了,直接用一张二维表存储所有[index, rest]的值即可,下面直接来说严格表结构的动态规划

严格表结构的动态规划
  • 先来看两个变量的范围:
    • index0 ~ coins.length
    • rest0 ~ amount
  • 所以二维表的大小为arr[coins.length][amount]
  • 考虑 base caseindex = coins.length 时,已经没有可考虑的硬币了,所以只有rest = 0的位置是1,其他位置都是0
  • index < coins.length的情况下,可以看到是通过一个for循环枚举 index 号硬币使用多少枚,然后把总的方法数相加并返回。而当前index号之前硬币的枚数确定了,接下来要确定index + 1之后的硬币使用的枚数,所以dp[index][rest]的值依赖dp[index + 1][...](即index + 1号硬币取各个枚数的情况)
/**
 * 严格表结构动态规划
 */
public static int ways2(int[] coins, int amount) {
    if (coins == null || coins.length == 0) {
        return 0;
    }
    int n = coins.length;
    int[][] dp = new int[n + 1][amount + 1];
    dp[n][0] = 1;
    for (int index = n - 1; index >= 0; index--) {
        for (int rest = 0; rest <= amount; rest++) {
            // num 是coin[index]使用的枚数
            for (int num = 0; coins[index] * num <= rest; num++) {
                dp[index][rest] += dp[index + 1][rest - coins[index] * num];
            }
        }
    }
    return dp[0][amount];
}

来分析一下时间复杂度

  • 首先是遍历整张表的复杂度: O ( N ∗ a m o u n t ) O(N * amount) O(Namount)
  • 计算每个格子的时候,我们进行了一个枚举每个硬币使用多少个的操作,最糟糕的情况就是硬币面值为1的情况,这时候我们需要枚举最多 a m o u n t amount amount
  • 所以总复杂度为 O ( N ∗ a m o u n t 2 ) O(N * amount^2) O(Namount2)

可以看到,其实枚举这个操作还是比较耗时的,遍历整张表这个过程是必要的,那枚举这个过程是否可以再优化呢?答案是可以

优化后的动态规划

画图分析枚举的整个过程,我们假定coin[index] = 3, rest = 200

在这里插入图片描述

可以看到dp[index][rest]依赖dp[index][rest], dp[index][rest - 3], dp[index][rest - 6], ...等等,从右到左每次都增加一枚硬币coin[index],但我们可以发现,其实该行每一个格子再计算时都是如此(从右到左,-3、-3、…),所以其实,前面的格子 dp[index][197] 已经帮我们算好了 dp[index][rest - 3] + dp[index][rest - 6] + ... 的值我们没有必要再加一遍了。

/**
 * 优化后的动态规划
 */
public static int ways3(int[] coins, int amount) {
    if (coins == null || coins.length == 0) {
        return 0;
    }
    int n = coins.length;
    int[][] dp = new int[n + 1][amount + 1];
    dp[n][0] = 1;
    for (int index = n - 1; index >= 0; index--) {
        for (int rest = 0; rest <= amount; rest++) {
            dp[index][rest] = dp[index + 1][rest];
            if (rest - coins[index] >= 0) {
                dp[index][rest] += dp[index][rest - coins[index]];
            }
        }
    }
    return dp[0][amount];
}

这种优化方式叫做斜率优化:当填表的时候,存在枚举行为,我们就可以利用观察的方式,看看邻近的位置是否可以替代枚举行为

2.9.5 动态规划总结

对于一个dp题目,解题套路:

  • 首先,需要找到一个尝试的版本,即暴力递归的版本
    • 尝试的方式可以是从左往右(从右往左)尝试范围尝试等等(这两个是比较常见的尝试模型,基本可以搞定七成以上的题目)
  • 暴力递归记忆化搜索
    • 这个过程比较容易,就是分析递归函数有几个变量,然后决定用几维数组来存储搜索结果
  • 记忆化搜索严格表结构动态规划
    • 首先,考虑 Base case 的情况,把能直接得到值的位置先填到表格里
    • 分析递归过程,搞清楚表格之间的依赖关系,从而确定表格的填写顺序
  • 优化填写过程
    • 遍历表格是必须的,无法优化;如果表格在填写时,不能直接利用简单运算得出结果,而是需要枚举、遍历等操作,这时就需要考虑优化的可能性

真正做题的时候,就会发现最难的还是尝试这一步,先不说能不能找出尝试的版本(),我们如何确定一个尝试是好的尝试还是坏的尝试呢?

  1. 单可变参数维度
    • 一个参数为整型的变化范围肯定要比参数为整型数组的变化范围少,所以尽量选择维度少的参数
  2. 可变参数个数
    • 可变参数个数决定了表结构的维度,所以可变参数个数越少越好
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值