夜深人静写算法(七)- 字典树

一、前言

  我们在写代码的时候,经常会遇到记不住某个变量的名字,然后需要在一段代码里面通过 Ctrl + F 查询某个变量的情况,编辑器在查询这个变量的时候,用到的就是字符串匹配算法,优秀的字符串匹配算法非常多,其中传达的思想也颇为巧妙。
  相比 KMP、BM、RK 等字符串匹配算法,字典树相对容易理解,又是 AC自动机 的前置技能,而且编码相对简单,对于新手来说,实为一个不错的选择。所以,我打算把它作为字符串匹配算法的敲门砖,放在这一章来讲。
  那么,让我们开始吧!为了共同的愿景而努力!让天下没有难学的算法在这里插入图片描述

二、字典树的原理与实现

1、字典树简介

  • 字典树,又称 trie 树,是一种树形的数据结构。可以用作词频统计,利用字符串的公共前缀来减少查询时间。一般用来查找某个字符串 S 是否在一个字符串集合中。

【例题1】给定 n ( n < = 100000 ) n(n <= 100000) n(n<=100000) 个长度不超过 20 的字符串,字符串都由小写字母组成, m ( m < = 100000 ) m(m<=100000) m(m<=100000) 次询问,每次询问给出一个字符串,问这个字符串是否在之前给定的 n n n 个字符串中。

  • 这个问题,经常出现在一些网站的搜索引擎中,只不过它把实际问题的规模缩小了。如果把这个 n n n 个字符串存下来,每次询问都去这 n n n 个字符串里一一匹配查找,最坏时间复杂度为 O ( n w ) O(nw) O(nw),其中 w w w 为字符串的最大长度, m m m 次查询下来的总时间复杂度就是 O ( n m w ) O(nmw) O(nmw)。实际情况下,字符串的个数以及查询次数肯定远不止这些。
  • 利用字典树,就可以把每次的字符串查找时间控制在 O ( w ) O(w) O(w),从而大大降低了查询时间。由于 n n n 个字符串存在一个预处理建树的过程,建树的时间复杂度为 O ( n w ) O(nw) O(nw),所以整个过程的时间复杂度为 O ( n w + m w ) O(nw+mw) O(nw+mw)
  • 那么接下来,就让我们来见识下字典树是如何优化查询效率的。

2、字典树原理

1)字典树结构

  • 首先我们给出一个字符串集合,如下:

trie、tree、try、is、if、even、evening

  • 建立的字典树如图二-2-1所示:
    图二-2-1
  • 对这个图进行一个简单的介绍,如下:
  • 1)结点:为了方便对结点进行索引,每个结点都用一个数字标识;
  • 2)根结点:字典树的根结点只有一个,用数字 0 标识;
  • 3)树边:字典树的树边上是对应字符串的字母;
  • 4)结尾结点:结尾结点即图中的蓝色结点,用于标记一个完整字符串,即 从根结点到蓝色结点路径上的字母 所组成的字符串就是字符串集合中的字符串,这个例子中字符串集合有 7 个字符串,所以对应了 7 个蓝色结点。注意:结尾结点并不一定是叶子结点。

2)字符串插入

  • 对某个字符串插入字典树的过程,就是遍历这个字符串的过程。

插入算法实现如下:
  1)定义 当前结点 为字典树根结点 r o o t root root,遍历给定字符串 s s s
  2)对于字符串第 i i i 个字符 s [ i ] s[i] s[i],查询 当前结点 是否有 s [ i ] s[i] s[i] 这个子结点;
   2.a)如果不存在,则创建一个新结点;
   2.b)如果存在,不作处理;
  3)更新 当前结点 为原 当前结点 i i i 号子结点;
  4)遍历完毕字符串 s s s 后在 当前结点 打上一个标记,标识 结尾结点;

  • 如上面的例子,字典树初始只有一个根结点,第 1 个插入的字符串为 trie,所以得到的字典树是一个链表,如图二-2-2所示:
    图二-2-2
  • 当插入第 2 个字符串 tree 时,由于有公共前缀 tr 的存在,所以可以少创建两个结点,插入完毕以后如图二-2-3所示:
    图二-2-3
  • 当然,也可以表示成 图二-2-4 所示的树形结构。
    图二-2-4
  • 用同样方法,继续插入 try,如图二-2-5所示。
    图二-2-5
  • 当所有字符串都插入完毕,也就构造了一棵完整的字典树,每次插入过程的时间复杂度为 O ( w ) O(w) O(w) n n n 次插入的总时间复杂度为 O ( n w ) O(nw) O(nw)

