在论坛和聊天室这样的场景里,为了保证用户体验,我们经常需要屏蔽很多不良词语。对于单个关键词查找,自然是indexOf、正则那样的方式效率比较高。但对于关键词较多的情况下,多次重复调用indexOf、正则的话去匹配全文的话,性能消耗非常大。由于目标字符串通常来说体积都比较大,所以必须要保证一次遍历就得到结果。根据这样的需求,很容易就想到对全文每个字符依次匹配的方式。比如对于这段文字:“Mike Jordan had said “Just do IT”, so Mark has been a coder.”,假如我们的关键词是“Mike”“Mark”,那么可以遍历整句话,当找到“M”就接着看能不能匹配到“i”或者“a”,能一直匹配到最后则成功找到一个关键词,否则继续遍历。那么关键词的结构就应该是这样的:

  1 var keywords = {
  2   M: {
  3     i: {
  4       k: {
  5         e: {end: true}
  6       }
  7     },
  8     a: {
  9       r: {
 10         k: {end: true}
 11       }
 12     }
 13   }
 14 }

由上文可以看出这个数据就是一个树结构,而根据关键词组来创建树结构还是比较耗时的,而关键词却又是我们早已给定的,所以可以在匹配前预先创建这样的数据结构。代码如下:

  1 function buildTree(keywords) {
  2   var tblCur = {},
  3     key, str_key, Length, j, i;
  4   var tblRoot = tblCur;
  5 
  6   for(j = keywords.length - 1; j >= 0; j -= 1) {
  7     str_key = keywords[j];
  8     Length = str_key.length;
  9     for(i = 0; i < Length; i += 1) {
 10       key = str_key.charAt(i);
 11       if(tblCur.hasOwnProperty(key)) {
 12         tblCur = tblCur[key];
 13       } else {
 14         tblCur = tblCur[key] = {};
 15       }
 16     }
 17     tblCur.end = true; //最后一个关键字
 18     tblCur = tblRoot;
 19   }
 20   return tblRoot;
 21 }

这段代码中用了一个连等语句:tblCur = tblCur[key] = {},这里要注意的是语句的执行顺序,由于[]的运算级比=高,所以首先是在 tblCur对象中先创建一个key属性。结合tblRoot = tblCur = {} 看,执行顺序就是:

  1 var tblRoot = tblCur = {};
  2 tblRoot = tblCur;
  3 tblCur['key'] = undefined;  // now tblRoot = {key: undefined}
  4 tblCur['key'] = {};
  5 tblCur = tblCur['key'];
通过上面的代码就构建了好了所需的查询数据,下面看看查询接口的写法。

对于目标字符串的每一字,我们都从这个keywords顶部开始匹配。首先是 keywords[a] ,若存在,则看 keyword[a][b],若最后 keyword[a][b]…[x]=true 则说明匹配成功,若 keyword[a][b]…[x]=undefined,则从下一个位置重新开始匹配 keywords[a] 。

  1 function search(content) {
  2   var tblCur,
  3     p_star = 0,
  4     n = content.length,
  5     p_end,
  6     match, //是否找到匹配
  7     match_key,
  8     match_str,
  9     arrMatch = [], //存储结果
 10     arrLength = 0; //arrMatch的长度索引
 11 
 12   while(p_star < n) {
 13     tblCur = tblRoot; //回溯至根部
 14     p_end = p_star;
 15     match_str = "";
 16     match = false;
 17     do {
 18       match_key = content.charAt(p_end);
 19       if(!(tblCur = tblCur[match_key])) { //本次匹配结束
 20         p_star += 1;
 21         break;
 22       } else {
 23         match_str += match_key;
 24       }
 25       p_end += 1;
 26       if(tblCur.end) //是否匹配到尾部
 27       {
 28         match = true;
 29       }
 30     } while (true);
 31 
 32     if(match) { //最大匹配
 33       arrMatch[arrLength] = { 
 34         key: match_str,
 35         begin: p_star - 1,
 36         end: p_end
 37       };
 38       arrLength += 1;
 39       p_star = p_end;
 40     }
 41   }
 42   return arrMatch;
 43 }
 44

以上就是整个关键词匹配系统的核心。这里很好的用到了js的语言特性,效率非常高。我用一篇50万字的《搜神记》来做测试,从中查找给定的300个成语,匹配的效果是1秒左右。重要的是,由于目标文本是一次遍历的,所以目标文本的长短对查询时间的影响几乎不计。对查询时间影响较大的是关键词的数量,目标文本的每个字都遍历一遍关键词,所以对查询有一定影响。

