什么是动态规划
定义
动态规划(简称 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)
}
计算过程:
从上面的函数调用的递归树可以看到,很多节点被重复的执行,函数调用需要保存上下文所以有着不小的开销。如果在执行的时候把执行过的子节点保存起来,后面要用到的时候直接查表调用的话可以节约大量的时间,这就是动态规划的思想。
动态规划实现
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)开始计算,用两个变量记住算过的子问题的值,下一次直接取已经计算过的两个变量的值,大大减少了重复的计算。
解题步骤
什么样的问题可以考虑使用动态规划解决呢?
如果一个问题,可以把所有可能的答案穷举出来,并且穷举出来后,发现存在重叠子问题,就可以考虑使用动态规划。
解决动态规划问题的五部曲:
- 确定dp数组(dp table)以及下标的含义
- 确定递推公式
- dp数组如何初始化
- 确定遍历顺序
- 举例推导dp数组
以上面的斐波拉契数列为例子
这里我们要用一个一维dp数组来保存递归的结果
-
确定dp数组以及下标的含义,dp[i]的定义为:第i个数的斐波那契数值是dp[i]
-
确定递推公式: dp[i] = dp[i - 1] + dp[i - 2]
-
dp数组如何初始化,找到临界条件dp[0] = 0,dp[1] = 1
-
确定遍历顺序,从递推公式dp[i] = dp[i - 1] + dp[i - 2];中可以看出,dp[i]是依赖 dp[i - 1] 和 dp[i - 2],那么遍历的顺序一定是从前到后遍历的
-
举例推导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个步骤来解题:
- 确定dp数组(dp table)以及下标的含义,dp[i]:爬到第i个台阶,有dp[i]种方法。
- 确定递推公式:因为每次只能跳1个或2个台阶,所以第i个台阶只能从i-1或i-2个台阶跳上去,所以第i个台阶的跳法就是i-1和i-2个台阶的跳法之和,即:dp[i] = dp[i-1] + dp[i-2]。
- dp数组如何初始化,第一个台阶跳一次,第二个台阶一次跳2个台阶和每次跳一个跳两次,所以dp[1]=1,dp[2]=2
- 确定遍历顺序,i需要从i-1和i-2得到,所以需要从前往后。
- 举例推导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 。
解题思路:
- 确定dp数组以及下标的含义,dp[i]:i个数的数组中的连续子序列的和的最大值。
- 确定递推公式: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])。
- 初始化:dp[i]依赖于dp[i-1],所以dp[i]最小为dp[0]=nums[0]
- 确定遍历顺序,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
解题思路:
- 确定dp数组以及下标的含义,dp[i][j]:表示从(0, 0)出发到(i, j)所有的路径数量。
- 确定递推公式:因为机器人每一步只能向右或者向下,所以到达(i, j)位置只有两种路径,从(i, j-1)向下一格或者从(i-1, j)向右一格到达(i, j),可以得到递推公式:dp[i][j] = dp[i][j-1] + dp[i-1][j]。
- 初始化:dp[i][0]一定都是1,因为从(0, 0)的位置到(i, 0)的路径只有一条,那么dp[0][j]也一样。
- 确定遍历顺序,从递推公式可以看出,从上到下一层一层遍历就可以了。
# @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
解题思路:
- 确定dp数组以及下标的含义,dp[i][w]:对于当前的i个物品,背包容量为w时,可以装的最大价值时dp[i][w]。
- 确定递推公式: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]])。
- 初始化:没有物品或者背包没有容量时,价值为0所以,dp[0][..] = dp[0][..] = 0。
- 确定遍历顺序,从递推公式可以看出,双重循环遍历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