3)字符串查询

  • 查询某个字符串是否存在,也是遍历这个字符串的过程。

查询算法实现如下:
  1)定义 当前结点 为字典树根结点 r o o t root root,遍历给定字符串 s s s
  2)对于字符串第 i i i 个字符 s [ i ] s[i] s[i],查询 当前结点 是否有 s [ i ] s[i] s[i] 这个子结点;
   2.a)如果不存在,则返回 false
   2.b)如果存在,不作处理;
  3)更新 当前结点 为原 当前结点 i i i 号子结点;
  4)遍历完毕字符串 s s s 后在 当前结点 上判断是否存在结尾标记,存在则返回 true,否则返回 false

  • 例如,在这棵字典树查找字符串 eval 时,从根结点开始查询字符 e e e,找到结点 11,从 11 的子结点中,查询字符 v v v,找到结点 12,但是,在 12 的子结点中找不到字符 a a a,所以字符串 eval 查询不到,如图二-2-6所示:
    在这里插入图片描述
    图二-2-6
  • 再如,查找字符串 eve 时,最后查询到结点 13,由于结点 13 没有结尾标记,所以表明 eve 只是某个字符串的前缀,并不是一个完整字符串,所以也是查询失败的;而如果查询的字符串为 even,由于最终到达的结点 14 是有结尾标记的,所以查询成功,如图二-2-7所示。
    图二-2-7

3、字典树实现

1)类结构设计

  • 定义两个类:字典树结点类 和 字典树类;

a. 字典树结点类

  • 字典树结点包含三个信息:结尾标记、前缀数量、子结点列表,定义如下:
const int TRIE_NODE_COUNT = 26;
const int TRIE_NODE_NULL = -1;

// 字典树结点类
class TrieNode {
    
    
private:
    bool isword_;                 // 是否是1个完整字符串
    int num_;                     // 有多少个单次经过这个结点
    int nodes_[TRIE_NODE_COUNT];  // 注意这里存的是结点内存池的下标,相比存指针的好处是:字节数少一半
public:
    inline void resetData(){
    
    
        num_ = 0;
        isword_ = false;
    }
    inline void reset() {
    
    
        resetData();
        memset(nodes_, TRIE_NODE_NULL, sizeof(nodes_));
    }
    // 接口部分暂时省略
    ...
};
  • isword_代表结尾标记,是一个布尔值,当插入一个完整的字符串后,在对应的结点的这个标记置上 true
  • 令从根结点到这个结点的路径上的字母组成的字符串为 s s s,那么 num_就代表了整个字典中有多少个以 s s s 为前缀的字符串;
  • nodes_[TRIE_NODE_COUNT]则是一个连续的数组,用来存储结点的每个子结点的编号,也可以认为是指向子结点的指针,之所以没有用 TrieNode*,是因为 int 占 4 个字节,相比指针(64位机器下指针是 8 个字节)省一半内存;
  • 提供一个初始化函数reset(),将结点默认到初始化状态;
  • 由于字符集是有限的,所以可以把字符集映射到下标,提供一个映射函数来实现字符到下标的转换:

小写字母的下标映射

typedef const char ValueType;
const int TRIE_NODE_COUNT = 26;
int trieNodeValueHash(ValueType v) {
    
    
    return v - 'a';
}

大写字母的下标映射

typedef const char ValueType;
const int TRIE_NODE_COUNT = 26;
int trieNodeValueHash(ValueType v) {
    
    
    return v - 'A';
}

大小写字母混合的下标映射

typedef const char ValueType;
const int TRIE_NODE_COUNT = 52;
int trieNodeValueHash(ValueType v) {
    
    
    if(v >= 'a' && v <= 'z') 
        return v - 'a';
    return v - 'A' + 26;
}

