【数据结构与算法】BF算法、KMP算法及OJ题

简介: 【数据结构与算法】BF算法、KMP算法及OJ题

👉引言👈


字符串匹配就是在主串str中查找子串sub(也称为模式串),看子串sub是否在主串str中。如果存在,就返回子串sub在第一次在主串str中出现的位置或者地址(指针);如果不存在,就返回 -1 或者NULL。那么,我想有小伙伴就会问,如果子串sub为空字符串,函数的返回值是什么?库函数strstr的返回值是主串str的首元素地址。那么以下将要介绍的BF算法和KMP算法也和库函数strstr保持一致,即当子串sub为空字符串时,BK算法和KMP算法的返回值为 0(博主的BF算法和KMP的算法返回值为int,当然也可以设置为char*)。


在学习BF算法和KMP算法之前呢,我们来看一下库函数strstr的源码和模拟实现库函数strstr。


👉库函数strstr的源码👈


strstr的函数原型:


char * strstr ( const char *str1, const char * str2);


5158ed2749544592a6ad5f7d3c47a36e.png

char* __cdecl 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 (*s2 && !(*s1 - *s2))
            s1++, s2++;
        if (!*s2)
            return(cp);
        cp++;
    }
    return(NULL);
}


上面就是库函数strstr的源码了,那这代码是什么意思呢?现在就带着大家学习一下(见下图)。希望大家可以将下图仔细地看一下,写得非常的详细。

4b46b68ead834a768c5fa2bf9d17e7b3.png

以上就是库函数strstr的实现逻辑和图解了。其实库函数strstr的可读性较差,同时还没有对传进来的str1str2进行判断为不为NULL,健壮性较差。所以,我们就模拟实现库函数strstr


👉模拟实现库函数strstr👈


//模拟实现库函数strstr
//my_strstr不会修改str和sub的内容,所以用const修饰str和sub,增强代码的健壮性
//str:主串   sub:子串
#include <stdio.h>
#include <assert.h>
char* my_strstr(const char* str, const char* sub)
{
  //对str和sub进行是否等于NULL判断
  assert(str != NULL && sub != NULL);
  //sub为空字符串,直接返回str
  if (*sub == '\0')
  {
    return str;
  }
  const char* s1 = NULL;//遍历主串str
  const char* s2 = NULL;//遍历子串sub
  const char* mark = str;//标记位置
  while (*mark)
  {
    //回溯
    s1 = mark;
    s2 = sub;
    while (*s1 && *s2 && (*s1 == *s2))
    {
      s1++;
      s2++;
    }
    //如果*s2 == '\0',说明sub是str的子串,返回mark
    //反之,则mark++,继续查找
    if (*s2 == '\0')
    {
      return (char*)mark;
    }
    mark++;
  }
  //sub不是str的子串,返回NULL
  return NULL;
}
int main()
{
  char str[] = "ababcabcde";
  char sub[] = "abcd";
  char* ret = my_strstr(str, sub);
  printf("%s\n", ret);
  return 0;
}


其实,不管是库函数strstr还是自定义函数my_strstr的算法思想就是BF算法思想。那BF算法是什么呢?接下来,我们来学习一下。


👉BF算法👈


BF算法,即暴力(Brute Force)算法,是普通的模式匹配算法,BF算法的思想就是将目标串str的第一个字符与模式串sub的第一个字符进行匹配,若相等,则继续比较str的第二个字符和sub的第二个字符;若不相等,则比较str的第二个字符和sub的第一个字符,依次比较下去,直到得出最后的匹配结果。BF算法是一种暴力算法。


看完BF算法的定义,我们就可以发现库函数strstr和自定义函数my_strstr的算法都是BF算法,是一种暴力的算法。

61091fa3dca74171b96504b87115db06.png

博主再借助上面的例子给大家讲解一下BF算法。假定我们给出字符串 ”ababcabcdabcde”作为主串, 然后给出子串 ”abcd”,现在我们需要查找子串是否在主串中

出现,出现就返回主串中的第一个匹配的下标,没有出现则返回 -1。


只要在匹配的过程当中,匹配失败,那么,i 回退(或者回溯)到刚刚位置的下一个,j 回退到 0 下标重新开始。如此反复,直到最终找到或者找不到。


a3e8cd92932e49dd8e934001f18a12d2.png


BF算法的核心


BF算法的核心操作就是回溯。当str[i] != sub[j]时,那么,i 回溯到刚刚位置的下一个,j 回溯到 0 下标重新开始。j 的回溯到 0 比较简单,那 i 的回溯可以借助一个临时变量mark来记住。当然,i 也有有个回溯公式i = i - j + 1。因为 i 和 j 都是每次自增都是自增一的,所以才会有这个回溯公式,大家可以通过上面的例子来验证一下。在此,就不再进行证明了。


