字符串匹配算法 -- 暴力破解法(朴素法),RK算法,KMP算法

算法预处理时间匹配时间
朴素算法0O((n-m+1)m)
Rabin-KarpΘ(m)O((n-m+1)m)
KMP算法Θ(m)Θ(n)

术语

前缀和后缀

如果对某个字符串y∈∑*有x = wy, 则称字符串w是字符串x的前缀,记做 w ⊂ x。

如果对某个字符串y∈∑*有x = yw, 则称字符串w是字符串x的后缀,记做 w ⊃ x。

朴素字符串匹配算法

描述

朴素字符串匹配算法是通过一个循环找到所有有效偏移地址,该循环对n-m+1个可能的s值进行检测,看是否满足条件P[1..m] = T[s+1..s+m]。

图说明

如下图所示,文本T = “acaabc”,P = “aab”,在对文本T进行循环的时候,隐藏着一个循环,该循环用于逐个检测对应位置上的字符,直到所有位置都能够成功匹配或者有一个位置不能匹配为止。

《字符串匹配算法 -- 暴力破解法(朴素法),RK算法,KMP算法》

伪代码

NAVIE-STRING-MATCHER(T,P)
n = T.length
m = P.length
for s = 0 to n - m
    if P[1..m] == T[s + 1 .. s + m]
        print "Pattern occurs with shift"

代码实现

由于实现相对简单,这里对代码不在做过多的说明,核心在于遍历文本的过程,如果成功匹配第一个元素,则遍历模板,进行内部匹配。

// 朴素字符串匹配算法
// 匹配时间为O((N-M+1)M)
// 输出文本T中子串出现的次数
int Naivie_String_matcher(string T, string P)
{
    int num = 0;
    int n = T.size();
    int m = P.size();
    for(int s = 0; s <= n - m; ++s) {
        if(T[s] == P[0]) {
            int k = 0;
            for(int i = 0; i < m; ++i) {
                if(T[s + i] == P[i]) {
                    ++k;
                }
                else {
                    break;
                }
            }
            if(k == m) {
                ++num;
            }
        }
    }
    return num;
}

Rabin-Karp算法

描述

Rabin-Karp算法又叫做RK算法(下面都用这个简称),它的预处理时间为Θ(m),并且在最坏情况下,它的运行时间为Θ((n-m+1)m)。在实际情况下,它相对于朴素算法来说,是比较好的。

RK算法主要是利用两个数相对于第三个数模等价的概念。

例如:假设
A % B = C
D % B = C
即便不能判断A一定等于D , 但是如果取模不相等,那么A一定不等于D,所以可以用来提高效率

图说明

如下图, 我们选取的字符串T为“234590314121204”和模板字符串P为“3141”。我们将m个字符串转为d进制的数字(这里我们用十进制表示),转换后的整数取模进行匹配。当寻找到模相等的时候在进行内部匹配,检测是否为合法匹配。

《字符串匹配算法 -- 暴力破解法(朴素法),RK算法,KMP算法》

相应的数学公式

先思考一个问题,如何将字符串“31415”转换为十进制的31415?

我们可以容易知道
31415 = 3 × 10000 + 1 × 1000 + 4 × 100 + 1 × 10 + 5

31415 = ((((3 × 10 + 1) × 10 + 4 ) × 10) + 1 ) × 10 + 5

由上面的等式我们可以知道,给定一个模式P[1..m],假设p表示其相应的十进制值。那么我们利用霍纳法则得

p=P[m]+10(P[m1]+10(P[m2]+L+10(P[2]+10P[1])))

利用循环的方式我们可以简化代码,其中i为0到m

p0=0

p=10p+P[i]

对应的C++代码

for(int i = 0; i < m; ++i) {
        p = (d * p + P[i]) ;
}

当文本移动的时候

例如m = 5时,T = “31245678”,t_0 = 31245, t_1= 12456

如何得到t_1的值?这是我们要思考的第二个问题。可以看得出 t_1的值为t_0去掉最高位“3”和加末尾加上“6”,因此

t1=10(t010000T[0])+T[m+1]

归纳后得

ts+1=d(tsdm1T[s+1])+T[s+m+1]

相应的C++代码

//h = static_cast<int>(pow(d, m - 1));
t = d * (t - T[s] * h) + T[s + m];

伪代码

RABIN-KARP-MATCHER(T,P,d,q)
n = T.length
m = P.length
h = d^(m - 1) mod q
p = 0
t0 = 0
for i = 1 to m
    p = (dp + P[i]) mod q
    t0 = (dt0 + T[i]) mod q

for s = 0 to n - m
    if p == t(s)
        if P[1..m] == T[s + 1 .. s + m]
            print "Pattern occurs with shift"s
    if s < n - m
        t(s+1) = (d(t(s) - T[s + 1] * h) + T[s + m + 1]) mod q

