每日算法总结——如何将暴力递归转为严格表结构动态规划

下面以一道题目为例,演示如何将暴力递归优化成动态规划。

题目描述:给定n个位置:1~n,有一个机器人,当前处于cur位置,它想要去end位置,但是必须要走k步,问有多少种行走方法?

暴力递归

解题思路:分解尝试,机器人在当前位置上会有向左走和向右走两种选择,但如果在最左端最右端就会只有一种选择,每走一步就把剩余步数rest减一,当rest为0时,如果机器人在end上,说明形成了一种可行的方法。

public class RobotWalk {

    public static int walkWays(int n, int e, int s, int k) {
        return f(n, e, k, s);
    }

    /**
     * 暴力递归尝试
     * @param n 一共 1 ~ n 这么多位置——固定参数
     * @param end 最终的目标是end——固定参数
     * @param rest 还剩rest步要走
     * @param cur 当前在cur位置
     * @return 可以到达目的地的方法数
     */
    public static int f(int n, int end, int rest, int cur) {
        // rest == 0已经不能再走了,就看当前位置是否已经来到了end
        if (rest == 0) {
            return cur == end ? 1 : 0;
        }
        // rest == 1,只能往右走
        if (cur == 1) {
            return f(n, end, rest - 1, cur + 1);
        }
        // rest == n,只能往左走
        if (cur == n) {
            return f(n, end, rest - 1, cur - 1);
        }
        // 当前处于中间位置,左右都能走
        return f(n, end, rest - 1, cur - 1)
                + f(n, end, rest - 1, cur + 1);
    }
}

记忆化搜索优化

上述代码中的递归函数 f 的形参中,nend 是固定参数,只有 restcur 是可变参数,也就是说,每一种 restcur 的组合,都记录了从当前位置 curend 的方法数。

但是不难发现,所有组合的依赖关系其实是一棵二叉树:

在这里插入图片描述

树的高度为k,所以时间复杂度为 O ( 2 k ) O(2^k) O(2k),很多f(rest, cur)会被重复计算,导致总的时间复杂度很高。

由于每个 f(rest, cur) 都没有计算的先后次序要求,所以我们可以用一种数据结构将计算结果存储起来,避免大量的重复计算;因为有两个变量,所以可以利用二维数组来存储(变量更多的话,就用多维数组)。

public static int walkWays2(int n, int end, int start, int k) {
    // dp[rest][cur]: 从cur出发,走rest步,有多少种方法可以到达end
    int[][] dp = new int[k + 1][n + 1];
    for (int[] ints : dp) {
        Arrays.fill(ints, -1);
    }
    return f2(n, end, k, start, dp);
}

/**
 * 记忆化搜索优化
 *
 * @param n    一共 1 ~ n 这么多位置——固定参数
 * @param end  最终的目标是end——固定参数
 * @param rest 还剩rest步要走
 * @param cur  当前在cur位置
 * @return 可以到达目的地的方法数
 */
public static int f2(int n, int end, int rest, int cur, int[][] dp) {
    if (dp[rest][cur] != -1) {
        // 已经有记录了,直接返回
        return dp[rest][cur];
    }
    // rest == 0已经不能再走了,就看当前位置是否已经来到了end
    if (rest == 0) {
        dp[rest][cur] = cur == end ? 1 : 0;
    } else if (cur == 1) {
        // rest == 1,只能往右走
        dp[rest][cur] = f2(n, end, rest - 1, cur + 1, dp);
    } else if (cur == n) {
        // rest == n,只能往左走
        dp[rest][cur] = f2(n, end, rest - 1, cur - 1, dp);
    } else {
        // 当前处于中间位置,左右都能走
        dp[rest][cur] = f2(n, end, rest - 1, cur - 1, dp)
            + f2(n, end, rest - 1, cur + 1, dp);
    }
    return dp[rest][cur];
}

由于有了记忆表的存在,整个算法的时间复杂度降低为 O ( N ∗ K ) O(N*K) O(NK)

