18

在一个程序中,我需要有效地回答以下形式的查询:

给定一组字符串A和一个查询字符串,q返回所有s ∈ A使得qs

例如,应该返回给定A = {"abcdef", "aaaaaa", "ddca"}q = "acd"准确。"abcdef"


以下是我迄今为止考虑过的:

  1. 对于每个可能的字符,制作一个排序列表,列出它出现的所有字符串/位置。查询交错涉及的字符列表,并扫描它以查找字符串边界内的匹配项。

    这对于单词而不是字符可能会更有效,因为有限数量的不同字符会使返回列表非常密集。

  2. 对于每个可能具有的 n 前缀q,存储所有匹配字符串的列表。n实际上可能接近 3。对于长于该长度的查询字符串,我们会强制使用初始列表。

    这可能会加快速度,但可以很容易地想象一些 n 子序列存在于 中的所有字符串附近A,这意味着最坏的情况与仅暴力破解整个集合相同。


您是否知道任何数据结构、算法或预处理技巧可能有助于有效地为大型As 执行上述任务?(我s的 s 大约 100 个字符)


更新:有些人建议使用 LCS 来检查是否qs. 我只是想提醒一下,这可以使用一个简单的函数来完成,例如:

def isSub(q,s):
  i, j = 0, 0
  while i != len(q) and j != len(s):
    if q[i] == s[j]:
      i += 1
      j += 1
    else:
      j += 1
  return i == len(q)

更新 2:有人要求我提供有关 的性质及其元素的更多详细q信息A。虽然我更喜欢尽可能通用的东西,但我认为A长度约为 10^6 并且需要支持插入。元素s将更短,平均长度为 64。查询q将只有 1 到 20 个字符并用于实时搜索,因此查询“ab”将在查询“abc”之前发送。同样,我更喜欢尽可能少地使用上述解决方案。

更新 3:我突然想到,具有O(n^{1-epsilon})查找功能的数据结构可以让您解决 OVP / 反驳 SETH 猜想。这大概就是我们受苦的原因。唯一的选择是反驳猜想、使用近似值或利用数据集。我想 quadlets 和尝试会在不同的设置中做最后一个。

4

6 回答 6

8

它可以通过构建一个automaton. 您可以从NFA(非确定性有限自动机,类似于不确定性有向图)开始,它允许用epsilon字符标记边,这意味着在处理过程中您可以从一个节点跳转到另一个节点而不会消耗任何字符。我会尽量减少你的A. 假设你A是:

