大家好!今天给大家带来的是一道前缀树的题目。
原帖地址:http://leanote.com/blog/post/60768fe7ab64414216007075
题目
Trie(发音类似 “try”)或者说 前缀树 是一种树形数据结构,用于高效地存储和检索字符串数据集中的键。这一数据结构有相当多的应用情景,例如自动补完和拼写检查。
请你实现 Trie 类:
Trie() 初始化前缀树对象。
void insert(String word) 向前缀树中插入字符串 word 。
boolean search(String word) 如果字符串 word 在前缀树中,返回 true(即,在检索之前已经插入);否则,返回 false 。
boolean startsWith(String prefix) 如果之前已经插入的字符串 word 的前缀之一为 prefix ,返回 true ;否则,返回 false 。
来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/implement-trie-prefix-tree
著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。
思路分析
这个题目需要我们实现一个数据结构,能够快速查找放过的单词和他们的前缀
如果单纯的只是需要查找单词,我们可以使用Set或者是Map来实现。但是这里还需要查找前缀,如果我们还按照集合来实现,查找前缀就会特别麻烦,假设输入的字符串长度为M,共有N个,查找前缀字符串至少是O(M)的级别,对每个字符串遍历又是O(N)的级别,加起来就是O(MN)的级别,过于冗长。那么我们用了什么结构来实现呢?
题目里也给了我们提示,用树来实现,我们经常使用的是二叉树,在这里我们不使用二叉树,而是使用26叉树。因为有26个字母,我们对一个字符串进行拆分,对每一个字母建一棵树,这样在插入前缀相同字符串的时候,就不会重复占用空间,而且还能重复使用前缀,查找前缀也变成O(N)级别的操作了,所以这个题目的关键点就是如何去构建和维护我们的前缀树(也称字典树)
当然,还有一个点我们需要考虑的。就是如何表示一个字符串的结尾,假设我们的这棵树是这样的
'a'->'b'->'c'->空指针
那么在我们搜索前缀字符串"ab"的时候,它会返回true,可是我们搜索search的时候,它返回也为true,因为这个结构不知道我们放进去的是"abc"还是"ab"和"abc",这样就会出现问题。那么如何解决这个问题呢,也非常简单,对每一个树都加入一个标识符
'a'(非结尾)->'b'(非结尾)->'c'(结尾)->空指针
这样就一目了然了,如果我们还需向字典树中加入"ab"字符串,也不会引起冲突。
'a'(非结尾)->'b'(结尾)->'c'(结尾)->空指针
这样这个树在搜索"ab"和"abc"的时候都会返回true,而在其他答案的时候都会返回false。
细节都讲完了,接下来看看代码
C++代码:
class Trie {
private:
vector<Trie*> children;
bool isEnd;
Trie* searchPrefix(string prefix) {
Trie* node = this;
for (char ch : prefix) {
ch -= 'a';
if (node->children[ch] == nullptr) {
return nullptr;
}
node = node->children[ch];
}
return node;
}
public:
Trie() : children(26), isEnd(false) {}
void insert(string word) {
Trie* node = this;
for (char ch : word) {
ch -= 'a';
if (node->children[ch] == nullptr) {
node->children[ch] = new Trie();
}
node = node->children[ch];
}
node->isEnd = true;
}
bool search(string word) {
Trie* node = this->searchPrefix(word);
return node != nullptr && node->isEnd;
}
bool startsWith(string prefix) {
return this->searchPrefix(prefix) != nullptr;
}
};
Rust代码
#[derive(Default)]
pub struct Trie {
root: Node,
}
#[derive(Default)]
struct Node {
children: [Option<Box<Node>>; 26],
is_word: bool,
}
impl Trie {
pub fn new() -> Self {
Self::default()
}
pub fn insert(&mut self, word: String) {
let mut node = &mut self.root;
for c in word.as_bytes() {
let idx = (c - b'a') as usize;
let next = &mut node.children[idx];
node = next.get_or_insert_with(Box::<Node>::default);
}
node.is_word = true;
}
pub fn search(&self, word: String) -> bool {
self.get_node(&word).map_or(false, |w| w.is_word)
}
pub fn starts_with(&self, prefix: String) -> bool {
self.get_node(&prefix).is_some()
}
fn get_node(&self, s: &str) -> Option<&Node> {
let mut node = &self.root;
for c in s.as_bytes() {
let idx = (c - b'a') as usize;
match &node.children[idx] {
Some(next) => node = next.as_ref(),
None => return None,
}
}
Some(node)
}
}
Rust代码就比较复杂了。因为还是我们上一期讲到的所有权的问题,还有一个内存安全的问题。
如果我们直接写child=[Trie; 26]这个struct就会报错,错误是这个结构体有无限大小,所以我们必须给它外面包上一层Box<>,因为Box是一个智能指针,有着确定的大小。
而且为了确保空指针不会出现,我们必须在其外再加上一层Option,这是一个枚举类型,包含了Some(T)和None,主要是为了替代空指针而被创造的,它是一个内存安全的结构,常常被用于模拟空指针。