昨天看到一篇介绍TrieTree树的文章,感觉还不错的。于是小小研究了一下,并用C#实现了相关代码。
TrieTree树提供对字符的存储和遍历,可以用来查询词汇出现的频率或实现其他功能,速度还挺快。最特色的是,如果有相同前缀词的字符串,则它们的前缀部分可以被共享存储。
TrieTree类是一个单例类,对外提供对字符串的增/删,词语频次的查询,获取根节点下的所有词汇等等。客户端调用TreeTree单例类即可。
TrieTree类代码如下:
//TrieTree:单例类 //1.提供对字符串的增/删; //2.词语频次的查询; //3.节点下所有词汇的获取; public sealed class TrieTree { private static TrieTree _instance; private static object _locker=new object(); private readonly ITrieTreeNode _root; private TrieTree() { _root=new TrieTreeNode(); } public static TrieTree Instence { get { if (_instance == null) { lock (_locker) { if(_instance==null) _instance=new TrieTree(); } } return _instance; } } //添加 字符串 public void AddWord(string word) { _root.AddNode(word); } //移除 字符串 public void RemoveWord(string word) { _root.Remove(word); } //获取该词语被添加的频次 public int GetFrequency(string word) { return _root.GetFrequency(word); } //获取前缀词语被添加的频次 public int GetFrequencyByPrefix(string word) { return _root.GetFrequencyByPrefix(word); } //获取根节点下的所有词汇 public List<string> GetWords() { return _root.GetWords(); } }
ITrieTreeNode提供TrieTreeNode节点的对外方法,代码如下所示:
public interface ITrieTreeNode { //添加词语 void AddNode(string word); //移除词语 void Remove(string word); //获取该词语被添加的频次 int GetFrequency(string word); //获取前缀词语被添加的频次 int GetFrequencyByPrefix(string prefixWord); //从该节点开始,获取该节点下的所有词汇 List<string> GetWords(); //从最后的一个节点往上搜索,组装每一个节点的字符,并返回一个字符串 string GetWord(TrieTreeNode lastNode); }
真正的操作还在TrieTreeNode类中,这是一个节点,提供存储字符和其他数据的功能。代码如下所示:
public class TrieTreeNode: ITrieTreeNode { //字符 public char NodeChar { get; private set; } //作为前缀的使用频次 private int _frequencyByPrefix = 0; public int FrequencyByPrefix { get => _frequencyByPrefix; private set => _frequencyByPrefix = value; } //作为词语的使用频次 private int _frequency = 0; public int Frequency { get => _frequency; private set => _frequency = value; } //子节点 public HashSet<TrieTreeNode> Children { get; private set; } //父节点 public TrieTreeNode Parent { get; private set; } //判定 当前节点 是否为 词语的 最后一个字符节点。 //对删除节点有用,在删除节点的时候,需要判断是否存在该语句,即找到的最后一个字符是否为终结点字符,如果不是,则不能删除,因为不存在该词语 private bool _isTerminate = false; //实例化一个节点 public TrieTreeNode() { Frequency = 0; Children = new HashSet<TrieTreeNode>(); } //新增一个 字符 private TrieTreeNode AddNode(char nodeChar) { //找到子节点 var node = FindNode(nodeChar); if (node == null) { node=new TrieTreeNode {NodeChar = nodeChar,Parent = this}; Children.Add(node); } //节点的前缀频次自增1 node.AddFrequencyByPrefix(); return node; } //新增 字符串 public void AddNode(string word) { if (string.IsNullOrEmpty(word)) return; //获取首字符 var firstChar = word[0]; //将首字符新增到节点中 var node = AddNode(firstChar); //循环添加 节点链 for (int i = 1; i < word.Length; i++) { var child = node.AddNode(word[i]); node = child; } //最后一个节点设置为 词语的终结点 node._isTerminate = true; //节点的全词频次自增1 node.AddFrequency(); } //移除 字符串 public void Remove(string word) { RemoveWord(this, word); } private bool RemoveWord(TrieTreeNode parent,string word) { //如果字符串为空,则返回 if (string.IsNullOrEmpty(word)) return false; //寻找子节点 var node = parent.FindNode(word[0]); if (node == null) return false; //往下搜索 var canRemove = RemoveWord(node, word.Substring(1)); //如果找到最后的一个子节点不是终结点,则不用删除 if ((node._isTerminate && word.Length==1) || canRemove) { if(node._isTerminate && word.Length == 1) node.DecFrequency(); //往下搜索完毕之后,回到该子节点 //该节点的前缀频次减1 node.DecFrequencyByPrefix(); //如果 前缀频次为0,则删除该节点 if (node._frequencyByPrefix <= 0) parent.Children.Remove(node); return true; } return false; } //找出词语添加的频次 public int GetFrequency(string word) { return GetFrequency(word, false); } //获取前缀词语被添加的频次 public int GetFrequencyByPrefix(string prefixWord) { return GetFrequency(prefixWord, true); } //找出词语添加的频次 isPrefix:是否为前缀词语 private int GetFrequency(string word, bool isPrefix) { //如果字符串为空,则返回0 if (string.IsNullOrEmpty(word)) return 0; //从首字符开始查找 var node = FindNode(word[0]); //如果不存在首字符,则返回0 if (node == null) return 0; //从第二个字符开始查找 for (var index = 1; index < word.Length; index++) { var child = node.FindNode(word[index]); //如果在查找字符的时候中断,则证明无该词语,返回0 if (child == null) return 0; //遍历下一个子节点 node = child; } //如果找到最后的一个子节点 是终结点,则返回最后一个节点的全词频次 if (node._isTerminate) return node?.Frequency ?? 0; ////如果找到最后的一个子节点 不是终结点,则返回最后一个节点的前缀词频次 if (isPrefix) return node?.FrequencyByPrefix ?? 0; //没找到,则返回0 return 0; } //从该节点开始,获取该节点下的所有词汇 public List<string> GetWords() { return GetWords(this); } private List<string> GetWords(TrieTreeNode node) { List<string> words=new List<string>(); if (node == null) return words; //如果当前节点为词语的终结点,则获取该词语 if(node._isTerminate) words.Add(GetWord(node)); //遍历每一个子节点 foreach (var trieTreeNode in node.Children) { words.AddRange(GetWords(trieTreeNode)); } return words; } //从最后的一个节点往上搜索,组装每一个节点的字符,并返回一个字符串 public string GetWord(TrieTreeNode lastNode) { //如果当前节点不存在,或者为最顶层的根节点,则返回空字符串 if (lastNode == null || lastNode._frequencyByPrefix<=0) return string.Empty; return $"{GetWord(lastNode.Parent)}{lastNode.NodeChar}"; } //查找 字符 的节点 private TrieTreeNode FindNode(char nodeChar) => Children.FirstOrDefault(it => it.NodeChar == nodeChar); //频次自增1 private void AddFrequency() { this.Frequency=Interlocked.Increment(ref _frequency); } //前缀频次自增1 private void AddFrequencyByPrefix() { this.FrequencyByPrefix = Interlocked.Increment(ref _frequencyByPrefix); } //频次自减1 private void DecFrequency() { this.Frequency = Interlocked.Decrement(ref _frequency); } //前缀频次自减1 private void DecFrequencyByPrefix() { this.FrequencyByPrefix = Interlocked.Decrement(ref _frequencyByPrefix); } public override string ToString() { return NodeChar.ToString(); } }
以下是测试代码:
Stopwatch sw = new Stopwatch(); sw.Start(); var contents = File.ReadAllText(@"D:\MyCode\sourcecode\dotnet\dotnet core\ConsoleAppCoreTest\ConsoleAppCoreTest\Files\words.txt"); var splitChars = new [] {' ',',','.',':',';','(',')','@','#','$','%','\\','/','"','<','>' }; var words = contents.Split(splitChars, StringSplitOptions.RemoveEmptyEntries); Console.WriteLine($"从文件中共获取{words.Length}条词汇"); if (words?.Length > 0) { foreach (var word in words) { TrieTree.Instence.AddWord(word); } } sw.Stop(); Console.WriteLine($"新增数据花费了{sw.Elapsed.TotalSeconds}秒"); sw.Reset(); sw.Start(); var list = TrieTree.Instence.GetWords(); sw.Stop(); Console.WriteLine($"从TrieTree中共获取{list.Count}条词汇"); Console.WriteLine($"获取数据花费了{sw.Elapsed.TotalSeconds}秒"); list.ForEach(it => { Console.Write($"{it} "); }); Console.ReadKey();
测试的结果如下图所示:
为啥从文件中获取了3447条词汇,但从TrieTree中只获取到了1111条词汇?因为我实现的TrieTree是去重了的,即相同的字符串只获取一次。
从测试结果中可以看出,数据在新增的时候花费了24.5984毫秒,读取是1111条数据花费了7毫秒。速度确实还可以。如果数据多了的话,就有点费内存,很显然的是以空间换时间的算法。