BF算法代码实现


#include <stdio.h>
#include <string.h>
#include <assert.h>
//str: 主串    sub: 子串
//返回值:返回子串在主串中第一次出现的下标,没有出现则返回-1
//sub为空字符串时,返回0(与库函数strstr保持一致)
int BF(const char* str, const char* sub)
{
  //判断str和sub是不是NULL
  assert(str && sub);
  int strLen = strlen(str);
  int subLen = strlen(sub);
  //sub为空字符串的情况
  if (subLen == 0)  return 0;
  //主串长度小于子串长度的情况
  if (strLen < subLen)  return -1;
  //上面的主串长度小于子串长度情况会包含在while循环中
  //对这种情况进行单独处理,不用进入while循环
  int i = 0;
  int j = 0;
  while (i < strLen && j < subLen)
  {
    if (str[i] == sub[j])
    {
      i++;
      j++;
    }
    else
    {
      //回溯
      i = i - j + 1;
      j = 0;
    }
  }
  if (j == subLen)//j走完了子串sub,找到了
  {
    return i - j;
  }
  return -1;//没找到子串sub
}
int main()
{
  printf("%d\n", BF("ababcabcdabcde", "abcd"));//5
  printf("%d\n", BF("ababcabcde", "abcdf"));//-1
  printf("%d\n", BF("ababcabcde", "ab"));//0
  printf("%d\n", BF("ababcabcde", "\0"));//0
  printf("%d\n", BF("\0", "a"));//-1
  return 0;
}

ec6e04b0f6f14f73bf1f6a4cb95284dc.png


时间复杂度分析:最坏情况为O(m*n);m 是主串长度,n 是子串长度。


👉KMP算法👈


KMP算法是一种改进的字符串匹配算法,由D.E.Knuth,J.H.Morris和V.R.Pratt提出的,因此人们称它为克努特—莫里斯—普拉特操作(简称KMP算法)。KMP算法的核心是利用匹配失败后的信息,尽量减少模式串与主串的匹配次数以达到快速匹配的目的。具体实现就是通过一个next数组实现,函数本身包含了模式串的局部匹配信息。KMP算法的时间复杂度O(m+n)。


KMP算法和BF算法最大的区别就是:当str[i] != sub[j]时,i 并不会回退,并且 j 回退到特定位置(由next数组决定)。这就是KMP算法比BF算法高效的原因所在了。


为什么 i 可以不回退?


453f18d29eaf412e96642ba894aa967f.png

j 的回退位置

aacf3b46e94e44e296ec523cc2c9c07b.png


其实 j 回退的位置就是 j 前面的字符串的最长相同前后缀的长度,而next数组中存储的就是当前 j 位置前面的字符串的最长相同前后缀的长度。那最长相同前后缀是什么呢?我们现在来学习一下。


最长相同前后缀


现在博主给出一个例子,大家可以看一下。相信聪明的大家,一定能够看懂。


字符串 "abcdab"

前缀的集合:{"a","ab","abc","abcd","abcda"}

后缀的集合:{"b","ab","dab","cdab","bcdab"}

注意:前后缀的集合要求为非空真子集。


那么,最长相同前后缀就是"ab",其长度为 2。这个概念非常地重要,希望大家能够掌握。因为next数组存储的信息就是 j 位置前的字符串的相同前后缀的最大长度。当str[i] != sub[j]时,j 回退的位置就是next[j]。


其实通过肉眼观察,很容易就可以知道一个字符串的相同前后缀的最大长度。但是,如何用代码来求取子串sub的每个位置的相同前后缀的最大长度呢?接下来,我们就来学习next数组的引入。


next 数组的引入


KMP算法的精髓就是next数组:也就是用next[j] = K来表示,不同的 j 来对应一个 K 值。当str[i] != sub[j]时,此时 j 就要回退到下标为 K的位置。


K值求取的规则


1、规则:找到匹配成功部分的两个相等的真子串(不包含本身),一个以下标 0 字符开始,另一个以 j-1 下标 字符结尾。

2、不管什么数据,next[0] = -1,next[1] = 0;在这里,我们以下标来开始,而说到的第几个第几个是从 1 开始。


求next数组的练习


练习 1: 对于字符串”ababcabcdabcde”, 求其的 next 数组?

练习2: 对于字符串”abcabcabcabcdabcde”,求其的 next 数组?


