字符串专题-AC自动机

 

细节:

(1)多组数据记得添加init函数,并且init函数里面设置tot=-1,同时memset(tr[0] , 0) 。

(2)预处理last指针的时候,应该注意,v指向的是fail[v],与x没有任何关系。即【last[v] = ed[fail[v]]?fail[v]:last[fail[v]]】;

 

类型一:AC自动机与多串匹配

(1)第一部分:基于AC自动机、Trie图进行计数、匹配

例题一:luogu AC自动机模板 【统计有多少个pattern出现在str里】

把pattern插入到Trie里,使用build函数建Trie图,然后再拿str跑Trie图。

我们发现复杂度的不确定因素是每次向上跳fail的次数,这个东西可以优化。

我们考虑到每个节点只需要统计一次,所以每次向上跳fail时,把节点标记为visited。标记过的节点就不需要继续向上跳fail了,复杂度就是\(O(n)\)。

 

拓展题:Hrinity 【对fail指针的利用】

题意:给出 \(n \le 2500\) 个模式串pat和一个字符串 \(S\),求有多少个pat在S中出现,但是,如果 \(pat_i\) 和 \(pat_j\) 出现在S里,同时 \(pat_i\) 是\(pat_j\)的一个子串,那么i串不算入答案。

做法:先把所有的pat展开后插入AC自动机里,然后拿S串去匹配,存下所匹配到的模式串 \(p[]\)。然后枚举\(p[]\)里的模式串,并把它的子串的ending设置为0【也可以直接设置为-1】。最后再拿S串匹配一遍计算答案。

启发:如何从AC自动机里面删去所有属于自己的子串?只需要跑一遍AC自动机,对于每一个节点都跳fail到根即可。【注意,对于s[s.size()-1]这个节点,不是从ro开始删,而是从fail[ro]开始删,以为字符串S不算自己的子串】

因为每个节点的ed最多3次被设置成-1,所以还是 \(O(n)\) 的复杂度。

查看代码

const int charSet = 26;
int n, fail[maxn], tot, tr[maxn][charSet], ed[maxn], id[maxn], pos[maxn];
string pat[2511], s;

inline int newNode() {
  ++tot, me(tr[tot], 0);
  id[tot] = ed[tot] = fail[tot] = 0;
  return tot;
}

inline void init() { tot = -1, newNode(); }

inline string decompress(const string& s) {
  string ret;
  for (int i = 0; i < s.size(); i++) {
    if (isupper(s[i]))
      ret.push_back(s[i]);
    else {
      int t = 0;
      i++;
      while (isdigit(s[i + t])) t++;
      int num = stoi(s.substr(i, t));
      for (int j = 0; j < num; j++) {
        ret.push_back(s[i + t]);
      }
      i += 1 + t;
    }
  }
  return ret;
}

inline void insert(string& s, int sid) {
  int ro = 0;
  s = move(decompress(s));
  for (int i = 0; i < s.size(); i++) {
    if (!tr[ro][s[i] - 'A']) tr[ro][s[i] - 'A'] = newNode();
    ro = tr[ro][s[i] - 'A'];
  }
  ed[ro] = 1, id[ro] = sid, pos[sid] = ro;
}

inline void build() {
  queue<int> q;
  for (int i = 0; i < charSet; i++)
    if (tr[0][i]) q.push(tr[0][i]);
  while (q.size()) {
    int x = q.front();
    q.pop();
    for (int i = 0; i < charSet; i++) {
      int v = tr[x][i];
      if (v) {
        fail[v] = tr[fail[x]][i];
        q.push(v);
      } else {
        tr[x][i] = tr[fail[x]][i];
      }
    }
  }
}

inline void solve() {
  cin >> n, init();
  for (int i = 1; i <= n; i++) cin >> pat[i], insert(pat[i], i);
  build(), cin >> s, s = move(decompress(s));
  // main work
  vector<int> pid;
  for (int i = 0, ro = 0; i < s.size(); i++) {  // 先找出那些是匹配的
    ro = tr[ro][s[i] - 'A'];
    for (int j = ro; j && ~ed[j]; j = fail[j]) {
      if (ed[j]) pid.emp(id[j]);
      ed[j] = -1;
    }
  }
  for (int i = 0; i <= tot; i++) ed[i] = 0;  // 清空重来
  for (int t = 0; t < pid.size(); t++)
    ed[pos[pid[t]]] = 1;  // 从已经匹配的字符串里面选
  for (int t = 0; t < pid.size(); t++) {
    int u = pid[t];
    for (int i = 0, ro = 0; i < pat[u].size(); i++) {
      ro = tr[ro][pat[u][i] - 'A'];
      int j = i == pat[u].size() - 1 ? fail[ro] : ro;  //删去所有子串
      for (; ~ed[j]; j = fail[j]) ed[j] = -1;
    }
  }
  int ans = 0;  // 最后再统计答案
  for (int i = 0, ro = 0; i < s.size(); i++) {
    ro = tr[ro][s[i] - 'A'];
    for (int j = ro; j && ~ed[j]; j = fail[j]) {
      if (ed[j]) ans++;
      ed[j] = -1;
    }
  }
  cout << ans << endl;
}

 

