题目链接
https://leetcode.com/problems/longest-common-subsequence/
题目描述
定义字符串的子序列为原字符串在不改变字符相对位置的前提下删除某些字符(或不删除)后组成的新字符串。给定两个字符串text1和text2,返回最长公共子序列的长度。如果没有公共子序列则返回0。
LCS能够用来判断两个字符串的相似度,两个字符串的最长公共子序列的长度越长就越相似。
示例
输入:text1 = "abcde", text2 = "ace"
输出:3
最长公共子序列为“ace”,长度为3。
输入:text1 = "abc",text2 ="def"
输出:0
没有公共子序列,因此返回0。
解决思路
此题可以用动态规划来解决。由于涉及到两个字符串,因此需要用到二维数组来暂存状态。而两个字符串的动态规划问题可以总结出一定的套路。
(1)明确dp数组的含义。
对于长度为m、n的两个字符串,一般会初始化dp大小为(m+1)*(n+1),这样索引为0的行和列表示空字符串的情况,索引在1,...m和1,...n范围内都是有效的。对于这个问题,设置二维数组dp[i][j]表示text1[0:i](text[0],text[1],...text[i-1])和text[0:j](text[0],text[1],...text[j-1])的最长公共子序列长度。
(2)确定base case。
根据dp[i][j]的状态定义,dp[i][0]与dp[0][j]应该被初始化为0(因为任何一个子序列和一个空子序列的最长公共子序列长度为0)。
(3)确定状态转移方程。
通过观察可以得到,dp[i][j]有两种更新方式:
(1)如果text[i-1] == text[j-1],此时text[i-1]和text[j-1]可以更新到公共子序列中,因此有dp[i][j] = dp[i-1][j-1] + 1
(2) 如果text[i-1] != text[j-1],那么dp[i][j]只能取dp[i-1][j]和dp[i][j-1]中的最大值,即取text[0:i-1]和text[0:j]的最长公共子序列长度与text[0:i]和text[0:j-1]的最长公共子序列长度中最大的一个。
最后dp[len(text1)][len(text2)]为返回结果。
解决思路Python实现
class Solution:
def longestCommonSubsequence(self, text1: str, text2: str) -> int:
m = len(text1)
n = len(text2)
dp = [[0 for i in range(n+1)] for j in range(m+1)]#注意二维数组行列数和索引取值的区别,构建一个m+1行n+1列的二维数组,行索引范围为[0,m]闭区间,列索引范围为[0,n]闭区间
for i in range(1,m+1):
for j in range(1,n+1):
if(text1[i-1] == text2[j-1]):
dp[i][j] = dp[i-1][j-1]+1
else:
print(i,j)
dp[i][j] = max(dp[i-1][j],dp[i][j-1])
return dp[m][n]
需要注意的是,python建立m行n列的二维数组时,可选的方法有很多:
(1)同样是利用generator,形式比较容易记,推荐。
a = [[0 for i in range(n)] for j in range(m)]
(2)利用generator,创建包含m个元素的列表,其中每个元素是包含n个0的列表。
a = [[0] * n for i in range(m)]
(3)创建一个空数组,将包含长度为n的list追加m次到数组中,每次追加的list都是一个独立的对象。
a = []
for i in range(m):
a.append([0] * n)
(4) 创建包含m个元素(比如0)的数组, 然后将每个数组元素更新为指向包含n个元素数组的地址。
a = [0] * m
for i in range(m):
a[i] = [0] * n
需要注意的是,下面这种建立二维数组的方法是错误的:
a = [[0]*n]*m
通过实验会发现,如果这样创建二维数组,当改变某个元素时,和该元素同列的元素都会发生改变。如在下面的例子中,将第一行第二列的元素设置为1后,该数组第二列的所有元素都变为1:
m = 5
n = 3
a = [[0]*m]*n
a[0][1] = 1
print(a)
# 输出[[0, 1, 0, 0, 0], [0, 1, 0, 0, 0], [0, 1, 0, 0, 0]]
原因是这种方式仅创建了一个整数类型的对象,[0]*m创建了指向这个整数对象的m个引用,m个引用构成了一维数组t。a = [[0] * m] * n 进一步创建了a[0],a[1],...a[n]个指向t的引用。如下图所示:
因此当修改a[0][1]为5时,会新创建一个值为5的整数类型对象,并且a[0][1]指向这个新的整数对象。由于a[0],...a[n-1]仍然指向这个一维数组对象,所以a[1][1],...a[n-1][1]也都指向这个新的整数对象。如下图所示:
时间复杂度与空间复杂度
时间复杂度与空间复杂度都为O(m*n)。
拓展一
在上面的解决方案中,可以看出二维数组是从上到下、从前向后更新的,最后返回结果为右下角的值。如下图所示:
其实空间复杂度还可以进一步降低,从dp更新方式中可以看出,dp[i][j]的更新仅与dp[i-1][j-1],dp[i-1][j]和dp[i][j-1]有关,即dp[i][j]的上面、左边和左上角的元素。
因此可以通过状态压缩,减少对不必要状态的存储。逐行更新二维数组,仅用一维数组dp来存储当前行,并用两个变量prerow,prerow_precol存储当前元素上面、左上角的元素。对于待更新的dp[j+1],dp[j]表示其左边的元素。
比较两个字符串text1和text2的大小,取较短的字符串(如text2)的长度n,设置长度为n+1的一维数组dp。第一层for循环表示二维数组中待更新的行,也表示text1当前的比较范围。在第二层for循环中更新dp[1:n+1]。dp[j]表示text2[0:j](从第1个元素到第j个元素)和text1[0:i]的最长公共子序列的长度。
注意prerow和prerow_precol在第二层循环中的更新方式:prerow_precol为左上角元素,等于上一次循环中的prerow;prerow为上边的元素,等于此时还未更新的dp[j+1].
下面通过一个例子来说明算法流程:
Python实现
def longestCommonSubsequence(self, text1: str, text2: str) -> int:
m, n = map(len, (text1, text2))
if m < n:
return self.longestCommonSubsequence(text2, text1)
#保证行数大于列数
dp = [0] * (n + 1)
for c in text1:
prevRow, prevRowPrevCol = 0, 0 #给定某一行
for j, d in enumerate(text2):#更新这一行上的所有元素
prevRow, prevRowPrevCol = dp[j + 1], prevRow
dp[j + 1] = prevRowPrevCol + 1 if c == d else max(dp[j], prevRow)
return dp[-1]
时间复杂度与空间复杂度
时间复杂度为O(mn),空间复杂度为O(min(m,n))。
拓展二
如果需要进一步给出所有最长公共子序列,就从动态规划表右下角的元素开始回溯即可(需要维护二维数组):
(1)如果dp[i][j]对应的元素text1[i-1] == text2[j-1],那么将text1[i-1]加入LCS中,并进一步判断dp[i-1][j-1]对应的元素关系。
(2)如果dp[i][j]对应的元素text1[i-1] != text2[j-1],比较dp[i-1][j]和dp[i][j-1],进入值较大的格子中进一步进行判断。
(3)如果dp[i-1][j]==dp[i][j-1],说明最长公共子序列有多个,两个格子都要进一步回溯。
(4)到i<=0或j<=0时回溯停止,将LCS逆序输出。
Python实现
def lcsLength():#得到最长公共子序列的长度和动态规划表
dp = [[0 for _ in range(n+1)] for _ in range(m+1)]
for i in range(1,m+1):
for j in range(1,n+1):
if(text1[i-1] == text2[j-1]):
dp[i][j] = dp[i-1][j-1] + 1
else:
dp[i][j] = max(dp[i-1][j],dp[i][j-1])
return dp[m][n],dp
def recursion(i,j,lcs,length): #dp[i][j]是当前在动态规划表中的位置
while(i > 0 and j > 0):
if(text1[i-1] == text2[j-1]):
lcs+=(text1[i-1])
i -= 1 #更新在格子里的位置
j -= 1
elif(dp[i-1][j] > dp[i][j-1]):
i -= 1
elif(dp[i-1][j] < dp[i][j-1]):
j -= 1
elif (dp[i-1][j] == dp[i][j-1]):
recursion(i-1,j,lcs,length)
recursion(i,j-1,lcs,length)
return
print(lcs[::-1])
def longestCommonSubsequence():
lcs = ''
recursion(m,n,lcs,length)
text1 = "ABCBDAB"
text2 = "BDCABA"
m, n = map(len, (text1, text2))
length,dp = lcsLength()
longestCommonSubsequence()#
需要注意的是,lcs最好被初始化定义为字符串对象,如果lcs为可变数据类型如list,由于传递的是引用,无法保存递归栈每层的lcs变量,但是可以将传入递归的lcs改为深拷贝对象,这样也能输出正确结果:
def recursion(i,j,lcs,length): #dp[i][j]是当前在动态规划表中的位置
while(i > 0 and j > 0):
if(text1[i-1] == text2[j-1]):
lcs+=(text1[i-1])
i -= 1 #更新在格子里的位置
j -= 1
elif(dp[i-1][j] > dp[i][j-1]):
i -= 1
elif(dp[i-1][j] < dp[i][j-1]):
j -= 1
elif (dp[i-1][j] == dp[i][j-1]):
recursion(i-1,j,copy.deepcopy(lcs),length)
recursion(i,j-1,copy.deepcopy(lcs),length)
return
print(lcs[::-1])
def longestCommonSubsequence():
lcs = []
recursion(m,n,lcs,length)
由于每次调用都是往上或往左(或同时往上、往左)移动一步,因此最多移动m+n步能到i = 0或j = 0的情况,时间复杂度为O(m+n)。
此外还有通过将LCS问题转化为LIS问题(最长上升子序列)来将时间复杂度优化为O(nlogn)的方法,详见https://leetcode.com/problems/longest-common-subsequence/discuss/349508/O(nlogn)-with-4ms-C%2B%2B
相似问题
最长公共子串
参考
https://www.geeksforgeeks.org/python-using-2d-arrays-lists-the-right-way/
https://snakify.org/en/lessons/two_dimensional_lists_arrays/