【问题标题】:Java indexOf(蛮力方法)对我或其他一些子字符串算法更实用吗?(Would Java indexOf (brute force method) be more practical for me or some other substring algorithm?)
【发布时间】:2014-04-02 23:10:18
【问题描述】:

我正在寻找在许多短文本行(干草堆)中找到非常短的子字符串(模式、针)。但是,我不太确定在幼稚的蛮力方法之外使用哪种方法。

背景:我正在做一个有趣的附带项目,我收到多个用户的短信聊天日志(2000-15000 行文本和 2-50 个用户),我想找到所有各种模式匹配在基于我想出的预定词的聊天记录中。到目前为止,我正在寻找大约 1600 种模式,但我可能会寻找更多。

例如,我想找出在平均短信日志中使用的与食物相关的词的数量,例如“汉堡包”、“披萨”、“可乐”、“午餐”、“晚餐”、“餐厅” ”、“麦当劳”。虽然我给出了英语示例,但我实际上会在我的程序中使用韩语。这些指定的单词中的每一个都有自己的分数,我将其分别作为键和值放入哈希图中。然后,我会显示食物相关词的得分最高者以及这些用户最常使用的食物词。

我目前的方法是通过空格来消除每一行文本,并使用 haystack 包含模式的 contains 方法(使用 indexOf 方法和朴素的子字符串搜索算法)处理 haystack 中的每个单独的单词。

wordFromInput.contains(wordFromPattern);

举个例子,有 17 个用户在聊天,13000 行文本,1600 种模式,我发现使用这种方法整个程序需要 12-13 秒。而在我正在开发的 Android 应用上,处理需要 2 分 30 秒,这太慢了。

最初,我尝试使用哈希映射并仅获取模式而不是在 ArrayList 中搜索它,但后来我意识到这是...

not possible with hash table

我想用子字符串做什么。

我通过 Stackoverflow 环顾四周,发现了很多有用且相关的问题,例如这两个:

12。我比较熟悉各种字符串算法(Boyer Moore、KMP 等)

我最初认为天真的方法对于我的情况当然是最糟糕的算法类型,但是找到this question,我意识到我的情况(短模式,短文本)实际上可能更多用朴素的方法有效。但我想知道是否有什么我完全忽略了。

这里是snippet of my code,但如果有人想更具体地了解我的问题。

虽然我删除了大部分代码以简化它,但我用来实际匹配子字符串的主要方法是在 matchWords() 方法中。

我知道那是非常丑陋和糟糕的代码(5 个 for 循环...),所以如果对此有任何建议,我也很高兴听到。

所以要清理它:

  • 聊天记录中的文本行 (2000-10,000+),干草堆
  • 1600 多种图案、针头
  • 主要使用韩语字符,但也包含一些英文
  • 蛮力幼稚方法实在是太慢了,但正在讨论是否有其他替代方案,即使有,考虑到短模式和文本的性质,它们是否实用。

我只是想要一些关于我的思考过程的意见,可能还有一些一般性的建议。但另外,如果可能的话,我想对特定算法或方法提出一些具体建议。

【问题讨论】:

  • java.util.regex 如何适应?
  • 对不起,因为我删除了很多matchWords()之外的部分,有很多东西可能看起来有点混乱。但我在开始时使用了正则表达式来消除句子中的空格,这样我就可以处理单个单词。编辑:还是你建议我使用 java.util.regex?
  • 据我了解,这个问题可以说更适合程序员,因为它是一个白板讨论(而不是“为什么这段代码不起作用?”)。
  • 我想知道正则表达式是否是一个可能的解决方案,但我还没有完全考虑你的问题。如果一开始就被排除在外,我不想考虑太多。
  • 您是否考虑过使用像 en.wikipedia.org/wiki/… 这样的多模式匹配算法,或者为您的模式集构建一个通用的后缀树?

标签: java string algorithm substring


【解决方案1】:

