MENU

TRIE(3)

July 7, 2018 • Read: 3555 • 算法阅读设置

例 2 题目链接:hihoCoder1107

搜索引擎现在一般都有关键词提示或者说是补全功能。就是当你在搜索框里输入一个关键词 s 时,搜索引擎会自动提示你一些频率比较高,同时前缀是 s 的关键词

这道题的大意就是给定你 N 个高频的查询字符串。然后题目定义如果一个字符串 s 满足,有不少于 5 个高频字符串是以 s 为前缀的,那么我们就称 s 是 “合适的前缀”。同时如果一个 “合适的前缀” s,删掉 s 的最后一个字符之后就不是 “合适的前缀” 了,那我们就称 s 是 “最短的合适前缀”。最后题目问你对于给定 N 个高频字符串,一共有几个 “最短的合适前缀”

举个例子,假如高频的字符串是如下 12 个:a ab abc abcde abcde abcba bcd bcde bcbbd bcac bee bbb,那么 “最短的合适前缀” 一共有 4 个,是 ab bb bc be。需要注意一点是,样例中故意给了两个一样的字符串 abcde,提醒你需要处理输入中有重复字符串的情况

首先我们看一下为什么 ab 是 “最短的合适前缀”。以 ab 为前缀的字符串有 ab abc abcde abcde abcba 5 个,这里 abcde 要算 2 次;而以 a 为前缀的字符串有 6 个,多了一个 a。所以 ab 砍掉 b 之后就不是合适的前缀了,所以 ab 是一个 “最短的合适前缀”

同理以 b 为前缀的高频字符串有 6 个,所以 b 不是合适的;但是 bb,bc,be 都是合适的,所以 bb bc be 也都是 “最短的合适前缀”

通过对样例的分析,我们可以发现:如果我们用所有高频字符串构造 Trie,那么找 “最短的合适前缀” 其实就是找一个节点 p,满足以 p 为根的子树中的终结点不多于 5 个,同时以 p 的父节点为根的子树中的终结点大于 5

而关于计算 Trie 的一个子树中终结点的数目,我们在上一节已经做过这样的题目了。方法是用一个 cnt 数组(int cnt [MAX_NODE])在插入字符串的时候把沿途的节点 cnt 都加一。等所有高频字符串都插入完成之后,遍历 trie 中的每一个节点,看有几个节点 p 满足 cnt [p]<=5 且 cnt [p.father]>5

其中遍历 trie 可以用之前讲的 dfs 算法,整个算法的伪代码如下:

  • Insert(W):
  • P = root
  • Cnt[p]++
  • For i = 1...W.len
  • If p.thru(W[i]) == NULL//没有标识为W[i]的边
  • P.addChild(W[i],new Node())
  • P = P.thru(W[i])
  • Cnt[p]++
  • P.markEndPoint()//标记P为终结点
  • DFS(P,father):
  • If Cnt(p) <= 5 AND Cnt(father) > 5
  • Ans++
  • Return
  • For i = 0...25
  • If P.thru(i) != 0
  • DFS(P.thru(i),p)
  • Main:
  • For i = 0...N-1
  • Insert(S[i])
  • Ans = 0
  • DFS(root,-1)
  • Print ANS

 完整的代码如下:

  • #include <cstdio>
  • #include <cstring>
  • using namespace std;
  • const int MAX_NODE = 1000000 + 10;
  • const int CHARSET = 26;
  • int trie[MAX_NODE][CHARSET] = {0};
  • int cnt[MAX_NODE] = {0};
  • int n,ans,k = 1;
  • char s[2000010];
  • void insert(char *w) {
  • int len = strlen(w);
  • int p = 0;
  • cnt[0]++;
  • for(int i = 0;i < len;i++) {
  • int c = w[i] - 'a';
  • if(!trie[p][c]) {
  • trie[p][c] = k;
  • k++;
  • }
  • p = trie[p][c];
  • cnt[p]++;
  • }
  • }
  • void dfs(int x,int father) {
  • if(x != 0 && cnt[x] <= 5 && cnt[father] > 5)
  • ans++;
  • for(int i = 0;i < 26;i++)
  • if(trie[x][i])
  • dfs(trie[x][i],x);
  • }
  • int main() {
  • memset(trie,0,sizeof(trie));
  • scanf("%d",&n);
  • for(int i = 0;i < n;i++) {
  • scanf("%s",s);
  • insert(s);
  • }
  • dfs(0,-1);
  • printf("%d\n",ans);
  • return 0;
  • }

第 10~23 行是插入,基本和上一节带 cnt 的插入没有区别。稍微有一点不同是,这次我们把 cnt [0] 也就是根节点也统计进去了。然后就是 24~30 的 DFS,x 是当前搜索到的节点编号,father 是 x 的父节点编号。第 25 行的判断就是如果 x 不是根,并且 x 以下的终结点数目不少于 5,同时 x 父节点以下的终结点数目就大于 5 了。那么 x 就是 “最短的合适前缀”。第 27~29 行是递归搜索 x 的子节点。最后 main 函数里,我们只用调用 dfs (0, -1),dfs 过程就会把 ans 算出来,最后再输出 ans 就可以了

之前讲的几道题都是和字符串相关的题目,毕竟 Trie 本身被提出就是来处理字符串问题的数据结构。不过实际上 Trie 也可以应用在其他的 “看上去” 不是字符串的场景中。后面我们就讲几道 Trie 应用在整数 xor 异或值最大的题目

首先我们看这样一个问题:给定一个包含 N 个整数的集合 S={A1, A2, A3, … AN}。然后有 M 个询问,每次询问给定一个整数 X,让你找一个 Ai 使得 Ai xor X 的值最大

