leetcode刷题之前缀树

55 阅读4分钟

1. 基本概念

  • 前缀树:一种用于快速检索的多叉树结构,利用字符串的公共前缀来降低查询时间,核心思想是空间换时间,经常被搜索引擎用于文本词频统计
  • 优点:最大限度地减少无畏的字符比较,查询效率高
  • 缺点:内存消耗大
  • 特性:
    • 不同字符串的相同前缀只保存一份
    • 节点不存放数据,数据存储在树的边上,节点存放字符经过的次数和结束的次数
  • 实现(对应 Leetcode 第 208 题):

image.png

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 题:单词替换

image.png

小编第一次拿到这个题目的时候,并没有想到采用前缀树来做,而是使用哈希表来做。具体思路如下:首先将 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;
}

上述代码分成两个步骤:

  1. 根据 dictionary 构建前缀树
  2. 根据 sentence 中的单词到前缀树中查询,看是否有相同的词根。

用前缀树的方法,节省了使用前种方法比较单词的子串和词根是否相同的时间。因此,其时间复杂度是 O(d+w)