子序列自动机 (内含非自动机的线性做法)

子序列自动机 (Subsequence Automaton)

时隔两个月回来学自动机.

子序列自动机可以在线性时间识别一个字符串 a 是否是 s 的子序列.

首先考虑 s 没有重复字符的情况, 那么 s 的子序列就是 2Lens 种, 分别是每个字符选或不选得到的子序列.

子序列的结尾有 n 个, 分别代表以 n 个字符结尾的子序列. 每个状态可以由它前面的每个状态转移过来, 所以转移数量的复杂度是 O(n2).

如果在 s 后面再加一个已经出现的字符 c, 会增加 n 个转移, 因为每个结尾字符都可以转移到第二个 c, 在原有的基础上再末尾加一个字符 c.

但是对于转移到第二个 c 的转移边, 有一些浪费的情况. 假设第一个 c 是第 i 个字符, 第二个 c 是第 n+1 个字符, 那么 [1,i) 的字符到 i 的转移和到 n+1 的转移所代表的子序列是相同的, 所以只有 [i,n] 的状态才需要转移到 n+1.

综上所述, 对于某个状态 i, 对于每个字符 c 最多存在一个转移, 转移到它右边第一个这个字符对应的状态.

所以一个字符集大小为 k, 长度为 n 的字符串的子序列自动机的状态数是 O(n) 转移数是 O(nk).

模板

给一个正整数序列 A, 询问别的的正整数序列 B 是否是 A 的子序列.

A 建立子序列自动机, 匹配 B 即可.

但是, 一个测试点有多个 B, 字符集大小 mA 的长度 n 同阶, 都是 105, 如果用常规方式, O(nm) 会使时空双双爆炸.

考虑用数据结构优化. 因为第 i 个状态的转移只比第 i+1 个状态的转移增加了一个 i+1, 减少了一个后一个 i+1 的后继. (规定一个字符的后继是它后面第一个和它字符相同的字符, 如果没有就是 n+1)

这种一个版本只和另一个版本差别很小的数组, 可以通过可持久化线段数之可持久化数组来实现. 构造自动机的时空复杂度变成 O(nlogm).

再来看匹配, 所有的 B 串满足 lenB106, 复杂度是 O(lenBlogm), 时间复杂度正确.

代码难度极小, 构建自动机的过程只是对每个字符进行一次可持久化线段树修改, 匹配时, 对特定的版本进行查询即可. 复杂度 O((n+LenB)logm)

unsigned a[100005], m, n, Cnt(0), A, B, C, D, t, Ans(0), Tmp(0), CQPos, CQVal, Len, Ty;
char Flg(0);
inline void Clr() {}
struct Node{
  Node *LS, *RS, *Val;
}N[1700005], Ver[100005], *CntN(N);
void Chg(Node *x, unsigned L, unsigned R) {
  if(L == R) {x->Val = Ver + CQVal; return;}
  unsigned Mid((L + R) >> 1);
  if(CQPos <= Mid) {
    ++CntN;
    if(x->LS) CntN->LS = x->LS->LS, CntN->RS = x->LS->RS;
    x->LS = CntN;
    Chg(x->LS, L, Mid);
  } else {
    ++CntN;
    if(x->RS) CntN->LS = x->RS->LS, CntN->RS = x->RS->RS;
    x->RS = CntN;
    Chg(x->RS, Mid + 1, R);
  }
}
Node *Qry(Node *x, unsigned L, unsigned R) {
  if(L == R) {return x->Val;}
  unsigned Mid((L + R) >> 1);
  if(CQPos <= Mid) {if(x->LS) return Qry(x->LS, L, Mid);}
  else if(x->RS) return Qry(x->RS, Mid + 1, R);
  return NULL;
}
int main() {
  Ty = RD(), n = RD(), t = RD(),   m = RD();
  for (register unsigned i(1); i <= n; ++i) a[i] = RD();
  for (register unsigned i(n - 1); i < 0x3f3f3f3f; --i) {
    Ver[i].LS = Ver[i + 1].LS, Ver[i].RS = Ver[i + 1].RS;// 继承上一个 
    CQPos = a[i + 1], CQVal = i + 1;
    Chg(Ver + i, 1, m);  // 后一个节点有到它自己后继的转移, 删除, 设为它自己
  }
  for (register unsigned i(1); i <= t; ++i) {
    Len = RD(), Flg = 0;
    register Node *Now(Ver);
    for (register unsigned j(1); j <= Len; ++j) {
      if(Flg) RD();
      else {
        CQPos = RD();
        Now = Qry(Now, 1, m);
        if(!Now) Flg = 1;
      }
    }
    printf(Flg ? "No\n" : "Yes\n");
  }
  return Wild_Donkey;
}

