系列索引

  1. Unicode 与 Emoji
  2. 字典树 TrieTree 与性能测试
  3. 生产实践

在有了 Unicode 和 Emoji 的知识准备后,本文进入编码环节。

我们知道 Emoji 是 Unicode 字符序列后,自然能够理解 Emoji 查找和敏感词查找完全是一回事:索引Emoji列表或者关键词、将用户输入分词、遍历筛选。

本文不讨论适用于 Lucene、Elastic Search 的分词技术。

这没问题,我的第1版本 Emoji 查找就是这么干的,它有两个问题

  1. 传统分词是基于对长句的二重遍历;
  2. 对比子句需要大量的 SubString() 操作,这会带来巨大的 GC 压力;

二重遍历可以优化,用内层遍历推进外层遍历位置,但提取子句无可避免,将在后文提及。

字典树 Trie-Tree

字典树 Trie-Tree 算法本身简单和易于理解,各编程语言可以用100行左右完成基本实现。

这里也有一个非常优化的实现,主页可以看到作者的博客园地址以及优化经历。

更深入的阅读请移步到

本文不仅要检测Emoji/关键字,还期望进行定位、替换等更多操作,故从头开始。

JavaScript 版本实现

考虑到静态语言的冗余,以下使用更有表现力的 JavaScript 版本剔除无关部分作为源码示例,完整代码见于 github.com/jusfr/Chuye.Character

以下实现使用到了 ECMAScript 6 中的 Symbol语法,见于 [Symbol@MDN Web 文档](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Symbol) ,不影响阅读。

  1. const count_symbol = Symbol('count');
  2. const end_symbol = Symbol('end');
  3. class TrieFilter {
  4. constructor() {
  5. this.root = {[count_symbol]: 0};
  6. }
  7. apply(word) {
  8. let node = this.root;
  9. let depth = 0;
  10. for (let ch of word) {
  11. let child = node[ch];
  12. if (child) {
  13. child[count_symbol] += 1;
  14. }
  15. else {
  16. node[ch] = child = {[count_symbol]: 1};
  17. }
  18. node = child;
  19. }
  20. node[end_symbol] = true;
  21. }
  22. findFirst(sentence) {
  23. let node = this.root;
  24. let sequence = [];
  25. for (let ch of sentence) {
  26. let child = node[ch];
  27. if (!child) {
  28. break;
  29. }
  30. sequence.push(ch);
  31. node = child;
  32. }
  33. if (node[end_symbol]) {
  34. return sequence.join('');
  35. }
  36. }
  37. findAll(sentence) {
  38. let offset = 0;
  39. let segments = [];
  40. while (offset < sentence.length) {
  41. let child = this.root[sentence[offset]];
  42. if (!child) {
  43. offset += 1;
  44. continue;
  45. }
  46. if (child[end_symbol]) {
  47. segments.push({
  48. offset: offset,
  49. count : 1,
  50. });
  51. }
  52. let count = 1;
  53. let proceeded = 1;
  54. while (child && offset + count < sentence.length) {
  55. child = child[sentence[offset + count]];
  56. if (!child) {
  57. break;
  58. }
  59. count += 1;
  60. if (child[end_symbol]) {
  61. proceeded = count;
  62. segments.push({
  63. offset: offset,
  64. count : count,
  65. });
  66. }
  67. }
  68. offset += proceeded;
  69. }
  70. return segments;
  71. }
  72. }
  73. module.exports = TrieFilter;

包含空白行不过87行代码,只用看3个方法

  • apply(word):添加关键词word
  • findFirst(sentence):在语句sentence中检索第1个匹配项
  • findAll(sentence):在语句sentence中检查所有匹配项

使用示例

索引关键字 HelloHey,在语句 'Hey guys, we know "Hello World" is the beginning of all programming languages'中进行检索

  1. const assert = require('assert');
  2. const base64 = require('../src/base64');
  3. const TrieFilter = require('../src/TrieFilter');
  4. describe('TrieFilter', function () {
  5. it('feature', function () {
  6. let trie = new TrieFilter();
  7. let words = ['Hello', 'Hey', 'He'];
  8. words.forEach(x => trie.apply(x));
  9. let findFirst = trie.findFirst('Hello world');
  10. console.log('findFirst: %s', findFirst);
  11. let sentence = 'Hey guys, we know "Hello World" is the beginning of all programming languages';
  12. let findAll = trie.findAll(sentence);
  13. console.log('findAll:\noffset\tcount\tsubString');
  14. for (let {offset, count} of findAll) {
  15. console.log('%s\t%s\t%s', offset, count, sentence.substr(offset, count));
  16. }
  17. });
  18. })

