把「号码后缀与不吉利数字的最长公共前缀长度」当作状态,用 KMP 失配链建出转移矩阵,再做矩阵快速幂,在 O(m^3 log n) 内计数不含给定子串的 n 位数字串。
OJ: roj
题目 ID: 1646
难度:提高+/省选-
标签:动态规划字符串字符串算法矩阵快速幂一本通
创建: 2026-09-30 23:55
更新: 2026-10-07 13:50
形式化题目
给定整数
即
注意长度必须恰好为
数据范围:
暴力解法
思路
把号码从左到右一位一位地考虑,维护一个数
每读入一位数字
关键观察是:
设
答案
代码
本题不提供暴力代码文件(任务要求 --no-brute --no-gen,教学上只需理解状态定义与转移,brute.cpp 并非必要),完整可运行实现见下方正解的 main.py;暴力解法不单独给出代码。
复杂度
时间
瓶颈
瓶颈不在
正解
思路
一句话本质:转移规则与位数
第一步:看清状态转移的结构。
对固定的 111 时,1,匹配长度就清零,这样的数字有 9 个。
只要系数与
则
第二步:怎样填出
- 若
且 ,说明"继续延长当前匹配"走不通,于是沿失配指针回退 ,直到回退到一个能让 的前缀; - 回退停止后,若
则新长度 (成功多匹配一位),否则 (一位都没匹配上); - 只有当
时才把这条转移记进 —— 意味着不吉利数字已经出现,这条路径必须被排除。
状态
下面用样例 11 的最长真前后缀是 1,故 fail[2] = 1)。四个状态与读入 1 时的走向如下:
graph LR
q0["q=0<br/>(未匹配)"] -->|"读 1"| q1["q=1<br/>后缀 1"]
q1 -->|"读 1"| q2["q=2<br/>后缀 11"]
q2 -->|"读 1"| q3["q=3<br/>出现 111"]
q1 -.->|"读非 1,失配回退"| q0
q2 -.->|"读非 1,失配回退"| q0
q0 -->|"读非 1"| q0
style q3 fill:#fdd,stroke:#c00
观察重点:只有读入 1 才会沿主链前进一位,读入其余 9 个数字都会把匹配长度清零;红色的 111,不再计数。回退虚线体现了 fail 链的作用:fail[2] = 1(后缀 1 仍是 A 的前缀),但此时要接的新数字仍不是 1,于是继续退到 fail[1] = 0,最终匹配长度归零。
据此填出转移矩阵(行 = 当前
| 0 | 1 | 2 | 3 | |
|---|---|---|---|---|
| 0 | 9 | 1 | 0 | 0 |
| 1 | 9 | 0 | 1 | 0 |
| 2 | 9 | 0 | 0 | 0 |
| 3 | 0 | 0 | 0 | 0 |
读表方法:1 的 9 个数字留在 1 进入 1 前进到 1 会变成 9。最后一行全 0,正是吸收态。
第三步:用矩阵快速幂处理
把
样例验证。 用样例 111、
| 已填位数 |
|||||
|---|---|---|---|---|---|
| 0 | 1 | 0 | 0 | 0 | 1 |
| 1 | 9 | 1 | 0 | 0 | 10 |
| 2 | 90 | 9 | 1 | 0 | 100 |
| 3 | 0 | 90 | 9 | 0 | 99 |
| 4 | 91 | 0 | 90 | 0 | 181 |
1 的号码都会丢掉匹配长度 2,而唯一保持匹配的选择——读 1——会凑齐 111 被剔除,所以长度为 3 的合法号码只剩 99 个。这正是"吸收态被排除"在数据上的直接体现。
代码
build_fail 求失配数组,build_transition 用失配链建出矩阵 mat_pow 做 solve 完成
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-09-30 23:55
# update_at: 2026-10-01 00:06
import sys
ALPHABET = range(10) # 每个 X_i 的取值范围 0..9
def build_fail(pat: list[int]) -> list[int]:
"""pat 的失配指针数组 fail[i]:pat[0:i+1] 的最长真前后缀长度。"""
fail = [0] * (len(pat) + 1)
for i in range(1, len(pat)):
j = fail[i]
while j and pat[i] != pat[j]: # 回退到能接上 pat[i] 的最长前缀
j = fail[j]
fail[i + 1] = j + 1 if pat[i] == pat[j] else 0
return fail
def build_transition(pat: list[int], fail: list[int]) -> list[list[int]]:
"""自动机转移矩阵 trans[q][q2]:已匹配 q 位时读入一个数字后匹配到 q2 位的数字个数。
状态 m 表示"不吉利数字已出现",是吸收态,题面禁止它出现,故不参与计数。
"""
m = len(pat)
trans = [[0] * (m + 1) for _ in range(m + 1)]
for q in range(m): # 状态 m 是吸收态,不需要从它出发的转移
for d in ALPHABET:
j = q
while j and pat[j] != d: # 失配则沿着 fail 链回退
j = fail[j]
nxt = j + 1 if pat[j] == d else 0
if nxt < m: # 只保留"还没出现完整不吉利数字"的转移
trans[q][nxt] += 1
return trans
def mat_mul(a: list[list[int]], b: list[list[int]], mod: int) -> list[list[int]]:
"""模 mod 的矩阵乘法:优化掉零元的稀疏乘,n,m<=20 时比 numpy 更可控。"""
n, k, m = len(a), len(b), len(b[0])
out = [[0] * m for _ in range(n)]
for i in range(n):
row = out[i]
for t in range(k):
if a[i][t]: # a[i][t] 为 0 时整行贡献为 0,直接跳过
v = a[i][t]
b_row = b[t]
for j in range(m):
row[j] = (row[j] + v * b_row[j]) % mod
return out
def mat_pow(base: list[list[int]], exp: int, mod: int) -> list[list[int]]:
"""矩阵快速幂:对 exp 做二进制分解,反复平方。"""
size = len(base)
result = [[int(i == j) for j in range(size)] for i in range(size)] # 单位矩阵
while exp:
if exp & 1:
result = mat_mul(result, base, mod)
base = mat_mul(base, base, mod)
exp >>= 1
return result
def solve() -> None:
it = iter(sys.stdin.buffer.read().split())
n = int(next(it)) # 号码长度
m = int(next(it)) # 不吉利数字长度
mod = int(next(it)) # 模数
pat = [c - 48 for c in next(it)] # 不吉利数字的 m 个十进制位(bytes 逐字节减 '0')
fail = build_fail(pat)
trans = build_transition(pat, fail)
# dp 向量:v[q] = 已经填了 i 位、当前匹配到不吉利数字前 q 位、且从未出现完整串的方案数。
# 每填一位就是右乘一次 trans,故 n 位后 v = e_0 * trans^n,答案是不含吸收态的全部状态和。
v = [[1] + [0] * m] # 初始向量 e_0(行向量),未填任何位时匹配长度必然是 0
v = mat_mul(v, mat_pow(trans, n, mod), mod)
print(sum(v[0]) % mod) # 状态 0..m-1 之和;状态 m 已在建矩阵时排除
if __name__ == "__main__":
solve()C++ 版(同一算法):
/**
* 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 00:19
* update_at: 2026-10-06 00:19
*/
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int MAXM = 25; // m <= 20,矩阵是 (m+1) 阶,开 25 足够
ll n, m, mod;
ll a[MAXM]; // a[i]:不吉利数字的第 i 位(下标从 0 开始),取值 0..9
ll fail[MAXM]; // fail[i]:a[0..i-1] 的最长真前后缀长度(KMP 失配数组)
ll trans[MAXM][MAXM]; // trans[q][q2]:已匹配 q 位时读一个数字变成 q2 位的数字个数
ll base_mat[MAXM][MAXM], result_mat[MAXM][MAXM], tmp_mat[MAXM][MAXM]; // 矩阵幂用的三个方阵
ll v[MAXM]; // v[q]:填了若干位后当前匹配长度为 q 且从未出现完整不吉利数字的方案数
// 求 KMP 失配数组:fail[i] 表示前 i 位的最长相等真前后缀长度。
void build_fail() {
fail[0] = 0;
fail[1] = 0;
for (ll i = 1; i < m; i++) {
ll j = fail[i];
while (j && a[i] != a[j]) j = fail[j]; // 回退到还能接上 a[i] 的前缀
if (a[i] == a[j]) j++;
fail[i + 1] = j;
}
}
// 建转移矩阵:从匹配长度 q 出发,对 10 个数字分别求 KMP 转移后的新长度。
// 只统计新长度 q2 < m 的转移;q2 == m 意味着不吉利数字出现,这条路径直接排除。
void build_transition() {
memset(trans, 0, sizeof(trans));
for (ll q = 0; q < m; q++) { // 吸收态 m 不需要出边
for (ll d = 0; d <= 9; d++) {
ll j = q;
while (j && a[j] != d) j = fail[j]; // 沿失配链回退
ll q2 = j + 1;
if (a[j] != d) q2 = 0;
if (q2 < m) trans[q][q2]++; // 多少个数字能完成 q -> q2 的转移
}
}
}
// 矩阵乘法 c = a * b(mod 意义下),size = m+1。
void mat_mul(ll c[][MAXM], ll x[][MAXM], ll y[][MAXM]) {
memset(tmp_mat, 0, sizeof(tmp_mat));
for (ll i = 0; i <= m; i++) {
for (ll t = 0; t <= m; t++) {
if (x[i][t] == 0) continue; // 零元整行贡献为 0,跳过
for (ll j = 0; j <= m; j++) {
tmp_mat[i][j] = (tmp_mat[i][j] + x[i][t] * y[t][j]) % mod;
}
}
}
// 结果先写进 tmp_mat,再拷回 c,避免 x 或 y 就是 c 时被覆盖出错
for (ll i = 0; i <= m; i++) {
for (ll j = 0; j <= m; j++) {
c[i][j] = tmp_mat[i][j];
}
}
}
// 矩阵快速幂:result_mat = trans^n(mod 意义下),二进制分解反复平方。
void mat_pow() {
for (ll i = 0; i <= m; i++) {
for (ll j = 0; j <= m; j++) {
result_mat[i][j] = (i == j); // 单位矩阵
}
}
memcpy(base_mat, trans, sizeof(trans));
ll e = n;
while (e) {
if (e & 1) mat_mul(result_mat, result_mat, base_mat);
mat_mul(base_mat, base_mat, base_mat);
e >>= 1;
}
}
// 求答案:初始向量 v = e_0,右乘 trans^n,再对非吸收态求和。
void solve() {
build_fail();
build_transition();
mat_pow();
memset(v, 0, sizeof(v));
v[0] = 1; // 没填任何位时匹配长度必然是 0,方案数 1
ll ans = 0;
for (ll q = 0; q < m; q++) { // 状态 m 是吸收态,不参与计数
ll sum = 0;
for (ll t = 0; t <= m; t++) {
sum = (sum + v[t] * result_mat[t][q]) % mod;
}
ans = (ans + sum) % mod;
}
cout << ans << endl;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
string s;
cin >> n >> m >> mod >> s;
for (ll i = 0; i < m; i++) {
a[i] = s[i] - '0'; // 不吉利数字的每一位,允许前导 0
}
solve();
return 0;
}复杂度
- 时间复杂度:
。构造自动机 量级,矩阵快速幂共 层、每层 。代入 、 :约 次乘加。 - 空间复杂度:
,保存常数个 矩阵, 时不到 400 个整数。实测峰值内存 14.9 MB。
总结
本题的关键不是"数数",而是找到能把历史压缩成一个数的状态:号码当前后缀与不吉利数字的最长公共前缀长度
实测 10 个真实评测点全部通过,最大规模(