算法笔记(2):KMP 算法

介绍

KMP算法(全称Knuth-Morris-Pratt字符串查找算法,由三位发明者的姓氏命名)是可以在文本串 \(S\)\(O(|S|+|P|)\) 查找模式串 \(P\) 的一种算法。

思想

考虑 \(O(|S||P|)\) 的暴力匹配算法,对于文本串 \(S\)每一位开始与模式串 \(P\) 逐一匹配
缺点在于每次都要重新开始逐一匹配,不能充分利用前面匹配得到的信息,导致算法效率低下

那么我们需要考虑的问题是 匹配失败后从哪一位开始重新匹配最好?

首先,我们需要引入一些概念

前缀函数

子串\(S[i..j]\) \((i≤j)\),即 \(S[i],S[i+1],\ldots,S[j]\)
前缀\(Prefix(S,k)=S[0..k]\)
真前缀\(Prefix(S,k)=S[0..k]\) \((k \neq |S| - 1)\) 【不包含 \(S\) 本身】
后缀\(Suffix(S,k)=S[k..|S|-1]\)
真后缀\(Suffix(S,k)=S[k..|S|-1]\) \((k \neq 0)\) 【不包含 \(S\) 本身】
公共前后缀\(Prefix(S,k) = Suffix(S,|S|-k-1)\)
真公共前后缀\(Prefix(S,k) = Suffix(S,|S|-k-1)\) \((k \neq |S|-1)\) 【不包含 \(S\) 本身】

定义 前缀函数 \(\pi[i]\)\(S[0..i]\)最长真公共前后缀的长度
\(\pi[i] = \max_{k = 0 \dots i-1}\{k: S[0..k] = S[i-k..i]\}\)

怎么维护?

  1. 暴力:枚举前缀子串 \(O(n)\),枚举该串的前后缀 \(O(n)\),比较 \(O(n)\),总 \(O(n^3)\)

  2. 注意到 \(S[0..i]\) 的真公共前后缀 移除最后一位后的串\(S[0..i-1]\) 的真公共前后缀,我们可以根据这个构造递推方程

    对于 \(k = 0 \dots i-2\)
    如果 \(S[k+1] == S[i]\)\(\pi[i] = \max(\pi[i],\{k: S[0..k] = S[i-1-k..i-1]\} + 1)\)
    否则 \(\pi[i] = \max(\pi[i],0)\)

    因此我们只需要从大到小枚举 \(S[0..i-1]\) 的所有真公共前后缀进行匹配就可以得出 \(S[0..i]\) 的最长真公共前后缀
    那么如何从大到小枚举 \(S[0..i-1]\) 的所有真公共前后缀?
    \(\overbrace{\underbrace{S_0S_1}_k S_2S_3}^{\pi[i-1]} \dots \overbrace{S_{i-4}S_{i-3} \underbrace{S_{i-2}S_{i-1}}_k}^{\pi[i-1]}S_i\)
    假设下一个真公共前后缀 \(S[0..k] = S[i-1-k..i-1]\) \((k < \pi[i]-1)\)
    因为 \(S[0..\pi[i-1]-1] = S[i-\pi[i-1]..i-1]\)
    \(S[i-1-k..i-1] = S[\pi[i-1]-1-k..\pi[i-1]-1]\)
    那么 \(S[0..k] = S[\pi[i-1]-1-k..\pi[i-1]-1]\) ①②


    假设 \(S_0S_1\) = \(S_{i-2}S_{i-1}\)
    因为 \(S_0S_1S_2S_3\) = \(S_{i-4}S_{i-3}S_{i-2}S_{i-1}\)
    \(S_{i-2}S_{i-1}\) = \(S_2S_3\)
    那么 \(S_0S_1\) = \(S_2S_3\) ①②

    由于 \(S[0..k] = S[\pi[i-1]-1-k..\pi[i-1]-1]\) 且 这是下一个真公共前后缀
    \(\pi[\pi[i-1]-1] = k\)\(\pi的定义\)
    那么找下标为 \(i-1\)下一个真公共前后缀 \(\Leftrightarrow\) 找下标为 \(\pi[i-1]-1\)最长公共前后缀

    综上,从大到小枚举 \(S[0..k]\) 的所有真公共前后缀只需要不断地将 \(k \to \pi[k]-1 \to \pi[\pi[k]-1]-1 \to .. -1\) 不断循环直到成功匹配或者枚举到-1即可

    因此,求 \(S[i]\) ,令 \(k = i-1\),按照上面的枚举方式不断循环即可
    如果成功匹配,那么 \(\pi[i] = \pi[k] + 1\)
    否则 \(\pi[i] = 0\)

    代码

    点击查看代码
    vector<int> getSuffixFunction(string S){//0-based
    	//"aabaaab"
    	int n = S.size();
    	vector<int> pi(n);
    	for(int i = 0; i < n; i ++ ){	//递推求pi
    		int k = i-1;
    		while(k != -1 && S[pi[k]] != S[i])k = pi[k]-1;	//从大到小枚举S[0..k]的所有真公共前后缀
    		//k == -1:说明枚举过程结束
    		//S[pi[k]] == S[i]:说明匹配成功
    		if(k == -1 || S[pi[k]] != S[i])pi[i] = 0;	//匹配失败
    		else pi[i] = pi[k] + 1;		//匹配成功
    	}
    	return pi;
    	//[0,1,0,1,2,2,3]
    }
    

理解前缀函数的递推后,字符串匹配就简单了
将模式串 \(P\) 和 文本串 \(S\) 按照 \(P + ? + S\) 【?为 \(P\)\(S\) 中均没有出现的字符】的形式拼接求前缀函数即可
模式串在文本串中的条件为 \(\pi[i] = |P|\),首位为 \(i - 2|P|\)


模式串 \(P\) = "ABA"
文本串 \(S\) = "ABABA"
新串:\(ABA?ABABA\)
下标:\(0\ 1\ 2\ 3\ 4\ 5\ 6\ 7\ 8\)
\(\pi\):$\ \ \ \ 0\ 0\ 1\ 0\ 1\ 2\ 3\ 2\ 3$

代码

点击查看代码
	vector<int> KMP(string S,string P){//0-based
		S = P + "?" + S;
		int n = S.size();
		vector<int> pi(n);
		vector<int>res;
		for(int i = 0; i < n; i ++ ){
			int k = i-1;
			while(k != -1 && S[pi[k]] != S[i])k = pi[k]-1;
			if(k == -1 || S[pi[k]] != S[i])pi[i] = 0;
			else pi[i] = pi[k] + 1;
			if(pi[i] == P.size())res.push_back(i - 2*P.size());
			//返回首位
		}
		return res;
	}
posted @ 2024-12-03 00:20  Keith-  阅读(128)  评论(0)    收藏  举报