输出结果

  1. $ mocha .
  2. findFirst: Hello
  3. findAll:
  4. offset count subString
  5. 0 2 He
  6. 0 3 Hey
  7. 19 2 He
  8. 19 5 Hello

源码使用的二重遍历是一个优化版本,我们后面提及。

当我们的 TrieFilter 实现的更完整时,比如在声明类型的节点以保存父节点的引用便能实现关键词移除等功能。而当索引词组全部是 Emoji 时,在用户输入中检索 Emoji 并不在话下。

C# 实现

C# 实现略显冗长,作者先实现了泛型节点和树 github.com/jusfr/Chuye.Character 后来发现优化困难,最终采用的是基于 Char 的简化版本。

  1. class CharTrieNode {
  2. private Dictionary<Char, CharTrieNode> _children;
  3. public Char Key { get; private set; }
  4. internal Boolean IsTail { get; set; }
  5. public CharTrieNode this[Char key] {
  6. get {
  7. if (_children == null) {
  8. return null;
  9. }
  10. CharTrieNode child;
  11. if (!_children.TryGetValue(key, out child)) {
  12. return null;
  13. }
  14. return child;
  15. }
  16. set {
  17. _children[key] = value;
  18. }
  19. }
  20. public Int32 Count {
  21. get {
  22. if (_children == null) {
  23. return 0;
  24. }
  25. return _children.Count;
  26. }
  27. }
  28. public CharTrieNode(Char key) {
  29. Key = key;
  30. }
  31. public CharTrieNode Apppend(Char key) {
  32. CharTrieNode child;
  33. if (_children == null) {
  34. _children = new Dictionary<Char, CharTrieNode>();
  35. child = new CharTrieNode(key);
  36. _children[key] = child;
  37. return child;
  38. }
  39. if (!_children.TryGetValue(key, out child)) {
  40. child = new CharTrieNode(key);
  41. _children[key] = child;
  42. }
  43. return child;
  44. }
  45. public Boolean TryGetValue(Char key, out CharTrieNode child) {
  46. child = null;
  47. if (_children == null) {
  48. return false;
  49. }
  50. return _children.TryGetValue(key, out child);
  51. }
  52. }
  53. public interface IPhraseContainer {
  54. void Apply(String phrase);
  55. Boolean Contains(String phrase);
  56. Boolean Contains(String phrase, Int32 offset, Int32 length);
  57. }

为了和基于 Hash 的实现作为对比,定义了IPhraseContainer作为数据入口,基于 TrieTree 的CharTriePhraseContainerApply() 实现和 JavaScript 版本如出一辙,而基于 Hash 的 HashPhraseContainer 内部维护和操作着一个 HashSet<String>

高层次的API则由PhraseFilter 提供,内部依赖了一个 IPhraseContainer实现。

由于测试结果已然,基于 Hash 的实现后期将移除以减少代码冗余。

PhraseFilter 内部,检索方法如下,注意ClassicSearchAll()是优化版本的二重遍历,和 JavaScript 版本并无实质区别,但从 IPhraseFilter 定义的 SearchAll() 方法将遍历操作交由了 CharTriePhraseContainer 处理,因为 Trie-Tree查找只需要一次遍历

  1. public IEnumerable<ArraySegment<Char>> SearchAll(String phrase) {
  2. var container = _container as CharTriePhraseContainer;
  3. if (container != null) {
  4. return container.SearchAll(phrase);
  5. }
  6. return ClassicSearchAll(phrase);
  7. }
  8. public IEnumerable<ArraySegment<Char>> ClassicSearchAll(String phrase) {
  9. if (phrase == null) {
  10. throw new ArgumentNullException(nameof(phrase));
  11. }
  12. var chars = phrase.ToCharArray();
  13. var offset = 0;
  14. while (offset < phrase.Length) {
  15. //设置子句长度和将来要使用的 offset 推进值
  16. var count = 1;
  17. var proceeded = 1;
  18. //判断 offset 后续位置的字母是否在关键字中
  19. while (offset + count <= phrase.Length) {
  20. //快速断言
  21. if (_assertors.Count == 0 || _assertors.All(x => x.Contains(phrase, offset, count))) {
  22. //判断子句是否存在,_container 可能基于 HashSet 等
  23. if (_container.Contains(phrase, offset, count)) {
  24. //记录 offset 推进值
  25. proceeded = count;
  26. yield return new ArraySegment<Char>(chars, offset, count);
  27. }
  28. }
  29. count += 1;
  30. }
  31. //推进 offset 位置
  32. offset += proceeded;
  33. }
  34. }

