系列索引

  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) ,不影响阅读。

const count_symbol = Symbol('count');
const end_symbol = Symbol('end'); class TrieFilter {
constructor() {
this.root = {[count_symbol]: 0};
} apply(word) {
let node = this.root;
let depth = 0;
for (let ch of word) {
let child = node[ch];
if (child) {
child[count_symbol] += 1;
}
else {
node[ch] = child = {[count_symbol]: 1};
}
node = child;
}
node[end_symbol] = true;
} findFirst(sentence) {
let node = this.root;
let sequence = [];
for (let ch of sentence) {
let child = node[ch];
if (!child) {
break;
} sequence.push(ch);
node = child;
} if (node[end_symbol]) {
return sequence.join('');
}
} findAll(sentence) {
let offset = 0;
let segments = []; while (offset < sentence.length) {
let child = this.root[sentence[offset]]; if (!child) {
offset += 1;
continue;
} if (child[end_symbol]) {
segments.push({
offset: offset,
count : 1,
});
} let count = 1;
let proceeded = 1; while (child && offset + count < sentence.length) {
child = child[sentence[offset + count]];
if (!child) {
break;
} count += 1;
if (child[end_symbol]) {
proceeded = count;
segments.push({
offset: offset,
count : count,
});
}
}
offset += proceeded;
} return segments;
}
} 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'中进行检索

const assert     = require('assert');
const base64 = require('../src/base64');
const TrieFilter = require('../src/TrieFilter'); describe('TrieFilter', function () {
it('feature', function () {
let trie = new TrieFilter();
let words = ['Hello', 'Hey', 'He'];
words.forEach(x => trie.apply(x)); let findFirst = trie.findFirst('Hello world');
console.log('findFirst: %s', findFirst); let sentence = 'Hey guys, we know "Hello World" is the beginning of all programming languages';
let findAll = trie.findAll(sentence); console.log('findAll:\noffset\tcount\tsubString');
for (let {offset, count} of findAll) {
console.log('%s\t%s\t%s', offset, count, sentence.substr(offset, count));
}
});
})

输出结果

$ mocha .
findFirst: Hello
findAll:
offset count subString
0 2 He
0 3 Hey
19 2 He
19 5 Hello

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

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

C# 实现

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

    class CharTrieNode {
private Dictionary<Char, CharTrieNode> _children; public Char Key { get; private set; } internal Boolean IsTail { get; set; } public CharTrieNode this[Char key] {
get {
if (_children == null) {
return null;
}
CharTrieNode child;
if (!_children.TryGetValue(key, out child)) {
return null;
}
return child;
}
set {
_children[key] = value;
}
} public Int32 Count {
get {
if (_children == null) {
return 0;
}
return _children.Count;
}
} public CharTrieNode(Char key) {
Key = key;
} public CharTrieNode Apppend(Char key) {
CharTrieNode child;
if (_children == null) {
_children = new Dictionary<Char, CharTrieNode>();
child = new CharTrieNode(key);
_children[key] = child;
return child;
} if (!_children.TryGetValue(key, out child)) {
child = new CharTrieNode(key);
_children[key] = child;
}
return child;
} public Boolean TryGetValue(Char key, out CharTrieNode child) {
child = null;
if (_children == null) {
return false;
}
return _children.TryGetValue(key, out child);
}
} public interface IPhraseContainer {
void Apply(String phrase);
Boolean Contains(String phrase);
Boolean Contains(String phrase, Int32 offset, Int32 length);
}

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

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

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

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

public IEnumerable<ArraySegment<Char>> SearchAll(String phrase) {
var container = _container as CharTriePhraseContainer;
if (container != null) {
return container.SearchAll(phrase);
}
return ClassicSearchAll(phrase);
} public IEnumerable<ArraySegment<Char>> ClassicSearchAll(String phrase) {
if (phrase == null) {
throw new ArgumentNullException(nameof(phrase));
} var chars = phrase.ToCharArray();
var offset = 0; while (offset < phrase.Length) {
//设置子句长度和将来要使用的 offset 推进值
var count = 1;
var proceeded = 1; //判断 offset 后续位置的字母是否在关键字中
while (offset + count <= phrase.Length) {
//快速断言
if (_assertors.Count == 0 || _assertors.All(x => x.Contains(phrase, offset, count))) {
//判断子句是否存在,_container 可能基于 HashSet 等
if (_container.Contains(phrase, offset, count)) {
//记录 offset 推进值
proceeded = count;
yield return new ArraySegment<Char>(chars, offset, count);
}
}
count += 1;
} //推进 offset 位置
offset += proceeded;
}
}

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

public IEnumerable<ArraySegment<Char>> SearchAll(String phrase) {
if (phrase == null) {
throw new ArgumentNullException(nameof(phrase));
} var chars = phrase.ToCharArray();
var offset = 0; while (offset < phrase.Length) {
var current = _root[phrase[offset]];
if (current == null) {
//推进 offset 位置
offset += 1;
continue;
} //如果是结尾,即单字符命中关键字
if (current.IsTail) {
yield return new ArraySegment<Char>(chars, offset, 1);
} //设置子句长度和将来要使用的 offset 推进值
var count = 1;
var proceeded = 1; //判断 offset 后续位置的字母是否在关键字中
while (current != null && offset + count < phrase.Length) {
current = current[phrase[offset + count]];
if (current == null) {
break;
} count += 1;
if (current.IsTail) {
//设置已经推进的 offset 大小
proceeded = count;
yield return new ArraySegment<Char>(chars, offset, proceeded);
}
} //推进 offset 位置
offset += proceeded;
}
}

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