这两道练习就是考察对最长相同前后缀概念的理解,如果还是不懂的话,再看多几次最长相同前后缀的概念。下图是两道练习题的答案:


5a21404df9ec4aa3b31dce36372e16d0.png


到这里,相信大家对如何求next数组应该问题不大了。接下来的问题就是,已知next[i] = k,怎么求next[i+1] = ?如果我们能够通过next[i]的值,通过公式转换得到next[i+1]的值,那么我们就能够通过代码实现这部分。那么其中公式怎么来的呢?见下图证明:


695682a77b3e4de0964738e17b159156.png


35e81bc8dabe4c4f92c5c23a036b2abd.png

338827b8c6d5496bb0b610e056e1d369.png


KMP算法代码实现


#include <stdio.h>
#include <assert.h>
#include <string.h>
#include <stdlib.h>
void GetNext(char* sub, int* next, int subLen)
{
  next[0] = -1;
  if (subLen == 1)
  {
    return;
  }
  next[1] = 0;
  int i = 2;//当前的i下标
  int k = 0;//前一项的k
  while (i < subLen)
  {
    if (k == -1 || sub[i - 1] == sub[k])
    {
      next[i] = k + 1;
      i++;
      k++;
    }
    else
    {
      k = next[k];
    }
  }
}
//str:主串
//sub:子串
//pos:从主串的pos位置开始找
int KMP1(const char* str, const char* sub, int pos)
{
  assert(str && sub);
  int strLen = strlen(str);
  int subLen = strlen(sub);
  if (subLen == 0)  return 0;
  if (strLen < subLen)  return -1;
  if (pos < 0 || pos >= strLen) return -1;
  int* next = (int*)malloc(sizeof(int) * subLen);
  assert(next);
  //引入next数组
  GetNext(sub, next, subLen);
  int i = pos;//遍历主串
  int j = 0;//遍历子串
  while (i < strLen && j < subLen)
  {
    if (j == -1 || str[i] == sub[j])
    {
      i++;
      j++;
    }
    else
    {
      j = next[j];
    }
  }
  if (j == subLen)
  {
    free(next);
    next = NULL;
    return i - j;
  }
  else
  {
    free(next);
    next = NULL;
    return -1;
  }
}
int main()
{
  printf("%d\n", KMP1("ababcabcde", "abcd", 0));//5
  printf("%d\n", KMP1("ababcabcde", "abcdf", 0));//-1
  printf("%d\n", KMP1("ababcabcde", "ab", 0));//0
  printf("%d\n", KMP1("ababcabcde", "a", 0));//0
  printf("%d\n", KMP1("ababcabcde", "a", -1));//-1
  printf("%d\n", KMP1("ababcabcde", "a", 11));//-1
  printf("%d\n", KMP1("\0", "a", 0));//-1
  return 0;
}

62bcbd7849054454a0999f3e99728ec4.png


next数组的优化


next数组的优化,即如何得到nextval数组。

字符串: "aaaaaaaab"

next数组:[-1 0 1 2 3 4 5 6 7]

nextval数组: [-1 -1 -1 -1 -1 -1 -1 -1 7]

为什么出现修正后的数组,假设在 5 号位置匹配失败了,那退一步还是字符 a,还是匹配失败,接着退还是 字符 a。

271f1e54abdb4eccb85420733eec2da3.png


那么如何对next数组来进行优化呢?


1bef6904504246c8b3fd599c1da843ce.png


为了让大家熟悉next数组的优化,请大家完成下面的练习。


练习:模式串 t= “abcaabbcabcaabdab” ,该模式串的 next 数组的值为( ) , nextval 数组的值为 ()。

A. 0 1 1 1 2 2 1 1 1 2 3 4 5 6 7 1 2

B. 0 1 1 1 2 1 2 1 1 2 3 4 5 6 1 1 2

C. 0 1 1 1 0 0 1 3 1 0 1 1 0 0 7 0 1

D. 0 1 1 1 2 2 3 1 1 2 3 4 5 6 7 1 2

E. 0 1 1 0 0 1 1 1 0 1 1 0 0 1 7 0 1

F. 0 1 1 0 2 1 3 1 0 1 1 0 2 1 7 0 1

答案:D和F,大家尝试做一下,做不出来再看下面的解析。


14e43672a592488aa8eb17b40fef76f3.png

nextval 数组代码实现


