查找第一期 - 单词查找树

527 阅读11分钟

查找第一期 - 单词查找树

简介

单词查找树又称”字典树“、”Trie 树“,是一种树形结构。单词查找树示意图如下所示

单词查找树的作用

单词的快速查找(不然为啥叫单词查找树嘛),当然也可以在单词查找的基础上进行扩展,例如进行词频统计存取数据等等,这个会在后面详细介绍

单词查找树的特性

  1. 根节点不存在字符,其他每个节点只存在一个字符。其根节点的存在只是为了简化查询操作,并无特殊意义
  2. 从根节点到某个节点的路径连接起来时,就是一个完整的字符串 如上图所示,整个路径连一起为字符串 "tree"
  3. 每个节点的子节点所包含在字符唯一。不会出现一下情况
  4. 每个节点都存在 R 条链接,R 为字符的范围。举个例子,如果是 ASCII 编码,则 R 为 256。R 向单词查找树可能会存在很多的空键,所以在绘制时一般忽略空键。示意图如下所示

字母表的大小为 R,在一棵由 N 个键构造的单词查找树中,未命中查找平均所需检查的数量为~,链接总数在 RN 到 RNw 之间(w 为键的平均长度),所以 R 向单词查找树适用于字母范围和键长比较小的情况(《算法 4》有证明)

单词查找树优势

  1. 查找命中所需要的时间与查找键的长度成正比
  2. 查找未命中只需要查找若干个字符,M 为未命中所需要查询的次数(M<=L),未命中平均查找字符数为 《算法 4 有证明》

如上图所示,查询字符串 "te" 时,在 t 节点的子节点匹配失败,无法匹配 "e"

单词查找树与其他查找方式比较

和遍历相比

引入一个新华词典查找汉字 ”树“ 的例子

  1. 第一种查询方式:从新华词典第一个页开始翻,每一页看下是否存在“树”,直到第 n 页后,查找到”树“
  2. 第二种查询方式:”树“拼音为”shu“
    • 先在字典目录中查询”s“的页码,然后翻到对应的页码
    • 再根据”h“翻到对应的页码;
    • 再根据“u”翻到对应的页码
    • 最后根据“shu”拼音确认一个大概的范围,再在这个范围内寻找“树”

第一种方式类比遍历,第二种方式类比单词查找树。无需多说,第一个方式比第二种方式慢太多了。如果想查询个“字典树”这三个字,按照第一种方式估计得半天时间

和散列表相比

单纯的对于字符串查询,散列表可以做到 O(1),难道还有比 O(1)更快的算法?那单词查找树还能有啥优势?

其实散列表相较于单词查找树存在两个弊端

  1. 空间占用:散列表需要显示得存储每个字符,而单词查找树可以压缩空间。单词查找树对于相同前缀的字符串,是存储到一起的,例如 tree,和 trie,tr 占用同一个空间。而散列表需要分别存储 tree 和 trie
  2. 前缀匹配:散列表无法查询前缀。例如我现在存储的是 "tree"和"trie",但是我想查询的是 “是否包含"tr*"的字符”,此时散列表就无能为力了

单词查找树代码实现

此处实现代码,假设所有字符为英文小写字母,即 R=26

定义单词查找树方法

class TrieTree {
  match(str: string): boolean; // 查询字符串是否存在与单词查找树中
  insert(str: string): void; // 插入
  delete(str: string): void; // 删除
}

定义树节点

class TrieTreeNode {
  value: number;
  next: (TrieTreeNode | null)[];

  constructor(value: number, R: number) {
    this.value = value;
    this.next = new Array(R).fill(null);
  }
}

这里值得一提的是,既然我们都已经知道了 R 向单词查找树的特性每个节点都含有 R 条链接,那我们这里为什么不直接使用数组来存储呢?例如如下存储方式

// 为了展示方便,R就不取26了,实在是太长了,假设R=5
root = [null, null, null, null, null];

// 当存储 "ab" 时
const root = [
  [null, [null, null, null, null, null], null, null, null],
  null,
  null,
  null,
  null,
];

由于 root[0] !== null,则表示 "a" 字符存在,root[0][1] !== null,则表示 "a" 字符下 "b" 字符存在,合一起则为 "ab"

那为什么不用这种方式存储呢?这里需要考虑到一个问题,就是删除操作。举个例子

我们看向这该单词查找树右侧,假设右侧存储 "tr","tree","trie" 字符串,此时我想删除 "tr",既然要删除 "tr" 那么必然的需要删除掉,t 节点、r 节点,但是删除 t、r 节点的后果就是再也无法访问到后续的 e 节点和 i 节点,表示 e 节点和 i 节点也莫名其妙被删除了,这显然是错误的

所以,我们需要存储一个表示当前节点是否可用,所以使用 value 来存储当前节点的频率,当此节点频率为 0 时,则表示此节点是不存在的,之所以未删除是为了后续节点可以正常访问

初始化

class TrieTree {
  private root: TrieTreeNode;
  private readonly R: number;

  constructor(R: number) {
    this.R = R;
    this.root = new TrieTreeNode(0, this.R);
  }