例题二:Searching the string 【利用fail指针 + last优化】

这道题考察点是利用AC自动机统计模式串pattern。匹配方式有两种,一种是可以overlap的,一种是不可以的。

所以考虑维护 1.cnt[0]可以重叠的数量 和 2.cnt[1]不可重叠的数量。

【如果只是询问可重叠的pattern串的出现次数,可以见洛谷加强模板题,此题可以通过fail链进行拓扑排序,进而统计答案】

但是这一题,由于cnt[1]的存在,我们只能通过fail链不断向上跳,一个一个地统计答案。

(1)维护cnt[0]

跑AC自动机,在节点ro处不断往上跳fail链,然后cnt[ro][0]++,跳fail跳到0节点处结束。

这一步也可以通过last优化剪枝优化复杂度,每一步跳到的节点都是ending。

(2)维护cnt[1]

我们还是需要跑AC自动机和跳fail链,但是我们还要维护多2个变量,就是tim[ro]和len[ro],当且仅当tim[ro]+len[ro]<i时,cnt[ro][1]++。

注意:如果字符串下标从0开始,就是\( tim[ro]+len[ro] \le i \)

最后复杂度是 \(O(ans + 26*n) \)。因为每个答案都是+1暴力统计的,所以对复杂度影响很大。【此题的pattern大小为6,所以才跑得很快,大概不超过100时,也可以很快】

查看代码

string str, pat[maxn];
int n, tp[maxn], kase;
int tot, tr[maxn][26], cnt[maxn][2], ed[maxn];
int fail[maxn], last[maxn], len[maxn], tim[maxn];

inline int newNode() {
  ++tot, me(tr[tot], 0), ed[tot] = 0, tim[tot] = -1;
  cnt[tot][0] = cnt[tot][1] = fail[tot] = last[tot] = 0;
  return tot;
}

// init tr[0]
inline void init() { tot = -1, newNode(); }

inline void insert(const string& s) {
  int ro = 0;
  for (const char& c : s) {
    if (!tr[ro][c - 'a']) tr[ro][c - 'a'] = newNode();
    ro = tr[ro][c - 'a'];
  }
  ed[ro] = 1, len[ro] = s.size();
}

inline void initFail() {
  queue<int> q;
  for (int i = 0; i < 26; i++)
    if (tr[0][i]) q.push(tr[0][i]);
  while (q.size()) {
    int x = q.front();
    q.pop();
    for (int i = 0, v; i < 26; i++) {
      v = tr[x][i];
      if (v) {
        fail[v] = tr[fail[x]][i];
        last[v] = ed[fail[v]] ? fail[v] : last[fail[v]];
        q.push(tr[x][i]);
      } else {
        tr[x][i] = tr[fail[x]][i];
      }
    }
  }
}

inline void work(const string& s) {
  int ro = 0;
  for (int t = 0; t < s.size(); t++) {
    ro = tr[ro][s[t] - 'a'];
    for (int i = ed[ro] ? ro : last[ro]; i; i = last[i]) {
      cnt[i][0]++;
      if (tim[i] + len[i] <= t) cnt[i][1]++, tim[i] = t;
    }
  }
}

inline int getAns(const string& pat, int tp) {
  int ro = 0;
  for (const char& c : pat) ro = tr[ro][c - 'a'];
  return cnt[ro][tp];
}

inline void solve() {
  cin >> n, init();
  for (int i = 1; i <= n; i++) cin >> tp[i] >> pat[i], insert(pat[i]);
  initFail();
  work(str);
  cout << "Case " << ++kase << "\n";
  for (int i = 1; i <= n; i++) cout << getAns(pat[i], tp[i]) << "\n";
  cout << "\n";
}

int main() {
  ios_fast;
  int TEST = 1;
  // freopen("test_input.txt", "r", stdin);
  // freopen("test_output.txt", "w", stdout);
  // cin >> TEST;
  while (cin >> str) solve();
}

 

例题三:POI 病毒【Trie图,有向图判断有无环】

如果删去ending节点之后,Trie图形成了环,那么就可以不断走环上的节点,形成无穷长的字符串。

然后对这个有向图dfs一遍就可以知道有没有环了。【使用vis数组和instk数组,保证每个节点只被访问一遍】

 

例题四:CF1202E You Are Given Some Strings...【思维题 + 计数】