代码

本人没有完全按照上面的伪代码实现,本代码在VS2013上可以成功运行,但在sublime3上有个bug,对于匹配纯数字字符串,它可以完美匹配,在字符字符串上,只能部分匹配。例如:文本为“abcdefghijdefgkldefg”,对应模板为“d”,“de”,“def”,“defgh”。。。“defghij”中“def”和“defgh”无法匹配成功。置于原因可以与我探讨。

// Rabin-Karp算法
// 预处理时间(M),匹配时间O((N-M+1)M),实际优于朴素算法
// d : 表示字符都是由d为基数表示的数字
// q : 素数,用于模计算
// 输出子串出现的次数
int Rabin_Karp_matcher(string T, string P, int d, int q)
{
    int num = 0;
    int n = T.size();
    int m = P.size();
    // 计算h
    int h = static_cast<int>(pow(d, m - 1));
    /*int pow_d = 1; for(int i = 0; i < m - 1; ++i) { pow_d *= d; } int h = pow_d % d;*/
    // p和t0用于求出相应的子串对应的d进制整数
    /* 相应数学公式(霍纳法则) p = P[m] + d(P[m - 1] + d(P[m - 2] + d(P[m - 3] + ... + d(P[2] + dP[1])...)) 可以看出一共有(m-1)个d 通过数学归纳法我们可以知道 p = (d * p + P[i])) 又因为要取模 易知p(总) % q 和每次计算的出的p % q是一样的 */
    int p = 0;
    int t = 0;
    for(int i = 0; i < m; ++i) {
        p = (d * p + P[i]) % q;
        t = (d * t + T[i]);
    }

    for(int s = 0; s <= n - m; ++s) {
        if(p == (t % q)) {
            int k = 0;
            for(int i = 0; i < m; ++i) {
                if(T[s + i] == P[i]) {
                    ++k;
                }
                else {
                    break;
                }
            }
            if(k == m) {
                ++num;
                cout << "The position : " << s + 1 << endl;
            }
        }
        // 前面我们之算出了T的前m个字符对应的d进制的值,如果不匹配,那么需要往下移动一个字符
        // 所以我们需要去除最前面的数,在尾部加入新的数,例如m = 5时,T = "31245678",t(0) = 31245, t(1) = 12456
        // 根据公式
        // t(s+1) = d * (t(s) - d^(m - 1) * T[s + 1]) + T[s + m + 1]
        // 其中h % q = d^(m-1)
        // h 是一个具有m数位的文本窗口的高位数的数位上的数字“1”的值。
        if(s < n - m) {
            t = (d * (t - T[s] * h) + T[s + m]);
        }
    }
    return num;
}

Knuth-Morris-Pratt算法

建议看之前先看一篇国外的关于KMP算法的博客:The Knuth-Morris-Pratt Algorithm in my own words

描述

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

当我们进行匹配的时候,假设文本T =“bacbababaabcbab”和模板 P = “ababaca”

  1. 当我们进行字符串匹配的时候,如果匹配到成功的字符,那么它将进行内循环,如下图所示
    《字符串匹配算法 -- 暴力破解法(朴素法),RK算法,KMP算法》

  2. 逐一匹配
    《字符串匹配算法 -- 暴力破解法(朴素法),RK算法,KMP算法》

  3. 检索字符
    《字符串匹配算法 -- 暴力破解法(朴素法),RK算法,KMP算法》

  4. 检索字符
    《字符串匹配算法 -- 暴力破解法(朴素法),RK算法,KMP算法》

  5. 检索字符
    《字符串匹配算法 -- 暴力破解法(朴素法),RK算法,KMP算法》

  6. 当匹配到不相同的时候开始进行跳步
    《字符串匹配算法 -- 暴力破解法(朴素法),RK算法,KMP算法》

  7. 根据部分匹配表(Partial Match Table)
    char : | a | b | a | b | a | c | a |
    index: | 0 | 1 | 2 | 3 | 4 | 6 | 7 |
    value: | 0 | 0 | 1 | 2 | 3 | 0 | 1 |

查表可知,最后一个匹配字符a对应的”部分匹配值”为3

因此按照下面的公式算出向后移动的位数:移动位数 = 已匹配的字符数 – 对应的部分匹配值(value)

因为 5 – 3 等于2,所以将搜索词向后移动2位。

  1. 跳两步
    《字符串匹配算法 -- 暴力破解法(朴素法),RK算法,KMP算法》

分析

上图简单介绍了下KMP进行匹配时候的运行原理,现在我们来进行分析KMP算法的正确性,然后再来搞清楚怎么获取到部分匹配表。

