算法学习笔记——LeetCode分类算法
2.动态规划
1.主要思想
当一个问题通过划分为众多子问题进行求解时,就需要对子问题的求解进行规划,以减少冗余计算,提高算法效率,达到优化算法的目的——这种时候就应当考虑用动态规划对子问题进行调度。这种问题往往是递归问题。
动态规划往往适用于有重叠子问题和最优子结构性质的问题,动态规划方法所耗时间往往远少于朴素解法。
2.动态规划的要素
- 重叠子问题:动态规划法仅仅解决每个子问题一次,具有天然剪枝的功能,从而减少计算量,一旦某个给定子问题的解已经算出,则将其记忆化存储,以便下次需要同一个子问题解之时直接查表。
- 蛮力找最优:动态规划基于的递归往往是基于蛮力遍历所有可能的特征。
- 最优子结构:大问题的解,必定是由小问题的最优解组合而成。
3.动态规划模板步骤
- 确定动态规划状态
- 写出状态转移方程(画出状态转移表)
- 考虑初始化条件
- 考虑输出状态
- 考虑对时间,空间复杂度的优化(Bonus)
4.动态规划的应用
1)引入——斐波那契数列
问题描述:求斐波那契数列的第n个元素Fn(n>1)。
解题思路:
1.朴素递归
直接套用斐波那契数列的递推公式F(n)=F(n-1)+F(n-2)。
2.动态规划
利用递归求解过程中存在明显的冗余——在计算Fn时,它的几乎所有子问题都被重复计算过,而且越小的子问题,重复求解的次数越多。这时有一个减少重复计算的方法就是用一个数列存储子问题的解,这样子问题只需计算一次,其后大问题只需从数列中调用解即可。这即是应用动态规划思想的一个最简单的例子。
python代码实现如下:
2)Leetcode 300.最长上升子序列
问题描述:
给定一个无序的整数数组,找到其中最长上升子序列的长度。(可能会有多种最长上升子序列的组合,只需要输出对应的长度即可。算法的时间复杂度应该为 O(n2) 。
示例:
输入: [10,9,2,5,3,7,101,18]
输出: 4
解释: 最长的上升子序列是 [2,3,7,101],它的长度是 4。
解题思路:(根据动态规划模板步骤进行)
设输入的数组为a[0..n-1]
- 确定动态规划状态
是否存在状态转移? 根据什么样的状态比较好转移,找到对求解问题最方便的状态转移。对于本题可以直接用一个一维数组dp来存储状态转移。dp[i]定义为输入数组a[0..n-1]中a[i]的上升子序列的最长长度。
- 写出状态转移方程(画出状态转移表)
利用归纳思想,写出状态转移方程。以示例中的数组为例,a[0..7]=[10,9,2,5,3,7,101,18],对于a[5]=7,dp[5]代表a[5]的最长伤身子序列的长度,dp[5]可以通过如下方法求出:先找出a[5]之前的比7小的元素的上升子序列,然后将7加到其后,就形成了新的上升子序列,最后找到最长的长度赋值给dp[5]即可。代码描述如下:
for i in [0..n-1]:
for j in [0..i]:
if a[i] > a[j]:
dp[i] = max{dp[i], dp[j]+1}
- 考虑初始化条件
接下来就要考虑边界条件了。边界条件通常需要考虑如下三个方面:
- dp数组整体的初始值
- dp数组(二维)i=0和j=0的地方
- dp存放状态的长度,是整个数组的长度还是数组长度加一。这点需要特别注意。
对于此问题中数组a[0..n-1],其中每个元素都是其自身的一个子序列,因此dp中每个元素的值应该初始化为1,dp长度当与数组a的长度相同。
- 考虑输出状态
大体算法结构设计好后,就需要考虑输出内容:什么内容才是需要展示的?是dp中的元素还是需要另外求解?通常情况我们需要的包括:
- 返回dp数组中最后一个值作为输出,一般对应二维dp问题。
- 返回dp数组中最大的那个数字,一般对应记录最大值问题。
- 返回保存的最大值,一般是Maxval=max(Maxval,dp[i])这样的形式。
对于该问题,dp是存储每个元素的最长上升子序列的长度值,因此只需输出dp中最大元素即可。算法伪代码如下:
LengthOfLIS(a[0..n-1]):
if a is null: return 0
dp[0..n-1]=[1,1,...,1]
for i in [0..n-1]:
for j in [0..i]:
if a[i] > a[j]:
dp[i] = max{dp[i], dp[j]+1}
return max{dp[0..n-1]}
- 考虑对时间,空间复杂度的优化(Bonus)
算法设计好后,需要对算法进行评估,对算法进行复杂度分析,若算法的复杂度太高,当进行优化。
对于该算法,时间复杂度为O(n2),空间复杂度为O(n),可利用二分法将时间复杂度降至O(nlogn)。
算法python实现如下:
def LengthOfLIS(nums):
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) # 输出条件
【拓展】对于子序列问题,通常可以用本题中的算法模板进行求解,模板总结如下:
for i in [0..n-1]:
for j in [0..i]:
if a[i] > a[j]:
dp[i] = max{dp[i], dp[j]+…}
3)Leetcode 674.最长连续递增序列
问题描述:
给定一个未经排序的整数数组,找到最长且连续的的递增序列。
示例:
输入: [1,3,5,4,7]
输出: 3
解释: 最长连续递增序列是 [1,3,5], 长度为3。
尽管 [1,3,5,7] 也是升序的子序列, 但它不是连续的,因为5和7在原数组里被4隔开。
解题思路:
这道题与上道题类似,都是找递增序列最长长度,不同的是该题寻找的是连续序列,而上题的最长递增子序列未必连续。仍然根据动态规划的步骤模板分析设计如下:
- 确定动态规划状态
与上题相同,通过数列dp存储数组中截止每个元素的最长连续递增子序列。
- 写出状态转移方程
该问题有两种情况:首先,如果当前元素比其前元素小,则其前元素的连续递增子序列到此元素截止,此元素的连续递增子序列最长长度将设为1;其次,若当前元素与其前一个元素构成递增关系,则当前元素的最长递增子序列可以看做前一个元素的最长递增子序列加上当前元素,则该元素递增子序列的最长长度为dp[i]=dp[i-1]+1。该思路可以如下表示:
- 考虑初始化条件
与前题相同,dp初始化为全为1的向量即可。
- 考虑输出状态
与前题相同,该题输出内容也是dp中最大元素。
- 考虑对时间,空间复杂度的优化
该题所求问题为连续子序列,因此只需对输入数组遍历一次即可,算法的优化空间不大。
最后算法的python实现如下:
def LengthOfLCIS(nums):
if len(nums) == 0:
return 0
dp = [1] * len(nums)
for i in range(1,len(nums)):
if nums[i] > nums[i-1]:
dp[i] = dp[i-1] + 1
else:
pass
return max(dp)
4)Leetcode5. 最长回文子串
问题描述:给定一个字符串 s,找到 s 中最长的回文子串。你可以假设 s 的最大长度为 1000。
示例:
输入: "babad"
输出: "bab"
注意: "aba" 也是一个有效答案。
解题思路:
- 确定动态规划状态
该问题需要的输出是子串而非长度,因此需要用一定的方法“记录”搜索过程中的回文子串,仅一维向量存储状态转移肯定是不够的,因此考虑用二维数组存储状态转移。这里定义dp[i][j]记录输入序列中从i到j的子串是否为回文串。
- 写出状态转移方程(画出状态转移表)
对于该问题,有如下两方面的考虑:
首先,如果一个串为回文,则该串首尾的元素一定相同;
其次,若一个串为回文,则其去掉首尾的元素后仍然是回文,因此判断一个串是否是回文,可以先判断该串的首尾元素是否相同,如果相同,则判断该串去掉首尾元素的子串是否是回文,如果是,则该串为回文。下图即该思想的展示:
状态转移方程如下:
if s[i]==s[j]:
if j-i<3:
dp[i][j]=True
else:
dp[i][j]=dp[i+1][j-1]
- 考虑初始化条件
该题需要建立二维数组dp来存储转移状态,因此只需将数组dp中的元素都初始化为false即可。
- 考虑输出状态
根据题意可知该题所需的输出结果为一个串,因此要找出子回文串中长度最长的回文串的首尾位置。可以通过遍历dp中为true的元素,计算其下表之差,将差最大的起始位置记录下来,即可输出最长回文子串。
- 考虑对时间,空间复杂度的优化(Bonus)
该算法的时间空间复杂度都为O(n2),都可以进一步优化,具体优化方法将另外总结一篇笔记,此处不再进行优化。
该算法的python实现如下:
def longestPalindrome(s):
n = len(s)
if n == 0:
return 0
if n < 2:
return s
dp = [[False] * n] * n
max_len = 1
start = 0
for j in range(1, n):
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]
else:pass
if dp[i][j] == True:
cur_len = j - i + 1
if cur_len > max_len:
max_len = cur_len
start = i
for i in range(n):
print(dp[i])
return s[start:start + max_len]
5)Leetcode516. 最长回文子序列
问题描述:给定一个字符串s,找到其中最长的回文子序列。可以假设s的最大长度为1000。
解题思路:
此问题与上一个问题类似,不同之处在于此问题要求的是最长回文子序列的长度,返回结果是一个数值,且子序列无需连续。算法分析设计如下:
- 确定动态规划状态
与上一题一样,设置一个二维数组dp来存储转移状态。dp[i][j]存储第i个元素到第j个元素的最长回文子串的长度。
- 写出状态转移方程
与上一题以及求最长上升子序列类似,当一个串首尾元素相同时,可以考虑该串是否为回文串,因为这里只需要获取长度,因此只需将该串去掉首尾元素后子串的最长回文子串的长度加2即为该串中最长回文子串的长度;
如果该串的首尾元素不相等,假设该串去掉首元素产生的子串为a1,去掉尾元素产生的子串为a2,则该串的最长回文子串就是a1与a2的最长回文子串中长度最长的那个,可以表示为max{length of sub-a1,length of sub-a2}。
状态转移方程如下:
if s[i]==s[j]:
dp[i][j]= dp[i+1][j-1]+2
else:
dp[i][j]=max(dp[i][j-1],dp[i+1][j])
- 考虑初始化条件
显然,当只有一个字符时,最长回文序列为1,所以当i=j时,dp[i][j]=1;当i>j时,不存在子序列,所以dp[i][j]=0;当i<j时,就需要通过状态转移方程进行计算了。
- 考虑输出状态
因为dp存储的是每个子序列的最长回文子序列的长度值,因此输出dp中的最大值即可。
- 考虑对时间,空间复杂度的优化
根据初始化条件的分析,我们知道当i>j时,不存在子序列,因此二维数组dp中有接近一半的空间被浪费了,因此可以对该算法的空间复杂度进行优化。优化算法同前题另外总结,此处不再赘述。
此算法python实现如下:
def longestPalindromeSubseq(s):
n = len(s)
if n == 0:
return 0
dp = [[0] * n] * n
for i in range(n):
dp[i][i] = 1
for i in range(n, -1, -1):
for j in range(i + 1, n):
if s[i] == s[j]:
dp[i][j] = dp[i+1][j-1] + 2
else:
dp[i][j] = max(dp[i+1][j], dp[i][j-1])
return dp[0][-1]
6)Leetcode72. 编辑距离
问题描述:给定两个单词 word1 和 word2,计算出将 word1 转换成 word2 所使用的最少操作数 。
示例:
你可以对一个单词进行如下三种操作:
插入一个字符
删除一个字符
替换一个字符
示例 1:
输入: word1 = "horse", word2 = "ros"
输出: 3
解释:
horse -> rorse (将 'h' 替换为 'r')
rorse -> rose (删除 'r')
rose -> ros (删除 'e')
解题思路:
- 确定动态规划状态
该问题输入两个单词,因此考虑使用二维数组作为转移状态的存储容器。建立二维数组dp,dp[i][j]表示word1长度为i、word2长度为j时word1转化为word2需要的最少操作次数。
- 写出状态转移方程
对于该问题,可以先从两个单词的末尾同时进行遍历,当word[i]=word[j]时,此时定位的两个字母相同,状态转移到前一个状态,即dp[i][j]=dp[i-1][j-1]。而当word[i]≠word[j]时,则需要对当前位置考虑三种操作——插入、删除或替换。
- 考虑初始化条件
这里用空字符串来额外加入到word1和word2中,这样的目的是方便记录每一步操作,例如如果其中一个是空字符串,那么另外一个字符至少的操作数都是1,就从1开始计数操作数,以后每一步都执行插入操作,也就是当i=0时,dp[0][j]=j,同理可得,如果另外一个是空字符串,则对当前字符串执行删除操作就可以了,也就是dp[i][0]=i。
- 考虑输出状态
在转移表中可以看到,可以从左上角一直遍历到左下角的值,所以最终的编辑距离就是最后一个状态的值,对应的就是dp[-1][-1]。
- 考虑对时间,空间复杂度的优化
该题与上题类似,由于二维数组中都有近一半的空间没有利用,因此可以对时间空间复杂度进行优化。此处暂不优化。
【!】本题未完全理解,需要反复斟酌
代码实现如下:
def minDistance(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] #返回最终状态就是所求最小的编辑距离
7)Leetcode198. 打家劫舍
问题描述:
你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。
给定一个代表每个房屋存放金额的非负整数数组,计算你在不触动警报装置的情况下,能够偷窃到的最高金额。
示例 1:
输入: [1,2,3,1]
输出: 4
解释: 偷窃 1 号房屋 (金额 = 1) ,然后偷窃 3 号房屋 (金额 = 3)。
偷窃到的最高金额 = 1 + 3 = 4 。
解题思路:
- 确定动态规划状态
根据动态规划的思想,该题可以直接以所需输出作为状态转移数组的定义。定义数组dp,dp[i]表示在第i个房屋能够偷窃到的最高金额。
- 写出状态转移方程
该问题的实质是数列中有间隔的元素组成的子数组的最大和问题,因此,dp中每个元素表示的即是其与其前间隔为1的子数组的元素和。假设输入数组为p,则dp[i]=a[i-2]+p[i]。根据题目条件,如果第i间房屋未被抢,则第i-1、i+1间房屋一定都被抢,则此时dp[i]=dp[i-1]。
综合上面两种情况,dp[i]=max{a[i-2]+p[i],dp[i-1]}。
- 考虑初始化条件
因为该题中数组元素一定是非负的,所以dp中存储的值为截止每个房间所盗金额与前面房屋中的金额之和,因此dp最开始的两个元素需要进行赋值,所以初始化只需要dp[0]=nums[0],dp[1]=max(nums[0],nums[1])。
- 考虑输出状态
通过上面的步骤,dp中最后一个元素即为所求问题的解。
- 考虑对时间,空间复杂度的优化
该算法进行了一轮迭代,时间复杂度为O(n),无法继续优化;空间复杂度为O(n),可以改用一个历史变量存储状态转移,将空间复杂度优化到O(1)。
python实现如下:
def rob(nums):
n = len(nums)
if n == 0:
return 0
if n == 1:
return nums[0]
dp = [nums[0], max(nums[0], nums[1])]
for i in range(n - 2):
dp.append(max(dp[-1] + nums[i + 2], dp[-2]))
return dp[-1]
8)Leetcode213. 打家劫舍 II
问题描述:
你是一个专业的小偷,计划偷窃沿街的房屋,每间房内都藏有一定的现金。这个地方所有的房屋都围成一圈,这意味着第一个房屋和最后一个房屋是紧挨着的。同时,相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。
给定一个代表每个房屋存放金额的非负整数数组,计算你在不触动警报装置的情况下,能够偷窃到的最高金额。
示例 1:
输入: [2,3,2]
输出: 3
解释: 你不能先偷窃 1 号房屋(金额 = 2),然后偷窃 3 号房屋(金额 = 2), 因为他们是相邻的。
解题思路:
- 确定动态规划状态
该题转移状态与上题相同,因此与上题定义同样的数组dp来存储转移状态。
- 写出状态转移方程
该题与上题类似,但是比上题多一个条件,所有房屋围城一个环——即第一间房屋与最后一间房屋只能选择其中一间。因此,除了与上一题相同的步骤外,最后还要对首尾两间房中选择使金额最大的一间。
- 考虑初始化条件
与上题初始化条件相同。
- 考虑输出状态
与上题相同,直接输出数组dp最后一个元素就是所要求的结果。
- 考虑对时间,空间复杂度的优化
与上题思路相同。
python实现如下:
def rob2(nums):
n = len(nums)
if n == 0:
return 0
if n <= 2:
return max(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])
temp = max(dp)
nums = nums[:-1]
for i in range(2, n):
dp[i] = max(dp[i-2] + nums[i], dp[i-2])
return max(dp[-1], temp)