整数字符串的下标映射

typedef int ValueType;
const int TRIE_NODE_COUNT = 10;
int trieNodeValueHash(ValueType v) {
    
    
    return v - '0';
}
  • 如图二-3-1所示,字符集为 [ a , z ] [a,z] [a,z], 6 号结点的 a a a 子结点不存在,所以 nodes[0] = -1 b b b y y y z z z 子结点存在,且 nodes[1] = 7,nodes[24] = 15,nodes[25] = 20。并且 结点 7 和 结点 20 是某个字符串结尾,所以对应的 TrieNodeisword_字段为true
    图二-3-1

b. 字典树类

  • 字典树类存储了所有结点信息;
  • 为了避免每次创建结点的时候,从堆上申请内存,我实现的时候,采用一个简单的内存池,并且在类构造时就把内存申请好,申请结点的数量为:字符串数 × 最大字符串长度(TRIE_NODE_CACHES = TRIE_WORD_COUNT * TRIE_WORD_LENGTH)。 当然也可以采用类似 vector 的倍增动态扩容,有兴趣的读者可以自己实现一下,限于篇幅这里就不作展开了。
  • 尤其需要注意的是,如果不采用动态扩容的话,内存池必须预估好量,全程用到的结点数不能超过TRIE_NODE_CACHES,如果结点数大于内存池的大小,是会引起数组下标越界的,这会使得程序进入一个不确定态,是一定要避免的。
const int TRIE_WORD_COUNT = 400000;
const int TRIE_WORD_LENGTH = 10;
const int TRIE_NODE_CACHES = TRIE_WORD_COUNT * TRIE_WORD_LENGTH;

// 字典
树类
class TrieTree {
    
    
public:
    TrieTree(int nodeCacheCount = TRIE_NODE_CACHES);
    virtual ~TrieTree();
    void initialize() {
    
    
        nodeId_ = 0;
        root_ = genNode();
    }
    // 接口部分暂时省略
    ...
private:
    int genNode() {
    
    
        TrieNode *pkNode = &(nodes_[nodeId_]);
        pkNode->reset();
        return nodeId_++;
    }
private:
    int nodeId_;
    int root_;
    TrieNode *nodes_;
};

TrieTree::TrieTree(int nodeCacheCount = TRIE_NODE_CACHES) : nodes_(NULL), 
                                                  root_(0), nodeId_(0) {
    
    
    nodes_ = new TrieNode[nodeCacheCount];
}
TrieTree::~TrieTree() {
    
    
    if (nodes_) {
    
    
        delete[] nodes_;
    }
}
  • nodes_指针存储了所有将要用到的字典树结点的内存首地址,在类构造的时候在堆上申请空间;
  • nodeId_则代表了目前已经用掉的结点数,每次调用genNode()返回一个已经初始化好的字典树结点;

2)接口设计

a. 字典树的插入

  • 字典树的插入在上文已有详细的算法描述,这里先给出代码,然后再来对代码进行解释。
typedef const char ValueType;
void TrieTree::insert_word(int vSize, ValueType v[]) {
    
        // 1
    TrieNode *pkNow = root();
    for (int i = 0; i < vSize; ++i) {
    
    
        int nodeIdx = trieNodeValueHash(v[i]);            // 2
        checkNode(pkNow, nodeIdx);                        // 3
        pkNow = node(pkNow->getNode(nodeIdx));            // 4
        pkNow->addNum(1);                                 // 5
    }
    pkNow->setWord(true);                                 // 6
}
  • 1)ValueType由于插入的数据类型不一,所以这里用typedef来定义;
  • 2)trieNodeValueHash用于将原本是非整数类型(或非连续)的数据类型,转换成下标,方便在父结点获取子结点时能通过 O ( 1 ) O(1) O(1) 的时间复杂度内获取到;
  • 3)checkNode用于检查当前结点是否存在nodeIdx这个子结点,不存在则创建1个,实现如下:
