字典树【Tire树】基础讲解

236 阅读11分钟

作用

用于高效的查找和存储字符串集合的数据结构。

适用于字符类型不多的字符串。

数据的存储

存储形式1【项目中常用写法】

图解思路

看着下图去理解。

image.png 一个树形结构用来存储字符串,根节点不存储数据。除了根节点与第一层子节点的关系外,剩余的父子关系代表字符的拼接,而根节点仅仅是用于连接每个字串的第一个字符。

比如root->a->b【a为父】代表'a'+'b'==>"ab"。二者拼接合并,父在前子在后。

那么从根节点到整棵树的叶子节点的每一条路径就代表一个字符串【root->a->b->c代表字串abc】。

思考以下问题:

那么若是树中已经存储了“acbd”,此时还想存储"acb"怎么实现呢?

答案:

因为字典树的存储特征,我们会发现树中已经存有"acb"了,但是若是进行数据的取出时,我却无法知道树中的“acbd”这条路径中的"acb"此前是否存储过,因为如果单纯按之前方法存储的话,可能每个字符都是结束,但是具体是否存储了这样的字串我们并不知情。

所以可以在每个字串的最后一个字符上做一个标记,表示字串的结束。例如最开始的图中所有存储的字串分别为"a"、"ab"、"af"、"ce"

实现思路

那实现起来其实很简单,每个节点为一个对象,简称节点对象,节点对象有一个属性用于存储一个状态表示是否为字串的结尾;还有一个以字符为下标的对象,简称子节点对象,对应下标的元素值为一个节点对象,子节点对象是用于存储树结构中该节点对象的子节点的。

每次存储时,先将要存储的字符串转为以字串中每个字符为元素的字符数组,然后逐层查找看是否有对应的字符,没有则存储,当当前字符为该字串的最后一个字符时,进行标记。

实现代码【js】
let tireTree = { children: null }, p = tireTree;
// p用于遍历存储,下方的string为要存储的一串字符串。
string = string.split("");
string.forEach((it, index) => {
    if (!p.children) {
        p.children = {};
    }
    p = p.children;
    if (!p[it])
        p[it] = {};
    p = p[it];
    if (index === item.length - 1) { // 最后一个字符时,进行标记
        p.finished = true;
    }
})

存储形式2【算法竞赛模板】

优势

第一种存储形式是以对象的形式解决问题的,但是这种解决方案对于c语言等,并不是很适合竞赛,因为对象的效率要比直接使用数组要慢的多,所以此处也介绍一下使用数组模拟字典树的方法。

思路

若是使用数组,可能很多人直接想到将字符映射为从0开始的数字,这样使用下标就可以直接探查到字典树中是否存储了该字符。这点是正确的。

但是有的人很容易想简单,觉得单纯的二维数组,第一维表示上面说的字符映射,第二位表示层数。这就错了。因为这样存储第一维无法区分哪个字符是树中的哪条分支。

举个错误例子方便理解:比如字典树中存储 1->2->0与1->3

那错误的存储便是下图中演示的,我们无法区分哪个字符在哪个分支。

image.png

所以得作出一个小小的改变,我们将每个一维的行,用于存储一个节点的子节点的字符情况。

那么怎么区分这行是谁的子节点的数据的。我们想到可以通过一维行的下标来进行区分,将每一个新存储的字符进行编号,该编号index将会作为该字符子节点的位置的索引。也就是说子节点将会存储在下标的值为index的那一行中。

代码
let tireTree = [], p = 0, inx = 0;
// p用于遍历存储,下方的string为要存储的一串字符串。
string = string.split("");
string.forEach((it, index) => {
    if (tireTree[p] === undefined) {
        tireTree[p] = {};
    }
    if (tireTree[p][it] === undefined) {
        tireTree[p][it] = {};
        tireTree[p][it].index = ++inx;
    }
    if (index === item.length - 1) {
        tireTree[p][it].finished = true;
    }
    p = tireTree[p][it].index;
})
拓展思路【优雅版的存储形式1】

我们再在原先的思路上做出一点小改变。我们其实也可以将二维数组变为一个存储对象的一维数组。也就是说,我们将子节点的状态作为一个属性存储在对象中。

除此之外,对象中还存储了结束状态【是否有以该节点为结尾的字串】。

而其他的思路和之前的一样。都是一个数组的下标代表一个字典树中的节点。

代码2
let tireTree = [], p = 0, inx = 0;
// p用于遍历存储,下方的string为要存储的一串字符串。
string = string.split("");
string.forEach((it, index) => {
    if (tireTree[p] === undefined) {
        tireTree[p] = {};
    }
    if (!tireTree[p].son) {
        tireTree[p].son = {};
    }
    if (!tireTree[p].son[it]) tireTree[p].son[it] = ++inx;
    p = tireTree[p].son[it];
    if (index === item.length - 1) {
        tireTree[p][it].finished = true;
    }
})

