目录
一、基础理论
二、例题
1. 青蛙跳台阶
2. 解密数字
3. 最长不含重复字符的子字符串
4. 连续子数组的最大和
5. 最长递增子序列
6. 最长回文字符串
7. 机器人路径条数
8. 礼物的最大价值
一、基础理论
动态规划其实是一种空间换时间的基于历史数据的递推算法,甚至有时连空间也可以节省。动态规划算法,需要 3 个步骤。第一步决定用于记录历史计算结果的数据结构,例如 dp[];第二步构建递推公式,例如 dp[n]=dp[n-1]+dp[n-2];第三步设定初始值和递推顺序,例如 dp[0]=0, dp[1]=1。
二、例题
1. 青蛙跳台阶
一只青蛙一次可以跳上 1 级台阶,也可以跳上 2 级。求该青蛙跳上一个 n 级的台阶总共有多少种跳法。
示例:
输入:3
输出:3
本题首先使用 dp[i] 数据结构存储中间结算结果,表示 i 级台阶的跳法数量,由于要想跳到第 i 级台阶只能从第 i-1 级或者 i-2级台阶跳过来,所以递推公式为 dp[i] = dp[i-1] + dp[i-2],由于想要跳到第 1 级台阶只有一种跳法,要跳到第 2 级台阶有两种跳法,所以初始值 dp[1]=1, dp[2]=2。
def jumpFloor(n):
if (n <= 2):
return n
dp = [0]*(n+1) # 创建一个数组来保存历史数据,多补了一个用不到的0位
dp[1] = 1 # 给出初始值
dp[2] = 2
for i in range(3, n+1): # 通过关系式来计算dp[n]
dp[i] = dp[i-1] + dp[i-2]
return dp[n]
该问题还可以进一步扩展为:一只青蛙一次可跳上 1 级台阶,也可以跳上 2 级 … 它也可以跳上 n 级。求该青蛙跳上一个n级的台阶(n 为正整数)总共有多少种跳法。
扩展问题无法用动态规划来解,需要通过分析得到公式,具体为:
f(n) = f(n-1) + f(n-2) + f(n-3) +… f(1)
f(n-1) = f(n-2) + f(n-3) +… f(1)
f(n) = 2*f(n-1) n>1
2. 解密数字
现有一串神秘的密文 ciphertext,经调查,密文的特点和规则如下:
密文由非负整数组成,数字 0-25 分别对应字母 a-z
请根据上述规则将密文 ciphertext 解密为字母,并返回共有多少种解密结果。
示例:
输入: ciphertext = 216612
输出: 6
解释: 216612 解密后有 6 种不同的形式,分别是 "cbggbc","vggbc","vggm","cbggm","cqggbc" 和 "cqggm"
本题首先使用 dp[i] 表示长度为 i 的字符串的解法,然后分析递推公式。如果第 i-1 数字为 0,那 dp[i] == dp[i-1];如果第 i-1 数字为 1,那 dp[i] == dp[i-1] + dp[i-2];如果第 i-1 数字为 2,且第 i 数字小于 6,那 dp[i] == dp[i-1] + dp[i-2];如果第 i-1 数字大于 2,那 dp[i] == dp[i-1]。所以只需要 i-1 和 i 数字组成的数字大于等于 10 小于等于 25,则 dp[i] == dp[i-1] + dp[i-2],否则 dp[i] == dp[i-1]。需要注意,在使用 dp 时,我们在前面补一个用不到的 0,这样做的目的是让索引 i 与实际长度匹配,但是当需要访问 s 时需要注意 dp[i] 与 s[i-1] 对应。一定要理清楚 dp 与 s 的索引之间的关系,否则很容易搞混了。
def translateNum(num):
s = str(num)
dp = [1] * (len(s)+1) # 定义状态列表,在0位置添加了一个1,以便于递推公式的进行,以及索引与实际长度匹配
dp[1] = 1 # 长度1时的解法
for i in range(2, len(s)+1): # 长度2到长度n的解法
if int(s[i-2:i]) >9 and int(s[i-2:i]) < 26: # dp[i]对应s[i-1]索引
dp[i] = dp[i-1] + dp[i-2]
else:
dp[i] = dp[i-1]
return dp[-1]
3. 最长不含重复字符的子字符串
请从字符串中找出一个最长的不包含重复字符的子字符串,计算该最长子字符串的长度。
示例 :
输入: "abcabcbb"
输出: 3 解释: 因为无重复字符的最长子串是 "abc",所以其长度为 3。
该问题首先使用 dp[i] 表示以 s[i] 为结点的最长不包含重复字符的字符串长度,最终结果就是 dp 中的最大值。那递推公式很容易想到,如果 s[i] 不存在于以 s[i-1] 为结点的最长不包含重复字符的字符串中,那么 dp[i] = dp[i-1] + 1;如果s[i] 存在于以 s[i-1] 为结点的最长不包含重复字符的字符串中,且重复字符所在索引是 j,那么 dp[i] = i - j。此时引出一个问题,如何获取这个字符串,如何判断一个字符是否存在于这个字符串中,以及如何获取重复字符的索引,这个还是非常困难的。所以这里采用另外一种办法,可以使用字典数据结构把所有的字符和索引均存储起来,键是字符,值是索引,当字符存在重复时,会被覆盖,所以通过这个字典可以获取 s[j] 左侧是否存在重复字符,如果存在可以获得最近的索引,如果不存在可以使用默认值 -1 表示,那么此时就可以通过 i-j 与 dp[i-1] + 1 的大小比较以判断是否存在重复字符了,但这种办法会增加内存使用量。这里初始值 dp[0] = 1。
def lengthOfLongestSubstring(s): # 本函数有很多优化点,但为了便于理解,这里没有优化
if not s: return 0 # 如果为空,则返回 0 || 可优化掉
dic = {} # 定义哈希表,用于获取最近相同字符的索引
dic[s[0]] = 0 # 初始化第一个字符对应的哈希表 || 可优化掉,直接放进循环里
dp = [1] * len(s) # 初始化第一个字符对应的 dp || 可优化掉,直接使用一个变量值记录
res = 1 # 初始化第一个字符的 res 结果
for i in range(1, len(s)): # 从第二个字符开始处理
j = dic.get(s[i], -1) # 获取 s[i] 左侧最近相同元素的索引 j
if dp[i-1] + 1 < i - j: # 如果 i-j 大于 dp[i-1]+1 说明在 s[j] 并不存在于以 s[j-1] 为结点的最长无重复字符串中 || 可优化为min()函数
dp[i] = dp[i-1] + 1
else: # 反之
dp[i] = i - j
dic[s[i]] = i # 哈希表记录每个字符,这会造成内存使用量偏高
res = max(res, dp[i])
return res
针对本问题,这里还有一个思路,即实时维护一个集合,当向右遍历字符串时,使得集合中的元素使用对应着当前的最长无重复字符串。那此时就需要一个左指针 left 和一个右指针 i,以及需要实时维护的集合 lookup。i 需要向右遍历,所以 i 对应一个 for 循环。每遍历一个 i 值,需要首先删除集合 lookup 中与 s[i] 重复的字符左侧的所有字符,这里使用了一个很巧妙的办法,即从 left 开始一直删除,直至不存在重复。最后需要将当前字符 s[i] 放入集合中,以完成针对 i 的遍历。此方案在空间消耗上要优于方案1。
def lengthOfLongestSubstring(s):
if not s:return 0
left = 0 # 左指针
lookup = set() # 字典
max_len = 0
cur_len = 0
for i in range(len(s)): # i是右指针,每遍历一个i,lookup都是由left到i的元素组成的字典
cur_len += 1
while s[i] in lookup: # 每遍历一个i,删除lookup中的重复元素,并同步修改left
lookup.remove(s[left])
left += 1
cur_len -= 1 # 附带着同步修改当前i对应字符串的长度
if cur_len > max_len:
max_len = cur_len
lookup.add(s[i])
return max_len
4. 连续子数组的最大和
输入一个长度为 n 的整型数组 array,数组中的一个或连续多个整数组成一个子数组。求所有子数组的和的最大值。
示例:
输入:[1,-2,3,10,-4,7,2,-5]
输出:18
该问题取 dp[i] 作为以 s[i] 为结尾的连续子数组的最大和,那 max(dp) 为最终结果。递推公式为 if dp[i-1]>0: dp[i]=dp[i-1]+s[i] else: dp[i]=s[i]。
def maxSubArray(nums):
dp = [nums[0]] * len(nums)
for i in range(1, len(nums)):
if dp[i-1] > 0:
dp[i] = dp[i-1] + nums[i]
else:
dp[i] = nums[i]
return max(dp)
如果不仅想要获取最大和,还需要获取最大和所对应的字符串,那就需要增加几个变量实时记录当前 dp[i] 所对应的字符串,这里其实属于滑动窗口问题了,滑动窗口的右边界很容易界定,就是遍历的参数,左边界不太好想清楚,其实左边界只有在 dp[i-1] 小于 0 的时候才会更新,且更新为 i,想明白了这一点,那就好编程了。
def maxSubArray(nums):
dp = [nums[0]] * len(nums)
max_value = nums[0]
beign = 0
end = 0
max_beign = 0
max_end = 0
for i in range(1, len(nums)):
if dp[i-1] > 0:
dp[i] = dp[i-1] + nums[i]
else:
dp[i] = nums[i]
beign = i # 这个是最关键的点,需要想清楚
end = i
if dp[i] > max_value:
max_value = dp[i]
max_beign = beign
max_end = end
return (max_value, nums[max_beign:max_end+1])
5. 最长递增子序列
给你一个整数数组 nums ,找到其中最长严格递增子序列的长度。子序列是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,[3,6,2,7] 是数组 [0,3,1,6,2,2,7] 的子序列。
示例:
输入:nums = [10,9,2,5,3,7,101,18]
输出:4 解释:最长递增子序列是 [2,3,7,101],因此长度为 4 。
该问题选用 dp 作为中间数据存储结构,以 dp[i] 表示以 s[i] 为结尾的最大递增子序列长度。接下来是递推公式,首先想到的是与 dp[i-1] 之间的关系,但是这次的子序列是允许跳跃的,所以不一定只是与 dp[i-1] 之间有关系,所以仔细想清楚后,发现 dp[i] 其实与 dp[0]...dp[i-1] 都有关系,需要找出来的是在 s[i]>s[index] 前提下找到 max(dp[index]...)。
def lengthOfLIS(nums):
dp = [1]*len(nums)
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)
如果想要同步获取这个子序列,那需要定义一个变量 result 保存所有的最长递增子序列,reslut[i] 存储的是以 s[i] 为结尾的最长递增子序列。然后在更新 dp 的时候同步更新 result。这里需要特别注意的是 [[]]*len(nums) 的方式属于浅拷贝,更新任何一个 [] 都会同步影响其它 []。在做 List 的整体赋值时一定注意好是需要赋值、浅拷贝还是深拷贝。深拷贝可以使用 copy 包或者 [:] 运算符。
import copy
def lengthOfLIS(nums):
dp = [1]*len(nums)
max_dp = 1
max_i = 0
# results = [[]]*len(nums) # 不能这样写,这是浅拷贝
results = [[] for _ in range(len(nums))] # 记录以s[i]结尾的最长递增子序列
for i in range(len(nums)):
for j in range(i,-1,-1): # 检查i之前所有序列
if nums[i] > nums[j]: # 前提是s[i]大于s[j]
if dp[i] < dp[j] + 1: # 次之是序列长度要更长,因为存在相等的情况,所以存在答案不唯一
dp[i] = dp[j] + 1
results[i] = copy.deepcopy(results[j]) # 注意这里一定要用深拷贝
# results[i] = results[j][:] # 注意这里一定要用深拷贝
results[i].append(nums[i])
if max_dp < dp[i]:
max_dp = dp[i]
max_i = i
return (dp[max_i], results[max_i])
6. 最长回文字符串
给你一个字符串 s,找到 s 中最长的回文字符串。
示例 :
输入:s = "babad"
输出:"bab" 解释:"aba" 同样是符合题意的答案。
本题采用二维 dp 作为中间存储数据结构,dp[i,j] 表示 s[i] 到 s[j] 是否为回文字符串,主对角线表示每个单独的字符均为长度 1 的回文字符,如果首尾两个元素相等,中间部分也是回文字符串,那当前字符串也为回文字符串,所以递推公式为 dp[i,j] = dp[i+1,j-1] and s[i]==s[j],从递推公式可以得到遍历输出是 i 从大到小 j 从小到大,至此可以获取任意字符串是否为回文字符串。在遍历的同时记录下最长长度和对应的起止点。
def longest_palindrome(s):
dp = [[True]*len(s) for _ in range(len(s))] # 注意深浅拷贝问题,直接相乘属于浅拷贝
max_len = 1
max_beign = 0
max_end = 0
for i in range(len(s)-2, -1, -1):
for j in range(i+1, len(s)):
dp[i][j] = dp[i+1][j-1] and s[i]==s[j]
if dp[i][j] and max_len < j-i+1: # 记录最长长度和对应起始终止位置
max_len = j-i+1
max_beign = i
max_end = j
return (max_len, s[max_beign:max_end+1])
本题还可以采用中心扩展法,即定义一个函数可以实现通过两边扩展遍历的方式寻找最长回文字符串,返回回文字符串的长度和起始点,然后遍历所有字符,检查每个字符对应的最长回文字符串,即可获取最长回文字符串。暴力遍历法的时间复杂度是O3,空间复杂度是O1,而动态规划的时间复杂度是O2,空间复杂度是O1,最后中心扩展法的时间复杂度是O2,空间复杂度是O1。
def longest_palindrome(s):
def check(s, i, j): # 检查从i和j向两边扩散的字符串是否是回文字符串
while i>=0 and j<len(s) and s[i]==s[j]: # 实际仅仅用于 i=j 或者 i=j-1 两种情况
i -= 1
j += 1
return j-i-1, i+1, j-1 # 返回回文字符串的长度和起始以及终止点
max_len = 1
max_beign = 0
max_end = 0
for i in range(len(s)):
len1, left1, right1 = check(s, i, i)
len2, left2, right2 = check(s, i, i+1)
if max_len < max(len1, len2):
max_len = max(len1, len2)
if len1 >= len2:
max_beign = left1
max_end = right1
else:
max_beign = left2
max_end = right2
return s[max_beign:max_end+1]
7. 机器人路径条数
一个机器人位于一个 mxn 网格的左上角(起始点在上图中标记为 “Start” )。机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为 “Finish”)。问总共有多少条不同的路径?
示例:
输入:m=3, n=7
输出:28
本问题需要准备一个二维的 dp 数据结构记录中间过程,dp[i,j] 表示从起点到 (i,j) 位置的路径条数,dp[-1,-1]即为最终结果。那递推公式显然就是 dp[i,j]=dp[i-1,j]+dp[i,j-1]。初始条件,第一行和第一列全部为1,递推顺序是第二行开始从左到右,从上到下。
def uniquePaths(m, n):
dp = [[1 for _ in range(n)] for _ in range(m)]
for i in range(1, m):
for j in range(1, n):
dp[i][j] = dp[i - 1][j] + dp[i][j - 1]
return dp[-1][-1]
8. 礼物的最大价值
在一个 mxn 的棋盘的每一格都放有一个礼物,每个礼物都有一定的价值(价值大于 0)。你可以从棋盘的左上角开始拿格子里的礼物,并每次向右或者向下移动一格、直到到达棋盘的右下角。给定一个棋盘及其上面的礼物的价值,请计算你最多能拿到多少价值的礼物?
示例:
输入:[ [1,3,1], [1,5,1], [4,2,1] ]
输出:12 解释: 路径 1→3→5→2→1 可以拿到最多价值的礼物
本问题需要准备一个二维的 dp 数据结构记录中间过程,dp[i,j] 表示从起点到 (i,j) 位置的最多礼物,dp[-1,-1] 即为最终结果。那递推公式显然就是 dp[i,j]=max(dp[i-1,j]+dp[i,j-1])+grid[i,j]。初始条件,第一行和第一列全部为累计求和结果,递推顺序是第二行开始从左到右,从上到下。
def maxValue(grid):
m, n = len(grid), len(grid[0])
dp = [[grid[0][0] for _ in range(n)] for _ in range(m)]
for i in range(1, m):
dp[i][0] = dp[i-1][0] + grid[i][0]
for j in range(1, n):
dp[0][j] = dp[0][j-1] + grid[0][j]
for i in range(1, m):
for j in range(1, n):
dp[i][j] = max(dp[i-1][j], dp[i][j-1]) + grid[i][j]
return dp[-1][-1]