动态规划详解

本文详细介绍了动态规划的概念、递归与减支递归解法,以及自底向上动态规划。以青蛙跳台阶问题为例,展示了如何找出最优子结构、确定边界状态和状态转移方程,最终通过代码实现。适合理解和应用动态规划解决实际问题。
摘要由CSDN通过智能技术生成

什么是动态规划

动态规划(Dynamic programming,简称 DP),通过把原问题分解为相对简单的子问题的方式求解复杂问题的方法。动态规划适用于有重叠子问题和最优子结构性质的问题。

什么是最优子结构性质?

问题的最优解包含子问题的最优解。反过来说就是,可以通过子问题的最优解,推导出问题的最优解。

如果把最优子结构,对应到动态规划问题模型上,可以理解为,后面阶段的状态可以通过前面状态推导出来。

而且这个最优子结构具有无后效性,即当前的状态与后面的决策没有关系,后面的决策能够安心地使用前面的局部最后解。

简单来说,动态规划就是,把给定问题拆成一个个子问题,直到子问题可以直接解决。然后把子问题答案保存起来,以减少重复计算。再根据子问题答案反推,得出原问题解的一种方法。

根据以上描述,可以提炼出动态规划的核心思想:

拆分子问题;保存子问题答案,记住过往,减少重复计算;

示例

leetcode原题:一只青蛙一次可以跳上1级台阶,也可以跳上2级台阶。求该青蛙跳上一个10级的台阶总共有多少种跳法。

可以整理下思路:前提:青蛙一次可以跳 1 or 2级台阶

  1. 如果青蛙要跳上10级台阶,它可以从 第8级台阶跳2级 or 第9级台阶跳1级 到第10级台阶,至此问题可以转移为 青蛙跳到第8 or 9级台阶有几种跳法;
  2. 如果青蛙要跳上9级台阶,它可以从 第7级台阶跳2级 or 第8级台阶跳1级 到第9级台阶,至此问题可以转移为 青蛙跳到第7 or 8级台阶有几种跳法;
  3. 如果青蛙要跳上8级台阶,它可以从 第6级台阶跳2级 or 第7级台阶跳1级 到第8级台阶,至此问题可以转移为 青蛙跳到第6 or 7级台阶有几种跳法;

综上所述,将f(n)定义为跳上n级台阶的跳法数,则

f(10) = f(9) + f(8)
f(9) = f(8) + f(7)
f(8) = f(7) + f(6)
......
f(3) = f(2) + f(1)
f(2) = 2
f(1) = 1

可以总结出通项公式:

f(n) = f(n-1) + f(n-2),其中n >= 3
f(1) = 1
f(2) = 2

至此,已经可以根据以上公式,递归解决问题。

普通递归解法

public static int numWays(int n) {
    if (n == 1) {
        return 1;
    }

    if (n == 2) {
        return 2;
    }

    return numWays(n-1) + numWays(n -2);
}

用此方法已经能解决问题,但是仔细分析这个递归过程,递归树:

截图


这个递归过程的总 时间复杂度 = 解决一个子问题时间 * 子问题个数

其中解决一个子问题的时间 = f(n-1) + f(n-2),时间复杂度为O(1),子问题个数 = 递归树节点数 = 2^n - 1,时间复杂度为O(2^n);

因此以上递归算法的时间复杂度为 = O(1) * O(2^n) = O(2^n),当n比较大时,算法所需时间爆炸式增长,很容易超时。

但是分析以上递归树节点,可以发现有很多重复节点的计算,比如f(8)计算了2次,自然的优化思路是将这些重复计算的节点结果保存下来,当需要用到的时候直接取,能够节省很多计算时间,减去很多递归树的重复分支


保存节点结果的递归解法(减支递归法)

public static void main(String[] args) {
    StopWatch stopWatch = new StopWatch();
    stopWatch.start("numWays");
    System.out.println(numWays(45));
    stopWatch.stop();
    System.out.println(stopWatch.getLastTaskTimeMillis());

    stopWatch.start("numWaysWithJianZhi");
    System.out.println(numWaysWithJianZhi(45));
    stopWatch.stop();
    System.out.println(stopWatch.getLastTaskTimeMillis());
}

public static int numWays(int n) {
    if (n == 1) {
        return 1;
    }

    if (n == 2) {
        return 2;
    }

    return numWays(n-1) + numWays(n -2);
}

static Map<Integer, Integer> fnMap = Maps.newHashMap();