考虑枚举分裂点 \(idx\),在 \(idx\)之前的字符串为 \(s_i\),在 \(idx+1\)之后的字符串为\(s_j\),那么分裂点对答案的贡献根据乘法原理就是两者数量的乘积。把所有s串正着插进去,对t正着做一遍匹配,然后把所有s串反着插进去,对t反着做一遍匹配。

求出pre和suf数组之后,\(\sum^{t.size()-1}_{i=1}pre_i*suf_{i+1}\) 就是答案。

 

例题五:D. Por Costel and the Censorship Committee【AC自动机匹配模式串 + 思维 + DP】

题意:给出字符串 \(S\),和模式串集合 \(pat_i\),要求把 \(S\) 中的一些字符换成'*'使得S中不包含 \(pat_i\)。如果第 i 位换成'*',花费为val[i],请你删去所有的 \(pat_i\),并且使得花费最小。【不会做,参考了别人的代码

这道题也卡了好久,如果花费相同,那么直接删掉最后一位就行(注意删掉之后要返回根节点)。但是包含权值之后,就无法直接贪心了。

我们首先在 \(S\)中找出所有的\(pat_i\)的区间\([L,R]\),如果一个大区间包含另一个小区间,我们删去大的,保留小的。

这样我们得到一些区间集合,而且这些区间集合要么相交,要么相离。相离的区间集合之间可以单独计算对答案的贡献,所以主要考虑相交的时候该怎么处理。

可以使用DP,定义状态:dp[i]表示在i处放一个'*',而且[1,i]之间的pat全部删除的最小花费。

DP状态的转移只发生在相邻区间上[L1,R1]和[L2,R2],其中 \( L1 < L2 \le R1 < R2\)。因为只有前一个pat被覆盖了,才能转移到当前这个,前两个的甚至更前的都无法转移到当前这个。

转移方程就是:dp[i] = \( min^{i-1}_{j = L1}(dp[j]) + val[i] \),转移过程可以单调队列维护。

最后的难点就是怎么用AC自动机计算出所有的 [l,r] 区间。【难以表诉啊,可以看看 AC提交记录

 

 

(2)第二部分:利用fail树的性质求解问题

例题一:BZOJ单词 【fail树】

题意:给你n个单词,这n个单词组成了一篇文章,问你每个单词在文章里出现了多少次。【每个单词之间用逗号隔开】(单词的总长度不会超过10^6)

单词的个数小于等于200。计算fail树里子树的大小就行。

 

拓展:阿狸的打字机【Fail树 + DFS序 + 树状数组/线段树】

做法基本和上面这一题相同,根据DFS序、包含关系(即子树里的节点)求解问题。

这道题先对Fail树求出dfn序来建树状数组,然后在DFS自动机里面的Trie树,我们dfs到Trie树的一个节点,就在树状数组上的dfn[y]处加1,然后查询x节点子树内的权值和,就是y节点形成的前缀里有多少个x。【可以好好思考一下】

【即fail树的一个重要性质就是:x节点下有多少个节点,就代表Trie里面有多少个前缀包含x】

由于我们dfs到y节点,说明当前树状数组里面所有的1都是y节点的前缀,所以查询得到的包含x的前缀都属于y。

我们呢就得到字符串y里有多少个字符串x了。

细节:应该使用 dfn_num 来建树状数组!而不是使用tot 【就算用tot,也应该是tot+1!!】

 

例题二:GRE Words 【利用fail树进行DP】一篇不错的题解

题目大意:给定一个由字符串构成的序列,不同位置的字符串有自己权值。现在让你选出一个子序列,使得在这个子序列中,前面的串是后面的串的子串。请你求满足条件的子序列的权值的最大值。一个子序列权值是所有元素权值的和。

 一看题目第一反应是DP,考虑前面一个串 \(I\) 和后面一个串 \(J\),如果i是j的一个子串,那么可以使用dp[i]更新dp[j]。

但是怎样才能进行更新呢?

回想起AC自动机里面fail树的性质,如果一个字符串 \(J\) 包含字符串 \(I\),那么j的某些节点一定在i的子树里面。

我们可以直接把dp[i]更新到i节点的子树里面,然后dp[j]就是字符串 \(J\)的所有节点的权值中的最大值【即\( max\{val[node\space of\space J]\}\) 】。

发现这可以使用线段树维护区间最大值,然后查询单点最大值来求出dp[j]。

果然还是对Fail树不熟悉啊,看到包含关系没第一时间想到Fail树。。。

细节:(1)线段树初始化应该从0到4*dfnnum (2)dfnnum应该清 0

查看代码

const int charSet = 26;
int n, kase, fail[maxn], tot, tr[maxn][charSet], id[maxn];
int dfn[maxn], sz[maxn], dfnnum;
int mx[maxn * 4], tg[maxn * 4], val[maxn];
string pat[maxm];
vector<vector<int>> e;

inline int newNode() {
  ++tot, me(tr[tot], 0), fail[tot] = 0;
  return tot;
}