Trie-Tree查找是按输入语句匹配 CharTrieNode 的过程。

  1. public IEnumerable<ArraySegment<Char>> SearchAll(String phrase) {
  2. if (phrase == null) {
  3. throw new ArgumentNullException(nameof(phrase));
  4. }
  5. var chars = phrase.ToCharArray();
  6. var offset = 0;
  7. while (offset < phrase.Length) {
  8. var current = _root[phrase[offset]];
  9. if (current == null) {
  10. //推进 offset 位置
  11. offset += 1;
  12. continue;
  13. }
  14. //如果是结尾,即单字符命中关键字
  15. if (current.IsTail) {
  16. yield return new ArraySegment<Char>(chars, offset, 1);
  17. }
  18. //设置子句长度和将来要使用的 offset 推进值
  19. var count = 1;
  20. var proceeded = 1;
  21. //判断 offset 后续位置的字母是否在关键字中
  22. while (current != null && offset + count < phrase.Length) {
  23. current = current[phrase[offset + count]];
  24. if (current == null) {
  25. break;
  26. }
  27. count += 1;
  28. if (current.IsTail) {
  29. //设置已经推进的 offset 大小
  30. proceeded = count;
  31. yield return new ArraySegment<Char>(chars, offset, proceeded);
  32. }
  33. }
  34. //推进 offset 位置
  35. offset += proceeded;
  36. }
  37. }

由于不存在二重遍历和 SubString() 调用,性能和开销相对基于 Hash 或正则的方法有长足进步。

使用示例

项目源码已经被我打包和发布到了 nuget

PM > Install-Package Chuye.TrieFilter

