Jusfr

目录

系列索引

在有了 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/) ,不影响阅读。

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 = @"颠簸了三小时飞机✈️➕两小时公交地铁

相关文章: