0%

题目大意

太近就不合法了

时间限制:3000 MS (Others) / 6000 MS (Java)

内存限制:524288 KB (Others) / 524288 KB (Java)

nn 个依次排列的位置,编号为 1,2,,n1, 2, \ldots, n

每次询问给出两个整数 x,kx, k。你需要选择若干位置,满足以下两个条件:

  1. 位置 xx 不能被选择。

  2. 任意两个被选择的位置的编号之差的绝对值不小于 kk

每次询问相互独立。请你求出每次询问的合法选择方案数。注意,允许不选择任何位置。由于答案可能很大,请将结果对 998244353998244353 取模后输出。

第一行包含一个整数 TT,表示测试数据的组数。

对于每组测试数据:

第一行包含两个整数 n,qn, q,分别表示位置的数量和询问的数量。

接下来 qq 行,每行包含两个整数 xi,kix_i, k_i,表示一次询问。

数据范围与约定

  • T=104T = 10^4

  • 1n,q1051 \le n, q \le 10^5

  • 1xi,kin1 \le x_i, k_i \le n

  • n=106\sum n = 10^6

  • q=106\sum q = 10^6

对于每次询问,输出一行一个整数,表示合法的选择方案数对 998244353998244353 取模后的结果。

Plaintext

1
2
3
4
5
6
7
8
9
10
3
5 2
3 2
4 3
1 2
1 1
1 1
3 2
2 1
1 3

Plaintext

1
2
3
4
5
6
9
7
1
1
4
3

第一组测试数据

  • 共有 55 个位置,即 {1,2,3,4,5}\{1, 2, 3, 4, 5\}

  • 第一次询问x=3,k=2x=3, k=2):不能选择位置 33,且相邻选择的位置距离至少为 22。合法的方案有 99 种,分别为:空集 \emptyset, {1}\{1\}, {2}\{2\}, {4}\{4\}, {5}\{5\}, {1,4}\{1, 4\}, {1,5}\{1, 5\}, {2,4}\{2, 4\}, {2,5}\{2, 5\}

  • 第二次询问x=4,k=3x=4, k=3):不能选择位置 44,且相邻选择的位置距离至少为 33。合法的方案有 77 种,分别为:空集 \emptyset, {1}\{1\}, {2}\{2\}, {3}\{3\}, {5}\{5\}, {1,5}\{1, 5\}, {2,5}\{2, 5\}(注意 {1,4}\{1, 4\} 不合法因为包含了 44)。

第二组测试数据

  • 只有 11 个位置,即 {1}\{1\}

  • 两次询问均为 x=1,k=1x=1, k=1,不能选择位置 11。唯一合法的方案是不选择任何位置,即空集 \emptyset,方案数为 11

第三组测试数据

  • 共有 33 个位置,即 {1,2,3}\{1, 2, 3\}

  • 第一次询问x=2,k=1x=2, k=1):不能选择位置 22,相邻位置距离至少为 11。合法方案有 44 种:\emptyset, {1}\{1\}, {3}\{3\}, {1,3}\{1, 3\}

  • 第二次询问x=1,k=3x=1, k=3):不能选择位置 11,相邻位置距离至少为 33。合法方案有 33 种:\emptyset, {2}\{2\}, {3}\{3\}

思路讲解

image

image

image

image

下面的这个组合式子,可以比较简单的使用这个映射来证明啊。

f(m,k)=c=0m+k1k(m(c1)(k1)c)f(m, k) = \sum_{c=0}^{\lfloor \frac{m+k-1}{k} \rfloor} \binom{m - (c-1)(k-1)}{c}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
constexpr ll SQRT = 200;

ll f_cal(ll m, ll k) {
ll ans = 0;
ll up = (m + k - 1) / k;
for (int c = 0; c <= up; ++c) {
ans += CC(m - (c - 1) * (k - 1), c);
ans %= mod;
}
ans %= mod;
if (ans < 0) ans += mod;
return ans;
}

void Solve() {
Cin(N, Q);
map<ll, vector<ll> > mp;
vector<vector<ll> > F(N + 2, vector<ll>(min(N, SQRT) + 2));

for (int k = 1; k <= min(N, SQRT); ++k) {
F[0][k] = 1;
for (int i = 1; i <= N; ++i) {
F[i][k] = F[i - 1][k] + (i - k < 0 ? 1 : F[i - k][k]);
F[i][k] %= mod;
}
}
for (int _ = 1; _ <= Q; ++_) {
ll x, k;
Cin(x, k);
ll up = (N + k - 1) / k;
if (k > SQRT) {
// 这个的计算是 k 越小,计算的越多的啊。
ll ans = f_cal(N, k) - (x - k < 0 ? 1 : f_cal(x - k, k)) * (
N - x - k + 1 < 0 ? 1 : f_cal(N - x - k + 1, k));
ans %= mod;
if (ans < 0) ans += mod;
write(ans);
putc('\n');
} else {
// 因此我们把这个 k 比较小的这个直接用数组存起来,后面直接输出即可
ll ans = 0;
ans = F[N][k] - (x - k < 0 ? 1 : F[x - k][k]) * (N - x - k + 1 < 0 ? 1 : F[N - x - k + 1][k]);
ans %= mod;
if (ans < 0) ans += mod;
write(ans);
putc('\n');
}
}
}

