数据结构之串与KMP算法详解

news2025/1/10 11:31:26

一. 定义(了解)

串,即字符串,是计算机系统和网络传输中最常用的数据类型,任何非数值型的处理都会以字符串的形式存储和使用。

串(String)是由零个或多个字符组成的有限序列,一般记为:
S = ′ a 1 a 2 … a n ′ ( n ≥ 0 ) S='a_1a_2 \dots a_n' \quad (n \geq 0) S=a1a2an(n0)

  • S :串名

  • a_i :字符(任意一种可被编码的符号,即为字符)

  • n :串的长度(n=0S=空

  • 子串:串的子序列(如abc即为dabcd的子串)

  • 主串:子串的原始串

  • 位置:字符在串中的序号(子串的位置由首字符标识)

  • 串相等:长度相等且每个字符相等。

  • 串的大小比较规则:

    • 从第一个字符依次往后比较,以第一个不相等的字符的大小标识串的大小

    • 若每个字符都相等,则短串小于长串。

    • 若串长度也相等,则串相等。

    • 注: 字符的大小比较规则,以编码值的大小为准.

      如按ASCALL编码,字符A编码63,字符a编码97,则可认为A<a

    对于串的正确理解,需要注意以下几点:

    1. 空格串与空串并不等价,如串' '是长度为2的空格串(实际上,除了空格还有许多不可见字符如'\n'等,对于这些字符在字符串里的地位和可见字符完全等价)。
    2. 串的逻辑结构和线性表非常相似,实际上,将线性表中的元素类型设为字符型就是串结构的一种实现方式。区别在于线性表主要关注的是元素"crud"等操作,而其中元素类型是具有抽象性的;而串则限定了其中的元素类型为字符型,其关注的重点主要是查找删除或者插入一个子串这种独属于字符串的操作。

串同样具有顺序链式两种存储结构,这里不过多赘述,它的基本操作如下:

  • StrAssign(&T, chars): 赋值操作。把串 T 赋值为 chars
  • StrCopy(&T, S): 复制操作。由串 S 复制得到串 T
  • StrEmpty(S): 判空操作。若 S 为空串,则返回 TRUE,否则返回 FALSE
  • StrCompare(S, T): 比较操作。若 S > T,则返回值 > 0;若 S = T,则返回值 = 0;若 S < T,则返回值 < 0。
  • StrLength(S): 求串长。返回串 S 的元素个数。
  • SubString(&Sub, S, pos, len): 求子串。用 Sub 返回串 S 的第 pos 个字符起长度为 len 的子串。
  • Concat(&T, S1, S2): 串联接。用 T 返回由 S1S2 联接而成的新串。
  • Index(S, T): 定位操作。若主串 S 中存在与串 T 值相同的子串,则返回它在主串 S 中第一次出现的位置;否则函数值为 0
  • ClearString(&S): 清空操作。将 S 清为空串。
  • DestroyString(&S): 销毁操作。将串 S 销毁。

二. 串的模式匹配(重要)

以下讨论中的串结构我们均采用顺序存储的模式,其声明如下:

typedef struct {
    char ch[MAXLEN]; //注意,这里我们统一从下标为1的位置开始使用数据
    int length;
}String; //string(首字母小写)为C++语言内置的数据类型,这里使用首字母大写以示区别
1. 朴素算法

所谓串的模式匹配,即是串的基本操作中的Index(S, T)的实现。

如对于串S="google", T="ogl"的测试用例,函数应该返回TS中的位置,即应返回2

而对于串S="google", T="ogld"的测试用例来说,由于T并不是S的子串,所以应该返回-1

这里暴力匹配的方法应该很容易理解:

int Index(String S, String T) {
    int i = 1, j = 1; //字符串的下标从1开始
    while (i <= S.length && j <= T.length) {
        if (S.ch[i] == T.ch[j]) {
            ++i, ++j;
        }
        else {
            i = i - j + 2;
            j = 1;
        }
    }
    if (j > T.length) {
        return i - T.length;
    }
    return 0; //匹配失败
}

朴素模式匹配

这种模式匹配的时间复杂度为 O(mn) 其中n和m分别是串S和T的长度,因为在最坏的情况下(每次都是T字符串的最后一位不匹配),针对S中的每一个字符,T都需要遍历整个字符串。

// 朴素模式匹配最坏情况
S = "000000000000000000000000000001";
T = "000001";
2. KMP算法

显然,朴素匹配的思想太过”朴素“,无法满足我们对Index函数的性能要求,那么,我们该如何优化呢?

试想上面提到的朴素算法最坏情况的那个例子:

S = "000000000000000000000000000001";
T = "000001";

朴素算法耗时之处就在于,我们在比对子串时需要从T字符串开头逐字符进行对比,一旦匹配失败,则主串i指针需要回溯到当前对比子串的下一个子串开头,正是这个回溯的过程,才导致了朴素匹配算法的效率低下。

00000'0'000000000000000000000001
00000'1'
//匹配失败后,需要进行回溯
0'0'0000000000000000000000000001
 '0'00001   //需要从这里开始重新对比

但 Knuth Morris Pratt (KMP算法的三位创始人,KMP算法因此得名) 认为,这种回溯操作不是必要的:

00000'0'000000000000000000000001
00000'1' 
/*
当最后一个字符匹配失败后,实际上前面的字符信息我们已经已知
即,当T字符串中第j个字符不匹配时,实际上就代表着,S和T的前j-1个字符是匹配的!
*/

那么,将i指针回溯到下一个子串的初始位置就不必要的,因为在上次匹配中,我们已经知道了:

  • 子串的第二位和主串的第二位匹配
  • 根据子串本身的特点(KMP算法的核心所在),子串的第一位与子串第二位相同。
  • 所以,我们得出主串的第二位和子串的第一位一定匹配!那么,i指针回溯到下一个子串的位置(这里指第二位)就没有必要。

那么,i指针到底应该回溯到哪呢?事实上,i指针并不需要回溯!

理由是,既然子串匹配失败,那就代表着假如可以匹配成功,那么子串的结尾字符一定会在i之后(否则不会匹配失败)。

所以,我们只需要将j(指向子串)指针回溯到一个合理的位置继续对比即可。

00000'0'000000000000000000000001
00000'1'
//只需要将j指针回溯到第一位即可
00000'0'000000000000000000000001
     '0'00001 //i指针不回溯,继续对比

其实i不回溯本质上的原因就在于在扫描的过程中,位置i之前串的信息我们已经掌握,没有回溯的必要。

所以现在的问题就变成了,当不匹配发生时,j指针应该回溯到什么位置?

所以到这就可以看出,解决问题的关键实际上不在于主串,而在于子串,所以KMP算法的核心就是利用子串本身的特点简化匹配过程

这里我们引入next数组next[j]的含义是当子串第j位发生不匹配时,j指针应该回退到next[j]的位置。

我们先介绍其手算的方式,这也是考研数据结构的核心考点

假设子串T="abaabc";

  1. T[1]不匹配时

    由于是子串的首字符不匹配,所以此时应当进行的操作实际上是将主串指针i后移一位继续对比,但是为了程序书写的一致性,我们让j回退到0,然后让ij同时后移一位,即**next[1]=0;**。

  2. T[2]不匹配时

    需要将j回溯到首位继续对比,故**next[2]=1**。

  3. T[3]不匹配时

    ***ab'*'********
       ab'a'  //发生不匹配
     
    ***ab'*'********
         'a'ba //你应该让j指针回到第一位进行对比
    

    所以,next[3]=1

  4. T[4]不匹配时

    ***aba'*'*******
       aba'a'  //发生不匹配
     
    ***aba'*'*******
         a'b'aa //你应该让j指针回到第2位进行对比
    

    所以,next[4]=2

  5. T[5]不匹配时

    ***abaa'*'******
       abaa'b'  //发生不匹配
     
    ***abaa'*'******
          a'b'aab //你应该让j指针回到第2位进行对比
    

    所以,next[5]=2

  6. T[6]不匹配时

    ***abaab'*'******
       abaab'c'  //发生不匹配
     
    ***abaab'*'******
          ab'a'abc //你应该让j指针回到第3位进行对比
    

    所以,next[6]=3

所以对于子串"abaabc"来说,其next数组情况如下:

j123456
next[j]011223

那么,我们使用next数组来模拟一下匹配过程:

假设主串S="abaccabaacabaabca",子串T="abaabc",那么,匹配流程为:

1. 
aba'c'cabaacabaabca
aba'a'bc  //T[4]不匹配,j=next[4]=2
    
2. 
aba'c'cabaacabaabca
  a'b'aabc  //T[2]不匹配,j=next[2]=1
    
3. 
aba'c'cabaacabaabca
   'a'baabc  //T[1]不匹配,j=next[1]=0, 然后i和j同时++
    
4. 
abac'c'abaacabaabca
    'a'baabc  //T[1]不匹配,j=next[1]=0, 然后i和j同时++
    
5. 
abaccabaa'c'abaabca
     abaa'b'c  //T[5]不匹配,j=next[5]=2
    
6. 
abaccabaa'c'abaabca
        a'b'aabc  //T[5]不匹配,j=next[5]=2
    
7. 
abaccabaa'c'abaabca
         'a'baabc  //T[2]不匹配,j=next[2]=1
    
8. 
abaccabaac'abaabc'a
          'abaabc'  //完全匹配,算法结束

所以得到了next数组以后,模式匹配的代码如下:

int Index(String S, String T, int next[]) {
    int i = 1, j = 1; //字符串的下标从1开始
    while (i <= S.length && j <= T.length) {
        if (j = 0 || S.ch[i] == T.ch[j]) {
            ++i, ++j;
        }
        else {
            j = next[j]; //发生匹配失败的情况,i指针不回溯
        }
    }
    if (j > T.length) {
        return i - T.length;
    }
    return 0; //匹配失败
}

接下来,我们总结一下next数组的手算方式:

对于任意一个子串:

  • next[1]=0:首字符不匹配,等于0之后两个指针同时++。
  • next[2]=1:第二个字符不匹配,则回到第一个字符开始比较。

从第三个字符开始,我们可以采用手动模拟的形式计算,即自己观察若第j (j > 2) 个字符不匹配,则j指针应该回溯到哪里才能让前j-1个字符与主串对应位置的字符相等。

例:求"aabaac"next数组。

0 1 2 1 2 3

下面我们分析一下如何编程求出next数组(考研了解即可)

我们假设子串 T = T 1 T 2 … T m T=T_1T_2 \dots T_m T=T1T2Tm

我们已知 next[1]=0,我们假设 next[j]=k (j>1),那么就代表着,当第j个元素匹配失败时,我们应该从第k个元素开始继续比较,那就意味着:
T 1 T 2 … T k − 1 = T j − k + 1 T j − k + 2 … T j − 1 T_1T_2 \dots T_{k-1}=T_{j-k+1}T_{j-k+2} \dots T_{j-1} T1T2Tk1=Tjk+1Tjk+2Tj1
且不存在更长的子序列满足这个条件。

就像,T=abaabc,其next[6]=3 (next[c]=a)的真正原因在于,失配的c的前两个字符组成的子串ab,与T[3]的前缀ab相同,所以当最后一个c失配时,只需要从T[3]开始匹配即可。

那么,我们现在来讨论next[j+1]的情况。

  • T k = T j T_k = T_j Tk=Tj 时,next[j+1] = k+1,即 next[j+1]=next[j]+1

  • T k ≠ T j T_k \neq T_j Tk=Tj 时,

    代表着此时j+1位置的前k个字符,和字符串的前k个字符就不相等了,那么我们怎么办呢?

    答案是显然的,找更短的子串使其相等。即让 k1=next[k],接着判断前k1个元素和j+1位置的前k1个元素组成的子序列是否相等,若相等,则next[j+1]=k1+1;若还不相等,则继续缩短子序列进行判断(k2=next[k1])。

    若一直不相等,则子序列的长度会被缩短为0,因为next[1]=0,此时,next[j+1]=1,即从头开始比较。

kmp

将以上思路,转换为代码,就可以得到next数组的编程求法了:

void get_next(String T, int next[])
{
    int i = 1, k = 0;
    next[1] = 0;
    while (i < T.length) {
        if (k == 0 || T.ch[i] == T.ch[k]) {
            i++, k++;
            next[i] = k;
        }
        else {
            k = next[k];
        }
    }
}

到这里为止,我们就可以分析KMP算法的时间复杂度了(假设主串长为n,子串长为m):

  • 对于匹配的过程,由于主串指针i不回溯,故时间复杂度 O ( n ) O(n) O(n)
  • 对于求解next数组的过程,由于程序主体是从第二个字符开始依次求解,而next数组的回溯速度较快(近乎常数),所以时间复杂度近似 O ( m ) O(m) O(m)

故整体的时间复杂度为: O ( m + n ) O(m+n) O(m+n)

这里时间复杂度的分析仅为了便于理解,本篇文章不详细解析KMP算法时间复杂度的计算,感兴趣可自行查阅资料。

3. KMP算法进一步优化

这里让我们回到朴素的那个例子:

S = "000000000000000000000000000001";
T = "000001";

试着求解一下这里子串Tnext数组,其值如下:

j123456
T[j]‘0’‘0’‘0’‘0’‘0’‘1’
next[j]012345

假设当T[5]发生失配时,按照next数组,我们应该将j指针回溯到4的位置:

***0000'*'********* 
   0000'0'1  //T[5]发生失配, '*'必然不是0
 
***0000'*'*********
    000'0'01 //回溯到4的位置, 必然失配

但是这样就会产生一个问题,当T[5]发生失配时,我们可以知道主串对应的位置的值必然不等于T[5],而T[4]==T[5],所以j指针回溯到4的位置必然失配。

事实上,观察子串"000001"子串可知,当T[5]发生失配时,回溯到除0以外的任何位置都会失配。

那么问题出在哪了呢?我们观察为什么会出现这种现象,很容易看出这里的 T[5]==T[next[5]],即出现了T[j]==T[next[j]]的现象,这其实是不应该出现的,因为如果T[j]发生了失配,且T[j]==T[next[j]],那么T[next[j]]就一定会发生失配。

所以在这里,我们就可以对next数组的生成进行优化,如果T[j]==T[next[j]],那么我们应该递归似的将next[j]赋值为next[next[j]],直到二者不相等为止。优化以后的数组,我们习惯上称为nextVal数组,其生成代码如下:

void get_nextVal(String T, int nextVal[])
{
    int i = 1, k = 0;
    nextVal[1] = 0;
    while (i < T.length) {
        if (k == 0 || T.ch[i] == T.ch[k]) {
            i++, k++;
            if (T.ch[i] != T.ch[k]) {
                nextVal[i] = k;
            } else {
                //实际计算时是从前往后遍历,这样可以保证这条语句最多只需要执行一次
                nextVal[i] = nextVal[k];
            }
        }
        else {
            k = nextVal[k];
        }
    }
}

考研的同学需要注意题目需要求解的是next还是nextVal

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2065301.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

多选类型项,点击亮或不亮

用于菜单下拉 多选项 。 <div style"display: flex; flex-wrap: wrap;margin: 0 auto;"><div v-for"(item, index) in prpductnames" :key"item.id"><span :class"{ selected: selectArr.includes(item.id) }" click&q…

《计算机操作系统》(第4版)第7章 文件管理 复习笔记

第7章 文件管理 一、文件和文件系统 1. 数据项、记录和文件 数据组成可分为数据项、记录和文件三级&#xff0c;它们之间的层次关系如图7-1所示。 图7-1 文件、记录和数据项之间的层次关系 (1)数据项 在文件系统中&#xff0c;数据项是最低级的数据组织形式&#xff0c;可以分为…

Grove Vision AI V2之GPIO

一、说明 实现一个LED闪烁的Demo&#xff0c;Grove Vision AI V2开发板上有一个USER_LED&#xff0c;由GPIO SEN_D2驱动&#xff0c;SEN_D2为高电平是USER_LED亮&#xff0c;SEN_D2为低电平时USER_LED灭。 USER_LED部分电路如下&#xff1a; 二、创建例程 1、创建文件 在See…

MySQL的源码安装及基本部署(基于RHEL7.9)

这里源码安装mysql的5.7.44版本 一、源码安装 1.下载并解压mysql , 进入目录: wget https://downloads.mysql.com/archives/get/p/23/file/mysql-boost-5.7.44.tar.gz tar xf mysql-boost-5.7.44.tar.gz cd mysql-5.7.44/ 2.准备好mysql编译安装依赖: yum install cmake g…

上线eleme项目

&#xff08;一&#xff09;搭建主从从数据库 主服务器master 首先下载mysql57安装包&#xff0c;然后解压 复制改目录到/usr/local底下并且改个名字 cp -r mysql-5.7.44-linux-glibc2.12-x86_64 /usr/local/mysql 删掉/etc/my.cnf 这个会影响mysql57的启动 rm -rf /etc…

浪潮服务器主板集成RAID常见问题

★主板集成RAID出现Initialize初始化&#xff0c;如下图 判断及解决方案&#xff1a; 1.机器是否有过插拔硬盘等操作。 2.系统初始化-系统启动会非常的慢。一般为非法关机或者断电导致。 3.出现此情况耐心等待磁盘初始化完成即可。系统初始化时间以具体的数据大小来决定&#…

CLion IDE用MSVC和cmake编译darknet(带GPU)

这个配置教程给用过pytorch&#xff0c;懂得深度学习代码的基本流程&#xff0c;但又不熟悉windows c开发环境的宝子们使用。 安装CUDA&#xff0c;CUDNN 一般都有&#xff0c;不说了。注意上nvidia官网看一下显卡架构&#xff0c;后面要用&#xff0c;比如我的丽台M2000架构…

从零开始Dify本地部署|Windows

参考官方文档部署 Dify本地源码启动 windows最好结合WSL使用&#xff0c;懒得配置WSL&#xff0c;就是硬肝&#xff01; 1.Clone Dify 代码 先找到项目GitHub 开源链接clone 下来&#xff0c;使用docker部署运行&#xff08;Windows配置docker环境这里不赘述&#xff09; gi…

Prometheus Alertmanager告警之邮件、钉钉群、企业微信群机器人报警

文章目录 一、部署alertmanager相关组件1.alertmanager-config2.alertmanager-message-tmpl3.alertmanager 二、调试邮件告警三、钉钉群/企业微信群 报警3.1添加钉钉群机器人3.2添加企业微信群机器人3.3部署alertmanager-webhook-adaptermessage-tmplalertmanager-webhook-adap…

Windows环境下,使用bat脚本配置本地域名解析(含新增、修改、清理)

适用场景&#xff1a; 1.内部不存在DNS服务器的客户&#xff1b; 2.客户电脑不知道前期是否过某域名的本地解析。 整体思路&#xff1a; 1.备份原始hosts配置文件&#xff1b; 2.将hosts配置文件中包含xxxxxxxxxx.com域名的解析清空&#xff1b; 3.写入正确的解析到hosts配置文…

发布MindSearch到ModelScope创空间

一、创建空间 1.点击【我要创建】来创建一个应用 2.填写完基础信息 会出现一个未发布的应用 二、上传代码 1.参照当前页的指示一步一步将MindSearch部署需要的文件上传到指定的repo即可 2.在当前页点击【空间文件】查看刚才通过命令上传的文件 三、上线应用 1.点击【设置】可…

【论文阅读】A Closer Look at Parameter-Efficient Tuning in Diffusion Models

Abstract 大规模扩散模型功能强大&#xff0c;但微调定制这些模型&#xff0c;内存和时间效率都很低。 本文通过向大规模扩散模型中插入小的学习器(称为adapters)&#xff0c;实现有效的参数微调。 特别地&#xff0c;将适配器的设计空间分解为输入位置、输出位置、函数形式的…

什么是ConcurrentHashMap?实现原理是什么?

什么是ConcurrentHashMap&#xff1f;实现原理是什么&#xff1f; 在多线程环境下&#xff0c;使用HashMap进行put操作时存在丢失数据的情况&#xff0c;为了避免这种bug的隐患&#xff0c;强烈建议使用ConcurrentHashMap代替HashMap。 HashTable是一个线程安全的类&#xff…

HashMap是线程安全的吗?为什么呢?

HashMap是线程安全的吗&#xff1f;为什么呢&#xff1f; HashMap是线程不安全的&#xff01; 线程不安全体现在JDK.1.7时在多线程的情况下扩容可能会出现死循环或数据丢失的情况&#xff0c;主要是在于扩容的transfer方法采用的头插法&#xff0c;头插法会把链表的顺序给颠倒…

【亲测有效】JS Uncaught TypeError: [function] is not a constructor

【亲测有效】JS Uncaught TypeError: [function] is not a constructor 在JavaScript编程中&#xff0c;Uncaught TypeError: [function] is not a constructor 是一个相对常见的错误&#xff0c;通常发生在尝试使用某个值作为构造函数&#xff0c;但实际上它不是构造函数的情况…

鸿蒙卡片服务开发

首先先创建一个项目 在该项目下创建一个卡片服务 在module.json5文件下配置 {"module": {..."extensionAbilities": [{"name": "EntryFormAbility","srcEntry": "./ets/entryformability/EntryFormAbility.ets",…

Linux文件操作:文件描述符fd

文章目录 前言&#xff1a;回顾一下文件提炼一下关于文件的理解&#xff1a; 理解文件&#xff1a;通过系统调用操作文件&#xff1a;理解标志位传参&#xff1a;打开文件 open写入信息 write 理解文件描述符&#xff1a;对于open的返回值&#xff1a;文件描述fd的本质是什么呢…

设计模式之Decorator装饰者、Facade外观、Adapter适配器(Java)

装饰者模式 设计模式的基本原则&#xff0c;对内关闭修改。 Decorator Pattern&#xff0c;装饰者模式&#xff0c;也叫包装器模式(Wrapper Pattern)&#xff1a;将一个对象包装起来&#xff0c;增加新的行为和责任。一定是从外部传入&#xff0c;并且可以没有顺序&#xff0…

Qml 实现仿前端的 Notification (悬浮出现页面上的通知消息)

【写在前面】 经常接触前端的朋友应该经常见到下面的控件&#xff1a; 在前端中一般称它为 Notification 或 Message&#xff0c;但本质是一种东西&#xff0c;即&#xff1a;悬浮弹出式的消息提醒框。 这种组件一般具有以下特点&#xff1a; 1、全局/局部显示&#xff1a;它不…

基于单片机的信号发生器设计

本设计采用了STM32F103C8T6单片机作为控制核心&#xff0c;通过控制DDS模块产生不同频率且高稳定和低失真的信号&#xff0c;再通过放大电路对信号的幅值进行放大。此外通过按键可以使用户对频率进行调节以及对输出波形进行切换&#xff0c;由于AD9833输出的幅值是固定的&#…