目录
- 背景:
- 常见四种类型题目
- 1. 矩阵类型(10%)
- 2. 零钱和背包(10%)
- 3. 序列类型(40%)
- [70. 爬楼梯](https://leetcode-cn.com/problems/climbing-stairs/)
- [55. 跳跃游戏](https://leetcode-cn.com/problems/jump-game/)
- [45. 跳跃游戏 II](https://leetcode-cn.com/problems/jump-game-ii/)
- [132. 分割回文串 II](https://leetcode-cn.com/problems/palindrome-partitioning-ii/)
- [300. 最长上升子序列](https://leetcode-cn.com/problems/longest-increasing-subsequence/)
- [139. 单词拆分](https://leetcode-cn.com/problems/word-break/)
- 4.Two Sequences DP(40%)
- 总结:
背景:
120. 三角形最小路径和
如同树问题一样,这道题可以使用DFS来解决。
对于树问题,因为子树与子树之间是没有重复元素的,所以可以使用DFS来做
对于这道题,会有重复的情况,所以适合使用动态规划来解决。
# 自顶向下
# 子问题:dp[i,j]为从[0,0]到[i,j]点的最短路径
# 递推式:dp[i,j] = min(dp[i-1][k] for k in range(j-1, j+2) + triangle[i][j])
# 初始值: dp[0,0] = triangle[0][0]
# 时间复杂度: O(n^2) n为三角形行数
# 空间复杂度:O(n^2)
class Solution:
def minimumTotal(self, triangle: List[List[int]]) -> int:
def getLastMin(dp, i, j):
left = j - 1 if j > 0 else 0
right = j if j < len(triangle[i]) - 1 else j - 1
return min([dp[i - 1][k] + triangle[i][j] for k in range(left, right + 1)])
if len(triangle) == 1:
return triangle[0][0]
# dp[i,j]为从[0,0]到[i,j]点的最短路径
dp = [[] for _ in range(len(triangle))]
for i in range(len(triangle)):
for j in range(len(triangle[i])):
dp[i].append(0)
# for x in dp:
# print(x)
# 递推式:
# dp[i,j] = min(dp[i-1][k] for k in range(j-1, j+2) + triangle[i][j])
# 初始值: dp[0,0] = triangle[0][0]
for i in range(len(triangle)):
for j in range(len(triangle[i])):
if i == 0 and j == 0:
dp[i][j] = triangle[0][0]
else:
dp[i][j] = getLastMin(dp, i ,j)
# for x in dp:
# print(x)
return min(dp[-1])
# 自顶向下的动态规划 + 空间优化
# 我们发现dp[i]的值只与上一层的dp[i],dp[i-1]有关,所以我们可以只保留上一层的值
# 子问题:dp[0][i]为从顶点到前一层第i点的最短路径
# dp[1][i]为从顶点到当前层第i点的最短路径
# 递推式:dp[1][i] = min( dp[0][i-1], dp[0][i] ) + traingle[cur_layer][i]
# 初始值: dp[0] = triangle[0], dp[1] = []
# 时间复杂度: O(n^2) n为三角形行数
# 空间复杂度:O(n)
class Solution:
def minimumTotal(self, triangle: List[List[int]]) -> int:
if not triangle:
return 0
# 初始化
n = len(triangle)
dp = [[] for _ in range(2)]
dp[0] = triangle[0]
for l in range(1, n): # l为当前层数,一直到最后一层(n-1)
for i in range(l + 1): # 当前层的元素数量为当前层数+1
if i == 0: # 左边界
dp[1].append(dp[0][0] + triangle[l][i])
elif i == len(dp[0]): # 右边界
dp[1].append(dp[0][i-1] + triangle[l][i])
else:
dp[1].append(min(dp[0][i], dp[0][i-1]) + triangle[l][i])
dp[0] = dp[1]
dp[1] = []
return min(dp[0])
5. 最长回文子串
子问题: d p [ i ] [ j ] = 从 索 引 i 到 索 引 j 的 字 符 串 是 否 为 回 文 子 串 dp[i][j] = 从索引i到索引j的字符串是否为回文子串 dp[i][j]=从索引i到索引j的字符串是否为回文子串
递推式:
d p [ i , j ] = T r u e dp[i,j]=True dp[i,j]=True $ if s[i]==s[j] 且 j-1 = i$
d p [ i , j ] = = T r u e dp[i,j] == True dp[i,j]==True i f s [ i ] = s [ j ] if s[i]=s[j] ifs[i]=s[j] and d p [ i + 1 ] [ j − 1 ] = = T r u e dp[i+1][j-1]==True dp[i+1][j−1]==True : $i = [0,n-3] $ , j = [ i + 2 , n − 1 ] j = [i+2, n-1] j=[i+2,n−1]
这是基础的二维数组动态规划,可惜的是,python并不能通过。
时间复杂度 : O ( n 2 ) O(n^2) O(n2)
空间复杂度: O ( n 2 ) O(n^2) O(n2)
当然,空间复杂度可以优化到 O ( n ) O(n) O(n),画图便知,这里就不赘述
class Solution:
def longestPalindrome(self, s: str) -> str:
if len(s) <= 1:
return s
# 子问题: dp[i,j]为索引i到索引j的字符串是否为回文子串
n = len(s)
dp = [[False] * n for _ in range(n)]
max_index = (0, 0)
i = n - 1
while i >= 0:
j = 0
while j <= n - 1:
# print(f'{i,j} is doing')
if i > j: # dp[i,j]为False if i > j 这一步不用写,只是为了展示思路
dp[i][j] = False
elif i == j: # dp[i,j]为True if i = j
dp[i][j] = True
elif j - i == 1 and s[i] == s[j]: # 如果向量两个字符相等, 则为True
dp[i][j] = True
elif s[i] == s[j] and dp[i+1][j-1]: # 如果相隔两个以上,且中间为True,则查看两边元素是否相等
dp[i][j] = True
# else:
# print(f"No idea {i,j}")
# 更新最长的回文子串
if dp[i][j] and j - i > max_index[1] - max_index[0]:
max_index = (i, j)
# print(f"update {s[i:j+1]}")
j += 1
i -= 1
# print('next')
# for x in dp:
# print(x)
return s[max_index[0]: max_index[1] + 1]
注:
空间复杂度优化为 O ( 1 ) O(1) O(1)的中心扩散法:
对每个起始的字符或者字符对向两边扩散,最后比较每次扩散后的长度
class Solution:
def longestPalindrome(self, s: str) -> str:
def expandFromCenter(left, right):
while left >= 0 and right <= len(s) - 1 and s[left] == s[right]:
left -= 1
right += 1
return left + 1, right - 1
start, end = 0, 0
for i in range(len(s)):
left1, right1 = expandFromCenter(i, i)
left2, right2 = expandFromCenter(i, i + 1)
if right1 - left1 > end - start:
start, end = left1, right1
if right2 - left2 > end - start:
start, end = left2, right2
return s[start: end + 1]
什么时候用动态规划?
- 求最值
- 求是否可行
- 求可行的个数
- 满足 不能排序或者交换
四要素
- 子问题/状态
- 递归方程
- 初始化
- 最后答案
常见四种类型题目
四要素都在注释中
1. 矩阵类型(10%)
64. 最小路径和
class Solution:
def minPathSum(self, grid: List[List[int]]) -> int:
if not grid:
return 0
# 子问题
# dp[i,j]为从左上角到grid[i,j]的最短路径
m, n = len(grid), len(grid[0])
if m * n == 1:
return grid[0][0]
dp = [[0] * n for _ in range(m)]
# 递推式
# if m > i > 0 and n > j > 0:
# dp[i,j] = min(dp[i-1, j], dp[i, j-1]) + grid[i,j]
# 初始化
# dp[0][0] = grid[0][0]
# dp[0][j] = dp[0][j-1] + grid[0][j]
# dp[i][0] = dp[i-1][0] + grid[i][0]
dp[0][0] = grid[0][0]
for j in range(n):
dp[0][j] = dp[0][j-1] + grid[0][j]
for i in range(m):
dp[i][0] = dp[i-1][0] + grid[i][0]
for i in range(1, m):
for j in range(1, n):
dp[i][j] = min(dp[i-1][j], dp[i][j-1]) + grid[i][j]
return dp[m-1][n-1]
# 2D dp 优化 1D dp
class Solution:
def minPathSum(self, grid: List[List[int]]) -> int:
if not grid:
return 0
m, n = len(grid), len(grid[0])
if m * n == 1:
return grid[0][0]
# 子问题
# dp[i]为从左上角到当前层的第i个元素的最短路径
dp = [101 for _ in range(n)] # 比最大值100大就行
# 初始化
# dp[0] = grid[0][0]
# 递推式
# if 0 < i < n: dp[i] = min(dp[i - 1], dp[i]) + grid[cur_layer][i]
dp[0] = 0
for l in range(m):
dp[0] = grid[l][0] + dp[0]
for i in range(1, n):
dp[i] = min(dp[i - 1], dp[i]) + grid[l][i]
return dp[n-1]
62. 不同路径
# 2D DP
class Solution:
def uniquePaths(self, m: int, n: int) -> int:
# 子问题
# dp[i,j] 从左上角到[i,j]的路径个数
# 初始化
# dp[0,j]=1, dp[i,0]=1
# 递推式
# dp[i,j] = dp[i-1][j] + dp[i][j-1]
dp = [[1] * n for _ in range(m)]
for i in range(m):
for j in range(n):
if i == 0 or j == 0:
dp[i][j] = 1
else:
dp[i][j] = dp[i-1][j] + dp[i][j-1]
return dp[m-1][n-1]
class Solution:
def uniquePaths(self, m: int, n: int) -> int:
# 子问题
# dp[i] 从左上角到当前层第i个点的路径个数
# 初始化
# dp[0]=1
# 递推式
# dp[i] = dp[i-1] + dp[i]
dp = [1 for _ in range(n)]
for l in range(1, m):
for i in range(1, n):
dp[i] = dp[i-1] + dp[i]
return dp[n-1]
63. 不同路径 II
# 1D dp
class Solution:
def uniquePathsWithObstacles(self, obstacleGrid: List[List[int]]) -> int:
# 子问题
# dp[i] 从左上角到当前层第i个点的路径个数
# 初始化
# dp[0]=1 dp[i]=0如果有障碍物
# 递推式
# dp[i] = dp[i-1] + dp[i], 有障碍物就不加
if not obstacleGrid:
return 0
m, n = len(obstacleGrid), len(obstacleGrid[0])
if m * n == 1:
return 1 - obstacleGrid[0][0]
dp = [0 for _ in range(n)]
block = False
# 第一行,遇到障碍物后,后面全是0
for i in range(len(obstacleGrid[0])):
if obstacleGrid[0][i] == 0:
dp[i] = 1
else:
break
for l in range(1, m):
if obstacleGrid[l][0] == 1 or dp[0] == 0:
dp[0] = 0
for i in range(1, n):
if obstacleGrid[l][i] == 1:
dp[i] = 0
else:
dp[i] = dp[i-1] + dp[i]
return dp[n-1]
矩阵类型题目总结
2D矩阵,当前值来自于上下左右方向(一般是两个方向),大概率有来自上方。
所以2D的dp几乎都可以优化成1D的dp,从左至右,与dp[i]和dp[i-1]
2. 零钱和背包(10%)
python### 322. 零钱兑换
class Solution:
def coinChange(self, coins: List[int], amount: int) -> int:
if amount == 0: # 金额为0
return 0
if not coins: # 没有硬币
return -1
if len(coins) == 1: # 只有一个硬币的情况
if amount % coins[0] == 0:
return amount // coins[0]
else:
return -1
# 子问题
# dp[i]为组成金额i所需最小的银币数量
dp = [-1 for _ in range(amount + 1)]
dp[0] = 0
coins = [c for c in coins if c <= amount] # 剔除大于amount的硬币
for c in coins:
dp[c] = 1
for i in range(1, amount+1):
if dp[i] != 1:
temp = [dp[i-c] for c in coins if dp[i-c] != -1 and i-c >= 0]
if temp:
dp[i] = min(temp) + 1
# print(f"{i} 块 {dp[i]} 个硬币")
return dp[amount]
backpack
class Solution:
"""
@param m: An integer m denotes the size of a backpack
@param A: Given n items with size A[i]
@return: The maximum size
"""
def backPack(self, m, A):
n = len(A)
dp = [0] * (m + 1)
dp_new = [0] * (m + 1)
for i in range(n):
for j in range(1, m + 1):
use_Ai = 0 if j - A[i] < 0 else dp[j - A[i]] + A[i]
dp_new[j] = max(dp[j], use_Ai)
dp, dp_new = dp_new, dp
return dp[-1]
3. 序列类型(40%)
70. 爬楼梯
# 空间复杂度 O(n)
class Solution:
def climbStairs(self, n: int) -> int:
if n <= 0:
return 1
# 子问题
# dp[i]:爬到i阶的方法数
dp = [1 for _ in range(n+1)]
# 递推式
# dp[i] = dp[i-1] + dp[i-2]
# 初始化dp[1] = 1 dp[2] = 2
for i in range(2, n+1):
if i == 2:
dp[2] = 2
else:
dp[i] = dp[i-1] + dp[i-2]
return dp[n]
class Solution:
def climbStairs(self, n: int) -> int:
if n <= 1:
return 1
# 子问题
# dp:爬到当前阶的方法数
# dp_1: 爬到当前的前一阶的方法数
# dp_2: 爬到当前的前二阶的方法数
# 初始化
dp_1, dp_2 = 1, 1
# 递推式
# dp = dp_1 + dp_2
# 从第二阶开始
for i in range(2, n+1):
temp = dp_1
dp_1 = dp_1 + dp_2
dp_2 = temp
# print(f"{i} 阶")
return dp_1
55. 跳跃游戏
虽说动态规划可以解决一切贪心算法可以解决的问题…但有些时候会造成计算复杂度太大,…
# 动态规划 (超时)
class Solution:
def canJump(self, nums) -> bool:
if len(nums) < 2:
return True
# 子问题
# dp[i] 表示能否调到位置i
dp = [False for _ in range(len(nums))]
# print(dp)
# 递推式
next_index = [i for i in range(nums[0])]
# print(f"初始化 {next_index}")
while next_index:
next_index_new = []
for i in next_index:
dp[i] = True
# print(f"{i} 可达 ")
temp = [i + k for k in range(nums[i]+1) if i+k < len(nums)]
for index in temp:
if not dp[index]:
dp[index] = True
next_index_new.append(index)
# print(f"从 {i} 到 {index} 可达 ")
next_index = next_index_new
return dp[-1]
# 贪心算法
class Solution:
def canJump(self, nums: List[int]) -> bool:
n = len(nums)
rightmost = 0
for i in range(n):
if i <= rightmost:
rightmost = max(rightmost, i + nums[i])
if rightmost >= n - 1:
return True
return False
45. 跳跃游戏 II
class Solution:
def jump(self, nums: List[int]) -> int:
if len(nums)<=1:
return 0
if nums[0] >= len(nums)-1:
return 1
step = 1
left, right = 0, nums[0]
rightmost = 0
while right >= left:
for i in range(left, right + 1):
if i + nums[i] > rightmost:
rightmost = i + nums[i]
# print(f"从 {i} 开始,最远 {rightmost}")
if rightmost >= len(nums)-1:
return step+1
left = right+1
right = rightmost
step+=1
从左到右,每次选取能走到的最远的点作为下一步的起点
class Solution:
def jump(self, nums: List[int]) -> int:
n = len(nums)
step = 0
cur_max = 0
step_max = 0
for i in range(n-1):
if cur_max >= i:
cur_max = max(nums[i]+i, cur_max)
if i == step_max: # 一直到i达到上一step的max,说明该下一步了
step += 1
step_max = cur_max
return step
132. 分割回文串 II
分为两个部分。表层的问题其实便是到索引i的分割次数,这是一个dp, 里层还有一个判断是否为回文串,时间复杂度为 n 2 2 {n^2}^2 n22,我们注意到里层的判断回文串有重复计算的部分,可以提前存储使得读取结果复杂度降维O(1),所以复杂度可以降到 O ( n 2 ) O(n^2) O(n2)
class Solution:
def minCut(self, s: str) -> int:
n = len(s)
dp_pal = [[False] * n for _ in range(n)]
# 首先构建回文子串的查询表,确保之后的查询时间复杂度为O(1)
for right in range(n):
for left in range(right+1):
if s[left] == s[right] and (right - left < 2 or dp_pal[left+1][right-1]):
dp_pal[left][right] = True
# 子问题: dp[i] 到索引i的字符串的最小分割次数
# 递推式: dp[i] = min(dp[j]+1 for j in range(0,i) if s[j+1:i] 是回文)
# 初始化: dp[i] 最多切i次, 最少不切
dp = [0 for _ in range(n)]
for i in range(n):
dp[i] = i
for i in range(1, n):
if dp_pal[0][i]:
dp[i] = 0
else:
dp[i] = min(dp[j] + 1 for j in range(i) if dp_pal[j+1][i])
return dp[n-1]
300. 最长上升子序列
动态规划可解,但是时间复杂度为 n 2 n^2 n2
class Solution:
def lengthOfLIS(self, nums: List[int]) -> int:
# 子问题:
# dp[i]: 从0到i, 以i结尾的的最长上升子序列长度
# 递推式:
# dp[i] = max(dp[i-k] for k in range(i) if nums[i] >= nums[i-k])
# 初始化:
# dp = [1]*n
if not nums:
return 0
n = len(nums)
dp = [1] * n
for i in range(n):
temp = [dp[k]+1 for k in range(i) if nums[i] > nums[k]]
if temp:
dp[i] = max(temp)
# print(f"{i} : {dp[i]}")
return max(dp)
解法二:贪心算法 + 动态规划
跟动态规划一样同样是针对每个元素进行更新,不同的是之前的动态规划,针对dp[k]我们都要计算一次k之前的元素,实际上是重复计算了很多。如果要优化,我们可以想到二分查找将这个O(N)的过程优化成O(logN)。
但是二分查找需要数组有序,我们需要构建一个有序的数组存出结果,于是我们可以构建一个tails, tails[k]表明长度为k+1的最长子序列的末尾元素。贪心的规则是我们要尽可能使得这个末尾元素小,经过证明可以得到最优解。
class Solution:
def lengthOfLIS(self, nums: List[int]) -> int:
def updateTails(tails, num):
# 用二分查找更新tails
if not tails or num > tails[-1]: # 添加tails元素
tails.append(num)
else: # 更新tails元素
left, right = 0, len(tails)-1
while left <= right:
mid = (left + right) // 2
if tails[mid] == num:
left = mid
break
elif tails[mid] < num:
left = mid + 1
else:
right = mid - 1
tails[left] = num
if not nums:
return 0
# 贪心 + 二分查找
n = len(nums)
# tails[k]表示目前为止长度为k+1的上升子序列的末值
tails = []
for num in nums:
updateTails(tails, num)
return len(tails)
139. 单词拆分
class Solution:
def wordBreak(self, s: str, wordDict: List[str]) -> bool:
# 状态 dp[i]为从0到i是否可拆分
# 递推式 dp[i] = [dp[k] for k in range(i) if s[k:i+1] in wordDict and dp[k]]
n = len(s)
dp = [False for _ in range(n)]
# 初始化
for i in range(n):
if s[:i+1] in wordDict:
dp[i] = True
# print(s[:i+1])
# print(f"初始值 {dp}")
# 递推式 dp[i] = [dp[k] for k in range(i) if s[k:i+1] in wordDict and dp[k]]
for i in range(n):
if not dp[i]:
# print(f"现在是{i}")
for k in range(i):
if dp[k] and s[k+1:i+1] in wordDict:
dp[i] = True
# print(s[k+1: i+1])
# print(f"更新{dp}")
return dp[-1]
4.Two Sequences DP(40%)
一般都是两个字符串的问题,这里我们要记住,初始化的时候记得要多padding一行一列,考虑好padding的值,后面会方便很多
1143. 最长公共子序列
注意这里我们给左上角加了padding,这样就方便一些
class Solution:
def longestCommonSubsequence(self, text1: str, text2: str) -> int:
# 状态: dp[i][j]为截止到text1的j和text2的i的公共子序列长度
# dp[i][j] = dp[i-1][j-1] + 1 if text1[j-1]==text2[i-1] else max(dp[i-1][j], dp[i][j-1])
# 初始化: dp[i][j] = 0
if not text1 or not text2:
return 0
m, n = len(text1)+1, len(text2)+1
dp = [[0] * m for _ in range(n)]
for i in range(1, n):
for j in range(1, m):
if text1[j-1] == text2[i-1]:
dp[i][j] = dp[i-1][j-1] + 1
else:
dp[i][j] = max(dp[i-1][j], dp[i][j-1])
# for x in dp:
# print(x)
return dp[-1][-1]
注意到上面的2D DP是可以优化为O(N)空间复杂度的
class Solution:
def longestCommonSubsequence(self, text1: str, text2: str) -> int:
# 状态: dp[j]当前i行 截止到text1的j和text2的i的公共子序列长度
# 递推式: dp_new[j] = dp[j-1] + 1 if text1[j]==text2[i] else dp_new[j] = max(dp[j], dp_new[j-1])
# 初始化: dp[0][j] = 0 dp[i][0] = 0
if not text1 or not text2:
return 0
m, n = len(text1)+1, len(text2)+1
dp = [0] * m
for i in range(1, n):
dp_new = [0] * m
for j in range(1, m):
if text1[j-1] == text2[i-1]:
dp_new[j] = dp[j-1] + 1
else:
dp_new[j] = max(dp[j], dp_new[j-1])
dp, dp_new = dp_new, dp
# for x in dp:
# print(x)
return dp[-1]
72. 编辑距离
class Solution:
def minDistance(self, word1: str, word2: str) -> int:
# 状态 dp[i][j]是word1[:i]到word2[:j]的编辑距离
# 递推式:
# if word1[i] == word2[j]: dp[i][j] = min(dp[i-1][j-1], dp[i-1][j]+1, dp[i][j-1]+1)
# if word1[i] != word2[j]: dp[i][j] = min(dp[i-1][j-1]+1, dp[i-1][j]+1, dp[i][j-1]+1)
# 初始值:
# all set 0
# dp[i][0] = dp[i-1][0] + 1
# dp[0][j] = dp[0][j-1] + 1
if not word1 and not word2:
return 0
if not word1 or not word2:
return 1
m, n = len(word1) + 1, len(word2) + 1
# 初始值
dp = [[0] * m for _ in range(n)]
for i in range(n):
dp[i][0] = i
for j in range(m):
dp[0][j] = j
for i in range(1, n):
for j in range(1, m):
# print(f"比较{word2[i]}和{word1[j]}")
# print(f"比较 {i,j}")
if word2[i-1] == word1[j-1]:
dp[i][j] = min(dp[i-1][j-1], dp[i-1][j]+1, dp[i][j-1]+1)
else:
dp[i][j] = min(dp[i-1][j-1]+1, dp[i-1][j]+1, dp[i][j-1]+1)
# for x in dp:
# print(x)
# for x in dp:
# print(x)
return dp[-1][-1]
优化空间:
class Solution:
def minDistance(self, word1: str, word2: str) -> int:
if not word1 and not word2:
return 0
if not word1 or not word2:
return 1
m, n = len(word1) + 1, len(word2) + 1
# 初始值
dp = [0] * m
for j in range(m):
dp[j] = j
dp_new = [0] * m
for i in range(1, n):
dp_new[0] = i
for j in range(1, m):
# print(f"比较{word2[i]}和{word1[j]}")
# print(f"比较 {i,j}")
if word2[i-1] == word1[j-1]:
dp_new[j] = min(dp[j-1], dp[j]+1, dp_new[j-1]+1)
else:
dp_new[j] = min(dp[j-1]+1, dp[j]+1, dp_new[j-1]+1)
dp, dp_new = dp_new, dp
# for x in dp:
# print(x)
return dp[-1]
总结:
总体来说,时候用动态规划的题目都有一个明显的特点,就是最优解明显依赖于其子问题的解,这在应对求最值,求是否可行,求可行的个数, 满足 不能排序或者交换的问题特别有用,只需要按照状态 -》递归 -》初始值的顺序做就可以