存储节点的附加信息

若是想要在节点上存储一些附加信息,比如,同样的字符串在字典树中存储了多少个。

有两种存储方式。

第一种是直接存储在字典树的节点之上,作为属性存在,这种方法适用于前面所说的两种实现方式。

第二种只适用于存储方式为竞赛模板的,方法是使用一个数组,在字典树外部以节点的inx为下标,将要存储信息存放在数组中。

实际应用

请不看下方代码完成本题目魔族密码 - 洛谷,下方代码为其中一种题解,该题解应用了上方讲述内容。

之后思考若是给出数据不是按照字典序时应该怎么处理。

存储形式1的思路讲解

此处直接解决非字典序数据版本的题目,当然也适用于原来的题目。

要求一个词链中最多的单词数,由于词链的特性——在一个词链中前一个单词是后一个单词的前缀,所以我们其实可以直接使用字典数来存储每个单词,并在存储字符的过程中计算出该条路径上已经存储的最大单词数。当单词结束时就进行一次标记,并将最大单词数记录在单词所在节点。同时记录从程序运行时到现在的路径上最大单词数。

而当我们当前存储的字符不为当前字串的最后一个字符,但是为之前某一个字串的最后一个字符时,我们应该查看之前存储在此位置上的最大单词数,与以当前字串中当前字符之前的字符所构成的最长词链的单词数 进行比较,将此位置的最大单词数更新,确保他一定为最大的。

这样在不断的处理过程中,我们就可以保证我们每次在节点中存储的数据都是当前数据构成词链的最大单词数。我们自然就可以得出题目中所求的整体的数据所构成的词链的最大单词数。

最终代码
const readline = require('readline');
const { finished } = require('stream');
const rl = readline.createInterface({
    input:process.stdin,
    output:process.stdout
});
let countLine = 1
let N;
let tireTree = { children: null }, inx = 0, res = 0;
let item;
rl.on('line', function(line) {
    if(countLine === 1) {
        //接收总输入行数
        N = parseInt(line);
    } else {
        // 处理数据
        item = line;
        if(countLine <= N + 1) {
            // 字符串变为数组处理
            item = item.split("");
            let p = tireTree, before = 0;// p用于遍历存储,
            //  before用于存储:当前字符之前的最长词链的单词数
            item.forEach((it, index) => {
                // 存储的过程
                if (!p.children) {
                    p.children = {};
                }
                p = p.children;
                if (!p[it]) {
                    p[it] = {};
                    p[it].index = ++inx;//使得每一个单词的末尾都会有一个独一无二的id
                }

                p = p[it];
                if (index === item.length - 1) {//标记最后一个字符
                    p.finished = before + 1;
                }
                if (p.finished) {
                    // 在字串结束时,存储当前词链的最大单词数。
                    p.finished = before = Math.max(before, p.finished);
                    // 获取最大单词数
                    res = Math.max(before, res);
                }
            })
        }
        if (countLine === N + 1) {
            console.log(res)
        }
    }

        countLine++;
})
存储形式2的思路讲解

本思路我们回归题目,不解决非字典树数据,仅考虑题目原本要求。

我们仍使用字典树存储,但是由于数据是字典序的,所以我们可以直接利用本字符前的字符组成的词链的单词个数来进行最大值的推算,每次单词结束时,将原来最大值加1,并存储在字符所处节点中。

这样不断操作就可得到最大单词数。

最终代码
const readline = require('readline');
const { finished } = require('stream');
const rl = readline.createInterface({
    input:process.stdin,
    output:process.stdout
});
let countLine = 1
let N;
let tireTree = [], inx = 0, res = 0;
let item;
rl.on('line', function(line) {
    if(countLine === 1) {
        //求和
        N = parseInt(line);
    } else {
        // 处理input
        item = line;
        if(countLine <= N + 1) {
            item = item.split("");
            let p = 0, before = 0;
            item.forEach((it, index) => {
                if (tireTree[p] === undefined) {
                    tireTree[p] = {};
                }
                if (tireTree[p][it] === undefined) {
                    tireTree[p][it] = {};
                    tireTree[p][it].index = ++inx;
                }
                if (index === item.length - 1) {
                    // 单词结束,标记并计算词链单词数
                    tireTree[p][it].count = before + 1;
                    tireTree[p][it].finished = true;
                }
                if (tireTree[p][it].finished){
                    // 每次遇到已结束的单词,计算此时的词链单词数,并筛出最大值
                    tireTree[p][it].count = before = Math.max(tireTree[p][it].count, before);
                    res = Math.max(res, tireTree[p][it].count);
                }
                p = tireTree[p][it].index;
            })
        }
        if (countLine === N + 1) {
            console.log(res)
        }
    }

        countLine++;
})

