【问题标题】:Find all concatenations of two string in a huge set在一个巨大的集合中查找两个字符串的所有连接
【发布时间】:2019-02-14 11:59:03
【问题描述】:

给定一组 50k 个字符串,我需要找到所有对 (s, t),使得 sts + t 都包含在这个集合中。

我尝试过的

,还有一个额外的约束:s.length() >= 4 && t.length() >= 4。这使得可以按长度为 4 的前缀和单独的后缀对字符串进行分组。然后对于长度至少为 8 的每个字符串 composed,我使用 composed 的前四个字符查找 s 的候选集,并使用其后四个字符查找 t 的候选集。这可行,但它需要查看 30M 候选对 (s, t) 才能找到 7k 的结果。

如此多的候选者来自于这样一个事实,即字符串是(主要是德语)来自有限词汇表的单词,并且单词的开头和结尾通常相同。它仍然比尝试所有 2.5G 对要好得多,但比我希望的要差得多。

我需要什么

由于额外的约束可能会被删除并且集合会增长,我正在寻找更好的算法。

“缺失”的问题

有人抱怨我没有问问题。所以缺少的问号在下一句的末尾。 如何才能更有效地做到这一点,最好不使用约束?

【问题讨论】:

  • 其实你不会问问题
  • @ScaryWombat 实际上,通过声明我需要一个算法,我隐含地问了一个问题。我曾经希望它相当明显,但这里似乎有问号搜索机器人。
  • 你写的 我正在寻找更好的算法 - 祝你好运
  • @ScaryWombat 我最后的评论不是针对你的。相反,我感谢您回答我的评论。无论如何,您认为这还不清楚吗?
  • 您是否考虑过先按长度排序,然后按字母顺序排序的列表?

标签: java algorithm string-algorithm


【解决方案1】:

一个可能的解决方案可能是这样。 您从第一个字符串作为前缀开始,第二个字符串作为后缀。 你遍历每个字符串。如果字符串以第一个字符串开头,则检查它是否以第二个字符串结尾。并一直坚持到最后。为了在检查字母本身是否相同之前节省一些时间,您可以进行长度检查。 这几乎是你所做的,但通过这个增加的长度检查,你可能可以剪掉一些。至少这是我的看法。