这道题我们当然可以用暴力枚举的方法搞定。时间复杂度是 O (NM),伪代码如下:

  • For i = 1...M
  • Ans = 0
  • For j = 1...N
  • If A[j] xor x > Ans xor x
  • Ans = A[j]
  • Print A[j]

这道题也是可以用 Trie 解决的。首先我们知道一个整数可以用二进制表示成一个 01 串。比如 3=(011), 5=(101), 4=(100)……。我们假设输入的整数都在 0~2^32-1 之间,于是我们可以用一个长度是 32 位的 01 串表示一个整数

然后对于给定的 N 个整数 A1, A2, A3, … AN,我们把它们对应的 01 串都插入到一个 trie 中。注意这里字符集只有 0 和 1,所以整个 trie 是一棵二叉树

下面我们举一个例子,为了描述方便,我们假设整数都在 0~7 之间,也就是可以用 3 位 01 串表示。现在假设 S={1, 2, 7},也就是说我们要在 Trie 中插入 {001, 010, 111}

这时假设我们要查询 x=4,也就是哪个数和 4 异或结果最大?4=(100),我们的做法是在 trie 树中,尽量与 4 的二进制位反着走。比如 4 的第一位(最高位)是 1,我们从 0 出发第一步就尽量沿着 0 走。因为我们要异或和最大,01 相反才能异或值是 1。并且这一步是可以贪心的,也就是说如果有相反的边,那么我们一定沿着这条边走。因为最高位异或得 1 的话,即便后面都是 0, 10000…000 也要比最高位是 0,后面都是 1 的 011111…111 大

所以我们第一步沿着标识是 0 的边,移动到了 1 号节点;4 第二位是 0,所以我们沿着标识是 1 的边移动到 4 号节点;4 的第三位是 0,但是 4 号节点没有标识是 1 的边,所以我们也只好沿着标识是 0 的边移动到 5 号节点。已经到了终结点,所以 5 号节点对应的 A2=(010) 2=2 就是我们要求的答案,A2 xor 4 = 6 是最大的

下面我们不忙写代码,我们先再看一道进阶版的题目:给定一个包含 N 个整数的数组 A=[A1, A2, A3, … AN],让你求出一段连续的子数组 A [l], A [l+1], … A [r] 使得异或和 A [l] xor A [l+1] xor A [l+2] xor … xor A [r] 的值最大

解决这道题目的关键,就是利用之前我们在枚举中提到的一个技巧,就是用前缀和表示部分和。之前我们遇到的部分和题目是加法的和,这里异或的和其实是一样的。我们定义 S [i]=A [1] xor A [2] xor A [3] xor … xor A [i],那么 A [l] xor A [l+1] xor A [l+2] xor … xor A [r] 就等于 S [r] xor S [l-1]。因为两个相同的数异或起来值是 0

所以这道题就变成了下面这样:给定一个数组:A=[A1, A2, … AN],我们先求出对应的 xor 前缀和 S [1], S [2], … S [N],问题是从 S 数组中找出两个数使得它们的异或值最大。这时就可以用到上一道题的方法了,伪代码如下:

  • S = 0
  • Ans = 0
  • Trie.insert(0)
  • For i = 1...N
  • S = S xor A[i]
  • T = Trie.search(S)
  • Ans = max(Ans,T)
  • Tire.insert(S)
  • Print(Ans)

完整的代码如下:

  • #include <cstdio>
  • #include <cstring>
  • using namespace std;
  • const int MAX_NODE = 1000000 + 10;
  • const int CHARSET = 2;
  • int trie[MAX_NODE][CHARSET] = {0};
  • int color[MAX_NODE] = {0},a[100000];
  • int t,n,m,ans,k = 1,b[32];
  • void insert(int x) {
  • for(int i = 31;i >= 0;i--) {
  • b[i] = x % i;
  • x >>= 1;
  • }
  • int p = 0;
  • for(int i 0;i < 32;i++) {
  • int c = b[i];
  • if(!trie[p][c]) {
  • trie[p][c] = k;
  • k++;
  • }
  • p = trie[p][c];
  • }
  • color[p] = 1;
  • }
  • int search(int x,int father) {
  • int ret = 0;
  • for(int i = 31;i >= 0;i--) {
  • b[i] = x % i;
  • x >> =1;
  • }
  • int p = 0;
  • for(int i 0;i < 32;i++) {
  • int c = 1 - b[i];
  • if(!trie[p][c]) {
  • p = trie[p][1 - c];
  • ret *= 2;
  • }
  • else {
  • p = trie[p][c];
  • ret = ret * 2 + 1;
  • }
  • }
  • return ret;
  • }
  • int main() {
  • scanf("%d",&t);
  • while(t--) {
  • memset(trie,0,sizeof(trie));
  • memset(color,0,sizeof(color));
  • insert(0);
  • ans = 0;
  • int s = 0;
  • scanf("%d",&n);
  • for(int i = 0;i < n;i++) {
  • scanf("%d",&a[i]);
  • s = s ^ a[i];
  • int tmp = search(s);
  • ans = max(ans,tmp);
  • insert(s);
  • }
  • printf("%d\n",ans);
  • }
  • return 0;
  • }

第 9~24 行是 insert 函数,这里我们插入一个整数 x,先把 x 对应的二进制 01 串求出来,b [0] 是最高位,b [31] 是最低位。然后再把 b [0]..b [31] 按位插入到 trie 中

第 25~44 行是 search 函数,注意我们要尽量与 b [i] 反着走,也就是尽量沿着 c=1-b [i] 走。如果 trie [p][c] 不存在,那么意味着这一位的异或值只能是 0 了,即 ret = ret * 2;否则以为这一位异或可以是 1,即 ret = ret * 2 + 1

Last Modified: May 12, 2021
Archives Tip
QR Code for this page
Tipping QR Code
Leave a Comment