动态规划-Dynamic Program
在Leetcode中,很多题目都可以用动态规划(Dynamic Program,简称DP)的思想来求解。那么到底什么样的问题可以用DP来解呢?
适合使用DP方法的问题通常具有这些特性:
- 原问题的求解通常需要分阶段进行,如果将求解的目标设为问题的状态,那么随着阶段的变化,状态也会变化或转移至下一阶段。
- 这种问题的求解能被拆解为对子问题的求解,也就是说当前阶段的求解可以由之前阶段的结果推断得到。
- 每个阶段的问题的求解方案通常具有多种,不同方案对应着之前不同阶段的子问题的最优解,原问题的最优解就是从这些子问题的最优解中选择最佳方案得到的,然后进入下一个阶段同时带来状态的转移。
- 通常,状态代表了问题的结果,最后一个阶段的状态就是原问题最终的最优解。
能使用DP求解的问题都具有这两个性质:
- 无后效性:某阶段的状态一旦确定,则此后阶段的决策不再受此前各种状态及决策的影响。
- 最优子结构:问题的最优解能由子问题的最优解推断而来。
如何设计DP算法:
- 将问题抽象出一种状态描述。一般以待求解的目标作为状态,动态规划的最终状态就是原问题的最优解。
- 将问题的求解分为阶段,思考阶段之间状态如何转移。写出状态转移方程。
实际解题中,可以先从边界情况开始列出几种简单的状态(最优解好找),然后到常规的场景分析状态的变化,找规律得到状态转移方程。则之后可以用迭代更新(如果只与过去几个子问题的最优解有关可以用少量变量记录并更新)或者是dp数组(以空间换时间,将之前问题的解记录下来,由下标直接获取)来得到任一状态的解。
DP算法的典型应用:
Leetcode 题库
Leetcode53-最大子数组
题目描述:
给你一个整数数组 nums ,请你找出一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。
测试用例:
用例1:
输入:nums = [-2,1,-3,4,-1,2,1,-5,4]
输出:6
解释:连续子数组 [4,-1,2,1] 的和最大,为 6 。
用例2:
输入:nums = [1]
输出:1
用例3:
输入:nums = [5,4,-1,7,8]
输出:23
思路分析:
先考虑边界条件,最大子数组可以是单个元素(例如当数字只有一个元素时,最大子数组就是它本身),也可以是整个数组。而任一单个元素都有可能是某个数组的最大子数组。
因此,给定一个数组,求其最大子数组,可以将问题不断分层次划分为同类型的小问题,直到最小的问题为 求只有一个元素的数组的最大子数组。
此时,我们对问题由小到大分层思考每个问题的最大子数组。为了方便表述,我们用nums[a,b]表示以nums中第a个元素和第b个元素为左、右边界的数组。那么:
最小一层的问题,对nums[0,0],即只有一个元素的数组,很简单,其解为该元素本身;
更大一层的问题,对nums[0,1],如果我们以暴力方法去解决,则其最大子数组有三种可能——要么是nums[0]这个元素,要么是nums[1]这个元素,要么是nums[0,1]这个数组本身。对这三种情况进行比较,即可得到该数组的最大子数组。注意到,如果按当前思路分析,当问题规模逐步扩大,nums[a,b]的可能的解会呈更大规模的增长。
因此,我们思考两个问题,看是否能用动态规划算求解?思考两个问题:(1)更小一层的问题的解是否为当前问题的子解?(2)请问当前问题是否可以做到只与更小一层问题的解有关而与过程无关?
答案是肯定的。如果我们定义每层问题为“对nums[0,n],求包含第n个元素的最大子数组”,那么每层问题的解只存在两种情况,num[0,n]或nums[n]。
当我们从nums数组最左端开始遍历,会遍历每个“以当前元素为右边界元素”的子数组;每右移一个元素,相当于求解更大一层的问题,可以以上一层问题的解为基础,判断当前问题的解为2者之中的哪个。因此回答了第一个问题,符合动态规划的“最优子结构”,而我们不关心上一层的答案是如何得到的,只需要利用上一层问题的最优解,符合动态规划的“无后效性”。
最后我们可以得到n个答案,即对每个nums[0,n]数组,其包含右边界元素的最大子数组之和。由于每个子数组都可能是nums的最大子数组,因此,我们通过找到最大值就能找到nums数组的最大子数组之和。
注:第一种方法中的子问题的结果包含了一些不确定的信息,导致了后面的阶段求解的子问题无法得到,或者很难得到,这叫「有后效性」;而通过将状态细化、准确化,则有可能可以得到「无后效性」的状态定义。
为了保证计算子问题能够按照顺序、不重复地进行,动态规划要求已经求解的子问题不受后续阶段的影响。这个条件也被叫做「无后效性」。换言之,动态规划对状态空间的遍历构成一张有向无环图,遍历就是该有向无环图的一个拓扑序。有向无环图中的节点对应问题中的「状态」,图中的边则对应状态之间的「转移」,转移的选取就是动态规划中的「决策」。
——摘录自《算法竞赛进阶指南》(李煜东 著)
运用动态规划算法:
首先我们定义问题状态,将以[0,n]为边界的数组包含右端点元素的最大子数组之和记为f(n);
其次,定义状态转移方程,f(n+1)=max(f(n-1)+nums[n], nums[n])。
对状态转移进行解释:如果我们已知边界为[0,n-1]且包含右端元素的数组就是当前最大子数组,而边界为[0,n]包含右端元素的数组的最大子数组是什么 则取决于此时的右端元素,即第n个元素。分两种情况:(1)上一个边界为[0,n-1]的数组,加上第n个元素,是当前数组的最大子数组;(2)第n个元素本身作为一个数组,是当前数组的最大子数组。
代码示例:
class Solution:
def maxSubArray(self, nums: List[int]) -> int:
res = [nums[0]]
for i in range(1, len(nums)):
res.append(max(res[i-1]+nums[i], nums[i]))
return max(res)
此代码复杂度为:时间O(n),空间O(n)。
我们还可对空间进行改进,即在迭代过程中用一个变量保存当前解,具体代码示例如下:
class Solution:
def maxSubArray(self, nums: List[int]) -> int:
curr_max, pre_res = nums[0], nums[0]
for i in range(1, len(nums)):
pre_res = max(pre_res+nums[i], nums[i])
curr_max = max(curr_max, pre_res)
return curr_max
题外话:此题还可以用分治法求解
分治法能够解决更通用的问题,每个子问题不是nums[0,n]的最大子数组,而是nums[l,r]的最大子数组。(此处会接触到新的数据结构——线段树)
分治法思路:
将一个数组从中间元素一分为二,分别计算其左、右子数组的解,那么可以基于其左右子数组的最优解,得到当前数组的最优解。那么具体如何倒推最优解呢?首先分析当前数组的最优解,其解集如下:
1)左子数组最优解
2)右子数组最优解
3)既包含左子数组元素又包含右子数组元素的子数组
(没有其他情况了,当前数组的最优解 如果不是 左、右子数组最优解,则也不可能是非 左、右子数组最优解 的解,因为这些情况在左、右子数组在找其最优解时已经被排除过了)
注意:第三种最优解,由于最大子数组的元素必须连续,该最优解必然包含当前数组用来分割为左右子数组的那个中间元素。因此,算法可以通过寻找解集范围内的最优值得到最优解。通过分治法,不断分割直到子数组仅为单个元素,再逐层倒推,即可得到目标数组的最优解。
代码实现如下:
class Solution:
def maxSubArray(self, nums: List[int]) -> int:
return func(nums, 0, len(nums))
def func(self, nums, left, right):
if left == right:
return None
if left == right-1:
return nums[left]
mid = left + (right-left)//2
l, r = mid, mid
cross_res = nums[mid]
while l>=0 and r<=right:
if l-1>=0 and cross_res < cross_res + nums[l]:
cross_res += nums[l-1]
l -= 1
if r+1<=right and cross_res < cross_res + nums[r]:
cross_res += nums[r+1]
r += 1
return max(self.func(left, mid-1), self.func(mid, right), cross_res)
时间复杂度为O(NlogN),空间复杂度为O(N)
Leetcode104-买卖股票的最佳时机
题目描述:
给定一个数组 prices ,它的第 i 个元素 prices[i] 表示一支给定股票第 i 天的价格。
你只能选择 某一天 买入这只股票,并选择在 未来的某一个不同的日子 卖出该股票。设计一个算法来计算你所能获取的最大利润。
返回你可以从这笔交易中获取的最大利润。如果你不能获取任何利润,返回 0 。
测试用例:
用例1:
输入:[7,1,5,3,6,4]
输出:5
解释:在第 2 天(股票价格 = 1)的时候买入,在第 5 天(股票价格 = 6)的时候卖出,最大利润 = 6-1 = 5 。
注意利润不能是 7-1 = 6, 因为卖出价格需要大于买入价格;同时,你不能在买入前卖出股票。
用例2:
输入:prices = [7,6,4,3,1]
输出:0
解释:在这种情况下, 没有交易完成, 所以最大利润为 0。
问题剖析:
阅读该问题,能够认识到问题求解的约束条件为:先买后卖、低买高卖,因此应该将prices数组从左到右不断更新最低买入价和最高卖出价得到最优解。
分析问题是否具有无后效性、最优子结构?
-未来买卖股票与过去股票买卖有关,而与具体哪天买或卖无关。即对应了「无后效性」:未来的问题与过去的那些问题求解的过程无关,仅与其结果有关。
-已知过去股票买卖价格在未来买卖股票时,如果当前价格高于买入价,则更新卖出价格,以更高价格卖出以获取更大收益;如果当前价格低于买入价,则更新买入价格(等未来卖出)。即对应了「最优子结构」:大问题的最优解能由子问题的最优解推断而来。
运用动态规划算法:
略,此题用迭代法更简洁。
代码示例:
略
其他解法1-迭代法:
核心思想:
逐步遍历整个股票价格数组,关注高价卖出,只要当前价格比历史最低价高,卖出就有收益。遍历过程中更新当前最大收益,一次遍历结束后可得最终解。
算法步骤:
记录三个变量: 当前最大收益,当前最高卖出价,历史最低买入价
先考虑边界条件: 当prices数组只有两个元素的时候,解有两种情况:
(1)prices[0]-prices[1],买入价为prices[0],卖出价为prices[1];(2)0,不买卖。
每次迭代,即prices往右扩展一个元素,判断:
(1)新的价格高于最低买入价,更新当前最高卖出价(可能仍为原最高卖出价,也可能为新的价格),更新当前收益;
(2)新的价格低于最低买入价,更新当前最低买入价。
代码示例:
class Solution:
def maxProfit(self, prices: List[int]) -> int:
lens = len(prices)
if lens < 2:
return 0
if prices[1] > prices[0]:
curr_res = prices[1] - prices[0]
curr_min, curr_max = prices[0], prices[1]
else:
curr_res = 0
curr_min, curr_max = min(prices[0], prices[1]), float('-inf')
for i in range(2, lens):
if prices[i] > curr_min:
curr_res = max(prices[i] - curr_min, curr_res)
curr_max = max(prices[i], curr_max)
else: # prices[i] <= curr_min
curr_min = prices[i]
return curr_res
时间复杂度:O(n),空间复杂度:O(n)
该解法类似单调栈。单调栈的作用是:用 O(n) 的时间得知所有位置两边第一个比他大(或小)的数的位置。
在这里插入代码片
Leetcode198-打家劫舍
题目详情:
问题剖析:
运用动态规划算法:
代码示例(摘自某大佬的代码):
class Solution:
def rob(self, nums: List[int]) -> int:
pre = 0
curr = 0
for num in nums:
pre, curr = curr + num, max(pre, curr)
return max(pre, curr)
Leetcode 221-最大正方形
题目详情:
https://leetcode-cn.com/problems/maximal-square/