字串的查找

思路

单独查询字典树中是否有一个指定的字串。

从根结点开始访问本节点的子节点,查看当前子节点并将这些子节点与字串中的对应位置的字符进行配对,若是没有找到对应字符,那么就说明字典树中没有存储该字串。

再者若是匹配到最后一个字符,且最后一个字符匹配成功,但是字典树对应的节点没有结束标记,则说明虽然这些字母都按照字串的顺序进行存储过了,但是并没有存储字串本身,而是存储了比他还长的字串。

存储形式1时的代码

function search(str) {
    let p = tireTree;
    if (!Array.isArray(str)) str = str.split('');
    str.forEach((item, index) => {
        if (p.children && p.children[item]) {//找到对应的字母
            p = p.children[item];
            if (index === str.length - 1 && p.finish) {
                // 结束时有标记
                return true;
            }
        }
        else return false;
    })
    return false;
}

存储形式2时的代码

function search(str) {
    let p = 0;
    if (!Array.isArray(str)) str = str.split('');
    str.forEach((item, index) => {
        if (tireTree[p] && tireTree[p][item]) {//找到对应的字母
            if (index === str.length - 1 && tireTree[p][item].finish) {
                // 结束时有标记
                return true;
            }
            p = tireTree[p][item].index;
        }
        else return false;
    })
    return false;
}

对字典树内存储内容的搜索

前置知识

本部分内容需先掌握dfs最基础的部分

思路

使用dfs进行字典树中内容的遍历,遍历过程中可以进行一定程度的处理,当搜索到标有结束标记的节点,那就说明一个字串被遍历出来了。

模板代码与实际应用

题目

P4407 [JSOI2009] 电子字典 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)

解题思路1:利用js对象的特性的解题方法【其实也可以使用字符串前缀hash法,思路一样】

该题解胜在简单,可以拓展思路,但是由于时间复杂度过高,所以无法AC本题。【其实也不是说过不了,但是js的效率比不了c,c都得卡常过,js就不行了,我也不会js卡常】

题中可以对一个已经存储的字符串或是要搜索的字符串进行一次 一个字母的增删改中的任意一个操作。

由于js的对象是可以随意增加属性的,所以可以直接将字串作为单例对象obj的属性key进行存储,在进行字串搜索的时候,对要搜索的字串进行操作后得到真正要搜索的字串real,然后再在obj中使用下标查看对象中是否已经存储。

而这样的算法所用到的算法思想其实就是简单的模拟。相关代码如下。

const readline = require('readline');
const rl = readline.createInterface({
    input:process.stdin,
    output:process.stdout
});
// node的输入配置
let countLine = 1, record = {}, mem = {}, remind = {};
//countLine 表示当前是第几次输入,record用于存储字串
//mem用于存储搜索过的字串对应的结果,下次搜索同样的字符就直接输出。
//remind用于记录库中已经匹配过的字串,
    //防止同一个要搜索的字串不同操作后匹配到同一个库中的字串。
let n, m;
rl.on('line', function(line) {
    if(countLine === 1) {
        //第一行输入的处理
        line = line.split(' ');
        n = parseInt(line[0]);
        m = parseInt(line[1]);

    } else {
        // 处理剩余行的input
        if (n-- > 0) {//前n行字串存储
            record[line] = true;
        } else {
            //后面的字串查询
            m--;
            let res = 0;
            if (record[line]) {//本身就在库中
                console.log(-1);
            }
            else if (mem[line]) {
                //虽然本身不在库中,但是之前已经搜索过了,直接输出
                console.log(mem[line]);
            }
            else {
                remind = {};
                //进行字串操作,逐个查找。
                let str = line.split("");
                //改,挨个位置进行字母的挨个替换,不要替换成原字符,白白做一次操作
                for (let i = 0; i < line.length; i++) {
                    for (let j = 'a'.charCodeAt(); j <= 'z'.charCodeAt(); j++) {
                        let temp = str[i];
                        str[i] = String.fromCharCode(j);
                        let x = str.join('')
                        if (record[x] && !remind[x]) {
                            //库中有且之前没有匹配过,则结果增加、并标记库中字串
                            res++;
                            remind[x] = true;
                        }
                        str[i] = temp;
                    }
                }
                //挨个字符删除
                for (let i = 0; i <= line.length; i++) {
                    let s = line.split("");
                    s.splice(i,1);
                    let x = s.join('')
                    //库中有且之前没有匹配过,则结果增加、并标记库中字串
                    if (record[x] && !remind[x]) {
                        res++;
                        remind[x] = true;
                    }
                    //增。在某个字符前增加一个字母
                    for (let j = 'a'.charCodeAt(); j <= 'z'.charCodeAt(); j++) {
                        let temp = line.split("");
                        temp.splice(i,0,String.fromCharCode(j));
                        let x = temp.join('')
                        if (record[x] && !remind[x]) {
                            res++;
                            remind[x] = true;
                        }
                    }
                }
                console.log(res);
                mem[line] = res;//记录搜索的字串的答案
            }

        } 
        if (m <= 0)
            rl.close();

    }

    countLine++;
})
解题思路2:利用字典树优化字串操作过程

