目录
问题描述
已知主串存储在文件中,请使用 KMP算法实现一行多次的匹配效果,通过输入待查找的模式串,判断文件中是否包含该模式串,如果包含,则显示模式串在文件中的位置(显示行数和该行第几个字符)
朴素模式匹配算法
首先遍历主串,找出主串中与模式串长度相同的子串,再让子串与模式串进行对比。
主串指针 i 和模式串指针 j 都从第 1 个字符开始匹配,如果 i = 4 时发生不匹配,即该位置的字符不是 b,那么有可能是 a。所以我们让 i 回到 2, j 回到 1,继续匹配。
这里我们可以看到,当主串指针 i 回到 2 且模式串指针 j 回到 1时,这就导致模式串多进行了两次重复的对比,其实这样的对比是没有意义的。
朴素模式匹配的缺点:
当某些子串与模式串能够部分匹配时,主串指针 i 就需要回溯,导致时间开销增加。最坏时间复杂度 O(nm)
KMP算法
KMP算法是一个非常优秀的模式匹配算法,其对于任何模式和目标序列,都可以在线性时间范围内完成匹配查找。KMP模式匹配算法,就很好的解决了主串指针 i 经常回溯的问题。
基本思想: 当子串与模式串发生不匹配时,主串指针 i 不用回溯,而让模式串指针 j 发生回溯。
平均时间复杂度 O(n + m)
KMP算法的匹配过程(图解)
如果 i 当前指向位置的字符不是 e,那么应该怎么做?
若 j = 6 时发生不匹配,则应让 j 回到 1
这里主串指针 i 并没有发生回溯!
如果 i 当前指向位置的字符不是 l,那么应该怎么做?
若 j = 5 时发生不匹配,则应让 j 回到 2
如果 i 当前指向位置的字符不是 g,那么应该怎么做?
若 j = 4 时发生不匹配,则应让 j 回到 1
如果 i 当前指向位置的字符不是 o,那么应该怎么做?
若 j = 3 时发生不匹配,则应让 j 回到 1
如果 i 当前指向位置的字符不是 o,那么应该怎么做?
若 j = 2 时发生不匹配,则应让 j 回到 1
结合上述图解,我们总结一下:
- 若当前两个字符匹配,则应让 i++,j++
- 若 j = 1 时发生不匹配,则应让 i++ ,而让 j 依然是 1
这里 j = 1 可以等价于先令 j = 0 再进行 j++ - 若 j = 2 时发生不匹配,则应让 j 回到 1
- 若 j = 3 时发生不匹配,则应让 j 回到 1
- 若 j = 4 时发生不匹配,则应让 j 回到 1
- 若 j = 5 时发生不匹配,则应让 j 回到 2
- 若 j = 6 时发生不匹配,则应让 j 回到 1
模式串 google,对应的 next 数组为:
序号 j | 1 | 2 | 3 | 4 | 5 | 6 |
---|---|---|---|---|---|---|
模式串 | g | o | o | g | l | e |
next [ j ] | 0 | 1 | 1 | 1 | 2 | 1 |
如果 j = k 时才发生匹配失败,令 j = next [ k ]
KMP算法实现一行多次匹配代码实现
void KMP(String p, String s, int next[], int row) { //KMP算法,p为主串,s为模式串,row为读取文件的行数
int i = 0;
int j = 1;
int count = 0; //记录模式串 s和主串 p匹配成功的次数
int index[MAX_SIZE]; //记录主串匹配成功的位置
while (i < p.length) { //遍历主串所有字符
//如果模式串第 1个字符不匹配,则让模式串指针 j = 1不变,而让主串指针 i后移 <==> 令 j = 0,然后 j++且 i++
if (j == 0 || p.str[i] == s.str[j - 1]) { //主串 p和模式串 s都是从下标为0的位置开始存储
i++;
j++;
} else {
j = next[j]; //如果模式串第 j个字符匹配失败,则让指针 j跳到 next[j]的位置
}
if (j > s.length) { //如果完全匹配
index[count++] = i - s.length + 1; //这里 +1是因为文件中列数从 1开始
j = 1; //重置模式串指针 j指向第 1个字符
}
}
if (count != 0) { //如果匹配成功,输出行和列的值
printf("\t\t\t%d", row);
for (i = 0; i < count; i++) {
printf("\t%d", index[i]);
}
printf("\n");
}
}
next 数组
当模式串第 j 个字符匹配失败时,令模式串指针 j 跳到 next [ j ],再继续匹配。
想要求解 next 数组,我们先了解一下什么是串的前缀和后缀?
串的前缀:包含第一个字符,且不包含最后一个字符的子串。
串的后缀:包含最后一个字符,且不包含第一个字符的子串。
例:模式串 ababc
前缀:abab,aba,ab,a
后缀:babc,abc,bc,c
next 数组求解方法
方法总结:
当第 j 个字符匹配失败,由前 1 ~ ( j - 1 ) 个字符组成的串,记为 S, 则 next [ j ] = S的前后缀字符串能匹配上的最大长度 + 1
特别地,next [1] = 0
例:模式串 aaaab,求解其 next 数组
- next [1] = 0
- 第2个字符前只有1个字符,组成的S串为 a,其前后缀能匹配上的最大长度为 0,则 next [2] = 0 + 1 = 1
- 第3个字符前有2个字符,组成的S串为 aa,其前后缀能匹配上的最大长度为 1,则 next [2] = 1 + 1 = 2
- 第4个字符前有3个字符,组成的S串为 aaa,其前后缀能匹配上的最大长度为 2,则 next [2] = 2 + 1 = 3
- 第5个字符前有4个字符,组成的S串为 aaaa,其前后缀能匹配上的最大长度为 3,则 next [2] = 3 + 1 = 4
模式串 aaaab,对应的 next 数组为:
序号 j | 1 | 2 | 3 | 4 | 5 |
---|---|---|---|---|---|
模式串 | a | a | a | a | b |
next [ j ] | 0 | 1 | 2 | 3 | 4 |
例:模式串 ababaa,求解其 next 数组
如果 i 当前指向位置的字符不是 a,那么应该怎么做?
若 j = 6 时发生不匹配,由前 1 ~ 5 个字符组成的串为 ababa
串的前缀:abab,aba,ab,a
串的后缀:baba,aba,ba,a
当发生不匹配时,通过让模式串右移,在 j = 4 时模式串前面的字符串 aba 都匹配上了, 其长度为 3,所以我们只需要让 next [6] = 3 + 1 = 4
同样的,虽然模式串继续往右移,在 j = 2 时模式串前面的字符也能够匹配上,但是我们应该优先考虑模式串能匹配上的长度最长这种情况。
后续的推导过程类似,感兴趣的小伙伴可以自己推导一下。
模式串 ababaa,对应的 next 数组为:
序号 j | 1 | 2 | 3 | 4 | 5 | 6 |
---|---|---|---|---|---|---|
模式串 | a | b | a | b | a | a |
next [ j ] | 0 | 1 | 1 | 2 | 3 | 4 |
经过之前的分析,我们可以发现 next [2] 一定是等于 1。因为当 j = 2 时,其前后缀字符串能匹配上的最大长度显然为 0,即 next [2] = 0 + 1 = 1
next 数组代码实现(推荐)
typedef struct {
char str[MAX_SIZE];
int length; //串长
} String;
void getNext(String s, int next[]) { //获取模式串 next数组,s为模式串
int i = 0, j = -1;
next[0] = -1;
next[1] = 0;
while (i < s.length - 1) {
if (j == -1 || s.str[i] == s.str[j]) {
i++;
j++;
next[i + 1] = j + 1;
} else {
j = next[j];
}
}
}
暴力求解 next 数组
void getNext(String s, int next[]) { //获取模式串 next数组,s为模式串
int i = 3;
//如果模式串第 1个字符不匹配,则让模式串指针 j = 1不变,而让主串指针 i后移 <==> 令 j = 0,然后 j++且 i++
next[1] = 0;
next[2] = 1; //模式串第 2个字符对应位置的 next[2]一定为 1,因为没有前后缀字符串
while (i < s.length + 1) {
//模式串第 i个位置,前后缀字符串最多有 (i - 2)组,且字符串长度最大优先匹配
for (int k = 0; k < i - 2; k++) {
//字符串完全匹配标志位,每组初始默认 key为真 <==> key = 1
int key = 1;
//遍历第 k组前缀字符串,第 k组前(后)缀字符串共有(i - 2 - k)个字符
for (int j = 0; j < i - 2 - k; j++) {
//如果有一对字符不匹配,置标志位 key为假 <==> key = 0
if (s.str[j] != s.str[j + k + 1]) {
key = 0;
break;
}
}
if (key == 1) { //如果第 k组匹配成功,置 next[i] = (i - 2 - K) + 1
next[i] = i - k - 1;
break;
}
if (k == i - 3) { //如果最后一组也没有匹配成功
next[i] = 1;
}
}
i++;
}
}
next 数组
这里回顾一下前面对模式串 google 的分析过程。
若 j = 4 时发生不匹配,即 i 当前指向位置的字符不是 g,按照我们之前求 next 数组的方法,应该让 j 回到第1个字符的位置。但是第1个字符刚好也是 g,很明显和当前不匹配的这个字符是一样的,所有这就导致模式串多进行了一次无意义的对比。
其实我们本来就可以利用模式串 google 已存在的这些信息,提前就能知道第1个字符和第4个字符是相同的。所以如果第4个字符发生不匹配,那么肯定和第1个字符也不匹配,其实我们就可以直接让 next [4] = next [1]
对比模式串 google 之前的 next 数组和优化后的 nextval 数组:
序号 j | 1 | 2 | 3 | 4 | 5 | 6 |
---|---|---|---|---|---|---|
模式串 | g | o | o | g | l | e |
next [ j ] | 0 | 1 | 1 | 1 | 2 | 1 |
nextval [ j ] (优化) | 0 | 1 | 1 | 0 | 2 | 1 |
nextval 数组求解方法
- 先求出模式串对应的 next 数组
- 令 nextval [1] = 0,从左到右依次遍历。
- 如果当前第 i 个字符与其 next [ i ] 位置的字符相同,则让 nextval [ i ] = nextval [ next [ i ] ]
- 否则,nextval [ i ] = next [ i ]
例:已知模式串 aaaab,求解其优化后的 nextval 数组
序号 j 1 2 3 4 5 模式串 a a a a b next [ j ] 0 1 2 3 4 next 数组优化过程如下:
- nextval [1] = 0
- 因为 next [2] = 1,第2个字符和第1个字符相同,则 nextval [2] = nextval [1] = 0
- 因为 next [3] = 2,第3个字符和第2个字符相同,则 nextval [3] = nextval [2] = 0
- 因为 next [4] = 3,第4个字符和第3个字符相同,则 nextval [4] = nextval [3] = 0
- 因为 next [5] = 4,第5个字符和第4个字符不相同,则 nextval [5] = next [5] = 4
模式串 aaaab,对应的 nextval 数组为:
序号 j 1 2 3 4 5 模式串 a a a a b nextval [ j ] 0 0 0 0 4
例:已知模式串 ababaa, 求解其优化后的 nextval 数组
序号 j 1 2 3 4 5 6 模式串 a b a b a a next [ j ] 0 1 1 2 3 4 nextval 数组求解过程如下:
- nextval [1] = 0
- 因为 next [2] = 1,第2个字符和第1个字符不相同,则 nextval [2] = next [2] = 1
- 因为 next [3] = 1,第3个字符和第1个字符相同,则 nextval [3] = nextval [1] = 0
- 因为 next [4] = 2,第4个字符和第2个字符相同,则 nextval [4] = nextval [2] = 1
- 因为 next [5] = 3,第5个字符和第3个字符相同,则 nextval [5] = nextval [3] = 0
- 因为 next [6] = 4,第6个字符和第4个字符不相同,则 nextval [6] = next [6] = 4
模式串 ababaa,对应的 nextval 数组为:
序号 j 1 2 3 4 5 6 模式串 a b a b a a nextval [ j ] 0 1 0 1 0 4
到此为止,我们已经学习了 nextval 数组的求解。在 KMP算法中,我们只需要用 nextval 数组替代原来的 next 数组,就可以得到更高的效率。
KMP算法的优化
当 j = k 时发生不匹配,令 j = nextval [ k ]
nextval 数组代码实现
// next数组的优化
nextval[1] = 0;
for (i = 2; i <= s.length; i++) {
//如果模式串第 i个字符等于要跳转的第 next[i]个字符,则让 nextval[i] = nextval[next[i]]
if (s.str[i - 1] == s.str[next[i] - 1]) {
nextval[i] = nextval[next[i]];
} else { //否则,next值保持不变
nextval[i] = next[i];
}
}
文件的分行读取
文件的读取借助 fopen 函数实现,这里我们采取分行读取方式。想要实现一行多次匹配,这里需要定义 index 数组来存储模式串在每一行出现的位置。
一行多次匹配实现方法:
在进行模式匹配时,如果主串与模式串完全匹配,则把模式串在主串中出现的位置,记录到 index 数组中,再重置模式串指针 j 的位置(令 j = 1 指向第1个字符),而主串指针 i 继续移动,直到遍历完主串所有字符为止。
采用上述方法,我们能够实现单行多次的模式匹配,那么只需逐行重复上述方法,就可以实现对文件中每一行的多次匹配。
分行读取代码实现
void fileByKMP(String s) { //逐行重复使用单行多次的模式匹配,s为模式串
int row = 0; //记录行数
FILE *fp;
char bufStr[MAX_SIZE];
String p;
int next[s.length + 1]; // next数组从下标为1的位置开始存储
getNext(s, next); //获取模式串 s对应的 next数组
fp = fopen("D:/test.txt", "r"); //打开文件,文件的绝对路径 D:/test.txt
if (fp == NULL) {
perror("\n\n打开文件失败...");
return;
}
printf("\n\n模式串所在位置:\n");
printf("\t\t\t行\t列\n");
while (!feof(fp)) { //如果没有读到文件末尾
fgets(bufStr, MAX_SIZE, fp); //获取文件每行字符串
row++;
strcpy(p.str, bufStr);
p.length = strlen(p.str);
KMP(p, s, next, row); //调用 KMP算法,文件中每一行字符串相当于一个主串
}
fclose(fp); //关闭文件
fp = NULL;
}