你可以replace the hashtable with a Trie

将文本行拆分为单词,使用空格分隔单词。然后检查单词是否在 Trie 中。如果它在 Trie 中,则更新与该词关联的计数器。理想情况下,计数器将集成到 Trie 中。

这个方法是 O(C),其中 C 是文本中的字符数。您不太可能避免至少检查每个字符一次。因此,至少在大 O 方面,这种方法应该尽可能好。

但是,听起来您可能不想列出您正在搜索的所有可能的单词。因此,您可能只想简单地使用您可以从所有单词中构建一个计数 Trie。如果没有别的可能会使您使用的任何模式匹配算法更容易。虽然,它可能需要对 Trie 进行一些修改。

【问题讨论】:

  • 哈希表的复杂性与 trie 相同,至少概率很高(如果实施正确)。我认为问题在于 OP not 目前正在标记他的字符串,无论出于何种原因
  • 关于复杂性的公平点。虽然,我更多地考虑使用 Trie 更快地拒绝的可能性,但是检查每个字符的需要使它成为一个小的改进。当然,不进行标记化对于复杂性来说会是一个相当大的问题。
  • @Nuclear:既然你已经在做,我想知道你为什么不建议模式字符串的通用后缀树/Aho-Corasick 自动机,让您解决O(n) 中的查询,但使用真正的子字符串搜索(无标记化)。您还可以计算匹配的聚合,例如匹配计数或与匹配关联的总“分数”(就像 OP 在他的 O(p*n) 实现中所做的那样)。
  • 我不熟悉该算法,也不想花时间将算法扩展为在我编写它时几乎相同的东西,因为它不是很明显,这将是必要的,并且可能需要大量的空间成本,具体取决于它的实施方式。但是,仔细观察一下(在您发布之前不久),它似乎确实非常适合此目的。
【解决方案2】:

您所描述的内容听起来像是 Aho-Corasick string-matching algorithm 的绝佳用例。该算法在源字符串中查找一组模式字符串的所有匹配项,并在线性时间(加上报告匹配项的时间)内完成。如果您有一组固定的字符串要搜索,您可以预先对模式进行线性预处理以非常快速地搜索所有匹配项。

这里有一个Java implementation of Aho-Corasick。我还没试过,但它可能是一个很好的匹配。

希望这会有所帮助!

