子序列查询的数据结构

时间:2013-08-01 14:09:04

标签: string algorithm search data-structures language-agnostic

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

  

给定一组字符串A和查询字符串q,返回所有s ∈ A,使q为s的{​​{3}}

例如,应该返回A = {"abcdef", "aaaaaa", "ddca"}q = "acd" "abcdef"


以下是我到目前为止所考虑的内容:

  1. 对于每个可能的字符,请为其显示的所有字符串/位置创建一个排序列表。对于查询交错所涉及字符的列表,并扫描它以查找字符串边界内的匹配。

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

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

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


  3. 您是否知道任何数据结构,算法或预处理技巧可能有助于大型A 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:我被要求提供有关qA及其元素性质的更多详情。虽然我更喜欢尽可能普遍运行的东西,但我认为A的长度大约为10 ^ 6,并且需要支持插入。元素s将更短,平均长度为64.查询q将只有1到20个字符并用于实时搜索,因此查询“ab”将在之前发送查询“abc”。同样,我更倾向于使用上述解决方案尽可能少。

    更新3:我发现,具有O(n^{1-epsilon})查询的数据结构将允许您解决OVP /反驳SETH猜想。这可能是我们遭受苦难的原因。然后,唯一的选择是反驳猜想,使用近似或利用数据集。我想象quadlets和try会​​在不同的设置中完成最后一次。

6 个答案:

答案 0 :(得分:8)

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