这道题还有线性做法:

考虑反客为主, 用 A 去匹配 B. (其实我一开始想的是可以用所有的 BTrie, 构造 AC 自动机, 然后就不会写了)

基本流程是将每个 B 当前匹配到的指针存下来, Posi 表示, 第 iB 的前 Posi 位是当前已经考虑过的 A 的子序列.

开一个 m 大小的桶, Bucketi 存所有 BkPosk=ik. 一开始, 初始化 Posi=1, 从左到右扫描 A, 对于 Ai, 每次将 BucketAi 中的所有 k 对应的 Posk 右移变成 Posk+1.

对于 B 的存储, 为了防止使用 vector, 我将所有 B 存在了一个数组中, 中间用空字符隔开, 这样, Posi 指向的位置就是 Bi1 在整个数组中的位置了.

中间的空字符充当了哨兵的角色, Bi 判完了, Posi 会丢进 Bucket0, 而不管 A 扫到哪一位, 都不会碰 Bucket0. 最后扫描所有的 Posi, BPosi=0 的说明整个 Bi 判完了, 输出 Yes, 否则输出 No.

这里桶存储多个数字的方式我使用了邻接表, 同样是防止使用 vector, 这样就可以优化常数了.

最后用了一发输出优化, 不知是不是又双叒叕反向优化了.

本来以为我的代码难看都是指针的锅, 没想到数组也能变得这么难看, 可能是极限卡常和丧心病狂的压行导致的, 不过勉勉强强挤进了前 5.

unsigned a[100005], b[1100005], Pos[100005], Bucket[100005], Nxt[100005], m, n, t;
signed main() {
  RD(), n = RD(), t = RD(), m = RD();
  for (register unsigned i(1); i <= n; ++i) a[i] = RD();
  for (register unsigned T(1), Len, Cnt(0); T <= t; ++T) {
    Pos[T] = ++Cnt, Len = Cnt + RD();
    while (Cnt < Len) b[Cnt++] = RD();
  }
  for (register unsigned i(1); i <= t; ++i)
    Nxt[i] = Bucket[b[Pos[i]]], Bucket[b[Pos[i]]] = i;
  for (register unsigned i(1), j, Tmp; i <= n; ++i) {
    Tmp = Bucket[a[i]], Bucket[a[i]] = 0;
    while (Tmp) j = Tmp, ++Pos[j], Tmp = Nxt[j], Nxt[j] = Bucket[b[Pos[j]]], Bucket[b[Pos[j]]] = j;
  }
  for (register unsigned i(1); i <= n; ++i)
    if(b[Pos[i]]) putchar('N'), putchar('o'), putchar('\n'); 
    else putchar('Y'), putchar('e'), putchar('s'), putchar('\n'); 
  return Wild_Donkey;
}
posted @   Wild_Donkey  阅读(109)  评论(0编辑  收藏  举报
编辑推荐:
· 从 HTTP 原因短语缺失研究 HTTP/2 和 HTTP/3 的设计差异
· AI与.NET技术实操系列:向量存储与相似性搜索在 .NET 中的实现
· 基于Microsoft.Extensions.AI核心库实现RAG应用
· Linux系列:如何用heaptrack跟踪.NET程序的非托管内存泄露
· 开发者必知的日志记录最佳实践
阅读排行:
· winform 绘制太阳,地球,月球 运作规律
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· AI 智能体引爆开源社区「GitHub 热点速览」
· Manus的开源复刻OpenManus初探
· 写一个简单的SQL生成工具
点击右上角即可分享
微信分享提示