【模版】字符串--KMP算法

字符串匹配是计算机中的一个基本问题。字符串匹配应用很广泛,比如你想在一篇文章中找到某个关键字所在的位置,或者是你想在一份名单中找到某个名字是否出现等等。

抽象描述起来,就是我们现在有一个长度为 n 的字符串S,称为主串,有一个长度为 m 的字符串P,称为模式串。我们如何找到模式串在主串中存在的位置呢?模式串是否在主串中存在呢?

 

Brute-Force算法(暴力匹配算法)

暴力匹配算法就像名字一样暴力,对于模式串P和主串S,我们分别先进行如下两个操作:

设定两个指针,假设主串匹配到i位置,模式串匹配到j位置。

  • 如果字符串匹配(S[i] == P[j]),那么让两个指针移动,即i++,j++,继续匹配下一个字符
  • 如果字符串不匹配(S[i] != P[j]),那么两个指针都回溯,i回溯到 i - (j - 1) 的位置,j回溯到字符串开始的位置

代码描述:

int BruteForce (string &s,string &p) {
    int n = s.size();
    int m = p.size();
    
    int i=0,j=0;
    while (i<n && j<m) {
        if (s[i] == p[j]) {
            i++;
            j++;
        }
        else
        {
            i = i - j + 1;
            j = 0;
        }
    }
    
    if (j == m) //如果匹配成功
        return i - j;
    else
        return -1;
}

不难分析,暴力算法的时间复杂度是O(m*n),因为每一次匹配失败,算法会直接从头回溯 i,j 指针,做了很多无用功,比如对于如下极端情况:

主串S:AAAAAAAAAAAAAAC

模式串P:AAC

当匹配失败后,我们会一直回溯 i 和 j 指针,直到最后匹配成功,可以看到,这个算法做了很多无用功,并且我们的不匹配的信息只有C一个,那么我们前面匹配成功的AA完全没有用

那么我们有没有一种算法,让 i 不往回退,只需要移动 j 即可呢?

答案是肯定的。这种算法就是本文的主旨KMP算法,它利用之前已经部分匹配这个有效信息,保持 i 不回溯,通过修改 j 的位置,让模式串尽量地移动到有效的位置。

 

KMP算法:

1.KMP算法简介:

    Knuth-Morris-Pratt 字符串查找算法,简称为 “KMP算法”,常用于在一个文本串S内查找一个模式串P 的出现位置,这个算法由Donald Knuth、Vaughan Pratt、James H. Morris三人于1977年联合发表,故取这3人的姓氏命名此算法。

如何通过前面匹配成功的信息使得 i 指针不回溯,只回溯 j 指针,将算法优化成线性复杂度呢?

下面先直接给出KMP的算法流程(如果感到一点点不适,没关系,坚持下,稍后会有具体步骤及解释,越往后看越会柳暗花明☺):
  • 假设现在文本串S匹配到 i 位置,模式串P匹配到 j 位置
  • 如果j = -1,或者当前字符匹配成功(即S[i] == P[j]),令i++;j++,继续匹配下一个字符;
  • 如果j != -1,且当前字符匹配失败(即S[i] != P[j]),则令 i 不变,j = next[j]。此举意味着失配时,模式串P相对于文本串S向右移动了j - next [j] 位。
  • 换言之,当匹配失败时,模式串向右移动的位数为:失配字符所在位置 - 失配字符对应的next 值
    很快,你也会意识到next 数组各值的含义:代表当前字符之前的字符串中,有多大长度的公共前缀后缀。例如如果next [j] = k,代表 j 之前的字符串中有最大长度为k 的相同前缀后缀。
    此也意味着在某个字符失配时,该字符对应的next 值会告诉你下一步匹配中,模式串应该跳到哪个位置(跳到next [j] 的位置)。如果next [j] 等于0或-1,则跳到模式串的开头字符,若next [j] = k 且 k > 0,代表下次匹配跳到j 之前的某个字符,而不是跳到开头,且具体跳过了k 个字符。
 
