动态规划算法入门

222 阅读9分钟

什么是动态规划

定义

动态规划(简称 DP),是一种在数学、管理科学、计算机科学、经济学和生物信息学中使用的,通过把原问题分解为相对简单的子问题的方式求解复杂问题的方法。动态规划常常适用于有重叠子问题和最优子结构性质的问题。

核心思想

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

例子:斐波拉契数列:0、1、1、2、3、5、8... ,求第n个数的大小

规律:f(n) = f(n-1) + f(n-2)

递归实现

def fib(n) {
    return 1 if n==1
    return 1 if n==2
    return fib( n-1)+fib(n-2)
}

计算过程:

image.png 从上面的函数调用的递归树可以看到,很多节点被重复的执行,函数调用需要保存上下文所以有着不小的开销。如果在执行的时候把执行过的子节点保存起来,后面要用到的时候直接查表调用的话可以节约大量的时间,这就是动态规划的思想。

动态规划实现

def fib(n) {
    return 0 if n==1
    return 1 if n==2
    a, b = 0, 1
    (2..n).each do |x|
        a = b
        b = a + b
    end
    return b
}

动态规划采用自底向上的方法,从f(1)、f(2)开始计算,用两个变量记住算过的子问题的值,下一次直接取已经计算过的两个变量的值,大大减少了重复的计算。

解题步骤

什么样的问题可以考虑使用动态规划解决呢?

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

解决动态规划问题的五部曲:

  1. 确定dp数组(dp table)以及下标的含义
  2. 确定递推公式
  3. dp数组如何初始化
  4. 确定遍历顺序
  5. 举例推导dp数组

以上面的斐波拉契数列为例子

这里我们要用一个一维dp数组来保存递归的结果

  1. 确定dp数组以及下标的含义,dp[i]的定义为:第i个数的斐波那契数值是dp[i]

  2. 确定递推公式: dp[i] = dp[i - 1] + dp[i - 2]

  3. dp数组如何初始化,找到临界条件dp[0] = 0,dp[1] = 1

  4. 确定遍历顺序,从递推公式dp[i] = dp[i - 1] + dp[i - 2];中可以看出,dp[i]是依赖 dp[i - 1] 和 dp[i - 2],那么遍历的顺序一定是从前到后遍历的

  5. 举例推导dp数组,按照这个递推公式推导一下,当N为10的时候,dp数组应该是如下的数列:

    0 1 1 2 3 5 8 13 21 34 55

class Solution:
    def fib(self, n: int) -> int:
        if n <= 1:
            return n
            
        dp = [0, 1]
        for i in range(2, n + 1):
            total = dp[0] + dp[1]
            dp[0] = dp[1]
            dp[1] = total
        
        return dp[1]

几种经典模型

线性模型

动态规划线性模型是动态规划的一种常见模型,通常用于解决一维序列或数组相关的问题。这类问题通常涉及到在给定限制条件下,找到最优解或计算某种状态的最大值或最小值。

假设你正在爬楼梯。需要 n 阶你才能到达楼顶。每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?

示例 1:

输入: n = 2
输出: 2
解释: 有两种方法可以爬到楼顶。
1. 1 阶 + 1 阶
2. 2 阶

示例 2:

输入: n = 3
输出: 3
解释: 有三种方法可以爬到楼顶。
1. 1 阶 + 1 阶 + 1 阶
2. 1 阶 + 2 阶
3. 2 阶 + 1 阶

使用上面的5个步骤来解题:

  1. 确定dp数组(dp table)以及下标的含义,dp[i]:爬到第i个台阶,有dp[i]种方法。
  2. 确定递推公式:因为每次只能跳1个或2个台阶,所以第i个台阶只能从i-1或i-2个台阶跳上去,所以第i个台阶的跳法就是i-1和i-2个台阶的跳法之和,即:dp[i] = dp[i-1] + dp[i-2]。
  3. dp数组如何初始化,第一个台阶跳一次,第二个台阶一次跳2个台阶和每次跳一个跳两次,所以dp[1]=1,dp[2]=2
  4. 确定遍历顺序,i需要从i-1和i-2得到,所以需要从前往后。
  5. 举例推导dp数组,按公式i=6时,dp[i] = 13,跟代码的计算结果进行比较。
# @param {Integer} n
# @return {Integer}
def climb_stairs(n)
  # 方法1:
  return n if n == 1 or n == 2
  
  dp = Array.new(n+1)
  dp[1], dp[2] = 1, 2
  (3..n).each do |i|
    dp[i] = dp[i-1] + dp[i-2]
  end

  dp[n]

  # 方法2:
  # return n if n == 1 or n == 2
  # dp = [1, 2]
  # (3..n).each do 
  #   total = dp[0] + dp[1]
  #   dp[0] = dp[1]
  #   dp[1] = total 
  # end

  # dp[1]