A = {'ab, 'bc'}

如果您为NFA字符串构建ab,则应该得到以下内容:

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

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

  1. S状态是起始状态,F是结束状态。
  2. 如果您处于F状态,则表示您的字符串符合后续条件。
  3. 在自动机内传播的规则是你可以消耗e(epsilon)来向前跳跃,因此你可以在每个时间点处于多于一个状态。这称为e闭包。
  4. 现在,如果给定b,则从州S开始,我可以跳过一个epsilon,到达2,然后消费b并到达3 }。现在给定end字符串我消耗epsilon并到达F,因此b符合sub-sequence ab的条件。 aab您也可以尝试使用上述自动机。

    关于NFA的好处是他们有一个开始状态和一个最终状态。可以使用NFA轻松连接两个epsilons。有各种算法可以帮助您将NFA转换为DFADFA是一个有向图,它可以遵循给定角色的精确路径 - 特别是,它在任何时间点始终处于完全一个状态。 (对于任何NFA,都有相应的DFA,其状态对应于NFA中的状态集。)

    因此,对于A = {'ab, 'bc'},我们需要为NFA构建ab,然后为NFA构建bc,然后加入两个NFAs和构建整个大DFA的{​​{1}}。

    修改

    NFA的子序列的NFA为abc,因此您可以将NFA构建为:

    enter image description here

    现在,考虑输入a?b?c?。要查询acdab的子序列,您可以使用此NFA:{'abc', 'acd'}。拥有NFA后,您可以将其转换为DFA,其中每个州都将包含(a?b?c?)|(a?c?d)abc或两者的子序列。

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

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

    编辑2

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

    编辑3

    正则表达式10,000

    enter image description here

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

    enter image description here

    实际上NFA的状态要少得多。

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

    编辑4

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

答案 1 :(得分:7)

测试

此主题中有四个主要提案:

  1. Shivam Kalra建议根据A中的所有字符串创建自动机。这种方法在文献中略有尝试,通常名称为“有向无环子序列图”(DASG)。

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

  3. 在“数据库中的高效子序列搜索”中,Rohit Jain,Mukesh K. Mohania和Sunil Prabhakar建议使用Trie结构进行一些优化,并递归搜索树以查询查询。他们也有类似三元组理念的建议。

  4. 最后还有'天真'的方法,wanghq建议通过为A的每个元素存储一个索引进行优化。

  5. 为了更好地了解值得继续努力的内容,我已经在Python中实现了上述四种方法,并对两组数据进行了基准测试。通过在C或Java中完善的实现,可以更快地实现几个实现;而且我没有包括针对'trie'和'naive'版本建议的优化。

    测试1

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

    作为A大小函数的施工时间(以秒为单位): Construction time

    A大小为单位的查询时间(以秒为单位): Query time

    测试2

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

    作为A大小函数的施工时间(以秒为单位): enter image description here

    A大小为单位的查询时间(以秒为单位): enter image description here

    结论

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

    • 自动机在查询时非常快(恒定时间),但是无法为|A| >= 256创建和存储它们。有可能进行更密切的分析可以产生更好的时间/记忆平衡,或者适用于其余方法的一些技巧。

    • dup- / trip- / quadlet方法的速度是我的trie实现速度的两倍,是'naive'实现速度的四倍。我只使用了线性数量的合并列表,而不是j_random_hacker建议的n^3。有可能更好地调整方法,但总的来说这是令人失望的。

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

    • 如果你的性能可以降低,那么天真的方法相对来说只需很少的费用就可以了。

答案 2 :(得分: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-string在c [s]中只有几个序列,那么它在某种意义上是区别。对于大多数数据集,并非所有k字符串都具有同等的区别性,这可以用于我们的优势。在预处理之后,考虑丢弃具有超过一些固定数量(或总数的一些固定部分)序列的所有列表,原因有三:
    • 他们需要大量空间来存储
    • 在查询处理期间,他们需要花费大量时间进行交叉
    • 相交它们通常不会缩小整个交叉点
  3. 没有必要考虑q的每个 k-子序列。虽然这将产生最小的交集,但它涉及合并(| q |选择k)列表,并且很可能仅使用这些k子序列的一小部分来产生几乎一样小的交集。例如。你可以限制自己尝试q的所有(或几个)k子串。作为进一步的过滤器,只考虑那些c [s]中的序列列表低于某个值的k子序列。 (注意:如果每个查询的阈值相同,您也可以从数据库中删除所有此类列表,因为这会产生相同的效果,并节省空间。)

答案 3 :(得分:2)

一个想法;
如果q往往很短,可能会减少A和q到一组会有帮助吗?
因此,对于该示例,导出到{(a,b,c,d,e,f),(a),(a,c,d)}。查找任何q的可能候选者应该比原始问题更快(这实际上是一个猜测,不确定究竟是多少。可能会对它们进行排序并在Bloom过滤器中“组合”类似的那些?),然后使用强力来清除误报。
如果A字符串很长,你可以根据它们的出现使字符唯一,这样就是{(a1,b1,c1,d1,e1,f1),(a1,a2,a3,a4,a5 ,A6),(A1,C1,D1,D2)}。这很好,因为如果你搜索“ddca”,你只想匹配第二个d到第二个d。你的字母表的大小会上升(对于bloom或bitmap样式的操作不好),并且在你获得新的A时会有所不同,但是误报的数量会下降。

答案 4 :(得分:2)

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

  1. 如果A是B的子序列,则A中的所有字符都应出现在B中。
  2. 对于B中的那些角色,他们的位置应按升序排列。
  3. 请注意,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]
    

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

    此处的策略是选择之前和之前所选位置的位置。 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
    

    一如既往,我可能完全错了:)

答案 5 :(得分:1)

您可能希望了解Dan Gusfield的字符串和序列的书籍算法。事实证明,它的一部分可以在互联网上找到。您可能还想阅读Gusfield的Introduction to Suffix Trees。事实证明,这本书涵盖了许多方法,为您提出了一些问题。它被认为是该领域的标准出版物之一。

  1. 获取快速longest common subsequence算法实现。实际上,确定LCS的长度就足够了。请注意,Gusman的书中有非常好的算法,并且还指出了更多此类算法的来源。
  2. 使用s ∈ A
  3. 返回所有length(LCS(s,q)) == length(q)