1. 前文

本文和大家一起聊聊字典树,从字典二字可知,于功能而言,字典树是类似于英汉字典的一棵信息树。字典树有 2 大特点:

  • 有容乃大。能存储大量的数据信息。
  • 提供有基于关键字的查询、检索机制。

常用字典树存储字符串(单词)信息,使用字典树能方便实现字符串的存储、查询、统计、排序……一系列操作。

2. 字典树特点

字典树是树结构的典型应用,如下图所示,为一棵字典树。字典树的叶节点起标志性作用,标记字符串的结束,类似于C++字符串的结束符号\0

C++ 树进阶系列之深度剖析字典(trie)树_子节点

通过对结构的观察,可以大致了解字典树的几个特点:

  • 字典树不是二叉树,字典树的每一个节点可以有多个子节点,除根节点外每个节点存储一个字符信息(常用于字符信息存储,但不仅限于字符信息)。
  • 顺着根节点向子节点连接,可以找到一个字符串信息。正因为这个特性,字典树如其名,可存储大量的单词。如从根节点开始,找到它的第一个子节点a,然后找到a的子节点c,再顺着 c找到e。这样就能得到子符串ace
  • 具有公共前缀的字符串不需要重复存储,公用共同的祖先,如aceact的公共祖先节点是ac。这也是字典树的一大特点,相比较其它的存储方案,具有高度的空间利用率。

3. 字典树的物理存储

字典树常用的API插入查询,其它可根据应用场景的需要进行扩展。

字典树的物理实现可以使用矩阵链式存储 2 种方案,本文分别探讨这 2 种方案的实现过程。

3.1 链式存储

为了简化操作,实现过程中使用了STLvector容器存储任一节点的子节点,当然,也可以使用带有头节点的链表。

3.1.1 结构类型

节点类型: 存储字典树的节点信息。

#include <iostream>
#include <vector>
using namespace std;
/*
*字典树节点
*/
struct TrieNode {
	//节点对应字符
	char data;
	//所有子节点指针
	vector<TrieNode*> childs;
    TrieNode(){
		this->data='#';
	} 
};
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.

字典类: 维护字典的常用API,此处先提供基本的API,后面根据需要再扩展。

/*
*字典树类
*/
class Trie {
	private:
		//根节点
		TrieNode* root;
		//字典树上所有字符串(单词)
		vector<string> words;
	public:
		/*
		*构建函数
		*/
		Trie() {
			//初始化节结点
			this->root=new TrieNode();
		}
		/*
		*返回节结点
		*/
		TrieNode* getRoot() {
			return this->root;
		}
		/*
		* 查询节点是否包括值为 ch 的子节点
		*/
		TrieNode* findChild(TrieNode* parent,char ch);
		/*
		*插入新的单词(字符串)
		*/
		void insert(string word);
		/*
		*查询字典树是否存在指定的字符串(单词)
		*/
		TrieNode* search(string word);
		/*
		* 查询字典树上所有字符串(单词)
		*/
		void getAllWords(TrieNode *node,string word);
		/*
		*显示字典树上的所有单词
		*/
		void showAllWords()
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.
  • 30.
  • 31.
  • 32.
  • 33.
  • 34.
  • 35.
  • 36.
  • 37.
  • 38.
  • 39.
  • 40.
  • 41.
  • 42.
  • 43.
  • 44.
3.1.2 常规 API
3.1.2.1 insert函数

功能描述: 提供添加字符串(单词)的功能,是构建字典树的第一重要环节。

**实现流程:**现以添加abc字符串为例,讲解添加函数的实现过程。

  • 首先构建根节点。根节点不需要存储具体的数据信息。

C++ 树进阶系列之深度剖析字典(trie)树_字典树_02

  • 分解字符串abc,先读入字符a,以根节点为当前节点,查询当前节点是否存在值为a的子节点。

C++ 树进阶系列之深度剖析字典(trie)树_字符串_03