inline void init() { tot = -1, newNode(); }

inline void insert(const string& s, int sid) {
  int ro = 0;
  for (int i = 0; i < s.size(); i++) {
    if (!tr[ro][s[i] - 'a']) tr[ro][s[i] - 'a'] = newNode();
    ro = tr[ro][s[i] - 'a'];
  }
  id[sid] = ro;
}

inline void build() {
  queue<int> q;
  for (int i = 0; i < charSet; i++)
    if (tr[0][i]) q.push(tr[0][i]), e[0].emp(tr[0][i]);
  while (q.size()) {
    int x = q.front();
    q.pop();
    for (int i = 0; i < charSet; i++) {
      int v = tr[x][i];
      if (v) {
        fail[v] = tr[fail[x]][i];
        q.push(v), e[fail[v]].emp(v);
      } else {
        tr[x][i] = tr[fail[x]][i];
      }
    }
  }
}

inline void dfs(int x) {
  sz[x] = 1, dfn[x] = ++dfnnum;
  for (int i = 0; i < e[x].size(); i++) dfs(e[x][i]), sz[x] += sz[e[x][i]];
}

inline void push_down(int ro) {
  tg[ls] = max(tg[ls], tg[ro]);
  tg[rs] = max(tg[rs], tg[ro]);
  mx[ls] = max(mx[ls], tg[ro]);
  mx[rs] = max(mx[rs], tg[ro]);
}

inline void update(int ro, int l, int r, int s, int e, int val) {
  if (s <= l && r <= e) {
    tg[ro] = max(tg[ro], val), mx[ro] = max(mx[ro], val);
    return;
  }
  push_down(ro);
  if (s <= mseg) update(ls, l, mseg, s, e, val);
  if (mseg < e) update(rs, mseg + 1, r, s, e, val);
  mx[ro] = max(mx[ls], mx[rs]);
}

inline int query(int ro, int l, int r, int x) {
  if (l == r) return mx[ro];
  push_down(ro);
  return x <= mseg ? query(ls, l, mseg, x) : query(rs, mseg + 1, r, x);
}

inline int dp(const string& s) {
  int ret = 0;
  for (int i = 0, ro = 0; i < s.size(); i++) {
    ro = tr[ro][s[i] - 'a'];
    ret = max(ret, query(1, 1, dfnnum, dfn[ro]));
  }
  return ret;
}

inline void solve() {
  cin >> n, init(), dfnnum = 0;
  for (int i = 1; i <= n; i++) cin >> pat[i] >> val[i], insert(pat[i], i);
  e.assign(tot + 1, vector<int>());
  build(), dfs(0);
  // 初始化应该到 4 * dfnnum
  for (int i = 0; i <= 4 * dfnnum; i++) tg[i] = mx[i] = 0;
  int ans = 0;
  for (int i = 1; i <= n; i++) {
    int tmp = val[i] + dp(pat[i]);
    update(1, 1, dfnnum, dfn[id[i]], dfn[id[i]] + sz[id[i]] - 1, tmp);
    ans = max(ans, tmp);
  }
  cout << "Case #" << ++kase << ": " << ans << endl;
}

 

例题三:P5829 【模板】失配树【两个前缀的公共border】

做法:fail树上的lca而已。

 

 

类型二:AC自动机与DP

例题一:POJ Censored! 

AC自动机的tran函数作为DP的转移函数,类似于KMP辅助DP,但是这道题是多个串都不能出现,所以需要使用AC自动机。

此题的数据十分小,直接暴力DP即可,复杂度为: \( O(M*tot*|∑|) = (50*50*500) \) tot为AC自动机的节点数,看题目描述应该是500的样子。

然后此题还有一些细节:

(1)在build自动机的使用,应该利用fail来传递end数组【end数组表示有字符串在这个节点结尾】,这样DP才能得出正确答案。【细节看代码】

(2)最后统计答案的时候,别忘了 \(i : [0,tot]\) ,别漏了0节点。

贴一发正整数高精。【不能减法除法以及负数】

查看代码

struct BigInteger {
  int A[25];
  enum { MOD = 10000 };
  BigInteger() {
    memset(A, 0, sizeof(A));
    A[0] = 1;
  }
  void set(int x) {
    memset(A, 0, sizeof(A));
    A[0] = 1;
    A[1] = x;
  }
  void print() {
    cout << A[A[0]];
    for (int i = A[0] - 1; i > 0; i--) {
      if (A[i] == 0) {
        cout << "0000";
      } else {
        for (int k = 10; k * A[i] < MOD; k *= 10) cout << "0";
        cout << A[i];
      }
    }
    cout << endl;
  }
  int& operator[](int p) { return A[p]; }
  const int& operator[](int p) const { return A[p]; }
  BigInteger operator+(const BigInteger& B) {
    BigInteger C;
    C[0] = max(A[0], B[0]);
    for (int i = 1; i <= C[0]; i++)
      C[i] += A[i] + B[i], C[i + 1] += C[i] / MOD, C[i] %= MOD;
    if (C[C[0] + 1] > 0) C[0]++;
    return C;
  }
  BigInteger operator*(const BigInteger& B) {
    BigInteger C;
    C[0] = A[0] + B[0];
    for (int i = 1; i <= A[0]; i++)
      for (int j = 1; j <= B[0]; j++) {
        C[i + j - 1] += A[i] * B[j], C[i + j] += C[i + j - 1] / MOD,
            C[i + j - 1] %= MOD;
      }
    if (C[C[0]] == 0) C[0]--;
    return C;
  }
} dp[55][maxn], ans;

int n, m, p;
string str;
map<int, int> id;
int tot, tr[maxn][55], fail[maxn], ed[maxn];

inline int newNode() {
  ++tot, me(tr[tot], 0), ed[tot] = fail[tot] = 0;
  return tot;
}

inline void insert(const string& s) {
  int ro = 0;
  for (const char& ch : s) {
    int v = id[(int)ch];
    cout << v << endl;
    if (!tr[ro][v]) tr[ro][v] = newNode();
    ro = tr[ro][v];
  }
  ed[ro] = 1;
}

inline void build() {
  queue<int> q;
  for (int i = 0; i < n; i++)
    if (tr[0][i]) q.push(tr[0][i]);
  while (q.size()) {
    int x = q.front();
    q.pop();
    for (int i = 0; i < n; i++) {
      if (tr[x][i]) {
        fail[tr[x][i]] = tr[fail[x]][i];
        ed[tr[x][i]] |= ed[fail[tr[x][i]]];  
        // 由于fail指向的是最长后缀,如果后缀都是end,那么节点x也是end
        q.push(tr[x][i]);
      } else {
        tr[x][i] = tr[fail[x]][i];
      }
    }
  }
}

 

例题二:Favourite Numbers 【AC自动机 + 二分 + 经典数位DP】

看到题目给出了范围 [L,R],很容易想到先算出有多少小于等于L-1的合法数字,然后加上K。

然后考虑二分范围 [L, mid],验证的话需要用到数位DP。

定义: DP[len][node][flag:0、1]为当状态的长度为len,在Trie图的node节点的合法数字数量。flag表示该状态是否已经有合法数字。

本来我也想不到要多加一维flag的,以为是无脑数位DP。。。

因为之前做的DP计数,我都是直接把ending节点的转移到自身节点,最后统计答案的时候,直接统计ending节点的答案即可。这里我们用dfs记忆化搜索来进行DP,是从初始状态往终点去统计的,所以就不能等最后统计ending节点。但是这一维flag还是可以舍掉的。

我们考虑如果当前节点node已经是ending节点了,那么以后无论加上什么数,都是转移到自己就行,这样就空间减半。【详细看代码】

另外,dfs进行数位DP计数的细节别忘了哦。不仅可以有上界,还可以同时具备上下界。【详细可见这个blog

这道题数据太水了。。。之前忘了写前导0以及limit都过了。。。

【重写之后的代码:】

查看代码

ll L, R, K, n, dp[20][1300];
int num[20], Len, tr[1300][10], fail[1300], ed[1300], tot;
string s;

inline void insert(const string& s) {
  int ro = 0;
  for (const char& c : s) {
    int v = (c & 0X0F);
    if (!tr[ro][v]) tr[ro][v] = ++tot;
    ro = tr[ro][v];
  }
  ed[ro] = 1;
}

inline void build() {
  queue<int> q;
  for (int i = 0; i < 10; i++)
    if (tr[0][i]) q.push(tr[0][i]);
  while (q.size()) {
    int x = q.front();
    q.pop();
    for (int i = 0; i < 10; i++) {
      if (tr[x][i]) {
        fail[tr[x][i]] = tr[fail[x]][i];
        ed[tr[x][i]] |= ed[fail[tr[x][i]]];
        q.push(tr[x][i]);
      } else {
        tr[x][i] = tr[fail[x]][i];
      }
    }
  }
}

inline ll dfs(int len, int id, bool lim, bool lead) {
  if (!len) return ed[id];
  if (!lead && !lim && dp[len][id] != -1) return dp[len][id];
  int mx = lim ? num[len] : 9;
  ll ret = 0;
  for (int i = 0; i <= mx; i++) {
    if (lead && !i) {
      ret += dfs(len - 1, id, lim && i == mx, 1);
    } else {
      int v = ed[id] ? id : tr[id][i];
      ret += dfs(len - 1, v, lim && i == mx, 0);
    }
  }
  if (!lim && !lead) dp[len][id] = ret;
  return ret;
}

inline void initNum(ll x) {
  me(num, 0), Len = 0;
  while (x > 0) num[++Len] = x % 10, x /= 10;
}

