CCPC 2021 Online - F 复盘:固定模式子序列 DP 与贡献划分
题目与计数对象
CCPC 2021 Online F - Nun Heh Heh Aaaaaaaaaaa
给定若干个只包含小写英文字母的字符串。对于每个字符串,需要统计其中有多少个子序列形如:
1 | nunhehheh + 至少一个 a |
子序列只要求所选下标严格递增,不要求字符连续。即使最后得到的字符串相同,只要选择的下标不同,也要分别计数。答案对 取模。
题目给出的范围是:测试组数 ,单个字符串长度不超过 ,所有字符串的总长度不超过 。
这是一道固定模式串的子序列计数 DP。代码本身不长,真正值得复盘的是如何划分方案,以及如何区分累计状态和当前位置新增的状态。
按最后一个 h 划分方案
一个合法子序列由两部分组成:
1 | nunhehheh |
以及它后面非空的一串 a。
假设 nunhehheh 的最后一个 h 选在位置 ,并且 后面共有 个 a。后缀中的每个 a 都可以选或不选,因此共有 个子集;排除一个都不选的情况,非空选择数就是:
再记 为在 之前选出 nunhehhe 的方案数。固定 作为最后一个 h 时,产生的完整方案数为:
于是总答案可以写成:
这种划分不会重复。任意一个合法子序列中,nunhehheh 都有唯一的最后一个 h,所以它只会被归入一个位置 ;反过来,前八个字符的选法、固定的第九个 h 和后缀 a 的非空选法拼起来,也一定得到一个合法子序列。
只维护前八个字符
完整固定前缀是 nunhehheh,但最后一个 h 已经被用来划分答案,因此 DP 只需要维护它前面的八个字符:
1 | nunhehhe |
设这个长度为 的模式串为 。扫描原字符串的一个前缀时,定义:
其中 。特别地,,因为空串只有一种选法:什么都不选。其余状态初始化为 。实现时, 存在 dp[j] 中。
扫描到字符 时,如果它等于 的第 个字符,就可以把它接到所有已经匹配前 个字符的方案后面:
对应的一维转移是:
1 | for (int j = 8; j >= 1; --j) { |
这里必须倒序更新。假设模式串是 nn,如果正序计算,那么扫描一个 n 时会先用它更新 ,紧接着又用刚更新的 更新 ,相当于同一个原串位置被选了两次。倒序时,右侧状态读取的仍然是加入当前字符之前的左侧状态,每个位置至多使用一次。
当当前字符是 h 时,更新前的 就是前面定义的 。从语义上看,可以先累加这个位置的答案贡献,再进行 DP 转移。AC 代码采用了相反的书写顺序,但当前字符 h 不会更新以 e 结尾的 ,所以两种顺序在这里等价。
后缀 a 的非空选择
为了得到每个位置右侧有多少个 a,从后往前预处理后缀计数:
实现中,这个量存放在数组 a 中。只有当 是 h 时才会计算贡献,所以 a[i] 虽然定义为从 开始的 a 数量,但它与从 开始的数量相同。
当前位置的贡献就是:
1 | dp[8] * (qpow(2, a[i]) - 1) |
当后面没有 a 时,括号内等于 ,这个 h 不会产生合法方案。快速幂的返回值在 内,而这里减一不会出现负数;两个取模后的因子相乘也不会超过 long long 的范围。
初版思路为什么重复计数
我最初维护了完整模式串的状态:
然后在每个 h 处尝试计算:
1 | dp[9] * 2^cntA - 1 |
这里有两个相互独立的问题。
第一个问题是把累计完成量当成了当前位置的新完成量。 会保留此前所有已经完成的 nunhehheh,后面每遇到一个 h,旧方案仍然在这个状态中。如果每次都把整个 加入答案,旧方案就会反复贡献。
例如:
1 | nunhehhehha |
两个连续的 h 都可以作为固定前缀的最后一个字符,因此正确答案是 。若先更新完整状态、再在每个 h 处使用累计的 ,两次贡献分别包含 个和 个完整前缀,最终会得到 。多出来的那一次,就是第一个 h 结束的旧方案在第二个 h 处又被计算了一遍。
正确的统计粒度应当是:只保留前八个字符的累计方案数,再固定当前 h 作为第九个字符。这样 与当前位置一一配合,得到的正是“以当前位置新完成”的方案。
第二个问题是减一的位置。假设固定最后一个 h 后,前缀共有 种选法,后面共有 个 a。每一种前缀选法都必须排除“一个 a 也不选”的情况,因此贡献是:
而不是:
前一个式子为每一种前缀方案排除一次空集,后一个式子只在所有组合完成后减去一次,两者只有在 时才相同。
复杂度与可优化处
长度为 的 DP 转移对每个字符执行常数次操作,后缀 a 计数也是线性的。这两部分的时间复杂度都是 。
不过当前 AC 代码会在每个候选 h 处调用一次快速幂。一次 qpow 的复杂度为 ,因此严格按照这份实现计算,单个字符串的最坏时间复杂度是:
空间复杂度为 ,主要来自后缀计数数组;DP 状态只占常数空间。对于多组数据,总复杂度可以按各字符串长度分别求和。
如果预处理 ,或者在从右向左扫描时同步维护对应的二次幂,就能把快速幂查询降为 ,使整体时间复杂度变为 。
最终 AC 代码
点击展开/折叠 最终 AC 代码
1 |
|
这次真正要记住的状态粒度
固定模式串的子序列计数可以用一维 DP: 表示匹配模式串前 个字符的方案数,并且转移通常要倒序进行。
但这题的关键不只是套模板,而是先确定答案如何分类。把方案按固定前缀的最后一个 h 划分后,前面的 、当前位置和后缀的非空 a 子集分别负责三段互不重叠的下标选择,重复计数自然被消除。
以后再遇到“前缀模式 + 后缀任选字符”的结构,需要先问清两个问题:当前状态表示的是截至当前位置的累计量,还是恰好在当前位置产生的新增量;要求至少选择一个元素时, 又应该乘在什么对象上。这里的括号不是形式细节,而是计数对象本身。