提示:动态规划的概念很难把它描述清楚,所以在看目录一的时候,只需要一眼就过,不需要深入理解。接着往下看目录二的案例,大量的案例会让你理解它的使用套路。当你对几个案例有一定理解后,再回头看目录一的概念性内容就会豁然开朗,然后带着你对概念的理解继续看案例,相信你会兴奋的跳起来。
一、动态规划
动态规划(Dynamic Programming,简称 DP),是求解最优化问题的一种常用策略。
如果经验不足,可能不会立即想到使用动态规划解决问题,只能一步一步优化。怎样才能优化到动态规划呢?
通常的使用套路:
- 先使用暴力递归(自顶向下);
- 当出现了重叠子问题时,考虑记忆化搜索(递归的优化版);
- 最后使用递推(自底向上)。
使用套路是针对新手的(不熟悉动态规划),如果你已经对动态规划有深入理解,一般都是使用1.1. 常规步骤。
一般情况下,只要是求最值的问题,都可以使用动态规划解决。
1.1. 常规步骤
动态规划中的“动态”可以理解为是“会变化的状态”。
- 定义状态(状态是原问题、子问题的解)
- 比如定义
dp(i)
的含义
- 比如定义
- 设置初始状态(边界)
- 比如设置
dp(0)
的值
- 比如设置
- 确定状态转移方程
- 比如确定
dp(i)
和dp(i – 1)
的关系
- 比如确定
1.2. 相关概念
维基百科:Dynamic Programming is a method for solving a complex problem by breaking it down into a collection of simpler subproblems, solving each of those subproblems just once, and storing their solutions.
- 将复杂的原问题拆解成若干个简单的子问题。
- 每个子问题仅仅解决1次,并保存它们的解。
- 最后推导出原问题的解。
可以用动态规划来解决的问题,通常具备2个特点:
- 最优子结构(最优化原理):通过求解子问题的最优解,可以获得原问题的最优解
- 无后效性
- 某阶段的状态一旦确定,则此后过程的演变不再受此前各状态及决策的影响(未来与过去无关)
- 在推导后面阶段的状态时,只关心前面阶段的具体状态值,不关心这个状态是怎么一步步推导出来的
1.2.1. 无后效性
从起点(0, 0)
走到终点(4, 4)
一共有多少种走法?要求:只能向右、向下走。
假设dp(i, j)
是从(0, 0)
走到(i, j)
的走法:
1 | // x或y等于0时,只有1个方向,所以只有1中走法 |
无后效性:推导dp(i, j)
时只需要用到dp(i, j – 1)
、dp(i – 1, j)
的值,不需要关心dp(i, j – 1)
、dp(i – 1, j)
的值是怎么求出来的。
1.2.2. 有后效性
如果可以向左、向右、向上、向下走,并且同一个格子不能走2次。
有后效性:dp(i, j)
下一步要怎么走,还要关心上一步是怎么来的。也就是还要关心dp(i, j – 1)
、dp(i – 1, j)
是怎么来的。
二、场景示例
2.1. 找零钱
零钱兑换:https://leetcode-cn.com/problems/coin-change/
假设有25分、20分、5分、1分的硬币,现要找给客户41分的零钱,如何办到硬币个数最少?上一章节用贪心策略得到的并非是最优解(贪心得到的解是5枚硬币),如果使用动态规划就能实现最优解(得到的解是3枚硬币)。
假设dp(n)
是凑到n
分需要的最少硬币个数(这里的假设就是动态规划中的定义状态):
- 如果第1次选择了25分的硬币,那么
dp(n) = dp(n – 25) + 1
。这里的1代表已选择的25分硬币,还剩余n – 25
分,下面的选择同理。 - 如果第1次选择了20分的硬币,那么
dp(n) = dp(n – 20) + 1
- 如果第1次选择了5分的硬币,那么
dp(n) = dp(n – 5) + 1
- 如果第1次选择了1分的硬币,那么
dp(n) = dp(n – 1) + 1
第一次选择哪个硬币是不确定的,所以有上面4种可供选择方式。其实本质上就是选择需要最少硬币数的方式,所以dp(n) = min { dp(n – 25), dp(n – 20), dp(n – 5), dp(n – 1) } + 1
(最优子问题的解一定可以推出原问题的最优解),这样得到的必然是凑到n
分需要的最少硬币个数。
从这也可以看出,动态规划是把所有情况都考虑了,因此可以得到最优解。
2.1.1. 暴力递归
1 | int coins(int n) { |
类似于斐波那契数列的递归版本,会有大量的重复计算(自顶向下的调用,出现了重叠子问题),时间复杂度较高。
2.1.2. 记忆化搜索
只要包含大量重复计算的情况,就可以在暴利递归的基础上做记忆优化(把重复计算的结果进行存储,当下次遇到同样计算参数时,直接从缓存中查找对应的计算结果,而不必再次计算一遍)。
1 | /** |
2.1.3. 递推
递推可以认为是非递归。如果是递归,求大的值必须先把小的值求出来。如果我们反过来,先求小的值再求大的值,不仅不需要额外数组辅助,还不需要递归。
1 | /** |
时间复杂度、空间复杂度:$O(n)$
在上面代码基础上增加一个功能:请输出找零钱的具体方案(具体是用了哪些面值的硬币)
1 | static int coins(int n) { |
2.1.4. 通用实现
上面的代码主要是针对的固定面值硬币,下面的代码则给用户提供了面值数组入口(并且对数组顺序没有要求)。
1 | static int coins(int n, int[] faces) { |
2.2. 最大连续子序列和
给定一个长度为n的整数序列,求它的最大连续子序列和。
比如–2、1、–3、4、–1、2、1、–5、4
的最大连续子序列和是4 + (–1) + 2 + 1 = 6
。
2.2.1. 定义状态
最大连续子序列一定是以某一个数值为结尾的,所以只需要计算以某个数值结尾的子序列和即可。
假设dp(i)
是以nums[i]
结尾的最大连续子序列和(nums
是整个序列):
- 以
nums[0] (-2)
结尾的最大连续子序列是–2
,所以dp(0) = –2
- 以
nums[1] (1)
结尾的最大连续子序列是1
,所以dp(1) = 1
- 以
nums[2] (-3)
结尾的最大连续子序列是1、–3
,所以dp(2) = dp(1) + (–3) = –2
- 以
nums[3] (4)
结尾的最大连续子序列是4
,所以dp(3) = 4
- 以
nums[4] (-1)
结尾的最大连续子序列是4、–1
,所以dp(4) = dp(3) + (–1) = 3
- 以
nums[5] (2)
结尾的最大连续子序列是4、–1、2
,所以dp(5) = dp(4) + 2 = 5
- 以
nums[6] (1)
结尾的最大连续子序列是4、–1、2、1
,所以dp(6) = dp(5) + 1 = 6
- 以
nums[7] (-5)
结尾的最大连续子序列是4、–1、2、1、–5
,所以dp(7) = dp(6) + (–5) = 1
- 以
nums[8] (4)
结尾的最大连续子序列是4、–1、2、1、–5、4
,所以dp(8) = dp(7) + 4 = 5
2.2.2. 初始状态
dp(0)
的值是nums[0]
。
2.2.3. 状态转移方程
状态转移方程一定是求dp(i)
和dp(i – 1)
的关系。如果dp(i)
前面的子序列dp(i – 1)
和是负数,就没有必要把前面的数加进来,并且它的最大子序列和一定是自己。如果是正数,就把前面的子序列和一并算进来。
总结如下:
- 如果
dp(i – 1) ≤ 0
,那么dp(i) = nums[i]
- 如果
dp(i – 1) > 0
,那么dp(i) = dp(i – 1) + nums[i]
2.2.4. 具体实现
最大连续子序列和是所有dp(i)
中的最大值max { dp(i) },i ∈ [0, nums.length)
。
1 | private int maxSubArray(int[] nums) { |
空间复杂度:$O(n)$
时间复杂度:$O(n)$
由于我们只需要求最大和,不需要保存其他子序列和,因此可以优化一下内存空间。
1 | private int maxSubArray(int[] nums) { |
空间复杂度:$O(1)$
时间复杂度:$O(n)$
2.3. 最长上升子序列
最长上升子序列(Longest Increasing Subsequence,简称 LIS),也叫最长递增子序列。
最长上升子序列:https://leetcode-cn.com/problems/longest-increasing-subsequence/
题目:给定一个无序的整数序列,求出它最长上升子序列的长度(要求严格上升,意思是相等不算)。
比如[10, 2, 2, 5, 1, 7, 101, 18]
的最长上升子序列是[2, 5, 7, 101]
、[2, 5, 7, 18]
,长度是4。
2.3.1. 动态规划
2.3.1.1. 状态定义
假设nums = [10, 2, 2, 5, 1, 7, 101, 18]
,dp(i)
是以nums[i]
结尾的最长上升子序列的长度(i ∈ [0, nums.length)
)。
- 以
nums[0] (10)
结尾的最长上升子序列是10
,所以dp(0) = 1
- 以
nums[1] (2)
结尾的最长上升子序列是2
,所以dp(1) = 1
- 以
nums[2] (2)
结尾的最长上升子序列是2
,所以dp(2) = 1
- 以
nums[3] (5)
结尾的最长上升子序列是2、5
,所以dp(3) = dp(1) + 1 = dp(2) + 1 = 2
- 以
nums[4] (1)
结尾的最长上升子序列是1
,所以dp(4) = 1
- 以
nums[5] (7)
结尾的最长上升子序列是2、5、7
,所以dp(5) = dp(3) + 1 = 3
- 以
nums[6] (101)
结尾的最长上升子序列是2、5、7、101
,所以dp(6) = dp(5) + 1 = 4
- 以
nums[7] (18)
结尾的最长上升子序列是2、5、7、18
,所以dp(7) = dp(5) + 1 = 4
最长上升子序列的长度是所有dp(i)
中的最大值max { dp(i) },i ∈ [0, nums.length)
。
要计算dp(i)
的最长上升子序列,需要把i
之前的元素都遍历一遍,这样才能找到目标。如果没有找到比i
小的元素,最长上升子序列就是当前元素(这里就可以看出,dp(i)
默认值是1)。
2.3.1.2. 初始状态
dp(0) = 1
,所有的dp(i)
默认都初始化为1。
2.3.1.3. 状态转移方程
遍历j ∈ [0, i)
:
- 当
nums[i] > nums[j]
,nums[i]
可以接在nums[j]
后面,形成一个比dp(j)
更长的上升子序列,长度为dp(j) + 1
,最终dp(i) = max { dp(i), dp(j) + 1 }
- 当
nums[i] ≤ nums[j]
,nums[i]
不能接在nums[j]
后面,跳过此次遍历(continue
)。
2.3.1.4. 具体实现
1 | static int lengthOfLIS1(int[] nums) { |
空间复杂度:$O(n)$
时间复杂度:$O(n^2)$
2.3.2. 二分搜索
2.3.2.1. 思路
- 把每个数字看做是一张扑克牌,从左到右按顺序处理每一个扑克牌。
- 如果牌顶 ≥ 选择的扑克牌,将选择的扑克牌压在牌顶对应的牌堆上面。
- 如果牌顶 < 选择的扑克牌,就在最右边新建一个牌堆,将选择的扑克牌放入这个新牌堆中。
- 当处理完所有牌,最终牌堆的数量就是最长上升子序列的长度。
核心:只要拿到的扑克牌比牌顶大(或相等),就重新开始计算最长上升子序列的长度(即牌堆的数量)。
2.3.2.2. 实现
代码不会具体体现牌堆,而是直接在数组的对应位置(虚拟牌堆)进行牌顶覆盖。
1 | private static int lengthOfLIS(int[] nums) { |
时间复杂度:$O(n^2)$
空间复杂度:$O(n)$
2.3.2.3. 二分搜索优化
上面的代码时间复杂度是$O(n^2)$,而且牌顶数组是有序的(1, 5, 7, 18
),因此当拿到一张牌时,可以使用二分搜索找出牌最终要放入的牌堆位置并覆盖牌顶(有点类似插入排序)。
1 | private static int lengthOfLIS(int[] nums) { |
时间复杂度:$O(nlog{n})$
空间复杂度:$O(n)$