AC代码

AC
https://acm.hdu.edu.cn/contest/view-code?cid=1236&rid=7030

心路历程(WA,TLE,MLE……)

题目大意

前缀码

时间限制: C/C++/Rust/Pascal 2秒,其他语言4秒

空间限制: C/C++/Rust/Pascal 1024 M,其他语言2048 M

在这道题目中,码字(codeword)指一个非空字符串,码本(codebook)指一个由若干互不相同的码字组成的非空集合。

如果一个码本中任意两个不同的码字 xxyy,都满足 xx 不是 yy 的前缀,且 yy 也不是 xx 的前缀,则该码本被称为前缀码(prefix code)。

Soy 得到了两个非空字符串 sspp。他想使用 ss 的子串作为码字来构造一个前缀码。但是,他不希望 pp 在任何码字中出现。

更正式地说,如果一个字符串 xx 满足以下所有条件,则它是一个合法的码字

  • xx 是非空字符串;

  • xxss 的一个子串;

  • pp 不是 xx 的子串。

子串仅通过其字符串内容进行区分。如果同一个字符串在 ss 的多个不同位置出现,它仍然只被视为一个合法的码字。

一个有效的码本指完全由合法码字组成的前缀码。换句话说,它是一个由合法码字组成的非空集合 CC,使得对于集合中的任意两个不同字符串 x,yCx, y \in C,互不为对方的前缀。

如果两个有效码本中,存在一个码本包含某个码字而另一个码本不包含,则认为这两个码本是不同的。

请计算 Soy 能构造出多少种不同的有效码本。由于答案可能非常大,请将结果对 998244353998244353 取模后输出。

第一行包含一个字符串 ss1s51051 \le \vert{}s\vert{} \le 5 \cdot 10^5)。

第二行包含一个字符串 pp1p51051 \le \vert{}p\vert{} \le 5 \cdot 10^5)。

保证两个字符串均仅由小写英文字母组成。

输出一个整数,表示不同的有效码本数量对 998244353998244353 取模后的结果。

在样例中,s="aba"s = \text{"aba"} 的子串包含 a, b, ab, ba, aba。其中包含 p="ba"p = \text{"ba"} 的子串有 baaba

剔除掉包含 pp 的子串后,合法码字的集合为:{"a", "b", "ab"}

利用这些合法码字构成的有效码本(即集合内的码字互不为前缀)如下:

  1. {"a"}

  2. {"b"}

  3. {"ab"}

  4. {"a", "b"}

  5. {"b", "ab"}

注意:{"a", "ab"} 不是有效码本,因为 "a""ab" 的前缀。

因此总共有 55 种不同的有效码本。

思路讲解

注意,使用 endpos 集合对这个字符串的子串进行分组啊,而不是反过来。

可以看到 BB 和 ABB 的 endpos 集合相同,所以被放在了同一个 SAM 节点啊

这个是自动机:

image

这个是 link 树:

image

一开始的时候(即刚遍历完 ab 的时候) b 和 ab 显然是处于同一个节点中。

image

然后读入到第三个字符 ‘b’ 的时候,b 和 ab 就要分道扬镳了。

image

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 定义后缀自动机的最大状态数,通常是字符串长度的两倍
const int MAXLEN = 200005;

// 后缀自动机节点结构
struct State {
int len; // 该状态包含的最长子串的长度
int link; // 后缀链接,指向包含当前状态最短子串的最长真后缀的状态
// (把最短子串的第一个字母去掉,也就是所谓的最长真后缀)
int next[26]; // 字符转移边,假设字符集为小写字母 a-z
};

State st[MAXLEN * 2];
int sz; // 当前自动机中的状态总数
int last; // 指向当前整个字符串完整前缀所在的状态

// 初始化后缀自动机
void sam_init() {
// 状态 0 是初始状态,代表空串
st[0].len = 0;
st[0].link = -1;
for (int i = 0; i < 26; ++i) {
st[0].next[i] = -1;
}
sz = 1;
last = 0;
}

后缀自动机的重要性质

这些性质乍看之下非常简单啊,但是务必事先清楚,否则之后可能会看不懂一些东西啊。

核心定理:同一个状态里的字符串,长度必定是绝对连续的
在 SAM 中,如果状态 VV包含了一个短字符串 SS 和一个长字符串 LL,那么介于它们长度之间的所有后缀,必须全部都在状态 VV **,绝对不可能有断层。为什么?(反证法)**
假设状态里有长度为 3 的串和长度为 5 的串(它们在原串中出现的集合 endposendpos 是一模一样的,比如都出现了 4 次)。
那么长度为 4 的那个后缀,它的出现次数绝不可能超过 4 次(因为它包含长度为 5 的串),也绝不可能少于 4 次(因为它被长度为 3 的串包含)。
所以长度为 4 的串,出现集合也必定完全一样!它必须乖乖待在同一个状态里。

