• Trie树和后缀树(转,简化)


    作者:July、yansha。
    出处:http://blog.csdn.net/v_JULY_v 。 

      1.Trie树

      1)简述

      又称单词查找树,Trie树,是一种树形结构,是一种哈希树的变种。典型应用是用于统计,排序和保存大量的字符串(但不仅限于字符串),所以经常被搜索引擎系统用于文本词频统计。它的优点是:利用字符串的公共前缀来减少查询时间,最大限度地减少无谓的字符串比较,查询效率比哈希表高。

      3个基本性质:

    1. 根节点不包含字符,除根节点外每一个节点都只包含一个字符。
    2. 从根节点到某一节点,路径上经过的字符连接起来,为该节点对应的字符串
    3. 每个节点的所有子节点包含的字符都不相同。

      假设有b,abc,abd,bcd,abcd,efg,hii 这6个单词。

      好比大海搜人,立马就能确定东南西北中的到底哪个方位,如此迅速缩小查找的范围和提高查找的针对性。

      那么,对于一个单词,我只要顺着他从根走到对应的节点,再看这个节点是否被标记为红色就可以知道它是否出现过了。把这个节点标记为红色,就相当于插入了这个单词。

      2)比较

      已知n个由小写字母构成的平均长度为10的单词,判断其中是否存在某个串为另一个串的前缀子串。下面对比3种方法:

    1. 最容易想到的:即从字符串集中从头往后搜,看每个字符串是否为字符串集中某个字符串的前缀,复杂度为O(n^2)。
    2. 使用hash:我们用hash存下所有字符串的所有的前缀子串,建立存有子串hash的复杂度为O(n*len),而查询的复杂度为O(n)* O(1)= O(n)。
    3. 使用trie:因为当查询如字符串abc是否为某个字符串的前缀时,显然以b,c,d....等不是以a开头的字符串就不用查找了。所以建立trie的复杂度为O(n*len),而建立+查询在trie中是可以同时执行的,建立的过程也就可以成为查询的过程,hash就不能实现这个功能。所以总的复杂度为O(n*len),实际查询的复杂度也只是O(len)。(说白了,就是Trie树的平均高度h为len,所以Trie树的查询复杂度为O(h)=O(len)。好比一棵二叉平衡树的高度为logN,则其查询,插入的平均时间复杂度亦为O(logN))。
        下面解释下上述方法3中所说的为什么hash不能将建立与查询同时执行,而Trie树却可以:
    • 在hash中,例如现在要输入两个串911,911456,如果要同时查询这两个串,且查询串的同时若hash中没有则存入。那么,这个查询与建立的过程就是先查询其中一个串911,没有,然后存入9、91、911;而后查询第二个串911456,没有然后存入9、91、9119114、91145、911456。因为程序没有记忆功能,所以并不知道911在输入数据中出现过,只是照常以例行事,存入9、91、911、9114、911...。也就是说用hash必须先存入所有子串,然后for循环查询
    • 而trie树中,存入911后,已经记录911为出现的字符串,在存入911456的过程中就能发现而输出答案;倒过来亦可以,先存入911456,在存入911时,当指针指向最后一个1时,程序会发现这个1已经存在,说明911必定是某个字符串的前缀。

      3)代码实现

      1 // trie tree.cpp : 定义控制台应用程序的入口点。
      2 //
      3 #include "stdafx.h"
      4 //功能:统计一段英文的单词频率(文章以空格分隔,没有标点) 
      5 //思路:trie节点保存单词频率,然后通过DFS按字典序输出词频 
      6 //时空复杂度: O(n*len)(len为单词平均长度)
      7 //copyright@yansha 2011.10.25
      8 //updated@July 2011.10.26
      9 //程序尚不完善,有很多地方还需改进。
     10 #include <stdio.h>
     11 #include <stdlib.h>
     12 #include <string.h>
     13 #include <ctype.h>
     14 #include <assert.h>
     15 
     16 #define num_of_letters 26
     17 #define max_word_length 20
     18 
     19 // 定义trie树节点
     20 struct Trie   
     21 {   
     22   int count;   
     23   Trie *next[num_of_letters]; 
     24 };   
     25 
     26 // 定义根节点 
     27 Trie *root = NULL;
     28 
     29 /**
     30 * 建立trie树,同时保存单词频率
     31 */
     32 void create_trie(char *word)
     33 {
     34   int len = strlen(word);
     35   Trie *cur = root, *node;
     36   int pos = 0;
     37 
     38   // 深度为单词长度
     39   for(int i = 0; i < len; ++i)
     40   {
     41     // 将字母范围映射到0-25之间
     42     pos = word[i] - 'a';
     43 
     44     // 如果当前字母没有对应的trie树节点则建立,否则处理下一个字母
     45     if(cur->next[pos] == NULL)   //1、这里应该有个查找过程
     46     {
     47       node = (Trie *)malloc(sizeof(Trie));
     48       node->count = 0;
     49 
     50       // 初始化next节点
     51       for(int j = 0; j < num_of_letters; ++j)
     52       node->next[j] = NULL;
     53 
     54       // 开始处理下一个字母
     55       cur->next[pos] = node;
     56       }
     57   cur = cur->next[pos];
     58   }
     59   // 单词频率加1
     60   cur->count++;
     61 }
     62 
     63 /**
     64 * 大写字母转化成小写字母
     65 */
     66 void upper_to_lower(char *word, int len)
     67 {
     68   for (int i = 0; i < len; ++i)
     69   {
     70     if(word[i] >= 'A' && word[i] <= 'Z')
     71     word[i] += 32;
     72   }
     73 }
     74 
     75 /**
     76 * 处理输入
     77 */
     78 void process_input()
     79 {
     80   char word[max_word_length];
     81 
     82   // 打开统计文件(注意保持文件名一致)
     83   FILE *fp_passage = fopen("passage.txt", "r");  
     84   assert(fp_passage);
     85 
     86   // 循环处理单词
     87   while (fscanf(fp_passage, "%s", word) != EOF)  
     88   {  
     89     int len = strlen(word);  
     90     if (len > 0)  
     91       upper_to_lower(word, len);  
     92     create_trie(word);  
     93   }  
     94   fclose(fp_passage);  
     95 }
     96 
     97 /**
     98 * 深度优先遍历
     99 */ 
    100 void trie_dfs(Trie *p, char *queue)
    101 {
    102   for(int i = 0; i < num_of_letters; ++i)
    103   {
    104     if(p->next[i] != NULL)
    105   {
    106     // 定义队列头结点
    107     char *head = queue;
    108 
    109     // 在末尾增加一个字母
    110     while (*queue != '')
    111       queue++;
    112     *queue = (char)(i + 'a');
    113     queue = head;
    114 
    115     // 在控制台打印单词及其频率
    116     if (p->next[i]->count > 0)
    117       printf("%s	%d
    ", queue, p->next[i]->count);
    118 
    119     trie_dfs(p->next[i], queue);
    120 
    121     // 在末尾去掉一个字母
    122     head = queue;
    123     while (*(queue+1) != '')
    124       queue++;
    125     *queue = '';
    126     queue = head;
    127     }
    128   }
    129 }
    130 
    131 int main()
    132 {
    133   // 初始化trie树根节点
    134   root = (Trie *)malloc(sizeof(Trie));
    135   for(int j = 0; j < num_of_letters; ++j)
    136   root->next[j] = NULL;
    137 
    138   // 处理输入
    139   process_input();
    140 
    141   // 分配一个保存单词中间结果的队列
    142   char *queue = (char*) calloc(max_word_length, sizeof(char));
    143 
    144   // 通过DFS打印结果
    145   trie_dfs(root, queue);
    146   system("pause");
    147   return 0;
    148 }
    View Code

      2.后缀树

      1).简介

      后缀,顾名思义,甚至通俗点来说,就是所谓后缀就是后面尾巴的意思。比如说给定一长度为n的字符串S=S1S2..Si..Sn,和整数i,1 <= i <= n,子串SiSi+1...Sn便都是字符串S的后缀。

      以字符串S=XMADAMYX为例,它的长度为8,所以S[1..8], S[2..8], ... , S[8..8]都算S的后缀,我们一般还把空字串也算成后缀。这样,我们一共有如下后缀。对于后缀S[i..n],我们说这项后缀起始于i。

    S[1..8], XMADAMYX, 也就是字符串本身,起始位置为1
      S[2..8], MADAMYX,起始位置为2
         S[3..8], ADAMYX,起始位置为3
           S[4..8], DAMYX,起始位置为4
              S[5..8], AMYX,起始位置为5
                S[6..8], MYX,起始位置为6
                   S[7..8], YX,起始位置为7
                     S[8..8], X,起始位置为8
                                     空字串,记为$。

        而后缀树,就是包含一则字符串所有后缀的压缩Trie(关键在于压缩)。把上面的后缀加入Trie后,我们得到下面的结构:

      

      仔细观察上图,我们可以看到不少值得压缩的地方。比如蓝框标注的分支都是独苗,没有必要用单独的节点同边表示。如果我们允许任意一条边里包含多个字母,就可以把这种没有分叉的路径压缩到一条边。另外每条边已经包含了足够的后缀信息,我们就不用再给节点标注字符串信息了。我们只需要在叶节点上标注上每 项后缀的起始位置。于是我们得到下图:

      

      这样的结构丢失了某些后缀。比如后缀X在上图中消失了,因为它正好是字符串XMADAMYX的前缀。为了避免这种情况,我们也规定每项后缀不能是其它后缀的前缀。要解决这个问题其实挺简单,在待处理的子串后加一个空字串就行了。例如我们处理XMADAMYX前,先把XMADAMYX变为 XMADAMYX$,于是就得到suffix tree--后缀树了,如下图所示:

      2).回文串

      找出给定字符串里的最长回文。例如输入XMADAMYX,则输出MADAM。

      思维的突破点在于考察回文的半径,而不是回文本身。所谓半径,就是回文对折后的字串。比如回文MADAM 的半径为MAD,半径长度为3,半径的中心是字母D。显然,最长回文必有最长半径,且两条半径相等。还是以MADAM为例,以D为中心往左,我们得到半径 DAM;以D为中心向右,我们得到半径DAM。二者肯定相等。因为MADAM已经是单词XMADAMYX里的最长回文,我们可以肯定从D往左数的字串 DAMX与从D往右数的子串DAMYX共享最长前缀DAM。而这,正是解决回文问题的关键。现在我们有后缀树,怎么把从D向左数的字串DAMX变成后缀

      到这个地步,答案应该明显:把单词XMADAMYX翻转(XMADAMYX=>XYMADAMX,DAMX就变成后缀了)就行了。于是我们把寻找回文的问题转换成了寻找两坨后缀的LCA的问题。当然,我们还需要知道到底查询那些后缀间的LCA。很简单,给定字符串S,如果最长回文的中心在i,那从位置i向右数的后缀刚好是S(i),而向左数的字符串刚好是翻转S后得到的字符串S‘的后缀S'(n-i+1)。这里的n是字符串S的长度。

      最低共有祖先,LCA(Lowest Common Ancestor),也就是任意两节点(多个也行)最长的共有前缀。

      算法:

    1. 预处理后缀树,使得查询LCA的复杂度为O(1)。这步的开销是O(N),N是单词S的长度 ;
    2. 对单词的每一位置i(也就是从0到N-1),获取LCA(S(i), S‘(N-i+1)) 以及LCA(S(i+1), S’(n-i+1))。查找两次的原因是我们需要考虑奇数回文和偶数回文的情况。这步要考察每坨i,所以复杂度是O(N) ;
    3. 找到最大的LCA,我们也就得到了回文的中心i以及回文的半径长度,自然也就得到了最长回文。总的复杂度O(n)。 

      3)后缀树应用

      1. 查找字符串o是否在字符串S中。 
          方案:用S构造后缀树,按在trie中搜索字串的方法搜索o即可。 
          原理:若o在S中,则o必然是S的某个后缀的前缀。 
        例如S: leconte,查找o: con是否在S中,则o(con)必然是S(leconte)的后缀之一conte的前缀.有了这个前提,采用trie搜索的方法就不难理解了。。 
      2. 指定字符串T在字符串S中的重复次数。 
          方案:用S+’$'构造后缀树,搜索T节点下的叶节点数目即为重复次数 
          原理:如果T在S中重复了两次,则S应有两个后缀以T为前缀,重复次数就自然统计出来了。。 
      3. 字符串S中的最长重复子串 
          方案一:列出所有后缀数组。 对其首字母排序,
            对数组进行扫描比较邻接元素,取出最多相同邻接元素的子字符串,保存那个字符串和长度。
        方案二:原理同2,具体做法就是找到最深的非叶节点。 
          这个深是指从root所经历过的字符个数,最深非叶节点所经历的字符串起来就是最长重复子串。 
        为什么要非叶节点呢?因为既然是要重复,当然叶节点个数要>=2。 
      4. 两个字符串S1,S2的最长公共部分 
          方案:将S1#S2$作为字符串压入后缀树,找到最深的非叶节点,且该节点的叶节点既有#也有$(无#)。 

      3.自动机,KMP算法,Extend-KMP,后缀树,后缀数组,trie树,trie图及其应用

    kmp

        首先这个字符串匹配算法,主要思想就是要充分利用上一次的匹配结果,找到匹配失败时,模式串可以向前移动的最大距离。这个最大距离,必须要保证不会错过可能的匹配位置,因此这个最大距离实际上就是模式串当前匹配位置的next数组值。也就是max{Aj 是 Pi 的后缀  j < i},pi表示字符串A[1...i],Aj表示A[1...j]。模式串的next数组计算则是一个自匹配的过程。也是利用已有值next[1...i-1]计算next[i]的过程。我们可以看到,如果A[i] = A[next[i-1]+1] 那么next[i] = next[i-1],否则,就可以将模式串继续前移了。
    整个过程是这样的:
    void next_comp(char * str){
       int next[N+1];
       int k = 0;
       next[1] = 0;
       //循环不变性,每次循环的开始,k = next[i-1] 
       for(int i = 2 ; i <= N ; i++){
          //如果当前位置不匹配,或者还推进到字符串开始,则继续推进
          while(A[k+1] != A[i] && k != 0){
               k = next[k];
          }     
          if(A[k+1] == A[i]) k++;
          next[i] = k;
       } 
    }
        复杂度分析:从上面的过程可以看出,内部循环再不断的执行k = next[k],而这个值必然是在缩小,也就是是没执行一次k至少减少1;另一方面k的初值是0,而最多++ N次,而k始终保持非负,很明显减少的不可能大于增加的那些,所以整个过程的复杂度是O(N)。
        上面是next数组的计算过程,而整个kmp的匹配过程与此类似。

    extend-kmp

        为什么叫做扩展-kmp呢,首先我们看它计算的内容,它是要求出字符串B的后缀与字符串A的最长公共前缀。extend[i]表示B[i...B_len] 与A的最长公共前缀长度,也就是要计算这个数组。观察这个数组可以知道,kmp可以判断A是否是B的一个子串,并且找到第一个匹配位置?而对于extend[]数组来说,则可以利用它直接解决匹配问题,只要看extend[]数组元素是否有一个等于len_A即可。显然这个数组保存了更多更丰富的信息,即B的每个位置与A的匹配长度。
        计算这个数组extend也采用了于kmp类似的过程。首先也是需要计算字符串A与自身后缀的最长公共前缀长度。我们设为next[]数组。当然这里next数组的含义与kmp里的有所过程。但它的计算,也是利用了已经计算出来的next[1...i-1]来找到next[i]的大小,整体的思路是一样的。
        具体是这样的:观察下图可以发现
        首先在1...i-1,要找到一个k,使得它满足k+next[k]-1最大,也就是说,让k加上next[k]长度尽量长。实际上下面的证明过程中就是利用了每次计算后k+next[k]始终只增不减,而它很明显有个上界,来证明整个计算过程复杂度是线性的。如下图所示,假设我们已经找到这样的k,然后看怎么计算next[i]的值。设len = k+next[k]-1(图中我们用Ak代表next[k]),分情况讨论:
    • 如果len < i 也就是说,len的长度还未覆盖到Ai,这样我们只要从头开始比较A[i...n]与A的最长公共前缀即可,这种情况下很明显的,每比较一次,必然就会让i+next[i]-1增加一.
    • 如果len >= i,就是我们在图中表达的情形,这时我们可以看到i这个位置现在等于i-k+1这个位置的元素,这样又分两种情况:
      1. 如果 L = next[i-k+1] >= len-i+1,也就是说L处在第二条虚线的位置,这样我们可以看到next[i]的大小,至少是len-i+1,然后我们再从此处开始比较后面的还能否匹配,显然如果多比较一次,也会让i+A[i]-1多增加1.
      2. 如果 L < len-i+1 也就是说L处在第一条虚线位置,我们知道A与Ak在这个位置匹配,但Ak与Ai-k+1在这个位置不匹配,显然A与与Ai-k+1在这个位置也不会匹配,故next[i]的值就是L。这样next[i]的值就被计算出来了,从上面的过程中我们可以看到,next[i]要么可以直接由k这个位置计算出来,要么需要在逐个比较,但是如果需要比较,则每次比较会让k+next[k]-1的最大值加1.而整个过程中这个值只增不减,而且它有一个很明显的上界k+next[k]-1 < 2*len_A,可见比较的次数要被限制到这个数值之内,因此总的复杂度将是O(N)的。 

    参考:http://www.cnblogs.com/v-July-v/archive/2011/10/22/2316412.html

    百度百科

  • 相关阅读:
    React—ref 属性
    【支付宝SDK】沙箱调试,以及遇到的坑
    python 字符串相乘
    微信小程序授权获取手机号 出现-41003错误
    微信小程序 动态添加 view input picker
    PHP 获取当月的第一天和最后一天
    微信小程序 弹窗组件
    微信小程序 图片等比例 适应屏幕尺寸
    微信小程序图片预览
    div背景图占满
  • 原文地址:https://www.cnblogs.com/jslee/p/3441023.html
Copyright © 2020-2023  润新知