  • 因字典树刚创建,此子节点不存在,则为节结点新建值为a的子节点,并且当前节点指针指向新建的节点。

C++ 树进阶系列之深度剖析字典(trie)树_子节点_04

  • 继续从字符串中分割出字符b,检查当前节点是否存在值为b的子节点。没有则为当前节点创建值为b的新节点,重设当前节点的指针为新建节点。
    C++ 树进阶系列之深度剖析字典(trie)树_字典树_05

  • 同理,分割出c,因当前节点不存在值为c的子节点,为当前节点构建新节点。且重设当前节点的指针。

C++ 树进阶系列之深度剖析字典(trie)树_字符串_06

  • 当字符串分割完毕后,最后添加值为#的叶节点作为结束标志符。

C++ 树进阶系列之深度剖析字典(trie)树_子节点_07

  • 在现有字典树上继续添加abd字符串(单词)时,当前节点需要重置为根结点。分割abd字符串时,因为值为ab的节点已经存在,则仅让当前节点向下滑动。

C++ 树进阶系列之深度剖析字典(trie)树_字典树_08

  • 分割出d字符,因当前节点不存在值为d的子节点,新建值为d的子节点。字符串分割完毕,为d节点添加结束子节点。如下图所示。

C++ 树进阶系列之深度剖析字典(trie)树_字符串_09

编码实现:

实现辅助函数findChild

/*
* 查询节点是否包括值为 ch 的子节点
*/
TrieNode* Trie::findChild(TrieNode* parent,char ch){
    //获取当前节点的所有子节点
    vector<TrieNode*> childs= parent->childs;
    //遍历查询
    for(int i=0; i<childs.size(); i++) {
        if( childs[i]->data==ch  ) {
            //存在
            return childs[i];
        }
    }
    //不存在
    return NULL;
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.

实现insert函数:

/*
* 插入新的单词(字符串)
* 参数:word 需要添加的字符串
*/
void Trie::insert(string word) {
    //从根节点开始
    TrieNode* currentNode=this->root;
    //分割字符串
    for(int i=0; i<word.size(); i++  ) {
        //查询子节点是否存在
        TrieNode* childNode= Trie::findChild(currentNode,word[i]);
        if(childNode==NULL  ) {
            //子节点不存在,构建新节点
            TrieNode* newNode=new TrieNode();
            newNode->data=word[i];
            //成为当前节点的子节点
            currentNode->childs.push_back(newNode);
            //重设当前节点
            currentNode= newNode;
        } else {
            //节点存在
            currentNode= childNode;
        }
    }
    //分割完毕,添加标志性节点
    TrieNode* flagNode=new TrieNode();
    currentNode->childs.push_back(flagNode);
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.

测试插入函数:

int main(int argc, char** argv) {
	Trie* trie=new Trie();
	trie->insert("abc");
	trie->insert("abd");
	TrieNode* root=trie->getRoot();
	cout<<"根节点的子节点:"<<root->childs[0]->data<<endl;
	cout<<"根节点的子节点的子节点:"<<root->childs[0]->childs[0]->data<<endl;
	cout<<"节点的子节点的子节点的子节点:"<<root->childs[0]->childs[0]->childs[0]->data<<endl;
	cout<<"节点的子节点的子节点的子节点:"<<root->childs[0]->childs[0]->childs[1]->data<<endl;
	return 0;
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.

输出结果:

C++ 树进阶系列之深度剖析字典(trie)树_字典树_10

3.1.2.2 search函数

功能描述: 查询给定的字符串(单词)是否存在于字典树中。

实现流程: 查询和插入流程相似。如果检查到存在与分割出来的字符值相等的子节点便继续向下查询,否则,认为查询失败。

不要求一定的是完整字符串(单词),仅是前缀也可以。

如下图所示,虽然字典树中不存在ab字符串(单词),因为存在ab前缀,也认为是存在的。

C++ 树进阶系列之深度剖析字典(trie)树_子节点_11

编码实现:

/*
* 查询字典树是否存在指定的字符串(单词)
*/
TrieNode* Trie::search(string word) {
    //从节结点开始
    TrieNode* currentNode=this->root;
    //分割需要查询的字符串
    for(int i=0; i<word.size(); i++) {
        //子节点是否存在
        TrieNode* childNode= Trie::findChild(currentNode,word[i]);
        if(childNode!=NULL) {
            //继续查询
            currentNode=childNode;
        } else {
            //不存在
            return NULL;
        }
    }
    //查询成功,只要字符串和字典树上的字符一一对应便可
    return currentNode;
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.

测试查询:

int main(int argc, char** argv) {
	Trie* trie=new Trie();
	trie->insert("abc");
	trie->insert("abd");
	TrieNode* root=trie->getRoot();
	TrieNode* node= trie->search("ab");
	if(node!=NULL){
		cout<<"字典树中存在 ab"<<endl; 
	}else{
		cout<<"字典树上不存 ab"<<endl;
	}
	return 0;
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.

输出结果:

C++ 树进阶系列之深度剖析字典(trie)树_子节点_12

3.1.2.3 getAllWords函数

功能描述: 返回字典树上的所有字符串(单词)。

实现流程: 对于整棵树的搜索常用的方案有深度广度搜索。针对此需求使用深度搜索便能查询出树上的所有单词。

C++ 树进阶系列之深度剖析字典(trie)树_字符串_13

编码实现:

实现辅助函数 showAllWords:显示字典树上的所有单词。

/*
*显示所有单词
*/
void showAllWords() {
    cout<<"字典树上的所有单词:"<<endl; 
    for(int i=0; i<this->words.size(); i++) {
        cout<<this->words[i]<<endl;
    }
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.

实现getAllWords函数:

/*
* 显示字典树上所有字符串
*/
void Trie::getAllWords(TrieNode *node,string word) {
    //当前节点
    TrieNode* currentNode=node;
    //当前节点的子节点
    vector<TrieNode*> childs=currentNode->childs;
    for(int i=0; i<childs.size(); i++) {
        if(childs[i]->data=='#') {
            //如果节点值为结束符号,则获取到了完整单词
            this->words.push_back(word);
        } else {
            //否则,继续递归
            string word_=word;
            word_.append( {childs[i]->data} );
            getAllWords(childs[i], word_ );
        }
    }
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.

测试代码:

int main(int argc, char** argv) {
	Trie* trie=new Trie();
	trie->insert("abc");
	trie->insert("abd");
	TrieNode* root=trie->getRoot();
	trie->getAllWords(root,"");
	trie->showAllWords();
	return 0;
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.

输出结果:

C++ 树进阶系列之深度剖析字典(trie)树_子节点_14

3.2 矩阵存储

使用矩阵存储树节点之间的关系也是一种常见方案。

基本存储思想:

  • 对每一个节点进行编号。

  • 以父节点的编号为矩阵的行号,子节点的编号为列号,行与列对应的单元格中存储节点的关系描述(或值、权重)。

C++ 树进阶系列之深度剖析字典(trie)树_字符串_15

在存储字典树信息时,对上述的存储方案可以稍加改进一下。

  • 如先确定根节点的编号为0,子节点a在矩阵中的列号由其对应的ASCII码决定,当然,会对其范围缩小。

    对应单元格中存储字符出现的顺序编号,并以此编号为此结点的唯一标志符号(类似于主键)。

    这个编号与字符添加顺序有关,与字符本身无关。如下图所示:

C++ 树进阶系列之深度剖析字典(trie)树_子节点_16

  • 字符b是字符a的子节点。添加过程如下图所示。

C++ 树进阶系列之深度剖析字典(trie)树_字典树_17

  • 继续完成其它节点关系的存储。

C++ 树进阶系列之深度剖析字典(trie)树_字符串_18

上述存储的优点:

  • 可以把矩阵的列数限制在 26 之内。
  • 查询任一节点的子节点时,可以根据字符本身所携带信息找到其存储位置。如果b节点下还有字符w的子节点,便能轻易知道其存储位置是 [2]['w'-'a']并能获取w节点的编号。

编码实现:

与前文的链式存储相毕较,仅是改变了存储方式,逻辑是没有发生任何变化。

#include <iostream>
#include <vector>
using namespace std;
/*
* 字典树类
*/
class Trie {
	private:
		/*
		* 矩阵:存储结点之间的关系
		* 1、矩阵的行数由结点数量决定, 简化问题,此处设置为 100
		* 2、矩阵的列数由字符数量决定
		*/
		int matrix[100][26];
		//所有节点由内部统一编号
		int number;
		//字符串的节束标志
		char endFlag[100];
		//存储字典树上的所有单词
		vector<string> allWords;
	public:
        /*
        * 初始化
        */
		Trie() {
			this->number=0;
			for(int i=0; i<100; i++)
				for(int j=0; j<26; j++)
					matrix[i][j]=0;
		}
		/*
		* 插入函数
		*/
		int insert(string word) {
			//当前节点指向节结点
			int current=0;
			//遍历字符串
			for(int i=0; i<word.size(); i++) {
				//检查当前节点下是否存在值为 word[i] 的子节点
				if( matrix[current][ word[i]-'a' ]==0 ) {
					//不存在
					matrix[current][ word[i]-'a' ]=++this->number;
				}
				//重设当前节点
				current=matrix[current][ word[i]-'a' ];
			}
			//添加结束标志
			endFlag[current]='#';
		}

		/*
		*查询指定的字符串是否存在字典树中
		*/
		int search(string word) {
			//从根节点开始
			int current=0;
			//遍历字符串
			for(int i=0; i<word.size(); i++) {
				if( matrix[current][ word[i]-'a' ]==0 ) {
					//没找到
					return -1;
				}
				//继续
				current= matrix[current][ word[i]-'a' ];
			}
			return current;
		}

		/*
		* 使用深度搜索算法获取树上的所有单词
		*/
		void getAllWords(int parent,string word) {
			int current=parent;
			//查询所有子节点
			for(int i=0; i<26; i++) {
				if(matrix[current][i]!=0  ) {
					if( endFlag[matrix[current][i]]=='#'  ) {
                         //注意此处需要添加一下
						word.append( {  char(i+'a')  } );
						this->allWords.push_back(word);
                          //恢复
						word.pop_back();
					} else {
						string word_=word;
						word_.append( {  char(i+'a')  } );
                          //递归调用
						getAllWords( matrix[current][i], word_);
					}
				}
			}
		}
		/*
		*显示字典树上的所有单词
		*/
		void showAllWords() {
			cout<<"字典树上的所有单词:"<<endl;
			for(int i=0; i<this->allWords.size(); i++ )
				cout<<this->allWords[i]<<endl;
        }
};
//测试
int main() {
	Trie*  trie=new Trie();
	trie->insert("abc");
	trie->insert("abd");
	int res= trie->search("ab");
	cout<<res<<endl;
	trie->getAllWords(0,"");
	trie->showAllWords();
	return 0;
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.
  • 30.
  • 31.
  • 32.
  • 33.
  • 34.
  • 35.
  • 36.
  • 37.
  • 38.
  • 39.
  • 40.
  • 41.
  • 42.
  • 43.
  • 44.
  • 45.
  • 46.
  • 47.
  • 48.
  • 49.
  • 50.
  • 51.
  • 52.
  • 53.
  • 54.
  • 55.
  • 56.
  • 57.
  • 58.
  • 59.
  • 60.
  • 61.
  • 62.
  • 63.
  • 64.
  • 65.
  • 66.
  • 67.
  • 68.
  • 69.
  • 70.
  • 71.
  • 72.
  • 73.
  • 74.
  • 75.
  • 76.
  • 77.
  • 78.
  • 79.
  • 80.
  • 81.
  • 82.
  • 83.
  • 84.
  • 85.
  • 86.
  • 87.
  • 88.
  • 89.
  • 90.
  • 91.
  • 92.
  • 93.
  • 94.
  • 95.
  • 96.
  • 97.
  • 98.
  • 99.
  • 100.
  • 101.
  • 102.
  • 103.
  • 104.
  • 105.
  • 106.
  • 107.
  • 108.
  • 109.
  • 110.
  • 111.

输出结果:

C++ 树进阶系列之深度剖析字典(trie)树_字符串_19

使用数组存储的优点:

  • 在查询所有单词时,会自动对其按字典进行排序。
  • 实现起来较直观,易理解。

4. 字典树的应用

至此,想必对字典树有了较好的理解,下文再提供 2 个案例 ,深入体会字典树的神奇之处。

4.1 自动补全

所谓自动补全:指当用户输入单词前缀,则会显示所有与此前缀有关联的单词。此功能在关键字搜索应用中经常可以看到。

如果字典树中存在单词集["cat","caton","cater","this"],当用户输入cat时,则自动显示所有以cat为前缀的单词:catoncater

实现原理:

  • 在字典树中查找前缀是否存在。
  • 如果存在,以此前缀最后一个字符的节点为当前节点进行深度搜索。

C++ 树进阶系列之深度剖析字典(trie)树_字典树_20

编码实现:

在前文的矩阵实现方案中添加如下函数。

class Trie{
    //省略…… 
    /*
    *自动补全函数
    */
    void autoComplete(string prefix) {
        //查询前缀字符串在字典树中是存在
        int nodeId= this->search(prefix);
        if(nodeId==-1) {
            return;
        }
        //存在,则从此节点开始进行深度搜索
        this->getAllWords(nodeId,prefix);
    }
    //省略……
}
//测试
int main() {
	Trie*  trie=new Trie();
	trie->insert("cat");
	trie->insert("caton");
	trie->insert("cater");
	trie->insert("this");
	trie->autoComplete("cat");
	trie->showAllWords();
	return 0;
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.

输出结果:

C++ 树进阶系列之深度剖析字典(trie)树_字典树_21

4.2 求 2 个字符串的最长公共前缀

所谓字符串的公共前缀指字符串前面相同的部分。如catoncater的公共前缀是cat。与自动补全功能是相逆的操作。

基本思想:

  • 使用字典树存储所有字符串。
  • 两个字符串的最长公共前缀的长度即他们所在的节点的公共祖先个数,于是,问题就转化为求公共祖先问题。

在树中求解节点的公共祖先问题可以有多种方案,本文侧重于字典树的理解,仅提供下面的穷举算法。其它方案可以自行查阅相关书籍。

/*
* 穷举算法求解 2 个字符串的求公共前缀
*/
string getMaxPrefix(string word,string word_) {
    string prefix="";
    //指向根结点
    int current=0;
    int idx=0;
    //最多查询结点数
    int len=word.size()>word_.size()?word_.size():word.size();
    while(idx<len) {
        int ok=false;
        //查询当前结点的子结点
        for(int i=0; i<26; i++) {
            if(matrix[current][i]==0)continue;
            //存在子结点,且为同一个结点
            if( matrix[current][ word[idx]-'a' ]!=0 &&  matrix[current][ word[idx]-'a' ]==matrix[current][ word_[idx]-'a' ]  ) {
                ok=true;
                prefix.append({ word[idx] });
                current=matrix[current][ word[idx]-'a' ];
                break;
            }
        }
        if(!ok)
            break;
        idx++;
    }
    return prefix;
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.

测试:

int main() {
	Trie*  trie=new Trie();
	trie->insert("cat");
	trie->insert("caten");
	trie->insert("cater");
	trie->insert("this");
	string prefix=trie->getMaxPrefix("caten","cater");
	cout<<"公共前缀:"<<prefix;
	return 0;
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.

输出结果:

C++ 树进阶系列之深度剖析字典(trie)树_字典树_22

5. 总结

本文介绍了字典树的逻辑结构,并且使用链式和矩阵2种方案实现了字典的物理存储。并且通过 2个具有代表性的案例让大家更深入理解字典树的实际应用。