T28 实现indexOf
题目链接:leetcode-cn.com/problems/im…
背景介绍
其实这题原本的名字其实「实现strStr」,但是对于我们JavaScript开发者来说「实现indexOf」显然会更加亲切一些,所以我便改了标题的名称。
众所周知,在V8 JavaScript Engine中,内置了「Linear Search」(处理length<7的短字符串)、「BMH Search」(处理length≥7的长字符串)、「BM Search」(特定条件下触发)这几种子字符串搜索算法。JS引擎在对字符串的具体情况进行研判后,会自动选择最适合的算法进行搜索。
在本文中,我们将学习运用其中的「Linear Search」和「BMH Search」来编写本题的解答代码。
朴素解法:Linear Search
所谓的「Linear Search」,实际上就是进行暴力枚举。
我们很容易想到它的实现方式:
引入指针i遍历整个传入的字符串haystack,再以haystack[i]为起点,向后检查是否存在等于needle的子串。
代码如下:
function strStr(haystack, needle) {
let len1 = haystack.length;
let len2 = needle.length;
//注意:这里我们以len1 - len2 + 1而不是len1为遍历终点.
//思考:这么做有什么好处?
for(let i = 0; i < len1 - len2 + 1; i++) {
let j = 0;
while(i + j < len1 && j < len2) {
if(needle[j] !== haystack[i + j]) break;
j++;
}
if(j === len2) return i;
}
return -1;
}
当字符串的长度较短时它的确是一个很不错的选择。
高速解法:BMH Search
「BMH Search」全称「Boyer Moore Horspool Search」,又称「Horspool Search」,是一种由「BM Search」(全称「Boyer Moore Search」)改进而来的高速字符串匹配算法。
「BMH Search」相对于暴力枚举的「Linear Search」最主要的优势在于它能够根据最新对比到的原字符串中的字符灵活控制对原字符串进行遍历的步长,而非死板地逐位遍历,以尽可能地排除不可能的位置,快速找到匹配子字符串的位置。
读完上面这段话,你现在可能还一脸懵逼。没关系,我们通过一个简单的例子来模拟一下「BMH Search」的执行过程。
简单的🌰
我们首先假定haystack = "JiangNanGame yyds!",needle = 'yyds'。
在正式开始模拟搜索之前,我们需要首先建立一个映射表shift,用于记录字符串needle中除最后一个字符外,每个出现过的字符从右往左数的下标位置(如果该字符出现多次,以最右侧的为准)。
根据上述的,在我们的例子中,应当建立如下的映射表shift:
| key | value |
|---|---|
"y" | 2 |
"d" | 1 |
首先,我们让haystack和needle并排放置,如下图所示:
然后,我们对两字符串进行对比,其规则为:从needle的最右端开始,从右往左与haystack中对应位置的字符进行比较。
经过比较我们发现,needle的最右端字符s与haystack中对应位置的n就不匹配。这时,让我们停止继续向左比较两个字符串,因为既然连最后一个字符都匹配不上,那整个字符串就不可能在这里匹配了。
现在让我们将needle向右移动needle.length个单位,如下图所示:
我们再重复之前的比较方法,可以发现这时needle的最右端字符s又立马与haystack中对应位置的n不匹配。
那么重复上面的方法,我们就继续向前移动needle,如下图所示:
再比较,再移动......
现在,needle的最右端字符s与haystack中对应位置的d还是不匹配。接下去还是继续向前移动needle.length个单位吗?
这显然是荒谬的,因为再这么移动下去,就会错过haystack中我们真正想要寻找的yyds。
现在,让我们回忆一下最开始我们建立的映射表shift,里面存在一个映射"d" → 1。不管你有没有反应过来,先让我们将needle向右移动1个单位试试看:
我们发现,现在两个字符串中的d的位置完全对应上了;更神的是,整个字符串needle也跑到了和haystack中内容完全对应的位置。我们的查找成功了!
分析🌰与提炼算法思想
现在让我们分析一下在刚才的例子中究竟发生了什么,能够让我们一下子找到匹配位置。并在此过程中提炼出「BMH Search」的算法思想。
最后一次移动
首先我们先抛开前几次比较和移动不谈,直接来看看最后一次移动。
为什么当最后一次移动1个单位时,needle能和haystack中对应的内容恰好完全匹配呢?别忘了,这个映射表shift是用于记录needle中字符(除最后一位)到字符串最右端距离的,而现在needle中的末端位s又恰恰是和原字符串haystack中的d对应。很明显,只需要让needle向右移动shift["d"]个单位,就能使needle能和haystack中对应的内容恰好完全匹配!
当然必须要说明的是,此处needle能和haystack中对应的内容恰好完全匹配的根本原因还是在于needle中的内容恰巧位于此处,映射表shift的作用在于告诉了我们再向右移动多少可以使两者对应。
事实上,当算法发现needle中的末端位s对应的d在映射表中存在时,它只不过是「试探性」地向右移动shift["d"]个单位,再通过从右向左的遍历以校验移动后两者能否发生匹配。如果校验后发现匹配失败,它仍然会继续向右搜索。
另外相信你也已经发现了,在本例中,shift中的"y" → 2并没有发生变化。事实上,在算法正式运行之前建立映射表的时候,我们是无法预知其中的哪几条记录会派上用场的,因此要把needle中除最后一位字符外所有字符的信息都予以记录。
前几次移动
现在我们再来看前几次比较和移动。为什么这几次我们直接一口气向右移动needle.length个单位呢?
以第一次为例。此时needle的最右端字符s对应原字符串haystack中的n在映射表中并没有记录,再这时加上needle和原字符串的对应内容经对比匹配不上,因此n就不可能是字符串needle中的某一位字符。换句话说,在这次移动时,无论我们怎么按映射表shift中的某一个value进行移动,都不可能发生匹配!
因而对于此时needle所对应的原字符串中的区域,我们只需要直接排除就可以了。又考虑到在映射表中必定有value < needle.length,我们直接将字符串needle向右移动其长度个单位就可以了。
两个问题
通过上面的分析,相信你对「BMH Search」基本算法思想已经有了一定的把握。下面我们再来思考两个问题:
- (i) 为什么在构建映射表
shift的时候,最后一位字符不能记录? - (ii) 为什么在进行匹配校验的时候,我们采用从右向左的比较?
对于问题(i),如果我们按前文介绍的规则在映射表中记录了最靠右的一位字符,则其value应当为0,例如本文例子中的s → 0。那么当出现needle中的最后一位字符与haystack中的字符"s"恰好对应,而又经校验后发现此处并非匹配位置的情况时,由于needle无法继续向右移动,程序便会进入死循环。
因此我们绝对不能记录最后一位字符。
对于问题(ii),实际上我在初次接触这个算法的时候也有疑问。目前国内外绝大多数资料上介绍的都是从右向左的比较,但仔细推敲之后我认为:无论是从右向左,还是一般的从左向右,并不会对算法的性能造成影响。
我的这一想法也在国外的一篇Papar中得到印证:
This comparison can happen right-to-left, left-to-right… In our analysis, we focus on the right-to-left case for concreteness, but the modifications for the other cases are trivial. ——《Exact Analysis of Horspool’s and Sunday’s Pattern Matching Algorithms with Probabilistic Arithmetic Automata》
总结
现在让我们再回过头来看看我在开始的时候对于「BMH Search」优势的介绍:
根据最新对比到的原字符串中的字符灵活控制对原字符串进行遍历的步长
现在再看到这句话,相信你已经恍然大悟了。所谓「灵活控制」,用通俗一点的话来说,指的便是研判在右侧靠近的区域,是否存在移动后实现匹配的可能性。
我们对上面一系列的分析和提炼总结如下:
- 如没有立即匹配的可能性,就直接以
needle.length的大步长直接「跳开」。 - 如有立即匹配的可能性,就借助映射表
shift进行小步长的「试探」。
这便是为什么「BMH Search」比朴素解法「Linear Search」快的奥秘所在!
代码实现
原题题解
下面直接给出「BMH Search」的代码实现,它与我们先前分析的算法思想完全一致。
代码如下:
function strStr(haystack, needle) {
const len1 = haystack.length;
const len2 = needle.length;
const shift = new Map();
/* 构建映射表shift */
for (let i = 0; i < len2 - 1; i++) {
shift.set(needle[i], len2 - i - 1);
}
/* 「BMH Search」核心部分 */
let start = 0; //用于标记移动后字符串needle的头部位置
while (start <= len1 - len2) {
//逐位对比needle和对应位置haystack的字符
//这里我们仍然使用从右向左校验的主流做法
let j = len2 - 1;
while (haystack[start + j] === needle[j]) {
j--;
//匹配成功则说明两者完全对齐
//此时needle的头部位置即为答案
if (j < 0) return start;
}
//向右移动needle
let key = haystack[start + len2 - 1];
start += shift.has(key) ? shift.get(key) : len2;
}
return -1;
}
有关编程过程中的注意点已全部用注释标记,本文不再进行赘述!
拓展:寻找所有匹配子串的下标
当然了,利用「BMH Search」或者我们最先介绍的「Linear Search」也可以实现搜索出所有匹配子字符串的功能。
修改「Linear Search」的代码来实现这一功能,对于我们来说是非常简单的。
「BMH Search」修改版本如下,感兴趣的同学可以自行研究哈:
function strStr(haystack, needle) {
const len1 = haystack.length;
const len2 = needle.length;
const shift = new Map();
const ans = [];
for (let i = 0; i < len2 - 1; i++) {
shift.set(needle[i], len2 - i - 1);
}
while (start <= len1 - len2) {
let j = len2 - 1;
while (haystack[start + j] === needle[j]) {
j--;
if (j < 0) ans.push(start);
}
/*
思考:
当匹配成功后,寻找后面匹配子串的时候,
为什么我们仍然利用shift移动needle,
而不是直接令start += 1或者start += needle.length?
*/
let key = haystack[start + len2 - 1];
start += shift.has(key) ? shift.get(key) : len2;
/*
提示:
haystack = "ABABA", needle = "ABA"
*/
}
return ans;
}
写在文末
我是来自在校学生编程兴趣小组江南游戏开发社的PAK向日葵,我们目前正在致力于开发自研的非营利性网页端同人游戏《植物大战僵尸:旅行》,以锻炼我们的前端应用开发能力。
我们诚挚邀请您体验我们的这款优秀作品,如果您喜欢TA的话,欢迎向您的同事和朋友推荐。如果您有技术方面的问题希望与我们探讨,欢迎直接与我联系。您的支持是我们最大的动力!