目录
一、前缀树的基础知识
二、实现前缀树
一、前缀树的基础知识
前缀树,又称为字典树,它用一个树状的数据结构存储一个字典中的所有单词。如果一个字典中包含单词 "can"、"cat"、"come"、"do"、"i"、"in" 和 "inn",那么保存该字典所有单词的前缀树如下图所示。
前缀树是一棵多叉树,一个节点可能有多个子节点。前缀树中除根节点外,每个节点表示字符串中的一个字符,而字符串由前缀树的路径表示。前缀树的根节点不表示任何字符。例如,从上图中前缀树的根节点开始找到字符 'c' 对应的节点,接着经过字符 'a' 对应的节点到达字符 'n' 对应的节点,该路径表示字符串 "can"。
如果两个单词的前缀(即单词最开始的若干字符)相同,那么它们在前缀树中对应的路径的前面的节点是重叠的。例如,"can" 和 "cat" 的前两个字符相同,它们在前缀树对应的两条路径中最开始的 3 个节点(根节点、字符 'c' 和 字符 'a' 对应的节点)重叠,它们共同的前缀之后的字符对应的节点一定在最后一个共同节点的子树中。例如,"can" 和 "cat" 的共同前缀 "ca" 在前缀树中的最后一个节点是第 3 层的第 1 个节点,两个字符串共同的前缀之后的字符 'n' 和 't' 都在最后一个公共节点的子树之中。
字符串在前缀树中的路径并不一定终止于叶节点。如果一个单词是另一个单词的前缀,那么较短的单词对应的路径是较长的单词对应的路径的一部分。例如,在上图中,字符串 "in" 对应的路径是字符串 "inn" 对应的路径的一部分。
如果前缀树路径到达某个节点时它表示了一个完整的字符串,那么字符串最后一个字符对应的节点有特殊的标识。例如,上图中字符串最后一个字符对应的节点都用灰色背景标识。从根节点出发到达表示字符 'i' 的节点,由于该节点被标识为字符串的最后一个字符,因此此时路径表示的字符串 "i" 是字典中的一个单词。接着往下到达表示字符 'n' 的节点,这个节点也被标识为字符串的最后一个字符,因此此时路径表示的字符串 "in" 是字典中的一个单词。接着往下到达另一个表示字符 'n' 的节点,该节点也有同样的标识,因此此时路径表示的字符串 "inn" 是字典中的另一个单词。
在本章中如果没有特殊说明,那么前缀树中都只包含英文小写字母。
二、实现前缀树
题目:
请设计实现一棵前缀树 Trie,它有如下操作:
-
函数 insert,在前缀树中添加一个字符串。
-
函数 search,查找字符串。如果前缀树中包含该字符串,则返回 true;否则返回 false。
-
函数 startWith,查找字符串前缀。如果前缀树中包含以该前缀开头的字符串,则返回 true;否则返回 false。
例如,调用函数 insert 在前缀树中添加单词 "goodbye" 之后,输入 "good" 调用函数 search 返回 false,但输入 "good" 调用函数 startWith 则返回 true。再次调用函数 insert 添加单词 "good" 之后,此时再输入 "good" 调用函数 search 则返回 true。
分析:
-
首先定义前缀树中节点的数据结构。前缀树中的节点对应字符串中的一个字符。如果只考虑英文小写字母,那么字符可能是从 'a' 到 'z' 的任意一个,因此前缀树中的节点可能有 26 个子节点。可以将 26 个子节点放到一个数组中,数组中的第 1 个元素是对应字母 'a' 的子节点,第 2 个元素是对应字母 'b' 的子节点,其余的以此类推。
值得注意的是,前缀树的节点中没有一个字段表示节点对应的字符。这是因为可以通过节点是其父节点的第几个子节点得知它对应的字符,也就没有必要在节点中添加一个字段。
节点中还需要一个布尔类型的字段表示到达该节点的路径对应的字符串是否为字典中一个完整的单词。
struct TrieNode {vector<TrieNode*> children; // 指向子节点的指针数组bool isWord; // 布尔类型的字段 TrieNode() : children(26, nullptr), isWord(false) {} };
-
在前缀树中添加(insert)单词时,首先到达前缀树的根节点,确定根节点是否有一个子节点和单词的第 1 个字符对应。如果已经有对应的子节点,则前往该子节点。如果该子节点不存在,则创建一个与第 1 个字符对应的子节点,并前往该子节点。接着判断该子节点中是否存在与单词的第 2 个字符相对应的子节点,并以此类推,将单词其他的字符添加到前缀树中。
当单词的所有字符都添加到前缀树中之后,所在的节点对应单词的最后一个字符。为了标识路径到达该节点时已经对应一个完整的单词,需要将该节点的 isWord 设为 true。
-
接下来考虑如何实现函数 search。还是从前缀树的根节点开始查找。如果根节点没有一个子节点和字符串的第 1 个节点相对应,那么前缀树中自然不存在查找的单词,直接返回 false。如果根节点中存在与第 1 个字符对应的子节点,则前往该子节点,接着匹配单词的第 2 个字符。以此类推,直到到达和字符串最后一个字符对应的节点。如果该节点的 isWord 的值为 true,那么路径到达该节点时正好对应输入的单词,因此前缀树中存在该输入的单词,可以返回 true;否则返回 false。
-
函数 startWith 和 search 不同,只要前缀树中存在以输入的前缀开头的单词就应该返回 true。当顺着路径逐个匹配输入前缀的字符时,如果某个字符没有节点与之对应,那么可以返回 false。如果一直到前缀的最后一个字符在前缀树中都有节点与之对应,那么说明前缀树中一定存在以该前缀开头的单词。此时无论当前节点的 isWord 的值是什么,都应该返回 true。
class Trie { public:Trie() : root(new TrieNode) {} void insert(string word) {TrieNode* cur = root;for (char ch : word){int index = ch - 'a';if (cur->children[index] == nullptr)cur->children[index] = new TrieNode;cur = cur->children[index];}cur->isWord = true;} bool search(string word) {TrieNode* cur = root;for (char ch : word){int index = ch - 'a';if (cur->children[index] == nullptr)return false; cur = cur->children[index];}return cur->isWord;} bool startsWith(string prefix) {TrieNode* cur = root;for (char ch : prefix){int index = ch - 'a';if (cur->children[index] == nullptr)return false; cur = cur->children[index];}return true;} private:TrieNode* root; };
如果输入的单词的长度为 n,那么函数 insert、search 和 startWith 的时间复杂度都是 O(n)。