简单分析

看到上文估计你也纳闷,对每个字都遍历一遍所有关键词,就算有些关键词有部分相同,但是完全遍历也是挺耗时的呀。但js中对象的属性是使用哈希表来进行构建的,这种结构的数据跟单纯的数组遍历是有很大不同的,效率要比基于顺序的数组遍历高得多。可能有些同学对数据结构不太熟悉,这里我简单说一下哈希表的相关内容。

 

首先看看数据的存储。

数据在内存的存储由两部分组成,一部分是值,另一部分是地址。把内存想象成一本新华字典,那字的解释就是值,而目录就是地址。字典里面是按拼音排序的,比如相同发音的“ni”就排在同一块,也就是说数组整齐排列在一块内存区域里面,这样的结构就是数组,你可以指定“ni” 1号,10号来访问。结构图如下:
array01

数组的优势是遍历简单,通过下标就能直接访问相应的数据了。但是它要增删某一项就非常困难。比如你要把第6项删掉,那第5项之后的数据都要向前移一个位置。如果你要删除第一位,整个数组都要移动,消耗非常大。

array02

 

为了解决数组增删的问题,链表就出现了。如果我们将值分成两部分,一部分用来储存原来的值,另一部分用来储存一个地址,这个地址指向另外一个同样的结构,以此类推就构成了一个链表。结构如下:

link01

从上图可以明显看出,对链表进行增删非常简单,只要把目标项和前一项的next改写就完成了。但是要查询某个项的值就非常困难了,你必须依次遍历才可以访问到目标位置。

link02

 

为了整合这两种结构的优势,聪明如你一定想到了下面这种结构。

hash

 

这种数据结构就是哈希表结构。数组里面存储链表的头地址,就可以形成一个二维数据表。至于数据如何分布,这个就是哈希算法,正规的翻译应该是散列算法。算法虽然有很多种,原理上都是通过一个函数对key进行求解,再根据求解得到的结果安放数据。也就是说key和实际地址之间形成了一个映射,所以这个时候我们不再以数组下标或者单纯的遍历来访问数组,而是以散列函数的反函数来定位数据。js中的对象就是一个哈希结构,比如我们定义一个obj,obj.name通过散列,他在内存中的位置可能是上图中的90,那我们想要操作obj.name的时候,底层就会自动帮我们通过哈希算法定位到90的位置,也就是说直接从数组的12项开始查找链表,而不是从0开始遍历整个内存块。

js中定义一个对象obj{key: value},key是被转换成字符串然后经过哈希处理得到一个内存地址,然后将值放入其中。这就可以理解为什么我们可以随意增删属性,也能理解为什么在js中还能为数组赋属性,而且数组也没有所谓的越界了。

在数据量较大的场合,哈希表具有非常明显的优势,因为它通过哈希算法减少了很多不必要的计算。所谓性能优化,其实就是让计算机少运算;最大的优化,就是不计算!

算法的优化

现在理解算法底层实现,回过头来就可以考虑对算法进行优化了。不过在优化前还是要强调一句:不要盲目追求性能!比如本案例中,我们最多就是5000字的匹配,那现有算法足矣,所有优化都是不必要的。之所以还来说说优化,就是为了提高自己对算法对程序的理解,而不是真的要去做那1ms的优化。

我们发现我们的关键词都没有一个字的,那我们按照一个字的单位进行关键词遍历显然就是一个浪费了。这里的优化就是预先统计关键词的最大最小长度,每次以最小长度为单位进行查找。比如说我测试用例的关键词是成语,最短都是4个字,那么我每次匹配都是4个字一起匹配,如果命中就继续深入查找到最大长度。也就是说我们最开始构造树的时候首先是以最小长度构建的,然后再逐字增加。

youhua01

 

简单计算一下,按照我们的测试用例,300个成语,我们匹配一个词只需一次对比,而单字查询的话我们需要对比4次,而每次对比我们都要访问我们的树结构,这就是可避免的性能消耗。更重要的是,这里的对比并不是字符串对比,这里我们的关键字都是作为key存在的,效果就是 和key in obj一样的,都是对key进行哈希变换然后访问相应的地址!所以千万不要纠结对比一个字和对比4个字的差异,我们没对比字符串!

 

关于多关键词的匹配就说到这里了,优化版代码我就不贴了,因为一般也用不到。码字不易,随手点赞哈!

更多关于前端开发的信息,请关注WeX5微信公众号!

894160860174831184

原创文章,转载请注明出处!本文链接:http://www.wex5.com/openway_wordsmatch/