[LeetCode]动态规划法的原理和编程实践

(Datawhale八月份组队学习)在我看过的诸多面经中,动态规划出现的频率非常之高。掌握动态规划的思想确实可以解决很多问题。本篇文章主要介绍动态规划法的原理,并亲手解决LeetCode上多道可以用动态规划思想解决的题目。

【LeetCode】系列文章

LeetCode分治算法的原理和编程实践 发布于20200819
LeetCode动态规划法的原理和编程实践 发布于20200822

一、动态规划的原理

  • 主要思想

若要解一个给定问题,我们需要解其不同部分(即子问题),再根据子问题的解以得出原问题的解。动态规划往往用于优化递归问题。如果运用递归的方式来求解会重复计算很多相同的子问题,利用动态规划的思想可以减少计算量。动态规划法仅仅解决每个子问题一次,具有天然剪枝的功能,从而减少计算量,一旦某个给定子问题的解已经算出,则将其记忆化存储,以便下次需要同一个子问题解之时直接查表。

  • 步骤
  1. 确定动态规划状态

  2. 写出状态转移方程(画出状态转移表)

  3. 考虑初始化条件

  4. 考虑输出状态

  5. 考虑对时间,空间复杂度的优化(Bonus)


二、编程实战

2.1 举例:第300题最长上升子序列
  • 题目描述

给定一个无序的整数数组,找到其中最长上升子序列的长度。

  • 解题思路

第1步:确定动态规划状态

该题目可以直接用一维数组 dp 来存储转移状态,dp[i] 可以定义为以 nums[i] 这个数结尾的最长递增子序列的长度。


第2步:写出一个好的状态转移方程

使用数学归纳法思维,写出准确的状态方程:比较当前 dp[i] 的长度和 dp[i] 对应产生新的子序列长度,我们用 j 来表示所有比 i 小的组数中的索引,可以用如下代码公式表示

for i in range(len(nums)):
    for j in range(i):
    	if nums[i]>nums[j]:
    		dp[i]=max(dp[i],dp[j]+1)

第3步:考虑初始条件

边界值考虑主要又分为三个地方:

(1) dp数组整体的初始值; (2) 二维dp数组 i=0 和 j=0 的位置

(3) dp存放状态的长度,是整个数组的长度或者是数组长度+1,需要特别注意

补充:几种Python常用的初始化方法

# 产生全为1且长度为n的数组
dp=[1 for _ in range(n)]
dp=[1]*n

# 产生全为0,长度为m,宽度为n的二维矩阵
dp=[[0 for _ in range(n)] for _ in range(m)]
dp=[[0]*n for _ in range(m)]

第4步:考虑输出状态

需要数组的哪些值:

(1) 返回dp数组中最后一个值作为输出,一般对应二维dp问题。

(2) 返回dp数组中最大的那个数字,一般对应记录最大值问题。

(3) 返回保存的最大值,一般是 Maxval=max(Maxval,dp[i]) 的形式。

本题的动态规划法标准答案

class Solution(object):
	def lengthOfLIS(self, nums: List[int]) -> int:
    	    if not nums:return 0  # 判断边界条件
        	dp=[1]*len(nums)      # 初始化dp数组状态
        	for i in range(len(nums)):
            	for j in range(i):
                	if nums[i]>nums[j]:   # 根据题目所求得到状态转移方程
                    	dp[i]=max(dp[i],dp[j]+1)
        	return max(dp)  # 确定输出状态

第5步:考虑对时间,空间复杂度的优化(Bonus)

之前方法遍历dp列表需要 O ( N ) O(N) O(N),计算每个 dp[i] 需要 O ( N ) O(N) O(N)的时间,所以总复杂度是 O ( N 2 ) O(N^2) O(N2)。前面遍历dp列表的时间复杂度无法降低,但在每轮遍历 [0,i] 的 dp[i]​ 元素的时间复杂度可以考虑设计状态定义,使得整个dp为一个排序列表,可以利用二分法来把时间复杂度降到了 O ( N l o g N ) O(NlogN) O(NlogN)


模板总结:

根据上述的方法,我们可以总结为模板并进行实战:

for i in range(len(nums)):
	for j in range(i):
		dp[i]=最值(dp[i], dp[j], ...)
2.2 第674题最长连续递增序列
  • 题目描述

给定一个未经排序的整数数组,找到最长且连续的的递增序列。

  • 标准答案
def findLengthOfLCIS(self, nums: List[int]) -> int:
        if not nums:return 0  # 判断边界条件
        dp=[1]*len(nums)      # 初始化dp数组状态
        # 注意需要得到前一个数,所以从1开始遍历,否则会超出范围
        for i in range(1,len(nums)): 
        	if nums[i]>nums[i-1]: # 根据题目所求得到状态转移方程
                    dp[i]=dp[i-1]+1
                else:
                    dp[i]=1
        return max(dp)  # 确定输出状态
