子序列查询的数据结构

Posted

技术标签:

【中文标题】子序列查询的数据结构【英文标题】:Data Structure for Subsequence Queries 【发布时间】:2013-08-02 12:21:59 【问题描述】:

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

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

例如,给定A = "abcdef", "aaaaaa", "ddca"q = "acd",应该返回正好"abcdef"


以下是我目前考虑过的:

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

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

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

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


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


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

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 和尝试会在不同的设置下做最后一个。

【问题讨论】:

如果我输入de,它还会返回abcdef吗? 是的,我已经添加了一个指向 Wikipedia 的链接,用于精确定义子序列 qqs 之间的关系是否还有其他特点?比如q很可能包含s的相邻字符,s的长度与q的顺序相同,等等。如果是这种情况,你可以看看algorithm for BLAST。如果不是,我认为A 的预处理不会有用,因为s 的任何子字符串都与q 无关。 @lcn Blast 好像是找到目标数据库中与查询的编辑距离最短的子串,所以经常会给出错误的答案 @ThomasAhle,如果您的q 包含s 的一些子字符串,我建议的是BLAST 使用的预处理想法。 BLAST 的目标并不重要。 【参考方案1】:

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

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

【讨论】:

你确定你没有考虑子串而不是子序列吗? 我通读了内容,没有发现任何听起来很有希望的东西。你能参考一下这个章节吗? 我知道如何做 lcs,但我看不出它对这个问题有什么帮助。如果你能找到一个联系,那将是非常有趣的,因为关于这个问题的文献非常多。 我在回答中添加了更多细节。希望这可以使连接清晰。 检查$s$ 是否是$q$ 在线性时间内的子序列非常简单。不需要算法来做到这一点。所需要的是一个预处理,使我们免于遍历所有 A【参考方案2】:

测试

此线程中有四个主要提案:

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

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

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

    最后是“幼稚”的方法,wanghq 建议通过为A 的每个元素存储一个索引来进行优化。

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

测试 1

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

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

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

测试 2

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

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

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

结论

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

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

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

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

如果您可以降低性能,那么简单的方法相对来说就可以,而且成本非常低。

【讨论】:

【参考方案3】:

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

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

请注意,A 中的字符可能在 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

这里的策略是选择在前一个所选位置之后和接近的位置。 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

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

【讨论】:

你没有错,只是你循环了 strSet 而不是预处理它。这样,您将永远无法获得比目标 O(|street|) 更好的复杂性。 抱歉,没明白你的意思。我的预处理是将位置存储在地图中。如果您需要针对该 strSet 运行多个查询,则预处理只发生一次。 我认为你只是加快了个别检查“是 q s 的子序列”。如果您运行多个查询,您仍然需要遍历与 strSet 一样大的映射列表,不是吗? 澄清一下,虽然这当然是一个优化,但问题是集合中字符串的长度不是瓶颈,strSet的大小是瓶颈【参考方案4】:

一个想法; 如果 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 时都会有所不同,但误报的数量会减少。

【讨论】:

这和random_hacker的思路基本一样? 从某种意义上说,是的。我们建议使用过滤器作为步骤 1。这样做是否有意义取决于一些未知参数(A 的数量、字母表的大小、A 的长度、q 的长度、每个 q 的结果数量, ...)过滤机制是不同的。我认为总的来说他的表现会比我的好。【参考方案5】:

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

A = 'ab, 'bc'

如果你为ab 字符串构建NFA,你应该得到这样的结果:

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

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

    S 状态是开始状态,F 是结束状态。 如果您处于F 状态,则意味着您的字符串符合子序列的条件。 在 autmaton 内传播的规则是您可以使用 e (epsilon) 向前跳转,因此您可以在每个时间点处于多个状态。这称为e 闭包。

现在如果给定b,从状态S开始,我可以跳一个epsilon,到达2,消耗b并到达3。现在给定end 字符串,我使用epsilon 并到达F,因此b 符合sub-sequenceab 的条件。 aab 也是如此,您可以尝试使用上述自动机。

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

所以,对于A = 'ab, 'bc',我们需要为ab 构建NFA,然后为bc 构建NFA,然后加入两个NFAs 并构建整个大NFADFA .

编辑

abc 的子序列的 NFA 将是 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 中有 10,000 个唯一字符。唯一的意思是 A 是这样的:'abc', 'def' 即 A 的每个元素的交集都是空集。那么就状态而言,您的 DFA 将是最坏的情况,即2^10000。但我不确定什么时候可以这样做,因为不可能有 10,000 唯一字符。即使您在 A 中有 10,000 个字符,仍然会有重复,这可能会大大减少状态,因为电子闭包最终可能会合并。我无法真正估计它可能会减少多少。但是即使有 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 州少。

【讨论】:

你认为它的复杂性是什么? (一旦构建了 dfa)它本质上不只是将查询字符串 "abc" 重写为 a.*b.*c.* 并在每个输入上运行它吗? 复杂度是输入字符串中的字符数或更少。因为您只在有向图中传播,消耗输入字符串中的字符。如果有n 字符来消耗你最坏情况的复杂性将是O(n) 如果我们为A="abc" 构建 NFA 会怎样。那不会接受"ac" 会吗?因为从(2) 的唯一路径将是eFb(3) 是的,我不包括..应该包括吗? 糟糕,那篇论文只证明了 |A|=2 的二次边界。这篇论文证明我们得到了相当大的爆炸|A|成长:sciencedirect.com/science/article/pii/S030439750500157X。自动机的大小为O(|s|^|A|/(|A|+1)^|A|*|A|!)。太糟糕了,但我们还是看看它在实践中是如何工作的【参考方案6】:

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

TTBOMK 没有神奇、快速的方法来回答这个问题(因为后缀树是神奇、快速的方法来回答涉及 substrings 而不是 subsequences 的相应问题em>)。不过,如果您预计大多数查询的平均答案集都很小,那么值得考虑加快这些查询(产生小尺寸答案的那些)的方法。

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

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

一些观察:

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

【讨论】:

我喜欢这个主意。你知道包含 s 的所有 k 子序列但不包含 s 的最短字符串的长度吗? @ThomasAhle:这取决于。如果 s = aaaa 且 k = 3,则 aaa 包含 s 的所有 k-子序列。

以上是关于子序列查询的数据结构的主要内容,如果未能解决你的问题,请参考以下文章

数据库 day60,61 Oracle入门,单行函数,多表查询,子查询,事物处理,约束,rownum分页,视图,序列,索引

从 CPU 使用率中位数的子查询中选择前 10 名,并使用 Influx 显示时间序列数据

MongoDB - 时间序列子文档的范围查询

MongoDB - 对时间序列子文档进行范围查询

视图序列索引

drf-基表断关联表关系级联删除正方向查询子序列化