初级字典树查找在 Emoji、关键字检索上的运用 Part-2
系列索引
在有了 Unicode 和 Emoji 的知识准备后,本文进入编码环节。
我们知道 Emoji 是 Unicode 字符序列后,自然能够理解 Emoji 查找和敏感词查找完全是一回事:索引Emoji列表或者关键词、将用户输入分词、遍历筛选。
本文不讨论适用于 Lucene、Elastic Search 的分词技术。
这没问题,我的第1版本 Emoji 查找就是这么干的,它有两个问题
- 传统分词是基于对长句的二重遍历;
- 对比子句需要大量的
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):添加关键词wordfindFirst(sentence):在语句sentence中检索第1个匹配项findAll(sentence):在语句sentence中检查所有匹配项
使用示例
索引关键字 Hello 和 Hey,在语句 '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 的CharTriePhraseContainer 的 Apply() 实现和 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的更多相关文章
- 初级字典树查找在 Emoji、关键字检索上的运用 Part-3
系列索引 Unicode 与 Emoji 字典树 TrieTree 与性能测试 生产实践 生产实践 我们最终要解决 Emoji 在浏览器和打印物上的显示一致. 进行了多番对比,,在显示效果和精度上,m ...
- 初级字典树查找在 Emoji、关键字检索上的运用 Part-1
系列索引 Unicode 与 Emoji 字典树 TrieTree 与性能测试 生产实践 前言 通常用户自行修改资料是很常见的需求,我们规定昵称长度在2到10之间.假设用户试图使用表情符号
- 字典树(查找树) leetcode 208. Implement Trie (Prefix Tree) 、211. Add and Search Word - Data structure design
字典树(查找树) 26个分支作用:检测字符串是否在这个字典里面插入.查找 字典树与哈希表的对比:时间复杂度:以字符来看:O(N).O(N) 以字符串来看:O(1).O(1)空间复杂度:字典树远远小于哈 ...
- 算法导论:Trie字典树
1. 概述 Trie树,又称字典树,单词查找树或者前缀树,是一种用于快速检索的多叉树结构,如英文字母的字典树是一个26叉树,数字的字典树是一个10叉树. Trie一词来自retrieve,发音为/tr ...
- poj 2503 Babelfish(Map、Hash、字典树)
题目链接:http://poj.org/bbs?problem_id=2503 思路分析: 题目数据数据量为10^5, 为查找问题,使用Hash或Map等查找树可以解决,也可以使用字典树查找. 代码( ...
- HDU 4825 Xor Sum (模板题)【01字典树】
<题目链接> 题目大意: 给定n个数,进行m次查找,每次查找输出n个数中与给定数异或结果最大的数. 解题分析: 01字典树模板题,01字典树在求解异或问题上十分高效.利用给定数据的二进制数 ...
- 3道入门字典树例题,以及模板【HDU1251/HDU1305/HDU1671】
HDU1251:http://acm.hdu.edu.cn/showproblem.php?pid=1251 题目大意:求得以该字符串为前缀的数目,注意输入格式就行了. #include<std ...
- 题解0014:信奥一本通1472——The XOR Largest Pair(字典树)
题目链接:http://ybt.ssoier.cn:8088/problem_show.php?pid=1472 题目描述:在给定的 N 个整数中选出两个进行异或运算,求得到的结果最大是多少. 看到这 ...
- 817E. Choosing The Commander trie字典树
LINK 题意:现有3种操作 加入一个值,删除一个值,询问pi^x<k的个数 思路:很像以前lightoj上写过的01异或的字典树,用字典树维护数求异或值即可 /** @Date : 2017- ...
随机推荐
- SQL Server 跨网段(跨机房)通过备份文件初始化复制
笔者最近碰到了需要搭建跨网段的SQL Server复制,实际的拓扑结构如下草图所示: 发布端A服务器位于CDC机房中 订阅端B服务器位于阿里云 因为SQL Server复制不支持通过IP连接分发服务器 ...
- python容错
#try: except: else: #为什么叫容错呢,先说说错误,这里说的错误并不是因为马虎或者什么原因在脚本中留下的bug,这个不能容掉,所谓容掉就是略过这个错误,要在测试时候发现并修正,需要容 ...
- Spine Skeleton Animation(2D骨骼动画)
骨骼动画 首先我们来看到底什么是骨骼动画: 在早期的机器上,渲染本身已经占用了很多CPU资源,因此,对于渲染,往往采取的是一种空间换时间的策略,以避免在模型的渲染中继续加重CPU的负担.帧动画模型在这 ...
- cron定时任务介绍
什么是cron? Cron是linux系统中用来定期执行或指定程序任务的一种服务或软件.与它相关的有两个工具:crond 和 crontab.crond 就是 cron 在系统内的宿主程序,cront ...
- 李嘉诚 《Are you ready》
当你们梦想着为伟大成功的时候,你有没有刻苦的准备? 当你们有野心作领袖的时候,你有没有服务于人的谦恭? 我们常常都想有所获得,但我们有没有付出的情操? 我们都希望别人听到自己的话,我们有没有耐性聆听别 ...
- Docker容器学习与分享06
Docker容器网络 Docker有三种原生网络:none网络.host网络.bridge网络. 先来学习一下bridge网络. 首先使用ifconfig命令查看一下本机的网络设备: 从图中可以看见多 ...
- Python代码小片段
1.前面变量值的改变不影响后面变量的调用 index=1 index,a=2,index+1 print(a,index) #2 2 2.类的继承(子类实例如何调用父类同名方法) class a: d ...
- BBS论坛博客系统
目录 BBS网站需求分析 BBS数据库设计 BBS用户登录 BBS用户注册 BBS网站首页 BBS个人首页 后台管理系统搭建 网站全部源码
- Python csv.md
csv csv模块可以用于处理从电子表格和数据库导出的数据到带有字段和记录格式的文本文件,通常称为逗号分隔值(csv)格式,因为逗号通常用于分隔记录中的字段. Reading csv.reader(c ...
- Docker技术入门与实战 第二版-学习笔记-7-数据管理(volume)
Docker 数据管理 为什么要进行数据管理呢?因为当我们在使用container时,可能会在里面创建一些数据或文件,但是当我们停掉或删除这个容器时,这些数据或文件也会同样被删除,这是我们并不想看见的 ...