严格表结构动态规划

上述的记忆化搜索,其实就是一个不断查表、填表的过程,我们可以试着分析一下整张表是怎样填的(以 n = 5、start = 2、end = 4、k = 4 为例):

  • 表的行代表当前剩余的步数 rest(0 ~ k),列代表当前所处的位置 cur(1~n)

  • rest = 0这一行的结果是已知的,也就是只有cur = end时,才是1,其余都是0。

  • cur 来到左边界时,只能向右走,所以[rest, cur] = [rest - 1, cur + 1],也就是只依赖右上角的值
    在这里插入图片描述

  • cur 来到右边界时,只能向左走,所以 [rest, cur] = [rest - 1, cur - 1],也就是只依赖左上角的值
    在这里插入图片描述

  • cur 来到中间位置时,左右都能走,所以 [rest, cur] = [rest - 1, cur - 1] + [rest - 1, cur + 1],也就是依赖左上角和右上角的值。
    在这里插入图片描述

  • 通过上述分析,我们可以发现整个递归过程完全可以转化成填表的过程,这其实就是严格表结构的动态规划,它和记忆化搜索是有区别的:

    • 记忆化搜索不去整理表格位置间的依赖关系,本质就是一个纯缓存
    • 严格表结构的动态规划需要纠结表格位置间的依赖顺序。
    • 而这个依赖顺序,其实就是我们数据结构课上学的动态转移方程(突然就好理解多了😭)
/**
 * 严格表结构的动态规划
 */
public static int dpWays(int n, int end, int start, int k) {
    // dp[...][0]废了不用
    int[][] dp = new int[k + 1][n + 1];
    dp[0][end] = 1;
    for (int rest = 1; rest <= k; rest++) {
        for (int cur = 1; cur <= n; cur++) {
            if (cur == 1) {
                dp[rest][1] = dp[rest - 1][2];
            } else if (cur == k) {
                dp[rest][k] = dp[rest - 1][k - 1];
            } else {
                dp[rest][cur] = dp[rest - 1][cur - 1] + dp[rest - 1][cur + 1];
            }
        }
    }
    return dp[k][start];
}

时间复杂度也是 O ( N ∗ K ) O(N*K) O(NK)

实战

题目练习:最少硬币

题目描述:给你一包含n个硬币的集合int arr[n],其中的每一个数代表的是对应硬币的面值,可能会有重复的硬币,请问组成(恰好等于)总金额 aim 所需的最少硬币数是多少?

解题思路:

首先,先找出一种可尝试的方法,即暴力递归法,如何尝试?

  • 从左到右的思路,对于每一枚硬币,都有选与不选两种选择
  • 初始时,我需要从0号硬币开始,求得凑齐aim块钱最少需要多少硬币,即此时rest = aimindex = 0
  • 做出选择
    • 如果选index号硬币,则之后我需要从inedx + 1号硬币开始,求得凑齐rest - arr[index]块钱最少需要多少硬币
    • 如果不选index号硬币,则之后我需要从inedx + 1号硬币开始,求得凑齐rest块钱最少需要多少硬币
  • Base Case的考虑
    • rest < 0时,说明已经选择的硬币总金额超过了目标金额aim,不满足条件,返回-1,表示没有办法达到条件了(说明之前的决策是错误的)
    • rest == 0时,说明已经选择的硬币总金额恰好组成目标金额aim,不再需要硬币了,返回0
    • rest > 0时:
      • 如果index == arr.length,说明已经没有硬币可选了,但是已经选择的硬币总金额还没达标,返回-1
      • 否则index < arr.length,说明rest > 0并且还有硬币可选,做出选择
/**
 * 暴力递归
 * @param arr 所有的硬币
 * @param index 从哪个硬币开始选
 * @param rest 需要凑齐的金额
 * @return 所需的最小硬币数
 */
