一、定义
动态规划(Dynamic Programming,DP)是运筹学的一个分支,是求解[决策过程最优化]的方法。
把多阶段过程转化为一系列单阶段问题,利用各阶段之间的关系,逐个求解,创立了解决这类过程优化问题的新方法——动态规划
虽然动态规划主要用于求解以时间划分阶段的动态过程的优化问题,但是一些与时间无关的静态规划(如线性规划、非线性规划),
只要人为地引进时间因素,把它视为多阶段决策过程,也可以用动态规划方法方便地求解。
在现实生活中,有一类活动的过程,由于它的特殊性,可将过程分成若干个互相联系的阶段,在它的每一阶段都需要作出决策,从而使整个过程达到最好的活动效果。因此各个阶段决策的选取不能任意确定,它依赖于当前面临的状态,又影响以后的发展。当各个阶段决策确定后,就组成一个决策序列,因而也就确定了整个过程的一条活动路线.这种把一个问题看作是一个前后关联具有链状结构的多阶段过程就称为多阶段决策过程,这种问题称为多阶段决策问题。在多阶段决策问题中,各个阶段采取的决策,一般来说是与时间有关的,决策依赖于当前状态,又随即引起状态的转移,一个决策序列就是在变化的状态中产生出来的,故有“动态”的含义,称这种解决多阶段决策最优化的过程为动态规划方法
动态规划算法通常用于求解具有某种最优性质的问题。在这类问题中,可能会有许多可行解。每一个解都对应于一个值,我们希望找到具有最优值的解。动态规划算法与分治法类似,其基本思想也是将待求解问题分解成若干个子问题,先求解子问题,然后从这些子问题的解得到原问题的解。与分治法不同的是,适合于用动态规划求解的问题,经分解得到子问题往往不是互相独立的。若用分治法来解这类问题,则分解得到的子问题数目太多,有些子问题被重复计算了很多次。
二、示例
动态规划(英语:Dynamic programming,简称 DP)是一种在数学、管理科学、计算机科学、经济学和生物信息学中使用的,通过把原问题分解为相对简单的子问题的方式求解复杂问题的方法。
动态规划常常适用于有重叠子问题和最优子结构性质的问题,并且记录所有子问题的结果,因此动态规划方法所耗时间往往远少于朴素解法。
动态规划有自底向上和自顶向下两种解决问题的方式。自顶向下即记忆化递归,自底向上就是递推。
使用动态规划解决的问题有个明显的特点,一旦一个子问题的求解得到结果,以后的计算过程就不会修改它,这样的特点叫做无后效性,求解问题的过程形成了一张有向无环图。动态规划只解决每个子问题一次,具有天然剪枝的功能,从而减少计算量。
从上面的定义中可以知道使用动态规划的场景特征:
- 求一个问题的最优解
- 大问题可以分解为子问题,子问题还有重叠的更小的子问题
- 整体问题最优解取决于子问题的最优解(状态转移方程)
- 从上往下分析问题,从下往上解决问题
- 讨论底层的边界问题
动态规划最重要的有三个概念:
- 最优子结构:是指每个阶段的最优状态可以从之前某个阶段的某个或某些状态直接得到(子问题的最优解能够决定这个问题的最优解),
- 边界:指的是问题最小子集的解(初始范围)
- 状态转移方程:是指从一个阶段向另一个阶段过度的具体形式,描述的是两个相邻子问题之间的关系(递推式)
dp最核心的部分是状态转移。所以一般第一步是找状态,定义一下dp数组,明确dp数组的含义;第二步是初始化dp数组;第三步是状态转移。
1、53. 最大子序和
https://leetcode-cn.com/problems/maximum-subarray/
给定一个整数数组 nums
,找到一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。
示例 1:
输入:nums = [-2,1,-3,4,-1,2,1,-5,4]
输出:6
解释:连续子数组 [4,-1,2,1] 的和最大,为 6 。
示例 2:
输入:nums = [1]
输出:1
示例 3:
输入:nums = [0]
输出:0
示例 4:
输入:nums = [-1]
输出:-1
示例 5:
输入:nums = [-100000]
输出:-100000
提示:
1 <= nums.length <= 3 * 104
-105 <= nums[i] <= 105
为了保证计算子问题能够按照顺序、不重复地进行,动态规划要求已经求解的子问题不受后续阶段的影响。这个条件也被叫做「无后效性」。 换言之,动态规划对状态空间的遍历构成一张有向无环图,遍历就是该有向无环图的一个拓扑序。 有向无环图中的节点对应问题中的「状态」,图中的边则对应状态之间的「转移」,转移的选取就是动态规划中的「决策」。 class Solution: def maxSubArray(self, nums): length = len(nums) # 列表的长度 dp = [0] * length # dp[i] 表示以 nums[i] 结尾 的 连续 子数组的最大和 dp[0] = nums[0] # 初始化,索引为0时,连续的最大值子数组的值是自身 for i in range(1, length): if dp[i - 1] > 0: # 如果前一个 连续的最大值子数组的值 大于 0 dp[i] = dp[i - 1] + nums[i] # 那么无论nums[i]是正数还是负数,加上正数后必定比自身的值大 else: dp[i] = nums[i] # 如果前一个 连续的最大值子数组的值 小于 0,直接使用nums[i] return max(dp) n = [-2, 1, -3, 4, -1, 2, 1, -5, 4] res = Solution().maxSubArray(n) print(res)
2、爬楼梯
https://leetcode-cn.com/problems/climbing-stairs/
假设你正在爬楼梯。需要 n 阶你才能到达楼顶。
每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?
注意:给定 n 是一个正整数。
示例 1:
输入: 2
输出: 2
解释: 有两种方法可以爬到楼顶。
1. 1 阶 + 1 阶
2. 2 阶
示例 2:
输入: 3
输出: 3
解释: 有三种方法可以爬到楼顶。
1. 1 阶 + 1 阶 + 1 阶
2. 1 阶 + 2 阶
3. 2 阶 + 1 阶
""" 分析: 假设要爬到 5 层楼梯: 先爬到第 3 层楼梯,爬到第3层楼梯有多少种方法,那么再爬2个台阶到第5层就有多少种方法 先爬到第 4 层楼梯,爬到第4层楼梯有多少种方法,那么再爬1个台阶到第5层就有多少种方法 那么爬到第5层的方法就是:爬到第3层和爬到第4层方法的总和 而爬到第4层和爬到第3层两种方式中,即使中间会有相同的部分,但只要整条路径存在不相同的部分,那就是不相同的方法。 那么就可以写出状态转移方程: f(n) = f(n-1) + f(n-2) 边界: f(1) = 1 f(2) = 2 """ class Solution: def climbStairs(self, n: int) -> int: if n < 3: return n dp = [0] * n dp[0] = 1 # 这里索引从0开始,需要注意 dp[1] = 2 for i in range(2, n): # 这里索引从0开始,需要注意 # 因此第3层开始,索引是2,最后一层是n-1 dp[i] = dp[i - 1] + dp[i - 2] return dp[n - 1] """ 思考:如果一次能爬3个阶梯呢? 实际也差不多: 状态转移方程: f(n) = f(n-1) + f(n-2) + f(n-3) 边界: f(1) = 1 f(2) = 2 f(2) = 3 """
3、杨辉三角
https://leetcode-cn.com/problems/pascals-triangle/
给定一个非负整数 numRows,生成「杨辉三角」的前 numRows 行。
在「杨辉三角」中,每个数是它左上方和右上方的数的和。
示例 1:
输入: numRows = 5
输出: [[1],[1,1],[1,2,1],[1,3,3,1],[1,4,6,4,1]]
示例 2:
输入: numRows = 1
输出: [[1]]
""" 我的思路: 从第三层开始,每一层除了首尾固定为1之外,其他的值 = 上一层的这个值的索引 + (上一层的这个值的索引-1) 例如: 第四层的:[1,3,3,1],首尾不管 索引为1的位置值 = 第三层的索引为0的值 + 第三层的索引为1的值(1+2) 索引为2的位置值 = 第三层的索引为1的值 + 第三层的索引为2的值(2+1) 得出: 边界: f(1) = [1] f(2) = [1, 1] 状态转移: f(n)[index] = f(n-1)[index - 1] + f(n-1)[index] """ class Solution: def generate(self, numRows: int) -> List[List[int]]: if numRows == 1: return [[1]] elif numRows == 2: return [[1], [1, 1]] # 初始化 dp = [[] for _ in range(numRows)] dp[0] = [1] dp[1] = [1, 1] for i in range(2, numRows): # 初始化长度 dp[i] = [1] * (i + 1) for j in range(len(dp[i])): if j != 0 and j != (len(dp[i]) - 1): # 过滤掉首尾 dp[i][j] = dp[i - 1][j - 1] + dp[i - 1][j] return dp
4、杨辉三角 II
https://leetcode-cn.com/problems/pascals-triangle-ii/
给定一个非负索引 rowIndex,返回「杨辉三角」的第 rowIndex 行。
在「杨辉三角」中,每个数是它左上方和右上方的数的和。
示例 1:
输入: rowIndex = 3
输出: [1,3,3,1]
示例 2:
输入: rowIndex = 0
输出: [1]
示例 3:
输入: rowIndex = 1
输出: [1,1]
# 方法1:跟上面一样,注意索引就好 class Solution: def getRow(self, rowIndex): """这里跟上面对比,注意索引""" if rowIndex == 0: return [1] elif rowIndex == 1: return [1, 1] dp = [[] for _ in range(rowIndex + 1)] dp[0] = [1] dp[1] = [1, 1] for i in range(2, rowIndex + 1): dp[i] = [1] * (i + 1) for j in range(len(dp[i])): if j != 0 and j != (len(dp[i]) - 1): dp[i][j] = dp[i - 1][j - 1] + dp[i - 1][j] return dp[rowIndex] class Solution: def getRow(self, rowIndex: int) -> List[int]: """ 只使用一个数组解决问题 当前行第 i 项的计算只与上一行第 i−1 项及第 i 项有关。 因此我们可以倒着计算当前行,这样计算到第 i 项时,第 i−1 项仍然是上一行的值。 举例子: 1 1 推出第三阶: 1 1 1 先构造默认值,补个1 倒着推(首尾不算),-2的位置: 1 + 1 = 2 得到 1 2 1 1 3 3 1 推出第五阶: 1 3 3 1 1 (后面补个1) 倒着推 3 + 1 = 4, 3 + 3 = 6, 1 + 3 = 4. 依次倒着更新推出结果。得出结果: 1 4 6 4 1 """ res = [1] * (rowIndex + 1) # 初始化 for i in range(2, rowIndex + 1): # 从第三行开始 for j in range(i - 1, 0, -1): # 首尾不算,都为1 res[j] = res[j] + res[j - 1] return res
5、斐波那契数
https://leetcode-cn.com/problems/fibonacci-number/
斐波那契数,通常用 F(n) 表示,形成的序列称为 斐波那契数列 。该数列由 0 和 1 开始,后面的每一项数字都是前面两项数字的和。也就是:
F(0) = 0,F(1) = 1
F(n) = F(n - 1) + F(n - 2),其中 n > 1
给你 n ,请计算 F(n) 。
示例 1:
输入:2
输出:1
解释:F(2) = F(1) + F(0) = 1 + 0 = 1
示例 2:
输入:3
输出:2
解释:F(3) = F(2) + F(1) = 1 + 1 = 2
示例 3:
输入:4
输出:3
解释:F(4) = F(3) + F(2) = 2 + 1 = 3
提示:
其实这道题跟爬楼梯的是类似的原理
class Solution: def fib(self, n: int) -> int: if n < 2: return n f0 = 0 f1 = 1 res = 0 for i in range(2, n + 1): res = f0 + f1 f0 = f1 f1 = res return res
6、泰波那契序列
https://leetcode-cn.com/problems/n-th-tribonacci-number/
泰波那契序列 Tn 定义如下:
T0 = 0, T1 = 1, T2 = 1, 且在 n >= 0 的条件下 Tn+3 = Tn + Tn+1 + Tn+2
给你整数 n,请返回第 n 个泰波那契数 Tn 的值。
示例 1:
输入:n = 4
输出:4
解释:
T_3 = 0 + 1 + 1 = 2
T_4 = 1 + 1 + 2 = 4
示例 2:
输入:n = 25
输出:1389537
class Solution: def tribonacci(self, n: int) -> int: if n < 2: return n elif n == 2: return 1 t0 = 0 t1 = 1 t2 = 1 res = 0 for i in range(3, n+1): res = t0 + t1 + t2 t0 = t1 t1 = t2 t2 = res return res
7、最长回文子串
https://leetcode-cn.com/problems/longest-palindromic-substring/
回文子串(这里只是说回文子串,并不是最长)的意思是:从左到右读,和从右到左读,是一样的,例如: bab 顺着读反着读是一样的。
给你一个字符串 s,找到 s 中最长的回文子串。
示例 1:
输入:s = "babad"
输出:"bab"
解释:"aba" 同样是符合题意的答案。
示例 2:
输入:s = "cbbd"
输出:"bb"
示例 3:
输入:s = "a"
输出:"a"
示例 4:
输入:s = "ac"
输出:"a"
官方解法
对于一个子串而言,如果它是回文串,并且长度大于 22,那么将它首尾的两个字母去除之后,它仍然是个回文串。例如对于字符串 extrm{``ababa''}“ababa”,如果我们已经知道 extrm{``bab''}“bab” 是回文串,那么 extrm{``ababa''}“ababa” 一定是回文串,这是因为它的首尾两个字母都是 extrm{``a''}“a”。
根据这样的思路,我们就可以用动态规划的方法解决本题。我们用 P(i,j)P(i,j) 表示字符串 ss 的第 ii 到 jj 个字母组成的串(下文表示成 s[i:j]s[i:j])是否为回文串:
class Solution: def longestPalindrome(self, s: str) -> str: n = len(s) if n < 2: return s max_len = 1 begin = 0 # dp[i][j] 表示 s[i..j] 是否是回文串 dp = [[False] * n for _ in range(n)] for i in range(n): dp[i][i] = True # 单个字符必然是回文子串 # 递推开始 # 先枚举子串长度 for L in range(2, n + 1): # 枚举左边界,左边界的上限设置可以宽松一些 for i in range(n): # 由 L 和 i 可以确定右边界,即 j - i + 1 = L 得 j = L + i - 1 # 如果右边界越界,就可以退出当前循环 if j >= n: break if s[i] != s[j]: dp[i][j] = False else: if j - i < 3: dp[i][j] = True else: dp[i][j] = dp[i + 1][j - 1] # 只要 dp[i][L] == true 成立,就表示子串 s[i..L] 是回文,此时记录回文长度和起始位置 if dp[i][j] and j - i + 1 > max_len: max_len = j - i + 1 begin = i return s[begin:begin + max_len]