初级字典树查找在 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)
:添加关键词word
findFirst(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- ...
随机推荐
- Python初学者第三天 运算符、while循环
3day Python基础语法 1.运算符:算数运算符.比较运算符.赋值运算符.逻辑运算符 A.算数运算符:a=10,b=3 + 加 a+b - 减 a-b * 乘 a*b / 除 a/b ...
- Hadoop 的序列化
1. 序列化 1.1 序列化与反序列化的概念 序列化:是指将结构化对象转化成字节流在网上传输或写到磁盘进行永久存储的过程 反序列化:是指将字节流转回结构化对象的逆过程 1.2 序列化的应用 序列化用于 ...
- SmartUpload相关类的说明
㈠ File类 这个类包装了一个上传文件的所有信息.通过它,可以得到上传文件的文件名.文件大小.扩展名.文件数据等信息. File类主要提供以下方法: 1.saveAs作用:将文件换名另存. 原型: ...
- JQUERY方法给TABLE动态增加行
比如设置table的id为tabvar trHTML = "<tr><td>...</td></tr>"$("#tab&q ...
- BZOJ1005:[HNOI2008]明明的烦恼(组合数学,Prufer)
Description 自从明明学了树的结构,就对奇怪的树产生了兴趣......给出标号为1到N的点,以及某些点最终的度数,允许在任意两点间连线,可产生多少棵度数满足要求的树? Input 第一行为N ...
- 【洛谷】【最小生成树】P1195 口袋的天空
[题目背景:] 小杉坐在教室里,透过口袋一样的窗户看口袋一样的天空. 有很多云飘在那里,看起来很漂亮,小杉想摘下那样美的几朵云,做成棉花糖. [题目描述:] 给你云朵的个数N,再给你M个关系,表示哪些 ...
- Linq EF 添加数据执行事务处理
在EF4.1的DBContext中实现事务处理(BeginTransaction)和直接执行SQL语句的示例 2012-12-12 10:39 5538人阅读 ...
- Java应用中的编码问题(转载)
第三篇:JAVA字符编码系列三:Java应用中的编码问题这部分采用重用机制,引用一篇文章来完整本部分目标.来源: Eceel东西在线 问题研究--字符集编码 地址:http://china.ecee ...
- CentOS7创建本地YUM源的三种方法
这篇文章主要介绍了CentOS7创建本地YUM源的三种方法,本文讲解了使用CentOS光盘作为本地yum源.如何为CentOS创建公共镜像.创建完全自定义的本地源等内容,需要的朋友可以参考下 ...
- CentOS下iptables详解
一:前言 防火墙,其实说白了讲,就是用于实现Linux下访问控制的功能的,它分为硬件的或者软件的防火墙两种.无论是在哪个网络中,防火墙工作的地方一定是在网络的边缘.而我们的任务就是需要去定义到底防 ...