void TrieTree::checkNode(TrieNode *pkNow, int nodeIdx) {
    
    
    if (!pkNow->hasNode(nodeIdx)) {
    
    
        pkNow->setNode(nodeIdx, genNode());
    }
}
  • 4)然后将当前结点指针指向nodeIdx子结点;
  • 5)addNum(1)代表前缀标记自增 1;
  • 6)setWord(true)给最后遍历到的结点置上结尾标记;

b. 字典树的查询

  • 字典树的查询在上文已有详细的算法描述,这里先给出代码,然后再来对代码进行解释。
bool TrieTree::query_word(int vSize, ValueType v[]) {
    
    
    TrieNode *pkNow = root();
    for (int i = 0; i < vSize; ++i) {
    
    
        int nodeIdx = trieNodeValueHash(v[i]);
        if (!hasNode(pkNow, nodeIdx)) {
    
    
            return false;
        }
        pkNow = node(pkNow->getNode(nodeIdx));
    }
    return pkNow->isWord();
}
  • 基本和插入算法如出一辙,有两个区别:
  • 1)遍历查询子结点时,如果没有结点,则直接返回 false,不进行创建;
  • 2)遍历完字符串以后,是有返回值的,返回结尾结点的 isword_标记;

三、字典树的应用与扩展

1、前缀查询

【例题2】给定 n ( n < = 1 0 5 ) n(n<=10^5) n(n<=105) 个长度不超过 20 的小写字母组成的字符串。再给出 m ( m < = 1 0 5 ) m(m<=10^5) m(m<=105) 次询问,每次询问一个字符串 s s s,求出以 s s s 为前缀的字符串有多少个。

  • 由于字典树每个结点都记录了有多少个字符串经过这个结点,即上文提到的num_字段,所以只需要遍历字符串 s s s,然后在结尾的结点获取 num_的值,就是以 s s s 为前缀的字符串数量。

2、最短前缀表示

【例题3】给定 n ( n < = 1000 ) n(n<=1000) n(n<=1000) 个长度不超过 20 的小写字母组成的不同字符串,要求给出每个字符串的最短前缀表示,并且不产生冲突(当两个字符串拥有一个公共前缀 s s s 时就会产生冲突, s s s 不能作为两者的最短前缀表示)。

  • 问题可以转化成:对于每个字符串,都要找到这个字符串的一个前缀,使得其它字符串都没有这个前缀,并且使得前缀尽量短。那么对于字符串 s s s,只需要找到 s s s 的某个前缀,使得前缀最后一个字符对应到字典树所在结点上的 num_为 1 即可,对每个字符串进行遍历字典树的过程。
  • 如图三-2-1所示,橙色数字代表了结点上num_的值。值为 1 的结点,从根结点到这个结点表示的字符串一定可以唯一表示某个字符串。
    图三-2-1

3、删除字符串

  • 字符串的删除操作正好是插入操作的逆操作,还是遍历访问字符串,在访问到的结点将 num_值减 1 即可。如果能够将字符串都遍历完毕,则在最后一个结点将 isword_标记置成false

4、删除前缀

【例题4】度熊手上有一本神奇的字典,你可以在它里面做如下三个操作:
  1)insert : 往神奇字典中插入一个单词
  2)delete: 在神奇字典中删除所有前缀等于给定字符串的单词
  3)search: 查询是否在神奇字典中有一个字符串的前缀等于给定的字符串。

  • 删除给定前缀的字符串,需要考虑这个前缀在字典树中本身就不存在的情况;
  • 首先询问这个字符串是否在字典树存在,并且返回这个字符串的num_计数,然后通过遍历字符串在字典树中将所到路径上的计数都减去,当某个结点计数减到0,说明以这个字符串为前缀的字符串已经不存在了,直接将子结点置成 -1;

5、集合前缀

【例题5】询问一个不重复的字符串集合中,是否有一个串是另一个串的前缀。

  • 很好的利用了前缀树的概念,先将所有字符串插入字典树,然后枚举每个字符串,对每个字符串进行遍历:
  • 1)如果在叶子节点发现 结尾标记,表示是它自己,返回 true
  • 2)如果在内部结点发现 结尾标记,说明树上某个字符串是当前枚举的字符串的前缀,返回 false
  • 枚举完所有字符串,一旦出现一次 2) 的情况,表明这个集合中存在有一个串是另一个串的前缀。