inline ll calc(ll x) {
  me(dp, -1), initNum(x);
  return dfs(Len, 0, 1, 1);
}

inline void solve() {
  cin >> L >> R >> K >> n;
  for (int i = 1; i <= n; i++) cin >> s, insert(s);
  build();
  K += calc(L - 1);
  ll l = L, r = R + 1, mid;
  while (l < r) {
    mid = (l + r) >> 1;
    calc(mid) < K ? l = mid + 1 : r = mid;
  }
  if (r > R) {
    cout << "no such number" << endl;
    return;
  }
  cout << r << endl;
}

 

拓展题:ZOJ BCD code【AC自动机 + 更高难度的数位DP】

题解:LINK

细节:

(1)L-1的时候,我把LM[i]='9'写成了LM[i]=='9'WA了半天。。。。

(2)数位DP只需要计算一遍DP数组即可,因为当limit和lead都是false的时候,数量只与状态有关。【不建议把limit和lead当成状态,因为不同的数limit是不同的】

该题难点主要在需要预处理出 bcd[i][j]数组,预处理出这个才能去做数位DP。

然后数位DP又与之前的不一样,这里因为前导0是不算入字符里的,所以多添加一维前导0的状态。

 

例题三:Text GeneratorⅠ 【SPOJ :反向思考】

题意:给出n个串pat,让你生成长度为L的串S,要求S至少包含一个pat。求这样合法的S的数量。

考虑正难则反,我们求出不包含pat的数量num(具体可以做这道题 POJ:DNA sequence),那么 \( ans = 26^{L} - num\) 。

DP就用矩阵快速幂优化一下。

然后复杂度就是 \( 60^3 *log_2(L) \)。但是这道题卡常,仅仅是这样的话,不足以通过此题。

我们考虑求不包含pat的时候,有ending的节点是不能转移的,那么我么可以直接把它们从trie图里面删掉。这样节点的数量至少少了10个。

具体实现请看代码,使用一个id数组把tot个节点重新编号即可。

复杂度变成 \( O(50^3*log_2(L)) \) 甚至更优,从1.4sTLE变成400ms。

查看代码

ll L, R, K, n, dp[20][1300];
int num[20], Len, tr[1300][10], fail[1300], ed[1300], tot;
string s;

inline void insert(const string& s) {
  int ro = 0;
  for (const char& c : s) {
    int v = (c & 0X0F);
    if (!tr[ro][v]) tr[ro][v] = ++tot;
    ro = tr[ro][v];
  }
  ed[ro] = 1;
}

inline void build() {
  queue<int> q;
  for (int i = 0; i < 10; i++)
    if (tr[0][i]) q.push(tr[0][i]);
  while (q.size()) {
    int x = q.front();
    q.pop();
    for (int i = 0; i < 10; i++) {
      if (tr[x][i]) {
        fail[tr[x][i]] = tr[fail[x]][i];
        ed[tr[x][i]] |= ed[fail[tr[x][i]]];
        q.push(tr[x][i]);
      } else {
        tr[x][i] = tr[fail[x]][i];
      }
    }
  }
}

inline ll dfs(int len, int id, bool lim, bool lead) {
  if (!len) return ed[id];
  if (!lead && !lim && dp[len][id] != -1) return dp[len][id];
  int mx = lim ? num[len] : 9;
  ll ret = 0;
  for (int i = 0; i <= mx; i++) {
    if (lead && !i) {
      ret += dfs(len - 1, id, lim && i == mx, 1);
    } else {
      int v = ed[id] ? id : tr[id][i];
      ret += dfs(len - 1, v, lim && i == mx, 0);
    }
  }
  if (!lim && !lead) dp[len][id] = ret;
  return ret;
}

inline void initNum(ll x) {
  me(num, 0), Len = 0;
  while (x > 0) num[++Len] = x % 10, x /= 10;
}

inline ll calc(ll x) {
  me(dp, -1), initNum(x);
  return dfs(Len, 0, 1, 1);
}

inline void solve() {
  cin >> L >> R >> K >> n;
  for (int i = 1; i <= n; i++) cin >> s, insert(s);
  build();
  K += calc(L - 1);
  ll l = L, r = R + 1, mid;
  while (l < r) {
    mid = (l + r) >> 1;
    calc(mid) < K ? l = mid + 1 : r = mid;
  }
  if (r > R) {
    cout << "no such number" << endl;
    return;
  }
  cout << r << endl;
}

 

例题四:DNA repair 【DP + Trie图】

题意:给定n个病毒串(长度不超过10),然后给出长度不超过1000的字符串S。你能够把S中的一个字符换成另外一个字符(要求是AGCT四种之一)。请问最少换多少次使得S不包含病毒串。无解输出-1。