A = {'ab, 'bc'}

如果你NFAab字符串构建,你应该得到这样的东西:

     +--(1)--+ 
  e  |  a|   |e
(S)--+--(2)--+--(F)
     |  b|   |
     +--(3)--+

上图不是最好看的自动机。但有几点需要考虑:

  1. Sstate 是开始状态,F也是结束状态。
  2. 如果您处于F状态,则意味着您的字符串有资格作为子序列。
  3. 在 autmaton 内传播的规则是您可以消耗e(epsilon) 向前跳跃,因此您可以在每个时间点处于多个状态。这称为e闭包。

现在,如果给定b,从状态开始,S我可以跳一个epsilon,到达2,并消耗b和到达3。现在给定end字符串 I 消费epsilon和到达F,因此b有资格作为 a sub-sequenceof ab。也可以,a或者ab您可以尝试使用上述自动机。

好处NFA是它们有一个开始状态和一个最终状态。两个NFA可以很容易地连接使用epsilons。有多种算法可以帮助您转换NFADFA. DFA是一个有向图,它可以遵循给定字符的精确路径——特别是,它在任何时间点总是处于一个状态。(对于任何 NFA,都有一个相应的 DFA,其状态对应于 NFA中的状态集。)

因此,对于A = {'ab, 'bc'},我们需要NFAabthen NFAfor构建,然后bc将两者结合起来NFAs并构建DFA整个 big NFA

编辑

的子序列的 NFAabc将是a?b?c?,因此您可以将 NFA 构建为:

在此处输入图像描述

现在,考虑输入acd。要查询是否ab是 的子序列{'abc', 'acd'},您可以使用此 NFA: (a?b?c?)|(a?c?d)。拥有 NFA 后,您可以将其转换为 DFA,其中每个状态将包含它是否是子序列abcacd两者兼而有之。

我使用下面的链接从正则表达式制作 NFA 图形:

http://hackingoff.com/images/re2nfa/2013-08-04_21-56-03_-0700-nfa.svg

编辑 2

你是对的!如果您在A. 唯一我的意思是 A 是这样的:{'abc', 'def'}即 A 的每个元素的交集是空集。那么就状态而言,您的 DFA 将是最坏的情况,即2^10000. 但我不确定什么时候可以做到,因为永远不会有10,000独特的角色。即使您在 A 中有 10,000 个字符,仍然会有重复,这可能会大大减少状态,因为 e-closure 最终可能会合并。我无法真正估计它可能会减少多少。但是即使有 1000 万个状态,您也只会消耗不到 10 mb 的空间来构建 DFA。您甚至可以使用 NFA 并在运行时查找电子闭包,但这会增加运行时的复杂性。您可以搜索有关如何将大的正则表达式转换为 DFA 的不同论文。

编辑 3

对于正则表达式(a?b?c?)|(e?d?a?)|(a?b?m?)

在此处输入图像描述

如果您将上述 NFA 转换为 DFA,您将获得:

在此处输入图像描述

它实际上比 NFA 少得多。

参考: http ://hackingoff.com/compilers/regular-expression-to-nfa-dfa

编辑 4

在更多地摆弄那个网站之后。我发现最坏的情况是这样的 A = {'aaaa', 'bbbbb', 'cccc' ....}。但即使在这种情况下,州也比 NFA 州少。

于 2013-08-01T15:14:50.413 回答
7

测试

该线程中有四个主要建议:

  1. Shivam Kalra 建议根据A. 这种方法已经在文献中进行了轻微的尝试,通常以“有向无环子序列图”(DASG)的名称命名。

  2. J Random Hacker 建议将我的“前缀列表”想法扩展到查询字符串中的所有“n 选择 3”三元组,并使用堆将它们全部合并。

  3. 在注释“数据库中的高效子序列搜索”中,Rohit Jain、Mukesh K. Mohania 和 Sunil Prabhakar 建议使用经过一些优化的 Trie 结构并递归地搜索查询树。他们也有一个类似于三元组想法的建议。

  4. 最后是“幼稚”的方法,wanghq 建议通过为A.

为了更好地了解什么值得继续努力,我在 Python 中实现了上述四种方法,并在两组数据上对它们进行了基准测试。使用 C 或 Java 实现良好的实现可以使实现速度提高几个数量级;而且我没有包括为“trie”和“naive”版本建议的优化。

测试 1

A由来自我的文件系统的随机路径组成。q是 100 个平均长度为 7 的随机[a-z]字符串。由于字母表很大(而且 Python 很慢),我只能将 duplets 用于方法 3。

以秒为单位的构建时间与A大小的函数关系: 施工时间

以秒为单位的查询时间作为A大小的函数: 查询时间

测试 2

A[a-b]由长度为 20的随机采样字符串组成。q是 100 个[a-b]平均长度为 7 的随机字符串。由于字母表很小,我们可以将 quadlets 用于方法 3。

以秒为单位的构建时间与A大小的函数关系: 在此处输入图像描述

以秒为单位的查询时间作为A大小的函数: 在此处输入图像描述

结论

双对数图有点难读,但从数据中我们可以得出以下结论:

  • 自动机的查询速度非常快(恒定时间),但是它们不可能为|A| >= 256. 更仔细的分析可能会产生更好的时间/内存平衡,或者一些适用于其余方法的技巧。

  • dup-/trip-/quadlet 方法的速度大约是我的 trie 实现的两倍,是“naive”实现的四倍。我只使用线性数量的列表进行合并,而不是n^3j_random_hacker 建议的。也许可以更好地调整方法,但总的来说它令人失望。

  • 我的 trie 实现始终比天真的方法好两倍左右。通过合并更多的预处理(比如“这个子树中的下一个'c'在哪里”)或者可能将它与三元组方法合并,这似乎是今天的赢家。

  • 如果您可以使用更少的性能,那么朴素的方法以非常低的成本相对来说还不错。

于 2013-08-12T14:55:04.533 回答
3

正如您所指出的,A 中的所有字符串可能都包含 q 作为子序列,在这种情况下,您不能希望比 O(|A|) 做得更好。(也就是说,对于 A 中的每个字符串 i,您可能仍然能够比在 (q, A[i]) 上运行LCS所花费的时间做得更好,但我不会在这里关注这一点。)

TTBOMK 没有神奇的、快速的方法来回答这个问题(就像后缀树是回答涉及子字符串而不是子序列的相应问题的神奇、快速的方法一样)。然而,如果您希望大多数查询的答案集平均较小,那么值得寻找加速这些查询的方法(产生小尺寸答案的方法)。

我建议基于启发式 (2) 的概括进行过滤:如果某个数据库序列 A[i] 包含 q 作为子序列,那么它还必须包含 q 的每个子序列。(不幸的是,相反的方向并不正确!)因此,对于一些小的 k,例如您建议的 3,您可以通过构建一个列表数组来进行预处理,告诉您对于每个长度为 k 的字符串 s,包含 s 的数据库序列列表为一个子序列。即 c[s] 将包含包含 s 作为子序列的数据库序列的 ID 号列表。按数字顺序保留每个列表,以便以后启用快速交叉。

现在,每个查询 q 的基本思想(我们稍后会改进)是:找到 q 的所有 k 大小的子序列,在列表数组 c[] 中查找每个子序列,然后将这些列表相交以找到A 中可能包含 q 作为子序列的序列。然后对于这个(希望很小的)交集中的每个可能的序列 A[i],用 q 执行 O(n^2) LCS 计算,看看它是否真的包含 q。

几点观察:

  1. 可以在 O(m+n) 时间内找到大小为 m 和 n 的 2 个排序列表的交集。要找到 r 个列表的交集,请按任意顺序执行 r-1 对交集。由于取交集只能产生更小或相同大小的集合,因此可以通过先与最小的一对列表相交,然后是下一个最小的对(这必然包括第一个操作的结果)等相交来节省时间,依此类推. 特别是:按大小递增的顺序对列表进行排序,然后始终将下一个列表与“当前”交集相交。
    • 用不同的方法找到交集实际上更快,通过将每个 r 列表的第一个元素(序列号)添加到堆数据结构中,然后反复拉出最小值并用下一个值从堆中填充最近的最小值来自的列表。这将产生一个非递减顺序的序列号列表;任何连续出现少于 r 次的值都可以被丢弃,因为它不能是所有 r 个集合的成员。
  2. 如果一个 k 字符串 s 在 c[s] 中只有几个序列,那么它在某种意义上是有区别的。对于大多数数据集,并非所有的 k 字符串都具有同等的区分能力,这可以用于我们的优势。预处理后,考虑丢弃所有序列超过某个固定数量(或总数的某个固定部分)的列表,原因有 3 个:
    • 他们需要很多空间来存储
    • 它们在查询处理过程中需要很长时间才能相交
    • 与它们相交通常不会使整体交叉点缩小太多
  3. 没有必要考虑q 的每个k-子序列。尽管这将产生最小的交集,但它涉及合并 (|q| 选择 k) 个列表,并且很可能只使用这些 k 子序列的一小部分来产生几乎一样小的交集。例如,您可以限制自己尝试 q 的所有(或少数)k 子串。作为进一步的过滤器,只考虑那些在 c[s] 中的序列列表低于某个值的 k 子序列。(注意:如果每个查询的阈值都相同,则最好从数据库中删除所有此类列表,因为这将产生相同的效果,并节省空间。)
于 2013-08-05T14:53:40.267 回答
2

首先让我确保我的理解/抽象是正确的。应满足以下两个要求:

  1. 如果 A 是 B 的子序列,那么 A 中的所有字符都应该出现在 B 中。
  2. 对于 B 中的那些字符,它们的位置应该按升序排列。

请注意,A 中的 char 可能在 B 中出现多次。

为了解决 1),可以使用地图/集合。键是字符串 B 中的字符,值无关紧要。为了解决2),我们需要保持每个字符的位置。由于一个字符可能出现不止一次,因此该位置应该是一个集合。

