文章目录
一、前言
我们在写代码的时候,经常会遇到记不住某个变量的名字,然后需要在一段代码里面通过 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 是某个字符串结尾,所以对应的TrieNode
的isword_
字段为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 500∗500∗20∗3=1.5∗107,内存太大。
- 那么我们可以将数据全部读入以后,对后面给出的那 10000 个字符串来建立字典树,然后在前面的矩阵中进行询问,这样字典树的结点数只需要 2 ∗ 1 0 5 2 * 10^5 2∗105。
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 1、4、3、7 这四个数就可以构建 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 6→7→8,为 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 u→v,只要确定两个端点,它们之间的异或和也就确定了。定义 u → v u \to v u→v 路径上所有边的异或和为: f ( u , v ) f(u,v) f(u,v)。
- 再根据异或的特性,任意两个相同数的异或和为零,所以 u → v u \to v u→v 的异或和 等于 u → r u \to r u→r 和 r → v r \to v r→v 的异或和,即:
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】 的字典树求解。
- 本文所有示例代码均可在以下 github 上找到:github.com/WhereIsHeroFrom/模板/字符串/字典树