详解最长公共子序列问题
详解最长公共子序列问题
- 前言
- 一、最长公共子序列问题概述
- 1.1 问题定义
- 1.2 应用场景
- 二、动态规划求解最长公共子序列
- 2.1 动态规划原理
- 2.2 状态定义与转移方程
- 2.3 代码实现(Python)
- 三、输出最长公共子序列具体内容
- 3.1 回溯法原理
- 3.2 代码实现(Python)
- 四、复杂度分析
- 4.1 时间复杂度
- 4.2 空间复杂度
- 五、最长公共子序列问题的优化策略
- 5.1 滚动数组优化空间
- 5.2 其他优化思路
- 六、最长公共子序列问题的扩展与变体
- 6.1 最长公共子串(Longest Common Substring)
- 6.2 编辑距离(Edit Distance)
- 总结
前言
最长公共子序列(Longest Common Subsequence,LCS)问题作为动态规划的经典应用,一直备受关注。它不仅在文本相似度分析、版本控制、生物信息学等实际场景中有着广泛应用,还能帮助开发者深入理解动态规划的核心思想。本文我将全面且深入地讲解最长公共子序列问题,从问题描述、求解思路、算法实现,到优化策略、应用场景,结合代码示例和推导过程,带你彻底掌握这一经典算法问题。
一、最长公共子序列问题概述
1.1 问题定义
给定两个序列 X = [ x 1 , x 2 , ⋯ , x m ] X = [x_1, x_2, \cdots, x_m] X=[x1,x2,⋯,xm] 和 Y = [ y 1 , y 2 , ⋯ , y n ] Y = [y_1, y_2, \cdots, y_n] Y=[y1,y2,⋯,yn],最长公共子序列问题要求找出这两个序列中最长的公共子序列 Z Z Z。子序列是指从原序列中通过删除一些元素(也可以不删除任何元素)而得到的新序列,且不改变剩余元素的相对顺序。例如,对于序列 X = [ 1 , 3 , 4 , 5 , 6 , 7 , 7 , 8 ] X = [1, 3, 4, 5, 6, 7, 7, 8] X=[1,3,4,5,6,7,7,8] 和 Y = [ 3 , 5 , 7 , 4 , 8 , 6 , 7 , 8 , 2 ] Y = [3, 5, 7, 4, 8, 6, 7, 8, 2] Y=[3,5,7,4,8,6,7,8,2],它们的一个最长公共子序列为 Z = [ 3 , 5 , 7 , 8 ] Z = [3, 5, 7, 8] Z=[3,5,7,8],长度为 4 4 4。需要注意的是,最长公共子序列可能不唯一,但长度是确定的。
1.2 应用场景
-
文本处理:在文本编辑器的版本比较功能中,通过计算两个版本文本的最长公共子序列,可以确定新增、删除和修改的内容,从而实现高效的版本差异展示和合并 。
-
生物信息学:用于比较 DNA、RNA 或蛋白质序列的相似性,帮助研究生物进化关系、基因功能预测等。例如,通过分析不同物种的基因序列的 LCS,可以推断它们之间的亲缘关系 。
-
数据比对:在数据同步、数据版本控制等场景中,计算数据序列的最长公共子序列,能够快速定位数据的变化,提高数据处理效率。
二、动态规划求解最长公共子序列
2.1 动态规划原理
动态规划(Dynamic Programming,DP)是一种通过把原问题分解为相对简单的子问题,并保存子问题的解来避免重复计算,从而解决复杂问题的方法。它适用于具有最优子结构和重叠子问题性质的问题。
对于最长公共子序列问题,其最优子结构体现在:两个序列的最长公共子序列包含了它们前缀子序列的最长公共子序列。例如,若 Z = [ z 1 , z 2 , ⋯ , z k ] Z = [z_1, z_2, \cdots, z_k] Z=[z1,z2,⋯,zk] 是 X = [ x 1 , x 2 , ⋯ , x m ] X = [x_1, x_2, \cdots, x_m] X=[x1,x2,⋯,xm] 和 Y = [ y 1 , y 2 , ⋯ , y n ] Y = [y_1, y_2, \cdots, y_n] Y=[y1,y2,⋯,yn] 的最长公共子序列,那么 Z Z Z 的前缀 Z ′ = [ z 1 , z 2 , ⋯ , z k − 1 ] Z' = [z_1, z_2, \cdots, z_{k - 1}] Z′=[z1,z2,⋯,zk−1] 一定是 X ′ = [ x 1 , x 2 , ⋯ , x m − 1 ] X'\ = [x_1, x_2, \cdots, x_{m - 1}] X′ =[x1,x2,⋯,xm−1] 和 Y ′ = [ y 1 , y 2 , ⋯ , y n − 1 ] Y'\ = [y_1, y_2, \cdots, y_{n - 1}] Y′ =[y1,y2,⋯,yn−1] 的最长公共子序列(当 x m = y n = z k x_m = y_n = z_k xm=yn=zk 时)。同时,在求解过程中,会多次遇到相同的子问题,满足重叠子问题的特性,因此适合用动态规划求解。
2.2 状态定义与转移方程
-
状态定义:定义二维数组 d p [ i ] [ j ] dp[i][j] dp[i][j] 表示序列 X X X 的前 i i i 个元素和序列 Y Y Y 的前 j j j 个元素的最长公共子序列的长度,其中 0 ≤ i ≤ m 0 \leq i \leq m 0≤i≤m, 0 ≤ j ≤ n 0 \leq j \leq n 0≤j≤n。特别地, d p [ 0 ] [ j ] = 0 dp[0][j] = 0 dp[0][j]=0(当 X X X 为空序列时,LCS 长度为 0 0 0), d p [ i ] [ 0 ] = 0 dp[i][0] = 0 dp[i][0]=0(当 Y Y Y 为空序列时,LCS 长度为 0 0 0)。
-
转移方程:
-
当 x i = y j x_i = y_j xi=yj 时, d p [ i ] [ j ] = d p [ i − 1 ] [ j − 1 ] + 1 dp[i][j] = dp[i - 1][j - 1] + 1 dp[i][j]=dp[i−1][j−1]+1。这是因为找到了一个新的公共元素,当前位置的 LCS 长度等于去掉该公共元素后前缀子序列的 LCS 长度加 1 1 1。
-
当 x i ≠ y j x_i \neq y_j xi=yj 时, d p [ i ] [ j ] = max ( d p [ i − 1 ] [ j ] , d p [ i ] [ j − 1 ] ) dp[i][j] = \max(dp[i - 1][j], dp[i][j - 1]) dp[i][j]=max(dp[i−1][j],dp[i][j−1])。此时需要取不包含 x i x_i xi 的前缀子序列的 LCS 长度和不包含 y j y_j yj 的前缀子序列的 LCS 长度中的较大值,因为当前元素不匹配,LCS 长度不会因为它们而增加。
-
2.3 代码实现(Python)
def longest_common_subsequence(X, Y):m, n = len(X), len(Y)dp = [[0] * (n + 1) for _ in range(m + 1)]for i in range(1, m + 1):for j in range(1, n + 1):if X[i - 1] == Y[j - 1]:dp[i][j] = dp[i - 1][j - 1] + 1else:dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])return dp[m][n]X = [1, 3, 4, 5, 6, 7, 7, 8]
Y = [3, 5, 7, 4, 8, 6, 7, 8, 2]
print(longest_common_subsequence(X, Y)) # 输出 4
上述代码中,首先初始化二维数组 dp
,然后通过两层循环根据转移方程填充数组,最后返回 dp[m][n]
,即两个序列完整的最长公共子序列的长度。
三、输出最长公共子序列具体内容
3.1 回溯法原理
为了获取最长公共子序列的具体内容,需要在填充 dp
数组的过程中记录决策信息,然后通过回溯法从 dp[m][n]
开始,根据记录的决策逐步还原 LCS。在填充 dp
数组时,对于每个位置 ( i , j ) (i, j) (i,j),记录该位置的 LCS 长度是由哪个方向转移而来(是因为 x i = y j x_i = y_j xi=yj 从左上角转移,还是取上方或左方的较大值转移)。
3.2 代码实现(Python)
def longest_common_subsequence(X, Y):m, n = len(X), len(Y)dp = [[0] * (n + 1) for _ in range(m + 1)]# 记录决策信息,0表示从左上角转移,1表示从上方转移,2表示从左方转移direction = [[0] * (n + 1) for _ in range(m + 1)]for i in range(1, m + 1):for j in range(1, n + 1):if X[i - 1] == Y[j - 1]:dp[i][j] = dp[i - 1][j - 1] + 1direction[i][j] = 0else:if dp[i - 1][j] > dp[i][j - 1]:dp[i][j] = dp[i - 1][j]direction[i][j] = 1else:dp[i][j] = dp[i][j - 1]direction[i][j] = 2# 回溯获取LCSlcs = []i, j = m, nwhile i > 0 and j > 0:if direction[i][j] == 0:lcs.append(X[i - 1])i -= 1j -= 1elif direction[i][j] == 1:i -= 1else:j -= 1return "".join(lcs[::-1]) # 反转列表得到正确顺序X = "ABCBDAB"
Y = "BDCABA"
print(longest_common_subsequence(X, Y)) # 输出 "BCBA"
在上述代码中,新增了 direction
数组用于记录决策信息,在填充 dp
数组时同时更新 direction
数组。回溯过程中,根据 direction
数组的值决定回溯方向,最终得到最长公共子序列,并将其反转输出正确顺序。
四、复杂度分析
4.1 时间复杂度
动态规划求解最长公共子序列问题的时间复杂度主要取决于填充 dp
数组的过程。由于需要遍历两个序列的所有元素组合,即进行 m × n m \times n m×n 次操作(其中 m m m 和 n n n 分别是两个序列的长度),所以时间复杂度为 O ( m × n ) O(m \times n) O(m×n)。
4.2 空间复杂度
-
常规二维数组实现:使用二维数组
dp[m + 1][n + 1]
存储中间结果,空间复杂度为 O ( m × n ) O(m \times n) O(m×n)。 -
优化后的空间复杂度:观察转移方程可知,计算
dp[i][j]
时,只依赖于dp[i - 1][j]
、dp[i][j - 1]
和dp[i - 1][j - 1]
,因此可以使用滚动数组将空间复杂度优化到 O ( min ( m , n ) ) O(\min(m, n)) O(min(m,n))。具体实现是将二维数组降为一维数组,通过滚动更新来模拟二维数组的计算过程。
五、最长公共子序列问题的优化策略
5.1 滚动数组优化空间
def longest_common_subsequence(X, Y):m, n = len(X), len(Y)if m < n:X, Y = Y, Xm, n = n, mdp = [0] * (n + 1)for i in range(1, m + 1):prev = 0for j in range(1, n + 1):temp = dp[j]if X[i - 1] == Y[j - 1]:dp[j] = prev + 1else:dp[j] = max(dp[j], dp[j - 1])prev = tempreturn dp[n]X = [1, 3, 4, 5, 6, 7, 7, 8]
Y = [3, 5, 7, 4, 8, 6, 7, 8, 2]
print(longest_common_subsequence(X, Y)) # 输出 4
在上述代码中,通过将二维 dp
数组优化为一维数组,并使用变量 prev
记录上一行的 dp[i - 1][j - 1]
值,在遍历过程中滚动更新 dp
数组,将空间复杂度从 O ( m × n ) O(m \times n) O(m×n) 降低到 O ( n ) O(n) O(n)(当 m ≥ n m \geq n m≥n 时,若 n ≥ m n \geq m n≥m,则空间复杂度为 O ( m ) O(m) O(m))。
5.2 其他优化思路
在一些特殊场景下,如果序列具有某些性质(如序列元素的取值范围较小、序列存在一定的有序性等),可以结合其他数据结构或算法进一步优化。例如,当序列元素取值范围较小时,可以使用哈希表记录元素出现的位置,减少不必要的比较操作,提高算法效率。
六、最长公共子序列问题的扩展与变体
6.1 最长公共子串(Longest Common Substring)
最长公共子串要求公共子序列在原序列中是连续的,与最长公共子序列的区别在于子串的元素必须连续。其状态定义和转移方程有所不同:定义 dp[i][j]
表示以 X [ i − 1 ] X[i - 1] X[i−1] 和 Y [ j − 1 ] Y[j - 1] Y[j−1] 结尾的最长公共子串的长度,当 x i = y j x_i = y_j xi=yj 时,dp[i][j] = dp[i - 1][j - 1] + 1
;当 x i ≠ y j x_i \neq y_j xi=yj 时,dp[i][j] = 0
。最终结果是 dp
数组中的最大值。
6.2 编辑距离(Edit Distance)
编辑距离问题是指将一个字符串转换为另一个字符串所需的最少操作次数(插入、删除、替换)。可以通过动态规划求解,其状态定义和转移方程与最长公共子序列类似,但考虑了更多的操作情况。例如,定义 dp[i][j]
表示将字符串 X X X 的前 i i i 个字符转换为字符串 Y Y Y 的前 j j j 个字符所需的最少操作次数,根据字符是否相等以及不同的操作类型更新 dp
数组。
总结
最长公共子序列问题作为动态规划的经典案例,不仅具有重要的理论价值,还在众多实际场景中发挥着关键作用。通过深入理解动态规划的思想,掌握状态定义、转移方程的推导以及算法实现和优化方法,我们能够有效解决这一问题,并将其思想应用到更多类似的问题中。
That’s all, thanks for reading!
觉得有用就点个赞
、收进收藏
夹吧!关注
我,获取更多干货~