一、朴素的模式匹配算法
算法分析
图1:
- 第一步会比较主串的第一个字符和子串的第一个字符,此时箭头指向主串和子串的第一个字符(如图1),如果相等,箭头往后移动一位,比较它们的下一个字符,直到遇到不相等的字符或者字符串末尾;
图2:
- 在主串第四个字符处遇到了不相等的情况,我们让箭头指向主串的上一轮比较的第一个字符的下一个字符,并且重新指向子串的第一个字符(如图2),这个时候子串看起来往后移动了一位,箭头相比较图1也是后移了一位;
- 此时可以发现,第一次出现不同字符的位置前,主串和子串的 ‘a b a’ 部分是相等的,假如将它们一一对应,那么图2中正在比较的 'b‘ 也就对应着子串的 ‘b’ ,在知道子串中正在比较的 ‘a’ 和对应主串 ‘b’ 的 ‘b’ 不同的情况下,图2中的比较是没有必要的,箭头和子串都可以直接后移一位;
图3:
- 当我们来到图3时,可以发现此时箭头指向的主串 ‘a’ 字符对应子串第三个 ‘a’,在我们知道子串第一个 ‘a’ 字符和子串第三个 ‘a’ 字符相等的情况下,图3中的比较也是没有必要的,只要将箭头后移一位就可以了;
- 按照朴素的模式匹配算法,重复步骤1和2,当箭头指向子串的后面一个位置,并且指向主串的后面一个位置或者主串上时,我们就认为在主串中匹配到了子串。
时间复杂度
在00…0001(49个0)中匹配00…01(9个0)
假设主串长度为n,子串长度为m,字符相等时和不相等时,都是执行两条语句
- 最好情况,第一次就匹配上:
2m => O(m) - 稍差一点,每次首字母都不匹配,直到第n-m+1个字符才匹配上整个子串:
2(n-m) + 2m = 2n-2m+2m = 2n => O(n) - 最坏情况,每次都是最后一个字符匹配不上,直到第n-m+1个字符才匹配上整个子串:
2m(n-m) + 2m = 2mn-2m^2+2m = 2(m(n-m+1)) => O(m*(n-m+1))
二、 KMP模式匹配算法
算法分析
图4
- 这个算法,我们可以省略掉朴素模式匹配算法中没有必要的部分,比如图1中第一次遇到不同字符时,省略掉图2和图3,直接来到图4,那么我们是如何做到的呢?
- 我们把主串第一次出现不同字符的位置记为 Si,在图4中可以观察到,箭头还是指向了主串 Si 位置,这是因为主串 Si 前的字符串和子串存在一一对应的关系,在知道子串字符关系的情况下,和主串 Si 前的字符串的比较都是可以跳过的,需要比较的还是这个 Si 上的字符;
- 在图4中还可以观察到,子串往后移动了两位,使得箭头指向了第一个字符 ‘a’ 后面的 ‘b’ ,然后进行比较,这里的比较是不可以省略的,因为我们不知道它们相不相等,那么为什么子串会移动到这个位置呢?
- 我们把子串第一次出现不同字符的位置记为 Ti,观察发现,主串 Si 位置前的字符串的后缀字符串 “a” 和子串 Ti 位置前的字符串的前缀字符串 “a” 是相等的,也正是因为相等所以才可以跳过,指针指向了子串前缀字符串 “a” 的下一个字符,而且因为第一次遇到前后缀字符串相等的情况就会进入图4的状态,所以这里前后缀字符串相等的同时,长度也是最长的(在图4前面跳过的过程中,子串其实是一步一步往后移动的,在移动的过程中可以发现,因为主串 Si 前和子串 Ti 前的字符串存在一一对应的关系,所以每次都是将 “a b a” 的后缀和前缀字符串进行比较,如果不相等,子串往后移动一步,直到出现字符串相等的情况,才会来到图4);
- 求出 Ti 前字符串 “a b a” 的前后缀字符串相等的最长长度 MAX,这时 MAX + 1 就是出现不同字符后指针要指向的下一个子串的位置,记为子串 Ti 位置的 next 值,也叫 next(Ti);
- 如果我们给子串每个位置都算出 next 值,那么在子串的任何位置出现不同字符时,都可以指针在主串上不动的情况下移动子串,使得指针指向子串 next 位置,然后进行比较,这样就跳过了前面没有必要的比较和移动指针的过程,时间复杂度也会减小。
如何求子串的 next 数组
图6:
- 假设我们已经知道了 next[T(g)] = T(d),字符串 “abcdeabc” 的前后缀字符串相等时的最大长度就是 “abc” 字符串的长度;
- 补充一点:想要求一个字符串 S 的最长相等前后缀字符串的长度(假设长度为n),就先要求出 S 中长度为 n-1 的前缀字符串的最长相等前后缀字符串的长度 MAX,如果 MAX+1 上的字符和 n 上的字符相等,那么 S 的最长相等前后缀字符串的长度就是 MAX+1;
- 再补充一点:想要求一个字符串 S 的次长相等前后缀字符串的长度,就要先求出这个最长相等前后缀字符串的前缀字符串 S1front,再求出这个前缀字符串 S1front 的最长相等前后缀字符串的前缀字符串 S2front,S2front 的长度就是我们最终要求的值(因为次长相等前后缀字符串的前缀字符串肯定是在比 S1front 长度小1的前缀里去找,次长相等前后缀字符串的后缀字符串肯定是在比最长相等前后缀字符串的后缀字符串 S1rear 长度小1的后缀里去找,又因为最长相等前后缀字符串的后缀字符串 S1rear 和 S1front 相等,所以求 S1front 的前缀和 S1rear 的后缀相等时的最大长度也就是 S1front 的最大相等前后缀字符串长度);
- 再再补充一点:如果步骤2中,MAX+1 上的字符和 n 上的字符不相等,那么就需要求出 S 中长度为 n-1 的前缀字符串的次长相等前后缀字符串的长度 MAX2,如果 MAX2+1 上的字符和 n 上的字符相等,那么 S 的最长相等前后缀字符串的长度就是 MAX2+1,以此类推;
- 位置为1时,next为0;
- 如果 T(g) 和 next[T(g)] 上的字符相等,那么 next[T(h)] = next[T(g)]+1 = T(d)+1 = T(e),T(h) 前的字符串的前后缀字符串相等时的最大长度就是 “abcd” 字符串的长度(反推证明:如果存在更长的,说明 “abcdXX” 和 “XXabcg” 相等(X>=1),也就是说前者去除最后一个字符后的 “abcdX” 和后者去除最后一个字符后的 “XXabc” 相等,这与 next[T(g)] = T(d) 不符)
- 如果 T(g) 和 next[T(g)] 上的字符不相等,那么我们就需要缩小范围去找,找出"abcdeabc" 的次长相等前后缀字符串,根据第3步得知,也就是 next[next[T(g)]]-1长度的前缀,如果前缀后一个字符等于 “g”,则 next[h] = next[next[T(g)]]+1;如果还不相等, 这个时候如果 next(也就是前面的 next[next[T(g)]])值为 1(1代表没有相同的前后缀字符串了),我们就将 next[T(h)] 的值设为1,否则循环这一步的逻辑(这一步其实就是找出 “g” 前字符串中,满足相等并且前缀后一个字符和"g"相等的条件的前后缀中,最长的前后缀字符串;如果没有就看第一个字符和 “g” 相不相等,相等 next[T(h)] 为2,不相等 next[T(h)] 就为1);
- 通过上面的方法可以求出子串每个位置的 next 值。
图7:
next[j]:
- 当j = 1时,next[j] = 0
- 当{k|0<k<j-1, T(1…k)=T(j-k…j-1)}不为空时,next[j]=MAX{k|0<k<j-1, T(1…k)=T(j-k…j-1)} + 1
- 其他情况,next[j] = 1
时间复杂度
O(n+m)
代码实现
递归实现求 next 数组并实现KMP模式匹配算法:
int get_next(char* T, int i)
{
if (i == 1)
return 0;
int next_last = get_next(T, i - 1);
//先声明,MAX是字符串的前缀串和后缀串相等的最大长度
while (next_last > 0 && T[next_last] != T[i - 1])//这里如果相等则说明next_last是i前字符串的MAX
{
next_last = get_next(T, next_last);
}
//next_last == 0 || T[next_last] == T[i - 1]时返回
return next_last + 1;//i前字符串的MAX加1就是i处的next
}
void init_next(char* T, int* next)
{
//对next数组逐一求值
for (int i = 1; i < strlen(T); i++)
{
next[i] = get_next(T, i);
}
}
int index_KMP(char* S, char* T, int pos)
{
//字符数组的第一位存的是字符串的长度
int szS = S[0];
int szT = T[0];
//i作为主串的下标,j作为子串的下标
int i = pos;
int j = 1;
//next数组的长度固定为10,第一位存的是子串长度,也说明子串长度不能大于9
int next[10] = { szT };
//初始化next数组
init_next(T, next);
while (i <= szS && j <= szT)
{
if (j == 0 || S[i] == T[j])
{
i++;
j++;
}
else
{
j = next[j];
}
}
//j指向子串后面一位说明匹配成功,否则失败
if (j > szT)
return i - szT;
else
return 0;
}
递推实现求 next 数组并实现KMP模式匹配算法:
#define MAX_STR_LENGTH 30
int get_next(char* T, int* next)
{
assert(T && next);
int sz = strlen(T);
int i = 0;
for (i = 0; i < sz; i++)
{
if (i == 0)
{
next[i] = -1;
continue;
}
int pre_index = i - 1;
int pre_next_index = next[i - 1];
while (pre_next_index != -1 && T[pre_index] != T[pre_next_index])
pre_next_index = next[pre_next_index];
next[i] = pre_next_index + 1;
}
}
int index_KMP(char* S, char* T, int pos)
{
//计算出next数组并打印
int next[MAX_STR_LENGTH] = { 0 };
get_next(T, next);
for (int k = 0; k < strlen(T); k++)
{
printf("%-5d", next[k]);
}
printf("\n");
//i作为主串的下标,j作为子串的下标
int i = pos;
int j = 1;
int szS = strlen(S);
int szT = strlen(T);
while (i < szS && j < szT)
{
if (j == -1 || S[i] == T[j])
{
i++;
j++;
}
else
{
j = next[j];
}
}
//j指向子串后面一位说明匹配成功,否则失败
if (j == szT)
return i - szT;
else
return 0;
}
根据 next 数组求 nextval 数组:
void get_nextval(char* T, int* next, int* nextval)
{
assert(T && next && nextval);
int sz = strlen(T);
int i = 0;
for (i = 0; i < sz; i++)
{
if (i == 0)
{
nextval[i] = next[i];
}
//这里表示的是,我们在出现不同字符时需要跳转的 next 位置上的字符和当前位置上的字符相等,
//这个时候我们也没必要进行比较,直接跳到 next 的 nextval 位置上就可以了
if (T[next[i]] == T[i])
{
nextval[i] = nextval[next[i]];
}
else
{
nextval[i] = next[i];
}
}
}
总结
KMP模式匹配算法是在朴素模式匹配算法的基础上跳过了没有必要的步骤。
模式匹配的简要概括:
1. 从图1开始,第一次遇到不同字符时,我们把主串上指针指向的位置记为 Si,子串上指针指向的位置记为 Ti,此时 Si 前的字符串和 Ti 前的字符串是相等的,我们将他们一一对应;
2. 如果遵循朴素模式匹配算法的步骤,下一步应该把指针指向主串的第二个字符,并且指向子串的第一个字符,如图2所示,看上去好像是子串往后移动一步,然后从头开始和主串进行比较;
3. 观察图5,我们知道上一行的 “a b a” 和下一行的 “a b a” 是一一对应的,那么此时子串 "a b " 和主串 “b a” 的比较就变成子串 “a b” 和子串 “b a” 的比较,也就变成了子串 Ti 前字符串的长度为2的前缀字符串和相同长度的后缀字符串的比较,如果不相等,说明此时主串上指针位置的字符串和子串不匹配,子串就需要后移一位,变成子串长度为1的前缀字符串和相同长度的后缀字符串的比较,如果相等,子串不再移动,指针移动到 Si 的位置上。至此,我们跳过了所有可以跳过的步骤,从第一次遇到字符不相同的情况到达这里,如图4,也就是主串上指针还是指向 Si 的位置,子串被指针指向的位置变成了 Ti 的 next 位置(Ti 前字符串前后缀字符串相等时的最大长度,再加一得到 next 值,这里为2);
4. 然后重复以上步骤,直到指针指向了子串的后面一位,我们就认为在主串中匹配到了子串,位置是 Si - Length(子串)。
图5: