数据结构与算法之字符串:PM
字符串
字符串是⼀个很有意思的结构:由⼀连串字符的东西排成了⼀个线性序列
它的特点可以归纳为两个⽅⾯
相⽐于向量可以存储复杂的结构,字符串⾥⾯的元素只能是字符
它是⼀个整体,⼀般很长,通过整体或局部整体性进⾏计算
经常从⾥⾯挑出⼀段,我们称之为pattern,访问⽅式是 call-by-pattern
这个模式,⾥⾯蕴含诸多技巧
PM: Preliminaries 预备知识
既然是线性序列,我们就可以把它排个队,通过下标来访问(或通过⼀个秩的东西来访问,⼀个意思)下标从0~n-1, 假设在n的位置有⼀个虚拟的哨兵,我们叫界桩,同样在处理的时候在-1的位置也有⼀个哨兵通过这种⽅式来帮助我们理解⼀些算法实现
字符串还有⼀个概念叫做substr, 也就是它的局部, ⼀般它有由两个东西来的定义,i,k
S.substr(i,k) = S[i, i+k], 0 <= i < n, 0 <= k
i是起始字符的rank, k是长度,左闭右开
[i, i+k) 是⼀个切⽚
字符串还有⼀个概念叫做prefix前缀
学习了当上⾯的i为0的时候, 长度为k的⼦串叫做原字符串的前缀: [0, k)
S.prefix(k) = S.substr(0, k) = S[0,k), 0 <= k <= n
和prefix相反的概念,还有⼀个叫做suffix
后缀:[n-k, n)
S.suffix(k) = S.substr(n-k, n) = S[n-k, n), 0 <= k <= n
这样,字符串的substr,可以通过前缀和后缀的操作来得到
S.substr(i, k) = S.prefix(i+k).suffix(k) = S.suffix(n-i).prefix(k)
这个很容易理解
PM: Pattern Matching 模式匹配
什么是套利
字符串中研究很经典的问题是模式匹配问题
孙中山与宋庆龄
卡通大树Text: now is the time for all good people to come
Pattren: people
我们的people这个Pattern能否在原⽂本Text中出现
搜索引擎的本质其实和字符串搜索没区别
通常我们把原始字符串的长度记为:n = |T|项目合作方案
我们把模式记为:m = |P|
其中:2 << m << n
这⾥m也是⼀个⾜够⼤的变量
其中n的数量级是⾮常⼤的
将来如果有了⼀个算法,你如何来评价它的性能好坏
我们可以随机⽣成⼀个Text和Pattern,跑⼀次算法
再随机⽣成,跑⼀跑算法
写⼀个脚本,⽣成⾜够数量的随机Text和Pattern,进⾏算法测试
其实,这是⼀个⾮常不妥的算法,因为随机中,成功的可能⾮常⼩
因为随机⽣成的Text和Pattern都是完全混沌的,如果是26个英⽂字母
n种,Pattern的可能有26
Text的可能有26m种
成功的概率是: n / (26^m) 很低很低了
分母会出现指数爆炸并很快超过分⼦,整体很快趋于0
所以你测试再多,成功的可能依然很低
所以这种测试是⾮常有问题,⽽且是⾮常糟糕的
正确的做法是随机⽣成⼀个Text,然后在Text中选择出⼀个Pattern PM: Brute-Force 暴⼒算法
在这⾥插⼊图⽚描述
如果给定⼀个Text和Pattern, 任何⼀个Pattern串要和Text某个地⽅匹配上
命中位置⼀定在0 ~ n-m的位置,这⾥是⼀个⾮常简单的算法
通过在Text中移位Pattern进⾏对⽐,如上图所⽰,但是效率堪忧
上图绿⾊的是经过⼀轮⽐对后,成功的位置
红⾊是当前对齐中失败的位置,灰⾊是省下时间的位置
我们知道红⾊和绿⾊成本是⼀样的,都是经过⼀次对⽐
由上图可知
Best: O(n)
⼀般认识是上来就命中,但是这⼀般不作为我们的Best ca. 我们不考虑运⽓问题
我们考虑常规性问题,⼀般在失败意义上⽽⾔,从头到尾跑⼀遍
Pattern的第⼀个就不匹配,赶紧移动,匹配或不匹配
驱虫斑鸠菊丸Worst: O(n·m)
每次匹配到Pattern的最后⼀个才发现不匹配,并且在Text中从头到尾跑了⼀遍
⽆论最终是匹配或不匹配
算法实现:版本1
int match(char* P,char* T){
波纹的意思size_t n =strlen(T), i =0;
size_t m =strlen(P), j =0;
// ⾃左向右逐次⽐对
while(j < m && i < n){
if(T[i]== P[j]){
// 若匹配则转到下⼀对字符
i ++;
单词之间j ++;
}el{
// 否则,T回退,P复位
i -= j-1;// 注意这⾥每个时刻i和j的差都是i-j, 移动⼀格就是i-j+1,写法不同,意义相同
j =0;
}
}
return i-j;// 如何通过返回值,判断匹配结果:最后出格,通过判断i-j在允许的范围内
}
算法实现:版本2
int match(char* P,char* T){
size_t n =strlen(T), i =0;
size_t m =strlen(P), j;
for(i=0; i<n-m+1; i++){
// T[i]与P[0]对齐后,逐次⽐对
for(j=0; j<m; j++){
if(T[i+j]!= P[j]){
break;// 失配,转下⼀对齐位置
}
}
if(m <= j){
break;// 完全匹配
}
}
return i;
}
以上是两种蛮⼒算法的实现
PM: KMP: Good Prefix + Look-up Table
在这⾥插⼊图⽚描述
由上图可知,Pattern字符串中第⼀个字符是R, 当进⾏⼀次对⽐后,就可以记录下,当前对⽐中是否存在是R的字符如果存在,下次对⽐,直接记录并挪动到该位置,这样省去了中间,⽐如上图的EGR之间的对⽐,直接跨过来如上图的例⼦:
当前在0号位置对齐,移位到4号位置出现不匹配的错误
这时候,就没有必要在EG的位置移动位置了,因为不匹配
直接到R进⾏移位,也就是3号位的R
这⾥需要借助⼀个look-up table来处理
通过这个Pattern字符串, 不⽤出现主串, 我们可以给它预处理,记录⼀些笔记
如果在某个位置适配了,应该拿哪个字符和它对齐,我们看到失败是在O这个位置上
那么我们给O记录⼀个笔记1,如上图所⽰,这个意思就是说,如果失败在O字符上,
我们应该拿1号字符E和O进⾏对齐
在这⾥插⼊图⽚描述
上⾯⼜是另⼀个例⼦,我们看到,上⾯对齐的过程,直到L和X适配,发现失败了
蛮⼒算法是移动⼀位,现在我们有了⼀个查询表,我们看到在L的位置失败了,⽤L上记录的3
也就是Pattern上的3号字符也就是N,来和刚才失败的位置对齐
换句话说是⽤N来替代刚才失败的L来和X对齐,然后就可以继续对⽐下去…
⾸先我们可以看到这个过程移动⾮常的快,在这个例⼦中移动了4个位置,这4步跳过去都是安全的
并且剩下的3个字符CHI也是可以完美匹配成功的,所以这个查询表很重要,它可以让你⼤步移动
并且剩下的字符可以不⽤对⽐,必然能匹配成功,所以,接下来,只需要在刚才失败的位置继续努⼒即可这时候,N和X对⽐,显然⼜失败了,这时候,找到N头上记录的0,也就是Pattern的0号位C来对齐
这时候C是⾸字符了,仍然C和X适配失败,C头上记录的是-1,这时候的意思是整体向前移过去1位
其实-1代表的是通配符,天⽣就是绿⾊的,以此来解开僵局,这个算法就变得更加鲁棒简明
这就是著名的KMP算法
KMP算法具体实现
int match(char* P,char* T){
int* next =buildNext(P);// 构造⼀个查询表
int n =(int)strlen(T), i=0;
int m =(int)strlen(P), j=0;
while(j < m && i < n){
if(0> j || T[i]== P[j]){
i++;
j++;
}el{
j = next[j];
}
}
delete[] next;
return i - j;
}
直观的对⽐
在这⾥插⼊图⽚描述
只有绿⾊和红⾊部分花时间,最坏情况下也是线性的算法
青⾊的部分被KMP跳过去了, 灰⾊的部分并没有出现
总的时间复杂度不超过O(n)
PM: KMP: Understanding next[] 理解查询表