使用后缀树进行近似子串匹配

Posted

技术标签:

【中文标题】使用后缀树进行近似子串匹配【英文标题】:Approximate substring matching using a Suffix Tree 【发布时间】:2013-10-22 12:48:34 【问题描述】:

本文讨论了利用suffix tree 来缩短匹配时间的近似子字符串匹配技术。每个答案都针对不同的算法。

    近似子字符串匹配尝试在字符串 T 中查找子字符串(模式)P,最多允许 k不匹配。 要了解如何创建后缀树,请单击here。但是,某些算法需要额外的预处理。

我邀请人们添加新算法(即使它不完整)并改进答案。

【问题讨论】:

我添加了我能想到的。我没时间了。祝你好运! 【参考方案1】:

这是启动此线程的原始问题。

Esko Ukkonen 教授发表了paper:后缀树上的近似字符串匹配。他讨论了 3 种具有不同匹配时间的不同算法:

    算法A:O(mq + n) 算法B:O(mq log(q) + size of the output) 算法C:O(m^2q + size of the output)

其中m是子字符串的长度,n是搜索字符串的长度,q是编辑距离。

我一直在尝试理解算法 B,但在执行这些步骤时遇到了问题。有没有人有这个算法的经验?一个示例或伪算法将不胜感激。

特别是:

    就后缀树或输入字符串而言,size of the output 指的是什么? 最后的输出阶段列出了在T 中出现的所有Key(r),对于标记为输出的所有状态r算法C,定义了函数dp(第四页);我不明白索引 i 代表什么。它没有初始化,也没有增加。

以下是我的看法(我有待纠正):

    在第七页,我们介绍了后缀树的概念;状态实际上是后缀树中的一个节点:root 表示初始状态。 g(a, c) = b 其中 ab 是树中的节点,而 c 是树中的字符或子字符串。所以这代表了一个过渡;从 a,沿着 c 表示的边,我们移动到节点 b。这被称为go-to 转换。所以对于下面的后缀树,g(root, 'ccb') = red node Key(a) = edge sequence 其中 a 表示树中的一个节点。例如,Key(red node) = 'ccb'。所以g(root, Key(red node)) = red nodeKeys(Subset of node S) = Key(node) | node ∈ S 节点ab有一个后缀函数,f(a) = b:对于所有(或者可能有存在)aroot,存在一个字符c,一个子串x 和一个节点 b,这样 g(root, cx) = ag(root, x) = b。我认为这意味着,对于上面的后缀树示例,f(pink node) = green node 其中 c = 'a'x = 'bccb'。 有一个映射H,其中包含一个来自后缀树的节点和一个值对。该值由 loc(w) 给出;我仍然不确定如何评估该功能。此字典包含尚未消除的节点。 extract-min(H)是指从H得到(node, loc(w))对中值最小的条目。

算法的症结似乎与 loc(w) 的评估方式有关。我已经使用组合答案here 构建了我的后缀树;但是,这些算法适用于后缀树(未压缩的后缀树)。因此,需要以不同的方式维护和处理诸如深度之类的概念。在后缀树中,深度代表后缀长度;在后缀树中,深度只是表示树中的节点深度。

【讨论】:

【参考方案2】:

你做得很好。我不熟悉该算法,但今天阅读了该论文。你写的一切都是正确的。你说得对,解释的某些部分假设了很多。

您的问题

1.输出的大小在后缀树或输入字符串方面指的是什么?最后的输出阶段列出了所有在 T 中出现的 Key(r),用于标记为输出的所有状态 r。

输出包含 P 在 T 中的最大 k 距离匹配。特别是,您将获得每个匹配的最终索引和长度。所以很明显这也是 O(n) (记住 big-O 是一个上限),但可能更小。这是对不可能在少于 O(p) 的时间内生成 p 个匹配的事实的认可。其余时间限制仅涉及模式长度和可行前缀的数量,两者都可以任意小,因此输出大小可以占主导地位。考虑 k=0 并且输入是 'a' 以模式 'a' 重复 n 次。

2.看算法C,定义了函数dp(第四页);我不明白我代表什么索引。它没有初始化,也没有增加。

你是对的。这是一个错误。循环索引应为ij 呢?这是与动态程序中正在处理的输入字符对应的列的索引。它应该是一个输入参数。

让我们退后一步。第 6 页上的示例表是使用前面给出的公式 (1-4) 从左到右、逐列计算的。这些表明只需要 D 和 L 的前一列即可获得下一列。函数dp 只是从j-1 计算列j 的想法的一个实现。 D 和 L 的列j 分别称为dlj-1列D和L分别是函数输入参数d'l'