  static getCharCodeBaseA(s: string): number {
    return s.charCodeAt(0) - 97;
  }
}

查找(匹配)

单词查找树的每个节点都存储了,下一个节点所包含的所有可能的字符链接。拿 TrieTreeNode 来说,next 存储了下一个节点所有可能存在的字符

在查询时,可能遇到如下情况

  1. 节点 next 不包含字符,则此次为非命中查询,直接结束,为”不匹配“。例如查询 "te","t"可以匹配,但"e"无法匹配
  2. 节点 next 包含字符,则此次为命中查询,继续下一次查询。例如查询 "te","t" 匹配,还需要继续匹配 "e"
  3. 键遍历完成,结束查询。例如查询 "tr","t、r" 都可以匹配
  4. 完成查询后,如果当前节点频率为 0,则为”不匹配“,否则为”匹配“
class TrieTree {
  ...
  match(key: string): boolean {
    let i: number = 0;
    let node: TrieTreeNode = this.root;

    while (i < key.length) {
      const code: number = TrieTree.getCharCodeBaseA(key[ i ]);
      if (node.next[ code ] === null) {
        return false;
      }

      node = node.next[ code ]!;
      i++;
    }
    return node.value > 0;
  }
}

插入

在插入时,首先也是进行查找操作,可能出现如下情况

  1. 插入时,单词查找树不存在该字符,则需要创建一个新的节点来保存该字符,此时词频为 1
  2. 插入时,单词查找树中已经存在此字符,则进入下一步,此时词频+1
class TrieTree {
  ...
   insert(key: string): void {
    let i: number = 0;
    let node: TrieTreeNode = this.root;

    while (i < key.length) {
      const code: number = TrieTree.getCharCodeBaseA(key[ i ]);
      const nextNode: TrieTreeNode | null = node.next[ code ];

      if (nextNode === null) {
        node = node.next[ code ] = new TrieTreeNode(1, this.R);
      } else {
        nextNode.value++;
        node = nextNode;
      }
      i++;
    }
  }
}

删除

删除时,可能出现如下情况

  1. 字符串无法匹配,则表示字符串不存在于该单词查找树,则不做任何操作
  2. 字符串匹配
    • 词频为 0,则表示字符串不存在于单词查找树,则不做任何操作
    • 词频大于 0,则涉及到所有字符的词频减 1
class TrieTree {
  ...
  delete(key: string): void {
    this.del(key, 0, this.root);
  }

  private del(key: string, i: number, node: TrieTreeNode): boolean {
    if (i === key.length) {
      return true;
    }

    const code: number = TrieTree.getCharCodeBaseA(key[ i ]);
    const nextNode: TrieTreeNode | null = node.next[ code ];

    if (nextNode !== null && this.del(key, i + 1, nextNode)) {
      nextNode.value--;
      return true;
    }

    return false;
  }
}

扩展 1-词频率统计

统计字符出现的频率,可能出现以下情况

  1. 查询字符不匹配,则词频为 0
  2. 查询字符匹配,则返回存储的值
class TrieTree {
  private getTreeNode(key: string): TrieTreeNode | null {
    let i: number = 0;
    let node: TrieTreeNode = this.root;

    while (i < key.length) {
      const code: number = TrieTree.getCharCodeBaseA(key[i]);
      if (node.next[code] === null) {
        return null;
      }
      i++;
      node = node.next[code]!;
    }
    return node;
  }

  getRank(key: string): number {
    const node: TrieTreeNode | null = this.getTreeNode(key);
    if (node === null) return 0;
    return node.value;
  }
}

扩展 2-存取数据

有一个需求,要求实现使用单词查找树存储如下数据{ name: "Tom",age: 24 },并且可以根据 key 值取出对应的值,表示为 [[Set]]("name","Tom"); [[GET]]("name") === "Tom"

我们看向 TrieTreeNode

class TrieTreeNode {
  value: number;
  next: (TrieTreeNode | null)[];

  constructor(value: number, R: number) {
    this.value = value;
    this.next = new Array(R).fill(null);
  }
}

我们原来是将 value 用来存储词频,那同理我们也可以使用 value 来存储值,我们对 TrieTreeNode 扩展

class TrieTreeNode {
  value: any; // 存储任意值
  next: (TrieTreeNode | null)[];

  constructor(value: number, R: number) {
    this.value = value;
    this.next = new Array(R).fill(null);
  }
}

存取数据实现

class TrieTree {
  ...
  // [[SET]]
  setValue(key: string, value: any): void {
    let i: number = 0;
    let node: TrieTreeNode = this.root;

    while (i < key.length) {
      const code: number = TrieTree.getCharCodeBaseA(key[i]);
      const nextNode: TrieTreeNode | null = node.next[code];

      if (nextNode === null) {
        node = node.next[code] = new TrieTreeNode(null, this.R);
      } else {
        node = nextNode;
      }
      i++;
    }
    node.value = value;
  }

  // [[GET]]
  getValue(key: string): any | null {
    const node: TrieTreeNode | null = this.getTreeNode(key);
    return node === null ? null : node.value;
  }