6、离线算法

【例题6】给出一个 500 × 500 500 \times 500 500×500 的大写字母矩阵 R,再给出 10000 次询问,每次询问一个长度 20 的字符串是否在 R 中出现过(出现过的意思是字符串在矩阵中能够通过横向、竖向、斜向找到),并且返回最小的起始下标。

  • 离线算法在很多场合都有应用,之前讲 并查集、 RMQ 的时候也频频出现了。
  • 对于这个问题,我们简单分析一下,如果用字母矩阵来建立字典树,总的结点数为 500 ∗ 500 ∗ 20 ∗ 3 = 1.5 ∗ 1 0 7 500 * 500 * 20 * 3 = 1.5 * 10^7 500500203=1.5107,内存太大。
  • 那么我们可以将数据全部读入以后,对后面给出的那 10000 个字符串来建立字典树,然后在前面的矩阵中进行询问,这样字典树的结点数只需要 2 ∗ 1 0 5 2 * 10^5 2105

7、模糊匹配

【例题7】一个单词全部由小写字母组成,而一个模式由小写字母、?、* 组成。一个 ? 匹配单个小写字母, 一个 * 匹配零个或多个小写字母,给定 n ( n < = 100000 ) n(n <= 100000) n(n<=100000) 个模式串, m ( m < = 100 ) m(m<=100) m(m<=100) 个单词,问每个单词匹配哪几个模式串。

  • 对所有的模式串建立字典树,下标映射函数实现如下:
typedef const char ValueType;
const int TRIE_NODE_COUNT = 26;
int trieNodeValueHash(ValueType v) {
    
    
    if (v == '?') return 26;
    if (v == '*') return 27;
    return v - 'a';
}
  • 然后对于每个单词,在字典树上进行深度优先搜索即可。

8、集合最大异或

【例题8】 给定 N N N 个数 ( N < = 100000 ) (N <=100000) (N<=100000) M ( M < = 100000 ) M (M <= 100000) M(M<=100000) 次询问,每次询问给出一个正整数 S, 在 N N N 个数 中寻找一个数 K ,使得 K 异或 S 的值最大;所有整数不超过 2 32 2^{32} 232

  • 1) N N N 个数都转换成二进制 01 01 01 字符串,并且用前导零补齐到 32 32 32 位比特位,执行字典树的插入操作构建一棵 01 01 01 字典树 (为了把问题描述简单,可以把这里的 32 改小,比如所有数的二进制位最高位 3 位,即数字不超过 2 3 2^{3} 23);
  • 那么 1 、 4 、 3 、 7 1、4、3、7 1437 这四个数就可以构建 001、100、011、111 的字典树;
    在这里插入图片描述
    图三-8-1
  • 2)对于询问的数 S,从最高位开始,如果它的位为1,那么根据异或的性质,要求的 K 对应的二进制位等于0的时候一定比等于1的时候更优(因为是从高位开始计算的);反之,如果这位等于0 则相反;那么在原字典树中从根节点遍历到叶子节点,如果发现某个最优子节点没有则被迫选择另一个子节点;
  • 3)需要最多的节点数统计;考虑如果 N 个数每个数都没有公共前缀,那么每个数字必然占了 32 个节点,所以最大节点数就是 N * 32(静态节点开辟的时候如果空间开小了可能导致 TLE 超时);

9、树的异或最长路