前提:同一个状态里的所有字符串,它们的结束位置集合(endposendpos)是完全一模一样的。

推导:假设我们在原字符串中随便挑一个它们的共同结束位置,比如位置 P

结论:既然结束位置固定在了 P,如果你要在这个位置截取一个长度为 L 的字符串,你只能往前数 L 个字符。位置一旦固定,长度一旦固定,切出来的字符串必然是唯一确定的**!**

在 SAM 中,任何一条转移边 u --(c)--> v 的构建,都必须且只能遵循唯一一条规则:
字符串 maxstr(u)+cmaxstr(u) + c endposendpos 集合,必须等于状态 vv 代表的 endposendpos 集合。

image

插入字符,构造后缀自动机:函数 sma_insert()

插入一个后缀字符 cc,我们到底要修改什么,其影响范围是什么?

只有新字符串的“后缀”们,它们的 endposendpos 才会新增 LL 其他任何子串的 endposendpos 都不变。

所以我们要对所有的新字符串的后缀进行修改。

新字符串即:比如说,abaababaab,遍历到了 abaaabaa, 最后的 bb 还没遍历到,abaaabaa 就是我们的新字符串

建立新字符串对应的 State 节点

这个就是平平无奇的这个初始化啊。相当于 new 一个下标为 cur 的这个节点啊,代表目前整个后缀字符串啊。(比如说,abaababaab,遍历到了 abaaabaa, 最后的 bb 还没遍历到,abaaabaa 的这个长度为 4 啊)

1
2
3
4
5
6
sz++;
int cur = sz;
st[cur].len = st[last].len + 1;
for (int i = 0; i < 26; ++i) {
st[cur].next[i] = -1;
}

2. 更新旧后缀的转移边

1
2
3
4
while (p != -1 && st[p].next[c - 'a'] == -1) {
st[p].next[c - 'a'] = cur;
p = st[p].link;
}

此时我们要把之前字符串的所有后缀都加上字符 cc,让它们变成新字符串的后缀。

我们通过 link 链不断回溯 lastlast 的各个后缀状态 pp

  • 如果 pp 没有字符 cc 的转移边,说明这个后缀加上 cc 后形成的新子串以前从未出现过(即 maxstr(p)+cmaxstr(p) + ‘c’ 对应的字符串后缀之前没有出现过啊(maxstr(p)maxstr(p),就是状态 p 所包含的最长子串,++ 是指的字符串与字符拼接)),我们直接给它连一条边指向 curcur

  • 如果回溯过程中遇到了一个状态 pp 已经有了 cc 的转移边,说明从这个长度开始的后缀加上 cc 形成的子串,在之前的字符串里已经出现过了,循环终止。

  • 如果我们强行继续更新下面的,由于不是新子串,我们不能够保证这个任何一条转移边 u --(c)--> v 必须满足:字符串 maxstr(u)+cmaxstr(u) + cendposendpos 集合,必须等于状态 vv 代表的 endposendpos 集合。

3. 处理后缀链接 (核心难点,分三种情况)

1
2
3
if (p == -1) {
st[cur].link = 0;
}

如果一直回溯到了虚拟边界(p=1p = -1),说明字符 cc 以前压根就没出现过。新状态 curcur 没有其它真后缀曾在图中出现,它的后缀链接直接指向根节点(状态 00,代表空串)。

1
2
3
4
5
6
7
8
int to = st[cur].next[c - BASE];
// to 可以作为我们的 link 啊
// 但是我们需要其为最小串的这个去掉首字母以后的串啊
if (st[p].len + 1 == st[to].len) {
// (p 是字符串)p + ‘c’ 就是 to 中的最长子串,因此不需要对这个 to 进行这个分裂
// 根据 end pos 性质,to 中更短的,也是这个 cur 的这个后缀啊
st[cur].link = to;
}

状态 pp 是我们找到的第一个拥有 cc 转移边的状态,它转移到了 toto

如果 st[p].len+1==st[to].lenst[p].len + 1 == st[to].len说明 toto 状态恰好只包含我们需要的那部分后缀(没有比它更长的了,如果有比这个 maxstr(p)+cmaxstr(p) + c 更长的啊,就麻烦了,因为我们保证 maxstr(p)+cmaxstr(p) + c 这个是 curcur 的后缀,但是更长的,就一定不是 curcur 的后缀了)。这是一种完美的平滑过渡,toto 理所当然地成为了 curcur 的后缀链接。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
} else {
sz++;
int clone = sz;
st[clone].link = st[to].link;
st[clone].len = st[p].len + 1;
for (int i = 0; i < 26; ++i) {
st[clone].next[i] = st[to].next[i];
}
while (p != -1 && st[p].next[c - BASE] == to) {
// 原先的,转移到 to 已经不合适了
// maxstr(p) + c 的这个 endpos 包含 cur
// 但是原来的 to 是不包含 cur 的,因此需要更新
// 更新为这个这个 clone,因为 clone endpos 包含这个 cur
st[p].next[c - BASE] = clone;
p = st[p].link;
}
st[cur].link = clone;
// 使用连续性定理(后缀自动机的性质里面提到的),我们知道 to 中包含 len(maxstr(p))+2 的这个字符串啊
// 因此也接到这个 clone 上面啊
st[to].link = clone;
}

这是 SAM 构造中最精妙的部分。如果 st[p].len+1<st[to].lenst[p].len + 1 < st[to].len,说明出现了断层

  • 为什么会这样? toto 状态原本代表了多个不同长度的子串。由于当前追加了新字符 cctoto 中较短的那部分子串成为了新字符串的后缀(它们的出现位置集合 Endpos 增加了当前字符串的末尾),而 qq 中较长的那部分子串由于前面字符对不上,没有成为新字符串的后缀。

  • 怎么解决? 同一个状态里的子串 Endpos 集合必须完全一致,现在它们分道扬镳了,所以必须 toto 拆开

  • 克隆操作:

    1. 新建一个 cloneclone 状态,专门用来存放原本 toto 中较短的、成为了当前后缀的子串。所以它的长度严丝合缝地设为 st[p].len+1st[p].len + 1
    2. cloneclone 继承 toto 的所有出边和后缀链接(因为它本质上是 toto 的一部分,后续可以接的字符是一样的)。
    3. 我们把 toto 和新状态 curcur 的后缀链接都指向这个更“纯粹”的 cloneclone 状态。
    4. 最后,把 pp 的后缀链上,所有原本指向 toto 的转移边,全部重定向到 cloneclone(纠正历史遗留的指针)。

回到这道题目啊

1. 问题转化

题目要求我们找出 ss 的若干个子串,满足以下条件:

  1. 这些子串都不能包含 pp 作为子串。
  2. 任意两个选出的子串,互不为前缀(Prefix Code)。
  3. 选出的集合非空。
    如果不考虑包含 pp 的限制,所有子串的前缀关系天然构成了一棵前缀字典树(Prefix Trie)。题目中的“互不为前缀”,在树上等价于:选出的节点中,没有任何一个节点是另一个节点的祖先(即这组成了一个非空的反链 Anti-chain)。
    限制“不能包含 pp” 具有向下传递的性质:如果在字典树上某个节点对应的子串包含了 pp,那么它在字典树上的所有子孙节点(即所有以它为前缀的字符串)也必定包含 pp。因此,我们相当于在整棵前缀字典树上剪掉了一些子树,然后在剩余的有效树中选反链。

标红色的是因为包含了P,因此非法啊,如果按照这种前缀树的画法的话,那么起诉想表述的就非常清楚了

2. 树形 DP 计算反链数

在一棵普通的树上求反链的数量,可以通过树形 DP 解决。
dp[u]dp[u] 表示在以 uu 为根的子树中,选择反链的方案数加上1(加1代表在 uu 的子树中什么都不选的空集方案)。
• 如果我们不选 uu:可以在其各个子树中自由选择反链,根据乘法原理,方案数为 vchild(u)dp[v]\prod_{v \in child(u)} dp[v]
• 如果我们选了 uu:那么 uu 的所有子孙都不能被选,方案数为 11
两者相加,即:dp[u]=1+vchild(u)dp[v]dp[u] = 1 + \prod_{v \in child(u)} dp[v]
若在树中存在一条没有分叉的长为 LL 的链(即连续 LL 个节点都只有一个子节点),且链底部的子树 DP 值乘积为 DD,那么经过这 LL 个节点后,链顶部的 DP 值将恰好是 L+DL + D

3. 利用后缀自动机 (SAM) 压缩字典树

由于 ss 的长度可达 5×1055 \times 10^5,前缀字典树的节点数可能是 O(s2)O(\vert{}s\vert{}^2) 的,直接建树会超时超空间。
这里有一个经典结论:字符串 ss 的前缀字典树,可以通过构建其反串 sRs^R 的后缀自动机 (SAM) 的 Link Tree (Parent Tree) 来完美压缩
• 将 ss 反转得到 sRs^R,对 sRs^R 建立后缀自动机。
sRs^R 的 SAM 的 Link Tree,其边代表在 sRs^R 串前添加字符,也就等价于在原串 ss 后面添加字符。
• 因此,Link Tree 中的每一个节点 uu,实际上代表了前缀字典树上的一条没有分叉的链。链上每个节点对应的子串,它们在原串 ss 中都有相同的起始位置 BuB_u。链上包含的字符串长度范围是 [len(link(u))+1,len(u)][len(link(u)) + 1, len(u)]

4. KMP 结合 SAM 进行剪枝

我们需要知道 SAM 中每一个节点 uu 所代表的那条链,有多少个节点是“有效”的(即不包含 pp)。

  1. 在建立 SAM 时,记录每个节点在 sRs^R 中第一次出现的结束位置,以此可以推算出它在原串 ss 中的起始位置 BuB_u
  2. 使用 KMP算法 找出 pp 在原串 ss 中的所有出现位置。然后预处理出数组 nxt[i],表示在原串 ss 中,起点大于等于 ii 的最靠左的 pp 出现位置的终点索引
  3. 对于 SAM 上的节点 uu,它的字符串起点为 BuB_u
    ◦ 如果 ppBuB_u 及其之后最早完整出现的地方,终点索引为 EE(即 E=nxt[Bu]E = nxt[B_u]),那么任何起点为 BuB_u 且长度 EBu+1\ge E - B_u + 1 的字符串都会包含 pp
    ◦ 意味着该链允许的最大有效长度为 Mu=EBuM_u = E - B_u。如果 BuB_u 后面根本没有出现过 pp,则 Mu=M_u = \infty
  4. 节点 uu 原本代表长度从 len(link(u))+1len(link(u)) + 1len(u)len(u) 的链,剪去非法部分后,链上剩下的有效节点数量即为:Cu=max(0,min(len(u),Mu)len(link(u)))\displaystyle C_u = \max(0, \min(len(u), M_u) - len(link(u)))

AC代码

心路历程(WA,TLE,MLE……)

题目大意

题目:KV 缓存

题目描述

Soy 正在运行一个大型语言模型(LLM)服务。为了避免为不同的请求重复计算相同的前缀,该服务维护了一个持久化的 KV 缓存。

每个请求由一个由小写拉丁字母组成的字符串表示,其中每个字母代表一个 token。具有公共前缀的请求可以共享 KV 缓存条目。我们将缓存的当前内容建模为一棵字典树(Trie)。

最初,这棵字典树只包含根节点,没有任何边。

在处理请求字符串 ss 时,按顺序执行以下操作:

  1. Soy 从左到右处理整个字符串,从字典树的根节点开始。

  2. 如果下一个 token 对应的边已经存在,则无需任何代价即可复用其缓存结果。

  3. 否则,Soy 必须计算相应的 KV 状态。这需要消耗 11 个单位的计算代价,并且缺失的边会被添加到字典树中。

整个请求在任何缓存条目被删除之前会被完整地处理。特别地,请求中所有缺失的边会首先被添加,即使这会暂时使字典树包含超过 mm 条边。

在处理完整个请求后,Soy 会应用持久化缓存的大小限制。如果字典树包含超过 mm 条边,他必须不断地删除一个叶子节点以及连接它与其父节点的边,直到字典树恰好剩下 mm 条边。叶子节点是指在当前字典树中没有子节点的顶点。在处理当前请求时新添加的边也可以在此修剪步骤中被删除。

Soy 提前知道完整的 nn 个请求序列。在每次修剪步骤中,他可以选择删除哪些叶子节点的边。

请计算处理所有 nn 个请求所需的最小可能总计算代价。

字典树(Trie)是一种将一组字符串存储为有根树的数据结构。该树具有以下结构:树的每条边都标记有一个字母,同一个节点连出的边中,不存在两条标记相同字母的边。每个字符串可以通过沿着从根节点到某个顶点的路径来读取。
例如,我们可以为字符串 “min”、“trie”、“task” 和 “mini” 构建一棵字典树,它看起来像这样:
image

输入描述

每个测试用例的第一行包含两个整数 nnmm1n1061 \le n \le 10^61m1091 \le m \le 10^9)。

接下来的 nn 行中,第 ii 行包含一个字符串 sis_i —— 表示第 ii 个请求的 token 序列。保证 sis_i 仅由小写拉丁字母组成。保证所有字符串的长度之和 si\sum \vert{}s_i\vert{} 不超过 10610^6

输出描述

输出一个整数 —— 处理所有 nn 个请求的最小总计算代价。

样例

1
2
3
4
5
4 4
mini
trie
task
min
1
11

image

在本样例中,缓存的边数限制为 m=4m = 4。一种最优的计算和修剪策略如下:

  1. 处理第一个请求 mini:依次添加边 mini。计算代价为 44。此时字典树有 44 条边,未超过限制,无需修剪。

  2. 处理第二个请求 trie:从根节点开始添加边 trie。计算代价为 44。此时字典树包含 88 条边,超出了 m=4m=4 的限制,需要修剪掉 44 条边。我们可以选择删掉第一条链末尾的 i 以及第二条链后方的 r-i-e,只保留边 m-i-nt(共计 44 条边)。

  3. 处理第三个请求 task:复用已有的边 t(代价为 00),然后依次添加边 ask。计算代价为 33。处理后字典树包含 77 条边,需要修剪掉 33 条边。我们选择修剪掉刚增加的 a-s-k,继续保留 m-i-nt(共计 44 条边)。

  4. 处理第四个请求 min:边 m-i-n 已经完整存在于字典树中,可以直接复用全部 token,因此无需新建任何边。计算代价为 00。此时树中有 44 条边,无需修剪。

所有请求的总计算代价为 4+4+3+0=114 + 4 + 3 + 0 = 11。这也是在所有可能修剪策略中能达到的最小总计算代价。

思路讲解

问:题目相当于在每一次请求结束后,保留最多 mm 条边,如何将其转化为经典的缓存问题?

问:在传统的离线场景下(已知所有未来的访问序列)最优的缓存替换策略是什么?

问:直接对字典树的节点使用 Belady 策略,是否会违反“保留子节点必须保留父节点”的树形修剪约束?

