【问题标题】:Can someone explain this "Longest Common Subsequence" algorithm?有人可以解释这个“最长公共子序列”算法吗?
【发布时间】:2021-02-14 04:37:09
【问题描述】:

Longest Common Subsequence (LCS) 问题是:给定两个序列AB,找出在AB 中都找到的最长子序列。例如,给定A = "peterparker"B = "spiderman",最长公共子序列为"pera"

有人能解释一下这个Longest Common Subsequence算法吗?

def longestCommonSubsequence(A: List, B: List) -> int:
    # n = len(A)
    # m = len(B)
    
    indeces_A = collections.defaultdict(list)
    
    # O(n)
    for i, a in enumerate(A):
        indeces_A[a].append(i)
    
    # O(n)
    for indeces_a in indeces_A.values():
        indeces_a.reverse()
    
    # O(m)
    indeces_A_filtered = []
    for b in B:
        indeces_A_filtered.extend(indeces_A[b])
    
    # The length of indeces_A_filtered is at most n*m, but in practice it's more like O(m) or O(n) as far as I can tell.
    iAs = []
    # O(m log m) in practice as far as I can tell.
    for iA in indeces_A_filtered:
        j = bisect.bisect_left(iAs, iA)
        if j == len(iAs):
            iAs.append(iA)
        else:
            iAs[j] = iA
    return len(iAs)

所写的算法会找到longest common subsequence 的长度,但可以修改为直接找到longest common subsequence

我在 leetcode link 上寻找最快的 Python 解决方案时发现了这个算法。该算法是该问题最快的 Python 解决方案(40 毫秒),而且它似乎还具有 O(m log m) 时间复杂度,这比大多数其他解决方案的 O(m*n) 时间复杂度要好得多。

我不完全理解它为什么会起作用,并尝试到处寻找已知算法到 Longest Common Subsequence 问题以找到其他提及它的内容,但找不到类似的东西。我能找到的最接近的是Hunt–Szymanski algorithmlink,据说在实践中也有O(m log m),但似乎不是相同的算法。

我的理解:

  1. indeces_a 被颠倒,以便在 iAs for 循环中保留较小的索引(这在执行下面的演练时更加明显。)
  2. 据我所知,iAs for 循环找到了 indeces_A_filteredlongest increasing subsequence

谢谢!


这是算法的演练,例如 A = "peterparker"B = "spiderman"

     01234567890
A = "peterparker"
B = "spiderman"

indeces_A = {'p':[0,5], 'e':[1,3,9], 't':[2], 'r':[4,7,10], 'a':[6], 'k':[8]}

# after reverse
indeces_A = {'p':[5,0], 'e':[9,3,1], 't':[2], 'r':[10,7,4], 'a':[6], 'k':[8]}

#                     -p-  --e--  ---r--  a
indeces_A_filtered = [5,0, 9,3,1, 10,7,4, 6]

# the `iAs` loop

iA = 5
j = 0
iAs = [5]

iA = 0
j = 0
iAs = [0]

iA = 9
j = 1
iAs = [0,9]

iA = 3
j = 1
iAs = [0,3]

iA = 1
j = 1
iAs = [0,1]

iA = 10
j = 2
iAs = [0,1,10]

iA = 7
j = 2
iAs = [0,1,7]

iA = 4
j = 2
iAs = [0,1,4]

iA = 6
j = 3
iAs = [0,1,4,6] # corresponds to indices of A that spell out "pera", the LCS

return len(iAs) # 4, the length of the LCS

【问题讨论】:

  • 对于由单个重复字母组成的两个相同字符串,这是 O(m log m) 吗?
  • @גלעדברקן 在这种情况下,A = B = ch*m 用于某些字符 chindeces_A_filtered 将是 [rev * m],其中 rev = list(reversed(range(m)))。 IE。对于m = 4indeces_A_filtered 将等于[3,2,1,0, 3,2,1,0, 3,2,1,0, 3,2,1,0]。因此,在这种情况下,算法将是O(m*2 log m)。在最后的迭代中,iAs 将等于 [0,1,2,3]return len(iAs),即 4,这是正确的。
  • 在字符串A没有重复字符的情况下,整体时间复杂度为O(l log l),其中l = max(n, m)
  • 您上面的评论中O(m*2 log m) 中的m*2 是什么?是m times 2 还是m to the power of 2
  • 对不起,应该是O(m^2 log m),所以是m to the power of 2。另外,我发现另一个线程谈到将Longest Common Subsequence 减少到Longest Increasing Subsequence,但提到A 不能有重复元素:stackoverflow.com/questions/34656050/…

标签: python algorithm diff subsequence string-algorithm


【解决方案1】:

这里缺少的部分是“耐心排序”,它与最长递增子序列 (LIS) 的联系有点微妙但众所周知。代码中的最后一个循环是使用“贪婪策略”耐心排序的基本实现。它不是,一般来说,直接计算 LIS,而是计算 LIS 的长度。

可以在早期的引理 1 中找到一个足够简单的正确性证明,其中包括可靠计算 LIS 所需内容的草图(不仅仅是它的长度)