  // [[DELETE]]
  deleteValue(key: string): void {
    const node: TrieTreeNode | null = this.getTreeNode(key);
    if (node === null) return;
    node.value = null;
  }
}

三向单词查找树

现在又有另一个需求,我想快速查询的不是英文字符了,而是 某个地区的人名称。根据百度得中文常用字为 3500 字(不考虑生僻字哈),如果按照 R 向单词查找树的话,R=3500,假设 w=3,这将是巨大的空间消耗

由于 R 向单词树对于长键和大字符范围较大的情况下,空间消耗比较大,如果你能满足如此大的空间消耗,则它的性能是极好的。那么有没有一种性能较好,但空间消耗不是这么夸张的呢?接下来让我们介绍 三向单词查找树(TST)

三向单词查找树的一个节点包含一个键一个值三个链小于该键的左链,等于该键的中链,大于该键的右链

如上图所示的三向单词查找树,举个例子查找 "cn"

  1. 匹配"c":"c" 与 c 节点的 KEY 匹配,则进入 c 节点的中链
  2. 匹配 "n":"n" 与 o 节点 KEY 不匹配,且"n" < "o",则进入 o 节点左链
  3. 匹配"n":"n"与 n 节点 KEY 匹配,则 "cn"匹配完成,获取到 VALUE 为 "google"

三向单词查找树的特性

  1. 三向单词查找树最重要的性质就是每个节点只含三个链,因此它的空间远小于单词查找树
  2. 树的节点表示取决于键的插入顺序(R 向单词查找树则与键插入顺序无关)
  3. 根节点也包含字符了
  4. 查找成本,由 N 个随机字符串构造的三向单词查找树,未命中平均需要查找~,除此外一次插入或命中查找会比较一次键的所有字符《算法 4 证明》

三向单词查找树实现

定义三向单词查找树的方法

class ThreeTrieTree {
  getValue(key: string): any | null;
  setValue(key: string, value: any): void;
  deleteValue(key: string): void;
}

根据三向单词查找树特性定义树节点

class ThreeTrieTreeNode {
  key: string;
  value: any;
  left: ThreeTrieTreeNode | null = null;
  mid: ThreeTrieTreeNode | null = null;
  right: ThreeTrieTreeNode | null = null;

  constructor(key: string, value: any) {
    this.key = key;
    this.value = value;
  }
}

初始化

class ThreeTrieTree {
  private root: ThreeTrieTreeNode | null;

  constructor() {
    this.root = null;
  }
}

查找

查找时可能出现如下情况

  1. 节点为 null 时,匹配结束,未查找到结果
  2. 节点不为 null,
    • 字符等于节点 KEY,则进入中链,继续比较
    • 字符小于节点 KEY,则进入左链,继续比较
    • 字符大于节点 KEY,则进入右链,继续比较
  3. 当字符遍历完成且节点不为 null,则查找到结果
class ThreeTrieTree {
  ...
  getValue(key: string): any | null {
    const node: ThreeTrieTreeNode | null = this.getTreeNode(key);
    return node === null ? null : node.value;
  }

  private getTreeNode(key: string): ThreeTrieTreeNode | null {
    let i: number = 0;
    let node: ThreeTrieTreeNode | null = this.root;

    while (i < key.length - 1) {
      if (node === null) return null;

      if (key[i] === node.key) {
        node = node.mid!;
        i++;
      } else if (key[i] < node.key) {
        // 进入left
        node = node.left!;
      } else {
        // 进入 right
        node = node.right;
      }
    }
    return node;
  }
}

插入

插入也是需要先查找

  1. 当节点为 null,新建节点,并进入新节点的中键
  2. 节点不为 null
    • 字符等于节点 KEY,则进入中链,继续比较
    • 字符小于节点 KEY,则进入左链,继续比较
    • 字符大于节点 KEY,则进入右链,继续比较
  3. 当键遍历完成后,则设置当前节点的值
class ThreeTrieTree {

  ...
  setValue(key: string, value: any): void {
    this.root = this.setTreeNode(this.root, key, 0, value);
  }

  private setTreeNode(node: ThreeTrieTreeNode | null, key: string, i: number, value: any): any {
    if (node === null) {
      node = new ThreeTrieTreeNode(key[ i ], null);
    }

    if (i === key.length - 1) {
      // 此时匹配完成了,该赋值了
      node.value = value;
      return node;
    }

    if (key[ i ] === node.key) {
      node.mid = this.setTreeNode(node.mid, key, i + 1, value);
    } else if (key[ i ] > node.key) {
      node.right = this.setTreeNode(node.right, key, i, value);
    } else {
      node.left = this.setTreeNode(node.left, key, i, value);
    }
    return node;
  }
}

删除

删除也是需要先查找

  1. 当查询出的节点为 null,则不做任何操作
  2. 当查询处的节点不为 null,则清除该节点的值
class ThreeTrieTree {
  deleteValue(key: string): void {
    const node: ThreeTrieTreeNode | null = this.getTreeNode(key);
    if (node !== null) {
      node.value = null;
    }
  }
}

本文使用 mdnice 排版