后缀树

我是看这篇博文(Suffix Tree学习笔记)学后缀树的,作者写的很好,而且最后有个很实用的模板。

不过最近可能google大姨妈,一直打不开。网上有各种转帖,这里就不转了。

如果要学后缀树的话,还是去看原文或者看那个链接比较好。我这里也是对着那篇博文写的,图和例子也来自那篇博文。


对于一个字符串str[0,n),只要将str[0,n),str[1,n),str[2,n),str[3,n),...,str[n-1,n)依次插入到trie中,就能够在O(|P|)的时间内查询到str是否包含字串P。

每个字串需要O(n)的时间插入,最坏情况下需要O(n)的空间,故直接这么来时间和空间复杂度都是O(n^2)级的。对于稍微大一些的数据,平方级的空间需求可能就伤不起了。


由于str长度为n,故最多n个后缀,每个后缀都停留在trie的叶节点上。那么如果把只有一个子节点的节点向上或向下合并,那么合并后的非叶节点至少2个子节点,亦即整个树的节点数至多为O(2n)。这种树称为后缀树,它既有trie的快速查找字串的性质,同时把空间复杂度降低到可以接受的范围内(O(n)),下图是一个trie和suffix tree的例子,字符串为"MISSISSIPPI",图中的$为字符串结束符。

  


后缀树中每个节点保存了从上一个节点到本节点这条边上的字串。由于字符串给定后,每个字串可以用[起点,终点)这样的形式表示,所以就不必保存这个字串具体是什么了。再加上需要保存所有子节点,所以可以这样定义节点:

struct node{
  int b,e;
  vector<node*> next;
};

node::next也可以像trie的节点那样写成固定大小数组,前提是知道字母表的长度。这样查找起来也能快一点。不过本文为了方便就写成邻接表的形式了。

显然对于原字符串的任意字串,可能停在某个节点的最后一个字符("PI$","SI"等)或者此节点表示的区间内("SIPP",它停留在一个叶节点的区间内部)。前者称为显式节点,后者称为隐式节点。注意,如非特殊说明,下文提到的节点是对应回原来trie中的节点。trie的一个节点可以仅通过某个节点的指针(node* p)来表示,而后缀树中的一个节点需要表示为(node* p,int offset),其中offset是此节点在后缀树节点p的区间中的位置。

为了方便,下文中如果显式节点是后缀树中的叶节点,则简称为叶节点,否则称为内部节点。

隐式节点对应trie中因路径压缩而不表示出来的节点,也就是那些有且仅有一个子节点的节点。有时候需要把隐式节点改成显式节点。


本文构造后缀树的算法是Ukkonen算法。与trie依次插入每个后缀的做法不同(非常重要,我之前不明白就不明白在这个地方了),Ukkonen算法是先从左到右扫描字符串的每个字符,然后找到已有的所有后缀(包括空后缀),再在这些后缀最后加上当前字符(称此操作为扩展操作)。其基本流程是:

1. 依次扫描每个后缀str[0,i]

2. 假设str[0,1),str[0,2),...,str[0,i)已经插入到了后缀树中,那么按str[0,1),str[0,2),...,str[0,i)的顺序找到这些后缀对应的节点。

3. 对于2中找到的这些节点,如果此节点是:

叶节点,则将字符str[i]加入到叶节点最后;

内部节点,则先看看此节点的子节点中有没有以str[i]开头的节点,如果有的话就算了,如果没有则创建一个新节点作为此内部节点的子节点,其区间为[i,i+1),这一步是和trie类似的;

隐式节点,之前也说过,隐式节点是只有一个子节点的内部节点,只不过被压缩掉了。那么如果隐式节点的子节点以str[i]开头,那就算了,否则需要把此隐式节点转化为显式节点


那么朴素的做法就是:

struct node{
  int b,e; // 左闭右开
  vector<node*> next;
  node(int b = 0, int e = 0):b(b),e(e){}
  node* find_node(int db, int de, int& index) const{
    const node* p = this;
    index = p->b;
    while (db < de){
      int tb = p->b, te = p->e;
      while (tb < te && db < de && str[tb] == str[db]) ++tb,++db;
      if (db >= de) {index = tb; break;}
      for (vector<node*>::const_iterator iter = p->next.begin(); iter != p->next.end(); ++iter)
        if (str[(*iter)->b] == str[db]){
          p = *iter;
          break;
        }
    }
    return (node*)p;
  }
  void clear(){b = e = 0; next.clear();}
}root;

void build_suffix_tree(){
  if (!str[0]) return;
  int len = strlen(str)+1;
  root.clear();
  root.next.push_back(new node(0,1));

  for (int i = 1; i < len; ++i){
    for (int j = 0, index; j <= i; ++j){
      node *p = root.find_node(j,i,index);
      if (p->e == index){
        if (p->next.empty()){ // 叶节点
          ++p->e;
          continue;
        }
        // 内部节点
        bool flag = true;
        for (vector<node*>::iterator iter = p->next.begin(); iter != p->next.end() && flag; ++iter)
          if (str[(**iter).b] == str[i]) flag = false;
        if (flag) p->next.push_back(new node(i,i+1));
      }
      else { // 隐式节点
        if (str[index] == str[i]) continue; // 隐式节点已经包含了此字符
        // 分裂隐式节点
        node *right = new node(index, p->e), *newLeaf = new node(i,i+1);
        p->e = index;
        p->next.swap(right->next); // 存储问题,只能这么来了
        p->next.push_back(right);
        p->next.push_back(newLeaf);
      }
    }
  }
}