【例题9】一棵 n ( 1 < = n < = 100000 ) n(1<=n<=100000) n(1<=n<=100000) 个结点的树,定义两者之间的路径长度为路径上所有边权值的异或和,求一条最长异或和路径。图中最长异或和路径为: 6 → 7 → 8 6 \to 7\to 8 678,为 9   x o r   6 = 15 9 \ xor \ 6 = 15 9 xor 6=15

  • 题目给出的是一颗无根树,先确定一个根 r r r,转换成有根树。程序实现的时候,可以令 r = 0 r = 0 r=0 作为根。
  • 根据树的定义,任意两个点之间有且仅有一条路径,所以,对于任意一条路径 u → v u \to v uv,只要确定两个端点,它们之间的异或和也就确定了。定义 u → v u \to v uv 路径上所有边的异或和为: f ( u , v ) f(u,v) f(u,v)
  • 再根据异或的特性,任意两个相同数的异或和为零,所以 u → v u \to v uv 的异或和 等于 u → r u \to r ur r → v r \to v rv 的异或和,即:
    f ( u , v ) = f ( u , r )   x o r   f ( r , v ) = f ( r , u )   x o r   f ( r , v ) \begin{aligned}f(u,v) &= f(u, r) \ xor \ f(r, v) \\ &= f(r, u) \ xor \ f(r, v) \end{aligned} f(u,v)=f(u,r) xor f(r,v)=f(r,u) xor f(r,v)
  • 根据以上特性,从根结点开始一次性遍历所有结点,每遍历到一个结点 x x x 计算异或和 f ( r , x ) f(r, x) f(r,x),总的时间复杂度为 O ( n ) O(n) O(n)
  • 然后问题就转换成了:从这些 f ( r , x ) ( 0 < = x < n ) f(r, x) (0 <= x < n) f(r,x)(0<=x<n) 选出两个值 u , v u,v u,v,使得他们的异或 f ( r , u )   x o r   f ( r , v ) f(r, u) \ xor \ f(r, v) f(r,u) xor f(r,v) 最大。
  • 转换成上面 【例题8】 的字典树求解。



四、字典树题集整理

题目链接 难度 解法
HDU 2072 单词数 ★☆☆☆☆ 字典树 模板
PKU 2418 Hardwood Species ★☆☆☆☆ 字典树 模板
PKU 2945 Find the Clones ★☆☆☆☆ 字典树 模板
PKU 1002 487-3279 ★☆☆☆☆ 字典树 模板
HDU 1800 Flying to the Mars ★☆☆☆☆ 字典树 模板
PKU 2503 Babelfish ★☆☆☆☆ 字典树 的 字符串映射
HDU 1075 What Are You Talking About ★☆☆☆☆ 字典树 的 字符串映射
HDU 1251 统计难题 ★☆☆☆☆ 【例题2】字典树 的 前缀统计
HDU 1298 T9 ★★☆☆☆ 字典树 + 深搜
PKU 2513 Colored Sticks ★★☆☆☆ 字典树+欧拉回路判定
HDU 1305 Immediate Decodability ★★☆☆☆ 【例题5】字典树 的 前缀性质
HDU 1671 Phone List ★★☆☆☆ 字典树 的 前缀性质
HDU 3724 Encoded Barcodes ★★☆☆☆ 字典树 的 前缀性质
PKU 3193 Cow Phrasebook ★★☆☆☆ 字典树 的 前缀性质
PKU 2001 Shortest Prefixes ★★☆☆☆ 【例题3】字典树 的 最短前缀表示
HDU 1247 Hat’s Words ★★☆☆☆ 字典树 + 深搜
HDU 4287 Intelligent IME ★★☆☆☆ 字典树 + 深搜
PKU 1816 Wild Words ★★★☆☆ 【例题7】字典树 + 深搜
HDU 2846 Repository ★★★☆☆ 字典树 的 离线算法
HDU 1857 Word Puzzle ★★★☆☆ 【例题6】字典树 的 离线算法
HDU 5536 Chip Factory ★★★☆☆ 字典树 + 异或最大值
HDU 4825 Xor Sum ★★★☆☆ 【例题8】字典树 + 异或最大值
PKU 3764 The xor-longest Path ★★★☆☆ 字【例题9】典树 + 异或最大值
HDU 4099 Revenge of Fibonacci ★★★☆☆ 字典树 + 高精度
HDU 5687 Problem C ★★★☆☆ 【例题4】字典树 的 前缀删除
HDU 6059 Kanade’s trio ★★★★☆ 字典树 + 数位统计
HDU 6625 three arrays ★★★★☆ 字典树 + 数位统计
HDU 3460 Ancient Printer ★★★★☆ 字典树 + 树形DP

猜你喜欢

转载自blog.csdn.net/WhereIsHeroFrom/article/details/112271312