算法系列——动态规划(DP)

1,255 阅读3分钟

动态规划

本文先通过一道题的求解过程逐步带大家了解动态规划,后续会做一些练习,带大家解决动态规划相关题目

1. 首先看一道算法题 —— 剑指 Offer 10- I. 斐波那契数列

写一个函数,输入 n ,求斐波那契(Fibonacci)数列的第 n 项。斐波那契数列的定义如下:

1F(0) = 0,   F(1) = 1
2F(N) = F(N - 1) + F(N - 2), 其中 N > 1.

斐波那契数列由 0 和 1 开始,之后的斐波那契数就是由之前的两数相加而得出。

答案需要取模 1e9+7(1000000007),如计算初始结果为:1000000008,请返回 1

1.1. 首先我们想到的可能是递归

public int fib(int n) {

    if (n == 0) return 0;
    if (n == 1) return 1;

    return (fib(n - 1) + fib(n - 2)) % 1000000007;
}

img

原理:通过把f(n) 分解成 f(n - 1) 和 f(n - 2) 两个子问题计算,并递归,直到终止条件为止

缺点:通过上图可以看出递归会有大量的重复调用过程,时间利用率是O(2^n)

1.2. 记忆化搜索/备忘录算法

Map<Integer, Integer> memo = new HashMap<>();

public int fib(int n) {

    if (n == 0) return 0;
    if (n == 1) return 1;
    if (!memo.containsKey(n))
        memo.put(n, fib(n - 1) + fib(n - 2));

    return memo.get(n);
}

这种方法是把计算结果保存起来(剪枝),下次可以直接拿来用,这种方法也是有缺点的:需要额外O(n)的空间

记忆化搜索是自上而下解决问题,如果一个问题可以自上而下解决,那么它也可以自下而上解决——动态规划DP

上面的方法改为自下而上实现

public int fib(int n) {
    Map<Integer, Integer> memo = new HashMap<>();
    memo.put(0, 0);
    memo.put(1, 1);

    for(int i = 2; i <= n; i ++) {
        memo.put(i, memo.get(i - 1), memo.get(i - 2));
    }
    return memo.get(n);
}

记忆化搜索时间复杂度已经优化的很好了,但是需要额外的空间,有什么方法可以不用额外申请空间呢?接下来我们看下动态规划(DP)

1.3. 使用动态规划(DP)解答上面的问题

其实自下而上的记忆化方法已经算是DP了,只是用到了额外的map空间,我们把它修改下,用两个指针记录子问题的解

public int fibDp(int n) {
    int pre = 0, cur = 1, sum;

    for(int i = 0; i < n; i ++) {
        sum = (pre + cur) % 1000000007;
        pre = cur;
        cur = sum;
    }

    return cur;
}

2. 动态规划 (Dynamic Programming)

首先看下动态规划的定义:

将原问题拆解成若干子问题,同时保存子问题的答案,使得每个子问题只求解一次,最终获得原问题的答案

动态规划算法的基本思想与分治法类似,也是将待求解的问题分解为若干个子问题(阶段),按顺序求解子阶段,前一子问题的解,为后一子问题的求解提供了有用的信息。在求解任一子问题时,列出各种可能的局部解,通过决策保留那些有可能达到最优的局部解,丢弃其他局部解。依次解决各子问题,最后一个子问题就是初始问题的解

那么我们什么时候可以用到动态规划呢

根据定义我们可以得出:当一个大的问题可以拆分成多个结构相同的子问题,且子问题的解可以被多次使用,这时我们就可以用DP来解决

动态规划的关键元素

  • 最优子结构:首先需要定义子结构,也就是拆分成的子问题结构
  • 边界:然后我们需要找到问题的边界,相当于递归的出点
  • 状态转移公式:最后把子问题通过转移方程递推得到最终答案

动态规划的一般解题思路:

状态定义->转移方程->初始状态->返回值

3. 相似问题:

3.1 剑指 Offer 10- II. 青蛙跳台阶问题

和斐波那契额数列类似,忽略

3.2 198. 打家劫舍

你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。

给定一个代表每个房屋存放金额的非负整数数组,计算你 不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。

示例 1:

输入:[1,2,3,1] 输出:4 解释:偷窃 1 号房屋 (金额 = 1) ,然后偷窃 3 号房屋 (金额 = 3)。 偷窃到的最高金额 = 1 + 3 = 4 。 示例 2:

输入:[2,7,9,3,1] 输出:12 解释:偷窃 1 号房屋 (金额 = 2), 偷窃 3 号房屋 (金额 = 9),接着偷窃 5 号房屋 (金额 = 1)。 偷窃到的最高金额 = 2 + 9 + 1 = 12 。