public static int numWaysWithJianZhi(int n) {
    if (n == 1) {
        return 1;
    }

    if (n == 2) {
        return 2;
    }
    Integer numWays = fnMap.get(n);
    if (numWays != null) {
        return numWays;
    }

    numWays = numWaysWithJianZhi(n-1) + numWaysWithJianZhi(n -2);
    fnMap.put(n, numWays);
    return numWays;
}

输出:

1836311903
3403
1836311903
0

可以看到如果将节点记录保存下来,会很快。那是保存已经计算过的节点结果,将递归树的分支剪掉了

截图

递归树变成光秃秃的树干,即:

  • 子问题个数 = 树节点数= n
  • 解决一个子问题的时间复杂度 = O(1)
  • 保存节点结果的递归算法的时间复杂度 = O(n)
  • 由于需要保存 n 个节点的计算结果,所以空间复杂度 = O(n)

自底向上的动态规划解法

动态规划 的基本思想和上述 保存节点结果的递归算法一致,都是减少重复计算,时间复杂度也都是 O(n),有所区别的是:

  1. 可以看到,保存节点结果的递归算法是从 f(n) > f(n-1) > … > f(1)方向求解,即 自顶向下;
  2. 动态规划从较小问题的解,由交叠性质,逐步决策出较大问题的解,f(1) > f(2) > … > f(n-1) > f(n)方向求解,即自底向上;

动态规划的几个典型特征:最优子结构,状态转移方程,边界,重叠子问题。

在上述青蛙跳阶问题中:

  1. 最优子结构:f(n-1) , f(n-2)
  2. 状态转移方程:f(n) = f(n-1) + f(n-2),其中n >= 3
  3. 边界:f(1) = 1,f(2) = 2
  4. 重叠子问题:f(10) = f(9)+ f(8),f(9) = f(8)+ f(7),…

那么自底向上的解法为

截图

可以看到用一个for循环就可以搞定了。

保存节点结果的递归解法,空间复杂度是O(n),但是,仔细观察上图,可以发现,f(n) 只依赖前面两个数,所以只需要两个变量a和b来存储,就可以满足需求了,因此空间复杂度 = O(1)

截图

public static void main(String[] args) {
    stopWatch.start("numWaysWithDP");
    System.out.println(numWaysWithDP(45));
    stopWatch.stop();
    System.out.println(stopWatch.getLastTaskTimeMillis());
}

输出:

1836311903
0

总结

什么问题可以使用动态规划

如果一个问题,可以把所有可能的答案穷举出来,并且穷举出来后,发现存在重叠子问题,就可以考虑使用动态规划。

经典应用场景:最长递增子序列、最小编辑距离、背包问题、凑零钱问题等。

解题思路

动态规划的核心思想就是拆分子问题,记住过往,减少重复计算。 并且动态规划一般都是自底向上的,动态规划的思路:

  • 穷举分析
  • 找出规律,确定最优子结构
  • 确定边界
  • 写出状态转移方程
  • 用状态转移方程回归示例,确定正确性
  • 转化为代码实现

leetcode实战案例

题目

给定一个整数数组 nums ,找到其中最长严格递增子序列的长度。

示例1:
输入:nums = [10,9,2,5,3,7,101,18,7]
输出:4
解释:最长递增子序列是 [2,3,7,101],因此长度为 4 。

示例2:
输入:nums = [0,1,0,3,2,3]
输出:4

分析

分析问题时,可以思考下,动态规划主要思想是:是否具有最优子结构,并且是否无后效性,子问题子问题是否重复

以示例1进行问题分析。

自底向上穷举分析
  1. 当nums只有一个元素10,最长严格递增子序列是[10],长度为1;
  2. 当nums再加入一个元素9,最长严格递增子序列是[10] or [9],长度为1;
  3. 当nums再加入一个元素2,最长严格递增子序列是[10] or [9] or [2],长度为1;
  4. 当nums再加入一个元素5,最长严格递增子序列是[2,5],长度为2;
  5. 当nums再加入一个元素3,最长严格递增子序列是[2,5] or [2,3],长度为2;
  6. 当nums再加入一个元素7,最长严格递增子序列是[2,5,7] or [2,3,7],长度为3;
  7. 当nums再加入一个元素101,最长严格递增子序列是[2,5,7,101] or [2,3,7,101],长度为4;
  8. 当nums再加入一个元素18,最长严格递增子序列是[2,5,7,18] or [2,3,7,18] or [2,5,7,101] or [2,3,7,101],长度为4;
  9. 当nums再加入一个元素7,最长严格递增子序列是[2,5,7,18] or [2,3,7,18] or [2,5,7,101] or [2,3,7,101],长度为4;