【讨论】:

    【解决方案2】:

    不确定这是否比您的解决方案更好,但我认为值得一试。

    构建两个Tries,一个是正常顺序的候选,另一个是颠倒的单词。

    从深度 4 向内向前走 Trie 并使用叶子的其余部分来确定后缀(或类似的东西)并在后面查找它 Trie

    我过去曾在https://stackoverflow.com/a/9320920/823393 发布过Trie 实现。

    【讨论】:

    • 你认为它的算法复杂度是多少?
    • 我们也可以使用任何高效的 Set 实现来代替向后的 Trie,对吧?
    【解决方案3】:

    算法 1:测试对,而不是单打

    一种方法可能是,而不是从所有可能的对到包含这些对的所有可能的复合字符串,而是从所有可能的复合字符串中工作,看看它们是否包含对。这将问题从n^2 查找(其中n 是>= 4 个字符的字符串数)更改为m * n 查找(其中m 是所有字符串的平均长度>= 8 个字符,减7,并且n 现在是字符串数 >= 8 个字符)。这是它的一个实现:

    int minWordLength = 4;
    int minPairLength = 8;
    
    Set<String> strings = Stream
       .of(
          "a", "abc", "abcdef", "def", "sun", "sunshine", "shine",
          "bear", "hug", "bearhug", "cur", "curlique", "curl",
          "down", "downstream", "stream"
       )
       .filter(s -> s.length() >= minWordLength)
       .collect(ImmutableSet.toImmutableSet());
    
    strings
       .stream()
       .filter(s -> s.length() >= minPairLength)
       .flatMap(s -> IntStream
          .rangeClosed(minWordLength, s.length() - minWordLength)
          .mapToObj(splitIndex -> ImmutableList.of(
             s.substring(0, splitIndex),
             s.substring(splitIndex)
          ))
          .filter(pair ->
              strings.contains(pair.get(0))
              && strings.contains(pair.get(1))
          )
       )
       .map(pair ->
          pair.get(0) + pair.get(1) + " = " + pair.get(0) + " + " + pair.get(1)
       )
       .forEach(System.out::println);
    

    给出结果:

    downstream = down + stream
    

    如上所示,这具有m * n 的平均算法复杂度。所以实际上,O(n)。在最坏的情况下,O(n^2)。有关算法复杂性的更多信息,请参阅hash table

    说明

    1. 将所有长度为四个或更多字符的字符串放入一个散列集(搜索的平均复杂度为 O(1))。为方便起见,我使用了 Guava 的 ImmutableSet。随意使用。
    2. filter:仅限于长度为 8 个或更多字符的项目,表示我们的候选项目是列表中其他两个单词的组合。
    3. flatMap:对于每个候选,计算所有可能的子词对,确保每个子词至少有 4 个字符长。由于可能有多个结果,这实际上是一个列表列表,因此将其展平为一个单深列表。
      1. rangeClosed:生成所有整数,表示将在我们将检查的对的第一个单词中的字符数。
      2. mapToObj:使用每个整数与我们的候选字符串组合来输出两个项目的列表(在生产代码中,您可能想要更清晰的东西,例如两个属性值类或适当的现有类)。
      3. filter:仅限于两个都在列表中的对。
    4. map:结果稍微好一点。
    5. forEach:输出到控制台。

    算法选择

    此算法适用于比列表中的项目数短得多的单词。如果列表很短并且单词很长,那么切换回组合任务而不是分解任务会更好。鉴于该列表的大小为 50,000 个字符串,而德语单词虽然很长,但不太可能超过 50 个字符,这是有利于该算法的 1:1000 因素。

    另一方面,如果您有 50 个字符串,平均长度为 50,000 个字符,那么使用不同的算法会更有效率。

    算法 2:排序并保留候选列表

    我想了一会儿的一个算法是对列表进行排序,知道如果一个字符串代表一对的开始,那么所有可能是其对之一的候选字符串将按顺序紧随其后,在以该字符串开头的一组项目中。对上面的棘手数据进行排序,并添加一些混杂因素 (downer, downs, downregulate),我们得到:

    a
    abc
    abcdef
    bear
    bearhug
    cur
    curl
    curlique
    def
    down ---------\
    downs         |
    downer        | not far away now!
    downregulate  |
    downstream ---/
    hug
    shine
    stream
    sun
    sunshine
    

    因此,如果保留所有要检查的项目的运行集合,我们可以在每个单词基本恒定的时间内找到候选组合,然后直接探测剩余单词的哈希表:

    int minWordLength = 4;
    
    Set<String> strings = Stream
       .of(
          "a", "abc", "abcdef", "def", "sun", "sunshine", "shine",
          "bear", "hug", "bearhug", "cur", "curlique", "curl",
          "down", "downs", "downer", "downregulate", "downstream", "stream")
       .filter(s -> s.length() >= minWordLength)
       .collect(ImmutableSet.toImmutableSet());
    
    ImmutableList<String> orderedList = strings
       .stream()
       .sorted()
       .collect(ImmutableList.toImmutableList());
    List<String> candidates = new ArrayList<>();
    List<Map.Entry<String, String>> pairs = new ArrayList<>();
    
    for (String currentString : orderedList) {
       List<String> nextCandidates = new ArrayList<>();
       nextCandidates.add(currentString);
       for (String candidate : candidates) {
          if (currentString.startsWith(candidate)) {
             nextCandidates.add(candidate);
             String remainder = currentString.substring(candidate.length());
             if (remainder.length() >= minWordLength && strings.contains(remainder)) {
                pairs.add(new AbstractMap.SimpleEntry<>(candidate, remainder));
             }
          }
       }
       candidates = nextCandidates;
    }
    pairs.forEach(System.out::println);
    

    结果:

    down=stream
    

    这个算法的复杂度有点复杂。我认为搜索部分是O(n) 平均,O(n^2) 最坏情况。最昂贵的部分可能是排序——这取决于所使用的算法和未排序数据的特征。因此,将其与一粒盐一起使用,但它有可能。在我看来,这将比从庞大的数据集构建Trie 便宜得多,因为您只需全面探测一次,并且不会获得任何构建成本的摊销。

    另外,这次我选择了Map.Entry 来持有这对。你怎么做是完全随意的。制作一个自定义的 Pair 类或使用一些现有的 Java 类都可以。

    【讨论】:

    • 这听起来很有趣。如果你有一堆非常长的单词,这不会太有效,因为你最终会检查所有不同的方法来将它们分成对,但这样的可能性有多大?
    • 是的。该算法适用于比列表中的项目数短得多的单词。如果列表很短而单词很长,那么切换回组合任务而不是分解任务会更好。
    • 你的第一个算法非常清楚,完全符合我的需要。实际上,这是我应该自己找到的东西。第二个很奇怪,但很有趣。我不会害怕排序; n * log(n) 类似于 n * 20,通过不尝试所有拆分索引,您可以节省大约 20 倍。
    • @maaartinus 长度为 4+ 和 8+ 的字符串的计数是多少,8+ 字符串的平均长度是多少?有多少复合材料具有一对以上的组件? (就像 bigredhouse 是 bigred + house 和 big + redhouse。)你能在你的数据集上同时尝试这些,并告诉我性能特征是什么吗?
    • 总字符串:41906,4+字符串:39894,8+字符串:21854,它们的平均长度:11.043,总组合字符串:6964,非唯一组合字符串:360。第一个算法取110女士,我还没有尝试过其他的。对 39894 个字符串进行排序需要 43 毫秒,超出我的预期。
    【解决方案4】:

    您可以通过使用CharBuffer 视图避免大多数子String 创建并更改它们的位置和限制来改进Erik’s answer

    Set<CharBuffer> strings = Stream.of(
        "a", "abc", "abcdef", "def", "sun", "sunshine", "shine",
        "bear", "hug", "bearhug", "cur", "curlique", "curl",
        "down", "downstream", "stream"
     )
    .filter(s -> s.length() >= 4) // < 4 is irrelevant
    .map(CharBuffer::wrap)
    .collect(Collectors.toSet());
    
    strings
        .stream()
        .filter(s -> s.length() >= 8)
        .map(CharBuffer::wrap)
        .flatMap(cb -> IntStream.rangeClosed(4, cb.length() - 4)
            .filter(i -> strings.contains(cb.clear().position(i))&&strings.contains(cb.flip()))
            .mapToObj(i -> cb.clear()+" = "+cb.limit(i)+" + "+cb.clear().position(i))
        )
        .forEach(System.out::println);
    

    这是相同的算法,因此不会改变时间复杂度,除非您将隐藏的字符数据复制成本考虑在内,这将是另一个因素(乘以平均字符串长度)。

    当然,只有当您使用与打印匹配项不同的终端操作时,差异才会变得显着,因为打印是一项昂贵的操作。同样,当源是大文件上的流时,I/O 将主导操作。除非您进入完全不同的方向,例如使用内存映射并将此操作重构为在 ByteBuffers 上运行。

    【讨论】:

    • 我试图用CharBuffer 做类似的事情,但这要好得多!
    • 虽然我很欣赏这个答案并学到了一些东西,但我的一部分感觉有点......对从我的答案中完整提取的代码数量感到不安。我不确定这种感觉是否有任何价值,但至少我有机会表达它。 :)
    • @ErikE 答案命名并链接源,因此每个人都可以看到发生了哪些变化,哪些没有发生变化。好吧,无论如何,答案中已经解释了变化。这不就是这里发布代码的全部内容吗?希望有人用它来构建新的东西?
    • @Holger 我不知道。 yank 代码看起来不太好。也许我错了。也许它实际上应该是讨人喜欢的。
    • @ErikE 这应该很讨人喜欢。毕竟,您在这里发布 CC 下的代码,就是为了允许其重用。成千上万的读者中的一些人给了你一个赞成票,除了你很少知道谁在某个地方使用你的代码。所以你应该对这个额外的反馈感到高兴。我本可以编写一段看起来不同的类似代码,但为什么呢?我用这个答案来承认你的解决方案非常有用,我的额外想法并没有改变这种有用性。
    猜你喜欢
    • 2018-10-03
    • 2021-05-07
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2015-06-18
    相关资源
    最近更新 更多