子字符串的查找/KMP算法(正在更新)

作者:disappearedgod
时间:2014-6-20

前言

本博客为系列博客。主要根据的是《算法》forth Edition来写的。在后面的时间会逐渐加上一些题目

字符串的查找问题是一个匹配问题。我们希望在文本(String txt)中能够找到我们想要找到的匹配项(string pat)。

正文

5.3 字符串查找


字符串的一种基本操作就是子字符串查找:给定一段长度为N的文本和一个长度为M的模式字符串,在文本中找到一个和该模式相符的字符串。
解决该问题的大部分算法都可以很容易的扩展为找出文本中所有和该模式相符的字符串、同级该模式在文本中的出现次数、或者找出上下文(和该模式相符的子字符串周围的文字)的算法。

为了更好地理解算法,请记住模式相对于文本是很短的(M可能等于100或者1000),而文本相对于模式是很长的(N可能等于100万或者是10亿)。在字符串查找中,一般会对模式进行预处理来支持在文本中的快速查找。


5.3.1 历史简介


1)字符串查找有一个简单而使用广泛的暴力算法。虽然它在最坏情况下的运行时间与MN成正比,但是在处理许多处理许多应用程序中的字符串时(除了一些变态的情况外),它的实际运行时间一般与M+N成正比。
2)在1970年,S.Cook在理论上证明了一个关于某种特定类型的抽象计算机的结论。这个结论暗示了:在最坏情况下,用时与M+N成正比的解决子字符串查找问题的算法。D.E.Knuth & V.R.Pratt改进了Cook用来证明定力的宽假并将它提炼为一个相对简单而实用的算法。同时,J.H.Morris在实现一个文本编辑器时候,为了解决在文本中避免“回退”的问题也发明了类似的算法。
Knuth、Morris和Pratt在1976年发表了他们的算法。在这段时间里,R.S.Boyer和J.S.Moore发明了一种在许多应用程序中都非常快的算法,该算法一般只会检查文本字符串中的一部分字符。(许多文本编辑器都会使用以显著降低字符串查找的响应时间)。
3)KMP算法与BM算法都需要对模式字符串进行复杂的预处理,这个过程十分晦涩也限制了他们的应用范围。在1980年,M.O.Rabin和R.M.Karp使用散列开发出了一种与暴力算法几乎一样简单但运行时间与M+N成正比的概率极高的算法。另外,他们的算法还肯以扩展到二维的模式和文本中,这使得它比其他算法更适用于图像处理。


5.3.2 暴力子字符串查找算法(Brute-force substring search)


子字符串查找的一个最显而易见的方法就是在文本中patten可能出现匹配的任何地方检察匹配是否存在。
public static int search(String pat, String txt){
	int M = pat.length();
	int N = txt.length();
	for(int i = 0; i <= N-M; i++){
		int j;
		for(int j=0; j < M; j++)
			if(txt.charAt(i+j)!=pat.charAt(j))
				break;
		if(j==M) return i;   //patten
	}
	return N; // no patten
}
在典型的字符串处理应用程序中,索引j增长的机会很少,因此该算法的运行时间与N成正比。绝大多数比较在比较第一个字符时就会产生不匹配。
但是这样的情况是不好说的。对于这种暴力法搜素出时间复杂度的数学模型来说有如下讨论。

命题M
在最坏情况下,暴力子字符串查找算法在长度为N的文本中查找长度为M的模式需要~NM次字符比较。
证明:算法的最坏情况下是字符串A接连作为patten的字符串B。对于N-M+1个可能匹配的位置,模式中的所有字符串都需要和文本比对,cost = M(N-M+1)。一般来说M远小于N,因此总成本为~NM。

这里给出另一种提出参考的代码,使用了一个指针i跟踪文本,一个指针j跟踪patten。
public static int search(String pat, String txt){
	int j,M = pat.length();
	int i,N = txt.length();
	for(i = 0 ,j = 0; i <= N && j < M; i++){		
		if(txt.charAt(i+j)!=pat.charAt(j))
			j++;
		else{
			i -= j;
			j = 0; //come back
		}
	}
	if(j == M) 
		return i-M;   //patten
	else 
		return N;
}

DG Tips:这样的算法容易让人想到,坏处就是出现命题M一样的NM的时间复杂度。然后大家对这个算法做了很多优化,一个很容易想到优化位置就是回溯的时候是否需要回退到原来的位置——拿上面的算法举例子,我们已经比对过了很多的文本,一个非常惋惜的例子就是不匹配发生在最后一个文字,形如“txt = pat.substring(0,pat.length()-2)+pat;”的例子。

5.3.3 Knuth-Morris-Pratt 子字符串查找算法

Knuth、Morris和Pratt发明的算法的基本思想是当出现不匹配是,就能知道一部分文本内容。我们可以利用这些信息避免将指针回退到全部已经匹配过的字符前。
KMP算法的主要思想是提前判断如何重新开始查找,而这种判断只取决于patten本身。


public class KMP                                                                                                                                                 

KMP(String pat)        create a DFA that can search for pat

int search(String txt)                     find index of pat in txt

Substring search API


public class KMP {
    private final int R;       // the radix
    private int[][] dfa;       // the KMP automoton
    private char[] pattern;    // either the character array for the pattern
    private String pat;        // or the pattern string
    // create the DFA from a String
    public KMP(String pat) {
    } 
    // create the DFA from a character array over R-character alphabet
    public KMP(char[] pattern, int R) {   
    } 
    // return offset of first match; N if no match
    public int search(String txt) {
    }
    // return offset of first match; N if no match
    public int search(char[] text) {
        // simulate operation of DFA on text
    }  
}


