算法导论(三)字符串匹配

   字符串匹配方法对于在编辑文本程序时,能极大的提升响应效率,如网址查询搜索引擎,DNA序列匹配等。

基本定义

字符串匹配问题的形式定义:

  • 文本(Text)是一个长度为 n 的数组 T[1..n];
  • 模式(Pattern)是一个长度为 m 且 m≤n 的数组 P[1..m];
  • T 和 P 中的元素都属于有限的字母表 Σ 表
  • 如果 0≤s≤n-m,并且 T[s+1..s+m] = P[1..m],即对 1≤j≤m,有 T[s+j] = P[j],则说模式 P 在文本 T 中出现且位移为 s,且称 s 是一个有效位移(Valid Shift)

                                                         

比如上图中,目标是找出所有在文本 T = abcabaabcabac 中模式 P = abaa 的所有出现。该模式在此文本中仅出现一次,即在位移 s = 3 处,位移 s = 3 是有效位移。

字符串匹配算法通常分为两个步骤:预处理(Preprocessing)和匹配(Matching)。所以算法的总运行时间为预处理和匹配的时间的总和。下图描述了常见字符串匹配算法的预处理和匹配时间。

                                                       

其中涉及到的解决字符串匹配的算法包括:朴素算法(Naive Algorithm)Rabin-Karp 算法、有限自动机算法(Finite Automation)、 Knuth-Morris-Pratt 算法即KMP Algorithm Boyer-Moore 算法、Simon 算法、Colussi 算法、Galil-Giancarlo 算法、Apostolico-Crochemore 算法、Horspool 算法和 Sunday 算法等。

朴素的字符串匹配算法

朴素的字符串匹配算法又称为暴力匹配算法(Brute Force Algorithm),它的主要特点是:

  1. 没有预处理阶段;
  2. 滑动窗口总是后移 1 位;
  3. 对模式中的字符的比较顺序不限定,可以从前到后,也可以从后到前;
  4. 匹配阶段需要 O((n - m + 1)m) 的时间复杂度;
  5. 需要 2n 次的字符比较;

很显然,朴素的字符串匹配算法 NAIVE-STRING-MATCHER 是最原始的算法,它通过使用循环来检查是否在范围 n-m+1 中存在满足条件 P[1..m] = T [s + 1..s + m] 的有效位移 s。

1 NAIVE-STRING-MATCHER(T, P)
2  n = length[T]
3  m = length[P]
4  for s = 0 to n - m
5     if P[1 .. m] = T[s + 1 .. s + m]
6          then print "Pattern occurs with shift" s

                       

如上图中,对于模式 P = aab 和文本 T = acaabc,将模式 P 沿着 T 从左到右滑动,逐个比较字符以判断模式 P 在文本 T 中是否存在。

可以看出,NAIVE-STRING-MATCHER 没有对模式 P 进行预处理,所以预处理的时间为 0。而匹配的时间在最坏情况下为 Θ((n-m+1)m),如果 m = [n/2],则为 Θ(n^2)。

Rabin-Karp算法

对于一个时间复杂度为O((N-M+1)*M)的字符串匹配算法,即Rabin-Karp算法。Rabin-Karp算法的预处理时间是O(m), 匹配时间O((N-M+1)*M),既然与朴素算法的匹配时间一样,而且还多了一些预处理时间,那为什么我们 还要学习这个算法呢?

虽然Rain-Karp在最坏的情况下与朴素的世间复杂度一样,但是实际应用中往往比朴素算法快很多。而且该算法的 期望匹配时间是O(N+M)。在朴素算法中,我们需要挨个比较所有字符,才知道目标字符串中是否包含子串。那么, 是否有别的方法可以用来判断目标字符串是否包含子串呢?

答案是肯定的,确实存在一种更快的方法。为了避免挨个字符对目标字符串和子串进行比较, 我们可以尝试一次性判断两者是否相等。因此,我们需要一个好的哈希函数(hash function)。 通过哈希函数,我们可以算出子串的哈希值,然后将它和目标字符串中的子串的哈希值进行比较。 这个新方法在速度上比暴力法有显著提升。

