【问题标题】:The fastest C++ algorithm for string testing against a list of predefined seeds (case insensitive)针对预定义种子列表进行字符串测试的最快 C++ 算法(不区分大小写)
【发布时间】:2016-09-27 07:50:59
【问题描述】:

我有种子字符串列表,大约 100 个预定义字符串。所有字符串都只包含 ASCII 字符。

std::list<std::wstring> seeds{ L"google", L"yahoo", L"stackoverflow"};

我的应用不断收到大量可以包含任何字符的字符串。我需要检查每条收到的线并确定它是否包含任何种子。比较必须不区分大小写。

我需要最快的算法来测试收到的字符串。

现在我的应用使用这个算法:

std::wstring testedStr;
for (auto & seed : seeds)
{
    if (boost::icontains(testedStr, seed))
    {
        return true;
    }
}
return false;

效果很好,但我不确定这是不是最有效的方法。

如何实现算法以获得更好的性能?

这是一个 Windows 应用程序。应用接收到有效的std::wstring 字符串。


更新

对于这个任务,我实现了 Aho-Corasick 算法。如果有人可以查看我的代码,那就太好了——我对此类算法没有丰富的经验。实现链接:gist.github.com

【问题讨论】:

  • 作为一个微小的改进,我建议将std::list 替换为一个数组(普通数组,std::arraystd::vector)。它可能会稍微提高性能。另外,为什么你只在其中一个文字上有 L 前缀?
  • 它们包含种子还是种子?有区别
  • 你试过std::search发现不够快吗?
  • 种子列表是在编译时知道的,还是只在运行时才知道?
  • 值得注意的是:您很可能不需要需要最快的算法。我们经常为“最快”的算法付出血和泪的代价,而“快”就足够了。 “最快”算法必须包括管理特定 CPU 上的缓存、不同操作码的运行速度等内容,并根据您正在查看的特定单词集以及单词出现的相对概率进行定制。这些细节很痛苦。使用下面的“快速”算法 =)

标签: c++ string windows algorithm


【解决方案1】:

作为 DarioOO 答案的变体,您可以通过为字符串编码 lex parser 来获得可能更快的正则表达式匹配实现。虽然通常与yacc 一起使用,但在这种情况下,lex 自己完成了这项工作,而 lex 解析器通常非常高效。

如果所有字符串都很长,这种方法可能会失败,因为 Aho-CorasickCommentz-WalterRabin-Karp 等算法可能会提供显着改进,我怀疑 lex 实现是否使用任何此类算法。

如果您必须能够在不重新配置的情况下配置字符串,这种方法会更难,但由于 flex 是开源的,您可以蚕食它的代码。