使用示例

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

PM > Install-Package Chuye.TrieFilter

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

var filter = new PhraseFilter();
var filename = Path.Combine(Directory.GetCurrentDirectory(),"chuye-emoji.txt");
filter.ApplyFile(filename); var clause = @"颠簸了三小时飞机✈️➕两小时公交地铁

初级字典树查找在 Emoji、关键字检索上的运用 Part-2的更多相关文章

  1. 初级字典树查找在 Emoji、关键字检索上的运用 Part-3

    系列索引 Unicode 与 Emoji 字典树 TrieTree 与性能测试 生产实践 生产实践 我们最终要解决 Emoji 在浏览器和打印物上的显示一致. 进行了多番对比,,在显示效果和精度上,m ...

  2. 初级字典树查找在 Emoji、关键字检索上的运用 Part-1

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

  3. 字典树(查找树) leetcode 208. Implement Trie (Prefix Tree) 、211. Add and Search Word - Data structure design

    字典树(查找树) 26个分支作用:检测字符串是否在这个字典里面插入.查找 字典树与哈希表的对比:时间复杂度:以字符来看:O(N).O(N) 以字符串来看:O(1).O(1)空间复杂度:字典树远远小于哈 ...

  4. 算法导论:Trie字典树

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

  5. poj 2503 Babelfish(Map、Hash、字典树)

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

  6. HDU 4825 Xor Sum (模板题)【01字典树】

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

  7. 3道入门字典树例题,以及模板【HDU1251/HDU1305/HDU1671】

    HDU1251:http://acm.hdu.edu.cn/showproblem.php?pid=1251 题目大意:求得以该字符串为前缀的数目,注意输入格式就行了. #include<std ...

  8. 题解0014:信奥一本通1472——The XOR Largest Pair(字典树)

    题目链接:http://ybt.ssoier.cn:8088/problem_show.php?pid=1472 题目描述:在给定的 N 个整数中选出两个进行异或运算,求得到的结果最大是多少. 看到这 ...

  9. 817E. Choosing The Commander trie字典树

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

随机推荐

  1. JS获取元素宽高的两种情况

    JS获取元素宽高分两种情况, 一.内联样式,也就是直接把width和height写在HTML元素中的style里: 这种情况使用     document.getElementById('xxx'). ...

  2. 一个能够编写、运行SQL查询并可视化结果的Web应用:SqlPad

    SqlPad 是一个能够用于编写.运行 SQL 查询并可视化结果的 Web 应用.支持 PostgreSQL.MySQL 和 SQL Server.SqlPad 目前仅适合单个团队在内网中使用,它直接 ...

  3. Android中两个Activity之间简单通信

    在Android中,一个界面被称为一个activity,在两个界面之间通信,采用的是使用一个中间传话者(即Intent类)的模式,而不是直接通信. 下面演示如何实现两个activity之间的通信. 信 ...

  4. C++设计模式 ==> 装饰(者)模式

    简介 装饰模式指的是在不必改变原类文件和使用继承的情况下,动态地扩展一个对象的功能.它是通过创建一个包装对象,也就是装饰来包裹真实的对象.装饰模式使用对象嵌套的思想,实现对一个对象动态地进行选择性的属 ...

  5. php linux 环境搭建

    Apache源于NCSAhttpd服务器,经过多次修改,成为世界上最流行的Web服务器软件之一.Apache取自“a patchy server”的读音,意思是充满补丁的服务器,因为它是自由软件,所以 ...

  6. PyQt5--StatusBar

    # -*- coding:utf-8 -*- ''' Created on Sep 13, 2018 @author: SaShuangYiBing ''' import sys from PyQt5 ...

  7. Python datetime.md

    datetime datetime模块包含了一些用于时间解析.格式化.计算的函数. Times 时间值由time类来表示, Times有小时, 分, 秒和微秒属性. 以及包含时区信息. 初始化time ...

  8. Concurrent包学习之 BlockingQueue源码学习

    上一篇学习了ExecutorService和其它相关类的源码,本篇要学习的BlockingQueue中的源码,as always,先上类图 其实继承(实现)的层次比较简单,我们只要需要先学习一下Blo ...

  9. BZOJ4556:[TJOI\HEOI2016]字符串(后缀数组,主席树,二分,ST表)

    Description 佳媛姐姐过生日的时候,她的小伙伴从某东上买了一个生日礼物.生日礼物放在一个神奇的箱子中.箱子外边写了一个长为n的字符串s,和m个问题.佳媛姐姐必须正确回答这m个问题,才能打开箱 ...

  10. 友盟推送SDK集成测试、常见问题以及注意事项总结

    最近为了解决公司APP在一些手机出现的推送问题重新集成了最新版的友盟推送SDK,花费了几天时间终于把集成和测试工作完成,最终在华为,Nexus,三星,小米,HTC,魅族等10多部手机上测试并达到了预想 ...