"Longest Increasing Subsequences: From Patience Sorting to the Baik-Deift-Johansson Theorem" David Aldous 和 Persi Diaconis

【讨论】:

  • 谢谢!而且我认为相反的步骤:for indeces_a in indeces_A.values(): indeces_a.reverse() 确保当B 中的字母匹配A 中的多个位置时,最多使用其中一个位置。例如。当我们在B = "spiderman" 中有"p"A = "peterparker" 中匹配位置[0,5] 时,反转确保在indeces_A_filteredLIS 中最多使用50 之一。
  • 正是如此!如果不反转,“过滤”列表将是[0,5, 1,3,9, 4,7,10, 6],它的 LIS 长度为 6 ([0, 1,3, 4,7,10]),例如,“蜘蛛侠”中的“r”被计数了 3 次(在索引 4 处, 7 和 10)在“彼得帕克”中。反转确保 - 正如你所说 - 每组中最多有一个实例被计算在内。这很聪明。
【解决方案2】:

了解 LIS 算法

def lenLIS(self, nums):
    lis = []
    for num in nums:
        i = bisect.bisect_left(lis, num)
        if i == len(lis):
            lis.append(num) # Append
        else:
            lis[i] = num # Overwrite
    return len(lis)

上面的算法为我们提供了nums 的最长递增子序列 (LIS) 的长度,但它不一定为我们提供 numsLIS。要了解为什么上面的算法是正确的,以及如何修改它以获得numsLIS,请继续阅读。

我通过例子来解释。


示例 1

nums = [7,2,8,1,3,4,10,6,9,5]
lenLIS(nums) == 5

算法告诉我们numsLIS 的长度是5,但是我们如何得到numsLIS

我们将lis的历史表示如下(这在下面解释):

7
2, 8
1, 3, 4, 10
          6, 9
          5

我们代表lis 历史的方式是有意义的。首先,想象一个由行和列组成的空表。我们最初位于顶行。在for num in nums: 循环的每次迭代中,我们要么Append Overwrite 取决于num 的值和lis 的值:

  • Append:我们通过在下一列中写入附加值 (num) 来表示这一点,即在当前行的第 ith 列。附加值始终大于当前行中的所有值。
  • Overwrite:如果lis[i] 已经等于num,我们不会对表做任何事情。否则,我们通过向下移动到下一行并在新行的ith 列写入新值 (num) 在表中表示这一点。新值始终小于列中的所有其他值。

观察:

  1. 表格可以是稀疏的。
  2. 值按从左到右、从上到下的顺序插入。因此,每当我们在表格中向上或向左移动时,我们都会移动到 nums 的早期元素。
  3. 当我们穿过一行时,值会增加。因此,向左移动会使我们的价值降低。
  4. 假设在(r, i) 处有一个值v,但在(r, i-1) 处没有。这只能作为覆盖的结果发生。考虑覆盖之前lis 的状态。有一个值v 必须放在lis 中,我们将这个位置计算为i = bisect_left(lis, v)i 将被计算 s.t. lis[i-1] < v < lis[i]。从v 到(r, i),我们可以通过向左移动一次(到空的(r, i-1))然后向上移动一次或多次直到我们遇到一个值来到达表中的lis[i-1]。那个值将是@ 987654364@.
  5. 2.3. 放在一起,我们已经证明,在表格中,我们总是可以向左移动一次,然后向上移动零次或多次以达到较小的值。将此运动的一个应用程序表示为prev。此外,1. 告诉我们,我们通过执行prev 遇到的较小值是早先出现在nums 中的值。

我们使用4. 从表中获取LIS(nums)。从表格最右边的一个值开始,然后重复执行prev 以反向遍历LIS(nums) 的其他值。

在示例中执行此过程,我们从9 开始。应用prev 一次,我们得到6。第二次,我们得到4,然后是3,然后是1。确实[1,3,4,6,9]numsLIS 之一。


示例 2:

nums = [2,7,10,14,25,5,6,5,10,20,1,22,4,12,7,11,9,25]
lenLIS(nums)

lis的历史:

2, 7, 10, 14, 25
   5,  6  10, 20
1                22
   4          12
       7      11
           9     25

所以lenLIS(nums) == 6len(LIS(nums)。让我们找到LIS(nums)

同样,从表中最右边的值之一开始:22。应用prev 一次,我们得到20。第二次,我们得到10,然后是6,然后是5,然后是2。所以[2,5,6,10,20,22]LISnums

我们可以从其他最右边的值开始:25。应用prev 一次,我们得到11。第二次,我们得到10,然后是6,然后是5,然后是2。所以[2,5,6,10,11,25]nums 的另一个有效LIS


这个解释和https://www.stat.berkeley.edu/~aldous/Papers/me86.pdf中的解释类似,但我发现自己更容易理解,只是想分享一下,以防对其他人有用。

【讨论】:

    猜你喜欢
    • 2013-04-13
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2012-12-11
    • 1970-01-01
    • 2011-01-16
    • 2011-03-17
    相关资源
    最近更新 更多