【问题讨论】:

    【解决方案3】:

    我很确定 string.contains 已经高度优化,所以用其他东西替换它对你没有多大好处。

    所以,我怀疑,要走的路不是在你的聊天词中寻找每一个银行词,而是一次进行多次比较。

    第一种方法是创建一个巨大的正则表达式来匹配你所有的银行字。编译它并希望正则表达式包足够高效(机会是 - 它是)。您将有一个相当长的设置阶段(正则表达式编译),但匹配应该快得多。

    【问题讨论】:

    • 我一直很害怕使用正则表达式,因为我不太熟悉它。此外,我有点担心性能可能不会显着提高。
    • 好吧,正则表达式非常简单 - 如果您的银行由“红色”、“蓝色”和“苹果”组成,那么您的正则表达式应该是 red|blue|apple
    • 哦,在你尝试之前你不会知道性能。所以试试看吧。
    • @zmbq:它针对其用例进行了优化,但不适用于 OP 用例,您希望在字符串中找到多个模式
    • 一旦你找到一个模式,你可以再次查看字符串的其余部分 - 甚至可以使用旧的较慢的方式,因为模式通常不会被发现。
    【解决方案4】:

    您可以为需要匹配的单词建立一个索引,并在处理它们时对其进行计数。如果您可以使用 HashMap 来查找每个单词的模式,则成本将为 O(n * m)

    您可以对所有可能的词使用 HashMap,然后可以稍后剖析这些词。

    例如说你需要匹配red和apple,你可以组合起来

    redapple = 1
    applered = 0
    red = 10
    apple = 15
    

    这意味着红色实际上是11(10 + 1),而苹果是16(15 + 1)

    【问题讨论】:

    • 我有点困惑。所以我需要匹配的单词来自文本。因此,在这种情况下,red 和 apple 来自用户文本(haystack),而不是模式(needle)。但是从我必须从一个句子中制作的所有单词组合中进行缩放,这不是很困难吗?我可能误解了你的回答。
    • @Nopiforyou 你只需要你看到的所有单词,然后你对这些匹配进行模式匹配。那么你只需要考虑唯一词的数量,而不是所有的词。
    【解决方案5】:

    我不懂韩语,所以我想用韩语修改字符串的策略不一定像英语那样可行,但也许这种伪代码策略可以用你的韩语知识应用到让它起作用。 (Java当然还是一样,但是例如,在韩语中,字母“ough”是否仍然很有可能是连续的?甚至还有字母“ough”吗?但是话虽如此,但希望原理可以应用

    我会使用 String.toCharArray 创建一个二维数组(如果需要可变大小,则使用 ArrayList)。

    if (first letter of word matches keyword's first letter)//we have a candidate
        skip to last letter of the current word //see comment below
        if(last letter of word matches keyword's last letter)//strong candidate
            iterate backwards to start+1 checking remainder of letters
    

    我建议跳到最后一个字母的原因是,从统计上来说,一个单词的前两个字母的“辅音、元音”非常高,尤其是名词,因为任何食物都是名词(您给出的几乎所有关键字示例都与辅音元音的结构相匹配)。而且由于只有 5 个元音(加 y),第二个字母“i”出现在关键字“pizza”中的可能性本来就很高,但在那之后,这个词仍然很有可能变成不匹配。

    但是,如果您知道第一个字母和最后一个字母匹配,那么您可能有一个更强大的候选者,然后可以反向迭代。我认为在更大的数据集上,这将比按顺序检查字母更快地消除候选人。基本上你会让太多的假候选人通过第二次迭代,从而增加你的整体条件操作。这听起来可能很小,但在这样的项目中,有很多重复,所以微优化会很快积累。

    如果这种方法可以在结构上可能与英语非常不同的语言中应用(尽管我在这里说的是无知),那么我认为它可能会为您提供一些效率,无论您是否通过迭代 char 数组来实现它或使用扫描仪,或任何其他构造。

    【问题讨论】:

    • 我认为这种优化会受到韩语的限制,尽管它可能适用于英语。结尾的很多字符,尤其是动词,很难区分单词本身。
    【解决方案6】:

    诀窍是要意识到,如果您可以将正在搜索的字符串描述为正则表达式,那么根据定义,您也可以使用状态机来描述它。

    在消息中的每个字符处,为 1600 个模式中的每一个启动一个状态机,并将字符传递给它。这听起来很可怕,但相信我,它们中的大多数无论如何都会立即终止,所以你并没有真正做大量的工作。请记住,状态机通常可以在每一步使用简单的开关/外壳或ch == s.charAt 进行编码,因此它们接近于轻量级。

    显然,当您的一台搜索机器在搜索结束时终止时,您知道该怎么做。任何在完全匹配之前终止的都可以立即丢弃。

    private static class Matcher {
        private final int where;
        private final String s;
        private int i = 0;
    
        public Matcher ( String s, int where ) {
            this.s = s;
            this.where = where;
        }
    
        public boolean match(char ch) {
            return s.charAt(i++) == ch;
        }
    
        public int matched() {
            return i == s.length() ? where: -1;
        }
    }
    
    // Words I am looking for.
    String[] watchFor = new String[] {"flies", "like", "arrow", "banana", "a"};
    // Test string to search.
    String test = "Time flies like an arrow, fruit flies like a banana";
    
    public void test() {
        // Use a LinkedList because it is O(1) to remove anywhere.
        List<Matcher> matchers = new LinkedList<> ();
        int pos = 0;
        for ( char c : test.toCharArray()) {
            // Fire off all of the matchers at this point.
            for ( String s : watchFor ) {
                matchers.add(new Matcher(s, pos));
            }
            // Discard all matchers that fail here.
            for ( Iterator<Matcher> i = matchers.iterator(); i.hasNext(); ) {
                Matcher m = i.next();
                // Should it be removed?
                boolean remove = !m.match(c);
                if ( !remove ) {
                    // Still matches! Is it complete?
                    int matched = m.matched();
                    if ( matched >= 0 ) {
                        // Todo - Should use getters.
                        System.out.println("    "+m.s +" found at "+m.where+" active matchers "+matchers.size());
                        // Complete!
                        remove = true;
                    }
                }
                // Remove it where necessary.
                if ( remove ) {
                    i.remove();
                }
            }
            // Step pos to keep track.
            pos += 1;
        }
    }
    

    打印

    flies found at 5 active matchers 6
    like found at 11 active matchers 6
    a found at 16 active matchers 2
    a found at 19 active matchers 2
    arrow found at 19 active matchers 6
    flies found at 32 active matchers 6
    like found at 38 active matchers 6
    a found at 43 active matchers 2
    a found at 46 active matchers 3
    a found at 48 active matchers 3
    banana found at 45 active matchers 6
    a found at 50 active matchers 2
    

    有几个简单的优化。通过一些简单的预处理,最明显的是使用当前字符来确定可能适用的匹配器。

    【问题讨论】:

    • 更有趣的是实际上一次匹配所有模式的 DFA。这正是Aho-Corasick 自动机所做的。
    • 此外,您的实现必须为每个模式管理多达 O(m) 匹配器,其中 m 是模式大小。所以你基本上做一个O(n*m)字符串匹配一个非常高的常数因子(很多分配),而O(n)匹配很容易做到(例如使用String.contains)。你需要在自动机中构建一个失败函数,这样你至少每个模式只需要一个(如果实施得好,这会给你 Knuth-Morris-Pratt 算法)
    • @NiklasB。 - 你是对的 - 有更好的算法。这种实现并不是最优的,它主要是为了演示将每个字符呈现给匹配器嵌套的技术,而不是要求每个匹配器依次搜索文本。
    【解决方案7】:

    这是一个相当宽泛的问题,所以我不会详细介绍,但大致如下:

    使用诸如广泛的lemmatizer 之类的东西对干草堆进行预处理,以创建消息的“仅主题词”版本,方法是注意其中的所有词都涵盖了哪些主题。例如,“汉堡包”、“披萨”、“可乐”、“午餐”、“晚餐”、“餐厅”或“麦当劳”的任何出现都会导致为该消息收集“主题”词“食物” .有些词可能有多个主题,例如“麦当劳”可能在主题“食物”和“商业”中。大多数单词没有任何主题。

    在此过程之后,您将拥有仅包含“主题”字词的干草堆。然后创建一个Map&lt;String, Set&lt;Integer&gt;&gt; 并用主题词和包含它的聊天消息ID 集填充它。这是包含它的聊天消息的主题词的reverse index

    查找包含所有 n 个单词的所有文档的运行时代码非常简单且超快 - 接近 O(#terms):

    private Map<String, Set<Integer>> index; // pre-populated
    
    Set<Integer> search(String... topics) {
        Set<Integer> results = null;
        for (String topic : topics) {
            Set<Integer> hits = index.get(topic);
            if (hits == null)
                return Collections.emptySet();
            if (results == null)
                results = new HashSet<Integer>(hits);
            else
                results.retainAll(hits);
            if (results.isEmpty())
                return Collections.emptySet(); // exit early
        }
        return results;
    }
    

    这将在 O(1) 附近执行,并且告诉你哪些消息共享所有搜索词。如果您只想要数字,请使用返回的Set 的微不足道的size()

    【问题讨论】: