【算法 - 动态规划】原来写出动态规划如此简单!

从本篇开始,我们就正式开始进入 动态规划 系列文章的学习。

本文先来练习两道通过 建立缓存表 优化解题过程的题目,对如何将 递归函数 修改成 动态规划 的流程有个基本的熟悉。

基本流程

  1. 用最简单的想法完成题目要求的 递归 函数;
  • 定义明确 递归函数 的功能!!!
  1. 分析是否存在 重叠子问题 ,即能否进行 剪枝 操作;

  2. 建立 数组或集合 缓存,寻找 状态转移方程 ,完成动态规划。

不太懂没关系,相信通过下面两道题目的练习就能找到感觉。

走到目标位置

假设有 N 个位置从左到右排成一排,记为 1 ~ N 。一个机器人开始在 start 位置上(1 ≤ start ≤ N),可以往左或者往右走,规定机器人只能走 K 步,最终能来到 aim(1 ≤ aim ≤ N) 位置的方法有多少种。

注意:

  • 若机器人在 1 位置,下一步只能向右走到 2 位置;

  • 若机器人在 N 位置,下一步只能向左走到 N-1 位置。

递归的准备

定义递归函数的功能: 从当前位置出发,走 k 步到达目的地,共有多少种行走的方法。

思考递归需要的参数: 当前位置、目标位置、需要走的步数、能行走的范围。

明确递归的边界条件: 如果当前需要走的步数为 0 ,且此时正好在目标位置,即找到了一种有效的行走方法;反之没有找到。

思路

寻找相同类型子问题:

  • 若机器人当前在 1 号位置 ,只能 往右走 ,因此走 k 步的答案,应该和在 2 号位置上走 k-1 步的答案一样。
  • 若机器人当前在 N 号位置 ,只能 往左走 ,因此走 k 步的答案,应该和在 N-1 号位置上走 k-1 步的答案一样。
  • 若机器人当前在 中间位置 ,因此走 k 步的答案,应该是前一个位置走 k-1 步与后一个位置走 k-1 步的总和。

代码

java 复制代码
public static int ways(int start, int K, int aim, int N) {
    if (N < 2 || start < 1 || start > N || aim < 1 || aim > N || K < 1) {
        return -1;
    }
    // 调用递归函数
    return process(start, K, aim, N);
}

private static int process(int cur, int remain, int aim, int N) {
    // 已经来到目标位置,且步数为 0。
    if (remain == 0) {
        return cur == aim ? 1 : 0;
    }
    // 到了最左边
    if (cur == 1) {
        return process(2, remain - 1, aim, N);
    }
    // 到了最右边
    if (cur == N) {
        return process(N - 1, remain - 1, aim, N);
    }
    // 在中间位置
    return process(cur - 1, remain - 1, aim, N) + process(cur + 1, remain - 1, aim, N);
}

相信上面的代码很容易看懂 ~


思考上面的递归过程,画出部分递归调用的过程图:

例如,当前机器人位置在 4 号位置还有 5 步要走,用 4,5 表示。

  1. 若往左走,来到 3 号位置,还有 4 步要走,用 3,4 表示;
  2. 若往右走,来到 5 号位置,还有 4 步要走,用 5,4 表示;

以此类推,递归调用图中出现了相同的 4,3 ,即出现了 重叠子问题 ,因此就有必要进行 缓存优化


接下来我们使用 缓存 的方法优化该递归过程:

思路

写完递归的代码之后,再来修改缓存代码就变的非常简单。

考虑到递归传递的参数中 process(int cur, int remain, int aim, int N) ,只有 cur, remain 两个参数会发生变化,因此,就可以构造一个 以这两个参数为变量二维 dp 数组

    1. 思考两个变量的取值范围,构造数组长度。由于1 ≤ cur ≤ N ,0 ≤ remain ≤ K ,因此将数组长度设置为 dp[N+1][K+1] 。
    1. 将 dp 数组值设置为 -1。若计算过该位置该步长的情况,就更新 dp 中对应位置的值。因此,若值不为 -1 ,说明之前计算过,可以直接获取,达到了剪枝的目的。
    1. 更新的方法是,先用变量 ans 记录下情况数,在退出本层递归时更新 dp 表。

