在刷题时有一道很经典的题型:从输入的字符串中,找出给定的子字符串的位置。列如:在字符串 “goose is not good” 中找出 “good” 的位置。
暴力循环——朴素的模式匹配算法
不考虑任何优化和性能,我们直接挨个遍历主串和子串(双重循环),依次检查每个字符是否相等,只要输入正确,就一定能检查。
//查找子串在主串中出现的位置
static int Index(string S, string T)
{
int i = 0;//i用于定位主串的下标
int j = 0;//j用于定位子串的下标
while (i < S.Length && j < T.Length)
{
if (S[i] == T[j])
{
i++;
j++;
}
else
{
//主串下标后腿至上次匹配的首位的下一位
//如果确定子串的字符是完全互不相等,i可以不用回退
i = i - j + 1;
j = 0;//子串下标也退回到首位,准备重新匹配
}
}
if (j >= T.Length)
return i - T.Length;
else
return -1;
}
输出结果:
13
分析一下:如果“good”在首位,那就是一开始就能匹配成功,比如“good idea”中查找“good”,时间复杂度为O(1)。但是如上述例子中所诉good在最后,那就需要从头遍历到尾是最坏的情况则主串需要循环(n-m+1)次(n为主串的长度,m为子串的长度),此时算法的时间复杂度则O((n-m+1)*m)。
KMP模式匹配算法
在上面查找good的案例中我们其实做了一些无用功,当程序查找到goos时,发现该组不能匹配,我们将主串下标移到了第二位 “o” 其实这之后的三次匹配肯定都是无效的,我们可以直接从s的下一位开始匹配,去掉无用的匹配过程。如果主串S=“abcdefgabcdex”
(这里可以很长,举例子就写简短一点),匹配子串T="abcdex"
,如果用匹配算法的话,当匹配到“f”之后,又从第二开始匹配,这时匹配“bcdef”
都属于无效匹配,其实可以直接从f
这个元素开始匹配。
注意这是KMP算法的关键:
- 我们已经确定了T串中的字符均不相等,避免回溯。我们已经知道了T中的第二位和主串的第二位相等了,那样就意味着T串中的首位肯定和主串中的第二位不想等。以此类推,下一轮匹配时可以直接从
"f"
开始了 - T子串也出现字符相等的情况例如将上述的主串设置为
S=“abcabxabcabedefg”
需要匹配的子串设置为S=“abcabe”
,当匹配到 “x” 前面已经有 “abcab” 已经匹配上了,这个时候主串直接从第四位开始匹配,子串从第三位开始匹配。 - 难点:怎么确定当局部匹配失败后,下次从哪一位上开始从新匹配呢?KMP算法的难点就在于求最长匹配公共前缀和后缀的长度。把子串的匹配位置变化定义为一个next数组,在求解next数组之前,需要理解一个最长前后缀字符的概念:
字符串“ABC” :前缀字符串"A",“AB”,“ABC”,后缀字符子串:“C”、“BC”、“ABC”,在计算next数组判断前后缀字符子串的来判断next的值
j | 0 | 1 | 2 | 3 | 4 | 5 |
---|---|---|---|---|---|---|
子串 | a | b | c | a | b | e |
next[j] | -1 | 0 | 0 | 0 | 1 | 2 |
分析如下:
(1)当j=0时,设置为默认状态 next[j]=-1;
(2)当j=1时, 此时j由0到j-1的串前后缀没有重复字符next[1]=0;
(3)当j=2时,此时j由0到j-1的串前后缀没有重复字符,next[2]=0;
(4)当j=3时,此时j由0到j-1的串前后缀没有重复字符,next[3]=0;
(5)当j=4时,此时j由0到j-1的串时 “abca” ,前缀字符 “a” 和后缀字符 “a” 相等,因此k值为1,因此next[4]=1;
(6)当j=5时,此时j由1到j-1的串时 “abcab” ,前缀字符 “ab” 和后缀字符 “ab” 相等,因此k值为2,因此next[5]=2;
又例如求T="aaaaab"
的next变化数组
j | 0 | 1 | 2 | 3 | 4 | 5 |
---|---|---|---|---|---|---|
子串 | a | a | a | a | a | b |
next[j] | -1 | 0 | 1 | 2 | 3 | 4 |
分析如下:
(1)当j=0时,next[0]为默认的-1;
(2)当j=1时,此时j由1到j-1的串前后缀没有重复字符next[1]=0;
(3)当j=2时,此时j由1到j-1的串 “aa”, 前后缀字符子串均为**“a”,因此next[2]=1;
(4)当j=3时,此时j由1到j-1的串“aaa”**,前后缀字符子串均为 “a”, “aa”, 因此next[3]=2
…
next[j],从第1位开始,实际上就是j由0到j-1的最长公共子串的长度。
KMP 算法的核心,先求出 next 数组:
static int[] get_next(string T)
{
int[] next = new int[T.Length];
int i = 0;
int j = -1;
next[0] = -1;
while (i < T.Length-1)
{
if (j == -1 || T[i] == T[j])
{
j++;
i++;
next[i] = j;
}
else
{
j = next[j];
}
}
return next;
}
输入:“aaaaab”
输出: -1 0 1 2 3 4
在确定了next数组后,我们就可以将朴素的模式匹配进行优化:
static int Index_KMP(string S, string T)
{
int i = 0;//i用于定位主串的下标
int j = 0;//j用于定位子串的下标
var next = get_next(T);
while (i < S.Length && j < T.Length)
{
if (j==0||S[i] == T[j])
{
i++;
j++;
}
else
{
//相比暴力的解法,KMP的主串不会回退
j = next[j];//子串下标也退回到合适的位置,准备重新匹配
}
}
if (j >= T.Length)
return i - T.Length;
else
return -1;
}
算法分析: 计算next数组时候只涉及了简单的循环因此时间复杂度为O(m),由于在KMP算法中i值不回溯,因此在主串中查找匹配的时候时间复杂度为O(n),因此整体的时间复杂度为O(m+n)相对暴力的模式匹配的时间复杂度O((n-m+1)*m)来说相对好些。不过KMP算法在主串和子串之间存在许多“部分匹配”的情况下才能体现它的优势,如果子串的字符是互不相等的情况,朴素的模式匹配和KMP模式匹配的差异并不明显。
Next数组还有优化空间,当子串类似“aaaab”存在多个连续相同的字符是,next数组回溯还是存在无用功。只需要将核心逻辑添加一个判断即可:
if (T[i] != T[j])
next[i] = j;
else
next[i] = next[j];