动态规划(Dynamic programming)
动态规划是算法中经常用到的,通过将原问题分解成相对简单的子问题来求解复杂的问题的方法。动态规划与分治方法相似,通过组合子问题的解来求解原问题。与之不同的是,动态规划应用在子问题有重叠的情况下,即不同的子问题有共同的子子问题。解决重复子问题,并将子问题的结果保存在表格内,这就避免重复计算。
动态规划一般求解最优化问题,因为有很多可行性解,每个解都有一个值,我们需要寻找最优解的值,这个值称为这个问题的一个最优解,而不是最优解,因为可以有很多解都能达到最优值。
所以动态规划能解决的问题是能将原问题分解成小问题,并且小问题需要重复计算的情况,以空间换时间,用dp类似的表格记录小问题的计算解,从而计算最终结果的解。类似递推加上记忆化加上剪枝。
题目实例
1. 爬楼梯
Leetcode 70
假设你正在爬楼梯。需要 n
阶你才能到达楼顶。
每次你可以爬 1
或 2
个台阶。你有多少种不同的方法可以爬到楼顶呢?
问题分析:
根据数学思维,假如现在在n台阶,那上一步是在n-1或者n-2台阶上,所以很容易得出f(n)=f(n-1)+f(n-2),边界是f(1)=1 ,f(0)=0
因此代码可以写成
def climbStairs(self, n: int) -> int:
if n <= 3:
return n
return self.climbStairs(n - 1) + self.climbStairs(n - 2)
fib(5)
/ \
fib(4) fib(3)
/ \ / \
fib(3) fib(2) fib(2) fib(1)
/ \ / \ / \
fib(2) fib(1) fib(1) fib(0) fib(1) fib(0)
/ \
fib(1) fib(0)
这时候采用简单的递归是能解决问题。但是在递归调用的时候有很多重复计算的子问题,所以这时候我们加上记忆化的操作就有下面的这层代码。
def climbStairs(self, n: int) -> int:
use = {0: 1, 1: 1}
def dp(n):
if n in use:
return use[n]
use[n] = dp(n - 1) + dp(n - 2)
return use[n]
return dp(n)
这次通过将字典的方法将已经计算的结果保存进去,从而避免重复计算,因为这题比较简单所以不涉及剪枝的概念,所以动态规划的解法为:
def climbStairs(self, n: int) -> int:
if n<=2:
return 2
dp=[0]*n
dp[0],dp[1]=1,2
for i in range(2,n):
dp[i]=dp[i-1]+dp[i-2]
return dp[-1]
由上可以理解动态规划可以类似动态递推。而dp的解法空间复杂度为O(n),若想采用O(1)的方法可以直接用递推recursion的方式,自底向上求解。
def climbStairs(self, n: int) -> int:
if n<=3:
retrun n
f1,f2=1,2
for _ in range(2,n):
f1,f2,=f2,f1+f2
return f2
当然这个方法也不是最优解,因为时间复杂度为O(n),若想加快的话,需要借助矩阵幂与分治的方法。可以降到O(logn)
2. 最大子序和
Leetcode 53
给你一个整数数组 nums
,请你找出一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。
子数组 是数组中的一个连续部分。
思路:
- 一般可以采用递归+记忆化也就是递推来考虑,
- 若是采用dp的化:
- 定义状态:dp[n]
- 状态转移方程:dp[n]=best(dp[n-1],dp[n-2]…) 此处为理解的难点。
- 最优子结构。
问题分析
- 这个题目可以定义dp[i] 为nums[i]结尾的时候连续最大子数组和,子问题为nums[i] 跟nums[i-1]相加是否更大
- 状态转移方程
- 为,看dp[i] =max(nums[i],dp[i-1]+nums[i]),如果nums[i] 为正数,则dp[i-1]+nums[i] 更大,如果nums[i] 为负数,则需要更新dp[i]。此处的max类似剪枝的功能,这是一个比较简单的。dp问题的难点是状态转移中的类似剪枝的功能的寻找。
- 求出dp里面最大值。
class Solution:
def maxSubArray(self, nums: List[int]) -> int:
dp=[0] * len(nums)
dp[0]=nums[0]
for i in range(1,len(nums)):
dp[i]=max(nums[i],dp[i-1]+nums[i]) # 都为负数,取nums[i]
return max(dp)
单独拿出状态转移方程的话可以考虑以下做法
class Solution:
def maxSubArray(self, nums: List[int]) -> int:
n=len(nums)
max_value=nums[0]
for i in range(1,n):
if nums[i-1]>0:
nums[i] +=num[i-1]
max_value=max(nums[i],max_value)
return max_value
此方法节省空间,只用原数组进行操作代替dp。
3. 最长上升子序列(300)
Leetcode 300
给你一个整数数组 nums ,找到其中最长严格递增子序列的长度。
子序列 是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,[3,6,2,7] 是数组 [0,3,1,6,2,2,7] 的子序列。
示例 1:
输入:nums = [10,9,2,5,3,7,101,18]
输出:4
解释:最长递增子序列是 [2,3,7,101],因此长度为 4 。
示例 2:
输入:nums = [0,1,0,3,2,3]
输出:4
示例 3:
输入:nums = [7,7,7,7,7,7,7]
输出:1
问题分析
- 此处的难点是递增的子序列不是连续的,意思不止取前一个,可能是前j个都得去比较才能得到后续长度。默认dp[i]就是前i个元素的子序列长度了。
- 两层循环i,j i->n; j->i:
- 剪枝判断 nums[j]<nums[i];如果前面的的nums[j] <nums[i],就去比较前面j->i 中 dp[j]+1,dp[i]的大值。
- Dp[i]=max(dp[j]+1,dp[i]) ;此处需要在i->n的循环时将dp[i]初始化为1,因为i的前面全是递减的话初始值为1
- 返回max(dp)
class Solution:
def lengthOfLIS(self, nums: List[int]) -> int:
n=len(nums)
dp=[1]*len(nums)
for i in range(n):
for j in range(i):
if nums[i]>nums[j]:
dp[i]=max(dp[j]+1,dp[i])
return max(dp)
4. 三角形最小路径和
Leetcode 120
给定一个三角形 triangle ,找出自顶向下的最小路径和。
每一步只能移动到下一行中相邻的结点上。相邻的结点 在这里指的是 下标 与 上一层结点下标 相同或者等于 上一层结点下标 + 1 的两个结点。也就是说,如果正位于当前行的下标 i ,那么下一步可以移动到下一行的下标 i 或 i + 1 。
示例1:
输入:triangle = [[2],[3,4],[6,5,7],[4,1,8,3]]
输出:11
解释:如下面简图所示:
2
3 4
6 5 7
4 1 8 3
自顶向下的最小路径和为 11(即,2 + 3 + 5 + 1 = 11)。
问题分析
- 定义dp[i] [j] 为第i层j列的路径和。
- 状态转移是:dp[i] [j] = min(dp[i-1] [j], dp[i-1] [j-1])+triangle[i] [j]
- 剪枝是j==0时,dp[i] [j] = dp[i-1] [j] +triangle[i] [j]; j 是最后一位时 dp[i] [j] = dp[i-1] [j-1] +triangle[i] [j],其他取状态转移方程
- 最后返回最后一层的最小值dp[-1]
from typing import List
class Solution:
def minimumTotal(self, triangle: List[List[int]]) -> int:
dp = [[0] * len(i) for i in triangle]
dp[0][0] = triangle[0][0]
for i in range(1, len(triangle)):
for j in range(len(triangle[i])):
if j == 0:
dp[i][j] = dp[i - 1][j] + triangle[i][j]
elif j == len(triangle[i]) - 1:
dp[i][j] = dp[i - 1][j - 1] + triangle[i][j]
else:
dp[i][j] = min(dp[i - 1][j - 1], dp[i - 1][j]) + triangle[i][j]
return min(dp[-1])