对于Emoji 检索,需要准备一份Emoji 列表或者从 chuye-emoji.txt 获取。

  1. var filter = new PhraseFilter();
  2. var filename = Path.Combine(Directory.GetCurrentDirectory(),"chuye-emoji.txt");
  3. filter.ApplyFile(filename);
  4. var clause = @"颠簸了三小时飞机✈️➕两小时公交地铁
  5. 初级字典树查找在 Emoji、关键字检索上的运用 Part-2的更多相关文章

      1. 初级字典树查找在 Emoji、关键字检索上的运用 Part-3
      1. 系列索引 Unicode 与 Emoji 字典树 TrieTree 与性能测试 生产实践 生产实践 我们最终要解决 Emoji 在浏览器和打印物上的显示一致. 进行了多番对比,,在显示效果和精度上,m ...

      1. 初级字典树查找在 Emoji、关键字检索上的运用 Part-1
      1. 系列索引 Unicode 与 Emoji 字典树 TrieTree 与性能测试 生产实践 前言 通常用户自行修改资料是很常见的需求,我们规定昵称长度在2到10之间.假设用户试图使用表情符号 ‍

      1. 字典树(查找树) leetcode 208. Implement Trie (Prefix Tree) 、211. Add and Search Word - Data structure design
      1. 字典树(查找树) 26个分支作用:检测字符串是否在这个字典里面插入.查找 字典树与哈希表的对比:时间复杂度:以字符来看:O(N).O(N) 以字符串来看:O(1).O(1)空间复杂度:字典树远远小于哈 ...

      1. 算法导论:Trie字典树
      1. 1. 概述 Trie树,又称字典树,单词查找树或者前缀树,是一种用于快速检索的多叉树结构,如英文字母的字典树是一个26叉树,数字的字典树是一个10叉树. Trie一词来自retrieve,发音为/tr ...

      1. poj 2503 Babelfish(Map、Hash、字典树)
      1. 题目链接:http://poj.org/bbs?problem_id=2503 思路分析: 题目数据数据量为10^5, 为查找问题,使用Hash或Map等查找树可以解决,也可以使用字典树查找. 代码( ...

      1. HDU 4825 Xor Sum (模板题)【01字典树】
      1. <题目链接> 题目大意: 给定n个数,进行m次查找,每次查找输出n个数中与给定数异或结果最大的数. 解题分析: 01字典树模板题,01字典树在求解异或问题上十分高效.利用给定数据的二进制数 ...

      1. 3道入门字典树例题,以及模板【HDU1251/HDU1305/HDU1671】
      1. HDU1251:http://acm.hdu.edu.cn/showproblem.php?pid=1251 题目大意:求得以该字符串为前缀的数目,注意输入格式就行了. #include<std ...

      1. 题解0014:信奥一本通1472——The XOR Largest Pair(字典树)
      1. 题目链接:http://ybt.ssoier.cn:8088/problem_show.php?pid=1472 题目描述:在给定的 N 个整数中选出两个进行异或运算,求得到的结果最大是多少. 看到这 ...

      1. 817E. Choosing The Commander trie字典树
      1. LINK 题意:现有3种操作 加入一个值,删除一个值,询问pi^x<k的个数 思路:很像以前lightoj上写过的01异或的字典树,用字典树维护数求异或值即可 /** @Date : 2017- ...

    1. 随机推荐

        1. 一次失败的生产系统中AlwaysOn AG切换经历
        1. 14:25分左右,某数据库主副本服务器崩溃报错,在数据库无法接收SQL语句进行调整的情况下重启了主副本服务器. 由于服务器重启时间会比较长,为了保证主副本服务器重启期间数据库能正常进行写入,强制将主库 ...

        1. MSSQL段落还原脚本
        1. --段落还原:数据库损坏范围比较大,跨多个数据文件甚至跨文件组的时候,我们不得不恢复整个数据库.--这时如果数据库特别大,数据库恢复时间将会很长.但我们可以使用SQL Server提供的段落还原,来逐 ...

        1. xp_readerrorlog与sp_readerrorlog
        1. SQL SERVER 可以使用xp_readerrorlog 或者sp_readerrorlog来查看错误日志. xp_readerrorlog 一共有七个参数: 1. 存档编号 2. 日志类型(1为 ...

        1. MySQL应用架构优化-实时数据处理
        1. 1.1. 场景 在和开发人员做优化的时候,讨论最多的应该是结合应用场景编写出合适的SQL.并培训开发应该如何编写SQL让MySQL的性能尽量好.但是有一些的场景对于SQL的优化是行不通的. 打个比方, ...

        1. android的hwc浅析【转】
        1. https://blog.csdn.net/alien75/article/details/39290109 注:本文档基于kk进行分析,着重于概念的精确定义和版本历史演变 一.关于hwc的介绍 广义 ...

        1. [Hive_1] Hive 基本概念
        1. Hive 系列01 Hive 简介 & Hive 应用场景 & Hive 与 Hadoop 的关系 & Hive 与传统数据库对比 1. Hive 简介 [ 官方介绍 ] Ap ...

        1. DAU、UV、独立IP、PV的区别和联系
        1. 基本概念 DAU(Daily Active User)日活跃用户数量.常用于反映网站.互联网应用或网络游戏的运营情况.DAU通常统计一日(统计日)之内,登录或使用了某个产品的用户数(去除重复登录的用户 ...

        1. 死磕nginx系列--nginx服务器做web服务器
        1. nginx 做静态服务器 HTML页面如下 <!DOCTYPE html> <html lang="en"> <head> <meta c ...

        1. 服务器 一 MQTT服务器硬件
        1. 目的: 实现手机4G网络控制单片机,需要搭建服务器,手机或者各种控制端远程控制. 本教程 1  MQTT服务器硬件模块 2 MQTT服务器电脑搭建 2.1自己搭建 2.2租阿里云服务器 2 MQTT服 ...

        1. 简单的表格json控件
        1. 简单的表格json控件 由于最近做的项目一直有表格的形式展示数据,所以想写个简单的关于表格方面的控件出来,想用JSON数据直接渲染出来,因为开发给到我们前端的字段可能会叫不同的名字,所以我们前端渲染页 ...