定义 \(dp[i][j]\)为原串计算到第i个字母,在Trie图的j号节点时,至少要修改多少个字母。

首先初始化dp数组为inf,dp[0][0] = 0。

对于这道题的DP,我们需要删去所有的ending节点,或者在转移的时候忽略他们,同时转移方程如下:

令 tj = tr[j][k],则转移方程是 \( dp[i][tj] = min(dp[i][tj], dp[i-1][j] + (str[i] != k))  \)

理解:如果转移之后的字符和原来的不一样,说明更换一个字母才能走到tj,否则就不需要更换。

最后的答案就是 min_element(dp[n][0:tot])。

如果最小值都是inf,那么说明没有合法状态,也就是该DNA无法修复。

 

例题五:C. Genetic engineering 【DP,使用集合S里的字符串组成长度为L的串】

题意:给定m个子串,求构造长n的母串的方案数。母串中每个字符都至少来自一个子串。

定义 \(dp[i][j][k]\) 为 已经组成长度为i的串,当前处于Trie图的j号节点,但是末尾的k个字符还没有匹配的数量。

什么叫还没有匹配呢?我们遇到一个ending,就说明当前形成的字符串是恰好以S里的字符串结尾的,显然,此时是刚好匹配完所有字符的。

那么k就是我们上一次遇到ending的时候离现在有多少个字符。【感性的理解】

这些未匹配的字符要在下一个字符串中匹配。

所以我们需要维护一个len[i]数组(表示以第i个节点结尾的最长长度),转移的时候,假设tk是转移之后末尾未匹配字母的数量, \(len \ge k + 1\) 则说明多添加一个字符,能够使得后面k+1个字符得到匹配,此时tk = 0【原本是k个,加上转移的字符为k+1个】,否则就是 tk = k+1。

 

拓展题:Gao the String II 

题意:给定集合A和集合B,使用集合A的字符串组成一个长度小于等于L的字符串S,其权值为S包含多少集合B里面的字符串(可重复计数)。

此题做法与上题几乎一致。我们把集合A里面的字符串插入AC自动机里面,设置 \(len[ro]=s.size()\),但是不设置\(val[ro]\)

把集合B里面的字符串插入AC自动机里面,设置 \(val[ro]++\),但是不给len数组赋值。

