问题
编辑距离(leetcode 题号72)和最长公共子序列(leetcode 1143)是两道经典的动态规划问题,题目如下图所示。
解法对比
跟着labuladong刷题时,这两道里dp函数的传参一样,都是:
dp(word1: str, word2: str, i: int, j: int)
但是在对函数的定义上却不同:一个是返回将word1[:i]转化成word2[:j]所使用的最少操作数,另一个是返回text1[i:]和text2[j:]的最长公共子序列,注意i和j,一个是作为左侧边界,一个是作为右侧边界定义的。
# 编辑距离
class Solution:
def __init__(self):
self.memo = dict()
def dp(self, word1: str, word2: str, i: int, j: int):
""""返回将word1[:i+1]转化成word2[:j+1]所使用的最少操作数"""
# badcase,某一个指针提前走完了
if i == -1:
return j + 1
if j == -1:
return i + 1
if (i, j) in self.memo:
return self.memo[(i, j)]
if word1[i] == word2[j]:
self.memo[(i, j)] = self.dp(word1, word2, i - 1, j - 1)
else:
# 这里隐藏的是:两个指针是从右往左的
self.memo[(i, j)] = min(self.dp(word1, word2, i, j - 1) + 1,
self.dp(word1, word2, i - 1, j) + 1,
self.dp(word1, word2, i - 1, j - 1) + 1
)
return self.memo[(i, j)]
def minDistance(self, word1: str, word2: str) -> int:
"""自顶向下:递归解法,DP函数"""
m, n = len(word1), len(word2)
# 为什么不是m, n而是m-1, n-1
return self.dp(word1, word2, m - 1, n - 1)
# 最长公共子序列
class Solution_m():
def __init__(self):
self.memo = dict()
def dp(self, text1: str, text2: str, i: int, j: int):
""""返回text1[i:]和text2[j:]的最长公共子序列"""
m, n = len(text1), len(text2)
# badcase: 越界
if i > m-1 or j > n-1:
return 0
if (i, j) in self.memo:
return self.memo[(i, j)]
if text1[i] == text2[j]:
# 这里隐藏的是,两个指针是从左往右的
self.memo[(i, j)] = 1 + self.dp(text1, text2, i+1, j+1)
else:
self.memo[(i, j)] = max(self.dp(text1, text2, i+1, j),
self.dp(text1, text2, i, j+1)
)
return self.memo[(i, j)]
def longestCommonSubsequence(self, text1: str, text2: str) -> int:
return self.dp2(text1, text2, 0, 0)
跟着东哥的思路写确实可以提交通过,但是我没有想明白,为啥要这么定义啊?这两个dp函数定义成一样的意思能不能行啊?
(啰嗦几句,要是按照我以前应付面试的刷题心态,这种问题就略过了,管他呢,没那个脑子想了,能通过就行了!但是今年听了武志红老师《把事情做好的心理学课》后,不想把刷leetcode仍然当作是一个痛苦、应付差事的事情,所以这种问题我感觉挺有意思的,就停下来,不赶进度了,好好的琢磨琢磨~让刷题过程变得有趣起来。)
思考
先画两张图,理解下东哥给的思路:
如果将编辑距离的DP函数的定义改一下能不能行?
现在是:返回将word1[:i+1]转化成word2[:j+1]所使用的最少操作数;
状态转移函数是:
if word1[i] == word2[j]:
self.memo[(i, j)] = self.dp(word1, word2, i - 1, j - 1)
else:
self.memo[(i, j)] = min(self.dp(word1, word2, i, j - 1) + 1,
self.dp(word1, word2, i - 1, j) + 1,
self.dp(word1, word2, i - 1, j - 1) + 1
)
我们改成:返回将word1[i:]转化成word2[j:]所使用的最少操作数,也就是和最长公共子序列一样的定义。 试试看!
如果DP函数定义变了,那么状态转移函数也要变:
if word1[i] == word2[j]:
self.memo[(i, j)] = self.dp(word1, word2, i + 1, j + 1)
else:
self.memo[(i, j)] = min(self.dp(word1, word2, i, j + 1) + 1,
self.dp(word1, word2, i + 1, j) + 1,
self.dp(word1, word2, i + 1, j + 1) + 1
)
那么相应的bad case也要从:
# badcase,某一个指针提前走完了
if i == -1:
return j + 1
if j == -1:
return i + 1
更新为:
# badcase,某一个指针提前走完了
if i == len(word1):
return len(word2) - j
if j == len(word2):
return len(word1) - i
完整代码为:
class Solution_o:
def __init__(self):
self.memo = dict()
def dp(self, word1: str, word2: str, i: int, j: int):
""""返回将word1[:i]转化成word2[:j]所使用的最少操作数"""
# badcase,某一个指针提前走完了
if i == -1:
return j + 1
if j == -1:
return i + 1
if (i, j) in self.memo:
return self.memo[(i, j)]
if word1[i] == word2[j]:
self.memo[(i, j)] = self.dp(word1, word2, i - 1, j - 1)
else:
self.memo[(i, j)] = min(self.dp(word1, word2, i, j - 1) + 1,
self.dp(word1, word2, i - 1, j) + 1,
self.dp(word1, word2, i - 1, j - 1) + 1
)
return self.memo[(i, j)]
def dp(self, word1: str, word2: str, i: int, j: int):
""""返回将word1[i:]转化成word2[j:]所使用的最少操作数"""
# badcase,某一个指针提前走完了
if i == len(word1):
return len(word2) - j
if j == len(word2):
return len(word1) - i
if (i, j) in self.memo:
return self.memo[(i, j)]
if word1[i] == word2[j]:
self.memo[(i, j)] = self.dp(word1, word2, i + 1, j + 1)
else:
self.memo[(i, j)] = min(self.dp(word1, word2, i, j + 1) + 1,
self.dp(word1, word2, i + 1, j) + 1,
self.dp(word1, word2, i + 1, j + 1) + 1
)
return self.memo[(i, j)]
def minDistance(self, word1: str, word2: str) -> int:
"""自顶向下:递归解法,DP函数"""
m, n = len(word1), len(word2)
# 这里也有不同
#return self.dp(word1, word2, m-1, n-1)
return self.dp2(word1, word2, 0, 0)
结果也是对的!说明这两种定义是可以互通的!
总结
以下两种定义方式都行,看个人习惯。
我个人更喜欢右边的方式!因为python中数组的切片word[i:j]是左闭右开区间,用dp(i, j)表示从word1[i:]转化为word2[j:]的定义更简洁。
另一个延伸思考,DP的题目什么时候用DP数组,什么时候用DP函数?
DP数组是自底向上的写法,因为这种写法要求你算当前的dp[i][j]的时候,它所依赖的状态函数已经被求出来了。而DP函数的写法是一种自顶向下的写法,一般配合备忘录来写,就是算当前dp(i, j)时,它所依赖的状态不一定已经算出来的,但是通过递归最终能求出,又因为有备忘录,所以可以避免重复计算。
参考资料
- 《labuladong的算法小抄》