摘要:改进的字符串匹配算法–KMP算法
嵌入式软件开发时会在某个长字符串中,检索出现指定字符串标识的需求,最常用的库函数就是strstr。其源码有很多版本,常用经典实现方式:
char *strstr(const char *str1, const char *str2) {
char *cp = (char *) str1;
char *s1, *s2;
if (!*str2)
return ((char *) str1);
while (*cp) {
s1 = cp;
s2 = (char *) str2;
while (*s1 && *s2 && !(*s1 - *s2))
s1++, s2++;
if (!*s2)
return (cp);
cp++;
}
return NULL;
}
所有以str开头的字符串处理函数,最大限制是对传入的指针内容,中间不能出现‘\0’,否则会导致字符串截止。上面的源码中出现的3处条件判断,只对ASCII码字符串有效。
实际应用中,从一个大数组中找出小数组出现的位置,两个数组的内容可能出现0x00,使用strstr就无法满足需求。
根据查找需求,不难实现暴力查找算法,在主串中逐个查找子串第一个数据,相等则匹配第二个;出现不相同的则主串位置回到第一次相等位置加1,而子串回到第一个位置重新开始匹配。
#include "string.h"
#include "stdio.h"
typedef unsigned char uint8_t;
/**
* @brief brute_force 字符串暴力匹配算法
* @param str 主串数据
* @param str_size
* @param sub 模式串数据,需匹配的关键字数据
* @param sub_size
* @return
* <0 :fail
* >=0 :success ,sub在str中出现地址的偏移量
*/
static int brute_force(const uint8_t *str, int str_size,const uint8_t *sub,int sub_size)
{
int i = 0;
int j = 0;
if(sub_size==0)
{
return 0;
}
if(str_size < sub_size)
{
return -1;
}
while(i < str_size && (j < sub_size))
{
if(str[i] == sub[j])
{
i++;
j++;
}
else
{
i = i-j+1;
j = 0;
}
}
if(j == sub_size)
{
return i - j;
}
else
{
return -1;
}
}
int main(int argc, char *argv[])
{
int ret;
uint8_t str[]={0x00,0x11,0x00,0x11,0x33,\
0x00,0x11,0x00,0x11,0x44,\
0x00,0x11,0x00,0x11,0x22,\
0x00};
uint8_t sub[]={0x00,0x11,0x00,0x11,0x22};
ret=brute_force(str,sizeof(str),sub,sizeof(sub));
printf("ret=%d\r\n",ret);
return 0;
}
//运行结果
ret=10
从上面的暴力查找算法,sub出现在str主串的地址偏移10字节的地方,功能是正常,但效率呢?2次出现比较到最后一个字节才出现不相同,然后重新开始查找,是否可以改进呢?
在字串(模式串)中有多个字符和主串中的若干个连续字符比较都相等,但最后一个字符比较不相等时,主串和模式串地址都需要回退,这就损失了效率。
分析sub子串,其前部分有重复的0x00 0x11,如果在0x22处比较不同,是不需要回退,可以只是回退1个字节继续比较,这样提高检索效率。这个思路,就是本文重点KMP算法。
KMP算法是一种改进的字符串匹配算法,由D.E.Knuth,J.H.Morris和V.R.Pratt提出的,因此人们称它为克努特—莫里斯—普拉特操作(简称KMP算法)。KMP算法的核心是利用匹配失败后的信息,尽量减少模式串(子串)与主串的匹配次数,以达到快速匹配的目的。
以下数据是16进制,就是为了表明KMP支持任意值的数组查找,不限ASCII。
假设已经查找到中间某个状态,如下表:
主串 | 00 | 11 | 00 | 11 | 33 | 00 | 11 | 00 | 11 | -44- | 00 | 11 | 00 | 11 | 22 | 00 |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
模式串 | 00 | 11 | 00 | 11 | -22- |
使用暴力查找,下一步比较的效果如下:
主串 | 00 | 11 | 00 | 11 | 33 | 00 | -11- | 00 | 11 | 44 | 00 | 11 | 00 | 11 | 22 | 00 |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
暴力 | -00- | 11 | 00 | 11 | 22 |
而使用KMP可以越过前面的:
主串 | 00 | 11 | 00 | 11 | 33 | 00 | 11 | 00 | 11 | -44- | 00 | 11 | 00 | 11 | 22 | 00 |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
KMP | -00- | 11 | 00 | 11 | 22 |
数组下标不用回退,继续往前移动比较,效率明显比暴力查找快。
对于KMP中模式串比较如何偏移,需要在检索前根据模式串的前缀生成next数组。
完整KMP算法如下:
//自动重建偏移关系表
int *get_next(const uint8_t *str1, int length, int *next)
{
int i, j;
i = 0;
j = -1;
next[0] = -1;
while(i < length)
{
if(j == -1 || str1[i] == str1[j])
{
++i;
++j;
if(str1[i] != str1[j])
{
next[i] = j;
}
else
{
next[i] = next[j];
}
}
else
{
j = next[j];
}
}
return next;
}
/**
* @brief brute_force
* @copyright 微信公众号【嵌入式系统】
* @param str 主检索数据
* @param str_size
* @param sub 需匹配的关键字数据
* @param sub_size
* @return
* <0 :fail
* >=0 :success ,sub在str中出现的地址偏移量
*/
int kmp(const uint8_t *str, int str_size, const uint8_t *sub, int sub_size)
{
int i = 0;
int j = 0;
int next[1025];//大于sub_size
get_next(sub, sub_size, next);
while(i <= str_size && j <= sub_size)
{
if(j == -1 || str[i] == sub[j])
{
i++;
j++;
}
else
{
j = next[j];
}
}
if(j >= sub_size) //返回值为第一次匹配到该字符串的位置
{
return i - sub_size -1;//第i个,但从0开始计算下标,需要减1
}
else
{
return -1;
}
}
为验证三种查找算法的效率,定义较长的两ASCII字符串,运行10000次,其时间如下图:
KMP的算法明显占优,因为模式串中有循环出现的片段,这种更能体现KMP的效率。
对于不同场景的算法选择,可参考下面的方式:
if (主串和模式串内容都是ASCII串)
{
if(两字符串都比较简短)
{
strstr
}
else
{
strstr or kmp //子串有大量重复片段的更能体现效率,如 ABCDABCDABCDE,否则差不多
}
}
else
{
if(两字符串都比较简短)
{
暴力 or kmp //如果子串前面没有重复片段,而且长度小于255,暴力算法不一定差
}
else
{
kmp
}
}