【讨论】:

    【解决方案2】:

    如果匹配字符串的数量有限,这意味着您可以构造一棵树,这样,从根到叶读取,相似的字符串将占据相似的分支。

    这也称为trie, or Radix Tree

    例如,我们可能有字符串cat, coach, con, conchdark, dad, dank, do。他们的尝试可能如下所示:

    搜索树中的一个词将从根开始搜索树。使其成为叶子将对应于与种子的匹配。无论如何,字符串中的每个字符都应该与它们的一个孩子匹配。如果没有,您可以终止搜索(例如,您不会考虑任何以“g”开头的单词或任何以“cu”开头的单词)。

    有多种算法可用于构建树以及搜索它以及动态修改它,但我想我会给出解决方案的概念概述而不是具体的概述,因为我不知道最好的算法。

    从概念上讲,您可以用来搜索树的算法与 基数排序 背后的思想有关,即字符串中的字符在给定条件下可能具有的固定数量的类别或值时间点。

    这让您可以对照您的word-list 检查一个字。由于您正在寻找此单词列表作为输入字符串的子字符串,因此它的功能远不止于此。

    编辑:正如其他答案所提到的,用于字符串匹配的 Aho-Corasick 算法是一种用于执行字符串匹配的复杂算法,由一个带有附加链接的特里树组成,用于通过树获取“快捷方式”并有不同的搜索模式来伴随这一点。 (有趣的是,Alfred Aho 还是流行的编译器教科书编译器:原理、技术和工具以及算法教科书计算机的设计与分析算法。他也是贝尔实验室的前成员。Margaret J. Corasick 似乎没有太多关于她自己的公开信息。)

    【讨论】:

    • 对我的最佳回答 :) 它部分展示了如何实现正则表达式,这就是 OP 最终需要的(尽管他不需要全部正则表达式功能)
    • 由于 OP 希望将这些单词作为更长(多单词?)字符串的子字符串来查找,我不确定循环遍历每个可能的起始字符或单词是否会像其他单词一样有效选项。 (在现实生活中,SIMD 向量对于这样的东西来说很重要。)尽管正如@DarioOO 所说,可能构建一个匹配任何单词的正则表达式,并仅在它发生变化时将其提供给正则表达式库,这将提供快速模式匹配这样的算法。
    • @PeterCordes 我们可以通过将字符循环替换为直接跳转(一种快速字符散列)来稍微改进算法。可能一些正则表达式库已经这样做了。
    • 不清楚这是否是最佳解决方案:这里有两个问题 (1) 没有提到检查 containment 的理论复杂性(不仅仅是匹配)和 (2)读取的分散性质对缓存不友好。
    • Aho-Corasick 是一个带有内部链接的 trie,允许您不必在字符串中的每个位置从根重新搜索。根据我的经验,它比具有大量搜索字符串的天真的 trie 快得多(1.5 倍)。
    【解决方案3】:

    编辑:正如 Matthieu M. 指出的那样,OP 询问字符串是否包含关键字。我的答案仅在字符串等于关键字或您可以拆分字符串时才有效,例如由空格字符。

    特别是有大量可能的候选者并在编译时使用perfect hash function 和 gperf 之类的工具了解它们值得一试。主要原则是,你用你的种子播种一个生成器,它会生成一个包含哈希函数的函数,该函数对所有种子值都没有冲突。在运行时,你给函数一个字符串,它会计算哈希值,然后检查它是否是与哈希值对应的唯一可能的候选者。

    运行时成本是对字符串进行哈希处理,然后与唯一可能的候选对象(种子大小为 O(1),字符串长度为 O(1))进行比较。

    要使比较不区分大小写,您只需在种子和字符串上使用 tolower。

    【讨论】:

    • 请注意,Perfect Hash 通常假设输入是预先确定的种子的一部分,但这里不是这种情况。因此,非种子和种子的哈希之间可能会发生冲突。哦,这仅适用于完整匹配,不适用于 containment
    • @MatthieuM。你说的对。我没有仔细阅读“包含”这个词,因为我被标题误导了。 gperf 已经在内部处理了非种子(很可能是通过将字符串与唯一可能的种子进行比较。
    • 好吧,如果您将“完美”替换为“滚动”,并不会损失太多。然后是 Rabin-Karp,它表现良好。我不确定它与 Aho-Corasick 相比如何,从未使用过后者。形式上,交流电更快,但这并不意味着很多。它也非常复杂,有很多对缓存不友好的操作。我会赌我的钱或拉宾卡普。
    • 完美的滚动哈希应该是可能的。
    【解决方案4】:

    因为字符串的数量不大(~100),你可以使用下一个算法:

    1. 计算您拥有的单词的最大长度。让它成为 N。
    2. 创建int checks[N]; 校验和数组。
    3. 我们的校验和将是搜索短语中所有字符的总和。因此,您可以为列表中的每个单词计算此类校验和(在编译时已知),并创建std::map&lt;int, std::vector&lt;std::wstring&gt;&gt;,其中int 是字符串的校验和,向量应包含具有该校验和的所有字符串。 为每个长度(最多 N)创建此类映射的数组,也可以在编译时完成。
    4. 现在通过指针移动大字符串。当指针指向 X 字符时,您应该将 X 字符的值添加到所有 checks 整数中,并且对于它们中的每一个(从 1 到 N 的数字)删除 (XK) 字符的值,其中 K 是 @987654325 中的整数数@ 大批。因此,对于存储在 checks 数组中的所有长度,您将始终拥有正确的校验和。 在地图上搜索之后,是否存在具有这种对(长度和校验和)的字符串,如果存在 - 比较它。

    它应该给出假阳性结果(当校验和和长度相等,但短语不相等时)非常罕见。

    所以,假设 R 是大字符串的长度。然后循环它需要 O(R)。 每一步你都会用“+”小数(char值)执行N次操作,用“-”小数(char值)执行N次操作,非常快。每一步你都必须在checks数组中搜索计数器,也就是O(1),因为它是一个内存块。

    此外,每一步你都必须在 map 的数组中找到 map,这也是 O(1),因为它也是一个内存块。 在 map 中,您必须搜索具有正确校验和 log(F) 的字符串,其中 F 是 map 的大小,它通常包含不超过 2-3 个字符串,所以我们通常可以假装它也是 O (1).

    您还可以检查,如果没有具有相同校验和的字符串(只有 100 个单词的可能性很大),您可以完全丢弃 map,存储对而不是 map。

    所以,最后应该给出 O(R),并且 O 非常小。 这种计算checksum 的方式可以更改,但它非常简单且完全快速,极少出现假阳性反应。

    【讨论】:

    • 这是一个有趣的想法,但我认为它不适用于许多不同长度的搜索字符串。也许用一个 6 字符的滑动窗口或其他东西来找到所有长度为 6 或更长的字符串的候选前缀?
    • @PeterCordes:为什么是 6?当然,这是一个有趣的概念,尽管我想知道长度的影响。我可能会尝试使用种子的最小长度(你是这样推导出 6 的吗?)。
    • @MatthieuM.:纯属猜测,但是是的,基于种子的长度。如果最小种子长度大于 6,绝对使用它。我认为 5 或 4 可能会产生太多误报,导致memcmp。如果我们需要匹配短字符串,可能需要一个单独的短字符串滑动窗口,所以桶更有可能是空的?或者只是使用完全不同的策略处理短字符串,例如传统的正则表达式样式匹配。较长字符串的滑动窗口将使模式保持简单。
    • 作为 O(N*M),其中N 是长度,M 是字符串计数,这不会很好地扩展。我认为我们可以通过一些分而治之的方式来实现对数加速?如果之前的过滤器失败了,我们只需要准确地计算一个值。
    • @PeterCordes 带有一个滑动窗口的想法非常酷,可以将 O(N * M) 减少到 O(M)。但是因为每个滑动窗口计算几乎立即执行(移动指针,+/- 小数),并且短语的数量约为 100,我们可以将 O(N * M) 计算为 O(M),之前的常数非常小,即使使用各种窗口,无论如何,Victor 可以轻松地尝试两者并比较他的数据。
    【解决方案5】:

    您应该尝试使用预先存在的正则表达式实用程序,它可能比您的手动算法慢,但正则表达式是关于匹配多种可能性,因此它可能已经比哈希图或简单的比较快几倍所有字符串。我相信 regex 实现可能已经使用了 RiaD 提到的 Aho–Corasick 算法,所以基本上你将拥有一个经过良好测试且快速的实现。

    如果你有 C++11,你已经有一个标准的正则表达式库

    #include <string>
    #include <regex>
    
    int main(){
         std::regex self_regex("google|yahoo|stackoverflow");
         regex_match(input_string ,self_regex);
    }
    

    我希望这会生成尽可能好的最小匹配树,所以我希望它非常快(并且可靠!)

    【讨论】:

    • 如果您有多个具有相同(长)前缀的候选并且字符串是最后一个或根本不在字典中,至少我知道的正则表达式实现表现不佳。对于常见的正则表达式实现,运行时间将介于 O(N) 和 O(N^2) 之间,因为这取决于种子。
    • 虽然 C++ 库的不同实现可以包括对此类用例的支持,但规范中并不能保证。如果用例纯粹是为了搜索固定的单词列表,并且效率是最重要的,那么最好直接使用实现 Aho-Corasick 算法的库,而不是依赖可能甚至可能无法实现的正则表达式引擎中的优化.
    • 我不希望标准库使用通用实现,我希望他们使用最好的算法并且他们的支持会随着时间变得更好,当然,因为 OP 想要性能,他会进行分析和测试各种解决方案。如果&lt;regex&gt; 对我产生良好的性能是最好的选择,因为它可以在未来更好地扩展(简单地添加更多的单词甚至是匹配单词的新规则)。我在少数地方使用了标准正则表达式,它已经比许多手工库更快。委员会从不强制要求“明确的算法”,而是施加了许多限制。
    • Rust regex 库使用 Aho-Corasick(以及许多其他技巧),但我不确定 C++ 实现是否这样做。尽管如此,这个答案仍然具有 simple 的优点,让我们面对现实吧,OP 无论如何都是从列表开始的......
    • Regex 可能是这个问题的次优解决方案。对于 prototype 来说还不错,但是 OP 要求“最快”,而 regex 很少是 除非 你遇到了这个特定的 regex 库有大量的情况优化。运行时 regex 库必须编译 regex 表达式,这需要一些时间,而且也不符合“最快”的条件。我将说明您的第一遍应该是使用std::regex,并确定它是否足够快,并使用它来检查正确性并将速度与您的手工优化进行比较(如果需要)。
    【解决方案6】:

    您可以使用Aho–Corasick algorithm

    它构建了特里/自动机,其中一些顶点标记为终端,这意味着字符串有种子。

    它内置于O(sum of dictionary word lengths) 并在O(test string length) 中给出答案

    优点:

    • 它特别适用于几个字典单词,检查时间不取决于单词的数量(如果我们不考虑它不适合记忆的情况等)
    • 算法不难实现(至少与后缀结构相比)

    如果是 ASCII,您可以通过降低每个符号来使其不区分大小写(非 ASCII 字符无论如何都不匹配)

    【讨论】:

    • 是的,可能是最快的方法。 AFAIK,这在防病毒软件中用于测试文件的病毒签名。
    • 缓存性能如何?我不确定在缓存位置方面有多少好的尝试——在一般的树中。
    • @RiaD 感谢您的帮助!我在 C++ 上实现了 AhoCorasick。它运行良好且快速))gist.github.com/Mezrin/07a5495e2cbb72bf5e68b3257b38b7ba。如果您可以查看代码,那就太好了))
    【解决方案7】:

    一种更快的方法是使用后缀树https://en.wikipedia.org/wiki/Suffix_tree,但是这种方法有很大的缺点——数据结构困难,构造困难。该算法允许从线性复杂度的字符串构建树https://en.m.wikipedia.org/wiki/Ukkonen%27s_algorithm

    【讨论】:

    • 解决性能和他的要求的唯一答案。根据字符串列表查找任何子字符串。这就是搜索引擎所做的!
    猜你喜欢
    • 2010-09-17
    • 2018-12-09
    • 2012-02-29
    • 2021-11-04
    • 2010-09-05
    • 1970-01-01
    • 2013-11-05
    • 2014-07-07
    • 2013-03-14
    相关资源
    最近更新 更多