简单来说,为了让j被回溯到一个合适的位置,我们引入了PMT(Partial Match Table,部分匹配表),next数组也和PMT相关。
 
一会我们会讲如何构造next数组:
 
KMP的大致的代码实现如下:(这边是字符串下标从0开始的写法)
int n = s.size();
int m = p.size();
int ne[SIZE]; ne[0]=-1;

for (int i = 0, j = -1; i < n; i ++ )
    {
        while (j != -1 && s[i] != p[j + 1]) j = ne[j];  //不匹配后,只回溯 j 指针
        if (s[i] == p[j + 1]) j ++ ;
        if (j == m - 1) //因为每次比较都是比较p[j+1]
        {
            cout << i - j << ' ';
            j = ne[j]; //继续匹配
        }
    }

很容易分析出来,KMP的时间复杂度整体是O(n)的,最大的问题是,我们如何构造出next数组呢?

2.next数组解析

next数组和模式串形成映射数组,存的数据next[i]就是模式串P从P[0]到P[i-1]的最长公共前后缀长度

  • ①寻找前缀后缀最长公共元素长度
    • 对于P = p0 p1 ...pj-1 pj,寻找模式串P中长度最大且相等的前缀和后缀。如果存在p0 p1 ...pk-1 pk = pj- k pj-k+1...pj-1 pj,那么在包含pj的模式串中有最大长度为k+1的相同前缀后缀。举个例子,如果给定的模式串为“abab”,那么它的各个子串的前缀后缀的公共元素的最大长度如下表格所示:

  • ②求next数组
    • next 数组考虑的是除当前字符外的最长相同前缀后缀,所以通过第①步骤求得各个前缀后缀的公共元素的最大长度后,只要稍作变形即可:将第①步骤中求得的值整体右移一位,然后初值赋为-1,如下表格所示:

  • ③根据next数组进行匹配
    • 匹配失配,j = next [j],模式串向右移动的位数为:j - next[j]。换言之,当模式串的后缀pj-k pj-k+1, ..., pj-1 跟文本串si-k si-k+1, ..., si-1匹配成功,但pj 跟si匹配失败时,因为next[j] = k,相当于在不包含pj的模式串中有最大长度为k 的相同前缀后缀,即p0 p1 ...pk-1 = pj-k pj-k+1...pj-1,故令j = next[j],从而让模式串右移j - next[j] 位,使得模式串的前缀p0 p1, ..., pk-1对应着文本串 si-k si-k+1, ..., si-1,而后让pk 跟si 继续匹配。如下图所示:

 

3.如何求出next数组?

我们先介绍一个概念:前缀函数

给定长为n的字符串s,其前缀函数定义为一个长为n的数组π。其中π[i]为s的第i个最长公共前后缀的长度。 一般规定π[0] = 0

同时,不难由定义看出,我们的next数组和前缀函数数组息息相关(有一些KMP的写法不一样,next数组不完全是前缀函数数组)

数学描述如下:

由定义,我们显然能得到如下结论:

如果π[i]=j,那么有s[0~j-1] = s[i-j+1~i]

 

由此,我们可以由此结论得出求前缀函数的朴素算法

vector<int> prefix_function(string s) {
  int n = (int)s.length();
  vector<int> pi(n);
  for (int i = 1; i < n; i++)
    for (int j = i; j >= 0; j--)
      if (s.substr(0, j) == s.substr(i - j + 1, j)) {
        pi[i] = j;
        break;
      }
  return pi;
}

//pi[0]初始化为0;
//string substr (size_t pos = 0, size_t len = npos) const;

显见该算法的时间复杂度为 O(n^3),具有很大的改进空间。 (两层循环+字符串截取)

 

优化方法1:

第一个重要的观察是 相邻的前缀函数值至多增加 1

