系列索引

  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. SQL Server复制入门(一)----复制简介 (转载)

    简介SQL Server中的复制(Replication)是SQL Server高可用性的核心功能之一,在我看来,复制指的并不仅仅是一项技术,而是一些列技术的集合,包括从存储转发数据到同步数据到维护数 ...

  2. python自学——文件处理(截取文件内容)

    #截断文件内容使用的函数为truncate()来截断文件中的内容# 注意当truncate()括号内没有定义对象时则会删除文件内容:当括号内为指定某个条件时文件会截取从0到指定位置的内容f=open( ...

  3. Unity Mono

    Unity的mscrolib.dll和.Net的mscrolib.dll 好奇于Unity的mscrolib.dll和.Net Framework提供的mscrolib是否一致. .Net的mscro ...

  4. Software Development Engineer, RDS Database Engines, Seattle

    DESCRIPTION About UsAmazon Aurora is an exciting new area of innovation for AWS, and the PostgreSQL- ...

  5. November 07th, 2017 Week 45th Tuesday

    Love is composed of a single soul inhabiting two bodies. 爱就是一个灵魂栖息在两个身体里. Love and family and childr ...

  6. 基于CNN网络的汉字图像字体识别及其原理

    现代办公要将纸质文档转换为电子文档的需求越来越多,目前针对这种应用场景的系统为OCR系统,也就是光学字符识别系统,例如对于古老出版物的数字化.但是目前OCR系统主要针对文字的识别上,对于出版物的版面以 ...

  7. tcp尽快尽快了

    看的撒积分卡拉斯的放假啊的顺口溜

  8. Atcoder 水题选做

    为什么是水题选做呢?因为我只会水题啊 ( 为什么是$Atcoder$呢?因为暑假学长来讲课的时候讲了三件事:不要用洛谷,不要用dev-c++,不要用单步调试.$bzoj$太难了,$Topcoder$整 ...

  9. BZOJ4766:文艺计算姬(矩阵树定理)

    Description "奋战三星期,造台计算机".小W响应号召,花了三星期造了台文艺计算姬.文艺计算姬比普通计算机有更多的艺术细胞. 普通计算机能计算一个带标号完全图的生成树个数 ...

  10. 【转】系统去掉 Android 4.4.2 的StatusBar和NavigationBar

    系统Hide Status Bar frameworks/base/core/res/res/values/dimens.xml 把  <dimen name="status_bar_ ...