上面的思路可以说是对于整个字串的匹配,而且貌似进行整个字串的匹配的话,已经是效率很高了。但仍无法通过题目。

这说明我们的观察视角错了,不应该以整个字串为视角,而是应该以字串中的每个字母作为视角。而这个题是字符串搜索,我们很容易想到字典树就是用来进行字符串存储和搜索的利器。而这个数据结构在进行搜索的时候刚好是以字母为视角的。

所以我们可以利用字典树的搜索来做。仔细想想,其实就是一道模板题.

而此时我们要使用存储形式2的拓展思路来构造字典树

const readline = require('readline');
const rl = readline.createInterface({
    input:process.stdin,
    output:process.stdout
});
let countLine = 1, tireTree = { index: 0 }, inx = 0, record = "", mem = [], res = 0;
let n, m;
rl.on('line', function(line) {
    if(countLine === 1) {
        //求和
        line = line.split(' ');
        n = parseInt(line[0]);
        m = parseInt(line[1]);

    } else {
        // 处理input
        if (n-- > 0) {
            insert(line);
        } else {
            //查找本身
            m--;
            res = 0;
            record = line;
            dfs(tireTree, 0, false);
            console.log(res);
            res = 0;
            mem = [];
        } 
        if (m <= 0)
            rl.close();

    }

    countLine++;
})
function dfs(x, y, flag) {
    if (res === -1)// 原样匹配过了
        return;
    //找到了
    if (x.finished && y === record.length) {
        if (flag) {// 操作过了
            if (!mem[x.index]) {
                mem[x.index] = true;
                res++;
            }
        }
        else {
            res = -1;
        }
        return;
    }
    //将record在此刻位置【y】前增加一个,由于可以增加任意字符,
    //而此时字典树的当前层是进行原来的字符的匹配的。但是此处增加了任意一个字符,
    //那么就意味着此时当前层的字典树可能在与任意一个字母匹配
    //所以但凡下一层有节点的字典树路径都可以匹配成功。
    if (!flag && x.children) {
        for (let i = 'a'.charCodeAt(0); i <= 'z'.charCodeAt(0); i++) {
            let t = String.fromCharCode(i);
            if (x.children[t]) {
                //字典树本层字符与虚幻字符匹配,下一个要匹配的字符仍该是当前位置y
                dfs(x.children[t], y, true);
            }
        }
    }
    //注意当往要搜索字串的末尾添加字符时,y在没有操作前就已经是length了,
    //所以该条语句应该在添加下方才能保证添加操作的完整性
    if (y >= record.length) {
        return;
    }
    if (!flag) {//之前没操作过
    //删
        if (y < record.length)
        //要搜索的字串删除一个字符,那么可以将此轮匹配终止一会,
        //而此轮匹配在删除操作之后,就应该与y位置的下一个字母匹配了
            dfs(x, y + 1, true);
    //改
        if (x.children)
            for (let i = 'a'.charCodeAt(0); i <= 'z'.charCodeAt(0); i++) {
                let t = String.fromCharCode(i);
                if (t !== record[y] && x.children[t]) {
                //改成和原来一样的字母就没有意义了
                //本层字符已经和y位置的字符匹配,该匹配下一个字符
                    dfs(x.children[t], y + 1, true);
                }

            }
    }
    if (x.children && x.children[record[y]]) {//本次匹配成功才可以什么也不做
        //什么也不做,正常匹配,因为没做什么,所以flag状态不可能改变
        dfs(x.children[record[y]], y + 1, flag)
    }


}
function insert(item) {
    item = item.split('');
    let p = tireTree;// p用于遍历存储,
    //  before用于存储:当前字符之前的最长词链的单词数
    item.forEach((it, index) => {
        // 存储的过程
        if (!p.children) {
            p.children = {};
        }
        p = p.children;
        if (!p[it]) {
            p[it] = {};
            p[it].index = ++inx;
        }
        p = p[it];
        if (index === item.length - 1) {//标记最后一个字符
            p.finished = true;
        }
    })
}