转:http://www.endless-loops.com/
忙碌的春节过完了,继续填坑…
字符串String类中最核心最重要的算法应该算就是字符串匹配算法了,String类中的find(),index(),count()以及split(),replace()等操作的基础都是字符串匹配。
所有字符串匹配算法要处理的根本问题就是当出现不匹配字符时,怎样向后移动模式串。
在后面我们将看到Python源码中的字符串匹配算法是基于Boyer-Moore算法,Horspool算法以及Sunday算法的混合算法。所以我们先详细介绍一下这三个算法。
Boyer-Moorenormal">算法:
Boyer-Moore 算法是相当有效的一个算法,真实应用中平均情况下比KMP算法快3-5倍。与KMP算法不同,Boyer-Moore算法在进行比较时是自后向前的,换句话说KMP算法是基于前缀的而Boyer-Moore算法是基于后缀的,基于后缀的优点在于可以更多地跳过文本字符,加快算法。
图 1 基于后缀的字符串匹配
先引入两个概念:
坏字符(bad character): 如上图b中a与不匹配情况,则称b为坏字符
好后缀(good suffix): 如上图中已匹配的阴影部分,称之为好后缀
当出现不匹配情况时,要将模式串向后移动,Boyer-Moore算法如何计算模式串向后移动的位数呢?
从坏字符角度考虑:
1) 如果坏字符b不存在模式串中:
这时可以直接让模式串全部跳过文本串中的b,如下图
图 2
2) 如果模式串中也包含坏字符b:
这时可以让模式串中最右边的b与文本串中的b对齐,如下图
图 3
从好后缀角度考虑:
1) 模式串中有子串和好后缀安全匹配,则将最靠右的那个子串移动到好后缀的位置,如下图:
图 4
2) 如果不存在和好后缀完全匹配的子串,则在好后缀中找既是模式串前缀又是模式串后缀的最长子串,再按下图所示移动模式串
图 5
Boyer-Moorenormal">算法模式串移动规则:
现在很容易明白模式串右移的长度为按坏字符计算与按好后缀计算到的较大者,即:
max{skip(坏字符), skip(好后缀)}
在很多资料中又把skip(坏字符)记为delta1 table, 把skip(好后缀) 记为delta2 table.
Horspool算法:
Horspool算法是首个对Boyer-Moore算法进行简化改进的算法,它认为对于较大的字符集来说,更多的情况是Boyer-Moore算法的坏字符情况能产生更大的移动距离,因为对于大字符集,模式串中出现与好后缀完全匹配的子串或部分匹配的前缀的概率很低。
Horspool 算法考虑模式串的末尾字符,如果这个字符与文本串中对应的则继续进行后缀搜索,直至匹配成功,或出现不匹配字符,对于后一种情况,则按以下两种情况计算移动距离:
1) 末尾字符c不在子串P[0,…p_len-2] 中出现,此时可以直接将模式串移动p_len个位置,如下图所示:
图 6
2) 末尾字符c也在子串p[0,…,p_len-2] 中出现,此时让动模式让子串p[0,…,p_len-2] 中最右边的c与原来的末尾位置对齐,如下图所示:
图 7
Sunday算法:
Sunday算法也是对Boyer-Moore算法的简化,原理与Horspool算法相似,区别在于Sunday算法考虑的是文本串中与模式串末尾对齐的字符的下一个字符,Sunday算法按以下两种情况计算模式串移动距离:
1) 模式串中不存在该字符,此时可以将模式串移动到该字符的下一个字符,如下图:
图 8
2) 模式串中存在该字符,此时可以移动模式串以使最靠右的该字符与主串中的该字符对齐, 如下图:
图 9
Python源码中字符串匹配算法
在Python源码stringobject.c中追踪string_find(),string_index(), string_count()三个函数,最后都要追踪到fastsearch.h中的fastsearch()函数 fastsearch.h 文件可以在python源码包的Object目录下找到。fastsearch.h里面包含了一些与python实现相关的繁琐细节,为了不让这些细节干扰我们理解算法,我对其中的代码进行了改写:
int fastsearch(char* s, int n, char* p, int m){ long mask; int skip; int i, j, mlast, w; w = n - m; if (w < 0) return -1; /* look for special cases */ if (m <= 1) { if (m <= 0)//如果模式串为空 return -1; /* use special case for 1-character strings */ for (i = 0; i < n; i++) if (s[i] == p[0]) return i; return -1; } mlast = m - 1; /* create compressed boyer-moore delta 1 table */ skip = mlast - 1; /* process pattern[:-1] */ for (mask = i = 0; i < mlast; i++) { mask |= (1 << (p[i] & 0x1F)); if (p[i] == p[mlast]) skip = mlast - i - 1; } /* process pattern[-1] outside the loop */ mask |= (1 << (p[mlast] & 0x1F)); for (i = 0; i <= w; i++) { // w == n - m; /* note: using mlast in the skip path slows things down on x86 */ if (s[i+m-1] == p[m-1]) { //(Boyer-Moore式的后缀搜索) /* candidate match */ for (j = 0; j < mlast; j++) if (s[i+j] != p[j]) break; if (j == mlast) /* got a match! */ return i; /* miss: check if next character is part of pattern */ if (!(mask & (1 << (s[i+m] & 0x1F)))) //(Sunday式的基于末字符的下一字符) i = i + m; else i = i + skip; //(Horspool式的基于末字符) } else { /* skip: check if next character is part of pattern */ if (!(mask & (1 << (s[i+m] & 0x1F)))) i = i + m; } } return -1;}
9~22 行处理的是模式串比文本串长以及模式串为空字符或单字符的特殊情况。
26~33行创建的是一个压缩了的boyer-moore delta1 table,不是一个table了而只是一个值skip, skip初始化为mlast-1,若在模式串中存在与末尾字符匹配的字符,29~33行代码将skip修改为该字符与末尾之前的间隔。代码中的mask是个布隆过滤器(Bloom filter),用于在后面判断文本串中的字符是否包含在模式串中。
38~58行进行匹配,先对比末位,若末位匹配,则由前至后进行前缀搜索(42~44行),搜索到了末尾即匹配成功(45~46行),出现不匹配情况时,先考察末位的下一位字符是否包含在模式串中,若包含则将模式串向后移动m位(48~49行),若包含则按依末尾字符计算出来的skip移动。若末位不匹配并且末位的下一位不包含在模式串中,也将模式串向后移动m位(55~56行),若末位不匹配并且末位的下一位包含在模式串中,那么执行for循环位中的i++,让模式串向后移动一位。
到这里仔细的同学可能和我最初一样心里冒出了三个疑问:
1.48~49行与55~56行不分明就是Sunday算法式的移动吗?为何在这里只移动了m位,不应该是像图8所示的m+1位吗?
2.51行不分明就是Horspool算法式的移动吗?为何29~33行计算skip时是
skip = mlast–i–1 = m-1–i–1,不应该是像图7所示的m-1-i吗?
3.为何在末字符不匹配,末字符的下一位字符也不包含在模式串中时,只是简单地执行循环体中的i++,让模式串向后移动一位,为何不像图9中所示地移动更多呢?
问题1和问题2的答案是相同的,因为在每次for循环之后都会执行i++,所以在循环内部就少加一个1,这个答案很简单,但我看这段代码时困惑了快半个小时了。。。写出来希望其他人看到后少困惑一会。至于问题3我们在后面再讨论。
现在我们很清晰地看出python源码的fastsearch算法是基于Horspool算法与Sunday算法对Boyer-Moore算法一个简化。fastsearch算法充分利用了Boyer-Moore算法基于后缀的优点,又在出现不匹配情况时结合Sunday算法与Horspool算法确保大多数情况下都产生较大的移动距离。
性能对比
注:考虑本文的篇已经过长,没有结合代码实现来分析各项指标,但在最后附上了以上算法实现的程序文件下载地址
虽然Boyer-Moore算法与Horspool以及Sunday算法的各项指标都一样,但由于系数和常数项的不同,在实际应用中Horspool和Sunday算法是优于Boyer-Moore算法的,
python fastsearch算法主要改进不仅在实际应用速度上,也在预处理的时间复杂度与空间复杂度上。
什么是优秀的字符串匹配算法?
这个问题没有绝对的答案,但python源码stringlib的作者Fredrik Lundh在这里提出了他的一些标准(fastsearch.h是stringlib的一部分):
When designing the new algorithm, I used the following constraints:
在设计新的字符串匹配算法时,我考虑下面的一些要求
1) should be faster than the current brute-force algorithm for all test cases (based on real-life code), including Jim Hugunin’s worst-case test
对于任何测试用例都比当前的蛮力算法快,包括Jim Hugunin的最坏情况测试(注:Jim Hugunin 是ironpython与 jython之父)
2) small setup overhead; no dynamic allocation in the fast path (O(m) for speed, O(1) for storage)
较小的预处理;没有动态分配(O(m)的时间复杂度,O(1)的空间复杂度)
3) sublinear search behaviour in good cases (O(n/m))
好情况下亚线性的性能 (O(n/m))
4) no worse than the current algorithm in worst case (O(nm))
在最坏情况下不比当前的算法差 (O(nm))
5) should work well for both 8-bit strings and 16-bit or 32-bit Unicode strings (no O(σ) dependencies)
对于8比特或16比特字符串以及32比特Unicode字符串都有良好性能(没有O(σ) 依赖)
6) many real-life searches should be good, very few should be worst case
大多数实际搜索应该是好的情况,最坏情况很少
7) reasonably simple implementation
适当易于实现
按Fredrik Lundh的说法
This rules out most standard algorithms (Knuth-Morris-Pratt is not sublinear, Boyer-Moore needs tables that depend on both the alphabet size and the pattern size, most Boyer-Moore variants need tables that depend on the pattern size, etc.).
这个要求淘汰了绝大多数标准算法,Knuth-Morris-Pratt 的最优情况不是亚线性的,Boyer-Moore 算法的模式串预处理得到的两个表(坏字符表,好后缀表)的大小既依赖于模式串大小也依赖于字符集大小,而大多数Boyer-Moore算法变体的模式串预处理需要的表也还是依赖于模式串的大小。
现在可以来给出上面遗留的问题3的答案了,在python fastsearch算法中如果在末字符不匹配并且末字符的下一位字符也不包含在模式串中时,像图8中所示地移动,会要求保存一个模式串长度大小的表,这就与上面第2)点的要求冲突了,Python对内存十分珍视,引用《Python源码剖析》中的一句话“恨不得一块内存掰成两半用”。
Python fastsearch 的效率如何呢?引用作者的话吧:
The new find implementation is typically 2-10 times faster than the one used in Python 2.3 (using a reasonably representative set of test strings, that is). For the find portions of the stringbench test suite, the new algorithm is up to 26 times faster.
再提一下fastsearch代码中的int型变量mask,前面说了这是个布隆过滤器(Bloom filter),对python这样的支持32 bit Unicode 字符的语言来说,如果用数组或hash表来实现判断文本串中某字符是否包含于模式串中,就会浪费大量内存,这种低效的实现方式显然是无法允许的,所以这里有个优雅的布隆过器,仅用一个int型变量就能判断所有Unicode字符是否包含于某字符串,大大节约了内存。布隆过滤器又是个说来话长的话,还是赶快打住吧,考虑下下一篇是不是结合python源码讲一下这个话题,相关资料可以参考:
http://www.google.com.hk/ggblog/googlechinablog/2007/07/bloom-filter_7469.html
http://blog.csdn.net/jiaomeng/archive/2007/01/27/1495500.aspx
算法实现:
由于这篇啰嗦的有点长了,我就不把Boyer-Moore,Horspool,Sunday 的代码贴出来了,我把程序文件做了个压缩包,需要的可以在下面的链接下载。www.endless-loops.com/wp-content/uploads/2011/02/StringSearch.zip