22.1-字典树与双数组字典树(数据结构基础篇)

49 阅读6分钟

22_1-字典树与双数组字典树(数据结构基础篇)

字典树

概念

就像我们平时使用字典查找单词,我们首先按照首字母在字典中找到对应的位置,然后,再依次按照第二、第三...个字母依次缩小范围,最后找到这个单词的准确位置。我们可以发现,如果按照这样的方式查找,画成一张图的话是这样的:

image-20211107111555661

字典树又叫(单词查找树Trie),作用是:单词查找,字符串排序

单词查找

通常,我们将字典树的每一个边代表一个字母。而每个节点有两种颜色,一种颜色是白色,代表当前查找的单词在我们的字典中不存在,另一种颜色是红色,代表当前要查找的单词在我们的词典中是存在的。

image-20211107111555661

字符串排序

使用字典树进行字典树排序,时间复杂度是O(n)

我们只需要对我们的字典树进行深度优先遍历(DFS),在遍历到每一个红色节点时输出单词,最终所得到的字符串数组就是按照字典序排序好的字符串数组。

# 对上面的字典树进行深度优先遍历,遇到红色节点时输出单词,结果如下
a
aae
af
c
fz
fzc
fzd
# 我们可以看到,输出的结果正式按照字典序输出的,而且我们只需要进行一次深度优先遍历即可,因此,时间复杂度是O(n)

字典树的升华

我们之前学习二叉树时,曾经学习过这样的一个概念:

树的节点代表集合

树的边代表关系

那么,在字典树当中,我们怎么理解这个概念呢?

image-20211107124932871

例如上图的红色箭头所指向的节点代表:“所有以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()}`);