假设模式字符P[1..q]与文本字符T[s + 1.. s + 1]匹配,s’是最小的偏移量,s’>s,那么对某些k < q ,满足

P[1..k] = T[s’ + 1 .. s’ + k]

的最小偏移s’>s是多少,其中s’+ k = s + q。换句话说,已知P_q⊃P_k,我们希望P_q的最长真前缀P_k也就是T_(s+q)的后缀。

即:已知一个模式P[1..m],模式P的前缀函数是函数π:{1,2,….,m}->{0,1,…,m-1},满足

π[q] = max{k:k < q 且 P_k ⊃ P_q}

即π[q]是P_q的真后缀P的最长前缀长度。

什么是前缀和后缀

要知道部分匹配表的由来,首先,要了解两个概念:”前缀”和”后缀”。
“前缀”指除了最后一个字符以外,一个字符串的全部头部组合。
“后缀”指除了第一个字符以外,一个字符串的全部尾部组合。

例如:字符串“ABCDEF”,那么它的前缀和后缀分别为

前缀:“A”,“AB”,“ABC”,“ABCD”,“ABCDE”
后缀:“BCDEF”,“CDEF”,“DEF”,“EF”,“F”

部分匹配表

算法导论书上说的有点不是很清楚,我们可以换一种思路,来得到部分匹配表。

对于模式P = “ababaca”,从左到右依次增加一个字符

  1. 当下标为0的时候
    “a” 的前缀和后缀都没有,故部分匹配表里的 value=0

  2. 当下表为1的时候
    “ab”的前缀为:“a”,后缀为“b”,由于前缀和后缀不相同,故 value = 0

  3. 当下标为2的时候
    “aba”的前缀为:“a”,“ab”,后缀为“ba”,“a”,由于前缀和后缀有一个相同,且最长度为1,故 value = 1

  4. 当下标为3的时候
    “abab”的前缀为:“a”,“ab”,“aba”,后缀为:“bab”,“ab”,“b”,由于前缀和后缀有一个相同,且最长度为2,故 value = 2

  5. 当下标为4的时候
    “ababa”的前缀为:“a”,“ab”,“aba”,“abab”,后缀为:“baba”,“aba”,“ba”,“a”,前缀和后缀有两个相同,但是最长长度为3,故 value = 3

以此类推,就能得出所有的部分匹配表

代码实现

int* compute_prefix_function(string P_string)
{
    int P_length = P_string.size();
    int* PI = new int[P_length];
    PI[0] = 0;
    int k = 0;
    for(int q = 1; q < P_length; ++q) {
        while(k > 0 && P_string[k] != P_string[q]) {
            k = PI[k];
        }
        if(P_string[k] == P_string[q]) {
            k += 1;
        }
        PI[q] = k;
    }

    return PI;
}

整体代码

得出部分匹配表后,剩下的就是根据部分匹配表里的值,进行跳步。
关键点就是下一个有效偏移s'= s + (q - π[q]),这里s表示有效偏移,q表示匹配的字符长度,π[q]表示对应部分匹配表里的信息

int* compute_prefix_function(string P_string)
{
    int P_length = P_string.size();
    int* PI = new int[P_length];
    PI[0] = 0;
    int k = 0;
    for(int q = 1; q < P_length; ++q) {
        while(k > 0 && P_string[k] != P_string[q]) {
            k = PI[k];
        }
        if(P_string[k] == P_string[q]) {
            k += 1;
        }
        PI[q] = k;
    }

    return PI;
}

// Knuth-Morris-Pratt算法
// 预处理时间Θ(m),匹配时间Θ(N)
// 输出子串出现的次数
int Knuth_Morris_Pratt_matcher(string T, string P)
{
    int num = 0;
    int n = T.size();
    int m = P.size();
    int k = 0;
    int* tb = compute_prefix_function(P);

    for(int i = 0; i < n; ++i) {
        if(k > 0 && T[i] != P[k]) {     // 进行跳步
            i += k - tb[k];                 // s'= s + (q - π[q])
            k = 0;
        }
        if(T[i] == P[k]) {
            k = k + 1;
        }
        if(k == m) {
            ++num;
            cout << "The position : " << i - k + 2 << endl;
            k = 0;
        }
    }
    return num;
}

拓展

字符串匹配算法还有很多,例如:Boyer-Moore algorithm,神奇的Sunday algorithm等等。

以后有机会,会把BM算法和Sunday算法加上。

参考资料

  1. Knuth–Morris–Pratt algorithm
  2. The Knuth-Morris-Pratt Algorithm in my own words
  3. 《算法导论》第32章
  4. 字符串匹配的KMP算法
    原文作者:KMP算法
    原文地址: https://blog.csdn.net/tutuxs/article/details/53774482
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