所以结构是这样的:

Map<Character, List<Integer>)
e.g.
abcdefab
a: [0, 6]
b: [1, 7]
c: [2]
d: [3]
e: [4]
f: [5]

一旦我们有了结构,如何知道字符的顺序是否正确,因为它们在 string 中A?如果Bacd,我们应该检查a位置 0(但不是 6)、c位置 2 和d位置 3。

这里的策略是选择在前一个所选位置之后和接近的位置。TreeSet 是此操作的理想选择。

public E higher(E e)
Returns the least element in this set strictly greater than the given element, or null if there is no such element.

运行时复杂度为 O(s * (n1 + n2)*log(m)))。

  • s:集合中的字符串数
  • n1:字符串中的字符数(B)
  • n2:查询字符串中的字符数(A)
  • m:字符串 (B) 中的重复项数,例如有 5 个a

下面是一些测试数据的实现。

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeSet;

public class SubsequenceStr {

    public static void main(String[] args) {
        String[] testSet = new String[] {
            "abcdefgh", //right one
            "adcefgh", //has all chars, but not the right order
            "bcdefh", //missing one char
            "", //empty
            "acdh",//exact match
            "acd",
            "acdehacdeh"
        };
        List<String> subseqenceStrs = subsequenceStrs(testSet, "acdh");
        for (String str : subseqenceStrs) {
            System.out.println(str);
        }
        //duplicates in query
        subseqenceStrs = subsequenceStrs(testSet, "aa");
        for (String str : subseqenceStrs) {
            System.out.println(str);
        }
        subseqenceStrs = subsequenceStrs(testSet, "aaa");
        for (String str : subseqenceStrs) {
            System.out.println(str);
        }
    }