问:如何高效模拟这一带有深度优先约束的贪心淘汰过程?

image

那么我们所谓的这个每个节点的数组啊,其实就是这个代码中的 visits 数组。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
vector<string> s(n + 1);
for (int i = 1; i <= n; ++i) {
cin >> s[i];
int u = 1;
for (char c : s[i]) {
int idx = c - 'a';
if (!ch[u][idx]) {
ch[u][idx] = ++tot;
depth[tot] = depth[u] + 1;
}
u = ch[u][idx];
// 记录该节点在第 i 个请求中被访问
// 因为是从左到右处理字符串,所以每个节点在同一次请求中最多被记录一次
visits[u].push_back(i);
}
}

然后我们删节点的话也不用真的去删除啊,就用一个这个 in_cache 数组记一下在不在缓存中就可以了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
for (char c : s[i]) {
int idx = c - 'a';
u = ch[u][idx];

// 如果该节点当前不在缓存中,需要计算 1 个单位代价并将其加入缓存
if (!in_cache[u]) {
total_cost++;
in_cache[u] = true;
cache_size++;
}

// 更新该节点的访问指针,定位到下一次被访问的时间
// 比我们的二分聪明一点啊
visit_ptr[u]++;
int next_access = INF;
// 如果大于的话,下一次访问时间就是 INF
if (visit_ptr[u] < visits[u].size()) {
next_access = visits[u][visit_ptr[u]];
}

// 记录该节点最新版本的下一次访问时间,并压入堆中
cur_next[u] = next_access;
pq.push({next_access, depth[u], u});
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 整个请求处理完毕后,开始应用缓存大小的限制
while (cache_size > m) {
auto [na, d, v] = pq.top();
pq.pop();

// 延迟删除:如果堆顶元素记录的时间戳与最新状态不一致,说明是失效的旧记录,直接忽略
// 这个是由于堆当中可能有多个这个版本的记录啊
if (na != cur_next[v]) {
continue;
}

// 万一节点已经被剔除出缓存,也直接忽略
if (!in_cache[v]) {
continue;
}

// 将该最不该保留的节点淘汰出缓存
in_cache[v] = false;
cache_size--;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
#include <iostream>
#include <vector>
#include <string>
#include <queue>
#include <tuple>

using namespace std;

const int INF = 1e9;
const int MAXN = 1000005;

// 字典树相关
int ch[MAXN][26];
int depth[MAXN];
int tot = 1; // 1 表示根节点

vector<int> visits[MAXN]; // visits[u] 记录节点 u 被访问到的所有请求编号
int visit_ptr[MAXN]; // 用于遍历 visits[u] 的指针

bool in_cache[MAXN]; // 记录节点当前是否在 KV 缓存中
int cur_next[MAXN]; // 记录节点当前最新的“下一次访问时间”

int main() {
// 优化标准输入输出
ios_base::sync_with_stdio(false);
cin.tie(NULL);

int n, m;
if (!(cin >> n >> m)) return 0;

vector<string> s(n + 1);
for (int i = 1; i <= n; ++i) {
cin >> s[i];
int u = 1;
for (char c : s[i]) {
int idx = c - 'a';
if (!ch[u][idx]) {
ch[u][idx] = ++tot;
depth[tot] = depth[u] + 1;
}
u = ch[u][idx];
// 记录该节点在第 i 个请求中被访问
// 因为是从左到右处理字符串,所以每个节点在同一次请求中最多被记录一次
visits[u].push_back(i);
}
}

long long total_cost = 0;
int cache_size = 0;

// 大根堆,用于模拟 Belady 最优缓存替换策略
// 存储元组: (下一次访问时间, 节点深度, 节点ID)
// C++ 默认的 max-heap 会优先弹出 下一次访问时间最大 的节点,
// 若时间相同,则优先弹出 深度最大 的节点(即树的叶子),符合题意
priority_queue<tuple<int, int, int>> pq;

for (int i = 1; i <= n; ++i) {
int u = 1;
for (char c : s[i]) {
int idx = c - 'a';
u = ch[u][idx];

// 如果该节点当前不在缓存中,需要计算 1 个单位代价并将其加入缓存
if (!in_cache[u]) {
total_cost++;
in_cache[u] = true;
cache_size++;
}

// 更新该节点的访问指针,定位到下一次被访问的时间
visit_ptr[u]++;
int next_access = INF;
if (visit_ptr[u] < visits[u].size()) {
next_access = visits[u][visit_ptr[u]];
}

// 记录该节点最新版本的下一次访问时间,并压入堆中
cur_next[u] = next_access;
pq.push({next_access, depth[u], u});
}

// 整个请求处理完毕后,开始应用缓存大小的限制
while (cache_size > m) {
auto [na, d, v] = pq.top();
pq.pop();

// 延迟删除:如果堆顶元素记录的时间戳与最新状态不一致,说明是失效的旧记录,直接忽略
if (na != cur_next[v]) {
continue;
}

// 万一节点已经被剔除出缓存,也直接忽略
if (!in_cache[v]) {
continue;
}

// 将该最不该保留的节点淘汰出缓存
in_cache[v] = false;
cache_size--;
}
}

cout << total_cost << "\n";

return 0;
}

AC代码

AC

https://ac.nowcoder.com/acm/contest/view-submission?submissionId=84468178

AC

https://ac.nowcoder.com/acm/contest/view-submission?submissionId=84471363

心路历程(WA,TLE,MLE……)

题目大意

唯首是瞻

时间限制:C/C++/Rust/Pascal 1 秒,其他语言 2 秒
空间限制:C/C++/Rust/Pascal 1024 MB,其他语言 2048 MB
Special Judge,64bit IO Format: %lld

在莫扎瑞拉的魔法国度里,一场召唤仪式出了大岔子。布朗尼本想召唤传说中的女法师加莱特完整降临,结果只有她的一颗头颅现身了。这颗漂浮的头颅却毫不在意,宣称道:“有头就够了!我要证明,光凭我的头脑就胜过你们的整副身躯。”

为了展示自己的智力,加莱特放出豪言:

“随便拿两个正整数来。只要你告诉我其中一个数的前 aa 位、另一个数的前 bb 位,我就能万无一失地说出它们乘积的前 cc 位。那些看不见的低位,就像身体对于天才一样无关紧要。”

学院里最敏锐的数学家奥兰洁特立刻察觉到了其中的漏洞。但仅仅指出谬误还不够,加莱特要求给出一个具体的反例:“你要真这么聪明,就找出两对数来:它们的前几位分别相同,乘积的前几位却不同!”

奥兰洁特一时之间构造不出这样的例子,于是她来向你求助。

形式化地说,给定三个正整数 aabbcc,请构造两对正整数 (x1,y1)(x_1, y_1)(x2,y2)(x_2, y_2),满足以下条件:

  • 在通常的十进制表示下(无前导零),x1x_1x2x_2 都至少有 aa 位,且二者的前 aa 位完全相同;

  • 同样地,y1y_1y2y_2 都至少有 bb 位,且二者的前 bb 位完全相同;

  • 乘积 x1y1x_1 \cdot y_1x2y2x_2 \cdot y_2 都至少有 cc 位,但二者的前 cc 位不同。

每个测试点仅一行,包含三个整数 aabbcc1a,b1051 \le a, b \le 10^51c<a+b11 \le c < a + b - 1)。

输出四个整数 x1x_1y1y_1x2x_2y2y_2,满足

10a1x1,x2<105105,10b1y1,y2<10510510^{a-1} \le x_1, x_2 < 10^{5 \cdot 10^5}, \qquad 10^{b-1} \le y_1, y_2 < 10^{5 \cdot 10^5}

且符合题目要求。这些整数均不能含有前导零。可以证明在给定约束下解总是存在的。

1
2 2 2
1
1704 1313 1789 1346

x1=1704x_1 = 1704x2=1789x_2 = 1789 的前 22 位均为 1717y1=1313y_1 = 1313y2=1346y_2 = 1346 的前 22 位均为 1313

1704×1313=22373521704 \times 1313 = 2237352,其前 22 位为 22221789×1346=24079941789 \times 1346 = 2407994,其前 22 位为 2424。两者的前 22 位不同,因此这是一组合法的构造。

思路讲解

题目大意

深算

时间限制:C/C++/Rust/Pascal 2 秒,其他语言 4 秒
空间限制:C/C++/Rust/Pascal 1024 MB,其他语言 2048 MB

汐和风子正在一个庞大的数字世界里斗智:这个世界包含从 111010010^{100} 的所有整数。

对局开始前,汐要从这些整数中恰好挑选 nn互不相同的数作为自己的手牌,其余的所有数则全部归风子所有。

随后进行 nn 轮出牌。每一轮中:

  • 风子先手,从她自己剩余的数中打出一个;

  • 汐必须立刻从自己剩余的手牌中打出一个数作为回应。(她要出一个比风小的数)

危险的规则在于:若风子打出的数严格小于汐回应的数,风子当场获胜。只有当汐打出的数小于等于风子打出的数时,本轮才算安全通过,两个数随即被弃置,游戏继续进行。

汐要想取得胜利,必须撑过全部 nn 轮,始终不让风子打出更小的数。

更糟糕的是,游戏开始前风子还附加了一道限制:她指定了 mm 个特定的数,并宣布汐的初始手牌中必须包含这 mm 个数中的每一个

于是汐开始盘算:在所有包含这 mm 个指定数、大小为 nn 的手牌方案中,有多少种能够保证她必胜——即无论风子如何巧妙地出牌,汐都有办法撑到最后?

答案可能极其巨大,请对 998244353998244353 取模后输出。

每个测试点包含多组测试数据。

第一行包含一个整数 tt1t10001 \le t \le 1000),表示测试数据组数。