缓存优化代码

java 复制代码
// dp缓存法
public static int ways(int start, int K, int aim, int N) {
    if (N < 2 || start < 1 || start > N || aim < 1 || aim > N || K < 1) {
        return -1;
    }
    int[][] dp = new int[N + 1][K + 1];
    for (int i = 0; i <= N; i++) {
        for (int j = 0; j <= K; j++) {
            dp[i][j] = -1;
        }
    }
    return process(start, K, aim, N, dp);
}

private static int process(int cur, int remain, int aim, int N, int[][] dp) {
    if (dp[cur][remain] != -1) {
        return dp[cur][remain];
    }
    int ans = 0;
    if (remain == 0) {
        ans = cur == aim ? 1 : 0;
    } else if (cur == 1) {
        ans = process(2, remain - 1, aim, N, dp);
    } else if (cur == N) {
        ans = process(N - 1, remain - 1, aim, N, dp);
    } else {
        ans = process(cur - 1, remain - 1, aim, N, dp) + process(cur + 1, remain - 1, aim, N, dp);
    }
    dp[cur][remain] = ans;
    return ans;
}

看懂思路之后,上面的代码也很容易看懂!

因为该递归是从原始问题开始,逐步分解为子问题的,因此称为 自顶向下备忘录 递归解法。


优化后的代码虽然使用 dp 数组进行了一定量的 剪枝 操作,但这并不是最终 动态规划版本 的代码,接下来,我们通过画图来寻找真正的 状态转移方程:

