2025/8/21
题目(Medium):
我的思路:
首先我们要知道前缀树是什么东西,前缀树就是指树中每一个节点的字符串值 = 它的父节点的字符串值 + 它与父节点连接的边的字符值。如下图所示:
图片和更详细的理解来自于这个博客:https://www.cnblogs.com/vincent1997/p/11237389.html
因此,对于这个树,我认为能够提前出三个数据:
- ①节点自身的值
- ②节点的每条边对应的字母值(可以是26个字母中任意一个)
- ③节点每条边对应的子节点
其中数据②③可以结合在一起,让一个字母值对应一个子节点。
因此我们可以得到节点的数据结构TrieNode:
①节点自身的字符串值(string)
②节点每条边对应的子节点(unordered_map<char, TrieNode*>)
class TrieNode{
public:
string nodeStr; //节点自身的值
unordered_map<char, TrieNode*> edgeSon; //节点的每个边代表的字母,以及其所对应的子节点
TrieNode(string nodeStr){
this->nodeStr = nodeStr;
}
};
而对于具体的前缀树的实现如下:
1.初始化
从图中我们可以看到前缀树的头节点中的字符为空字符,因此前缀树中需要一个头节点数据。
同时还需要记录一个字符是否已经被插入,也可以用一个哈希集合来存储
class Trie {
private:
TrieNode* head; //前缀树头节点
unordered_set<string> hasInsertStr; //已经被插入的字符串集合
public:
//每个节点包含三个数据结构:①它的子边的字母 ②它的子节点 ③它自身的串
Trie() {
head = new TrieNode(""); //头节点自身的串值是空
}
2.插入字符串
对于要插入的字符串,我们通过迭代器遍历它的每一个字符。然后判断该字符是否存在当前节点的哈希表中,如果不存在则创建该字符指向的节点。之后我们移动到该字符所指向的节点处
void insert(string word) {
//如果已经被插入过就不用再插入一次了
if(search(word))
return;
//遍历每一个字串
TrieNode* p = head;
for(const auto& c: word){
//先检索p所在节点是否存在该字母
if(p->edgeSon.find(c) == p->edgeSon.end()){
//如果还不存在就新创建一条该边和子节点
p->edgeSon.insert({c, new TrieNode(p->nodeStr + c)});
}
//然后跳转到对应节点位置
p = p->edgeSon[c];
}
//把新字符串插入到集合表中
hasInsertStr.insert(word);
}
3.检索字符串
直接在哈希集合中搜索是否存在即可
bool search(string word) {
//返回是否找得到
return hasInsertStr.find(word) != hasInsertStr.end();
}
4.检索前缀和
和插入的思路差不多,也是遍历字符串获取每个字符,然后判断当前节点是否存在该字符对应的边。区别是如果不存在的话不是创建新的边和子节点,而是直接返回 false,如果能顺利遍历完则返回true
bool startsWith(string prefix) {
if(search(prefix))
return true;
TrieNode* p = head;
for(const auto& c : prefix){
if(p->edgeSon.find(c) == p->edgeSon.end()){
//如果发现其中有一个没找到的话,就说明这个字符不存在的
return false;
}
else{
//否则发现有则跳转到对应节点
p = p->edgeSon[c];
}
}
//能完整检索完则说明存在的
return true;
}
完整代码如下:
class TrieNode{
public:
string nodeStr; //节点自身的值
unordered_map<char, TrieNode*> edgeSon; //节点的每个边代表的字母,以及其所对应的子节点
TrieNode(string nodeStr){
this->nodeStr = nodeStr;
}
};
class Trie {
private:
TrieNode* head; //前缀树头节点
unordered_set<string> hasInsertStr; //已经被插入的字符串集合
public:
//每个节点包含三个数据结构:①它的子边的字母 ②它的子节点 ③它自身的串
Trie() {
head = new TrieNode(""); //头节点自身的串值是空
}
void insert(string word) {
//如果已经被插入过就不用再插入一次了
if(search(word))
return;
//遍历每一个字串
TrieNode* p = head;
for(const auto& c: word){
//先检索p所在节点是否存在该字母
if(p->edgeSon.find(c) == p->edgeSon.end()){
//如果还不存在就新创建一条该边和子节点
p->edgeSon.insert({c, new TrieNode(p->nodeStr + c)});
}
//然后跳转到对应节点位置
p = p->edgeSon[c];
}
//把新字符串插入到集合表中
hasInsertStr.insert(word);
}
bool search(string word) {
//返回是否找得到
return hasInsertStr.find(word) != hasInsertStr.end();
}
bool startsWith(string prefix) {
if(search(prefix))
return true;
TrieNode* p = head;
for(const auto& c : prefix){
if(p->edgeSon.find(c) == p->edgeSon.end()){
//如果发现其中有一个没找到的话,就说明这个字符不存在的
return false;
}
else{
//否则发现有则跳转到对应节点
p = p->edgeSon[c];
}
}
//能完整检索完则说明存在的
return true;
}
};
/**
* Your Trie object will be instantiated and called as such:
* Trie* obj = new Trie();
* obj->insert(word);
* bool param_2 = obj->search(word);
* bool param_3 = obj->startsWith(prefix);
*/
时间复杂度:
- 初始化O(1)
- 插入O(L)【L为字符串长度】,
- 检索字符串O(1)
- 检索前缀和O(L)
空间复杂度:O(N·L^2)【因为可能插入N个单词,每个单词平均长度为L,因此对整个树的节点树最大可能是N·L,而每个节点又存储了自身对应的前缀和的字符串值,因此单个单词的路径和的空间复杂度可以达到O(L^2),而又有N个单词,所以达到了N·O(L^2),取其中最大的主导向为O(N·L^2)】
优化思路:
很显然你可以发现一个事情,就是你在每一个节点中存储的该节点对应的前缀和其实并没有用到,这就浪费了空间了。还有就是字母是有顺序的,且固定为26个,因此完全可以用一个固定长度的数组(或者向量容器)来给每个节点标为额定的潜在边。且对于是否已经插入了该字符串,我们可以给每个节点都设定一个变量表示这个节点是否为某一个字符串的结尾处
因此,我们可以做出如下的优化:
①删去保存每个节点对应的前缀和的值的string变量
②用向量容器初始化为长度26,可以装载前缀树节点的一个变量
③给每个节点添加一个变量isEnd表示是否为某个字符串的末尾节点
1.初始化
初始化这里我们直接以Trie这个类下手,给他创建一个向量容器变量和一个isEnd变量。同时还提供一个能够帮助我们快速检索到前缀和字符串的末尾节点的方法。
//每个节点最多有26个可能的子节点(因为有二十六个字母),可以按顺序快速检索到
//每个节点可以标记是否为末尾节点表示它是否被插入
private:
vector<Trie*> children; //孩子节点们
bool isEnd; //是否为末尾节点
//传入一个前缀字符,尝试从该节点开始往后搜索到这个前缀字符的所在的节点
Trie* findPrefix(string prefix){
Trie* p = this;
for(const auto& c : prefix){
int index = c - 'a';
if(p->children[index] == nullptr)
return nullptr;
else
p = p->children[index];
}
return p;
}
public:
//更简洁的构造函数的写法
// Trie() : children(26), isEnd(false){}
Trie() {
children = vector<Trie*>(26);
isEnd = false;
}
①这里构造函数有更加简洁的写法。
②这里对向量初始化的时候不用new,因为 new的话是初始化一个指针了
③对于检索到前缀和的末尾节点,我们让每个字符-"a"可以得到该字符对应的索引位置(0~25)。然后如果能够完全遍历则可以到达并返回,否则中间找不到了就返回空指针。
④注意这里Trie*表示指向每一个节点的指针
2.插入字符串
这里的思路和上面找到前缀和末尾节点的差不多,只是这里如果发现对应的字母边指向空的话,就给他创建一个对应的节点对象
void insert(string word) {
//遍历每一个数字
Trie* p = this;
for(const auto& c : word){
int index = c - 'a';
//如果该字母还没有被初始化过,那就初始化它
if(p->children[index] == nullptr){
p->children[index] = new Trie();
}
//然后让p移动到下一个位置
p = p-> children[index];
}
//最后一个到达的位置的isEnd设为真
p->isEnd = true;
}
同时记得遍历完毕后,把他实例化到达的最后一个节点的isEnd属性设置为true表示这个节点是该字符串的末尾位置
3.检索字符串
检索字符串就很简单了,可以把他当成一个前缀和去检索,只是这次检索到的前缀和的节点的isEnd属性要为true
bool search(string word) {
Trie* node = findPrefix(word);
return node != nullptr && node->isEnd;
}
4.检索前缀和
我们上面已经实现过检索前缀和所在节点位置了,直接调用它看看返回的是否为空节点就好了
bool startsWith(string prefix) {
return findPrefix(prefix) != nullptr;
}
完整代码如下:
class Trie {
//每个节点最多有26个可能的子节点(因为有二十六个字母),可以按顺序快速检索到
//每个节点可以标记是否为末尾节点表示它是否被插入
private:
vector<Trie*> children; //孩子节点们
bool isEnd; //是否为末尾节点
//传入一个前缀字符,尝试从该节点开始往后搜索到这个前缀字符的所在的节点
Trie* findPrefix(string prefix){
Trie* p = this;
for(const auto& c : prefix){
int index = c - 'a';
if(p->children[index] == nullptr)
return nullptr;
else
p = p->children[index];
}
return p;
}
public:
//更简洁的构造函数的写法
// Trie() : children(26), isEnd(false){}
Trie() {
children = vector<Trie*>(26);
isEnd = false;
}
void insert(string word) {
//遍历每一个数字
Trie* p = this;
for(const auto& c : word){
int index = c - 'a';
//如果该字母还没有被初始化过,那就初始化它
if(p->children[index] == nullptr){
p->children[index] = new Trie();
}
//然后让p移动到下一个位置
p = p-> children[index];
}
//最后一个到达的位置的isEnd设为真
p->isEnd = true;
}
bool search(string word) {
Trie* node = findPrefix(word);
return node != nullptr && node->isEnd;
}
bool startsWith(string prefix) {
return findPrefix(prefix) != nullptr;
}
};
/**
* Your Trie object will be instantiated and called as such:
* Trie* obj = new Trie();
* obj->insert(word);
* bool param_2 = obj->search(word);
* bool param_3 = obj->startsWith(prefix);
*/
时间复杂度:初始化O(1),其他操作O(L)【L是操作的字符串的长度】
空间复杂度:O(∣T∣⋅Σ)【T是插入的总字符串长度,Σ是字符集大小(这里为26)】
总结:
①对于要实现某个对象,我们需要先了解这个对象是什么,长什么样。然后才能确定具体去描述它的属性和方法
②对于有顺序和规律的边值(比如这里的字母),我们可以用固定长度的数组或者容器来简化地实现它
③对于每个节点,我们可以把他看成是默认有26个插槽,每次有需要的时候可以把它还没有插上的字母给插入到插槽中