找出规律,确定最优子结构

为方便说明,做如下定义:

subNums[i]:表示原数组nums[n]从nums[1]到nums[i]的子序列(1<=i<=n)
maxIncreaseSubSeq[i]:表示subNums[i]数组的最长严格递增子序列(1<=i<=n)
maxIncreaseSubSeqLen[i]:maxIncreaseSubSeq[i]的长度(1<=i<=n)
  1. 根据以上穷举分析,可以看到,maxIncreaseSubSeq[i]要么是以nums[i]结尾的最长严格递增子序列,要么是subNums[i-1]的最长严格递增子序列(maxIncreaseSubSeq[i-1]),即
maxIncreaseSubSeq[i] = max(以nums[i]结尾的最长严格递增子序列, subNums[i-1]的最长严格递增子序列)

  1. 接下来思考下,以nums[i]结尾的最长严格递增子序列,这个条件能否再做文章?

    既然是以nums[i]结尾的最长严格递增子序列,显然可以得到这个最长子序列除结尾nums[i]外的序列也是以nums[j]结尾的最长严格递增子序列,其中 0<=j<i 且 nums[j] < nums[i]

结合1,2可以得到:

maxIncreaseSubSeq[i] = max(max(以nums[j]结尾的最长严格递增子序列) + nums[i](0<=j<i 且 nums[j] < nums[i]))(1<=i<=n)
确定边界

当nums数组只有一个元素时,最长递增子序列的长度dp(1)=1,当nums数组有两个元素时,dp(2) = 2或者1, 因此边界就是dp(1)=1。

状态转移方程

截图

最长递增子序列 = max(dp[i]),其中 dp(i)表示以nums[i]结尾的最长严格递增子序列长度。

用状态转移方程回归示例

截图

  1. 当nums只有一个元素10,触达边界,dp[1] = 1;
  2. 当nums再加入一个元素9,dp[2] = 1;
  3. 当nums再加入一个元素2,dp[3] = 1;
  4. 当nums再加入一个元素5,dp[4] = dp[3] + 1 = 2;
  5. 当nums再加入一个元素3,dp[5] = dp[3] + 1 = 2;
  6. 当nums再加入一个元素7,dp[6] = max(dp[3],dp[4],dp[5]) + 1 =3;
  7. 当nums再加入一个元素101,dp[7] = max(dp[1],dp[2],dp[3],dp[4],dp[5],dp[6]) + 1 = 4;
  8. 当nums再加入一个元素18,dp[8] = max(dp[1],dp[2],dp[3],dp[4],dp[5],dp[6]) + 1 = 4;
  9. 当nums再加入一个元素7,dp[9] = max(dp[3],dp[4],dp[5]) + 1 = 3;

最长递增子序列 = max(dp[i])(1<=i<=9) = 4。

转化为代码实现
public static void main(String[] args) {
    int[] nums = new int[]{10,9,2,5,3,7,101,18,7};
    for (int i = 0; i < nums.length; i++) {
        System.out.println("dp[" + i + "] = " + maxIncreaseSubSeqWithDP(nums, i));
    }
}

public static int maxIncreaseSubSeqWithDP(int[] nums, int n) {
    if (n == 0) {
        return 1;
    }
    int maxDP = 1;
    int[] dp = new int[n + 1];
    //初始化就是边界情况
    dp[0] = 1;
    //自底向上遍历
    for (int i = 1; i <= n; i++) {
        dp[i] = 0;
        //从下标0到i遍历
        for (int j = 0; j < i; j++) {
            //找到前面比nums[i]小的数nums[j],即有dp[i]= dp[j]+1
            if (nums[j] < nums[i] && dp[j] > dp[i]) {
                dp[i] = dp[j];
            }
        }
        //加上自身
        dp[i] ++;
        //顺带求出max(dp)
        maxDP = maxDP < dp[i] ? dp[i] : maxDP;
    }
    return maxDP;
}

输出:

dp[0] = 1
dp[1] = 1
dp[2] = 1
dp[3] = 2
dp[4] = 2
dp[5] = 3
dp[6] = 4
dp[7] = 4
dp[8] = 4

符合预期。


参考:

看一遍就理解:动态规划详解

【动态规划理论】:一篇文章带你彻底搞懂最优子结构、无后效性和重复子问题

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值