Rabin-Karp算法的思想:

  1. 假设子串的长度为M,目标字符串的长度为N
  2. 计算子串的hash值
  3. 计算目标字符串中每个长度为M的子串的hash值(共需要计算N-M+1次)
  4. 比较hash值
  5. 如果hash值不同,字符串必然不匹配,如果hash值相同,还需要使用朴素算法再次判断

为了快速的计算出目标字符串中每一个子串的hash值,Rabin-Karp算法并不是对目标字符串的 每一个长度为M的子串都重新计算hash值,而是在前几个字串的基础之上, 计算下一个子串的 hash值,这就加快了hash之的计算速度,将朴素算法中的内循环的时间复杂度从O(M)将到了O(1)。

   public void search(char[] pat, char[] txt, int q) {
        int M = pat.length;
        int N = txt.length;
        int i, j;
        int d =256;  //模
        int p = 0;   // hash value for pat
        int t = 0;   // hash value for txt
        int h = 1;
        for (i = 0; i < M-1; i++)
            h = (h*d)%q;
        for (i = 0; i < M; i++) {
            p = (d*p + pat[i])%q;
            t = (d*t + txt[i])%q;
        }
        for (i = 0; i <= N - M; i++) {
            if ( p == t ) {
                for (j = 0; j < M; j++) {
                    if (txt[i+j] != pat[j])
                        break;
                }
                if (j == M) {
                    System.out.println("Pattern found at index: "+ i);
                }
            }
            if ( i < N-M ) {
                t = (d*(t - txt[i]*h) + txt[i+M])%q;
                if(t < 0)
                    t = (t + q);
            }
        }
    }

Knuth-Morris-Pratt 算法(KMP)

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

KMP算法背后的基本思想是:每当我们检测到不匹配(在一些匹配之后),我们就已经知道了文本中的一些字符(因为它们在不匹配之前匹配了模式字符)。我们利用这些信息来避免匹配我们知道无论如何匹配的字符。

我们来观察一下朴素的字符串匹配算法的操作过程。如下图(a)中所描述,在模式 P = ababaca 和文本 T 的匹配过程中,模板的一个特定位移 s,q = 5 个字符已经匹配成功,但模式 P 的第 6 个字符不能与相应的文本字符匹配。

                                           

此时,q 个字符已经匹配成功的信息确定了相应的文本字符,而知道这 q 个文本字符,就使我们能够立即确定某些位移是非法的。例如上图(a)中,我们可以判断位移 s+1 是非法的,因为模式 P 的第一个字符 a 将与模式的第二个字符 b 匹配的文本字符进行匹配,显然是不匹配的。而图(b)中则显示了位移 s’ = s+2 处,使模式 P 的前三个字符和相应的三个文本字符对齐后必定会匹配。KMP 算法的基本思路就是设法利用这些已知信息,不要把 "搜索位置" 移回已经比较过的位置,而是继续把它向后面移,这样就提高了匹配效率。

前缀和后缀

"前缀"和"后缀"。 "前缀"指除了最后一个字符以外,一个字符串的全部头部组合;"后缀"指除了第一个字符以外,一个字符串的全部尾部组合。如下图所示,为字符串bread的前后缀:

                                

部分匹配表

前缀函数:对于一个模式P,我们引入模式的前缀函数 π(Pi),π 包含有模式与其自身的位移进行匹配的信息。这些信息可用于避免在朴素的字符串匹配算法中,对无用位移进行测试。

 π[q] = max {k : k < q 且 Pk ⊐ Pq}  ,其中,k为已匹配字符数,Pk ⊐ Pq 表示P[1..k]为P[1..q]的后缀。

π[q],即"部分匹配值" 代表当前字符之前的字符串中,最长的共同前缀后缀的长度,也就是"前缀"和"后缀"的最长的共有元素的长度。下图给出了关于模式 P = ababababca 的完整前缀函数 π,可称为部分匹配表:

                      