我建议您仔细研究动态程序,直到您完全理解为止。该算法旨在避免重复的列计算。这里的“重复”意味着“在 essential 部分具有相同的值”,因为这才是最重要的。无关紧要的部分不会影响答案。

未压缩的特里树只是以明显的方式扩展的压缩特里树,每个字符都有一条边。除了“深度”的概念,这并不重要。在压缩树中,depth(s) 只是字符串的长度——他称之为 Key(s)——需要从根节点 s 获取。

算法 A

算法 A 只是一个巧妙的缓存方案。

他的所有定理和引理都表明:1)我们只需要关心列的基本部分,以及 2)列 j 的基本部分完全由可行前缀 Q_j 确定。这是以 j 结尾的输入的最长后缀,它匹配模式的前缀(在编辑距离 k 内)。换句话说,Q_j 是迄今为止所考虑的输入结束时 k 编辑匹配的最大开始。

有了这个,这里是算法 A 的伪代码。

Let r = root of (uncompressed) suffix trie
Set r's cached d,l with formulas at end page 7 (0'th dp table columns)
// Invariant: r contains cached d,l
for each character t_j from input text T in sequence
  Let s = g(r, t_j)  // make the go-to transition from r on t_j
  if visited(s)
    r = s
    while no cached d,l on node r
      r = f(r) // traverse suffix edge
    end while
  else
    Use cached d',l' on r to find new columns (d,l) = dp(d',l')
    Compute |Q_j| = l[h], h = argmax(i).d[i]<=k as in the paper 
    r = s
    while depth(r) != |Q_j|
      mark r visited
      r = f(r)  // traverse suffix edge
    end while
    mark r visited
    set cached d,l on node r
  end if
end for

为简单起见,我省略了输出步骤。

什么是遍历后缀边?当我们从 Key(r) = aX 的节点 r 执行此操作时(前导 a 后跟一些字符串 X),我们将前往具有 Key X 的节点。结果:我们将对应于可行前缀 Q_h 的每一列存储在带有前缀 Q_h 的输入后缀的 trie 节点。函数 f(s) = r 是后缀转移函数。

对于它的价值,Wikipedia picture of a suffix tree 很好地展示了这一点。例如,如果从“NA”的节点我们沿着后缀边,我们到达“A”的节点,然后从那里到“”。我们总是在剪掉主角。因此,如果我们用 Key(s) 标记状态 s,我们就有 f("NA") = "A" 和 f("A") = ""。 (我不知道他为什么不在论文中这样标记状态。这样可以简化很多解释。)

现在这很酷,因为我们只计算每个可行前缀的一列。但它仍然很昂贵,因为我们正在检查每个字符并可能遍历每个字符的后缀边。

算法 B

算法 B 的目的是通过跳过输入来加快速度,只触及那些可能产生新列的字符,即那些与以前看不见的模式前缀匹配的输入结尾。

正如您所怀疑的,算法的关键是loc 函数。粗略地说,这将告诉下一个“可能”输入字符在哪里。该算法有点像 A* 搜索。我们维护了一个最小堆(它必须有一个删除操作)对应于论文中的集合 S_i。 (他称它为字典,但这不是该术语的非常传统的用法。)如上所述,最小堆包含潜在的“下一个状态”,其键控于下一个“可能的字符”的位置。处理一个字符会产生新的条目。我们继续前进,直到堆为空。

你说得对,他在这里变得粗略。定理和引理并没有联系在一起来对正确性进行论证。他认为你会重做他的工作。我对这种挥手并不完全相信。但似乎确实有足够的东西来“解码”他想到的算法。

另一个核心概念是集合 S_i,尤其是未消除的子集。我们会将这些未消除的状态保留在最小堆 H 中。

你说这个符号掩盖了 S_i 的意图是对的。当我们从左到右处理输入并到达位置 i 时,我们已经积累了迄今为止看到的一组可行前缀。每次找到一个新的时,都会计算一个新的 dp 列。在作者的符号中,这些前缀将是所有 h一步 获得的所有状态。这产生的结果与遍历整个文本查找 Q_h 的每次出现和下一个字符 a,然后将对应于 Q_h a 的状态添加到 S_i 中的结果相同,但它更快。 S_i 状态的键正是下一个可行前缀 Q_i+1 的正确候选者。