end

给你一个整数数组 nums ,请你找出一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。

子数组 是数组中的一个连续部分。

示例 1:

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

解题思路:

  1. 确定dp数组以及下标的含义,dp[i]:i个数的数组中的连续子序列的和的最大值。
  2. 确定递推公式:dp[i]的值跟它的前一个dp[i-1]有关,如果dp[i-1]大于0,dp[i] = dp[i-1] + nums[i],如果dp[i-1]小于0,则子序列从nums[i]重新开始,dp[i] = nums[i],所以dp[i]应该取这两种情况的最大值dp[i] = max(dp[i - 1] + nums[i], nums[i])。
  3. 初始化:dp[i]依赖于dp[i-1],所以dp[i]最小为dp[0]=nums[0]
  4. 确定遍历顺序,dp[i]需要从dp[i-1]得到,所以需要从前往后。
# @param {Integer[]} nums
# @return {Integer}
def max_sub_array(nums)
    dp = Array.new(nums.size)
    # 初始化
    dp[0] = nums[0]
    # result保存一下最大值
    reslut = dp[0]
    (1..nums.size-1).each do |i|
        tmp = dp[i-1] + nums[i]
        # 通过递推公式算出dp[i]的值
        dp[i] = [tmp, nums[i]].max
        # 取最大的值
        reslut = [reslut, dp[i]].max
    end

    reslut
end

区间模型

动态规划区间模型是动态规划的一种常见模型,用于解决涉及区间或二维矩阵的问题。这类问题通常需要在给定限制条件下,找到最优解或计算某种状态的最大值或最小值。

不同路径

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

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

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

示例 1:

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

示例 2:

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

示例 3:

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

示例 4:

输入: m = 3, n = 3
输出: 6

解题思路:

  1. 确定dp数组以及下标的含义,dp[i][j]:表示从(0, 0)出发到(i, j)所有的路径数量。
  2. 确定递推公式:因为机器人每一步只能向右或者向下,所以到达(i, j)位置只有两种路径,从(i, j-1)向下一格或者从(i-1, j)向右一格到达(i, j),可以得到递推公式:dp[i][j] = dp[i][j-1] + dp[i-1][j]。
  3. 初始化:dp[i][0]一定都是1,因为从(0, 0)的位置到(i, 0)的路径只有一条,那么dp[0][j]也一样。
  4. 确定遍历顺序,从递推公式可以看出,从上到下一层一层遍历就可以了。
# @param {Integer} m
# @param {Integer} n
# @return {Integer}
def unique_paths(m, n)
    # 创建一个二维数组记录到每一个坐标的路径数
    dp = Array.new(m) { Array.new(n) }
    # 初始化
    (0...m).each {|i| dp[i][0] = 1}
    (0...n).each {|j| dp[0][j] = 1}
    # 由递推公式来计算到(i,j)的路径数
    (1...m).each do |i|
        (1...n).each do |j|
            dp[i][j] = dp[i-1][j] + dp[i][j-1]
        end
    end

    dp[m-1][n-1]
end

背包模型

背包模型是动态规划的一种常见模型,用于解决背包问题。背包问题通常涉及在给定容量限制下,选择一些物品放入背包,使得价值最大化或满足特定的条件。

给你一个可以装载容量W的背包和N个物品,每个物品有重量和价值两个属性,其中第i个物品的重量为wt[i],价值为val[i],现在让你用这个背包装物品,最多能装的价值是多少? N = 3, W = 4 wt = [2,1,3] val = [4,2,3] 最大值:6

解题思路:

  1. 确定dp数组以及下标的含义,dp[i][w]:对于当前的i个物品,背包容量为w时,可以装的最大价值时dp[i][w]。
  2. 确定递推公式:0/1背包就是在第i个物品放入背包或者不放入背包时,求最大的价值,不放入背包时价值为第i-1个物品的价值(dp[i-1][w]),将第i个物品放入背包时,之前包里的重量时W-wt[i],第i个物品的价值val[i],所以总价值为val[i] + dp[i-1][W-wt[i]],最终可得:dp[i] = max(dp[i-1][w], val[i] + dp[i-1][W-wt[i]])。
  3. 初始化:没有物品或者背包没有容量时,价值为0所以,dp[0][..] = dp[0][..] = 0。
  4. 确定遍历顺序,从递推公式可以看出,双重循环遍历val和wt即可。
def knapsack(num, weight, wt, val)
    dp = Array.new(num + 1) { Array.new(weight + 1, 0) }
    (1..num).each do |i|
        (1..weight).each do |w|
            if weight - wt[i-1] < 0
                dp[i][w] = dp[i-1][w]
            else
                dp[i][w] = [dp[i-1][w], dp[i-1][weight - wt[i-1]] + val[i-1]].max
            end
        end
    end

    dp[num-1][weight-1]
end