    public static List<String> subsequenceStrs(String[] strSet, String q) {
        System.out.println("find strings whose subsequence string is " + q);
        List<String> results = new ArrayList<String>();
        for (String str : strSet) {
            char[] chars = str.toCharArray();
            Map<Character, TreeSet<Integer>> charPositions = new HashMap<Character, TreeSet<Integer>>();
            for (int i = 0; i < chars.length; i++) {
                TreeSet<Integer> positions = charPositions.get(chars[i]);
                if (positions == null) {
                    positions = new TreeSet<Integer>();
                    charPositions.put(chars[i], positions);
                }
                positions.add(i);
            }
            char[] qChars = q.toCharArray();
            int lowestPosition = -1;
            boolean isSubsequence = false;
            for (int i = 0; i < qChars.length; i++) {
                TreeSet<Integer> positions = charPositions.get(qChars[i]);
                if (positions == null || positions.size() == 0) {
                    break;
                } else {
                    Integer position = positions.higher(lowestPosition);
                    if (position == null) {
                        break;
                    } else {
                        lowestPosition = position;
                        if (i == qChars.length - 1) {
                            isSubsequence = true;
                        }
                    }
                }
            }
            if (isSubsequence) {
                results.add(str);
            }
        }
        return results;
    }
}

输出:

find strings whose subsequence string is acdh
abcdefgh
acdh
acdehacdeh
find strings whose subsequence string is aa
acdehacdeh
find strings whose subsequence string is aaa

和往常一样,我可能完全错了:)

于 2013-08-11T08:46:03.383 回答
2

一念;
如果 q 往往很短,也许将 A 和 q 减少到一个集合会有所帮助?
因此,对于示例,派生为 { (a,b,c,d,e,f), (a), (a,c,d) }。查找任何 q 的可能候选者应该比原始问题更快(实际上这是一个猜测,不确定如何准确。也许对它们进行排序并在布隆过滤器中“分组”相似的那些?),然后使用蛮力清除误报。
如果 A 字符串很长,您可以根据它们的出现来使字符唯一,这样就是 {(a1,b1,c1,d1,e1,f1),(a1,a2,a3,a4,a5,a6), (a1,c1,d1,d2)}。这很好,因为如果您搜索“ddca”,您只想将第二个 d 与第二个 d 匹配。字母表的大小会增加(不利于绽放或位图样式操作),并且每次获得新的 A 时都会有所不同,但误报的数量会减少。

于 2013-08-09T22:15:11.610 回答
0

你可能想看看 Dan Gusfield 的 Book Algorithms on Strings and Sequences。事实证明,它的一部分可以在互联网上找到。您可能还想阅读 Gusfield 的后缀树简介。事实证明,这本书涵盖了许多解决您这类问题的方法。它被认为是该领域的标准出版物之一。

  1. 获取快速最长公共子序列算法实现。实际上,确定 LCS 的长度就足够了。请注意,Gusman 的书有非常好的算法,并且还指出了更多此类算法的来源。
  2. s ∈ A全部返回length(LCS(s,q)) == length(q)
于 2013-08-10T16:30:43.230 回答