1. 基本概念
- 前缀树:一种用于快速检索的多叉树结构,利用字符串的公共前缀来降低查询时间,核心思想是空间换时间,经常被搜索引擎用于文本词频统计
- 优点:最大限度地减少无畏的字符比较,查询效率高
- 缺点:内存消耗大
- 特性:
- 不同字符串的相同前缀只保存一份
- 节点不存放数据,数据存储在树的边上,节点存放字符经过的次数和结束的次数
- 实现(对应 Leetcode 第 208 题):
var Trie = function() {
this.children = {};
};
/**
* @param {string} word
* @return {void}
*/
Trie.prototype.insert = function(word) {
let node = this.children;
for(const ch of word) {
if(!node[ch]) {
node[ch] = {};
}
node = node[ch];
}
node.isEnd = true;
};
Trie.prototype.searchPrefix = function(prefix) {
let node = this.children;
for(const ch of prefix) {
if(!node[ch]) {
return false;
}
node = node[ch];
}
return node;
}
/**
* @param {string} word
* @return {boolean}
*/
Trie.prototype.search = function(word) {
const node = this.searchPrefix(word);
return node !== undefined && node.isEnd !== undefined;
};
/**
* @param {string} prefix
* @return {boolean}
*/
Trie.prototype.startsWith = function(prefix) {
return this.searchPrefix(prefix);
};
上述代码实现前缀树的基本构建和查询的功能。
2. 主要问题
前缀树是一种数据结构。在编程人员的视角来看,对于某一种数据结构,对他的操作无非有两个,一个是构建,一个是遍历。对于前缀树这种结构也是一样的,它需要解决的两个主要问题:构建和遍历查询。
- 前缀树构建:前缀树的构建需要根据前缀树的规则来完成。我们知道,前缀树是用来存放单词,也就是字符串的。对于每一个字符串,以字符为单位遍历当前前缀树节点,如果当前节点的子结点中不包含字符串含有的字符,则将该字符添加到当前节点的子节点列表中。之后,根据该字符移动当前节点的位置。
- 前缀树查询:前缀树的查询,是前缀树构建的反过程。在构建的时候,遇到未知节点则添加;然而在前缀树查询的时候,遇到未知节点则返回 false。
3. LeetCode 题目
648 题:单词替换
小编第一次拿到这个题目的时候,并没有想到采用前缀树来做,而是使用哈希表来做。具体思路如下:首先将 dictionary
中的词根都添加到哈希表中;然后将句子按照空格分割成数组,并且以单词为单位做判断。对于每个单词而言,从第一个字符开始遍历,并且和哈希表中的词根进行对比。如果相同则用哈希表中的词根替换当前单词;否则,继续遍历。因为题目中要求是最短的词根,所以按顺序找到的第一个词根肯定是最短的,找到之后将循环 break
。具体代码实现如下:
/**
* @param {string[]} dictionary
* @param {string} sentence
* @return {string}
*/
var replaceWords = function(dictionary, sentence) {
const senArr = sentence.split(' ');
const sucSet = new Set();
for(let suc of dictionary) {
sucSet.add(suc);
}
senArr.forEach((word, index) => {
const n = word.length;
for(let i = 0; i < n; i++) {
let subWord = word.substring(0, i + 1);
if(sucSet.has(subWord)) {
senArr[index] = subWord;
break;
}
}
});
return senArr.join(' ');
};
在提交代码的时候发现,代码的执行时间很长。然后经过粗略的分析,发现代码的执行时间应该在O(d + Σwi^2)。因此,小编就在想有没有什么方法可以降低代码的运行时间的?
更加省时的方法就是利用我们在上文提到的前缀树。废话不多说,先贴代码:
/**
* @param {string[]} dictionary
* @param {string} sentence
* @return {string}
*/
var replaceWords = function(dictionary, sentence) {
const trie = new Trie();
for(const word of dictionary) {
let cur = trie;
for(let i = 0; i < word.length; i++) {
const c = word[i];
if(!cur.children.has(c)) {
cur.children.set(c, new Trie());
}
cur = cur.children.get(c);
}
cur.children.set('#', new Trie());
}
const words = sentence.split(' ');
for(let i = 0; i < words.length; i++) {
words[i] = findRoot(words[i], trie);
}
return words.join(' ');
};
// 字典树(前缀树)类
class Trie {
constructor() {
this.children = new Map();
}
}
// findRoot 函数
function findRoot(word, trie) {
let root = '';
let cur = trie;
for(let i = 0; i < word.length; i++) {
const c = word[i];
if(cur.children.has('#')) {
return root;
}
if(!cur.children.has(c)) {
return word;
}
root += c;
cur = cur.children.get(c);
}
return root;
}
上述代码分成两个步骤:
- 根据
dictionary
构建前缀树 - 根据
sentence
中的单词到前缀树中查询,看是否有相同的词根。
用前缀树的方法,节省了使用前种方法比较单词的子串和词根是否相同的时间。因此,其时间复杂度是 O(d+w)