最后像上一题一样转移,转移方程: \(dp[i+1][tj][tk] = max(dp[i+1][tj][tk],dp[i][j][k]+val[tj]\) 

细节:

(1)val记录了B集合字符串出现的数量,需要在build函数里面根据fail来传递!!!!

(2)问的是小于等于L的字符串的最大权值,所以不要只在长度L里面取max

 

例题六:Growing Strings 【AC自动机 + 经典DP】 很多人说简单,那就掌握它吧。

题意:给n个不同字符串,找出一个字符串序列满足前一个字符串是后一个字符串的子串(此题相对与例题二:GRE Words来说可以自己修改字符串之间的顺序)。输出最长序列的长度。

这道题我一开始是用last优化+拓扑排序做的,字符串i如果是字符串j的一个子串,那么i连一条向j的边。那么就是问最长路而已。

连边的话枚举n个字符串跑AC自动机,因为last是根据fail来转移的上一个ending节点,所以只需要跳一次last(拓扑排序只连后一个节点,多余的边不连)。【详细看代码】

细节:

(1)last[ro]并不意味着是ro节点处最长的ending节点,应该考虑ending[ro]是不是true。

(2)此题的ending无法传递,因为ending代表了字符串的编号【还好这道题没有重复字符串】

查看代码

 const int charSet = 26;
int n, fail[maxn], last[maxn], ed[maxn], tot, tr[maxn][charSet];
int du[maxn], dis[maxn];
string s[maxm];
vector<vector<int>> e;

inline int newNode() {
  ++tot, me(tr[tot], 0), last[tot] = ed[tot] = fail[tot] = 0;
  return tot;
}

inline void init() { tot = -1, newNode(); }

inline void insert(const string& s, int id) {
  int ro = 0;
  for (int i = 0; i < s.size(); i++) {
    if (!tr[ro][s[i] - 'a']) tr[ro][s[i] - 'a'] = newNode();
    ro = tr[ro][s[i] - 'a'];
  }
  ed[ro] = id;
}

inline void build() {
  queue<int> q;
  for (int i = 0; i < charSet; i++)
    if (tr[0][i]) q.push(tr[0][i]);
  while (q.size()) {
    int x = q.front();
    q.pop();
    for (int i = 0; i < charSet; i++) {
      int v = tr[x][i];
      if (v) {
        fail[v] = tr[fail[x]][i];
        // 初始化 last 数组
        last[v] = ed[fail[v]] ? fail[v] : last[fail[v]]; 
        // ending节点无法根据fail传递
        q.push(v);
      } else {
        tr[x][i] = tr[fail[x]][i];
      }
    }
  }
}

inline void solve() {
  init();
  for (int i = 1; i <= n; i++) cin >> s[i], insert(s[i], i), dis[i] = du[i] = 0;
  build();

  // 连边
  e.assign(n + 1, vector<int>());
  for (int t = 1; t <= n; t++) {
    int ro = 0;
    for (int i = 0; i < s[t].size(); i++) {
      ro = tr[ro][s[t][i] - 'a'];
      // 如果ro已经是ending节点了,那么就不需要跳last了。
      if (ed[ro] && ed[ro] != t) // id是t,不要写成i。
        e[ed[ro]].emp(t), du[t]++;
      else if (last[ro]) // 如果有last,就跳last
        e[ed[last[ro]]].emp(t), du[t]++;
    }
  }

  // 拓扑排序 
  queue<int> q;
  for (int i = 1; i <= n; i++)
    if (!du[i]) q.push(i), dis[i] = 1;
  while (q.size()) {
    int x = q.front();
    q.pop();
    for (int i = 0; i < e[x].size(); i++) {
      du[e[x][i]]--, dis[e[x][i]] = max(dis[e[x][i]], dis[x] + 1);
      if (!du[e[x][i]]) q.push(e[x][i]);
    }
  }

  int ans = 0;
  for (int i = 1; i <= n; i++) ans = max(ans, dis[i]);
  cout << ans << endl;
}

那么这里还给出一种DP的做法:

我们考虑节点ro,dp[ro]代表ro节点所代表的前缀所形成的最长路。

转移方程就是:\(dp[ro] = max(dp[fa[ro]], dp[fail[ro]]) + val[ro] \)

因为字符串S[1:ro]所在序列的前一个字符串必须是它的一个子串,那么只能来源于父节点或者fail链指向的后缀。然而前缀和后缀是不相容的,所以应该取max。

查看代码

const int charSet = 26;
int n, fail[maxn], tot, tr[maxn][charSet];
int val[maxn], dp[maxn];
string s;

inline int newNode() {
  ++tot, me(tr[tot], 0);
  dp[tot] = val[tot] = fail[tot] = 0;
  return tot;
}

inline void init() { tot = -1, newNode(); }

inline void insert(const string& s, int id) {
  int ro = 0;
  for (int i = 0; i < s.size(); i++) {
    if (!tr[ro][s[i] - 'a']) tr[ro][s[i] - 'a'] = newNode();
    ro = tr[ro][s[i] - 'a'];
  }
  val[ro]++;
}

inline void build() {
  queue<int> q;
  for (int i = 0; i < charSet; i++)
    if (tr[0][i]) q.push(tr[0][i]), dp[tr[0][i]] = val[tr[0][i]];
  val[0] = 0;
  while (q.size()) {
    int x = q.front();
    q.pop();
    for (int i = 0; i < charSet; i++) {
      int v = tr[x][i];
      if (v) {
        fail[v] = tr[fail[x]][i];
        dp[v] = max(dp[x], dp[fail[v]]) + val[v];
        q.push(v);
      } else {
        tr[x][i] = tr[fail[x]][i];
      }
    }
  }
}

inline void solve() {
  init();
  for (int i = 1; i <= n; i++) cin >> s, insert(s, i);
  build();
  int ans = 0;
  for (int i = 0; i <= tot; i++)
    if (val[i]) ans = max(ans, dp[i]);
  cout << ans << endl;
}

 

AC自动机习题

1.L. Karshilov's Matching Problem 2021哈尔滨【AC自动机fail链倍增 + 栈】

这道题被TLE推出来了。首先看到n个pattern串,考虑字符串利器AC自动机。然后每次修改只会修改末尾的字符。所以直接倍增(因为字符相同,所以对着同一个字符连续的跳,用倍增优化掉)就行了,然后还要使用一个栈/队列来维护关键分割点即可。

 

2. Problem - J - Codeforces 期望步数 【从一个串S跳到另一个串T,需要保证T是S的真子串】

这是一个很套路的题目。给定n个字符串,你能自己选一个开始字符串S,然后一直往它的真子串T跳下去。然后你会发现,这个东西实际上可以使用AC自动机在Fail树上模拟。下面就是解法的重点:

  • 这是因为AC自动机的Fail数组不是字符串S的Border的含义,而是整个字符串集合\(Set\)的前缀。
  • 如果我们想让这个前缀是一个完整的字符串T,我们可以跳last,即直接跳到下一个字符串。

而对于我们一开始选择的S,它对应了AC自动机上的一条路径,它的贡献就是这条路径的期望之和再除去某个系数。

 

 

参考题单:

(1)洛谷AC自动机题单 

(2)天亮说晚安‘s 题单  

(3)OIWIKI-AC自动机

posted @ 2022-02-12 14:07  PigeonG  阅读(75)  评论(0)    收藏  举报