对于每组测试数据:

  • 第一行包含两个整数 nnmm1mn50001 \le m \le n \le 5000);

  • 第二行包含 mm 个整数 a1,a2,,ama_1, a_2, \ldots, a_m1ai1091 \le a_i \le 10^9),表示汐必须包含在手牌中的特定数字。保证 aa 中没有重复元素。

保证所有测试数据的 nn 之和不超过 50005000

对于每组测试数据,输出一个整数,表示能够保证汐必胜的初始手牌方案数,对 998244353998244353 取模。

1
2
3
4
5
6
7
3
1 1
2
2 1
2
6 3
1 4 5
1
2
3
0
1
34

第一组数据n=1n = 1,且手牌必须包含 22,因此汐的手牌被唯一确定为 {2}\lbrace 2 \rbrace。若风子打出 11,汐只能回应 22,此时 1<21 < 2,风子立即获胜。因此不存在必胜方案,答案为 00

第二组数据n=2n = 2,手牌必须包含 22。可以验证,只有手牌为 {1,2}\lbrace 1, 2 \rbrace 时汐才能保证获胜,因此答案为 11

第三组数据n=6n = 6,手牌必须包含 1,4,51, 4, 5 这三个数,另外还需从剩余整数中选出 33 个。在所有这样的手牌中,恰有 3434 种能保证汐必胜,因此答案为 3434

