文本编辑器中的查找功能是如何实现的呢?算法
文本编辑器中的查找功能本质上就是一个字符串匹配过程,所以能够用 BF 算法和 RK 算法 实现,可是在某些极端状况下,BF 算法性能会退化得比较严重,而 RK 算法须要用到哈希算法,设计一个能够适用于各类字符的哈希算法并非那么简单。数组
模式串和主串的匹配,能够看做是模式串在子串中不断向后滑动的过程。若是遇到两个子串不匹配, BF 算法和 RK 算法的作法就是将模式串向后移动一个字符的位置,而后继续进行比较。数据结构
在上面的例子中,c 和 d 不匹配,咱们就将模式串向后移动一位。可是,咱们发现,模式串中根本不存在字符 c,所以,咱们能够直接将模式串向后多移动几位。框架
一样地,在遇到相似状况的时候,咱们是否是均可以一次性将模式串向后多移动几位呢? BM 算法其实就是在寻找这些规律,借助这些规律,字符串匹配的效率也就会大大提升。编辑器
BM 算法包含两部分,分别是坏字符规则(bad character rule)和好后缀规则(good suffix shift)。性能
首先,BM 算法针对两个子串的比较是从后向前进行的,也就是按照下标从大到小进行比较。优化
咱们从模式串的末尾向前比较,当发现某个字符无法匹配的时候,这个没法匹配的字符就叫做坏字符(主串中的字符)。spa
咱们拿坏字符 c 在模式串中查找,发现模式串中根本不存在这个字符。此时,咱们就能够直接将模式串向后移动三个位置,再继续进行比较。.net
此时,最后一个字符 a 和 d 仍是没法匹配。可是,坏字符 a 存在于模式串中,咱们不能直接向后移动 3 位 ,而是应该让主串中的字符 a 和模式串中的 a 对齐,而后再继续进行比较。设计
能够看到,模式串的移动位数在不一样状况下是不同的,它们有什么规律呢?咱们将坏字符对应于模式串中的下标记为 si,将坏字符在模式串中从前日后第一次出现的位置记为 xi,若是坏字符在模式串中不存在,那么其值就为 -1。而后,模式串应该向后移动的位数就等于 si - xi。
利用坏字符规则,BM 算法在最好状况下的时间复杂度很是低,为 O(n/m)。好比,主串是 aaabaaabaaabaaab,模式串是 aaaa,每次匹配均可以直接向后移动 4 位,很是高效。
不过,只单纯利用坏字符规则是不够的。由于根据 si-xi 计算出来的移动位数,有多是负数。好比,主串是 aaaaaaaaaaaaaaaa,模式串是 baaa,不但不会向后移动,还会倒退。
实际上,好后缀规则和坏字符规则的思路很相似。
在上面的例子中,坏字符后面的字符 bc 是匹配的,它们就称之为好后缀,记做 {u}。咱们拿它在模式串中查找,若是找到了另外一个和 {u} 匹配的子串 {u*},那咱们就将模式串滑动到子串 {u*} 与主串 {u} 对齐的位置。
若是在模式串中找不到另外一个等于 {u} 的子串,咱们就直接将模式串移动到主串 {u} 的后面,由于中间的滑动过程都没法和 {u} 匹配上。
不过,当模式串中不存在等于 {u} 的子串时,直接将模式串移动到主串 {u} 的后面是有问题的,咱们有可能会错过主串和模式串匹配的状况。
事实上,当模式串中不存在等于 {u} 的子串时,只要 {u} 和模式串彻底重合,那确定模式串和主串就不可能匹配,但如果 {u} 和模式串部分重合,那就有可能会存在模式串和主串匹配的状况。
因此,针对这种状况,咱们不只要考虑好后缀是否存在于模式串中,还要考虑好后缀的后缀子串是否和模式串的前缀子串匹配。所谓好后缀的后缀子串,便是和好后缀最后一个字符对齐的子串,好比 abc 的后缀子串就是 c、bc。所谓前缀子串,便是和模式串第一个字符对齐的子串,好比 abc 的前缀子串就是 a、ab。
咱们从好后缀的后缀子串中,找到一个最长的而且能和模式串前缀子串匹配的子串,假设是 {v},而后将模式串滑动到好后缀的后缀子串与模式串的前缀子串对齐的位置。
最后,当模式串和主串中的某个字符不匹配的时候,咱们分别利用坏字符规则和好后缀规则计算出两个数字,选取较大的那个数做为模式串应该日后移动的位数。
首先,咱们应该怎么查找坏字符在模式串中的位置呢?若是每次都要在模式串中遍历查询,那确定效率很是低。这时候,散列表就派上用场了。咱们能够将模式串中的字符及其在模式串中的位置存储在散列表中,这样查找坏字符位置的时候就直接从散列中取出便可。
假设字符串的字符集不是很大,每一个字符长度是 8 个字节,那么咱们就能够用一个大小为 256 的数组来实现散列表的功能,数组下标就是对应字符的 ASCII 码,数组中的数据就是该字符在模式串中出现的位置。
# define SIZE 256
// 生成坏字符对应的散列表
void GenerateBC(char str[], int m, int bc[]) {
// 全部字符初始化为 -1
for (int i = 0; i < SIZE; i++)
{
bc[i] = -1;
}
for (int i = 0; i < m; i++)
{
int ascii = str[i] - '\0'; // 求出字符对应的 ASCII 码
bc[ascii] = i;
}
}
复制代码
接下来,咱们先把 BM 算法的大框架写好,只考虑坏字符规则,且不考虑移动位数为负的状况。
int BM(char str1[], int n, char str2[], int m) {
int bc[SIZE]; // 记录每一个字符在模式串中最后出现的位置,做为坏字符散列表
GenerateBC(str2, m, bc);
int i = 0; // 表示主串和模式串对齐时第一个字符的位置
int si = 0; // 坏字符对应于模式串中的位置
int xi = -1; // 坏字符在模式串中出现的位置
while (i <= n-m)
{
int j = 0;
// 从后向前进行匹配
for (j = m-1; j >= 0; j--)
{
// 找到了第一个不匹配的字符
if (str1[i+j] != str2[j]) break;
}
if (j < 0) return i; // 匹配成功
si = j;
xi = bc[str1[i+j] - '\0'];
i = i + si - xi; // 将模式串后移 si-xi 个位置
}
return -1;
}
复制代码
这样咱们就实现了包含坏字符规则的框架代码,接下来,咱们只须要向其中填入好后缀规则便可。好后缀处理过程当中最核心的两点是:
在模式串中,查找和好后缀匹配的另外一个子串;
在好后缀的的后缀子串中,查找最长的能和模式串前缀子串匹配的后缀子串。
由于好后缀也是模式串自己的后缀子串,所以,咱们就能够在模式串和主串匹配以前经过预处理,来预先计算出模式串的每一个后缀子串,对应的另外一个与之匹配子串的位置。
由于后缀子串的最后一个字符位置固定,所以,要表示模式串的后缀子串,咱们只须要记录其长度便可。
接下来,咱们引入 suffix 数组,其下标表示后缀子串的长度,而数组里面存储的是与这个后缀子串匹配的另外一个子串在模式串中的起始位置,以下所示。
另外,为了不模式串滑动过头,若是有多个子串都和后缀子串匹配,咱们须要记录最靠后的那个子串的起始位置。此时,咱们已经找出了和后缀子串匹配的子串,但最终咱们须要的是好后缀子串和模式串的前缀子串匹配的位置。所以,只有这一个数组是不够的,咱们引入另一个布尔型数组 prefix,来记录模式串的后缀子串是否能匹配其前缀子串。
咱们拿模式串中下标从 0 到 i 的子串(i 能够是 0 到 m-2)与整个模式串,求公共后缀子串。若是公共后缀子串的长度为 k,那咱们就记录 suffix[k] = j(j 表示公共后缀子串的起始下标)。若是 j=0,也就说公共后缀子串也是模式串的前缀子串,咱们就记录 prefix[k]=true。
// 生成好后缀数组
void GenerateGS(char str[], int m, int suffix[], bool prefix[]) {
for (int i = 0; i < m; i++)
{
suffix[i] = -1;
prefix[i] = false;
}
// [0, i] 的子串和模式串求公共后缀子串
for (int i = 0; i < m-1; i++)
{
int j = i;
int k = 0;
while (j>=0 && str[j] == str[m-1-k]) // 下标都向前移动
{
j--;
k++;
}
if (k != 0) suffix[k] = j + 1; // 公共后缀子串的起始位置
if (j == -1) prefix[k] = true; // 公共后缀子串同时也是模式串的前缀子串
}
}
复制代码
接下来,咱们来看遇到不匹配的字符时,如何根据好后缀规则,计算模式串向后移动的位数?
假设好后缀的长度是 k,咱们首先检查 suffix[k] 是否为 -1。若是不为 -1,那 x=suffix[k] 就表明与好后缀匹配的前缀子串在模式串中的起始位置,咱们就须要将模式串向后移动 j-x+1 个位置,j 为坏字符对应于模式串中的位置。若是为 -1 则说明不存在匹配的子串,咱们就寻找是否存在与好后缀的后缀子串匹配的前缀子串。
好后缀的后缀子串 b[r, m-1] 的长度为 k=m-r,其中 r 取值为 [j+2, m-1],若是 prefix[k]=true,表示长度为 k 的后缀子串有可匹配的前缀子串,咱们就须要将模式串向后移动 r 个位置。
若是上面两种状况都不知足,那咱们就须要将模式串向后移动 m 个位置,即移动到好后缀后面的位置。下图中应该是写错了,注意!!!
// 判断好后缀规则应该移动的位数
int MoveByGS(int j, int m, int suffix[], bool prefix[]) {
int k = m - j - 1; // 好后缀长度
if (suffix[k] != -1) return j + 1 - suffix[k];
for (int r = j + 2; r < m; r++)
{
if (prefix[m-r] == true) return r;
}
return m;
}
int BM(char str1[], int n, char str2[], int m) {
int bc[SIZE]; // 记录每一个字符在模式串中最后出现的位置,做为坏字符散列表
GenerateBC(str2, m, bc);
int suffix[m];
bool prefix[m];
GenerateGS(str2, m, suffix, prefix);
int i = 0; // 表示主串和模式串对齐时第一个字符的位置
int si = 0; // 坏字符对应于模式串中的位置
int xi = -1; // 坏字符在模式串中最后出现的位置
while (i <= n-m)
{
int j = 0;
// 从后向前进行匹配
for (j = m-1; j >= 0; j--)
{
// 找到了第一个不匹配的字符
if (str1[i+j] != str2[j]) break;
}
if (j < 0) return i; // 匹配成功
si = j;
xi = bc[str1[i+j] - '\0'];
int x = si - xi; // 坏字符规则应该向后移动的位数
int y = 0; // 好后缀规则应该向后移动的位数
if (j < m-1) y = MoveByGS(j, m, suffix, prefix);
x = x > y ? x : y;
i = i + x;
}
return -1;
}
复制代码
整个算法用到了额外的三个数组,bc 与字符集的大小有关,suffix 和 prefix 与模式串大小有关。若是咱们处理字符集很大的字符串匹配问题,bc 数组对内存的消耗就会比较多。由于好后缀规则和坏字符规则是独立的,若是咱们对运行的环境内存要求比较苛刻,那么就能够只使用好后缀规则。不过,这样 BM 算法的效率就会有一些降低。
另外,在极端状况下,预处理计算 suffix 和 prefix 数组的性能会比较差,好比模式串是 aaaaaa 这种包含不少重复字符的模式串,预处理的时间复杂度就是 。固然,大部分状况下,时间复杂度不会这么差。现有一些论文证实了在最坏状况下, BM 算法的比较次数上限是 3n。
获取更多精彩,请关注「seniusen」!