假设 N = 8,步数 K = 5,起始位置 start = 6,目标位置 aim = 3。由此可以画出初始 dp 表为:(坐标 用( 位置 , 剩余步数 )表示;表中的 数字大小 表示到达目标位置的 方法数

红色代表初始位置 (6,5),蓝色代表最终目标位置 (3,0)。 没有 0 号位置,因此第 0 列无效,用 × 表示。

根据递归函数中代码逻辑发现:

  1. 当步数为 0 时,只有目标位置的值为 1 ,其余均为 0。
java 复制代码
    // 已经来到目标位置,且步数为 0。
    if (remain == 0) {
        return cur == aim ? 1 : 0;
    }
  1. 当当前位置 cur 为 1 时,依赖 2 号位置,步数小 1 的信息(向左下依赖)。当当前位置 cur 为 N 时,依赖 N - 1 号位置,步数小 1 的信息(向左上依赖)。
java 复制代码
    // 到了最左边
    if (cur == 1) {
        return process(2, remain - 1, aim, N);
    }
    // 到了最右边
    if (cur == N) {
        return process(N - 1, remain - 1, aim, N);
    }
  1. 当在中间位置时,共同依赖前后一个位置,步数均小 1 的信息(向左上和左下依赖)。
java 复制代码
    // 在中间位置
    return process(cur - 1, remain - 1, aim, N) + process(cur + 1, remain - 1, aim, N);

由此可以在图中标注依赖关系方向。

根据图中的依赖方向很容易一行一行的填出所有答案:

表中坐标 (6,5) 的值是 5 ,说明初始在位置 6 走 5 步,走到位置 3 共有 5 种走法。

这张表就是动态规划表!

现在我们就通过代码模拟刚才的填表过程:

java 复制代码
// 动态规划
public static int ways3(int start, int K, int aim, int N) {
    if (N < 2 || start < 1 || start > N || aim < 1 || aim > N || K < 1) {
        return -1;
    }
    int[][] dp = new int[N + 1][K + 1];
    dp[aim][0] = 1;
    for (int remain = 1; remain <= K; remain++) {
        dp[1][remain] = dp[2][remain - 1];
        for (int cur = 2; cur < N; cur++) {
            dp[cur][remain] = dp[cur - 1][remain - 1] + dp[cur + 1][remain - 1];
        }
        dp[N][remain] = dp[N - 1][remain - 1];
    }
    return dp[start][K];
}

代码解释

第一列只有dp[aim][0]位置为 1 ,其余位置均为 0 。之后从上往下从左到右,一列一列的填写:

  • for 循环中,把第一行和最后一行单独填写,即分别向左下和左上依赖。中间行分别依赖左上和左下部分的值。
  • 最终返回要求的目标位置的 dp[start][K] 的值。

因为该方法是从最小的子问题开始逐步求解,因此称为 自底向上的动态规划。


上面这道题使用了 一张 dp 表 完成了自底向上的动态规划,下面我们增加点难度,来看一道使用 两张 dp 表 才能完成的动态规划题目。

纸牌博弈问题

牛客网链接-纸牌博弈问题:给定一个整型数组arr,代表数值不同的纸牌排成一条线。玩家 A 、B依次拿走每张纸牌,规定玩家 A 先拿,玩家 B 后拿,每个玩家每次只能拿走最左和最右侧的纸牌,玩家A和玩家B绝顶聪明。请返回最后的获胜者的分数。

例如:

返回获胜者的答案应该是:1+100=101

递归的准备

定义递归函数的功能:

  1. 如果是 先手 则能够获得的最大值;
  2. 如果是 后手 则能够获得的最大值。

思考递归需要的参数: 当前剩余的整个数组、两个边界 L 和 R 。

明确递归的边界条件: 只剩下一张牌时,如果是先手,获得该牌的数值,如果是后手,获得数值为 0 。

思路

  1. 如果是先手:
  • 拿了左侧 L最终获得的分数 = 此时左侧 L 的分数 + 剩下的部分做为后手时拿到的分数。
  • 拿了右侧 R最终获得的分数 = 此时右侧 R 的分数 + 剩下的部分做为后手时拿到的分数。
  • 此时应该取两种情况的 最大值 作为最终的抉择。
  1. 如果是后手:
  • 此时 [L, R] 上的抉择权就不在自己手中了(因为此时先手得先拿)。
  • 因此,作为后手的自己只能获得先手拿过之后的最小值(先手肯定不会让你拿最大了!又不傻~)

代码

java 复制代码
public static int win(int[] arr) {
    if (arr == null || arr.length == 0) {
        return 0;
    }
    int first = f(arr, 0, arr.length - 1);
    int after = g(arr, 0, arr.length - 1);
    return Math.max(first, after);
}
// 先手
private static int f(int[] arr, int L, int R) {
    if (L == R) {
        return arr[L];
    }
    // 先手拿左侧牌获得的分数
    int p1 = arr[L] + g(arr, L + 1, R);
    // 先手拿右侧牌获得的分数
    int p2 = arr[R] + g(arr, L, R - 1);
    return Math.max(p1, p2);
}
// 后手
private static int g(int[] arr, int L, int R) {
    if (L == R) {
        return 0;
    }
    // 如果先手拿了左侧牌,后手获得的分数
    int p1 = f(arr, L + 1, R);
    // 如果先手拿了右侧牌,后手获得的分数
    int p2 = f(arr, L, R - 1);
    return Math.min(p1, p2);
}

思考上面的递归过程,画出部分递归调用的过程图:

例如,此时纸牌长度下标为 1~5,用 1,5 表示。

  1. 若拿左侧,还剩下 2~5,用 2,5 表示;
  2. 若拿右侧,还剩下 1~4,用 1,4 表示;

以此类推,递归调用图中出现了相同的 2,4 ,即出现了 重叠子问题 ,因此就有必要进行 缓存优化

这次我们直接根据该递归函数画出最终版本的 动态规划 dp 表

思路

考虑到递归传递的参数中 f(int[] arr, int L, int R) ,只有 L, R 两个参数会发生变化,且 fg 函数相互依赖。因此,可以构造 两个 以这两个参数为变量二维 dp 数组

    1. 思考两个变量的取值范围,构造数组长度。由于0 ≤ L ≤ R < N,因此将数组长度均设置为 dp[N][N] 。
    1. 由于 L ≤ R , dp 表为上三角矩阵。

假设,数组 arr={1, 2, 100, 4}。

根据递归函数中代码逻辑发现:

  1. 当为先手时,fmap 的值为:
  • L == R 时为当前 arr[L] 的值。
  • L != R 时依赖 gmap 表对应相同位置中 arr[L] + gmap(L + 1, R)arr[R] + gmap(L , R - 1) 中的最大值。

这里一定要区分好谁和谁相加后取最大值哦~位置关系别搞乱了!

java 复制代码
private static int f(int[] arr, int L, int R) {
    if (L == R) {
        return arr[L];
    }
    // 先手拿左侧牌获得的分数
    int p1 = arr[L] + g(arr, L + 1, R);
    // 先手拿右侧牌获得的分数
    int p2 = arr[R] + g(arr, L, R - 1);
    return Math.max(p1, p2);
}
  1. 当为后手时,gmap 的值为:
  • L == R 时为 0 。
  • L != R 时依赖 fmap 表对应相同位置中 (L + 1, R)(L , R - 1) 中的最小值。
java 复制代码
private static int g(int[] arr, int L, int R) {
    if (L == R) {
        return 0;
    }
    // 如果先手拿了左侧牌,后手获得的分数
    int p1 = f(arr, L + 1, R);
    // 如果先手拿了右侧牌,后手获得的分数
    int p2 = f(arr, L, R - 1);
    return Math.min(p1, p2);
}

由此可以在图中展示出依赖关系:

由此可以完整填出整个 fmapgmap 表:

因此,返回最大值 max(101,6) = 101。

这两张表就是最终动态规划表!

现在我们就通过代码模拟刚才的填表过程:

java 复制代码
public static int win(int[] arr) {
    if (arr == null || arr.length == 0) {
        return 0;
    }
    int N = arr.length;
    int[][] fmap = new int[N][N];
    int[][] gmap = new int[N][N];
    for (int i = 0; i < N; i++) {
        fmap[i][i] = arr[i];
        // new 数组时, gmap 本来就为 0
        // gmap[i][j] = 0;
    }
    for (int startCol = 1; startCol < N; startCol++) {
        int L = 0;
        int R = startCol;
        // 从左上到右下斜着填表
        while (R < N) { // R 比 L 先到达边界
            fmap[L][R] = Math.max(arr[L] + gmap[L + 1][R], arr[R] + gmap[L][R - 1]);
            gmap[L][R] = Math.min(fmap[L + 1][R], fmap[L][R - 1]);
            // 斜向下走
            L++;
            R++;
        }
    }
    return Math.max(fmap[0][N - 1], gmap[0][N - 1]);
}

扩展

由于本题的 dp 表是两张 上三角矩阵 ,因此可以采用 压缩空间 的办法,将 二维数组 矩阵 压缩一维数组 ,可以减少大约一半的矩阵空间,但空间复杂度仍然是 <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( N 2 ) O(N^2) </math>O(N2) 级别的。

这里涉及到了有关 矩阵压缩 的知识,有兴趣的小伙伴可以自行学习下 如何将三角矩阵压缩成为一维数组 哦!

~ 点赞 ~ 关注 ~ 不迷路 ~!!!

相关推荐
刚学HTML19 分钟前
leetcode 05 回文字符串
算法·leetcode
AC使者39 分钟前
#B1630. 数字走向4
算法
冠位观测者43 分钟前
【Leetcode 每日一题】2545. 根据第 K 场考试的分数排序
数据结构·算法·leetcode
小_太_阳1 小时前
Scala_【1】概述
开发语言·后端·scala·intellij-idea
智慧老师1 小时前
Spring基础分析13-Spring Security框架
java·后端·spring
古希腊掌管学习的神1 小时前
[搜广推]王树森推荐系统笔记——曝光过滤 & Bloom Filter
算法·推荐算法
qystca2 小时前
洛谷 P1706 全排列问题 C语言
算法
浊酒南街2 小时前
决策树(理论知识1)
算法·决策树·机器学习
就爱学编程2 小时前
重生之我在异世界学编程之C语言小项目:通讯录
c语言·开发语言·数据结构·算法
学术头条2 小时前
清华、智谱团队:探索 RLHF 的 scaling laws
人工智能·深度学习·算法·机器学习·语言模型·计算语言学