字符串匹配问题的形式定义:
比如上图中,目标是找出所有在文本 T = abcabaabcabac 中模式 P = abaa 的所有出现。该模式在此文本中仅出现一次,即在位移 s = 3 处,位移 s = 3 是有效位移。
也叫朴素的字符串匹配算法。
是最常想到的,也是最好实现的,所以在简单情况下可以直接使用。
首先从原字符串最左端开始匹配子字符串,如果第一个字符与子字符串匹配,则继续看第二个字符与子字符串第二个字符是否匹配。。。如果不匹配,则找原字符串下一位与子字符串第一位相匹配,以此类推
let arr = 'hello world';
let sunArr = 'or';
function matchingStr (arr, sunArr) {
for(let i = 0; i < arr.length - sunArr.length + 1; i ++) {
//如任意个长度的查找3个长度的子串,大字符串的最后3-1个长度没必要比的
let j = 0;//用来判断查找的长度
while(j < sunArr.length) {//只要查找长度不大于子串长度就可以继续比
if(arr[i + j] != sunArr[j]) break;//循环的过程中只要有一个字符不符合,就退出
j ++;//下一位
}
if(j == sunArr.length) return i;//完全符合
}
reutrn -1;
}
console.log(matchingStr(arr,sunArr));
其实一个循环就可以解决:
function matchingAtr(arr, sunArr) {
let i = 0, j = 0;//i表示arr匹配的元素位置,j为sunArr匹配的元素位置
while(i < arr.length && j < sunArr.length) {
if(arr[i] == sunArr[j]) {
i ++;
j ++;
} else {
i = i - j + 1;//因为要下一位,所以+1
j = 0;
}
}
if(j == sunArr.length) {
return i - j;
} else {
return -1;
}
}
console.log(matchingAtr('hello world', 'rld'));
下面,我用自己的语言,试图写一篇比较好懂的KMP算法解释。
首先,字符串"BBC ABCDAB ABCDABCDABDE"的第一个字符与搜索词"ABCDABD"的第一个字符,进行比较。因为B与A不匹配,所以搜索词后移一位。
因为B与A不匹配,搜索词再往后移。
就这样,直到字符串有一个字符,与搜索词的第一个字符相同为止。
接着比较字符串和搜索词的下一个字符,还是相同。
直到字符串有一个字符,与搜索词对应的字符不相同为止。
这时,最自然的反应是,将搜索词整个后移一位,再从头逐个比较。这样做虽然可行,但是效率很差,因为你要把"搜索位置"移到已经比较过的位置,重比一遍。
一个基本事实是,当空格与D不匹配时,你其实知道前面六个字符是"ABCDAB"。KMP算法的想法是,设法利用这个已知信息,不要把"搜索位置"移回已经比较过的位置,继续把它向后移,这样就提高了效率。
怎么做到这一点呢?可以针对搜索词,算出一张《部分匹配表》(Partial Match Table)。这张表是如何产生的,后面再介绍,这里只要会用就可以了。
已知空格与D不匹配时,前面六个字符"ABCDAB"是匹配的。查表可知,最后一个匹配字符B对应的"部分匹配值"为2,因此按照下面的公式算出向后移动的位数:
移动位数 = 已匹配的字符数 - 对应的部分匹配值
因为 6 - 2 等于4,所以将搜索词向后移动4位。
因为空格与C不匹配,搜索词还要继续往后移。这时,已匹配的字符数为2(“AB”),对应的"部分匹配值"为0。所以,移动位数 = 2 - 0,结果为 2,于是将搜索词向后移2位。
因为空格与A不匹配,继续后移一位。
逐位比较,直到发现C与D不匹配。于是,移动位数 = 6 - 2,继续将搜索词向后移动4位。
逐位比较,直到搜索词的最后一位,发现完全匹配,于是搜索完成。如果还要继续搜索(即找出全部匹配),移动位数 = 7 - 0,再将搜索词向后移动7位,这里就不再重复了。
下面介绍《部分匹配表》是如何产生的。
首先,要了解两个概念:“前缀"和"后缀”。 "前缀"指除了最后一个字符以外,一个字符串的全部头部组合;"后缀"指除了第一个字符以外,一个字符串的全部尾部组合。
"部分匹配值"就是"前缀"和"后缀"的最长的共有元素的长度。以"ABCDABD"为例,
- "A"的前缀和后缀都为空集,共有元素的长度为0;
- "AB"的前缀为[A],后缀为[B],共有元素的长度为0;
- "ABC"的前缀为[A, AB],后缀为[BC, C],共有元素的长度0;
- "ABCD"的前缀为[A, AB, ABC],后缀为[BCD, CD, D],共有元素的长度为0;
- “ABCDA"的前缀为[A, AB, ABC, ABCD],后缀为[BCDA, CDA, DA, A],共有元素为"A”,长度为1;
- “ABCDAB"的前缀为[A, AB, ABC, ABCD, ABCDA],后缀为[BCDAB, CDAB, DAB, AB, B],共有元素为"AB”,长度为2;
- "ABCDABD"的前缀为[A, AB, ABC, ABCD, ABCDA, ABCDAB],后缀为[BCDABD, CDABD, DABD, ABD, BD, D],共有元素的长度为0。
自己的看法:
其实这样实现起来个人觉得会很吃力,我倒有一个简易一点的算法:
function computePMV(str) {
let pmv = [0];
for(let i = 1; i < str.length;) {
let j = 0, time = 1;
if(str[i] != str[j]) {
pmv[i] = 0;
i ++;
} else {
while(str[i] == str[j]) {
pmv[i] = time;
i ++;
j ++;
time ++;
}
}
}
return pmv;
}
console.log(computePMV('abcdabd'));
"部分匹配"的实质是,有时候,字符串头部和尾部会有重复。比如,“ABCDAB"之中有两个"AB”,那么它的"部分匹配值"就是2("AB"的长度)。搜索词移动的时候,第一个"AB"向后移动4位(字符串长度-部分匹配值),就可以来到第二个"AB"的位置。
KMP算法的时间复杂度为:O(m+n)
实现KMP算法:
//求next[]
function computePMV(str) {
let pmv = [0];
for(let i = 1; i < str.length;) {
let j = 0, time = 1;
if(str[i] != str[j]) {
pmv[i] = 0;
i ++;
} else {
while(str[i] == str[j]) {
pmv[i] = time;
i ++;
j ++;
time ++;
}
}
}
return pmv;
}
let bigArr = 'bbc abcdab abcdabcdabde'
let smallArr = 'abcdabd';
function KMP(bigArr,smallArr) {
let next = computePMV(smallArr);
for(let i = 0, j = 0; i < bigArr.length; ) {
if(bigArr[i] != smallArr[j] && j == 0) i ++;//j不等于0的时候i不能动
while(bigArr[i] == smallArr[j]) {//如果元素相等则开始while循环判断后续
i ++;
j ++;
}
if(j == smallArr.length) return i - j;
if(j != 0) j = next[j - 1];
}
return -1;
}
console.log(KMP(bigArr, smallArr));
一般文本编辑器中的查找功能都是基于它实现的 。
基于它的原理,通常搜索关键字越长,算法速度越快。
它的效率来自这样一个事实:
它是基于以下两个规则让模式串每次向右移动 尽可能大 的距离。
坏字符出现的时候有两种情况进行讨论。
1、模式串中没有出现了文本串中的那个坏字符,将模式串直接整体对齐到这个字符的后方,继续比较。
2、模式串中有对应的坏字符时,让模式串中 最靠右 的对应字符与坏字符相对。
这句话有一个关键词是 最靠右。
思考一下为什么是 最靠右?
坏字符规则实现:
1、如果模式串中存在已经匹配成功的好后缀,则把目标串与好后缀对齐,然后从模式串的最尾元素开始往前匹配。
2、如果无法找到匹配好的后缀,找一个匹配的最长的前缀,让目标串与最长的前缀对齐(如果这个前缀存在的话)。模式串[m-s,m] = 模式串[0,s] 。
3、如果完全不存在和好后缀匹配的子串,则右移整个模式串。
比如在 cod, code, cook, five, file, fat 这个字符串集合中查找某个字符串是否存在。如果每次查找都依次比较的话,效率会很低,但是用Trid树来解决就会更高效。
建树的过程中,将字符串的最后一个字符标记为橙色。
然后查找的时候,查到最后要判断最后字符是否为橙色,如果不是橙色然后也查找到了,那就说明它只是一个单词的中间子串,所以结果为不存在此字符串。
通过上图,可以发现Trid树的三个特点:
Trie树的插入操作:
比如要插入新单词cook,就有下面几步:
Trie树的删除操作:
Trie树的局限性
如前文所讲,Trie的核心思想是空间换时间,利用字符串的公共前缀来降低查询时间的开销以达到提高效率的目的。 假设字符的种数有m个,有若干个长度为n的字符串构成了一个 Trie树 ,则每个节点的出度为 m(即每个节点的可能子节点数量为m),Trie树 的高度为n。很明显我们浪费了大量的空间来存储字符,此时Trie树的最坏空间复杂度为O(m^n)。也正由于每个节点的出度为m,所以我们能够沿着树的一个个分支高效的向下逐个字符的查询,而不是遍历所有的字符串来查询,此时Trie树的最坏时间复杂度为O(n)。 这正是空间换时间的体现,也是利用公共前缀降低查询时间开销的体现。
实现Trie树:
//Trie树
let charArr = ['cod', 'code', 'cook', 'five', 'file', 'fat'];
let patternStr = 'cod';
//建Trie树
function insertHash(tree, patternStr) {//向Tree树中插入字符
// let hashHead = tree;
let pLen = patternStr.length;
let nowNode = tree;
for(let i = 0; i < pLen; i ++) {
let now = patternStr[i];//要比较的字符
if(nowNode[now] == undefined) {//不存在此字符
let hash = {orange: false};
nowNode[now] = hash;//新建一个hash给patternStr
}
if(i == pLen - 1) //如果是字符的最后一位,则变为橙色
nowNode[now].orange = true;
nowNode = nowNode[now];
}
}
//查找模式串是否在字符集中
function Trie(charArr,patternStr) {
//根据charArr建树
let TrieTree = {};//Trie树
let len = charArr.length;
for(let i = 0; i < len; i ++) {
insertHash(TrieTree, charArr[i]);
}
// console.log(TrieTree);//哈哈哈我真是太棒了!!
//根据Trie树查找模式串
let nowNode = TrieTree;//当前比较的
for(let i = 0; i < patternStr.length; i ++) {
let now = patternStr[i];
if(nowNode[now] != undefined) //说明有这个字符
nowNode = nowNode[now];
else //没有这个字符则返回没找到,false
return false;
}
//循环完之后没有return,则说明Trie树中有此字符,但也有可能是其他字符的子串,所以要看字符最后一位的orange是否为true
if(nowNode.orange == false)
return false;
else
return true;
}
console.log(Trie(charArr, patternStr));