初级字典树查找在 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 = @"颠簸了三小时飞机✈️➕两小时公交地铁🚇➕四小时大巴➕一小时🚢 终于到了我们的目的地像面粉一样的沙滩和碧绿的大海 这就是我们第一次旅行的地方in沙美岛🏖";
var segments = filter.SearchAll(clause).ToArray();
var searched = new SearchResult(clause, segments);
var replaced = searched.Replace(x => new String('*', x.Length));
var comparsion = "颠簸了三小时飞机*️*两小时公交地铁***四小时大巴*一小时** 终于到了我们的目的地像面粉一样的沙滩和碧绿的大海 这就是我们第一次旅行的地方in沙美岛**";
Assert.Equal(comparsion, replaced);
chuye-emoji.txt 文件是作者从 Unicode 网站整理而来。
检索关键字/敏感词完全是一回事,请自行准备,这里不作过多讨论,以下代码中使用的 Dump()
方法可以在 LINQPad 上快捷输出。
var filter = new PhraseFilter();
filter.Apply("Hello", "Hey");
var sentence = "Hey guys, we know \"Hello World\" is the beginning of all programming languages";
var searched = filter.SearchAll(sentence).ToArray();
searched.Select(x => new { x.Offset, x.Count, Substring = sentence.Substring(x.Offset, x.Count) }).Dump("Searched");
new SearchResult(sentence, searched).Replace(x => new String('*', x.Length)).Dump("Replaced");
实现自有 IPhraseProvider
、和 Autofac
的集成示例
class EmojiPhraseProvider : IPhraseProvider {
private readonly IEmojiRepository _emojiRepository;
public EmojiPhraseProvider(IEmojiRepository emojiRepository) {
_emojiRepository = emojiRepository;
}
public IEnumerable<String> Fetch() {
var values = _emojiRepository.GetValues();
return values.Select(x => x.value);
}
}
public class EmojiFinderModule : Module {
protected override void Load(ContainerBuilder builder) {
builder.RegisterType<EmojiPhraseProvider>().As<IPhraseProvider>();
builder.RegisterType<PhraseFilter>().OnActivated(OnPhraseFilterActivated).As<IPhraseFilter>().SingleInstance();
base.Load(builder);
}
private void OnPhraseFilterActivated(IActivatedEventArgs<PhraseFilter> obj) {
var provider = obj.Context.Resolve<IPhraseProvider>();
obj.Instance.Apply(provider);
}
}
性能测试
100000 次循环
trieFilter.SearchAll
Time Elapsed : 65ms
CPU Cycles : 174,521,817
Memory cost : 1,192
Gen 0 : 7
Gen 1 : 2
Gen 2 : 2
hashFilter.SearchAll
Time Elapsed : 627ms
CPU Cycles : 1,694,437,899
Memory cost : 2,440
Gen 0 : 137
Gen 1 : 2
Gen 2 : 2
JavaScript 版本
$ node trieFilter.js
Show pretty:
depth 00 count 002: │H
depth 01 count 002: │─e
depth 02 count 001: │──l
depth 03 count 001: │───l
depth 04 count 001: └────o
depth 02 count 001: └──y
findFirst: Hello
findAll:
offset count subString
0 3 Hey
19 5 Hello
marky: loop 100000 times
[ { startTime: 5.214011,
name: 'findAll',
duration: 180.187891,
优化手段
经典查找的性能瓶颈来源于基于二重遍历的分词,而大量的子句切分带来垃圾回收的压力.
优化I "分词"线性化
基本思想是在第一重遍历时,第二重遍历查找成功就使用当前子句末端位置作为下一次的遍历起点
该方法只因计算量减少同等比例地减少了
SubString()
的调用,但子符串切分不可避免
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) {
//设置子句长度和将来要使用的 offset 推进值
var count = 1;
var proceeded = 1;
//判断 offset 后续位置的字母是否在关键字中
while (offset + count <= phrase.Length) {
//这里可以加入快速断言,推迟子句切分
var clause = phrase.Substring(offset, count);
//判断子句是否存在,_container 可能基于 HashSet 等
//是否调用 phrase.Substring(offset, count) 视具体实现
if (_container.Contains(phrase, offset, count)) {
//记录 offset 推进值
proceeded = count;
yield return new ArraySegment<Char>(chars, offset, count);
}
count += 1;
}
//推进 offset 位置
offset += proceeded;
}
}
优化II 快速判断"分词"后的子句
- 基于子句长度的优化
使用一个整数通过位运算存储所有关键字长度组合,例如关键字Hi + Hello
的初始化中,长度组合计算过程
- 初始值
0
- 位运算
0 | 1 << len('Hi')
,长度组合为 4 - 位运算
4 | 1 << len('Hello')
,长度组合为 36
查找示例
- 查找 'Hey'
36 & (1 << len('Hey')) = 0
,查找结束
- 查找 'Hell'
36 & (1 << len('Hell')) = 0
,查找结束
- 查找 'Hello'
36 & (1 << len('Hello')) = 32
,进行后续查找
- 基于子句字符位置的优化
使用长度为 Char.MaxValue 的整型数组存储每一个字符在各个关键字上的位置所有组合,例如关键字Hi+Hello
的初始化中,该数组计算过程
- 添加 'Hi',
array['H'] = 1 << 0 = 1
array['i'] = 1 << 1 = 2
- 添加 'Hello'
array['H'] |= 1 << 0 = 1
array['e'] |= 1 << 1 = 2
array['l'] |= 1 << 2 = 4
array['l'] |= 1 << 3 = 4 | 8 = 12
array['o'] |= 1 << 4 = 16
最终字符组合为
'H': 1
'i': 2
'e': 2
'l': 12
'o': 16
忽略前置的长度检查,查找示例
- 查找 'Hey'
- 对比 'H',
array['H'] = 1
,表示 'H'在索引0上,对比通过 - 对比 'e',
array['e'] = 2
,表示 'e' 在索引1上, 对比通过 - 对比 'y',
array['y'] = 0
,表示 'y' 没有出现过,对比未通过,查找失败
- 对比 'H',
如果查找的是 'Helll',由于第5位是 'l',而array['l'] = 12
, 表示 'l' 是索引2或者3上,对比将不会通过
多次的性能对比,发现子句的快速判断性能非常不稳定,有时候会有拖累效果,可能与测试样本有关,没有更进一步的测试.
由于 Trie-Tree 查找步步逼近的过程,长度优化只能退化成"不大于最大长度的"判断.
性能对比
# 传统二重遍历
hashFilter.SearchAll
Time Elapsed : 3,613ms
CPU Cycles : 9,758,423,828
Memory cost : 19,176
Gen 0 : 880
Gen 1 : 2
Gen 2 : 2
# 优化遍历方法
hashFilter.SearchAll
Time Elapsed : 1,310ms
CPU Cycles : 3,538,391,198
Memory cost : 14,696
Gen 0 : 440
Gen 1 : 2
Gen 2 : 2
# trie 查找
trieFilter.SearchAll
Time Elapsed : 63ms
CPU Cycles : 171,441,680
Memory cost : 1,192
Gen 0 : 7
Gen 1 : 2
Gen 2 : 2