系列索引

  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. Linux下查看端口,强制kill进程

    1.查看8088端口被哪个进程占用:netstat -apn | grep 8088 2.强制kill某一进程:kill -s 9 1827

  2. 跨过Django的坑

    在最近的Django的学习中,慢慢的开始踩坑,开此栏,专为收纳Django的坑,在以后的学习中以便警示.(使用工具为pycharm专业版2018.2.4,python3.5.2,Django版本2.1 ...

  3. <button>与<input type="button">

    在做form表单,点击按钮随机生成两串密钥的时候 1.用第一种按钮的时候,会出现刷新form表单的现象.会把创建密钥前面的输入框中的字消失.虽然能生成密钥1和密钥2,但是会闪一下,随即消失.几个输入框 ...

  4. Linux每日小技巧---ss命令

    ss命令 ss是Socket Statistics的缩写.顾名思义,ss命令可以用来获取socket统计信息,它可以显示和netstat类似的内容.但ss的优势在于它能够显示更多更详细的有关TCP和连 ...

  5. MySQL主从复制异步原理以及搭建

    MySQL主从复制的原理: 1.首先,MySQL主库在事务提交时会把数据变更作为时间events记录在二进制日志文件binlog中:MySQL主库上的sync_binlog参数控制Binlog日志以什 ...

  6. 乘风破浪:LeetCode真题_028_Implement strStr()

    乘风破浪:LeetCode真题_028_Implement strStr() 一.前言     这次是字符串匹配问题,找到最开始匹配的位置,并返回. 二.Implement strStr() 2.1 ...

  7. [LOJ 2146][BZOJ 4873][Shoi2017]寿司餐厅

    [LOJ 2146][BZOJ 4873][Shoi2017]寿司餐厅 题意 比较复杂放LOJ题面好了qaq... Kiana 最近喜欢到一家非常美味的寿司餐厅用餐. 每天晚上,这家餐厅都会按顺序提供 ...

  8. SDN2017 第五次实验作业

    实验目的 1.搭建如下拓扑并连接控制器 2.下发相关流表和组表实现负载均衡 3.抓包分析验证负载均衡 实验步骤 建立以下拓扑,并连接上ODL控制器. 利用ODL下发组表.流表,实现建议负载均衡 s1组 ...

  9. 第二次SDN上机作业

    SDN第二次作业 1.安装floodlight fatter树在floodlight上的连接显示 2.生成拓扑并连接控制器floodlight,利用控制器floodlight查看图形拓扑 floodl ...

  10. IOS和安卓不同浏览器常见bug

    一.IOS自带safari浏览器 1.safari不支持fixed.input输入框 iOS下的 Fixed + Input 调用键盘的时候fixed无效问题 拖动页面时 header 和 foote ...