匹配统计

GitHub跳转原题关系图返回列表

把 B 与 A 用分隔符拼成 B#A,一次 Z 函数即得 A 每个后缀与 B 的 LCP,再做长度直方图,每个询问 O(1) 回答;总 O(N+M+Q)。

OJ: roj

题目 ID: 3054

难度:提高

标签:字符串Z函数python

创建: 2026-10-01 12:29

更新: 2026-10-07 13:50

形式化题目

给定长度 NN 的串 AA、长度 MM 的串 BB。对每个起点 i∈[1,N]i \in [1, N],定义它的匹配长度

Li=max⁡{ k⩽M:A[i..i+k−1]=B[1..k] },L_i = \max\{\, k \leqslant M : A[i..i+k-1] = B[1..k] \,\},

即后缀 A[i..N]A[i..N] 与 BB 的最长公共前缀长度(受 BB 的长度限制,最多匹配 MM 个字符)。 题目给出 QQ 个询问,每个询问一个整数 xx,要求输出满足 Li=xL_i = x 的起点个数

cnt[x]=∣{ i:Li=x }∣.\mathrm{cnt}[x] = \bigl|\{\, i : L_i = x \,\}\bigr|.

xx 可以超过 NN 或 MM,此时答案为 00。

题面示例:A = $ `aabcde`,B = $ ab,六个后缀 aabcde、abcde、bcde、cde、de、e 的匹配长度依次是 1,2,0,0,0,01, 2, 0, 0, 0, 0。下表把「后缀、与 BB 的公共前缀、匹配长度」逐行对齐, 注意长度是截断在 BB 的末尾的:

起点 ii 后缀 与 ab 的公共前缀 LiL_i
1 aabcde a(第 2 位 a ≠\neq b) 1
2 abcde ab(已比完 BB) 2
3 bcde 空 0
4 cde 空 0
5 de 空 0
6 e 空 0

于是 cnt[0]=4\mathrm{cnt}[0] = 4、cnt[1]=1\mathrm{cnt}[1] = 1、cnt[2]=1\mathrm{cnt}[2] = 1,x⩾3x \geqslant 3 全为 00, 与样例输出 4 1 1 0 0 一致。

正解

思路

朴素做法的瓶颈。 最直接的办法是对每个起点 ii 从零开始逐字符比:令 kk 从 00 递增, 只要 k<Mk < M、i+k⩽Ni + k \leqslant N 且 A[i+k]=B[k+1]A[i+k] = B[k+1] 就让 kk 加一。每趟最多比 MM 个字符, 总代价 O(NM)O(NM);当 AA 与 BB 都是 aaaa... 时 N=M=2×105N = M = 2 \times 10^5,要比较 4×10104 \times 10^{10} 次,必须优化。

瓶颈在于信息没有共享:AA 的相邻后缀之间高度重合,前一个位置比过的字符,后一个位置 又从头比了一遍。而且要注意,这题问的并不是「BB 在 AA 的哪些位置完整出现」这一个布尔 信息,而是每个后缀与 BB 的 LCP 长度这一整族量——只报告匹配位置的算法(str.find、 单次 KMP 匹配)拿不到它。

把整族 LCP 交给 Z 函数。 Z 函数的定义恰好就是 LCP:

z[j]=max⁡{ k:s[j..j+k−1]=s[1..k] }.z[j] = \max\{\, k : s[j..j+k-1] = s[1..k] \,\}.

把两个串拼起来,让 BB 当「模式串」、AA 当「被对齐的文本」:

s=B+#+A,#=chr(ord(′z′)+1).s = B + \# + A, \qquad \# = \mathrm{chr}(\mathrm{ord}('z') + 1).

对 AA 的起点 ii,它在 ss 中的下标是 j=M+1+ij = M + 1 + i,此时 s[j..]s[j..] 与 s[1..]s[1..] 的最长 公共前缀就是 BB 与 A[i..]A[i..] 的最长公共前缀,也就是 LiL_i。一次 Z 函数就同时算出了所有 起点的答案。

分隔符 #\# 有两个作用,缺一不可:

  1. 它大于所有小写字母、不等于 AA 或 BB 的任何字符,所以匹配不可能「跨过」它把 BB 的 末尾和 AA 的开头接起来;
  2. 更关键的是,它让 z[j]⩽Mz[j] \leqslant M:一旦比到 s[M]=#s[M] = \# 就必然失配, LCP 天然被截断在 BB 的末尾,正好符合题目「最多匹配 MM 个字符」的定义,不需要再写 额外的 min 或截断判断。