来分析下这个算法的复杂度。一共有n个后缀,所以要执行n轮扩展;每轮扩展要先找O(n)个节点,查找任意一个节点是O(n)的时间,所以总的来说是O(n^3)的时间复杂度——直接构造后缀trie也不过O(n^2)。也就是说,直接这么写是无法让人接受的。为了改进算法,可以从以下几点入手:

1. count and skip

可能读者已经发现了,find_node确切的说是找下一个后缀的终止节点而不是找任意字符串的终止节点,后者是可能不存在的,而代码在用到find_node的时候总是认为返回有效的节点。其中值得注意的是这句:

      while (tb < te && db < de && str[tb] == str[db]) ++tb,++db;
find_node是找一个上一步加入的后缀,它必然存在于后缀树中;我们知道,在trie中查找一个字符串,如果此字符串在trie中,那么路径是唯一的,后缀树的查找路径也一样。之前提到过,隐式节点是有且只有一个子节点的节点,那么在查找后缀时,匹配到某个后缀树节点后,可以跳过此节点上的所有隐式节点,直接查找下一个后缀树的节点——因为有且只有一条路径到达下一个节点。因此,find_node可以简写为:

  node* find_node(int db, int de, int& index) const{
    const node* p = this;
    index = p->b;
    while (db < de){
      int tb = p->b, te = p->e;
      if (de - db <= te - tb){
        index = tb + de - db;
        break;
      }
      db += te - tb;
      for (vector<node*>::const_iterator iter = p->next.begin(); iter != p->next.end(); ++iter)
        if (str[(*iter)->b] == str[db]){
          p = *iter;
          break;
        }
    }
    return (node*)p;
  }


2. stop when str[j,i] exists

对于每一轮扩展操作,它是对一个已经包含了str[0,i),str[1,i),...,str[i,i)的后缀树的每个后缀后边添加字符。既然后缀树包含了某个字符串可能的所有后缀,而任意子串又是某个后缀的前缀,因此后缀树包含了此字符串所有的字串。那么如果str[j,i]在后缀树中,作为str[j,i]的字串,str[j+1,i],str[j+1,i],...,str[i,i]也在后缀树中。

由于每一轮后缀的扩展是按从0到i的顺序操作的,因此当str[j,i]在后缀中时,可以即停止当前这一轮的操作。对应回原来的代码,就是将这句话

        if (flag) p->next.push_back(new node(i,i+1));

改成

        if (flag) p->next.push_back(new node(i,i+1));
        else break;

然后将这句话

        if (str[index] == str[i]) continue; // 隐式节点已经包含了此字符
改成

        if (str[index] == str[i]) break; // 隐式节点已经包含了此字符

3. open leaves

简单的说就是:如果一个节点成为了叶节点(除了一开始的根节点,当然你也可以改一下具体实现),那它就永远成了叶节点。

为什么会这样呢?考虑第i轮扩展操作,显然最长的后缀str[0,i)终止于叶节点。如果某个后缀str[j+1,i)终止于叶节点,那么str[j,i)必然终止于叶节点。

因为如果str[j,i)不终止于叶节点,则后缀树包含了字串str[j,i)+'c',那么它的子串str[j+1,i)+'c'也必然在后缀树中。

故后缀树的所有叶节点(假设有m个)对应于前m长的后缀,而对于这些后缀,以后再怎么扩展也不可能出现比它们还长的后缀了,于是它们就永远的终止于叶节点。

也就是说叶节点只会多不会少,而且一旦成了叶节点,就永远成了叶节点。

之前也提到过,叶节点的操作仅仅是把当前字符添加到最后。又因为叶节点永远不会变化,因此可以在建立叶节点的时候就把可能的所有后缀字符都扔进去。同时可以跳过所有对叶节点的更新操作(前m个叶子)。故代码可以进一步改为:

void build_suffix_tree(){
  if (!str[0]) return;
  int len = strlen(str)+1;
  root.clear();
  root.next.push_back(new node(0,len));

  for (int i = 1, m = 1; i < len; ++i){
    for (int j = m, index; j <= i; ++j){
      node *p = root.find_node(j,i,index);
      if (index == p->e){
        bool flag = true; // 内部节点
        for (vector<node*>::iterator iter = p->next.begin(); iter != p->next.end() && flag; ++iter)
          if (str[(**iter).b] == str[i]) flag = false;
        if (flag) {
          p->next.push_back(new node(i,len));
          ++m;
        }
        else break;
      }
      else { // 隐式节点
        if (str[index] == str[i]) break; // 隐式节点已经包含了此字符
        // 分裂隐式节点
        node *right = new node(index, p->e), *newLeaf = new node(i,len);
        p->e = index;
        p->next.swap(right->next); // 存储问题,只能这么来了
        p->next.push_back(right);
        p->next.push_back(newLeaf);
        ++m;
      }
    }
  }
}


