【问题标题】:Time complexity analysis of recursive algorithm递归算法的时间复杂度分析
【发布时间】:2015-03-14 10:14:58
【问题描述】:

我想知道以下算法的复杂性是多少,最重要的是,我想知道导致推导它的逐步过程。

我怀疑它是 O(length(text)^2*length(pattern)) 但我无法求解递归方程。

在对递归调用进行记忆化(即动态编程)时,复杂性会如何提高?

另外,我希望能提供一些技术/书籍,它们可以帮助我学习如何分析这种算法。

在 Python 中:

def count_matches(text, pattern):
  if len(pattern) == 0: return 1

  result = 0
  for i in xrange(len(text)):
    if (text[i] == pattern[0]):
      # repeat the operation with the remaining string a pattern
      result += count_matches(text[i+1:], pattern[1:])

  return result

在 C 中:

int count_matches(const char text[],    int text_size, 
                  const char pattern[], int pattern_size) {

  if (pattern_size == 0) return 1;

  int result = 0;

  for (int i = 0; i < text_size; i++) {
    if (text[i] == pattern[0])
      /* repeat the operation with the remaining string a pattern */
      result += count_matches(text+i, text_size-(i+1), 
                              pattern+i, pattern_size-(i+1));
  }

  return result;  
}

注意:算法有意对每个子字符串重复匹配。请不要关注算法正在执行什么样的匹配,只关注它的复杂性。

对算法中的(现已修复的)拼写错误表示歉意

【问题讨论】:

  • 我认为这两个例子一定是,而且它们并不完全相同
  • python 版本有一些拼写错误:第 2 行有一个 tex 变量,第 8 行有一个count() 调用。此外,如果pattern 比@987654326 短,python 版本会失败@。如果你只是在寻找一个字符串比较算法,prolly 你可以在没有递归的情况下实现。
  • @user2464424 谢谢,我更正了(我从C算法开始并添加了python以扩大受众范围)
  • @AnttiHaapala 他们怎么错了和不相同?
  • 在递归调用中,C版text+i, text_size-(i+1), pattern+i, pattern_size-(i+1)的参数应该是text+i+1, text_size-(i+1), pattern+1, pattern_size-1,根据Python版本?第一个第三个参数好像不对O_O...

标签: algorithm time-complexity


【解决方案1】:

我认为复杂度为 O(length(text)^3) 的直觉是不正确的。它实际上是 O(n!) 纯粹是因为实现是形式

def do_something(relevant_length):
    # base case

    for i in range(relevant_length):
        # some constant time work

        do_something(relevant_length - 1)

Example of O(n!)?中所述

如果使用记忆化,递归树会生成一次,然后每次都查找。

描绘递归树的形状。

我们每层进步一个角色。有2个基本情况。当我们到达模式的末尾或者如果文本中不再有任何字符可以迭代时,递归就会触底。第一个基本情况是明确的,但第二个基本情况只是在实现的情况下发生。

所以递归树的深度(高度)为 min[length(text), length(pattern)]。

有多少子问题?我们还每层进步一个字符。如果比较文本中的所有字符,使用高斯技巧求和 S = [n(n+1)] / 2,将在所有递归层中评估的子问题总数为 {length(text) * [长度(文本)+ 1]} / 2.

取长度(文本)= 6 和长度(模式)= 10,其中长度(文本)

PTTTTT
PTTTT
PTTT
PTT
PT
P

如果长度(文本)= 10 和长度(模式)= 6,其中长度(文本)> 长度(模式)会怎样。深度为 min[length(text), length(pattern)] = 6。

PTTTTTTTTT
PTTTTTTTT
PTTTTTTT
PTTTTTT
PTTTTT
PTTTT

我们看到的是长度(模式)并没有真正有助于复杂性分析。在长度(模式)

但是,由于文本和模式是一一对应的,我们最终做的工作要少得多。递归树看起来像方阵的对角线。

对于 length(text) = 6 和 length(pattern) = 10 以及对于 length(text) = 10 和 length(pattern) = 6,树是

P
 P
  P
   P
    P
     P

因此,memoized 方法的复杂度为

O( min( 长度(文本), 长度(图案) ) )

编辑:给定@fons 注释,如果从不触发递归怎么办?特别是在所有 i 的 text[i] == pattern[0] 永远不会为真的情况下。然后遍历所有文本是主导因素,即使长度(文本)>长度(模式)。

这意味着记忆方法的实际上限是

O( max( 长度(文本), 长度(图案) ) )

再想一想,在 length(text) > length(pattern) 和递归被触发的情况下,即使模式用尽了,递归和检查模式现在是否为空也需要恒定的时间,所以长度(文本)仍然占主导地位。

这使得记忆版本的上限为 O(length(text))。