public static int process1(int[] arr, int index, int rest) {
    if (rest < 0) {
        // 超过目标金额了
        return -1;
    } else if (rest == 0) {
        return 0;
    } else {
        if (index == arr.length) {
            // 没有硬币了
            return -1;
        } else {
            int select = process1(arr, index + 1, rest - arr[index]);
            int notSelect = process1(arr, index + 1, rest);
            if (select == -1 && notSelect == -1) {
                // 选和不选都行不通,说明之前的决策有问题,返回-1
                return -1;
            } else {
                // 返回可行的解
                if (select == -1) {
                    return notSelect;
                }
                if (notSelect == -1) {
                    return select + 1;
                }
                // 返回所需硬币数较少的解
                return Math.min(select + 1, notSelect);
            }
        }
    }
}

进一步优化:记忆化搜索版本

public static int minCoins2(int[] arr, int aim) {
    int[][] dp = new int[arr.length + 1][aim + 1];
    for (int[] i : dp) {
        Arrays.fill(i, -2);
    }
    return process2(arr, 0, aim, dp);
}

/**
 * 记忆化搜索
 * @param arr 所有的硬币
 * @param index 从哪个硬币开始选
 * @param rest 需要凑齐的金额
 * @param dp dp[index][rest]记录process2(arr, index, rset)的值
 * @return 所需的最少硬币数
 */
public static int process2(int[] arr, int index, int rest, int[][] dp) {
    if (rest < 0) {
        // 超过目标金额了
        return -1;
    }
    if (dp[index][rest] != -2) {
        return dp[index][rest];
    }
    if (rest == 0) {
        dp[index][rest] = 0;
    } else {
        if (index == arr.length) {
            // 没有硬币了
            dp[index][rest] = -1;
        } else {
            int select = process2(arr, index + 1, rest - arr[index], dp);
            int notSelect = process2(arr, index + 1, rest, dp);
            if (select == -1 && notSelect == -1) {
                // 选和不选都行不通,说明之前的决策有问题,返回-1
                dp[index][rest] = -1;
            } else {
                // 返回可行的解
                if (select == -1) {
                    dp[index][rest] = notSelect;
                } else if (notSelect == -1) {
                    dp[index][rest] = select + 1;
                } else {
                    // 返回所需硬币数较少的解
                    dp[index][rest] = Math.min(select + 1, notSelect);
                }
            }
        }
    }
    return dp[index][rest];
}

严格表结构的动态规划

分析上述dp表(dp[index][rest]表示从index号硬币开始选,凑够rest块钱需要的最少硬币数)的依赖关系

  • 首先 rest < 0(在表外),表示超过的目标金额,此时默认为 -1
  • rest = 0,表示恰好凑齐目标金额,不再需要硬币了,将该列设为0
  • rest > 0,但index = arr.length说明已经没有硬币可取,但还未凑够钱,说明决策有问题,将该行设为-1
  • rest > 0,且index < arr.length,就需要决策当前硬币的选与不选两种情况
public static int minCoins3(int[] arr, int aim) {
    int arrLength = arr.length;
    int[][] dp = new int[arrLength + 1][aim + 1];
    // rest = 0 的部分都为0
    for (int i = 0; i <= arrLength; i++) {
        dp[i][0] = 0;
    }
    // index = arrLength, rest > 0的部分都为-1
    for (int i = 1; i <= aim; i++) {
        dp[arrLength][i] = -1;
    }
    for (int cur = arrLength - 1; cur >= 0; cur--) {
        for (int res = 1; res <= aim; res++) {
            // 选当前硬币cur
            int select = -1;
            if (res - arr[cur] >= 0) {
                select = dp[cur][res - arr[cur]];
            }
            // 不选当前硬币cur
            int notSelect = dp[cur + 1][res];
            if (select == -1 && notSelect == -1) {
                dp[cur][res] = -1;
            } else if (select == -1) {
                dp[cur][res] = notSelect;
            } else if (notSelect == -1) {
                dp[cur][res] = select + 1;
            } else {
                dp[cur][res] = Math.min(select + 1, notSelect);
            }
        }
    }
    return dp[0][aim];
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值