分析

如果房屋数量大于两间,应该如何计算能够偷窃到的最高总金额呢?对于第 k~(k>2)k (k>2) 间房屋,有两个选项:

  • 偷窃第 kk 间房屋,那么就不能偷窃第 k-1k−1 间房屋,偷窃总金额为前 k-2k−2 间房屋的最高总金额与第 kk 间房屋的金额之和
  • 不偷窃第 kk 间房屋,偷窃总金额为前 k-1k−1 间房屋的最高总金额

转移方程为:

1dp[i]=max(dp[i−2]+nums[i],dp[i−1])

边界条件为:

  • dp[0]=nums[0] // 只有一间房屋,则偷窃该房屋
  • dp[1]=max(nums[0],nums[1]) // 只有两间房屋,选择其中金额较高的房屋进行偷窃
public int rob(int[] nums) {
    if (nums == null || nums.length == 0) {
        return 0;
    }

    if (nums.length == 1) return nums[0];

    int[] dp = new int[nums.length];
    dp[0] = nums[0];
    dp[1] = Math.max(nums[0], nums[1]);
    for (int i = 2; i < nums.length; i ++) {
        dp[i] = Math.max(dp[i - 2] + nums[i], dp[i-1]);
    }

    return dp[nums.length - 1];
}

上面的方法用到了额外空间,使用变量去掉数组

public int rob(int[] nums) {
    if (nums == null || nums.length == 0) {
        return 0;
    }

    if (nums.length == 1) return nums[0];

    int f = nums[0], s = Math.max(nums[0], nums[1]), tmp;
    for (int i = 2; i < nums.length; i ++) {
        tmp = s;
        s = Math.max(f + nums[i], s);
        f = tmp;
    }

    return s;
}

3.3 62. 不同路径

一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为“Start” )。

机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为“Finish”)。

问总共有多少条不同的路径?

img

例如,上图是一个7 x 3 的网格。有多少可能的路径?

示例 1:

输入: m = 3, n = 2 输出: 3 解释: 从左上角开始,总共有 3 条路径可以到达右下角。

  1. 向右 -> 向右 -> 向下
  2. 向右 -> 向下 -> 向右
  3. 向下 -> 向右 -> 向右 示例 2:

输入: m = 7, n = 3 输出: 28

提示:

1 <= m, n <= 100 题目数据保证答案小于等于 2 * 10 ^ 9

分析:

  1. **确定状态:**我们使用int[][] dp来存储子问题的结果,dp[i][j]就是从dp[0][0]走到dp[i][j]的路径数
  2. **确定状态转移方程:**机器人只能往右或往下走,所以到dp[i][j]的路径数就是它上边和左边格子的路径数之和:dp[i][j]=dp[i - 1][j] + dp[i][j - 1]
  3. **确定边界条件:**由于机器人只能往右或往下走,当 i == 0 或者j == 0时只有一条路径可走
public int uniquePaths(int m, int n) {
    // m或n为1的时候只有一条路径
    // 状态转移方程为res[i][j] = res[i - 1][j] + res[i][j - 1];

    if (m == 1 || n == 1) return 1;
    int[][] dp = new int[m][n];

    for(int i = 0; i < m; i ++) {
        for (int j = 0; j < n; j ++) {
            if (i == 0 || j == 0) {
                dp[i][j] = 1;
            } else {
                dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
            }
        }
    }

    return dp[m-1][n-1];
}

3.4 剑指 Offer 42. 连续子数组的最大和

输入一个整型数组,数组中的一个或连续多个整数组成一个子数组。求所有子数组的和的最大值。

要求时间复杂度为O(n)。

示例1:

输入: nums = [-2,1,-3,4,-1,2,1,-5,4] 输出: 6 解释: 连续子数组 [4,-1,2,1] 的和最大,为 6。

提示:

1 <= arr.length <= 10^5 -100 <= arr[i] <= 100 注意:本题与主站 53 题相同:leetcode-cn.com/problems/ma…

class Solution {
    public int maxSubArray(int[] nums) {
        if (nums == null) {
            return 0;
        }
        sum = nums[0];
        for (int i = 1; i < nums.length; i ++) {
            // nums[i - 1] 有两种情况,>0 或 <0
            // 求最大值,只需保留大于0的部分
            nums[i] = Math.max(nums[i - 1], 0) + nums[i];
            sum = Math.max(sum, nums[i]);
        }

        return sum;
    }
}

本文已同步到我的公众号,我会在公众号中持续分享一些技术点,欢迎订阅关注