延续上面的样例,s = $ `ab{aabcde`(`{` 就是 $\#)的 zz 数组是 0 0 0 1 2 0 0 0 0,切掉 BB 段和分隔符后的 1 2 0 0 0 0 正是 L1…L6L_1 \dots L_6:

ss 的下标 jj 1 2 3 4 5 6 7 8 9
字符 a b { a a b c d e
z[j]z[j] 0 0 0 1 2 0 0 0 0

观察重点:下标 44、55 对应 AA 的前两个起点,其 zz 值恰好是匹配长度 11、22;下标 6 往后是 bcde 与 B = $ `ab` 首字符就不同,全是 $0。也可以看下标 44:s[4..]s[4..] 与 ab{aabcde 的首字符都是 a,但第二位 a ≠\neq b 于是停住,z=1z = 1——匹配在 BB 的中途结束。

Z 算法为什么线性。 维护当前右端最靠后的一个匹配段(称为 Z 盒)[l,r)[l, r),其中 s[l..r−1]=s[1..r−l]s[l..r-1] = s[1..r-l]。对位置 jj:

  • 若 j<rj < r,由 Z 盒的对称性,z[j]z[j] 至少是 min⁡(r−j, z[j−l])\min(r - j,\ z[j - l])——可以直接借用左侧 对称位置已经算好的值,再从这个下界继续往右比;
  • 每轮结束时,若 j+z[j]>rj + z[j] > r 就把盒子右移到 j+z[j]j + z[j]。

盒子右端 rr 单调不减且不超过 ∣s∣|s|,因此「盒内成功比较」的总次数被 rr 的总增量限制, 而每次失败比较都恰好发生在盒子右端、同样被 rr 的总增量限制;整趟均摊 O(∣s∣)O(|s|)。

从长度到答案:直方图。 拿到 L1,…,LNL_1, \dots, L_N 之后,询问只是「某个值出现了几次」。 开一个长度 M+1M + 1 的桶 hit,对每个 LiL_i 做一次 hit[L_i] += 1,则询问 xx 的答案就是 hit[x](x>Mx > M 时直接为 00,因为 Li⩽ML_i \leqslant M 恒成立)。这样每个询问 O(1)O(1), 不必对每个询问重新扫描一遍 LL。

代码

Python 版:

python
#!/usr/bin/env python3
# Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
# rbook: -> https://rbook.roj.ac.cn  https://rbook2.roj.ac.cn
# rainboy的学习导航网站: https://idx.roj.ac.cn
# create_at: 2026-10-01 12:29
# update_at: 2026-10-01 12:29

import sys

DELIM = ord('z') + 1  # 分隔符:大于所有小写字母,保证匹配不会越过 B 的结尾(长度恒不超过 M)


def z_function(seq: bytes) -> list[int]:
    """Z 函数:z[i] 是 seq 与 seq[i:] 的最长公共前缀长度,均摊 O(|seq|)。"""
    z = [0] * len(seq)
    left = right = 0  # 目前右端最靠后的匹配段 [left, right)
    for i in range(1, len(seq)):
        if i < right:
            z[i] = min(right - i, z[i - left])  # 借用左端对称位置的答案,再截断到已知段的右端
        while i + z[i] < len(seq) and seq[z[i]] == seq[i + z[i]]:
            z[i] += 1
        if i + z[i] > right:
            left, right = i, i + z[i]
    return z


def solve() -> None:
    data = iter(sys.stdin.buffer.read().split())
    n = int(next(data))
    m = int(next(data))
    q = int(next(data))
    a = next(data)
    b = next(data)
    queries = [int(next(data)) for _ in range(q)]

    # 拼成 B + 分隔符 + A,一次 Z 函数就同时给出 A 每个后缀与 B 的匹配长度
    z = z_function(b + bytes((DELIM,)) + a)[m + 1:]

    hit = [0] * (m + 1)  # hit[length] = 匹配长度恰好为 length 的后缀个数
    for length in z:
        hit[length] += 1

    ans = [hit[t] if t <= m else 0 for t in queries]  # 匹配长度不会超过 M
    print('\n'.join(map(str, ans)))


if __name__ == "__main__":
    solve()

C++ 版(同一算法):

cpp
/**
 * Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
 * rbook: -> https://rbook.roj.ac.cn  https://rbook2.roj.ac.cn
 * rainboy的学习导航网站: https://idx.roj.ac.cn
 * create_at: 2026-10-06 16:52
 * update_at: 2026-10-06 16:52
 */

#include <bits/stdc++.h>
using namespace std;

const int MAXN = 200005;              // A 串长度上限
const int MAXM = 200005;              // B 串长度上限
const int MAXS = MAXN + MAXM + 5;     // 拼接串长度上限:B + 分隔符 + A

typedef long long ll;

ll n, m, q;
char A[MAXN];       // 被匹配的文本串
char B[MAXM];       // 模式串
char s[MAXS];       // 拼接串 s = B + 分隔符 + A,下标从 1 开始
int z[MAXS];        // z[i] 表示 s 与 s[i..] 的最长公共前缀长度
int hit[MAXM];      // hit[len] 表示匹配长度恰好为 len 的后缀个数

// 计算 s[1..len] 的 Z 数组,均摊 O(len)。
void z_function(int len) {
    z[1] = 0;
    int left = 1, right = 1; // 当前右端最靠后的匹配段 [left, right)
    for (int i = 2; i <= len; i++) {
        if (i < right) {
            // 借用左侧对称位置的答案,并截断到已知段的右端
            z[i] = min(right - i, z[i - left + 1]);
        }
        // 从下界继续往右逐字符比较
        while (i + z[i] <= len && s[z[i] + 1] == s[i + z[i]]) {
            z[i]++;
        }
        if (i + z[i] > right) {
            left = i;
            right = i + z[i];
        }
    }
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> n >> m >> q;
    cin >> (A + 1);
    cin >> (B + 1);

    // 拼接:s[1..m] 放 B,s[m+1] 放分隔符,s[m+2..m+1+n] 放 A
    for (int i = 1; i <= m; i++) {
        s[i] = B[i];
    }
    s[m + 1] = 'z' + 1; // 大于所有小写字母,保证匹配不会跨过分隔符
    for (int i = 1; i <= n; i++) {
        s[m + 1 + i] = A[i];
    }
    z_function(m + 1 + n);

    // A 的起点 i 在 s 中的下标是 m+1+i,其 z 值就是它与 B 的最长公共前缀
    for (int i = 1; i <= n; i++) {
        hit[z[m + 1 + i]]++;
    }

    // 每个询问 O(1) 回答;匹配长度不会超过 M
    for (int i = 1; i <= q; i++) {
        ll x;
        cin >> x;
        if (x >= 0 && x <= m) {
            cout << hit[x] << '\n';
        } else {
            cout << 0 << '\n';
        }
    }

    return 0;
}

z_function(seq) 是 Z 算法本体,返回整个 zz 数组;solve 里 z_function(b + bytes((DELIM,)) + a)[m + 1:] 一次完成拼接与切片,切片后下标 jj 就对应 起点 jj;随后的 hit 循环建直方图,最后一行按询问顺序 O(1)O(1) 取值输出,t <= m 同时挡住「越界」和「不可能存在的匹配长度」两种情况。

复杂度

  • 时间 O(N+M+Q)O(N + M + Q):拼接串长 M+1+NM + 1 + N,Z 函数均摊线性;建直方图 O(N)O(N);QQ 个询问 各 O(1)O(1)。
  • 空间 O(N+M)O(N + M):拼接串与 zz 数组各占 O(N+M)O(N + M),直方图占 O(M)O(M)。

总结

  • 题目本质是「求 AA 每个后缀与 BB 的 LCP 的分布」,而不是「BB 在 AA 中出现在哪里」; 后者(str.find、单次 KMP 匹配)拿不到这族长度。
  • 把 BB 放在前面、用不出现在字母表中的分隔符隔开 AA,AA 段的 Z 数组就直接给出全部 LiL_i;分隔符同时把 LCP 天然截断到 MM,与题目定义自动吻合。
  • 长度到手后只需一个大小为 M+1M+1 的直方图,QQ 个询问各自 O(1)O(1),总复杂度 O(N+M+Q)O(N + M + Q)、空间 O(N+M)O(N + M)。