#include <stdio.h>
#include <string.h>
#include <assert.h>
#include <stdlib.h>
//next数组的优化
//sub:子串
//str:主串
void NextVal(char* sub, int* nextval , int subLen)
{
  assert(sub);
  nextval[0] = -1;
  if (subLen == 1)
  {
    return;
  }
  nextval[1] = 0;
  int i = 2;//当前的i下标
  int k = 0;//前一项的k值
  //先将nextval数组初始化为next数组
  while (i < subLen)
  {
    if (k == -1 || sub[i - 1] == sub[k])
    {
      nextval[i] = k + 1;
      i++;
      k++;
    }
    else
    {
      k = nextval[k];
    }
  }
  //修改nextval数组
  i = 2;
  for (; i < subLen; i++)
  {
    //回退到的位置上的字符和当前i位置上的字符相等
    //nextval[i]为回退到的位置
    //sub[nextval[i]]为回退到的位置上的字符
    if (sub[i] == sub[nextval[i]])
    {
      nextval[i] = nextval[nextval[i]];
    }
  }
}
int KMP2(char* str, char* sub, int pos)
{
  assert(str && sub);
  int strLen = strlen(str);
  int subLen = strlen(sub);
  if (subLen == 0)  return 0;//子串sub为空字符串
  if (strLen < subLen)  return -1;//主串长度小于子串长度
  if (pos < 0 || pos >= strLen) return -1;//索引位置非法
  //nextval数组
  int* nextval = (int*)malloc(sizeof(int) * subLen);
  assert(nextval);//判断申请空间是否成功
  NextVal(sub, nextval, subLen);
  int i = pos;//遍历主串
  int j = 0;//遍历子串
  while (i < strLen && j < subLen)
  {
    if (j == -1 || str[i] == sub[j])
    {
      i++;
      j++;
    }
    else
    {
      j = nextval[j];
    }
  }
  if (j == subLen)//在主串中找到子串
  {
    free(nextval);
    nextval = NULL;
    return i - j;
  }
  else//在主串中找不到子串
  {
    free(nextval);
    nextval = NULL;
    return -1;
  }
}
int main()
{
  printf("%d\n", KMP2("ababcabcde", "abcd", 0));//5
  printf("%d\n", KMP2("ababcabcde", "abcdf", 0));//-1
  printf("%d\n", KMP2("ababcabcde", "ab", 0));//0
  printf("%d\n", KMP2("ababcabcde", "a", 0));//0
  printf("%d\n", KMP2("ababcabcde", "a", -1));//-1
  printf("%d\n", KMP2("ababcabcde", "a", 11));//-1
  printf("%d\n", KMP2("\0", "a", 0));//-1
  return 0;
}

2be9727dfc824c0c9f0cecec6ed8a575.png


👉找出字符串中第一个匹配项的下标👈


给你两个字符串 haystack 和 needle ,请你在 haystack 字符串中找出 needle 字符串的第一个匹配项的下标(下标从 0 开始)。如果 needle 不是 haystack 的一部分,则返回 -1 。

示例 1:


输入:haystack = "sadbutsad", needle = "sad"

输出:0

解释:"sad" 在下标 0 和 6 处匹配。第一个匹配项的下标是 0 ,所以返回 0 。



示例 2:


输入:haystack = "leetcode", needle = "leeto"

输出:-1

解释:"leeto" 没有在"leetcode" 中出现,所以返回 -1 。


void NextVal(char* needle, int* nextval, int len2)
{
    assert(needle);
    nextval[0] = -1;
    if(len2 == 1)
    {
        return;
    }   
    nextval[1] = 0;
    int i = 2;//当前i位置
    int k = 0;//前一项的k值
    //先将nextval数组初始化为next数组
    while(i < len2)
    {
        if(k == -1 || needle[i - 1] == needle[k])
        {
            nextval[i] = k + 1;
            i++;
            k++;
        }
        else
        {
            k = nextval[k];
        }
    }
  //修改nextval数组
  for (i = 2; i < len2; i++)
  {
    //回退到的位置上的字符和当前i位置上的字符相等
    //nextval[i]为回退到的位置
    //sub[nextval[i]]为回退到的位置上的字符
    if (needle[i] == needle[nextval[i]])
    {
      nextval[i] = nextval[nextval[i]];
    }
  }
}
int strStr(char * haystack, char * needle)
{
    assert(haystack && needle);
    int len1 = strlen(haystack);//haystack字符串的长度
    int len2 = strlen(needle);//needle字符串的长度
    if(len1 < len2) return -1;//主串长度小于子串长度
    //引入nextval数组
    int* nextval = (int*)malloc(sizeof(int) * len2);
    assert(nextval);//判断申请空间是否成功
    NextVal(needle, nextval, len2);
    int i = 0;//遍历主串
    int j = 0;//遍历子串
    while(i < len1 && j < len2)
    { 
        if(j == -1 || haystack[i] == needle[j])
        {
            i++;
            j++;
        }
        else
        {
          //回溯
            j = nextval[j];
        }
    }
    if(j == len2)//在主串中找到子串   
    {
        free(nextval);
        nextval = NULL;
        return i - j;
    }
    else//在主串中找不到子串
    {
        free(nextval);
        nextval = NULL;
        return -1;
    }
}