计算过程:

  • π[1] = 0,a 仅一个字符,前缀和后缀为空集,共有元素最大长度为 0;
  • π[2] = 0,ab 的前缀 a,后缀 b,不匹配,共有元素最大长度为 0;
  • π[3] = 1,aba,前缀 a ab,后缀 ba a,共有元素最大长度为 1;
  • π[4] = 2,abab,前缀 a ab aba,后缀 bab ab b,共有元素最大长度为 2;
  • π[5] = 3,ababa,前缀 a ab aba abab,后缀 baba aba ba a,共有元素最大长度为 3;
  • π[6] = 4,ababab,前缀 a ab aba abab ababa,后缀 babab abab bab ab b,共有元素最大长度为 4;
  • π[7] = 5,abababa,前缀 a ab aba abab ababa ababab,后缀 bababa ababa baba aba ba a,共有最大长度为 5;
  • π[8] = 6,abababab,前缀 .. ababab ..,后缀 .. ababab ..,共有元素最大长度为 6;
  • π[9] = 0,ababababc,前缀和后缀不匹配,共有元素最大长度为 0;
  • π[10] = 1,ababababca,前缀 .. ..,后缀 .. a ..,共有元素最大长度为 1;

所以P模式的部分匹配表,π 数组为:

其代码实现为KMP的辅助前缀函数prefix

  int[] prefix(char[] p) {
       
        int[] pi = new int[p.length];//π数组pi,1至m位,0位不取
        pi[1] = 0;//一个字符的前后缀都为空,公共元素长度为0
        int k = 0;//k为当前字串的前后缀组合中共有元素最长的长度

        //计算存储从2开始的子串的最大的公共长度π[i]值
        for (int i = 2; i <= pi.length; i++) {

            //如果已经存在公共元素长度k>0,且最大的公共长度的下一位和当前字符不同
            while (k > 0 && p[k+1] != p[i]) {
         /*
          *若字符串为ababaca,当 i=5 时,子串为ababa,k=3,子串前后缀最长公共元素为aba
          *当 i=6 时,子串为ababac,因为abab不等于abac即 p[k+1]!=p[i];
          *所以最大公共元素长度k要重新计算,新的位置为前缀中的当前最长公共元素aba(1,2,3处的)和
          *其后缀中的当前最长公共元素aba(3,4,5处的)的最长公共元素a(1处和5处),即k=pi[k];
          */        
                k = pi[k];
            }
            //最大的公共长度的下一位和当前字符相同
            if (p[k+1] == p[i]) 
                k++; //则最大长度加一

            pi[i] = k;//记录i处子串的π值
        }

        return pi;
  }

测试函数为:

   public static void main(String[] args) {
        String T = "#BBC ABCDAB ABCDABCDABDE";
        String P = "#ABCDABD";
        char[] t = T.toCharArray();       
        char[] p = P.toCharArray();
        int n = t.length-1;
        int m = p.length-1;
        int[] pi = prefix(p);
        int q = 0;
        for (int i = 1;i <= n;i++) {
            while (q > 0 && p[q+1] != t[i]) q = pi[q];
            if (p[q+1] == t[i]) q++;
            if (q == m) {
                System.out.println("找到一个,T的位置:"+(i-m+1)+"到"+i);
                q = pi[q];//寻找下一个匹配的位置
            }
        }
   }

   static int[] prefix(char[] p) {
        int[] pi = new int[p.length];
        pi[1] = 0;//一个字符的前后缀都为空,公共元素长度为0
        int k = 0;//k为当前字串的前后缀组合中共有元素最长的长度
        //从2开始的子串的最大的公共长度π[i]值
        for (int i = 2; i < pi.length; i++) {
            //如果已经存在公共元素长度k>0,且最大的公共长度的下一位和当前字符不同
            while (k > 0 && p[k+1] != p[i])
                k = pi[k];//返回上一次的最大长度处
            //最大的公共长度的下一位和当前字符相同
            if (p[k+1] == p[i])
                k++; //则最大长度加一
            pi[i] = k;//记录i处子串的π值
        }
        return pi;
    }

推荐阮一峰老师的文章,讲的通俗易懂

Boyer-Moore算法

字符串匹配的Boyer-Moore算法

 

 

 

参考文章:

《算法导论》

 

 

 

 

 

  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值