理解最长公共子序列算法的时间复杂度

12

我不理解递归函数用于“最长公共子序列”算法的O(2^n)复杂度。

通常,我会将此符号与算法的基本操作数(在此情况下为比较次数)联系起来,但是这一次在我的脑海中并没有意义。

例如,对于两个长度都为5的字符串,最坏情况下递归函数会计算251次比较。而2^5与该值相差甚远。

有人能解释一下这个函数的算法复杂度吗?

def lcs(xstr, ystr):
    global nComp
    if not xstr or not ystr:
        return ""
    x, xs, y, ys = xstr[0], xstr[1:], ystr[0], ystr[1:]
    nComp += 1
    #print("comparing",x,"with",y)
    if x == y:
        return x + lcs(xs, ys)
    else:
        return max(lcs(xstr, ys), lcs(xs, ystr), key=len)

你可能希望包含一个你正在查看和尝试理解的特定算法描述。 - Nathaniel Ford
大O复杂度描绘了一个函数随着输入值变得非常大时增长的方式,至少到一个常数因子。一个算法可以是O(1),但仍需要执行一亿次操作。或者是“O(n)”,对于n=1可能需要50000次操作,而对于n=2则需要10000次操作。这完全取决于它随着N的无限递增而增长的速度。此外,5是一个非常小的n值。没有算法,就没什么可说的了。 - CollinD
@CollinD 但是如果有两个字符串,它们的大小甚至可能不同,那么n是什么? - Daniel Catita
我将算法添加到帖子中。 - Daniel Catita
在这种情况下,n 很可能是较长字符串的长度。 - CollinD
2个回答

12
为了正确理解它,请仔细查看图表,并在阅读图表时按照递归自上而下的方法进行。
Here, xstr = "ABCD"
      ystr = "BAEC"

                                    lcs("ABCD", "BAEC")       // Here x != y 
                                  /                     \  
                lcs("BCD", "BAEC")   <--  x==y   -->    lcs("ABCD", "AEC")  x==y
                          |                                        |
                          |                                        |
                  lcs("CD", "AEC")   <--  x!=y   -->     lcs("BCD", "EC")
                 /                \                     /                \
                /                  \                   /                  \
               /                    \                 /                    \
      lcs("D","AEC")                  lcs("CD", "EC")              lcs("BCD", "C")
    /                \              /               \              /        \       
lcs("", "AEC")        lcs("D","EC")                  lcs("CD", "C")        lcs("BCD","")
  |        \         /              \                       |             /       |
Return     lcs("", "EC")    lcs("D" ,"C")            lcs("D", "")   lcs("CD","")  Return
           /         \       /         \             /        \       /        \ 
        Return      lcs("","C")    lcs("D","") lcs("","")  Return  lcs("D","")  Return
                     /     \         /     \      /                 /      \
                  Return   lcs("","")       Return            lcs("", "")  Return
                                 |                                  |
                              Return                            Return

注意:通常使用树形结构来表示递归调用的正确方式,但在这里我使用了图形方法来压缩树形结构,以便读者可以轻松理解递归调用。当然,这也更容易让我进行表达。


  • 由于上图中有一些冗余对,例如lcs("CD", "EC")是从lcs("CD", "AEC")中删除"A""BCD"是从lcs("BCD", "EC")中删除"B"得到的结果。因此,在执行过程中会多次调用这些对,这增加了程序的时间复杂度。

  • 正如您可以轻松看出每个对都会生成两个结果,直到遇到任何空字符串x==y为止。因此,如果字符串的长度分别为n,m (考虑xstr的长度为n,ystr的长度为m,我们考虑最坏情况)。那么,最终我们将有2n+m个结果。(如何思考?)

既然n+m是一个整数,假设为N。因此,算法的时间复杂度为:O(2N),对于较大的N值来说并不高效。

因此,我们更喜欢使用动态规划方法而不是递归方法。它可以将时间复杂度降低到:O(n.m) => O(n2),当n == m时。

即使现在您还很难理解逻辑,我建议您为xstr="ABC"ystr="EF"制作一个树形结构表示(不是我展示的图形)。我希望您能理解它。

如有疑问,请随时发表评论。


哇,很棒的答案!最让我困扰的是递归算法中比较的数量与2^N相差如此之大。使用动态规划,n*m实际上也是比较次数,因此人们可能认为这应该始终适用。 - Daniel Catita
我们不能记忆递归的 LCS 吗? - Jorge Barrios

2
O(2^n) 表示运行时间对于足够大的 n(2^n) 成比例。它并不意味着这个数字是坏的、高的、低的或特定的小 n,也不能给出计算绝对运行时间的方法。
要感受其含义,您应该考虑 n=1000、2000、3000 或甚至 1 百万、2 百万等的运行时间。
在您的示例中,假设当 n=5 时算法最多需要 251 次迭代,则 O(n) 的预测是当 n=50 时,需要 2^(50)/2^(5)*251 = 2^45*251 = ~8.8E15 次迭代。

好的,我明白了。但是如果有两个字符串,它们的大小甚至可能不同,那么n是什么? - Daniel Catita
由于最长公共子序列永远不可能比较短的字符串更长,因此它可能是较短字符串的长度。但这并不太重要。 - Aganju
O(2^n)并不意味着与2^n成比例。 - Paul Hankin
@Aganju 这不是更长的字符串长度吗?考虑到当字符串a为2个字符,字符串b为100个字符的情况时,让我怀疑它是更长的一个。 - CollinD

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接