我们如何选择合适的候选人?选择输入中位置 i 之后的下一个。这就是 loc(s) 的用武之地。状态 s 的 loc 值正是我上面所说的:输入中从 i 开始的位置,接下来出现与该状态相关联的可行前缀。

重要的一点是,我们不想仅仅将新找到的(通过从 H 中提取 min loc 值)“下一个”可行前缀分配为 Q_i+1(dp 列 i+ 的可行前缀1) 继续下一个字符 (i+2)。这是我们必须设置阶段尽可能向前跳到last字符k(带有dp列k)的地方,例如Q_k = Q_i+1。我们按照算法 A 中的后缀边向前跳过。只是这次我们通过更改 H 来记录我们的步骤以供将来使用:删除元素,这与从 S_i 中删除元素和修改 loc 值相同。

函数 loc(s) 的定义很简单,他从来没有说过如何计算它。还没有提到的是 loc(s) 也是 i 的函数,当前正在处理的输入位置(他从论文前面部分的 j 跳到这里的 i 因为当前输入位置是无用的。)影响是 loc (s) 随着输入处理的进行变化

事实证明,适用于消除状态的定义部分“刚刚发生”,因为状态在移除表格 H 时被标记为消除。所以对于这种情况,我们只需要检查标记。

另一种情况 - 未消除的状态 - 要求我们在输入中向前搜索,以寻找文本中未被其他字符串覆盖的下一个匹配项。这种覆盖的概念是为了确保我们总是只处理“最长可能”的可行前缀。必须忽略较短的,以避免输出最大匹配以外的输出。现在,向前搜索听起来很昂贵,但很高兴我们已经构建了一个后缀树,它允许我们在 O(|Key(s)|) 时间内完成。必须仔细注释 trie 以返回相关的输入位置并避免出现被覆盖的 Key(s),但这不会太难。他从来没有提到当搜索失败时该怎么做。这里loc(s) = \infty,即被淘汰,应该从H中删除。

也许算法中最棘手的部分是更新 H 以处理我们为可行前缀添加新状态 s 的情况,该前缀覆盖 H 中已经存在的某些 w 的 Key(w)。这意味着我们必须手术更新H 中的 (loc(w) => w) 元素。事实证明,后缀特里树再次通过其后缀边缘有效地支持这一点。

考虑到这一切,让我们试试伪代码。

H =  (0 => root)   // we use (loc => state) for min heap elements
until H is empty
  (j => s_j) = H.delete_min  // remove the min loc mapping from 
  (d, l) = dp(d', l', j) where (d',l') are cached at paraent(s_j)
  Compute |Q_j| = l[h], h = argmax(i).d[i]<=k as in the paper
  r = s_j
  while depth(r) > |Q_j|
    mark r eliminated
    H.delete (_ => r)  // loc value doesn't matter
  end while
  set cached d,l on node r

  // Add all the "next states" reachable from r by go-tos
  for all s = g(r, a) for some character a
    unless s.eliminated?
      H.insert (loc(s) => s)  // here is where we use the trie to find loc

      // Update H elements that might be newly covered
      w = f(s) // suffix transition
      while w != null
        unless w.eliminated?
          H.increase_key(loc(w) => w) // using explanation in Lemma 9.
          w = f(w) // suffix transition
        end unless
      end while
    end unless
  end for
end until

为了简单起见,我再次省略了输出。我不会说这是正确的,但它在球场上。一件事是他提到我们应该只处理不在“访问”之前的节点的 Q_j,但我不明白在这种情况下“访问”是什么意思。我认为算法 A 定义的访问状态不会发生,因为它们已从 H 中删除。这是一个谜......

引理 9 中的increase_key 操作在没有证据的情况下草草描述。他声称最小操作可以在 O(log |alphabet|) 时间内完成,这让人们想像不到。

怪癖的数量让我怀疑这是否不是论文的最终草稿。它也是 Springer 出版物,如果完全一样,这个在线副本可能会违反版权限制。可能值得在库中查找或为最终版本付费,以查看是否在最终审查期间消除了一些粗糙的边缘。

这是我所能得到的。如果您有具体问题,我会尽力澄清。

【讨论】:

以上是关于使用后缀树进行近似子串匹配的主要内容,如果未能解决你的问题,请参考以下文章

没有动态编程或后缀树的最长公共子串

最长回文子串和后缀特里树

后缀树

ural1297(后缀树组+rmq)

如何使用树找到最长的公共子串?

字符串-后缀树和后缀数组详解