2.3 第5题最长回文子串
  • 题目描述

给定一个字符串 s,找到 s 中最长的回文子串。你可以假设 s 的最大长度为 1000。

  • 标准答案
def longestPalindrome(self, s: str) -> str:
	length=len(s)
	if length<2:  # 判断边界条件
		return s
	dp=[[False for _ in range(length)]for _ in range(length)] # 定义dp状态矩阵
	# 定义初试状态,这步其实可以省略
	# for i in range(length):
	# dp[i][i]=True
	max_len=1
	start=0 # 后续记录回文串初试位置
	for j in range(1,length):
		for i in range(j):
		# 矩阵中逐个遍历
			if s[i]==s[j]:
				if j-i<3:
					dp[i][j]=True
				else:
					dp[i][j]=dp[i+1][j-1]
			if dp[i][j]: # 记录位置,返回有效答案
				cur_len=j-i+1
				if cur_len>max_len:
					max_len=cur_len
					start=i
	return s[start:start+max_len]
2.4 第516题最长回文子序列
  • 题目描述

给定一个字符串 s,找到 s 中最长的回文子串。你可以假设 s 的最大长度为 1000。

  • 标准答案
def longestPalindromeSubseq(self, s: str) -> int:
        n=len(s)
        dp=[[0]*n for _ in range(n)]  # 定义动态规划状态转移矩阵
        for i in range(n):  # 初始化对角线,单个字符子序列就是1
            dp[i][i]=1
        for i in range(n,-1,-1):  # 从右下角开始往上遍历
            for j in range(i+1,n):
                if s[i]==s[j]:   # 当两个字符相等时,直接子字符串加2
                    dp[i][j]= dp[i+1][j-1]+2  
                else:           # 不相等时,取某边最长的字符
                    dp[i][j]=max(dp[i][j-1],dp[i+1][j])
        return dp[0][-1]   # 返回右上角位置的状态就是最长
2.5 第72题编辑距离
  • 题目描述

给定两个单词 word1 和 word2,计算出将 word1 转换成 word2 所使用的最少操作数 。

  • 标准答案
def minDistance(self, word1, word2):
# m,n 表示两个字符串的长度
	m=len(word1) 
	n=len(word2)
	# 构建二维数组来存储子问题
	dp=[[0 for _ in range(n+1)] for _ in range(m+1)]
	# 考虑边界条件,第一行和第一列的条件
	for i in range(n+1):
		dp[0][i]=i  # 对于第一行,每次操作都是前一次操作基础上增加一个单位的操作
	for j in range(m+1):
		dp[j][0]=j # 对于第一列也一样,所以应该是1,2,3,4,5...
	for i in range(1,m+1):  # 对其他情况进行填充
		for j in range(1,n+1):
			if word1[i-1]==word2[j-1]: # 当最后一个字符相等的时候,就不会产生任何操作代价,所以与dp[i-1][j-1]一样
				dp[i][j]=dp[i-1][j-1]
			else:
				dp[i][j]=min(dp[i-1][j],dp[i][j-1],dp[i-1][j-1])+1 # 分别对应删除,添加和替换操作
	return dp[-1][-1] # 返回最终状态就是所求最小的编辑距离
2.6 第198题打家劫舍
  • 题目描述

你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。

  • 标准答案
def rob(self, nums):  
	if(not nums):   # 特殊情况处理
		return 0
	if len(nums)==1:
		return nums[0]
	n=len(nums)
	dp=[0]*n    # 初始化状态转移数组
	dp[0]=nums[0]  # 第一个边界值处理
	dp[1]=max(nums[0],nums[1]) # 第二个边界值处理
	for i in range(2,n):
		dp[i]=max(dp[i-2]+nums[i],dp[i-1]) # 状态转移方程
	return dp[-1]
2.7 第213题打家劫舍II
  • 题目描述

你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。

  • 标准答案
def rob(self, nums: List[int]) -> int:
        if not nums:
            return 0
        elif len(nums)<=2:
            return max(nums)
        def helper(nums):
            if len(nums)<=2:
                return max(nums)
            dp=[0]*len(nums)
            dp[0]=nums[0]
            dp[1]=max(nums[0],nums[1])
            for i in range(2,len(nums)):
                dp[i]=max(dp[i-1],dp[i-2]+nums[i])
            return dp[-1]
        return max(helper(nums[1:]),helper(nums[:-1]))

猜你喜欢

转载自blog.csdn.net/xylbill97/article/details/108169009