【讨论】:

  • 我理解原始算法复杂性的推理,我认为我同意。但我不认为我同意记忆版本。例如,对于length(text) = t,length(pattern) = p,t > p,并且t中没有p的匹配,复杂度是O(length(text))
  • @fons,看起来你是对的。假设您的意思是“p 中不匹配 t”,因为所有 i 的 text[i] == pattern[0] 都不是真的,因此实际上总是跳过递归。所以看起来 max 而不是 min 是所有情况的上限。
  • 在考虑更多之后对原始答案添加了一个编辑,以消除两个长度的最大值。
【解决方案2】:

嗯...我可能是错的,但据我所知,您的运行时应该专注于这个循环:

for c in text:
    if (c == pattern[0]):
      # repeat the operation with the remaining string a pattern
      result += count_matches(text[1:], pattern[1:])

基本上让你的文本长度为n,我们不需要模式的长度。

第一次运行这个循环时(在父函数中),我们将对其进行 n 次调用。在最坏的情况下,每个 n 调用都会调用您程序的 n-1 个实例。然后那些 n-1 个实例将在最坏的情况下调用 n-2 个实例,依此类推。

这将导致一个方程将是 n*(n-1)(n-2)...*1 这是 n !。所以你最坏的情况下运行时间是 O(n!)。很糟糕(:

我多次运行你的 python 程序,输入会导致最坏情况的运行时:

在 [21] 中:count_matches("aaaaaaa", "aaaaaaa")

输出[21]:5040

在 [22] 中:count_matches("aaaaaaaa", "aaaaaaaa")

输出[22]:40320

在 [23] 中:count_matches("aaaaaaaaa", "aaaaaaaaa")

输出[23]:362880

最后输入的是9个符号和9! = 362880。

要分析算法的运行时间,您首先需要考虑导致最差可能的运行时间的输入。在您的算法中,最佳和最差变化很大,因此您可能需要平均案例分析,但这非常复杂。 (您需要定义什么输入是平均的,以及出现最坏情况的频率。)

动态编程可以大大减轻您的运行时间,但分析更难。让我们先编写一个简单的未优化动态编程版本:

cache = {}
def count_matches_dyn(text, pattern):
  if len(pattern) == 0: return 1

  result = 0
  for c in text:
    if (c == pattern[0]):
      # repeat the operation with the remaining string a pattern
      if ((text[1:], pattern[1:]) not in cache.keys()):
        cache[(text[1:], pattern[1:])] = count_matches_dyn(text[1:], pattern[1:])
        result += cache[(text[1:], pattern[1:])]
      else:
        result += cache[(text[1:], pattern[1:])]

  return result

在这里,我们将所有对 count_matches 的调用缓存在字典中,因此当我们使用相同的输入调用 count 匹配项时,我们将获得结果,而不是再次调用该函数。 (这被称为memoization)。

现在我们来分析一下。主循环

  for c in text:
    if (c == pattern[0]):
      # repeat the operation with the remaining string a pattern
      if ((text[1:], pattern[1:]) not in cache.keys()):
        cache[(text[1:], pattern[1:])] = count_matches_dyn(text[1:], pattern[1:])
        result += cache[(text[1:], pattern[1:])]
      else:
        result += cache[(text[1:], pattern[1:])]

将在第一次调用时运行 n 次(我们的缓存为空)。然而,第一次递归调用将填充缓存:

cache[(text[1:], pattern[1:])] = count_matches_dyn(text[1:], pattern[1:])

并且同一循环中的所有其他调用都将花费 (O(1)。所以基本上顶级递归将花费 O(n-1) + (n-1)* O(1) = O(n-1) + O(n-1) = 2*O(n-1)。你可以看到,从调用进一步向下递归只有第一个会下降许多递归调用(O(n-1) 调用),其余的将花费 O(1),因为它们只是字典查找。鉴于所有这些,运行时是 (2*O(n-1) 摊销到 O(n)

免责声明。动态规划版的分析我不是很确定,欢迎指正(:

免责声明 2. 动态编程代码包含分析中未考虑的昂贵操作(text[1:]、pattern[1:])。这是故意这样做的,因为在任何合理的实现中,您都可以大大降低这些调用的成本。重点是展示简单的缓存如何大大减少运行时间。

【讨论】:

  • Big-O 表示法并不意味着最坏的情况——它只是描述函数渐近行为的一种方式。只是,在分析算法复杂度时,最坏情况的运行时往往是我们最感兴趣的。
  • @psmears 真的,我的错。我会解决的。
  • 不太正确。 text[1:], pattern[1:] 这两个操作导致了O(n^2) 的实际复杂度。然后你继续创建它们,那确实很慢。
  • @HuStmpHrrr 这是真的,但我只做了一个非常简化的缓存。在任何合理的实现中,这些字符串操作都会得到更好的优化(在 c 中,您可以事先知道确切的大小并使用数组副本)。我相信动态编程提供加速的要点仍然存在。
  • 同意。我只想强调,在 python 中讨论算法的复杂性和实际实现是不同的。 python中的一些小操作可能会带来巨大的开销,很难引起注意。
【解决方案3】:
  • 首先,让我们超越代码,阐述这段代码试图解决的问题。

Python 版本似乎将pattern 的出现次数计算为subsequencetext。 C 版本目前看起来很糟糕,所以我将在下面假设 Python 版本是正确的。

  • 然后,回顾代码并注意有关如何执行解决方案的一些一般事项。

该函数通过将 0 和 1 相加来计算答案。因此,运算的次数至少是得到答案需要加起来的 1 的数量,也就是答案本身。

  • 现在,让我们设计一个输入(text, pattern),它会在textpattern 的给定长度下给出最差的运行时间。

最大的答案显然是所有字母都相等的情况。

  • 之后,我们使用上面的输入简化和一些数学知识直接计算答案。

当所有字母都相等时,答案本质上是从n = len (text) 中选择k = len (pattern) 项目(字母)的方式数,即choose (n, k)

  • 接下来,我们选择 textpattern 的长度,这给了我们最差的复杂性。

例如:对于text = 'a' * 100pattern = 'a' * 50,我们有答案choose (100, 50) = 100! / 50! / 50!。 一般来说,对于text 的固定长度,pattern 的长度必须是该长度的一半,必要时将任一侧舍入。 在查看Pascal's triangle 时,这是一个直观的概念。 正式地,通过手动比较 choose (n, k)choose (n, k+-1) 来证明这一点很简单。

  • 估计我们得到的答案。

choose (n, 0) + choose (n, 1) + ... + choose (n, n) 的总和是 2n,同样直观地,choose (n, n/2) 是其中相当大的一部分。 更正式地说,通过Stirling's formula,它是turns out choose (n, n/2) 大约是2n 除以sqrt(n)

  • 最后,请注意,可能不需要更详细的分析。

当复杂度是指数级时,我们通常对精确的多项式因子不太感兴趣。 比如说,2100 (O (2^n)) 和 100 次 2100 (O (n * 2^n)) 操作同样不可能在合理的时间内完成。 重要的是将O (2^n) 减少到O (2^(n/2)),或者更好地找到多项式解决方案。

  • 回想一下,我们发现的是一个下限。

实际上,如果我们在顶部添加以下行,复杂度确实是choose (len (text), len (pattern) 乘以某个多项式:

if len(pattern) < len(text): return 0

确实,如果文本中剩余的字母数量小于模式的长度,则无法匹配。

  • 这是另一个角度的视图。

否则,我们可以有更多的递归分支,最终导致答案加 0。

从另一个角度来看,我们可以证明,在未修改的代码中,操作数可以高达len(text)的2次方。

确实,当text = 'a' * npattern = 'a' * n 时,假设我们已经处理了ktext 的字母。 这些字母中的每一个,独立于其他字母,都可以与pattern 的某些字母匹配,或者被排除在循环之外。 因此,对于text 的每个字母,我们有两种方法可以走,所以当我们处理ntext 的字母时,2^n 有两种方法,即到达我们递归函数的终止调用。

【讨论】:

    【解决方案4】:

    时间复杂度应该提高到大约 O(length(text) * length(pattern)) 来自递归( O(n!) )。

    记忆解决方案 (DP) 将涉及构建 text-vs-pattern 查找表,该查找表可以从文本和模式的末尾开始逐步建立。

    【讨论】:

      【解决方案5】:

      恐怕您的算法对于模式匹配不正确。 主要是因为一旦发现第一个字符匹配,它将在文本的其余部分中搜索子子字符串。 例如对于文本“abbccc”和模式“accc”,您的算法将返回等于 1 的结果。

      您应该考虑实现模式匹配的“朴素”算法,这与您尝试做的非常相似,但没有递归。 它的复杂度是 O(n*m),其中“n”是文本长度,“m”是模式长度。 在 Python 中,您可以使用以下实现:

      text = "aaaaabbbbcccccaaabbbcccc"
      pattern = "aabb"
      result = 0
      
          index = text.find(pattern)
          while index > -1:
              result += 1
              print index
              index = text.find(pattern, index+1)
      
      return result
      

      关于该主题的书籍,我最好的推荐是 Cormen 的 "Introduction to Algorithms",它涵盖了所有关于算法和复杂性的材料。

      【讨论】:

      • 算法是故意这样的,没有错。对于文本“abbccc”和模式“accc”,算法预计返回等于 1 的结果。我将从问题标题中删除“模式匹配”部分以减少混淆.
      猜你喜欢
      • 1970-01-01
      • 2020-09-19
      • 1970-01-01
      • 2011-02-12
      • 1970-01-01
      • 1970-01-01
      相关资源
      最近更新 更多