什么是最快的子串搜索算法?

时间:2010-07-06 04:58:28

标签: c algorithm string substring

好的,所以我听起来不像白痴我会更明确地陈述问题/要求:

  • Needle(pattern)和haystack(要搜索的文本)都是C样式的以null结尾的字符串。没有提供长度信息;如果需要,必须计算。
  • 函数应返回指向第一个匹配项的指针,如果未找到匹配项,则返回NULL
  • 不允许出现失败案例。这意味着具有非常量(或大常量)存储要求的任何算法都需要具有分配失败的后备情况(并且后备维护中的性能因此导致最坏情况的性能)。
  • 实现是在C中,虽然没有代码的算法(或链接到这样的)的良好描述也很好。

......以及我所说的“最快”:

  • 确定性O(n)其中n = haystack长度。 (但是如果它们与更强大的算法相结合以提供确定性O(nm)结果,则可以使用通常为O(n)的算法(例如滚动哈希)的想法。
  • 从不执行(可测量; if (!needle[1])的几个时钟等等)比天真蛮力算法更差,特别是在非常短的针上,这可能是最常见的情况。 (无条件的重预处理开销是不好的,因为试图以可能的针头为代价来改善病理针的线性系数。)
  • 与任何其他广泛实施的算法相比,任意针和干草堆,具有相当或更好的性能(搜索时间不会超过50%)。
  • 除了这些条件之外,我将保留“最快”开放式的定义。一个好的答案应该解释为什么你认为你建议“最快”的方法。

我目前的实现比glibc的双向实现大约慢10%和8倍(取决于输入)。

更新:我目前的最佳算法如下:

  • 对于长度为1的针,请使用strchr
  • 对于长度为2-4的针,使用机器字一次比较2-4个字节,如下所示:使用16位或32位整数预加载针,并从干草堆中循环旧字节输出/新字节输入在每次迭代。大海捞针的每个字节都只读取一次,并对0(字符串结束)和一个16位或32位比较进行检查。
  • 对于长度> 4的针,使用具有错误移位表的双向算法(如Boyer-Moore),该移位表仅应用于窗口的最后一个字节。为了避免初始化1kb表的开销,这对于许多中等长度的针来说是一个净损失,我保留一个位数组(32字节)标记移位表中的哪些条目被初始化。未设置的位对应于从不出现在针中的字节值,可以进行全针长度移位。

我脑海中留下的重大问题是:

  • 有没有办法更好地利用坏班次表? Boyer-Moore通过向后扫描(从右向左)充分利用它,但是双向扫描需要从左向右扫描。
  • 我发现的一般案例(没有内存或二次性能条件)的唯一两个可行的候选算法是Two-WayString Matching on Ordered Alphabets。但是,是否存在易于检测的情况,其中不同的算法将是最佳的?当然,空间算法中的许多O(m)(其中m是针长)可以用于m<100左右。如果针对针的简单测试可能只需要线性时间,那么也可以使用最坏情况二次算法。

奖励积分:

  • 假设针和干草堆都是结构良好的UTF-8,你能提高性能吗? (对于具有不同字节长度的字符,良好的形式在针和haystack之间强加了一些字符串对齐要求,并且当遇到不匹配的头字节时允许自动2-4字节移位。但是这些约束是否会超出你的范围最大后缀计算,良好的后缀转换等已经为您提供了各种算法?)

注意:我很清楚那里的大多数算法,而不是它们在实践中表现得如何。这是一个很好的参考,所以人们不会继续给我作为评论/答案的算法参考:http://www-igm.univ-mlv.fr/~lecroq/string/index.html

18 个答案:

答案 0 :(得分:35)

建立一个可能针和干草堆的测试库。在几种搜索算法上描述测试,包括暴力。选择最适合您数据的那个。

Boyer-Moore使用带有良好后缀表的错误字符表。

Boyer-Moore-Horspool使用错误的字符表。

Knuth-Morris-Pratt使用部分匹配表。

Rabin-Karp使用正在运行的哈希值。

它们都是交易开销,以减少不同程度的比较,因此现实世界的表现将取决于针和干草堆的平均长度。初始开销越多,输入越长越好。如果针头很短,可能会有蛮力。

编辑:

不同的算法可能最适合查找碱基对,英语短语或单个单词。如果所有输入都有一个最佳算法,那么它就会被公开。

想想下面的小桌子。每个问号可能有不同的最佳搜索算法。

                 short needle     long needle
short haystack         ?               ?
long haystack          ?               ?

这应该是一个图表,每个轴上有一系列较短到较长的输入。如果您在这样的图表上绘制每个算法,则每个算法都会有不同的签名。一些算法在模式中遭受大量重复,这可能会影响搜索基因等用途。影响整体性能的其他一些因素是不止一次搜索相同的模式并同时搜索不同的模式。

如果我需要一个样本集,我想我会抓一个像谷歌或维基百科这样的网站,然后从所有结果页面中删除html。对于搜索网站,键入单词然后使用建议的搜索短语之一。如果适用,请选择几种不同的语言。使用网页,所有文本都是短到中等,因此合并足够的页面以获得更长的文本。您还可以找到公共领域书籍,法律记录和其他大型文本。或者只是通过从字典中挑选单词来生成随机内容。但分析的目的是测试您将要搜索的内容类型,因此如果可能,请使用真实世界的样本。

我离开了很长很长的模糊。对于针,我认为短8个字符以下,中等不到64个字符,长到1k以下。对于大海捞针,我认为短于2 ^ 10,中等到2 ^ 20,长到2 ^ 30个字符。

答案 1 :(得分:24)

2011年发布,我相信很可能是Dany Breslauer,Roberto Grossi和Filippo Mignosi的"Simple Real-Time Constant-Space String Matching"算法。

更新

2014年,作者发表了这一改进:Towards optimal packed string matching

答案 2 :(得分:23)

http://www-igm.univ-mlv.fr/~lecroq/string/index.html 链接你指向的是 一些最知名和研究的优秀来源和摘要 字符串匹配算法。

大多数搜索问题的解决方案都涉及到 关于预处理开销,时间和方面的权衡 空间要求。没有单身 算法在所有情况下都是最优或实用的。

如果你的目标是为字符串搜索设计一个特定的算法,那么忽略它 我要说的其余部分,如果你想开发一个通用的字符串搜索服务 例程然后尝试以下:

花一些时间回顾一下这些特定的优点和缺点 您已经引用的算法。进行 审查的目的是找到一套 涵盖字符串搜索范围和范围的算法 然后,基于分类器构建前端搜索选择器 用于定位给定输入的最佳算法。这样你就可以 采用最有效的算法来完成这项工作。这是特别的 当算法非常适合某些搜索但降级很差时有效。对于 例如,对于长度为1的针,蛮力可能是最好的 随着针长度的增加,快速降低,sustik-moore algoritim可能变得更有效(超过小字母),然后对于更长的针和更大的字母,KMP或Boyer-Moore算法可能会更好。这些只是举例说明可能的策略。

多算法方法不是一个新想法。我相信它已被少数人聘用 商业排序/搜索包(例如,大型机上常用的SYNCSORT实现 几种排序算法,并使用启发式方法为给定的输入选择“最佳”算法

每种搜索算法都有几种变体 可以对其表现产生重大影响,因为, 例如,这个paper说明了。

对您的服务进行基准测试,以便对需要其他搜索策略或更有效的区域进行分类 调整你的选择器功能。这种方法不是快速或简单,但如果 做得好可以产生很好的效果。

答案 3 :(得分:18)

我很惊讶地看到本次讨论中引用的技术报告;我是上面名为Sustik-Moore的算法的作者之一。 (我们在论文中没有使用该术语。)

我想在此强调一点,对我而言,该算法最有趣的特点是证明每个字母最多只检查一次非常简单。对于早期的Boyer-Moore版本,他们证明每个字母最多检查3次,最多检查2次,并且这些证据更多涉及(参见纸上的引用)。因此,我也看到了提出/研究这种变体的教学价值。

在本文中,我们还描述了在放松理论保证的同时针对效率的进一步变化。这是一篇简短的论文,在我看来,普通高中毕业生应该可以理解这些材料。

我们的主要目标是将此版本引入其他可以进一步改进的版本。字符串搜索有很多变化,我们无法想到所有这些想法可以带来好处的地方。 (固定文本和更改模式,固定模式不同文本,预处理可能/不可能,并行执行,在大文本中查找匹配子集,允许错误,接近匹配等等)

答案 4 :(得分:14)

最快的子字符串搜索算法将取决于上下文:

  1. 字母大小(例如DNA与英文)
  2. 针长
  3. 2010年的论文"The Exact String Matching Problem: a Comprehensive Experimental Evaluation"为51个算法(不同的字母大小和针长)提供了运行时表,因此您可以为您的上下文选择最佳算法。

    所有这些算法都有C实现,以及测试套件,这里:

    http://www.dmi.unict.it/~faro/smart/algorithms.php

答案 5 :(得分:4)

一个非常好的问题。只需添加一些小块......

  1. 有人在谈论DNA序列匹配。但是对于DNA序列,我们通常做的是为大海捞针建立一个数据结构(例如后缀数组,后缀树或FM索引)并匹配许多针对它。这是一个不同的问题。

  2. 如果有人想要对各种算法进行基准测试,那真的很棒。压缩和后缀数组的构造有很好的基准,但我还没有看到字符串匹配的基准。潜在的干草堆候选人可以来自SACA benchmark

  3. 几天前我正在测试来自the page you recommended的Boyer-Moore实现(编辑:我需要像memmem()这样的函数调用,但它不是标准函数,所以我决定实现它)。我的基准测试程序使用随机干草堆。似乎该页面中的Boyer-Moore实现比glibc的memmem()和Mac的strnstr()快一些。如果您感兴趣,则实施为here,基准代码为here。这绝对不是一个现实的基准,但它是一个开始。

答案 6 :(得分:4)

我知道这是一个老问题,但大多数糟糕的班次表都是单一字符。如果它对您的数据集有意义(例如,特别是如果它是书面文字),并且如果您有可用空间,则可以通过使用由n-gram而不是单个字符组成的错误移位表来获得显着的加速。

答案 7 :(得分:3)

这是Python's search implementation,从整个核心使用。评论表明它使用压缩的boyer-moore delta 1表

我自己进行了一些非常广泛的字符串搜索实验,但这是针对多个搜索字符串的。对于低模式计数,HorspoolBitap的程序集实现通常可以针对Aho-Corasick等算法保留自己的实现。

答案 8 :(得分:3)

更快“搜索单个匹配字符”(ala strchr)算法。

重要说明:

  • 这些函数使用“编号/计数(前导|尾随)”gcc编译器内在 - __builtin_ctz。这些函数可能只在具有执行此操作的指令的机器上很快(即x86,ppc,arm)。

  • 这些函数假设目标体系结构可以执行32位和64位未对齐的加载。如果您的目标体系结构不支持此功能,则需要添加一些启动逻辑以正确对齐读取。

  • 这些功能是处理器中立的。如果目标CPU具有向量指令,则可能(更好)做得更好。例如,下面的strlen函数使用SSE3,并且可以通过简单修改将XOR扫描的字节进行异或,以查找0以外的字节。在运行Mac OS X 10.6(x86_64)的2.66GHz Core 2笔记本电脑上执行的基准测试:

    • strchr
    • 的843.433 MB / s
    • 2656.742 MB / s findFirstByte64
    • strlen
    • 的13094.479 MB / s

... 32位版本:

#ifdef __BIG_ENDIAN__
#define findFirstZeroByte32(x) ({ uint32_t _x = (x); _x = ~(((_x & 0x7F7F7F7Fu) + 0x7F7F7F7Fu) | _x | 0x7F7F7F7Fu); (_x == 0u)   ? 0 : (__builtin_clz(_x) >> 3) + 1; })
#else
#define findFirstZeroByte32(x) ({ uint32_t _x = (x); _x = ~(((_x & 0x7F7F7F7Fu) + 0x7F7F7F7Fu) | _x | 0x7F7F7F7Fu);                    (__builtin_ctz(_x) + 1) >> 3; })
#endif

unsigned char *findFirstByte32(unsigned char *ptr, unsigned char byte) {
  uint32_t *ptr32 = (uint32_t *)ptr, firstByte32 = 0u, byteMask32 = (byte) | (byte << 8);
  byteMask32 |= byteMask32 << 16;
  while((firstByte32 = findFirstZeroByte32((*ptr32) ^ byteMask32)) == 0) { ptr32++; }
  return(ptr + ((((unsigned char *)ptr32) - ptr) + firstByte32 - 1));
}

...和64位版本:

#ifdef __BIG_ENDIAN__
#define findFirstZeroByte64(x) ({ uint64_t _x = (x); _x = ~(((_x & 0x7F7F7F7F7f7f7f7full) + 0x7F7F7F7F7f7f7f7full) | _x | 0x7F7F7F7F7f7f7f7full); (_x == 0ull) ? 0 : (__builtin_clzll(_x) >> 3) + 1; })
#else
#define findFirstZeroByte64(x) ({ uint64_t _x = (x); _x = ~(((_x & 0x7F7F7F7F7f7f7f7full) + 0x7F7F7F7F7f7f7f7full) | _x | 0x7F7F7F7F7f7f7f7full);                    (__builtin_ctzll(_x) + 1) >> 3; })
#endif

unsigned char *findFirstByte64(unsigned char *ptr, unsigned char byte) {
  uint64_t *ptr64 = (uint64_t *)ptr, firstByte64 = 0u, byteMask64 = (byte) | (byte << 8);
  byteMask64 |= byteMask64 << 16;
  byteMask64 |= byteMask64 << 32;
  while((firstByte64 = findFirstZeroByte64((*ptr64) ^ byteMask64)) == 0) { ptr64++; }
  return(ptr + ((((unsigned char *)ptr64) - ptr) + firstByte64 - 1));
}

编辑2011/06/04 OP在评论中指出此解决方案存在“难以逾越的错误”:

  

它可以读取搜索到的字节或空终止符,它可以在没有读取权限的情况下访问未映射的页面或页面。你不能在字符串函数中使用大型读取,除非它们是对齐的。

这在技术上是正确的,但几乎适用于任何大于单个字节的块的算法,包括OP在评论中建议的the method

  

典型的strchr实现并不天真,但比您提供的效率更高。有关最广泛使用的算法,请参阅此末尾:http://graphics.stanford.edu/~seander/bithacks.html#ZeroInWord

它实际上与 alignment 本身无关。确实,这可能会导致在使用中的大多数常见体系结构上讨论的行为,但这更多地与微体系结构实现细节有关 - 如果未对齐的读取跨越4K边界(再次,典型),则该读取将导致程序如果下一个4K页面边界未映射,则终止故障。

但这不是答案中给出的算法中的“错误” - 行为是因为像strchrstrlen这样的函数不接受length参数来约束搜索的大小。搜索char bytes[1] = {0x55};,为了我们的讨论,恰好放在4K VM页面边界的最后,而下一页未映射,strchr(bytes, 0xAA)(其中strchr是一次一个字节的实现)将以完全相同的方式崩溃。相同的strchr相关堂兄strlen

如果没有length参数,就无法确定何时应该切换出高速算法并返回逐字节算法。更可能的“错误”是读取“超过分配的大小”,根据各种C语言标准在技术上导致undefined behavior,并且会被{{1}标记为错误}}

总之,任何操作大于字节块的东西都会更快,因为这会回答代码所做的事情以及OP指出的代码,但必须具有字节精确的读取语义,如果有的话可能会“错误”没有valgrind参数来控制“最后一次读取”的角落情况。

本答案中的代码是一个内核,用于在目标CPU具有快速length类似指令时能够快速查找自然CPU字大小块中的第一个字节。添加诸如确保它只在正确对齐的自然边界上运行或者某种形式的ctz绑定这样的事情是微不足道的,这将允许你从高速内核切换到更慢的字节 - 字节检查。

OP还在评论中说明:

  

至于你的ctz优化,它只会对O(1)尾部操作产生影响。它可以通过微小的字符串(例如length来提高性能,但肯定不会使用任何主要字符串的字符串。

这个陈述是否真实取决于所讨论的微架构。使用规范的4阶段RISC管道模型,几乎可以肯定。但是很难说现在的乱序超级标量CPU是否属实,其中核心速度可以使内存流速度完全相形见绌。在这种情况下,相对于“可以流式传输的字节数”,“可以退出的指令数量”存在较大差距,这不仅是合理的,而是相当普遍的,因此您可以使用“可以流式传输的每个字节可以退出的指令数“。如果这足够大,strchr("abc", 'a'); +移位指令可以“免费”完成。

答案 9 :(得分:3)

只是搜索“最快的strstr”,如果你看到有趣的东西,请问我。

在我看来,你对自己施加了太多的限制(是的,我们都希望在最大搜索者处使用亚线性线性),但是需要一个真正的程序员介入,直到那时我认为哈希方法只是一个漂亮的 - 肢体解决方案(由BNDM加强,缩短了2..16种模式)。

只是一个简单的例子:

将搜索模式(32字节)转换为字符串(206908949bytes)作为一行... 跳过性能(越大越好):3041%,6801754跳过/迭代 Railgun_Quadruplet_7Hasherezade_hits / Railgun_Quadruplet_7Hasherezade_clocks:0/58 Railgun_Quadruplet_7Hasherezade 表现: 3483KB /时间

将搜索模式(32字节)转换为字符串(206908949bytes)作为一行... 跳过性能(越大越好):1554%,13307181跳过/迭代 Boyer_Moore_Flensburg_hits / Boyer_Moore_Flensburg_clocks:0/83 Boyer_Moore_Flensburg 效果: 2434KB /时间

将搜索模式(32字节)转换为字符串(206908949bytes)作为一行... 跳过性能(越大越好):129%,160239051跳过/迭代 Two-Way_hits / Two-Way_clocks:0/816 双向效果: 247KB /时间

Sanmayce,
此致

答案 10 :(得分:3)

您在问题中提到的双向算法(顺便说一句,令人难以置信!)最近已得到改进,可以一次有效地处理多字节单词:Optimal Packed String Matching

我还没有阅读整篇论文,但似乎他们依赖于一些新的,特殊的CPU指令(包括在例如SSE 4.2中)作为时间复杂性声明的O(1),尽管它们不是可用,他们可以在O(log log w)时间内模拟它们,对于听起来不太糟糕的w位词。

答案 11 :(得分:3)

你可以实现4种不同的算法。每M分钟(根据经验确定)在当前实际数据上运行全部4。累计N次运行(也是TBD)的统计数据。然后在接下来的M分钟内仅使用获胜者。

在Wins上记录统计信息,以便您可以替换从未赢得过的新算法。专注于winningest例程的优化工作。在对硬件,数据库或数据源进行任何更改后,请特别注意统计信息。如果可能,请在统计日志中包含该信息,这样您就不必从日志日期/时间戳中找出它。

答案 12 :(得分:3)

我最近发现了一个很好的工具来衡量各种可用算法的性能: http://www.dmi.unict.it/~faro/smart/index.php

您可能会发现它很有用。 另外,如果我必须快速调用子串搜索算法,我会选择Knuth-Morris-Pratt。

答案 13 :(得分:2)

使用stdlib strstr

char *foundit = strstr(haystack, needle);

速度非常快,只花了我5秒左右打字。

答案 14 :(得分:2)

您可能还希望对多种类型的字符串使用不同的基准测试,因为这可能会对性能产生很大影响。算法将基于搜索自然语言(即使在这里因为不同的形态学仍然可能存在细微的区别),DNA字符串或随机字符串等而执行差异。

字母大小将在许多算法中发挥作用,针大小也将如此。例如,Horspool在英文文本方面表现不错,但由于字母大小不同而在DNA上表现不佳,因此对于不良角色规则来说生活很难。引入良好后缀大大加强了这一点。

答案 15 :(得分:0)

我不知道这是绝对最好的,但我对Boyer-Moore有很好的经验。

答案 16 :(得分:0)

这不能直接回答问题,但是如果文本很大,如何将其划分为重叠部分(重叠图案长度),然后使用线程同时搜索这些部分。关于最快的算法,我认为在Boyer-Moore的变体中,Boyer-Moore-Horspool是最快的算法之一。我在这个主题Algorithm faster than BMH (Boyer–Moore–Horspool) Search上发布了几个Boyer-Moore变体(我不知道他们的名字)。

答案 17 :(得分:0)

目前最快的是EPSM,由S. Faro和O. M. Kulekci撰写。 参见https://smart-tool.github.io/smart/

为SIMD SSE4.2(x86_64和aarch64)优化的“精确打包的字符串匹配”。它在所有尺寸上均表现稳定且最佳。

我链接到的站点比较了199种快速字符串搜索算法,而常规算法(BM,KMP,BMH)的运行速度相当慢。在这些平台上,EPSM的性能优于所有其他功能。这也是最新的。

2020年更新:EPSM最近针对AVX进行了优化,仍然是最快的。