6091cb7321aa49d7b4676c0ced10550e.png



👉总结👈


本篇博客主要讲解了库函数strstr的源码、模拟实现库函数strstr、BF算法以及KMP算法。博主相信这是全网最详细的字符串匹配算法讲解了,如果大家能够认真看完,肯定会有不少的收获。那就麻烦大家给个三连支持一下!谢谢大家啦!💖💝❣️




相关文章
|
2月前
|
存储 人工智能 算法
数据结构与算法细节篇之最短路径问题:Dijkstra和Floyd算法详细描述,java语言实现。
这篇文章详细介绍了Dijkstra和Floyd算法,这两种算法分别用于解决单源和多源最短路径问题,并且提供了Java语言的实现代码。
84 3
数据结构与算法细节篇之最短路径问题:Dijkstra和Floyd算法详细描述,java语言实现。
|
2月前
|
机器学习/深度学习 存储 缓存
数据结构与算法学习十:排序算法介绍、时间频度、时间复杂度、常用时间复杂度介绍
文章主要介绍了排序算法的分类、时间复杂度的概念和计算方法,以及常见的时间复杂度级别,并简单提及了空间复杂度。
33 1
数据结构与算法学习十:排序算法介绍、时间频度、时间复杂度、常用时间复杂度介绍
|
2月前
|
存储 算法 Java
Set接口及其主要实现类(如HashSet、TreeSet)如何通过特定数据结构和算法确保元素唯一性
Java Set因其“无重复”特性在集合框架中独树一帜。本文解析了Set接口及其主要实现类(如HashSet、TreeSet)如何通过特定数据结构和算法确保元素唯一性,并提供了最佳实践建议,包括选择合适的Set实现类和正确实现自定义对象的hashCode()与equals()方法。
36 4
|
2月前
|
搜索推荐 算法
数据结构与算法学习十四:常用排序算法总结和对比
关于常用排序算法的总结和对比,包括稳定性、内排序、外排序、时间复杂度和空间复杂度等术语的解释。
22 0
数据结构与算法学习十四:常用排序算法总结和对比
|
2月前
|
机器学习/深度学习 搜索推荐 算法
探索数据结构:初入算法之经典排序算法
探索数据结构:初入算法之经典排序算法
|
2月前
|
算法 Java 索引
数据结构与算法学习十五:常用查找算法介绍,线性排序、二分查找(折半查找)算法、差值查找算法、斐波那契(黄金分割法)查找算法
四种常用的查找算法:顺序查找、二分查找(折半查找)、插值查找和斐波那契查找,并提供了Java语言的实现代码和测试结果。
25 0
|
27天前
|
C语言
【数据结构】栈和队列(c语言实现)(附源码)
本文介绍了栈和队列两种数据结构。栈是一种只能在一端进行插入和删除操作的线性表,遵循“先进后出”原则;队列则在一端插入、另一端删除,遵循“先进先出”原则。文章详细讲解了栈和队列的结构定义、方法声明及实现,并提供了完整的代码示例。栈和队列在实际应用中非常广泛,如二叉树的层序遍历和快速排序的非递归实现等。
123 9
|
18天前
|
存储 算法
非递归实现后序遍历时,如何避免栈溢出?
后序遍历的递归实现和非递归实现各有优缺点,在实际应用中需要根据具体的问题需求、二叉树的特点以及性能和空间的限制等因素来选择合适的实现方式。
23 1
|
5天前
|
存储 缓存 算法
在C语言中,数据结构是构建高效程序的基石。本文探讨了数组、链表、栈、队列、树和图等常见数据结构的特点、应用及实现方式
在C语言中,数据结构是构建高效程序的基石。本文探讨了数组、链表、栈、队列、树和图等常见数据结构的特点、应用及实现方式,强调了合理选择数据结构的重要性,并通过案例分析展示了其在实际项目中的应用,旨在帮助读者提升编程能力。
26 5
|
21天前
|
存储 算法 Java
数据结构的栈
栈作为一种简单而高效的数据结构,在计算机科学和软件开发中有着广泛的应用。通过合理地使用栈,可以有效地解决许多与数据存储和操作相关的问题。