思路讲解

问:风子想最大化获胜概率,她会采取怎样的出牌策略?

答:风子为了尽可能让自己的牌小于汐的回应牌,一定会贪心地从小到大打出自己手中最小的 nn 张牌。因此,汐只需考虑如何战胜风子手中最小的 nn 个数即可。

问:为了抵挡风子打出的前 nn 小的牌,汐的手牌必须满足什么条件?

答:汐手牌中第 kk 小的牌必须小于等于风子手中第 kk 小的牌。这意味着,对于任意数字 xx,在从 11xx 的区间内,汐持有的牌的数量必须大于等于风子持有的牌的数量。

问:汐的手牌最大可以是多少?

答:由于汐要在前 2n2n 个数中获得比风子更多的牌(且两人最终各有 nn 张),汐的手牌必须全部包含在 112n2n 的范围内。如果有任何大于 2n2n 的数,汐在前 2n2n 中的牌数将小于 nn,必然在最后一轮失败。

问:如何将这个条件转化为经典的组合计数模型并求解?(这个确实是一个非常经典的 dp 模型啊)

答:将考察 11 2n2n 的过程视作折线图:属于汐的牌计为向右上走一步(+1),属于风子的牌计为向右下走一步(-1)。合法方案等价于从 (0,0)(0,0) 走到 (2n,0)(2n,0),中途不跌落 xx 轴以下,并且在指定的 mm 个位置必须向右上走。可以通过 O(n2)O(n^2) 动态规划求解。

问:为什么汐的必胜条件可以转化为卡特兰折线(括号序列)模型?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
#include <iostream>
#include <vector>

using namespace std;

const int MOD = 998244353;

void solve() {
int n;
int m;
cin >> n >> m;

vector<int> a(m);
bool possible = true;

for (int i = 0; i < m; ++i) {
cin >> a[i];
// 如果汐被强制要求选大于 2n 的牌,则她在前 2n 里的牌数不足 n 张,必然无法挡住风子前 n 小的牌
if (a[i] > 2 * n) {
possible = false;
}
}

if (!possible) {
cout << 0 << "\n";
return;
}

// 标记哪些数字是汐必须选的(即折线图中必须向右上走的位置)
vector<bool> is_fixed(2 * n + 1, false);
for (int i = 0; i < m; ++i) {
is_fixed[a[i]] = true;
}

// dp[j] 表示当前前缀和(相对高度)为 j 的方案数
vector<int> dp(n + 1, 0);
dp[0] = 1;

// next_dp 用于滚动数组更新
vector<int> next_dp(n + 1, 0);

for (int i = 1; i <= 2 * n; ++i) {
// 每轮开始前初始化 next_dp 为 0
for (int j = 0; j <= n; ++j) {
next_dp[j] = 0;
}

if (is_fixed[i]) {
// 当前数字必须属于汐,高度强制 +1
for (int j = 1; j <= n; ++j) {
next_dp[j] = dp[j - 1];
}
} else {
// 当前数字既可以给汐,也可以给风子
for (int j = 0; j <= n; ++j) {
// 如果分配给汐,高度 +1
if (j > 0) {
next_dp[j] = (next_dp[j] + dp[j - 1]) % MOD;
}
// 如果分配给风子,高度 -1,但前提是高度不能超过 n 且不跌破 0
if (j < n) {
next_dp[j] = (next_dp[j] + dp[j + 1]) % MOD;
}
}
}

// 将本轮计算的结果滚动覆盖回 dp 数组
for (int j = 0; j <= n; ++j) {
dp[j] = next_dp[j];
}
}

// 最终我们需要高度为 0,即汐和风子各拿到恰好 n 张牌
cout << dp[0] << "\n";
}

int main() {
// 优化输入输出流速度
ios_base::sync_with_stdio(false);
cin.tie(NULL);

int t;
if (cin >> t) {
while (t > 0) {
solve();
t--;
}
}

return 0;
}

AC代码

AC

https://ac.nowcoder.com/acm/contest/view-submission?submissionId=84449016

心路历程(WA,TLE,MLE……)