CSP-J 模拟赛3
CSP-J 普及模拟赛 3 讲评
T1 染色(paint)
题目大意
有一条数轴,小蓝把 \([L_1, R_1)\) 这段区间涂成了蓝色,小红把 \([L_2, R_2)\) 这段区间涂成了红色。问:有多少长度的区间被同时涂上了蓝色和红色?(也就是问紫色区间的长度)
思路分析
这道题其实就是在问:两个区间重叠的部分有多长?
想象一下:你手里有两根彩带,一根代表蓝色区间,一根代表红色区间,把它们放在一起,重叠的部分就是紫色。
我们可以分两步来思考:
-
先判断有没有重叠:如果蓝色区间的右端点 \(\le\) 红色区间的左端点,说明蓝色完全在红色的左边,不重叠;反过来也一样。只有 \(\max(L_1, L_2) < \min(R_1, R_2)\) 时,才有重叠。
-
再算重叠的长度:如果有重叠,重叠区间的左端点是两段起点的较大值 \(\max(L_1, L_2)\),右端点是两段终点的较小值 \(\min(R_1, R_2)\)。长度就是
右端点 - 左端点。
一句话总结
两个区间求交集,答案就是 \(\max(0, \min(R_1, R_2) - \max(L_1, L_2))\)。
举个例子
- 样例:\([0, 3)\) 涂蓝,\([1, 5)\) 涂红
- 重叠部分:\([1, 3)\),长度为 \(2\)
T2 石头称重(stone)
题目大意
有 \(n\) 块石头,每块石头有一个重量 \(a_i\)。这些石头有一个神奇的性质:对于任意一块石头 \(i\),它的重量 \(a_i\) 严格大于前面所有石头的重量之和。即:
现在要你从这 \(n\) 块石头中选出若干块(可以全都不选,也可以全选),问:所有可能的选法中,总重量第 \(k\) 小的选法对应的总重量是多少?
第一步:一共有多少种选法?
每块石头都有两种选择——"选"或者"不选"。对于 \(n\) 块石头:
- 第 1 块:2 种选择
- 第 2 块:2 种选择
- ...
- 第 \(n\) 块:2 种选择
根据乘法原理,总共有 \(2^n\) 种不同的选法。
举个例子:\(n=3\) 时,共有 \(2^3 = 8\) 种选法:
不选任何石头、只选第 1 块、只选第 2 块、选第 1+2 块、只选第 3 块、选第 1+3 块、选第 2+3 块、全选。
因此,如果题目给的 \(k\) 超过了 \(2^n\)(即 \(k > 2^n\)),说明根本没有第 \(k\) 小的选法,直接输出 -1。
第二步:石头的性质 → 二进制
这道题最核心的地方在于理解石头的性质。我们来看二进制是怎么做的。
二进制复习
在二进制中,第 \(0\) 位的权重是 \(2^0 = 1\),第 \(1\) 位的权重是 \(2^1 = 2\),第 \(2\) 位的权重是 \(2^2 = 4\),第 \(3\) 位的权重是 \(2^3 = 8\)……
你会发现一个关键事实:每一位的权重,都严格大于前面所有位权重之和!
- \(2 > 1\)
- \(4 > 1 + 2 = 3\)
- \(8 > 1 + 2 + 4 = 7\)
- \(16 > 1 + 2 + 4 + 8 = 15\)
这个性质带来了两个重要结论:
结论一(排序性质):比较两个二进制数的大小时,只需要从高位往低位看。高位大的数一定更大,不管低位是什么。
因为高位的一个"1"比所有低位全是"1"加起来还要大!比如 1000(8)一定大于 0111(7)。
结论二(唯一性):每个非负整数都有唯一的二进制表示。给定一个数,它能唯一地拆分成某些二进制位的和。
石头和二进制的一一对应
题目中石头的性质跟二进制是一样的!我们把石头"从大到小"排列(即按编号从 \(n\) 到 \(1\)),就像二进制从高位到低位一样:
| 二进制视角 | 石头视角 |
|---|---|
| 第 \(n-1\) 位(最高位),权重 \(2^{n-1}\) | 第 \(n\) 块(最重),重量 \(a_n\) |
| 第 \(n-2\) 位,权重 \(2^{n-2}\) | 第 \(n-1\) 块,重量 \(a_{n-1}\) |
| ... | ... |
| 第 \(0\) 位(最低位),权重 \(2^0 = 1\) | 第 \(1\) 块(最轻),重量 \(a_1\) |
这个对应关系意味着:不同的选法,得到的总重量一定不同。为什么?因为最重那块石头是否被选,就已经决定了总重量的大致范围——选了一定比不选大,跟二进制的高位完全一样!
第三步:大小关系 → 排序
现在来看看,不同的选法之间的大小关系是怎样的。
按照"二进制性质",我们可以把所有选法从轻到重排个序。什么选法最轻?当然是"什么都不选",总重量 = \(0\)。什么选法最重?当然是"全选"。
如果我们用一个长度为 \(n\) 的 01 串来表示选法(第 \(i\) 位为 \(1\) 表示选第 \(i\) 块石头,为 \(0\) 表示不选),那么:
| 编号(从 0 开始) | 01 串(选法) | 实际含义 |
|---|---|---|
| 0 | 000...000 |
什么都不选 |
| 1 | 000...001 |
只选第 1 块 |
| 2 | 000...010 |
只选第 2 块 |
| 3 | 000...011 |
选第 1 块和第 2 块 |
| 4 | 000...100 |
只选第 3 块 |
| ... | ... | ... |
| \(2^n - 1\) | 111...111 |
全选 |
你会发现,第 \(m\) 小的选法,对应的 01 串正好就是 \(m\) 的二进制表示!
🔑 关键理解:这个排序不是巧合,而是由石头的"二进制性质"保证的。因为高位石头比所有低位石头加起来还重,所以当两个选法比较时,只要看最高不同的那块石头谁选了——选了的那一方一定更重。这跟比较两个二进制数时的规则完全一致。
第四步:第 \(k\) 小 → 为什么是 \(k-1\)?
题目问的是第 \(k\) 小。这里的"第 \(k\) 小"是从 \(1\) 开始编号的:
- 第 \(1\) 小 = 什么都不选(总重量为 \(0\))
- 第 \(2\) 小 = 只选第 1 块
- 第 \(3\) 小 = 只选第 2 块
- …
而二进制的对应关系是从 \(0\) 开始的:
- 编号 \(0\) 的选法 = 什么都不选
- 编号 \(1\) 的选法 = 只选第 \(1\) 块
- 编号 \(2\) 的选法 = 只选第 \(2\) 块
- …
所以,第 \(k\) 小的选法 = 编号 \(k-1\) 的选法!我们需要做的就是把 \(k\) 减去 \(1\),然后看 \(k-1\) 的二进制表示。
这一步非常容易出错,也是很多同学丢分的地方。一定要记住:先把 \(k\) 减 \(1\),再求二进制!
第五步:完整算法流程
- 读入 \(n\) 和石头重量 \(a_1, a_2, \dots, a_n\)
- 特判:如果 \(k > 2^n\),直接输出
-1(因为总共只有 \(2^n\) 种选法,不存在第 \(k\) 小) - \(k \leftarrow k - 1\)(将"第几小"转换为从 0 开始的编号)
- 遍历二进制的每一位(从第 \(0\) 位到第 \(n-1\) 位):
- 如果 \(k\) 的第 \(i\) 位是 \(1\)(即
k >> i & 1为真),说明要选第 \(i+1\) 块石头 - 把 \(a_{i+1}\) 加到答案中
- 如果 \(k\) 的第 \(i\) 位是 \(1\)(即
- 输出答案
时间复杂度:\(O(n)\),轻松通过。
第六步:完整模拟一遍
以 \(n=3\) 为例来完整模拟一下:
| 石头编号 | 1 | 2 | 3 |
|---|---|---|---|
| 重量 | 3 | 10 | 25 |
验证性质:\(10 > 3\) ✓,\(25 > 3+10=13\) ✓。
总共有 \(2^3 = 8\) 种选法,按总重量从小到大排列:
| 第几小 | 编号 \((k-1)\) | 二进制(3 位) | 选了哪些石头 | 总重量 |
|---|---|---|---|---|
| 第 \(1\) 小 | \(0\) | 000 |
无 | \(0\) |
| 第 \(2\) 小 | \(1\) | 001 |
第 1 块 | \(3\) |
| 第 \(3\) 小 | \(2\) | 010 |
第 2 块 | \(10\) |
| 第 \(4\) 小 | \(3\) | 011 |
第 1、2 块 | \(13\) |
| 第 \(5\) 小 | \(4\) | 100 |
第 3 块 | \(25\) |
| 第 \(6\) 小 | \(5\) | 101 |
第 1、3 块 | \(28\) |
| 第 \(7\) 小 | \(6\) | 110 |
第 2、3 块 | \(35\) |
| 第 \(8\) 小 | \(7\) | 111 |
第 1、2、3 块 | \(38\) |
现在假设 \(k=6\)(问第 \(6\) 小):
- 先检查:\(k=6 \le 2^3=8\),合法 ✓
- \(k-1 = 5\),二进制是
101 - 第 \(0\) 位(最低位)是
1→ 选第 \(1\) 块(重量 3) - 第 \(1\) 位是
0→ 不选第 \(2\) 块 - 第 \(2\) 位是
1→ 选第 \(3\) 块(重量 25) - 答案:\(3 + 25 = 28\) ✓
AC 代码(带详细注释)
#include <bits/stdc++.h>
using namespace std;
const int N = 55; // n 最大到 50,多开一点
long long n, a[N]; // a[i] 可能很大,用 long long
int main() {
// 1. 读入石头数量和每块石头的重量
cin >> n;
for (int i = 1; i <= n; i++) {
cin >> a[i];
}
// 2. 读入 k
long long k, ans = 0;
cin >> k;
// 3. 特判:k 是否超过了总的选法数 2^n
// 1LL << n 表示 2^n(左移 n 位相当于乘以 2^n)
if (k > (1LL << n)) {
cout << -1 << endl;
return 0;
}
// 4. 关键一步:将"第 k 小"转换为从 0 开始的编号
// 第 1 小 → 编号 0,第 2 小 → 编号 1,…,第 k 小 → 编号 k-1
k--;
// 5. 遍历二进制的每一位(从第 0 位到第 n-1 位)
// k >> i:将 k 的二进制右移 i 位
// & 1:取最低位,判断是 0 还是 1
for (int i = 0; i < n; i++) {
if ((k >> i) & 1) { // 如果第 i 位是 1
ans += a[i + 1]; // 就选第 i+1 块石头
}
}
// 6. 输出答案
cout << ans << endl;
return 0;
}
关键细节 FAQ
Q1:为什么用
1LL << n而不是1 << n?当 \(n\) 比较大(比如 \(n > 31\))时,
1 << n会溢出(因为1默认是int类型,只有 32 位)。1LL是long long类型的1,有 64 位,可以安全地表示 \(2^{50}\) 这样的大数。
Q2:为什么不直接用数组存二进制?
用位运算
(k >> i) & 1可以直接取出 \(k\) 二进制表示的第 \(i\) 位,不需要额外开数组,代码更简洁。
Q3:石头编号和二进制位是怎么对应的?
二进制的第 \(0\) 位(最低位,
k & 1)对应第 \(1\) 块石头(\(a_1\)),第 \(1\) 位对应第 \(2\) 块石头(\(a_2\)),以此类推。代码中a[i + 1]就是处理这个对应关系。
一句话总结
石头重量满足"二进制性质"——高位石头比所有低位石头加起来还重,因此选法的大小顺序恰好等于二进制数的自然顺序。第 \(k\) 小的选法对应编号 \(k-1\),把 \(k-1\) 转成二进制后,哪一位是 \(1\) 就选对应的石头,总和即为答案。别忘了先特判 \(k > 2^n\) 的情况!
T3 字符串(string)
题目大意
一个字符串是"好的",要求它同时满足两个条件:
- 相邻不相同:任意长度为 \(2\) 的连续子串,两个字符不能相同。
- 三连各不相同:任意长度为 \(3\) 的连续子串,三个字符必须互不相同。
现在给定一个长度为 \(n\) 的模式串 \(s\),其中有些位置是 ?(表示这个位置可以填任意小写字母 a~z,共 26 种选择)。问:有多少种填法能让 \(s\) 变成一个"好的"字符串? 答案对 \(998244353\) 取模。
第一步:理解题目——"好的"字符串到底要求什么?
我们先来看几个具体的例子,建立直观感受:
| 字符串 | 长度 | 相邻有重复吗? | 有长度为 3 的重复吗? | 是"好的"吗? |
|---|---|---|---|---|
"abc" |
3 | a≠b, b≠c ✓ |
a,b,c 都不同 ✓ |
✅ 是 |
"aba" |
3 | a≠b, b≠a ✓ |
a,b,a 有重复(两个 a)✗ |
❌ 不是 |
"abb" |
3 | a≠b ✓,b=b ✗ |
— | ❌ 不是 |
"abca" |
4 | 都不同 ✓ | a,b,c 不同 ✓,b,c,a 不同 ✓ |
✅ 是 |
第二步:简化条件——其实只需要看连续三个字符!
这是本题最重要的观察:
条件 2 已经包含了条件 1! 如果任意连续的三个字符都互不相同,那么任意相邻的两个字符自然也互不相同。
为什么?假设 s[i], s[i+1], s[i+2] 三个字符互不相同,那么 s[i] ≠ s[i+1] 和 s[i+1] ≠ s[i+2] 自动成立。所以条件 1 是多余的,我们只需要关心条件 2。
也不需要看长度 ≥ 4 的子串! 如果一个字符串中存在某个长度 ≥ 4 的子串不合法,那么它里面一定藏着一个不合法的长度为 3 的子串。比如
abaca中,aba(第 1~3 个字符)就已经不合法了。
因此,整道题的核心条件简化为一条:
字符串中任意连续三个字符 \(s[i], s[i+1], s[i+2]\) 必须两两不同。即:
\[s[i] \neq s[i+1],\quad s[i+1] \neq s[i+2],\quad s[i] \neq s[i+2] \]
第三步:为什么不直接算?——需要 DP 的原因
如果字符串中没有 ?,判断它是不是"好的"只需要扫一遍检查每三个连续字符即可,\(O(n)\) 轻松搞定。
但有了 ? 之后,每个 ? 可以填 26 种字母,最坏情况下有 \(26^n\) 种可能的填法,暴力枚举显然不可行(\(n\) 最大可达 5000)。
这时就要用动态规划(DP)来高效地计算方案数。
第四步:DP 设计 —— 状态怎么定义?
关键观察
当我们填到位置 \(i\) 时,这个位置能填什么字符,只取决于前两个位置填了什么。更远的位置(\(i-3\) 及之前)已经无关了。
为什么?因为"连续三个字符互不相同"这个条件,只涉及 \(s[i-2], s[i-1], s[i]\) 这三个位置。当我们填 \(s[i]\) 时,只需要保证它和 \(s[i-1]\) 不同、和 \(s[i-2]\) 不同即可。
这正是 DP 的典型特征:当前决策只依赖于前两个状态。
状态定义
我们用 dp[i][j][k] 表示:
考虑字符串的前 \(i+1\) 个字符(即下标 \(0 \sim i\)),其中:
- 位置 \(i\)(当前最后一个字符)是字母 \(j\)(\(j\) 取 \(0 \sim 25\),对应
a~z)- 位置 \(i-1\)(倒数第二个字符)是字母 \(k\)(\(k\) 取 \(0 \sim 25\))
满足所有条件的方案数。
状态转移
当我们从 dp[i-1][k][p] 转移到 dp[i][j][k] 时:
位置: i-2 i-1 i
字符: p k j
└──┬──┘
dp[i-1][k][p] 已记录了这一段的合法性(k≠p)
现在我们要填 j,需要检查:
- j ≠ k(位置 i 和 i-1 不同)
- j ≠ p(位置 i 和 i-2 不同)
- k ≠ p 已经在 dp[i-1][k][p] 中保证了,无需再检查
转移方程:
如果 j ≠ k 且 j ≠ p:
dp[i][j][k] = dp[i][j][k] + dp[i-1][k][p]
用图来理解:
dp[i-1][k][p] dp[i][j][k]
┌─────────────┐ 填 j ┌─────────────┐
│ 位置 i-2: p │ ────────────────→ │ 位置 i-1: k │
│ 位置 i-1: k │ 条件是 j≠k, j≠p │ 位置 i: j │
└─────────────┘ └─────────────┘
如何处理 ??
- 如果 \(s[i]\) 是具体字母(如
'c'),那么 \(j\) 只能取这一个值,其余 25 个字母直接跳过。 - 如果 \(s[i]\) 是
?,那么 \(j\) 可以取遍 \(0 \sim 25\)(即 26 种字母)。
代码中这样判断:
if (s[i] != '?' && s[i] != j + 'a') continue; // 字母不匹配,跳过
第五步:初始化 —— 前两个字符怎么处理?
DP 从 \(i=2\) 开始递推(需要前两个字符的信息),所以 \(i=0\) 和 \(i=1\) 的情况需要手动初始化。
n = 1 的特判
如果字符串长度只有 \(1\),既没有相邻字符也没有连续三个字符,任何单字符都是"好的":
- 如果 \(s[0]\) 是
?,有 26 种填法 - 如果 \(s[0]\) 是确定的字母,只有 1 种
n ≥ 2 时:初始化 dp[1]
dp[1][j][k] 表示前两个字符(位置 0 和位置 1)分别为 \(k\) 和 \(j\) 时的方案数。条件只有一个:\(j \neq k\)(相邻不同)。
for (int j = 0; j < 26; j++) // j = s[1]
for (int k = 0; k < 26; k++) // k = s[0]
if ((s[1] == j+'a' || s[1] == '?') &&
(s[0] == k+'a' || s[0] == '?') &&
j != k)
dp[1][j][k] = 1;
第六步:完整模拟一遍
用一个具体例子走一遍 DP 过程。
设 \(n=3\),\(s =\) "a?c":
初始化 dp[1]
- \(s[0] =\)
'a',所以 \(k\) 只能是'a'(即 \(k=0\)) - \(s[1] =\)
'?',所以 \(j\) 可以是任意字母,但 \(j \neq k\) 即 \(j \neq\)'a' - 因此有 25 种初始化:
dp[1]['b']['a'] = 1,dp[1]['c']['a'] = 1, …,dp[1]['z']['a'] = 1
递推 i=2
- \(s[2] =\)
'c',所以 \(j\) 只能是'c'(\(j=2\))
我们看 dp[2]['c'][k] 能从哪里转移来:
对于每个 \(k\)(\(s[1]\) 是 ?,\(k\) 任意)和每个 \(p\)(\(s[0] =\) 'a',\(p\) 只能是 'a'):
需要:j≠k 且 j≠p
即:'c'≠k 且 'c'≠'a'('c'≠'a' 恒成立)
所以 k 不能是 'c',即 k ≠ 2
dp[1]['b']['a'] = 1→ \(k =\)'b',\(j =\)'c',\(p =\)'a',检查:'c'≠'b'✓,'c'≠'a'✓ →dp[2]['c']['b'] += 1dp[1]['c']['a'] = 1→ \(k =\)'c',检查:'c'≠'c'✗ → 跳过- 其他 23 种(
d~z)都满足条件 → 各加 1
所以 dp[2]['c'][k] = 1,其中 \(k \in \{\) b, d, e, …, z \(\}\),共 24 种。
统计答案
最终答案 = \(\sum\limits_{j,k} dp[n-1][j][k]\),其中 \(j\) 和 \(k\) 要分别匹配 \(s[n-1]\) 和 \(s[n-2]\)。
这里 \(n-1 = 2\):\(s[2]=\) 'c',\(s[1]=\) '?'(任何 \(k\) 都可以)。
所以答案 = 24。这个结果也符合直觉:a?c,中间不能填 a(会跟第一个重复),也不能填 c(会跟第三个重复),剩下 24 种。
第七步:边界情况和常见陷阱
1. n=1 必须特判
不特判的话,初始化 dp[1] 的循环会越界(字符串只有 1 个字符,访问 \(s[1]\) 是未定义行为)。
2. n=2 不需要递推
初始化 dp[1] 后直接跳到统计答案。因为递推循环 for i=2; i < s.size(); i++ 在 \(n=2\) 时不会执行,直接去统计答案,逻辑正确。
3. 取模要勤快
每加一次就要 % mod,不能攒到最后再取模——因为 dp 数组的值可能非常大(最坏有 \(26^{5000}\) 级别的方案数),long long 也存不下。
AC 代码(带详细注释)
#include <bits/stdc++.h>
using namespace std;
const int N = 5005;
const int mod = 998244353; // 大质数,用于取模
int n;
string s;
long long dp[N][26][26]; // dp[i][j][k]: 位置 i 填 j,位置 i-1 填 k 的方案数
int main() {
cin >> n >> s;
// ========== 特判:n = 1 ==========
// 只有一个字符时,没有"相邻"也没有"连续三个"的限制
if (n == 1) {
if (s[0] == '?')
cout << 26 << endl; // ? 可以填 26 种字母
else
cout << 1 << endl; // 确定字母只有 1 种
return 0;
}
// ========== 初始化:前两个字符 (i=0 和 i=1) ==========
for (int j = 0; j < 26; j++) { // j = s[1]
for (int k = 0; k < 26; k++) { // k = s[0]
if ((s[1] == j + 'a' || s[1] == '?') && // s[1] 匹配 j
(s[0] == k + 'a' || s[0] == '?') && // s[0] 匹配 k
j != k) { // 相邻不能相同
dp[1][j][k] = 1;
}
}
}
// ========== DP 递推:从第 3 个字符开始 ==========
for (int i = 2; i < s.size(); i++) {
for (int j = 0; j < 26; j++) { // j = 当前要填的字符 s[i]
// 剪枝:如果 s[i] 是确定字母且不匹配 j,跳过
if (s[i] != '?' && s[i] != j + 'a') continue;
for (int k = 0; k < 26; k++) { // k = s[i-1]
// 剪枝:如果 s[i-1] 是确定字母且不匹配 k,跳过
if (s[i-1] != '?' && s[i-1] != k + 'a') continue;
for (int p = 0; p < 26; p++) { // p = s[i-2]
// 剪枝:如果 s[i-2] 是确定字母且不匹配 p,跳过
if (s[i-2] != '?' && s[i-2] != p + 'a') continue;
// 核心条件:三个连续字符必须互不相同
// j≠k (相邻不同) 且 j≠p (隔一个也不同)
// k≠p 已由 dp[i-1][k][p] 保证(状态本身就要求相邻不同)
if (j != k && j != p) {
dp[i][j][k] = (dp[i][j][k] + dp[i-1][k][p]) % mod;
}
}
}
}
}
// ========== 统计答案 ==========
long long ans = 0;
for (int j = 0; j < 26; j++) { // j = s[n-1]
for (int k = 0; k < 26; k++) { // k = s[n-2]
if ((s[n-1] == j + 'a' || s[n-1] == '?') &&
(s[n-2] == k + 'a' || s[n-2] == '?')) {
ans = (ans + dp[n-1][j][k]) % mod;
}
}
}
cout << ans << endl;
return 0;
}
关键细节 FAQ
Q1:为什么 dp 的第二维和第三维都是 26?26 是怎么来的?
小写字母一共 26 个(
a~z)。每个位置可能填 26 种字母,所以我们需要 26×26 的空间来记录"最后两个字符"的所有可能组合。
Q2:转移时检查了
j!=k和j!=p,为什么不检查k!=p?因为
dp[i-1][k][p]本身已经保证了 \(k \neq p\)。回顾状态定义:dp[i-1][k][p]表示位置 \(i-1\) 填 \(k\)、位置 \(i-2\) 填 \(p\),而初始化时就要求了相邻不同(\(k \neq p\)),转移时又要求了 \(j \neq k\)(即新状态的相邻不同)。所以 \(k \neq p\) 是一条已经被保证的"继承条件",不需要重复检查。
Q3:时间复杂度是多少?会不会超时?
三重循环:\(n \times 26 \times 26 \times 26 ≈ 5000 \times 17576 ≈ 8.8 \times 10^7\)。加上剪枝(
continue跳过不匹配的字母),实际运算量更小。在 C++ 开-O2优化下大约 0.3~0.5 秒,完全能过。
Q4:模数 998244353 是什么?为什么不用 1e9+7?
\(998244353 = 119 \times 2^{23} + 1\),是一个质数,常用于 NTT(数论变换)。这道题是计数 DP,用 \(10^9+7\) 也完全可以,出题人选用这个模数可能是出于习惯或者为了统一。两个都是合法的大质数。
Q5:dp 数组开
long long会不会爆内存?
dp[5005][26][26]= \(5005 \times 676 = 3,383,380\) 个long long,每个 8 字节,共约 27 MB。通常在 256MB 的内存限制下绰绰有余。
一句话总结
字符串的"好"条件等价于任意连续三个字符互不相同。填到每个位置时,能填什么字母只取决于前两个位置,因此用
dp[i][j][k](位置 \(i\) 填 \(j\),位置 \(i-1\) 填 \(k\))递推即可。转移时检查 \(j \neq k\) 且 \(j \neq p\),遇到?就枚举 26 种字母。最终答案对所有合法末状态求和。
T4 喵喵(meow)
题目大意
有一张 \(n\) 个点 \(m\) 条边的无向连通图,每个点 \(i\) 上有 \(c_i\) 只猫猫。所有猫猫都要走到 \(1\) 号点集合,每只猫沿最短路走。现在有一次机会:可以在任意一个点 \(i\) 和 \(1\) 号点之间建一条捷径,走过捷径只需要时间 \(t\)。问:最多能让所有猫猫的总赶路时间减少多少?
📌 注意:捷径是双向的,且所有猫猫都会自动选择更快的路。如果捷径不如原来快,猫猫就不会走捷径。
第一步:把问题拆解成可操作的步骤
先想象一下没有捷径时,猫猫们怎么走:
- 每只猫猫从自己的起点出发,沿着最短路径走到 \(1\) 号点。
- 所有猫猫的路径拼在一起,形成了一棵以 \(1\) 为根的树——这就是最短路径树(Shortest Path Tree, SPT)。
🌳 什么是最短路径树?
对每个点 \(i\),我们只保留它到 \(1\) 的最后一步(即最短路上 \(i\) 的前驱节点 \(fa[i]\))。这样 \(n\) 个点、\(n-1\) 条边就构成了一棵树,每条边都在某条最短路上。
现在加入一条捷径 \((i, 1)\)(耗时 \(t\))。对于 \(i\) 的子树中的所有猫猫,它们原本要沿着树边走 \(dis[i]\) 的距离才能到 \(1\),现在只需要走 \(t\) 的捷径就行了。每只猫节省了 \(dis[i] - t\) 的时间!
所以这道题可以分成三个子任务:
- Dijkstra:跑一遍最短路,求出每个点的 \(dis[i]\) 和最短路径树上的父亲 \(fa[i]\)
- 建树 + 算子树猫数:根据 \(fa[i]\) 建出最短路径树,计算每个子树里有多少只猫猫
- 枚举捷径:对每个点 \(i\),计算如果在这里建捷径能省多少时间,取最大值
第二步:跑 Dijkstra —— 求最短路和最短路径树
Dijkstra 大家应该已经很熟悉了。本题有一个额外的要求:
当存在多条距离相同的最短路时,要选字典序最小的那条(即路径经过的点的编号序列字典序最小)。
这个要求怎么实现呢?其实很简单:在松弛时,如果发现另一条同样短的路径,就比较父节点编号,选更小的:
if (dis[v] > dis[u] + w) {
dis[v] = dis[u] + w;
fa[v] = u; // 找到更短的路,直接更新
}
if (dis[v] == dis[u] + w) {
if (u < fa[v]) fa[v] = u; // 一样短但父节点更小,更新(字典序更小)
}
💡 为什么父节点更小,路径字典序就更小?
因为字典序比较是逐点比的。假设有两条到 \(v\) 的路径 \(P_1\) 和 \(P_2\),它们在某个前缀处开始分叉——如果分叉点之前完全一样,分叉点的父节点更小,整体字典序就更小。递归地选择更小的父节点,就能保证全局字典序最小。
Dijkstra 跑完后,我们不仅得到了每个点的最短距离 \(dis[i]\),还得到了最短路径树的结构(通过 \(fa[i]\) 数组),以及一个天然的拓扑序——Dijkstra 中节点被弹出的顺序,就是 \(dis\) 从小到大的顺序,树上的父亲一定比儿子先弹出。
第三步:建树并计算子树猫猫数
有了 \(fa[i]\),我们就可以建出最短路径树。同时需要计算以每个点为根的子树中,总共有多少只猫猫。
这是一个经典的树形 DP(后序遍历):
其中 \(c_i\) 是点 \(i\) 原本就有的猫猫数量,\(sz[v]\) 是儿子 \(v\) 的子树猫猫总数。
🔑 后序遍历技巧:利用 Dijkstra 弹出的逆序——从最后一个弹出的节点倒着处理到第一个。因为 Dijkstra 的弹出顺序保证父亲在儿子之前,所以倒过来就是儿子在父亲之前,正好是后序遍历!
代码实现:
// 步骤1:建树
for (int i = 2; i <= n; i++) {
tree[fa[i]].push_back(i); // i 是 fa[i] 的儿子
}
// 步骤2:初始化每个点的猫数
for (int i = 1; i <= n; i++) {
sz[i] = c[i];
}
// 步骤3:逆拓扑序(从叶子到根)累加猫数
for (int j = order.size() - 1; j >= 0; j--) {
int u = order[j]; // 当前处理的点
for (int v : tree[u]) { // 遍历 u 的所有儿子
sz[u] += sz[v]; // 把儿子的猫猫加到 u 上
}
}
第四步:枚举捷径位置,计算最大节省
有了 \(dis[i]\) 和 \(sz[i]\),枚举就很简单了。对于每个点 \(i\):
- 子树 \(i\) 中的猫猫总数:\(sz[i]\) 只
- 原本到 \(1\) 的时间:\(dis[i]\)(所有子树内的猫猫都要先走到 \(i\),再沿树边走到 \(1\))
等等,这里需要仔细想一下:子树 \(i\) 里的猫猫,走到 \(1\) 并不一定都经过点 \(i\) 本身!比如点 \(i\) 的一个孙子节点 \(v\),它的猫猫走到 \(1\) 的路径是 \(v \rightarrow \dots \rightarrow i \rightarrow \dots \rightarrow 1\)。这些猫猫确实经过了点 \(i\),但它们走到 \(i\) 的路上也花了自己的时间。
🔑 关键洞察:捷径建在点 \(i\),意味着子树 \(i\) 中的所有猫猫都可以选择:走到 \(i\),然后直接走捷径到 \(1\)(花费 \(t\))。它们原本从 \(i\) 到 \(1\) 需要 \(dis[i]\) 的时间,现在只需要 \(t\)。所以每只猫节省了 \(dis[i] - t\)。
因此,在点 \(i\) 建捷径的总节省为:
节省量 = \(sz[i] \times (dis[i] - t)\)
如果 \(dis[i] \le t\)(捷径不如原路快),猫猫不会走捷径,节省为 \(0\)。但因为我们取 \(\max\),负数会被自然过滤。
枚举完所有 \(i\),取最大值即可。
第五步:完整模拟一遍
假设有这样一个图:
(2)
1 --- 2
| |
(5)| |(3)
| |
3 --- 4
(1)
- \(n=4\),\(m=4\),\(t=2\)
- 猫猫数量:\(c = [0, 2, 3, 1]\)(1 号点没猫,2 号 2 只,3 号 3 只,4 号 1 只)
Dijkstra 求最短路
| 点 \(i\) | 1 | 2 | 3 | 4 |
|---|---|---|---|---|
| \(dis[i]\) | 0 | 2 | 4 | 5 |
| \(fa[i]\) | — | 1 | 1 | 3 |
(点 4:\(1 \to 3 \to 4\) 距离 \(5+1=6\),\(1 \to 2 \to 4\) 距离 \(2+3=5\),所以 \(dis[4]=5\),\(fa[4]=2\))
弹栈顺序 = [1, 2, 3, 4]
建树 + 算猫数(后序遍历逆序:4 → 3 → 2 → 1)
1 (sz=?)
/ \
2 3 (sz=?)
/ \
4 (sz=?) (没儿子)
| 处理顺序 | 点 \(u\) | 儿子 | \(sz[u]\) 累加过程 | 最终 \(sz[u]\) |
|---|---|---|---|---|
| 1(逆序第一个) | 4 | 无 | \(c_4 = 1\) | 1 |
| 2 | 3 | 无 | \(c_3 = 3\) | 3 |
| 3 | 2 | 4 | \(c_2 + sz[4] = 2+1\) | 3 |
| 4(逆序最后一个) | 1 | 2, 3 | \(c_1 + sz[2] + sz[3] = 0+3+3\) | 6 |
枚举捷径
| 点 \(i\) | \(sz[i]\) | \(dis[i]\) | 节省 \(sz[i] \times (dis[i] - 2)\) |
|---|---|---|---|
| 1 | 6 | 0 | \(6 \times (0-2) = -12\) → 0 |
| 2 | 3 | 2 | \(3 \times (2-2) = 0\) |
| 3 | 3 | 4 | \(3 \times (4-2) = 6\) |
| 4 | 1 | 5 | \(1 \times (5-2) = 3\) |
最大节省 = 6(在点 3 建捷径)
验证:点 3 子树(3 号点自己 + 无儿子)有 3 只猫猫,原来走 \(3 \to 1\)(距离 4),现在走捷径只需 2,每只省 2,共省 \(3 \times 2 = 6\)。✓
AC 代码(带详细注释)
#include <bits/stdc++.h>
using namespace std;
const int N = 1e4 + 5; // 最大点数
int n, m, t; // n个点,m条边,新增捷径的通行时间
int c[N]; // 每个点的流浪猫数量
vector<pair<int, int>> g[N]; // 原图邻接表:g[u] 存储 (v, w)
vector<int> tree[N]; // 最短路树(以1为根),tree[u] 存储 u 的子节点
// Dijkstra 优先队列元素
struct node {
long long d; // 当前最短距离
int v; // 点编号
// 重载小于号,使优先队列成为小根堆(按距离从小到大)
friend bool operator<(node a, node b) {
return a.d > b.d;
}
};
long long d[N]; // 从点1到各点的最短距离
int vis[N]; // 是否已从优先队列中取出(最短路已确定)
int fa[N]; // 最短路树中每个点的父节点
// 即从1到该点的字典序最小最短路径上,该点的前一个点
void dijkstra() {
memset(d, 0x3f, sizeof(d)); // 距离初始化为极大值
priority_queue<node> q;
q.push({0, 1});
d[1] = 0;
while (!q.empty()) {
node cur = q.top();
q.pop();
int u = cur.v;
if (vis[u]) continue; // 已确定最短路,跳过
vis[u] = 1;
// 遍历 u 的所有邻边
for (auto [v, w] : g[u]) {
if (d[v] > d[u] + w) { // 找到更短路径
d[v] = d[u] + w;
fa[v] = u; // 记录父节点
q.push({d[v], v});
} else if (d[v] == d[u] + w) { // 路径长度相等时,选择字典序更小的路径
// 从 v 出发,路径第二个点是父节点,所以父节点编号越小,整条路径字典序越小
if (fa[v] > u) fa[v] = u;
}
}
}
}
long long s[N]; // 最短路树上,以 u 为根的子树中所有流浪猫的总数
void dfs(int u) {
s[u] = c[u]; // 先加上 u 点自身的猫
for (int v : tree[u]) { // 递归处理所有子节点
dfs(v);
s[u] += s[v];
}
}
int main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> m >> t;
for (int i = 1; i <= n; i++) cin >> c[i];
for (int i = 1; i <= m; i++) {
int u, v, w;
cin >> u >> v >> w;
g[u].push_back({v, w});
g[v].push_back({u, w});
}
// 计算原图最短路,并得到最短路树(每个点有且仅有一个父节点)
dijkstra();
// 构建最短路树:fa[i] 是 i 的父节点
// 注意 fa[1] 默认为 0,1 会被放入 tree[0],但 dfs 从 1 开始,不影响
for (int i = 1; i <= n; i++) {
tree[fa[i]].push_back(i);
}
// 从根 1 开始 DFS,计算每棵子树包含的猫总数
dfs(1);
long long ans = 0;
// 枚举新增捷径连接的点 i(不能是 1 本身)
for (int i = 2; i <= n; i++) {
// 若捷径时间 t < d[i],则所有原最短路经过 i 的猫都会受益。
// 它们原本从 i 到 1 还要走 d[i] 时间,现在走捷径只需 t,
// 每只猫节省 d[i] - t。
// 这些猫的数量就是最短路树上以 i 为根的子树猫总数 s[i]。
// 若 d[i] - t <= 0,则不会减少总时间,取 max 也不会更新答案。
ans = max(ans, s[i] * (d[i] - t));
}
cout << ans << '\n';
return 0;
}
关键细节 FAQ
Q1:为什么 Dijkstra 的弹出顺序可以作为拓扑序?
Dijkstra 从优先队列中弹出节点的顺序是按 \(dis\) 从小到大。对于最短路径树上的一条边 \((fa[v], v)\),边权 \(w > 0\),所以 \(dis[v] = dis[fa[v]] + w > dis[fa[v]]\),父亲的距离一定小于儿子,因此父亲一定先弹出。逆拓扑序就是儿子先处理、父亲后处理,正好满足后序遍历的要求。
Q2:
fa[v] = min(fa[v], u)为什么能保证字典序最小?最短路径树上,任何一条从根到 \(v\) 的路径由 \(fa\) 数组唯一确定。如果有两条同样短的路到达 \(v\),选择更小的父节点 \(u\) 意味着在路径的最后一个分叉点选了编号更小的节点。递归来看,这保证了整条路径的字典序最小。
Q3:为什么节省时间是 \(sz[i] \times (dis[i] - t)\)?子树里那些不在 \(i\) 节点上的猫猫呢?
子树 \(i\) 包含的是所有以 \(i\) 为根的后代节点。这些节点上的猫猫走到 \(1\),一定要先经过点 \(i\)。它们走到 \(i\) 后,面临两个选择:(1) 继续沿树边走到 \(1\),需 \(dis[i]\);(2) 走捷径,只需 \(t\)。所以每只猫都省了 \(dis[i] - t\),总共省了 \(sz[i] \times (dis[i] - t)\)。
Q4:如果 \(dis[i] < t\)(捷径比原路还慢)怎么办?
此时 \(sz[i] \times (dis[i] - t) < 0\),但我们在所有点中取 \(\max\)。由于 \(ans\) 初始值为 \(0\)(不建捷径,节省为 \(0\)),负数自然不会被选中。
Q5:为什么不用 long long 会错?
- \(n \le 2 \times 10^5\),\(c_i\) 可能很大
- \(dis[i]\) 可能达到 \(10^9\) 级别
- \(sz[i]\) 是所有子树猫数的和,最坏可能达到 \(10^{14}\) 级别
- 乘积 \(sz[i] \times dis[i]\) 可能达到 \(10^{23}\),需要用
long long(约 \(9 \times 10^{18}\) 上限)
额等等——\(10^{23}\) 超出了 long long 的范围!但实际题目数据范围有保证,或者答案本身不会超过 long long。如果真超出,需要用 __int128。不过就本题数据而言,long long 足够。
一句话总结
Dijkstra 求最短路 + 记录最短路径树的父亲(字典序处理)→ 利用 Dijkstra 弹出顺序的逆序做后序遍历,算出每个子树的猫猫总数 → 枚举每个点作捷径位置,节省量 \(= sz[i] \times (dis[i] - t)\),取最大。核心是理解"最短路径树上建捷径 = 该子树内的所有猫猫都少走 \(dis[i] - t\) 的距离"。
总结
| 题目 | 核心知识点 | 难度 |
|---|---|---|
| T1 染色 | 区间求交集 | ★☆☆☆☆ |
| T2 石头称重 | 二进制思想 | ★★☆☆☆ |
| T3 字符串 | 动态规划 DP | ★★★☆☆ |
| T4 喵喵 | 最短路 + 树上统计 | ★★★★☆ |
这四道题覆盖了区间处理、进制思想、动态规划、图论最短路等 CSP-J 的核心考点。建议大家重点复习 T3 的 DP 设计思路和 T4 的"最短路树"概念,这两类题型在正式比赛中非常常见!
CSP-J 普及模拟赛 3 —— 部分分解法题解
📖 本篇文章讲解每道题的非正解但能拿部分分的解法。每个解法都配有完整可运行的 C++ 代码,已经过实际测试验证。
当你面对一道题,正解思路一时想不出来,或者时间不多了——不要慌,先把部分分稳稳拿到手! 这就是本文的价值所在。
目录
| 题目 | 部分分策略 | 通过测试点 | 核心算法 |
|---|---|---|---|
| T1 染色 | 暴力枚举每个整数点 | 10/10 | 区间枚举 |
| T2 石头称重 | 子集枚举 + 优先队列 | 8/10 | 子集生成 / 优先队列 |
| T3 字符串 | 枚举 ? 的所有可能 |
6/10 | DFS 回溯 |
| T4 喵喵 | Floyd-Warshall 全源最短路 | 5/10 | Floyd + 最短路径树 |
T1 染色(paint)
题目回顾
有一条数轴,小蓝把 \([L_1, R_1)\) 涂成了蓝色,小红把 \([L_2, R_2)\) 涂成了红色。问:有多少长度的区间被同时涂上了蓝色和红色?(也就是问紫色区间的长度)
📐 区间是左闭右开的:\([L, R)\) 表示从 \(L\) 开始,到 \(R\) 结束(但不包括 \(R\))。比如 \([0, 3)\) 包含点 \(0, 1, 2\),不包含点 \(3\)。
正解 vs 部分分
| 正解 | 部分分(暴力) | |
|---|---|---|
| 做法 | 公式 \(\max(0, \min(R_1, R_2) - \max(L_1, L_2))\) | 枚举数轴上每个整数段 |
| 时间复杂度 | \(O(1)\)(一步出答案) | \(O(\max R - \min L)\) |
| 代码难度 | 要理解区间交集公式 | 超简单,几乎不会写错 |
暴力枚举的直观理解
数轴上的"长度"可以分解成一段一段的单位区间 \([i, i+1)\)(\(i\) 是整数):
数轴: ┃ 0 ┃ 1 ┃ 2 ┃ 3 ┃ 4 ┃ 5 ┃ ...
└─┬─┘ └─┬─┘ └─┬─┘
单位区间 [0,1)
这段要么是蓝色,要么是红色,要么是紫色(都是)
暴力做法的思路非常简单:把每个单位区间 \([i, i+1)\) 都检查一遍,看它是不是同时被蓝色和红色覆盖。如果是,答案就 +1。
举个具体例子:\(L_1=0, R_1=3\)(蓝色覆盖 \([0,3)\)),\(L_2=1, R_2=5\)(红色覆盖 \([1,5)\))
坐标: 0 1 2 3 4
├─────┼─────┼─────┼─────┤
蓝色: ████████████████████░░░░░░ [0,3)
红色: ░░░░░░████████████████████ [1,5)
──────┼─────┼─────┼─────┤
紫色: ██████████████ [1,3)
↑ ↑
i=1 i=2 → 2 段紫色 → 答案 = 2
逐步模拟
枚举 \(i\) 从 \(\min(L_1, L_2) = 0\) 到 \(\max(R_1, R_2) - 1 = 4\):
| \(i\) | 区间 \([i, i+1)\) | 蓝色?(\(0 \le i < 3\)) | 红色?(\(1 \le i < 5\)) | 紫色? |
|---|---|---|---|---|
| 0 | \([0,1)\) | ✅ | ❌ | ❌ |
| 1 | \([1,2)\) | ✅ | ✅ | ✅ → ans++ |
| 2 | \([2,3)\) | ✅ | ✅ | ✅ → ans++ |
| 3 | \([3,4)\) | ❌ | ✅ | ❌ |
最终 ans = 2,与正解结果一致。✅
为什么能拿满分?
本题数据范围非常小——坐标值不超过 100。所以枚举最多也就 100 次循环,在计算机眼里是一瞬间的事情。
💡 经验法则:数据范围 \(\le 10^6\) 的操作,在 C++ 里通常 1 秒内都能跑完。本题 100 次循环绰绰有余。
完整代码
// T1 染色 - 部分分解法:暴力枚举每个整数点
// 时间复杂度:O(maxR - minL),本题中 ≤ 100
#include <cstdio>
#include <algorithm>
using namespace std;
int main() {
freopen("paint.in", "r", stdin);
freopen("paint.out", "w", stdout);
int l1, r1, l2, r2;
scanf("%d %d %d %d", &l1, &r1, &l2, &r2);
int ans = 0;
// 枚举范围:从最小的左端点到最大的右端点
int start = min(l1, l2);
int end = max(r1, r2);
// 逐个检查每个单位段 [i, i+1)
for (int i = start; i < end; i++) {
// 条件:i 在蓝色区间内 且 i 在红色区间内
if (l1 <= i && i < r1 && l2 <= i && i < r2) {
ans++;
}
}
printf("%d\n", ans);
return 0;
}
测试结果
| 测试点 | 结果 | 说明 |
|---|---|---|
| paint1 ~ paint10 | ✅ 全部通过 | 数据范围小,暴力完全够用 |
T2 石头称重(stone)
题目回顾
有 \(n\) 块石头,第 \(i\) 块重 \(a_i\),且满足一个特殊性质:\(a_i > a_1 + a_2 + \dots + a_{i-1}\)。从 \(n\) 块石头中选出若干块(可以不选),问第 \(k\) 小的总重量是多少。
正解 vs 部分分
| 正解(见讲评课件) | 部分分 | |
|---|---|---|
| 核心思路 | 利用"二进制性质",第 \(k\) 小 = \(k-1\) 的二进制位对应的石头之和 | 不依赖性质,直接暴力生成子集和 |
| 时间复杂度 | \(O(n)\) | \(O(2^n)\) 或 \(O(k \log n)\) |
| 适用范围 | 通用 | \(n \le 20\)(枚举全部子集)或 \(k \le 10^6\)(优先队列) |
暴力枚举法(n ≤ 20)
直观理解
"选若干块石头"——每块石头要么选、要么不选,这恰好对应 \(2^n\) 种组合。我们可以用一个二进制掩码 mask 来表示选法:
| 掩码 mask | 二进制 | 选中了哪些石头 |
|---|---|---|
| 0 | 000 | 无 |
| 1 | 001 | 第 1 块 |
| 2 | 010 | 第 2 块 |
| 3 | 011 | 第 1、2 块 |
| 4 | 100 | 第 3 块 |
| 5 | 101 | 第 1、3 块 |
| 6 | 110 | 第 2、3 块 |
| 7 | 111 | 全部 |
对于每个 mask,计算对应的总重量,存入数组,最后排序取第 \(k\) 小即可。
复杂度分析
- 枚举 \(2^n\) 种掩码
- 每种掩码需要 \(O(n)\) 计算总重量
- 总复杂度:\(O(n \cdot 2^n)\)
- 当 \(n=20\) 时,\(20 \times 2^{20} \approx 2 \times 10^7\),可以接受
优先队列法(k 较小时)
当 \(n\) 很大(如 \(n=50\))但 \(k\) 不大(如 \(k \le 10^6\))时,枚举所有 \(2^{50}\) 个子集是不可能的。但我们只需要前 \(k\) 小的子集和,不需要全部生成。
核心思路
想象我们有一个"工厂",从小到大逐个生产子集和。工厂的核心是一台小根堆(优先队列),堆顶永远是最小的子集和。
算法步骤:
- 排序石头:把石头按重量从小到大排序(这样可以保证"扩展"出的新子集和是递增的)
- 初始化堆:把每块石头单独作为一个"种子"放入堆中,堆中每个元素是
(当前和, 最后选的石头下标) - 逐个弹出:弹出堆顶(当前最小的子集和),然后"扩展"它——在最后选的石头之后继续添加石头,把新的子集和放回堆中
- 重复直到找到第 \(k\) 小
手把手模拟
假设 \(n=4\),石头重量为 \([2, 3, 5, 7]\)(已排序),\(k=6\)。
注:第 1 小是空集(和为 0),我们要找的是第 \(k\) 小。
初始化堆(每块石头单独作为起点):
堆中的元素(小根堆,按和排序):
(2, idx=0) ← 只选第1块,和=2
(3, idx=1) ← 只选第2块,和=3
(5, idx=2) ← 只选第3块,和=5
(7, idx=3) ← 只选第4块,和=7
逐轮弹出(cnt 从 2 开始,因为 cnt=1 是空集,已经跳过):
| 轮次(cnt) | 弹出 | 扩展生成的新元素 | 当前堆的内容 |
|---|---|---|---|
| 2 | (2, 0) |
2+3=5,idx=1; 2+5=7,idx=2; 2+7=9,idx=3 |
(3,1) (5,1) (5,2) (7,0) (7,2) (9,3) |
| 3 | (3, 1) |
3+5=8,idx=2; 3+7=10,idx=3 |
(5,1) (5,2) (7,0) (7,2) (8,2) (9,3) (10,3) |
| 4 | (5, 1) |
5+5=10,idx=2; 5+7=12,idx=3 |
— (太长了,省略) |
| 5 | (5, 2) |
5+7=12,idx=3 |
— |
| 6 | (7, 0) |
7+... |
— |
第 6 次弹出来的是 7(即子集和 = 7)。这就是第 6 小的总重量。
让我们验证一下:\(n=4\),重量 \([2,3,5,7]\),所有 \(2^4=16\) 种子集和排序:
0, 2, 3, 5, 5, 7, 7, 8, 9, 10, 10, 12, 12, 14, 15, 17
↑第6小=7 ✓
🔑 为什么在"最后选的石头之后"扩展? 这是为了避免重复。比如子集 \(\{1,3\}\)(和 \(=2+5=7\)),如果我们允许在任意位置扩展,可能从 \(\{1\}\) 扩展出 \(\{1,3\}\),又从 \(\{3\}\) 扩展出 \(\{1,3\}\),造成重复。限制"只能往后选"保证每个子集只被生成一次。
复杂度分析
- 优先队列法:每次弹出后最坏扩展 \(O(n)\) 个新元素,弹出 \(k\) 次
- 时间复杂度:\(O(k \cdot n \log(kn))\)(堆的插入/删除是 \(O(\log \text{size})\))
- 当 \(k \le 10^6\) 且 \(n \le 50\) 时,约 \(5 \times 10^7\) 次操作,勉强可过
完整代码
// T2 石头称重 - 部分分:子集枚举 + 优先队列
// 方法1(n ≤ 20):枚举所有子集 O(2^n)
// 方法2(n 大但 k 小):优先队列逐个小到大生成 O(k log n)
#include <cstdio>
#include <vector>
#include <algorithm>
#include <queue>
using namespace std;
typedef long long ll;
int n;
ll w[66], k;
// ==================== 方法1:枚举所有子集 ====================
// 适用条件:n ≤ 20(2^20 ≈ 10^6,可接受)
ll solve_bruteforce() {
vector<ll> sums;
// mask 从 0 到 2^n - 1,每个 mask 表示一种选法
for (int mask = 0; mask < (1 << n); mask++) {
ll sum = 0;
// 检查 mask 的每一位:第 i 位为 1 表示选第 i 块石头
for (int i = 0; i < n; i++) {
if (mask & (1 << i)) {
sum += w[i];
}
}
sums.push_back(sum);
}
// 从小到大排序
sort(sums.begin(), sums.end());
// 第 k 小(k 从 1 开始编号)
return sums[k - 1];
}
// ==================== 方法2:优先队列逐个生成 ====================
// 适用条件:k 不大(k ≤ 10^6),n 可以大
ll solve_pq() {
// 先把石头按重量从小到大排序
sort(w, w + n);
// 第 1 小是空集(和为 0)
if (k == 1) return 0;
// 小根堆:pair<当前子集和, 最后选取的石头下标>
// greater<pair> 保证堆顶最小
priority_queue<pair<ll, int>, vector<pair<ll, int>>,
greater<pair<ll, int>>> pq;
// 初始化:每块石头单独作为一个"种子"子集放入堆中
for (int i = 0; i < n; i++) {
pq.push({w[i], i});
}
ll cur_sum = 0;
// cnt=2 开始:空集(第1小)已经跳过
for (ll cnt = 2; cnt <= k; cnt++) {
if (pq.empty()) return -1; // 堆空了,没有更多子集
cur_sum = pq.top().first; // 当前最小的子集和
int last = pq.top().second; // 这个子集最后选的是哪块石头
pq.pop();
// 扩展:在最后一块石头之后继续选石头(避免重复)
for (int j = last + 1; j < n; j++) {
pq.push({cur_sum + w[j], j});
}
}
return cur_sum;
}
int main() {
freopen("stone.in", "r", stdin);
freopen("stone.out", "w", stdout);
scanf("%d", &n);
for (int i = 0; i < n; i++) scanf("%lld", &w[i]);
scanf("%lld", &k);
// 根据数据范围选择方法
if (n <= 20) {
printf("%lld\n", solve_bruteforce());
} else if (k <= 1000000) {
printf("%lld\n", solve_pq());
} else {
// n 大且 k 大,暴力做不了
printf("-1\n");
}
return 0;
}
测试结果
| 测试点 | n | k | 结果 | 说明 |
|---|---|---|---|---|
| stone1 ~ stone4 | 8 | ≤ 250 | ✅ 通过 | 子集枚举,\(2^8=256\) |
| stone5 ~ stone6 | 20 | ≤ 233 | ✅ 通过 | 子集枚举,\(2^{20} \approx 10^6\) |
| stone7 | 50 | 243 | ✅ 通过 | \(k\) 小,优先队列可行 |
| stone8 | 50 | \(\sim 10^{15}\) | ❌ 失败 | \(k\) 太大,优先队列弹出 \(10^{15}\) 次不可能 |
| stone9 | 50 | \(\sim 10^{15}\) | ✅ 通过 | 正解也输出 -1(\(k > 2^n\)) |
| stone10 | 50 | \(\sim 3.7\!\times\!10^{13}\) | ❌ 失败 | \(k\) 太大 |
🎯 通过率:8/10。n=50 且 k 极大的两个点无法用部分分方法处理。
T3 字符串(string)
题目回顾
一个字符串是"好的",要求任意连续三个字符互不相同。给定带 ? 的模式串(? 可填任意小写字母),问有多少种填法。
正解 vs 部分分
| 正解(DP) | 部分分(DFS) | |
|---|---|---|
| 核心思路 | 三维 DP:dp[i][j][k] |
枚举所有 ? 的取值,逐一验证 |
| 时间复杂度 | \(O(n \times 26^3)\) | \(O(26^q \times n)\)(\(q\) = ? 个数) |
| 适用范围 | 通用 | \(q \le 6\) 或固定字符已冲突 |
暴力 DFS 的思路
遇到 ?,最简单的想法就是:把每种可能的填法都试一遍,数一数有多少种合法的。这就像解填空题——不知道的格子一个一个试呗。
具体来说:
- 先检查"必死"的情况:扫描一遍字符串,如果已经确定的字符之间就冲突了(比如两个相邻的确切字母相同),那不管
?填什么都不可能合法,直接返回 0。这个优化非常关键! - 收集所有
?的位置:记录哪些位置需要枚举 - DFS 回溯:对每个
?位置,尝试填a~z(26 种),填完后检查整个字符串是否合法
为什么只能过 6 个测试点?
关键在于 \(26^q\) 的增长速度:
? 的个数 \(q\) |
最坏枚举量 \(26^q\) | 能跑吗? |
|---|---|---|
| 0 | \(1\) | ✅ 瞬间 |
| 1 | \(26\) | ✅ |
| 2 | \(676\) | ✅ |
| 3 | \(17,576\) | ✅ |
| 4 | \(456,976\) | ✅ |
| 5 | \(11,881,376\) | ✅ 有点慢但还行 |
| 6 | \(308,915,776\) | ⚠️ 约 3 亿,O2 优化下勉强 |
| 7 | \(8,031,810,176\) | ❌ 80 亿,铁定超时 |
再看本题的数据分布:
| 测试点 | ? 个数 |
枚举量级 | 结果 |
|---|---|---|---|
| string1 | 1 | \(26^1\) | ✅ |
| string2 | 4 | \(26^4\) | ✅ |
| string3 | 2 | \(26^2\) | ✅ |
| string4 | 0 | 无需枚举 | ✅(固定字符直接判) |
| string5 | 7 | \(26^7\) | ✅(固定字符冲突,提前判 0!) |
| string6 | 0 | 无需枚举 | ✅(固定字符直接判) |
| string7~10 | 大量 | 爆炸 | ❌ |
🎯 string5 是关键:虽然
?很多,但固定字符之间已经冲突了,fixed_conflict()直接返回 true,答案就是 0,不需要枚举。这就是预处理优化的威力!
手把手模拟
以 \(s =\) "a?b" 为例(\(n=3\),1 个 ?):
步骤 1:预检固定字符冲突
- 固定字符:\(s[0]=\)
'a',\(s[2]=\)'b' - 检查相邻:\(s[0]\) 和 \(s[2]\) 不是相邻的,跳过
- 三个连续:只有 \(s[0], s[1], s[2]\),但 \(s[1]\) 是
?,有不确定的,跳过 - 结论:无冲突 ✓,继续
步骤 2:收集 ? 位置
- \(q\_pos = [1]\)(只有位置 1 是
?)
步骤 3:DFS 枚举
pos=1,尝试填 'a': s = "aab" → check: s[0]='a'=s[1]='a' → 相邻相同!❌
pos=1,尝试填 'b': s = "abb" → check: s[1]='b'=s[2]='b' → 相邻相同!❌
pos=1,尝试填 'c': s = "abc" → check: a≠b, b≠c, a≠b≠c → 全部通过!✅ ans++
pos=1,尝试填 'd': s = "abd" → check: 通过 ✅ ans++
...
pos=1,尝试填 'z': s = "abz" → check: 通过 ✅ ans++
最终 ans = 24(26 种字母去掉 'a' 和 'b',正好 24 种)。
完整代码
// T3 字符串 - 部分分:枚举所有 ? 的可能取值
// 适用范围:? 的个数 ≤ 6,或没有 ?
// 优化:先检查固定字符是否已经冲突(关键剪枝!)
// 时间复杂度:O(26^q * n),q 为 ? 个数
#include <cstdio>
#include <cstring>
using namespace std;
const int MOD = 998244353;
int n, q_pos[5005], q_cnt = 0;
char s[5005];
int ans = 0;
// ========== 预检:固定字符之间是否已经冲突 ==========
// 这是最重要的优化!如果固定的字符都不合法,
// 那无论 ? 填什么都不可能合法,直接返回 0
bool fixed_conflict() {
// 检查相邻的固定字符
for (int i = 2; i <= n; i++) {
if (s[i] != '?' && s[i-1] != '?' && s[i] == s[i-1])
return true; // 两个相邻的确切字母相同 → 冲突
}
// 检查三个连续的固定字符
for (int i = 3; i <= n; i++) {
char a = s[i-2], b = s[i-1], c = s[i];
if (a != '?' && b != '?' && c != '?') {
if (a == b || a == c || b == c) return true;
}
}
return false; // 固定字符之间没有冲突
}
// ========== 检查整个字符串是否合法 ==========
// 需要在每次填完所有 ? 后调用
bool check() {
// 条件1:相邻字符不能相同
for (int i = 2; i <= n; i++) {
if (s[i] == s[i-1]) return false;
}
// 条件2:连续三个字符必须互不相同
for (int i = 3; i <= n; i++) {
if (s[i] == s[i-1] || s[i] == s[i-2] || s[i-1] == s[i-2])
return false;
}
return true;
}
// ========== DFS 回溯:枚举每个 ? 的取值 ==========
// idx:当前正在处理第几个 ?(在 q_pos 数组中的下标)
void dfs(int idx) {
// 递归边界:所有 ? 都填完了
if (idx == q_cnt) {
if (check()) ans = (ans + 1) % MOD;
return;
}
// 当前 ? 在字符串中的位置
int pos = q_pos[idx];
// 尝试填 'a' ~ 'z'
for (char c = 'a'; c <= 'z'; c++) {
s[pos] = c;
dfs(idx + 1); // 递归处理下一个 ?
}
}
int main() {
freopen("string.in", "r", stdin);
freopen("string.out", "w", stdout);
scanf("%d", &n);
scanf("%s", s + 1); // 从下标 1 开始存储,方便处理
// 步骤1:预检固定字符冲突
if (fixed_conflict()) {
printf("0\n");
return 0;
}
// 步骤2:收集所有 ? 的位置
for (int i = 1; i <= n; i++) {
if (s[i] == '?') q_pos[q_cnt++] = i;
}
// 步骤3:如果 ? 太多,暴力吃不消
if (q_cnt > 6) {
printf("-1\n"); // 输出 -1 表示放弃
return 0;
}
// 步骤4:DFS 枚举
dfs(0);
printf("%d\n", ans);
return 0;
}
测试结果
| 测试点 | n | ? 个数 |
结果 | 说明 |
|---|---|---|---|---|
| string1 | 3 | 1 | ✅ 通过 | \(26^1 = 26\) |
| string2 | 8 | 4 | ✅ 通过 | \(26^4 \approx 4.6 \times 10^5\) |
| string3 | 8 | 2 | ✅ 通过 | \(26^2 = 676\) |
| string4 | 147 | 0 | ✅ 通过 | 无 ?,直接验证(答案 0) |
| string5 | 147 | 7 | ✅ 通过 | 固定字符冲突,提前判 0 |
| string6 | 5000 | 0 | ✅ 通过 | 无 ?,直接验证(答案 1) |
| string7 ~ string10 | 5000 | 很多 | ❌ 失败 | 需要 DP 正解 |
🎯 通过率:6/10。string7~10 需要 DP,暴力 DFS 无能为力。
T4 喵喵(meow)
题目回顾
有 \(n\) 个点 \(m\) 条边的无向连通图,每个点上有猫猫。所有猫猫沿最短路走到 \(1\) 号点。可以在某个点 \(i\) 和 \(1\) 号点之间建一条捷径(耗时 \(t\)),问最多能省多少总时间。
正解 vs 部分分
| 正解(Dijkstra) | 部分分(Floyd) | |
|---|---|---|
| 核心思路 | Dijkstra 求最短路 + 最短路径树 + 树形 DP | Floyd-Warshall 求全源最短路 |
| 时间复杂度 | \(O(m \log n)\) | \(O(n^3)\) |
| 适用范围 | \(n\) 可达 \(10^5\) | \(n \le 500\) 左右 |
| 代码量 | 较多 | 较少 |
Floyd-Warshall 算法是什么?
Floyd 是一个经典的全源最短路算法:一次性求出所有点对之间的最短距离。它的核心是一个简单的三重循环:
for (int k = 1; k <= n; k++)
for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++)
dist[i][j] = min(dist[i][j], dist[i][k] + dist[k][j]);
💡 Floyd 的 DP 思想:
dist[i][j]表示只经过编号 \(\le k\) 的中转点时,\(i\) 到 \(j\) 的最短距离。每轮加入一个新中转点 \(k\),尝试用它来"搭桥"缩短距离。
Floyd 的优点:
- 代码极短,不容易写错
- 一次性求出所有点对间的最短路
- 能处理负权边(但不能有负环)
Floyd 的缺点:
- \(O(n^3)\) 的时间复杂度,\(n=10000\) 时是 \(10^{12}\),完全不可能
整体算法流程
Floyd 求出全源最短路后,还需要做三件事:
第一步:建最短路径树
对每个节点 \(i\)(\(i \neq 1\)),要找出它在最短路径树上的父亲 \(p\)。
父亲 \(p\) 需要满足两个条件:
- 在最短路上:\(dist[1][p] + edgeWeight(p, i) = dist[1][i]\)(即 \(p\) 是 \(i\) 到 \(1\) 的前一步)
- 字典序最小:如果有多个候选,选编号最小的
for (int i = 2; i <= n; i++) {
int best_p = 0;
for (int p = 1; p <= n; p++) {
if (edge_w[p][i] == INF) continue;
// 检查 p 是否在 i 到 1 的最短路径上
if (dist[1][p] + edge_w[p][i] == dist[1][i]) {
if (best_p == 0 || p < best_p) best_p = p;
}
}
children[best_p].push_back(i); // i 成为 best_p 的儿子
}
第二步:从远到近计算子树猫数
把节点按 \(dist[1][i]\)(到 1 的距离)从大到小排序,依次处理。这样保证处理一个节点时,它的所有儿子(距离更远)都已经处理完毕——这就是树的后序遍历。
// 按距离排序
sort(order + 1, order + n + 1, cmp_by_dist);
// 从远到近处理
for (int idx = n; idx >= 1; idx--) {
int u = order[idx];
subtree_cats[u] = cats[u]; // 先从自己的猫开始
for (int v : children[u])
subtree_cats[u] += subtree_cats[v]; // 加上所有儿子的子树猫数
}
第三步:枚举捷径位置
for (int i = 2; i <= n; i++) {
if (dist[1][i] <= t) continue; // 捷径不如原路
ll save = (dist[1][i] - t) * subtree_cats[i];
max_save = max(max_save, save);
}
为什么只能过 5 个测试点?
Floyd 的 \(O(n^3)\) 复杂度是关键瓶颈:
| 测试点 | n | m | Floyd 操作量 | 能过吗? |
|---|---|---|---|---|
| meow1~3 | 5 | ≤ 9 | \(5^3 = 125\) | ✅ |
| meow4~5 | 500 | 1000 | \(500^3 = 1.25 \times 10^8\) | ✅ (O2 下约 0.1s) |
| meow6~10 | 10000 | ≤ 50000 | \(10000^3 = 10^{12}\) | ❌ |
🎯 通过率:5/10。\(n > 500\) 的测试点 Floyd 完全跑不动。
完整代码
// T4 喵喵 - 部分分:Floyd-Warshall 全源最短路
// 适用范围:n ≤ 500(Floyd O(n^3) 的极限)
// 与正解区别:O(n^3) vs O(m log n)
#include <cstdio>
#include <cstring>
#include <vector>
#include <algorithm>
using namespace std;
typedef long long ll;
const int MAXN = 505;
const ll INF = 1e18;
int n, m;
ll t;
ll cats[MAXN];
ll dist[MAXN][MAXN]; // Floyd 距离矩阵
ll edge_w[MAXN][MAXN]; // 原始边权(用于建最短路径树)
vector<int> children[MAXN]; // 最短路径树:children[u] = u 的所有儿子
ll subtree_cats[MAXN]; // 子树猫数
int order[MAXN]; // 按 dist[1][i] 排序的节点列表
// 排序比较函数:按到 1 号点的距离从小到大
bool cmp_by_dist(int a, int b) {
return dist[1][a] < dist[1][b];
}
int main() {
freopen("meow.in", "r", stdin);
freopen("meow.out", "w", stdout);
scanf("%d %d %lld", &n, &m, &t);
// ========== 特判:n 太大,Floyd 不可行 ==========
if (n > 500) {
printf("-1\n");
return 0;
}
// ========== 读入猫猫数量 ==========
for (int i = 1; i <= n; i++) scanf("%lld", &cats[i]);
// ========== 初始化距离矩阵 ==========
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= n; j++) {
dist[i][j] = (i == j) ? 0 : INF;
edge_w[i][j] = INF;
}
}
// ========== 读入图的边 ==========
for (int i = 1; i <= m; i++) {
int u, v; ll w;
scanf("%d %d %lld", &u, &v, &w);
if (w < edge_w[u][v]) { // 重边取最小值
edge_w[u][v] = edge_w[v][u] = w;
dist[u][v] = dist[v][u] = w;
}
}
// ========== Floyd-Warshall 求全源最短路 ==========
// 核心思想:依次尝试用每个点 k 作为中转站
for (int k = 1; k <= n; k++) {
for (int i = 1; i <= n; i++) {
if (dist[i][k] == INF) continue; // 剪枝
for (int j = 1; j <= n; j++) {
if (dist[k][j] == INF) continue; // 剪枝
ll nd = dist[i][k] + dist[k][j];
if (nd < dist[i][j]) dist[i][j] = nd;
}
}
}
// ========== 建最短路径树 ==========
// 对每个节点 i(除 1 号点),找它在最短路径树上的父亲
for (int i = 2; i <= n; i++) {
int best_p = 0;
for (int p = 1; p <= n; p++) {
if (edge_w[p][i] == INF) continue; // p 和 i 之间没有直接边
// 判断 p 是否在 i → 1 的最短路径上
if (dist[1][p] + edge_w[p][i] == dist[1][i]) {
// 选编号最小的父亲(字典序)
if (best_p == 0 || p < best_p) best_p = p;
}
}
if (best_p != 0) children[best_p].push_back(i);
}
// ========== 按距离排序 ==========
for (int i = 1; i <= n; i++) order[i] = i;
sort(order + 1, order + n + 1, cmp_by_dist);
// ========== 从远到近(逆序)计算子树猫数 ==========
// 远节点先处理,保证处理到某节点时它的儿子都已经算好
for (int idx = n; idx >= 1; idx--) {
int u = order[idx];
subtree_cats[u] = cats[u]; // 自己的猫
for (int v : children[u]) // 加上儿子的子树猫数
subtree_cats[u] += subtree_cats[v];
}
// ========== 枚举捷径位置,计算最大节省 ==========
ll max_save = 0;
for (int i = 2; i <= n; i++) {
if (dist[1][i] <= t) continue; // 捷径比原路还慢,不考虑
ll save = (dist[1][i] - t) * subtree_cats[i];
if (save > max_save) max_save = save;
}
printf("%lld\n", max_save);
return 0;
}
测试结果
| 测试点 | n | m | 结果 | 说明 |
|---|---|---|---|---|
| meow1 ~ meow3 | 5 | ≤ 9 | ✅ 通过 | Floyd \(O(5^3)\) 瞬间 |
| meow4 ~ meow5 | 500 | 1000 | ✅ 通过 | Floyd \(O(500^3) \approx 0.1\)s |
| meow6 ~ meow10 | 10000 | ≤ 50000 | ❌ 失败 | \(n\) 太大,Floyd \(O(n^3)\) 不可行 |
🎯 通过率:5/10。\(n \le 500\) 可以靠 Floyd 硬跑,\(n\) 再大就必须用 Dijkstra 了。
总结
| 题目 | 通过率 | 部分分算法 | 正解算法 | 拿不到的测试点原因 |
|---|---|---|---|---|
| T1 染色 | 10/10 | 暴力枚举区间 | 公式 O(1) | — |
| T2 石头称重 | 8/10 | 子集枚举 + 优先队列 | 二进制分解 | n=50 且 k 极大时失败 |
| T3 字符串 | 6/10 | 枚举 ? 的所有取值 |
三维 DP | ? 太多时组合爆炸 |
| T4 喵喵 | 5/10 | Floyd-Warshall | Dijkstra + 最短路径树 | n 太大时 O(n³) 超时 |
给同学们的比赛建议
-
🎯 先保底,再冲高。不要一上来就死磕正解。先把能拿的部分分稳稳拿到手(比如用本文的方法),再考虑正解。一个部分分的 AC 胜过十个正解的 WA。
-
💪 暴力出奇迹。数据范围小的题,直接暴力枚举往往是最快最稳妥的策略。T1 就是典型——公式说不定会写错,但暴力绝对不会错。
-
📊 学会读数据范围。拿到题目第一眼就看数据范围:
- \(n \le 20\) → \(O(2^n)\) 可过
- \(n \le 500\) → \(O(n^3)\) 可过
- \(n \le 5000\) → \(O(n^2)\) 可过
- \(n \le 10^5\) → 需要 \(O(n \log n)\)
-
✂️ 善用剪枝优化。T3 的
fixed_conflict()预判就是很好的例子——虽然?很多,但固定字符已经冲突了,答案就是 0,一秒钟都不用算。 -
📝 优先级策略:先写暴力保底分 → 想优化多拿分 → 最后冲刺正解。这样即使正解没想出来,也有稳稳的分数在手。
浙公网安备 33010602011771号