数据结构与算法-串
一、串的基本概念
1、串是由零个或多个字符组成的有限序列,又名叫字符串。
2、一般记为s="a1a2a3...an(n>=0)";其中s是串的名称,用双引号括起来的部分就是字符序列的值。
3、串中的字符数目n称为串的长度;
4、零个字符的串称为空串,它的长度为零,用""表示;
注意:空格串是只包含空格的串,空格串是有内容有长度的,而且还可以有多个空格。
5、对于两个不相等的字符串,如何判断它们的大小,判断依据如下:
给定两个字符串,
s="a1a2a3a4...an"
t="b1b2b3b4...bm"
(1)当n<m且ai=bi(i=1,2...n)。此时就说s<t。例如:s="hap",t="happy"。此时s<t;
(2)存在某个k<=min(m,n),使得ai=bi(i=1,2...k-1),ak<bk。此时也说s<t。例如:s="happen",t="happy"。此时s<t。
二、字符串的抽象数据类型
串的逻辑结构和线性表很相似,不同之处在于串针对的是字符集,也就是串中的元素都是字符,因此串的基本操作与线性表的操作还是有区别的。线性表更关注单个元素的操作,比如查找一个元素、插入或删除一个元素等,而串更多的是查找子串的位置、得到指定子串的位置,替换子串等操作。
以下用代码实现一下串的基本操作。
/* 生成一个其值等于字符串常量chars的串T */
Status StrAssign(String T,char *chars){
if(strlen(chars) > MAXSIZE) return ERROR;
T[0] = strlen(chars);
for(int i = 1; i<=T[0]; i++){
T[i]=chars[i-1];
}
return OK;
}
/*串S存在,由串S复制得到串T*/
Status StrCopy(String T, String S){
if(S == NULL || S[0] <= 0 || S[0]>MAXSIZE) return ERROR;
int temp = T[0];
T[0] += S[0];
for(int i=temp+1; i<=T[0]; i++){
T[i] = S[i-temp];
}
return OK;
}
/*串S存在,将串清空*/
Status ClearString(String S){
S[0] = 0;
return OK;
}
/*若串S为空,返回true,否则返回false*/
BOOL StringEmpty(String S){
if(S[0] <= 0) return YES;
return NO;
}
/*返回串S的元素个数,即串的长度*/
char StringLength(String S){
return S[0];
}
/*若S>T,返回值>0,若S=T,返回0,若S<T,返回值<0*/
int StrCompare(String T, String S){
int i = 0;
if(S[0]<T[0]){
return -1;
}
for(i = 0; i<T[0] && i<S[0]; i++){
if(T[i+1]<S[i+1]){
return 1;
}
else if(T[i+1]>S[i+1]){
return -1;
}
}
if(i == T[0] && i == S[0]) return 0;
if(i==T[0] && i<S[0]) return -1;
if(i==S[0] && i<T[0]) return 1;
return 0;
}
/*串S存在,1<=pos<=StrLength(S),且0<=len<=StrLength(S)-pos+1,用Sub返回串S的第pos个字符起长度为len的子串*/
Status SubString(String Sub, String S, int pos, int len){
if(S == NULL || S[0] <= 0) return ERROR;
if(pos<0 || pos>S[0]) return ERROR;
if(len<0 || (len+pos-1)>S[0]) return ERROR;
Sub[0] = len;
for(int i = 1; i<=len; i++){
Sub[i] = S[pos++];
}
return OK;
}
/*用T返回由S1和S2联接而成的新串*/
Status Concat(String T, String S1, String S2){
if(S1 == NULL || S1[0] <= 0){
if(S2 != NULL && S2[0]<MAXSIZE){
return StrCopy(T, S2);
}
else{
return ERROR;
}
}
else if(S2 == NULL || S2[0] <= 0){
if(S1 != NULL && S1[0]<MAXSIZE){
return StrCopy(T, S1);
}
else{
return ERROR;
}
}
if(S1[0]+S2[0]>MAXSIZE) return ERROR;
StrCopy(T, S1);
StrCopy(T, S2);
return OK;
}
/*串S和T存在,T是非空串,1<=pos<=StrLength(S)。
若主串S中存在和串T值相同的子串,则返回它在主串中S的第pos个字符之后第一次出现的位置,否则返回0*/
int Index(String T, String Sub, int pos){
if(T == NULL || T[0]<=0) return ERROR;
if(Sub == NULL || Sub[0]<=0) return ERROR;
if(pos<0 || pos>T[0]) return ERROR;
String temp;
while((pos+Sub[0]-1)<=T[0]){
SubString(temp, T, pos, Sub[0]);
if(StrCompare(temp, Sub) == 0){
return pos;
}
pos++;
}
return 0;
}
三、模式匹配算法
子串的定位操作通常称为串的模式匹配。这应该是串最重要的操作之一。
1、朴素的模式匹配算法(BF算法)
例如,我们需要从主串S="goodgoogle"中,找到T="google"这个子串的位置。
1、主串S第一位开始,S与T前三个字母都匹配成功,但S的第四个字母d与T的第四个字母g匹配失败。
image.png
2、主串S第二位开始,主串S首字母是o,而子串T的首字母是g,匹配失败。
image.png
3、主串S第三位开始,主串S的首字母是o,仍然不与子串的首字母g匹配。
image.png
4、主串S第四位开始,主串S的首字母是d,子串首字母是g,匹配失败。
image.png
5、主串第5位开始,S与T,6个字母全部匹配,匹配成功。
image.png
简单来说,就是对主串的每一个字符作为子串开头,与要匹配的字符串进行匹配,对主串做大循环,每个字符开头做T的长度的小循环,直到匹配成功或全部遍历完成。
BF算法代码
int Index(String T, String Sub, int pos){
if(T[0] < Sub[0]) return 0;
int i = pos;//i用于主串S中当前下标,若pos不为1,则从pos位置开始
int j = 1;//j用于子串T的当前位置下标值
while(i<=T[0] && j<=Sub[0]){
if(T[i] == Sub[j]){//主串和子串中两字母相同,则继续下位的字母比较
i++;
j++;
}
else{
i = i-j+2;//主串回到上次匹配的首位的下一位,即主串上次匹配的位置是3,则下次主串从第4个字符开始比较
j = 1;
}
}
if(j>Sub[0]){
return i-Sub[0]+1;
}
return 0;
}
BF算法效率分析:
BF算法中,
最好的情况是第一个字母之后就完全匹配,时间复杂度为0(1),比如S="googlegood",T="google"。
稍差一点的情况,如上述图中的示例,S="goodgoogle",T="google",时间复杂度为O(m+n),m为主串长度,n为子串长度。
最差的情况,就是每次不成功的匹配都发生在串T的最后一个字符。如:S="0000000000000000000000000001",T="000001";最坏情况的时间复杂度为O((m-n+1)*n)。
由上述分析可知,BF算法太低效了,
RK算法
假设我们有某个hash函数可以将字符串转换为一个整数,则hash结果不同的字符串肯定不同,但hash结果相同的字符串则很有可能相同(存在小概率不同的可能)。
按照这个思路,我们可以将主串分割成n个长度为m的子串,并对每个子串进行hash计算,得到每个子串对应的hash值。再将每个子串的hash值与需要匹配的字符串的hash值进行比较。
image.png
根据RK算法核心思想,接下来分步对RK思想进行解析。
1、字母换算成哈希值
设计hash函数:
hash(Si-m+1...Si) = Si-m+1*Dm-1 + Si-m+2*Dm-2 + ... + Si-1*D + Si
其中:D:表示进制;
2、主串拆解的子串哈希值求解规律
每次比较都对主串中的子串进行hash求解,那时间算法复杂度为O(m)。如果可以利用上一个子串的hash结果hash(Si-m+1...Si),在O(1)的时间内求出下一个子串的hash(Si-m+2...Si+1),则可以将整体复杂度降低到线性时间。
根据hash函数可以得到:
hash(Si-m+2...Si+1) = Si-m+2 *Dm-1 + Si-m+3 *Dm-2 + ... + Si *D + Si+1
= (hash(Si-m+1...Si) - Si-m+1 *Dm-1) * D + Si+1
3、代码实现
/*由于字符串是26个英文字母,为了最大概率减少hash重复,定义进制为26进制*/
#define D 26
/*计算hash函数
如当abc转成hash时,
abc=a*D^2+b*D^1+c*D^0
*/
long caluHash(char* S, int num){
long hashValue = 0;
for(int i = 0; i < num; i++){
hashValue = (hashValue*D+ (S[i] - 'a'));
}
return hashValue;
}
/*用来获取hash值计算时的进制高位,
当字符串长度为m时,进制高位为D^(m-1)
在计算下一个子串时,需要用到
*/
int getMaxValue(int m){
int h = 1;
for(int i = 0;i < m - 1;i++){
h = (h*D);
}
return h;
}
/*定义一个匹配函数,用来验证给定长度为m的子串确实等于主串中i位置之后的m个字符串*/
int isMatch(char *S, int i, char *P, int m){
int is = i;
int ip = 0;
for(is=i, ip=0; is!=m && ip!=m;is++,ip++){
if(S[is] != P[ip]){
return 0;
}
}
return 1;
}
int RK(char *S, char *P){
if(S == NULL || P == NULL) return -1;
int n = (int)strlen(S);
int m = (int)strlen(P);
long sHash = caluHash(S, m);
long pHash = caluHash(P, m);
long hValue = getMaxValue(m);
for(int i = 0; i<=(n-m); i++){
if(sHash == pHash) {
if(isMatch(S, i, P, m)){
return i+1;
}
}
//RK算法核心,通过前一个子串推导后一个子串的hash值
sHash = ((sHash-(S[i]-'a')*hValue)*D+(S[i+m]-'a'));
}
return -1;
}
总结:可以看出,RK算法可以在线性时间内完成匹配,RK算法时间稍慢的原因主要有hash结果相同不一定完全匹配,需要再逐字符进行对比。时间复杂度精确值应为O(n) + O(m*n)。