参照下图所示,只需如此考虑:当取一个尽可能大的π[i+1]时,必然要求新增的s[i+1]也与之对应的字符匹配

即 s[i+1] = s[π[i]] 时,此时π[i+1] = π[i] + 1。

所以当移动到下一个位置时,前缀函数的值要么增加一,要么维持不变,要么减少。

严格证明:π[i+1] - π[i] <= 1

利用反证法:

如果π[i] = j, π[i+1] = j+2, 那么s[0~j-1] = s[i-j+1~i], 并且s[0~j+2] = s[i-j,i+1]相同,显然此时此时显然s[0,j+1]相等,故π[i]应该是j+1,显然矛盾!

图解如下:

故我们的代码可以做如下改进:

vector<int> prefix_function(string s) {
  int n = (int)s.length();
  vector<int> pi(n);
  for (int i = 1; i < n; i++)
    for (int j = pi[i - 1] + 1; j >= 0; j--)  // improved: j=i => j=pi[i-1]+1
      if (s.substr(0, j) == s.substr(i - j + 1, j)) {
        pi[i] = j;
        break;
      }
  return pi;
}

显然,pi的值最多增加n,也就最多减少n,意味着仅需要n次字符串比较就可以得到所有pi的值,所以此时求前缀函数的复杂度为O(n^2)

优化方法2:

第一个优化方法中,我们讨论了计算π[i+1]时的最好情况:s[i+1] = s[π[i]],此时π[i+1] = π[i] + 1。现在我们考虑得更远一些,如果s[i+1] != s[π[i]]那怎么办呢?

失配时,我们希望在子串s[0~i]中,找到仅次于π[i]的第二长度 j ,使得在位置i的前缀性质依旧保持,即s[0~j-1] = s[i-j+1~i];

如果我们找到这样的长度 j ,那么只需要再次比较s[i+1]和s[j]。如果s[i+1]=s[j],那么就有 π[i+1] = j+1 ,否则,我们就继续找到仅次于 j 的第二长度  j(2)使得前缀性质继续得到保持,如此反复直到 j = 0。如果s[i+1] != s[0],则π[i+1] = 0。

观察上图发现,对于子串s[0~i]的第二长度 j 有如下性质

s[0~j-1] = s[i-j+1~i] = s[π[i]-j~π[i]-1]

也就是说,j 等价于子串 s[π[i]-1] 的前缀函数值,即 j = π[π[i]-1] 。同理可得,次于 j 的第二长度 j(2)为 s[j-1] 的前缀函数值,即 j(2)= π[j-1]。

显然,我们可以得到一个关于 j 的状态转移方程

j(n)= π [j(n-1)-1] ,( j(n-1)> 0 )

伪代码实现如下:

字符串下标从0开始:

string p; //需要自匹配的模式串
cin >> p;

int next[SIZE]
next[0] = 0;

int n = p.size();

for (int i=1;i<n;i++) {
    int j = next[i-1];
    while (j > 0 && s[i] != s[j]) j = next[j-1];
    if (s[i] = s[j]) j++;
    next[i] = j;
}

字符串下标从1开始:

char s[MAXSIZE]; //需要自匹配的模式串
cin >> p+1;

int next[SIZE]
next[1] = 0;

int n = p.size();

for (int i=2;i<=n;i++) {
    int j = next[i-1];
    while (j > 0 && s[i] != s[j+1]) j = next[j];
    if (s[i] == s[j+1]) j++;
    next[i] = j;
}

 

posted @   綾川雪絵  阅读(24)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 周边上新:园子的第一款马克杯温暖上架
· 分享 3 个 .NET 开源的文件压缩处理库,助力快速实现文件压缩解压功能!
· Ollama——大语言模型本地部署的极速利器
· DeepSeek如何颠覆传统软件测试?测试工程师会被淘汰吗?
· 使用C#创建一个MCP客户端
点击右上角即可分享
微信分享提示