其中m表示叶子的数量。


4. suffix link

(上次写到这的时候玩去了,之后代码重写了一下,总的来说差不多,除了个别变量换了个名字)

open leaves和stop when str[j,i] exists两者是有一定关系的。我们每次是从一个非叶节点开始扩展操作的:如果这个节点显式扩展了,那么m++,我们从下一个节点开始做同样的操作,并且由于m自增了,如果可能的话下次也只能从下一个节点开始找:直到到某个节点进行隐式操作(或到根节点)。因此下一轮可以直接从上一次结束的节点开始找第一个节点。

隐式扩展可以这样结束,显式扩展之后呢?由于下一个节点实际上是一个更短的后缀,假设显式扩展了节点aS+c(其中c是当前字符),那么aS必然是一个内部节点,且后缀树中有aS+d(d != c)。由于aS+d在后缀树中,则S+d也在后缀树中作为内部节点,而且很巧的是S就是我们要找的“更短的后缀”。因此可能的话保存一个从aS到S的指针,下次显式更新到aS后,就可以顺着这个指针到S进行下一步扩展操作,而不必从root开始重新找。这个指针就是后缀链接(suffix link)。

因此,最终版的代码就是这样的(修改版,原来那个bug很大):

char str[1024];

struct node{  
  int b,e;
  node *link, *next[27], *parent;
  node* find_node(int db, int de, int& pos) const{  
    const node* p = this;  
    pos = p->b;  
    while (db < de){  
      int tb = p->b, te = p->e;  
      if (de - db <= te - tb){  
        pos = tb + de - db;  
        break;  
      }  
      db += te - tb;  
      p = p->next[str[db]-'a'];
    }  
    return (node*)p;  
  }  
  void clear(){
    memset(this,0,sizeof(*this));
  }  
}root;  

int nodecount;
  
void build_suffix_tree(){  
  int len = strlen(str)+1;  
  str[len-1] = 'a'+26;
  root.clear();
  root.next[str[0]-'a'] = new_node(0,len,&root);
  node* cur = &root;  
  
  for (int i = 1, m = 1, pos = 0; i < len; ++i){  
    node* last = 0;  
    for (int j = m; j <= i; ++j){  
      if (last){
        if (cur->link) {
          cur = cur->link;
          pos = cur->e;
        }
        else cur = root.find_node(j,i,pos);
      }
      if (pos < cur->e){  
        if (str[pos] == str[i]) {++pos; break;}  
        // 隐式扩展  
        node* internal = new_node(cur->b,pos,cur->parent);
        node* leaf = new_node(i,len, internal);  
        cur->parent->next[str[internal->b]-'a'] = internal;
        cur->b = pos;  
        cur->parent = internal;
        internal->next[str[cur->b]-'a'] = cur;
        internal->next[str[leaf->b]-'a'] = leaf;
        cur = internal;
      }  
      else {  
        // 显式扩展  
        if (cur->next[str[i]-'a']){
          cur = cur->next[str[i]-'a'];
          pos = cur->b+1;
          break;
        }
        cur->next[str[i]-'a'] = new_node(i,len,cur);
      }  
      if (last) last->link = cur;  
      last = cur;  
      ++m;  
    }  
  }  
  str[len-1] = 0;
}

说实话我是没怎么理解这个算法是O(n)的。然后这是个简单的测试代码


void build_random_str(){
  static int t = 0;
  for (int i = 0, tt = t++; i < 1000; ++i){
    str[i] = tt % 26 + 'a';
    tt /= 3;
  }
  str[1000] = 0;
}

bool test(){
  int len = strlen(str);
  str[len] = 'a' + 26;
  nodecount = 0;
  build_suffix_tree();
  auto err = [len](int k)->bool{
    str[len] = 0;
    cout << str << endl;
    cout << str+k << endl;
    return false;
  };

  for (int i = 0; i < len; ++i){
    node* p = &root;
    int k = i;
    for (int j = p->b; j < p->e && k < i; ++j,++k)
      if (str[j] != str[k]) return err(k);
    if (k == len) continue;
    if (k > len) return err(k);
    if (!p->next[str[k]-'a']) return err(k);
    p = p->next[str[k]-'a'];
  }
  str[len] = 0;
  return true;
}

void print_suffix_tree(node *p = &root, int deep = 0){  
  for (int i = 0; i < deep; ++i)  
    printf("  ");  
  printf("|-");  
  for (int i = p->b; i < p->e; ++i) putc(str[i],stdout);  
  putc('\n', stdout);  
  for (node *q : p->next)  
    if (q) print_suffix_tree(q,deep+1);  
}  

int main(){
  for (int i = 0; i < 100000; ++i){
    build_random_str();
    if (!test()) {
      print_suffix_tree();
      break;
    }
  }

  return 0;
}



评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值