5.3.3.1 patten指针的回退
KMP字符串查找算法中,不会回退文本指针i,而是使用一个二维数组来记录匹配失败是模式指针j应该回退多远。(DG Tips: 在动态规划中的字符串匹配题目一般会构造一个二维数组来记录成败,这里的二维数组叫做dfa[][],这个名字的用意是构造确定优先状态自动机)

5.3.3.2 KMP Search Algorithm
假设得到了dfa[][]数组,就得到了后面款注所示的子字符串查找算法:
当i和j所指向的字符匹配失败时候,pattern可能匹配的下一个位置应该从i-dfa[txt.charAt(i)][j]处开始。按照算法,从该位置开始的dfa[txt.charAt(i)][j]个字符应该相同,因此无需回退到指针i,只需要将j设为dfa[txt.charAt(i)][j]并i++。

5. 3.3.3 DFA 模拟

dfa[][] 正是一个确定有限状态机。假设我们已经得到这个dfa[][],看看这个是如何运转的。





KMP substring search(DFA simulation)
public int search(String txt) {
        // simulate operation of DFA on text
        int M = pat.length();
        int N = txt.length();
        int i, j;
        for (i = 0, j = 0; i < N && j < M; i++) {
            j = dfa[txt.charAt(i)][j];
        }
        if (j == M) return i - M;    // found
        return N;                    // not found
    }


5.3.3.4 构造DFA
接下来解决KMP算法的关键:如何计算给定pattern相对应的数组dfa[][]

关键在于需要重新扫描文本字符的正是pat.charAt(1)到pat.charAt(j-1)之间(忽略首字母是因为pattern需要右移以为,忽略最后一个是因为匹配失败)。对于每一个可能匹配失败的位置都可以预先找到重启DFA的正确状态。

DFA应该如何处理?答案是,除非匹配成功,DFA都要跳转到前面的某个状态,所以计算DFA的No. j状态只需要如何处理前j-1个状态即可。

计算中最后一个关键细节是,你可以观察到在处理dfa[][]的第j列时维护重启位置X很容易:
因为X<j,所以可以有已经构造的DFA部分来完成-X的下一个值是dfa[pat.charAt(j)][X]。ep. dfa['C'][X]
由以上的讨论可以得到:
  • 将dfa[][X]复制到dfa[][j](对于匹配失败的情况);
  • dfa[pat.charAt(j)][j] 设为j+1(对于匹配成功的情况)
  • 更新X。

 public KMP(char[] pattern, int R) {
        this.R = R;
        this.pattern = new char[pattern.length];
        for (int j = 0; j < pattern.length; j++)
            this.pattern[j] = pattern[j];

        // build DFA from pattern
        int M = pattern.length;
        dfa = new int[R][M]; 
        dfa[pattern[0]][0] = 1; 
        for (int X = 0, j = 1; j < M; j++) {
            for (int c = 0; c < R; c++) 
                dfa[c][j] = dfa[c][X];     // Copy mismatch cases. 
            dfa[pattern[j]][j] = j+1;      // Set match case. 
            X = dfa[pattern[j]][X];        // Update restart state. 
        } 
    } 



命题N 
对于长度为M的模式字符串和长度为N的文本,Knuth-Morris-Pratt 字符串查找算法访问的字符不会超过M+N个。
证明:由代码可以马上得到,在计算dfa[][]时,算法会访问模式字符串中的每个字符一次,在search()方法中会访问文本中的每个字符(最坏情况下)一次。

还需要一个参数,字母表的大小R,所以构造DFA所需要总时间(and Spatial)将与MR成正比。如果构造DFA时为每个状态设置一个匹配转换和一个非匹配转换(而非指向每个可能出现的字符的多个转换),那么也可以去掉参数R,但构造过程会更加复杂一些。

KMP算法为最坏情况提供的线性级别运行时间保证是一个重要理论成果。


5.3.4 Boyer-Moore 字符串查找算法

与KMP算法一样,我们会根据匹配失败时文本和模式中的字符来决定下一步的行动。而预处理步骤的目的在于片判断对于文本中可能出现的每一个字符,同样考虑在匹配失败时算法赢怎么办。
5.3.4.1 启发式的处理不匹配的字符
5.3.4.2 起点
5.3.4.3 子字符串的查找
命题O 
在一般情况下,对于长度为N的文本和长度为M的模式字符串,使用了Boyer-Moore的子字符串查找算法通过启发式处理不匹配的字符需要~N/M次字符比较。

5.3.5 Rabin-Karp 指纹字符串查找算法

M.O.Rabin和R.A.Karp发明了一种完全不同的基于散列的字符串查找算法。我们需要计算模式字符串的散列函数,然后用相同的散列函数计算文本中所有可能的M个字符的子字符串散列值并寻找匹配,这是一种能够在常数时间内算出M个字符的子字符串散列值的方法(需要预处理),这样得到了在实际应用中的运行时间为线性级别的字符串查找算法,
5.3.5.1 基本思想
5.3.5.2 计算散列函数
5.3.5.3 关键思想
5.3.5.4 实现
5.3.5.5 小技巧:蒙克卡罗法验证正确性

5.3.6 总结


后记



习题

参考博客





评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值