22_1-字典树与双数组字典树(数据结构基础篇)
字典树
概念
就像我们平时使用字典查找单词,我们首先按照首字母在字典中找到对应的位置,然后,再依次按照第二、第三...个字母依次缩小范围,最后找到这个单词的准确位置。我们可以发现,如果按照这样的方式查找,画成一张图的话是这样的:
字典树又叫(单词查找树或Trie),作用是:单词查找,字符串排序
单词查找
通常,我们将字典树的每一个边代表一个字母。而每个节点有两种颜色,一种颜色是白色,代表当前查找的单词在我们的字典中不存在,另一种颜色是红色,代表当前要查找的单词在我们的词典中是存在的。
字符串排序
使用字典树进行字典树排序,时间复杂度是
O(n)
我们只需要对我们的字典树进行深度优先遍历(DFS),在遍历到每一个红色节点时输出单词,最终所得到的字符串数组就是按照字典序排序好的字符串数组。
# 对上面的字典树进行深度优先遍历,遇到红色节点时输出单词,结果如下
a
aae
af
c
fz
fzc
fzd
# 我们可以看到,输出的结果正式按照字典序输出的,而且我们只需要进行一次深度优先遍历即可,因此,时间复杂度是O(n)
字典树的升华
我们之前学习二叉树时,曾经学习过这样的一个概念:
树的节点代表集合
树的边代表关系
那么,在字典树当中,我们怎么理解这个概念呢?
例如上图的红色箭头所指向的节点代表:“所有以f作为第一个字母的单词的集合(fz, fzc, fzd)”,而f
这条边则代表所有前缀是f
的单词。我们可以理解为:“字典树的根节点就代表全集,即整本字典”
字典树的常规代码实现
const BASE = 26;
const charCodeA = 'a'.charCodeAt(0);
// 字典树的节点结构
class TrieNode {
public flag: boolean;// 代表当前节点是否能够独立成词,即true为红色节点,false为白色节点
public nexts: TrieNode[] = new Array(BASE);// 每一条边代表26个字母中的一个,因此这个节点数组最大长度为26(假设我们的字典树只存储小写字母)
constructor() {
this.flag = false;
this.nexts.fill(null);
}
}
class Trie {
private root: TrieNode;
constructor() {
this.root = new TrieNode();
}
static clearTrieNode(root: TrieNode): void {
if(!root) return;
for(let i=0;i<BASE;i++) Trie.clearTrieNode(root.nexts[i]);
root = null;
}
/**
* 往字典树中插入一个单词,如果是第一次插入这个单词,则返回true,否则返回false
* @param word
*/
public insert(word: string): boolean {
let p = this.root;
for(let x of word) {
// 当前字母在0~25中的索引
const idx = x.charCodeAt(0) - charCodeA;
// 如果当前字母对应的索引下不存在节点,则新建一个节点
if(!p.nexts[idx]) p.nexts[idx] = new TrieNode();
// 让指针指向当前节点,继续下一个字母的插入
p = p.nexts[idx];
}
// 循环结束后,我们的p指针指向插入单词的最后一个字母的位置
// 如果flag为true,说明当前单词不是第一次插入,返回false
if(p.flag) return false;
// 如果flag为false,说明归档前单词是第一次插入,更新当前节点的flag,返回true
return (p.flag = true);
}
/**
* 查找目标单词在字典树中是否存在
* @param word
* @returns
*/
public search(word: string): boolean {
let p = this.root;
for(let x of word) {
const idx = x.charCodeAt(0) - charCodeA;
p = p.nexts[idx];
// 如果p不存在,说明目标单词在字典树中不存在
if(!p) return false;
}
// 循环结束后,说明,至少我们要查找的单词在字典树中是能找到的,但是,这个单词在字典树中是否是一个合法的独立单词
// 还得看一下flag是否为true
return p.flag;
}
/**
* 使用字典序排序
* @param root 根节点
* @param s 当前节点的前缀字符串,如hello的前缀为hell
* @param res 排序后的单词数组
* @returns
*/
private _sort(root: TrieNode, s: string, res: string[]): void {
if(!root) return;
if(root.flag) res.push(s);
for(let i=0;i<BASE;i++) {
this._sort(root.nexts[i], s + String.fromCharCode(i + charCodeA), res);
}
}
/**
* 使用深度优先遍历将字典树中的所有单词按照字典序输出
* @param root
* @param res
* @returns
*/
public sort(root: TrieNode = this.root, res: string[] = []): string[] {
this._sort(root, "", res);
return res;
}
}
const inserts: string[] = ['hello', 'world', 'kiner', 'kanger', 'twh'];
const trie = new Trie();
inserts.forEach(word => trie.insert(word));
console.log(`查找单词【name】结果:${trie.search('name')}`);
console.log(`查找单词【kiner】结果:${trie.search('kiner')}`);
console.log(`查找单词【hello】结果:${trie.search('hello')}`);
console.log(`查找单词【hell】结果:${trie.search('hell')}`);
console.log(`查找单词【000】结果:${trie.search('000')}`);
console.log(`按照字典序排序:${trie.sort()}`)
字典树的竞赛代码实现
相较于常规字典树而言,竞赛代码实现字典树的nexts
中不在存储每个节点的信息,而是存储节点的索引,能够节省一定的存储空间,之后学习的双数组字典树,也是以此为基础的。
// 与上面的常规字典树类不同,这里存储的nexts是节点索引的数组,而非具体的某个节点
const BASE = 26;
const charCodeA = "a".charCodeAt(0);
class TrieNode {
flag: boolean;
nexts: number[];
constructor() {
this.nexts = new Array(BASE);
this.clear();
}
clear() {
this.flag = false;
this.nexts.fill(0);
}
}
const tries: TrieNode[] = new Array(100);
for (let i = 0; i < 100; i++) {
tries[i] = new TrieNode();
}
let count, root;
function clearTrie() {
// 根节点索引
root = 1;
// 下一个可以操作的索引
count = 2;
tries.forEach((t) => t.clear());
}
/**
* 创建一个新节点
* @returns
*/
function getNewNode() {
// 重置最后一个节点状态
tries[count].clear();
// 返回新的可操作性节点的索引
return count++;
}
function insert(s: string): void {
let p = root;
for (let x of s) {
const idx = x.charCodeAt(0) - charCodeA;
// console.log(tries, p);
if (tries[p].nexts[idx] === 0) tries[p].nexts[idx] = getNewNode();
p = tries[p].nexts[idx];
}
tries[p].flag = true;
}
function search(s: string): boolean {
let p = root;
for (let x of s) {
const idx = x.charCodeAt(0) - charCodeA;
p = tries[p].nexts[idx];
if (!p) return false;
}
return tries[p].flag;
}
function _sort(root: number, s: string = "", res: string[] = []): void {
if(root === 0) return;
if(tries[root].flag) res.push(s);
for(let i=0;i<BASE;i++) {
_sort(tries[root].nexts[i], s + String.fromCharCode(i + charCodeA), res)
}
}
function sort(): string[] {
const res: string[] = [];
_sort(root, "", res);
return res;
}
clearTrie();
const inserts: string[] = ["hello", "world", "kiner", "kanger", "twh"];
inserts.forEach((s) => insert(s));
console.log(`查找单词【name】结果:${search("name")}`);
console.log(`查找单词【kiner】结果:${search("kiner")}`);
console.log(`查找单词【hello】结果:${search("hello")}`);
console.log(`查找单词【hell】结果:${search("hell")}`);
console.log(`查找单词【000】结果:${search("000")}`);
console.log(`字典序输出结果:${sort()}`);
双数组字典树
概念
本质上的逻辑结构还是跟字典树是一样的,只是换了一种信息的表示方法,就像罗马数字与阿拉伯数字的关系,本质上,他们表示的都是相同的东西,都是数字,但是他们的表示形式不一样。
作用
双数组字典树相较于普通字典树而言,占用的空间大小少很多,大概是普通字典树的十几分之一左右,因此,适合在大数据检索时使用。
除此之外,由于我们双数组字典树的关键信息都存储在两个数组中,而数组中的信息又是极易序列化输出到文件中的,虽然我们创建双数组字典树是比较耗时的操作,但一旦双数组字典树创建好之后,我们的检索操作的效率是相当高的。利用这样一个特点,我们可以在我们的集群服务器上去执行建立双数组字典树的操作,创建好后,将两个数组的信息序列化输出到文件并传输到用户终端,如手机上,这样,用户的手机就可以非常高效的进行检索了。
双数组
base数组
是特殊确定的一组值,我们可以通过父节点在base
数组的值和当前边的编号i
确定子节点的值
check数组
用于检测子节点的实际父节点的编号,解决同一个子节点同时存在两个父节点的问题
是否独立成词
因为check
数组中存储的是节点编号,那么里面的合法值一定是自然数,那么我们就可以用一个负数代表当前节点独立成词,即红色节点,用正数代表不独立成词,即白色节点。如某个节点的父节点编号为3
,而这个节点能够独立成词,则在check
数组中表示为-3
,若不能独立成词,则表示为3
。
代码演示
我们基于上面的竞赛版本代码为基础实现一个双数组字典树
const BASE = 26;
const charCodeA = "a".charCodeAt(0);
class TrieNode {
flag: boolean;
nexts: number[];
constructor() {
this.nexts = new Array(BASE);
this.clear();
}
clear() {
this.flag = false;
this.nexts.fill(0);
}
}
const maxCount = 100;
const tries: TrieNode[] = new Array(maxCount);
for (let i = 0; i < maxCount; i++) {
tries[i] = new TrieNode();
}
let count, root;
function clearTrie() {
// 根节点索引
root = 1;
// 下一个可以操作的索引
count = 2;
tries.forEach((t) => t.clear());
}
/**
* 创建一个新节点
* @returns
*/
function getNewNode() {
// 重置最后一个节点状态
tries[count].clear();
// 返回新的可操作性节点的索引
return count++;
}
function insert(s: string): void {
let p = root;
for (let x of s) {
const idx = x.charCodeAt(0) - charCodeA;
// console.log(tries, p);
if (tries[p].nexts[idx] === 0) tries[p].nexts[idx] = getNewNode();
p = tries[p].nexts[idx];
}
tries[p].flag = true;
}
function search(s: string): boolean {
let p = root;
for (let x of s) {
const idx = x.charCodeAt(0) - charCodeA;
p = tries[p].nexts[idx];
if (!p) return false;
}
return tries[p].flag;
}
function _sort(root: number, s: string = "", res: string[] = []): void {
if (root === 0) return;
if (tries[root].flag) res.push(s);
for (let i = 0; i < BASE; i++) {
_sort(tries[root].nexts[i], s + String.fromCharCode(i + charCodeA), res);
}
}
function sort(): string[] {
const res: string[] = [];
_sort(root, "", res);
return res;
}
const base: number[] = new Array(maxCount);
const check: number[] = new Array(maxCount);
base.fill(0);
check.fill(0);
let daRoot = 1;
/**
* 根据常规字典树根节点计算base值
* @description base值满足:base + i 在check中对应的值为0,即判断同一个子节点不能同时存在两个父节点的情况
* @param root 常规字典树根节点
* @param check 当前 check 数组
*/
function getBaseValue(root: number, check: number[]): number {
// b本次尝试的base值,初始为1,flag用于判断当前是否需要继续下一轮循环查找下一个合法base值,默认为需要,
// 进入循环后,我们将flag先设置为false,假设本轮能够找到我们想要的合法base值,当遍历完每个字母后发现无法找到想要的base
// 再将flag设为true,让程序再次进入循环匹配下一个base
let b = 1,
flag = true;
while (flag) {
flag = false;
// 每次累加base值
b++;
// 遍历每一个字符
for (let i = 0; i < BASE; i++) {
// 如果当前节点的第i条边是空的,就继续循环
if (tries[root].nexts[i] === 0) continue;
// 如果当前节点存在第i条边,我们还需要确定第i边对应的位置是空的,如果是空的,则当前边的b值是合法的,继续下一轮循环
if (!check[b + i]) continue;
// 如果当前节点的第i条边在check中对应的位置不是空的,说明出现了一个节点同时有两个父节点的情况,我们需要将flag置为true,重新尝试新的b值
flag = true;
break;
}
}
// 至此,我们就找到了一个合法的base值了
return b;
}
function convert2DoubleArrayTrie(
root: number,
daRoot: number,
base: number[],
check: number[]
): void {
// 如果原字典树的根节点是0,则直接退出
if (root === 0) return;
// 确定根节点的base值
base[daRoot] = getBaseValue(root, check);
// 让当前节点的所有子节点认祖归宗,在check中将这些子节点的父节点标记为当前的daRoot
for (let i = 0; i < BASE; i++) {
// 当前节点不存在第i条边,继续循环
if (tries[root].nexts[i] === 0) continue;
// base + i => 父节点编号,即daRoot
check[base[daRoot] + i] = daRoot;
// 标记当前节点是否独立成词,如果独立成词,就将父节点编号标记为负数
if (tries[tries[root].nexts[i]].flag) check[base[daRoot] + i] = -daRoot;
}
// 递归确定每一颗子树的base值
for(let i=0;i<BASE;i++) {
if(tries[root].nexts[i] === 0) continue;
convert2DoubleArrayTrie(tries[root].nexts[i], base[daRoot] + i, base, check)
}
}
function searchDaTrie(s: string): boolean {
// 让p指针不断的从字典树的根节点向下搜索
let p = daRoot;
for(let c of s) {
// 计算每个字母的编号
const idx = c.charCodeAt(0) - charCodeA;
// p能向下走的前提是我要去的那个节点是当前p节点的子节点(由于独立成词的节点是负数,因此还要取一个绝对值)
// 如果不是,说明不存在这个单词
// console.log(c, idx, check.slice(0,50), Math.abs(check[base[p] + idx]), p);
if(Math.abs(check[base[p] + idx]) !== p) return false;
// 如果是p,则让p跳到下一个字母
p = base[p] + idx;
}
// 循环结束时,必须确保当前节点能够独立成词,即当前节点对应的check的值是小于0的
return check[p] < 0;
}
clearTrie();
const inserts: string[] = ["hello", "world", "kiner", "kanger", "twh"];
inserts.forEach((s) => insert(s));
convert2DoubleArrayTrie(root, daRoot, base, check);
console.log(`常规字典树查找单词【name】结果:${search("name")},双数组字典树查找单词【name】结果:${searchDaTrie("name")}`);
console.log(`常规字典树查找单词【kiner】结果:${search("kiner")},双数组字典树查找单词【kiner】结果:${searchDaTrie("kiner")}`);
console.log(`常规字典树查找单词【hello】结果:${search("hello")},双数组字典树查找单词【hello】结果:${searchDaTrie("hello")}`);
console.log(`常规字典树查找单词【hell】结果:${search("hell")},双数组字典树查找单词【hell】结果:${searchDaTrie("hell")}`);
console.log(`常规字典树查找单词【dsa】结果:${search("dsa")},双数组字典树查找单词【dsa】结果:${searchDaTrie("dsa")}`);
console.log(`字典序输出结果:${sort()}`);