目录
- 一、串的基本定义
- 二、串的存储结构
- 1.链式存储
- 2.顺序存储
- 三、BF匹配算法(暴力、朴素算法)
- 四、KMP匹配算法
- 1.用法梗概
- 2.原理讲解(手搓next数组)
- 3.机算next数组
- 1.原理
- 2.图解
- 4.进一步优化KMP算法
一、串的基本定义
前面我们学了线性表的栈和队列,这两种是操作受限的线性表,那么今天引入的串就是内容受限的线性表,就是内容只能是字符;下面就介绍几个基本概念
二、串的存储结构
由于串也是线性结构,而这类结构前面已经实现地很多了,我们今天主要引进一些新的概念和特点
1.链式存储
优点:操作方便,可以随时扩容
缺点:存储密度过低(存储密度=数据大小/实际大小)
由于其缺点是存储密度过低,我们可以将数据空间扩大(如果串够长的话),这样就可以将多个字符放进一个节点里面而这一个节点就可以称为块,这样定义的时候就是块链结构:
#define chunksize 10
typedef struct chunk
{char data[chunksize];//一个节点块存10个字符chunk* next;//节点指针域
}chunk;typedef struct
{chunk* head, * tail;//串头尾指针int length;//串长度
}lstring;
2.顺序存储
顺序存储就比较简单,这里就不做代码演示,我们就说一下它的使用情景,由于链式存储的优点是操作方便,比如增删改查,但是我们后面在对串进行匹配运算或者查找的运算的时候就会体现出顺序存储的优势,所以我们后面进行这些算法实现的时候都是用的顺序存储
三、BF匹配算法(暴力、朴素算法)
算法目的:确定主串所含的子串(模式串)第一次出现的位置,直白一点就是定位功能
算法思路:从主串的第一个字符开始依次与子串进行比较,直到匹配失败或者子串在主串中匹配到完全一样的一段字符
算法设计:用两个指针i和j来分别指向主串和子串的字符,比较时就用两个指针同时指向的字符,如果在匹配中途匹配失败i就要回溯到此次匹配开始处的下一个字符重新开始匹配,而这个时候j指针也要回溯到子串首字符处,直到匹配结束退出循环
这里的回溯是有技巧的,图中的i=i-j+2就是公式,我们可以设计一个变量在每次开始匹配时记忆位置方便下次回溯,但是这样也会有点麻烦,我们有更简单的方法 ,由于我们在匹配的时候都是i和j共同移动的,所以其实这里的j就起了记录i走了多少步的作用,由于每次j都是从1开始,所以i每次都走了j-1步,那么i-(j-1)就是我们最开始的位置,而这里的i=i-j+2其实就是回溯到了下一次匹配的开始位置,而子串由于也要重新开始和主串开始匹配,所以也要每次都回溯到1的位置
#include<iostream>
#define sstringsize 10
using namespace std;
typedef struct
{char data[sstringsize+1];int length;//记录串的长度
}sstring;void init(sstring& s)
{s.length = 0;
}//建立顺序串
void insert(sstring& s)
{if (s.length == sstringsize) { cout << "建立错误:串满" << endl; return; }char x;int i=s.length;while (cin >> x && x != '0'&&s.length<sstringsize){s.data[++i] = x;s.length++;}
}
int index_BF(const sstring& s, const sstring& t)
{int i=1, j=1;while (i <= s.length && j <= t.length){if (s.data[i++] == t.data[j++]);else{j = 1;i = i - j + 2;}}if (j>t.length)return i - t.length;else return 0;
}int main()
{sstring s,t;init(s);init(t);insert(s);insert(t);cout<<index_BF(s, t);
}
四、KMP匹配算法
1.用法梗概
上面我们讲的是一个比较慢的经典算法,但是这种算法过于笨拙,每一个匹配错误主串和子串都会回溯,这样时间复杂度就来到了O(m*n)了,所以这里就介绍KMP算法,这种算法的特点是主串的指针不用一直回溯;而这个算法的核心就是利用一个next数组使主串指针不回溯;
这里介绍一下几个关于next数组的基本概念:
- 前缀:包含首字符但不包含尾字符的子串
- 后缀:包含尾字符但不包含首字符的子串(前缀和后缀的概念都包含空串)
- next数组定义:当主串和模式串在某一位字符不匹配的时候,模式串要退回的位置
- next[j]的值=j前面的子串的最大前后缀重合数+1
当模式串指j针走到k处时,说明前面的1~k-1个字符都和主串匹配成功,那么当前位置匹配失败就使i指针不动,使j指针移动到next[j]处,这样继续匹配可以使i不回溯,使j部分回溯,大大减小时间复杂度,举个例子:
2.原理讲解(手搓next数组)
当我们中途匹配失败时,不用像暴力算法那样全部回溯,因为我们已知在匹配失败之前的字符都是匹配的,例如下图中主串在6处就是失配的,但是在这之前都是已知匹配的,我们就能只根据模式串判断下次模式串往后移动多少才算最快,很明显移动一步或者两步都是不行的,只有移动三步模式串的1、2才能和主串的4、5匹配上,所以精髓就是利用前面已知的内容找到可以匹配的公共前后缀,并且这个前后缀还需要是最长的那一个,避免漏掉中间可能匹配的情况,这样就可以不用移动主串,回溯一次模式串就可以,将重合部分的字符数+1就是为了对齐当前位置,这样就得到了当前位置的next数组的值,这就是next数组的值的由来,下次当我们在这个位置匹配失败时就可以直接移动模式串到达next数组指定的位置;已知上述原理,我们可以算出next[6]的值为2+1=3,也就是说i不动,j移动到3处
3.机算next数组
1.原理
经过下图手搓next数组可以总结处一下规律,原理也很简单,在介绍原理之前先捋清楚几个关系:
- j之前的最大前后缀重合数=next[j]-1
- Pj-1=Pnext[j]-1
- 前next[j]-1位字符和后next[j]-1位字符重合
原理:
- 每次前后缀最大重合数最多只能加1,因为当前位置j如果和重合前缀的后一个字符匹配(Pj=Pnext[j]),那么最大重合数就可以加1了,因为又多了一个重合字符
- 从next数组的原理来解释就是最后一个数组值是由前面的内容得出来的,与最后一个字符无关;从客观上讲就是无论最后一个字符是什么,只要这个字符不匹配我们的模式串指针都是要回溯的
2.图解
由于计算机无法想我们人脑一样去进行匹配最大前后缀重合数,而计算机就是通过递推的方式求解next数组,下面就是一些步骤图解:
这个图中的例子也许会显得很快,那么我们可以重新举个情况最坏的例子:求next[j+1],设最大重合数一直是j-2(最坏的情况),那么next[j]的值一直都是j-1,由于我们一直都是往前递推对Pj和递进的Pnext[j]进行比较的,这样求一个next值就会递推j次到达终点,时间复杂度就会是2j;那么最终我们就能得出结论:如果Pnext[j]≠Pj,那么所求的next[j+1]可能的值就要递推为next[next[j]]+1,以此类推直到找到尾字符与Pj相等的前缀或者j=0时结束
,这样无论情况多坏,我们只要有一次递推到j=0,后面两个字符只要相等next就+1,不相等就直接j=0,确定next的值;结论:最坏的情况就是类似aaaaab这种,时间复杂度为2n,第一次循环是遍历求解next值,第二次是不相等不断往前递推,下面是代码演示:
#include<iostream>
#define sstringsize 10
using namespace std;
typedef struct
{char data[sstringsize+1];int length;//记录串的长度
}sstring;void init(sstring& s)
{s.length = 0;
}//建立顺序串
void insert(sstring& s)
{if (s.length == sstringsize) { cout << "建立错误:串满" << endl; return; }char x;int i=s.length;while (cin >> x && x != '0'&&s.length<sstringsize){s.data[++i] = x;s.length++;}
}int* getnext(sstring s)
{int* next = new int[s.length+1];next[1] = 0;//规定首字符next值为0int i = 1, j = 0;while (i <s.length)//找完{//如果指针递推到0或者找到公共前后缀的重合字符下一个位置的next就在j的基础上加1进行对齐if (j == 0 || s.data[i] == s.data[j])next[++i] = ++j;else j = next[j];//更新模式串指针}return next;
}int index_KMP(const sstring& s, const sstring& t)
{int* next = getnext(t);int i = 1, j = 1;while (i <=s.length && j <= t.length){if (j==0||s.data[i] == t.data[j]) { i++; j++; }else j = next[j];}delete next;if (j > t.length)return i - t.length;else return 0;
}int main()
{sstring s,t;init(s);init(t);insert(s);insert(t);cout<<index_KMP(s,t);
}
4.进一步优化KMP算法
其实优化的不是这个算法思路,而是优化求next数组的方法,因为next数组由于不知道更新完模式串指针后两个字符是否相等,如果相等那肯定会匹配失败,因为没更新之前都匹配失败了,更新后还是这个字符那明显就还会失败,所以我们需要优化一下next数组的更新路径
int* getnextval(sstring s)
{int* next = new int[s.length + 1];next[1] = 0;//规定首字符next值为0int i = 1, j = 0;while (i < s.length)//找完{//如果指针递推到0或者找到公共前后缀的重合字符下一个位置的next就在j的基础上加1进行对齐if (j == 0 || s.data[i] == s.data[j])next[++i] = ++j;else j = next[j];//更新模式串指针}for(j=2;j<=s.length;j++)if (s.data[j] == s.data[next[j]])next[j] = next[next[j]];return next;
}
其实很简单,在求出next数组之后我们直接用一个for循环就可以把next数组优化一遍,注意要从前往后,如果从后往前就更新得不彻底了