CSP-J 模拟赛3

CSP-J 普及模拟赛 3 讲评


T1 染色(paint)

题目大意

有一条数轴,小蓝把 \([L_1, R_1)\) 这段区间涂成了蓝色,小红把 \([L_2, R_2)\) 这段区间涂成了红色。问:有多少长度的区间被同时涂上了蓝色和红色?(也就是问紫色区间的长度)

思路分析

这道题其实就是在问:两个区间重叠的部分有多长?

想象一下:你手里有两根彩带,一根代表蓝色区间,一根代表红色区间,把它们放在一起,重叠的部分就是紫色。

我们可以分两步来思考:

  1. 先判断有没有重叠:如果蓝色区间的右端点 \(\le\) 红色区间的左端点,说明蓝色完全在红色的左边,不重叠;反过来也一样。只有 \(\max(L_1, L_2) < \min(R_1, R_2)\) 时,才有重叠。

  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\) 严格大于前面所有石头的重量之和。即:

\[a_i > a_1 + a_2 + \dots + a_{i-1} \]

现在要你从这 \(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\),再求二进制!


第五步:完整算法流程

  1. 读入 \(n\) 和石头重量 \(a_1, a_2, \dots, a_n\)
  2. 特判:如果 \(k > 2^n\),直接输出 -1(因为总共只有 \(2^n\) 种选法,不存在第 \(k\) 小)
  3. \(k \leftarrow k - 1\)(将"第几小"转换为从 0 开始的编号)
  4. 遍历二进制的每一位(从第 \(0\) 位到第 \(n-1\) 位):
    • 如果 \(k\) 的第 \(i\) 位是 \(1\)(即 k >> i & 1 为真),说明要选第 \(i+1\) 块石头
    • \(a_{i+1}\) 加到答案中
  5. 输出答案

时间复杂度:\(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 位)。1LLlong 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)

题目大意

一个字符串是"好的",要求它同时满足两个条件:

  1. 相邻不相同:任意长度为 \(2\) 的连续子串,两个字符不能相同。
  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'] += 1
  • dp[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!=kj!=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\) 的时间!

所以这道题可以分成三个子任务:

  1. Dijkstra:跑一遍最短路,求出每个点的 \(dis[i]\) 和最短路径树上的父亲 \(fa[i]\)
  2. 建树 + 算子树猫数:根据 \(fa[i]\) 建出最短路径树,计算每个子树里有多少只猫猫
  3. 枚举捷径:对每个点 \(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(后序遍历)

\[sz[i] = c_i + \sum_{v \in children(i)} sz[v] \]

其中 \(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\)的子集和,不需要全部生成。

核心思路

想象我们有一个"工厂",从小到大逐个生产子集和。工厂的核心是一台小根堆(优先队列),堆顶永远是最小的子集和。

算法步骤:

  1. 排序石头:把石头按重量从小到大排序(这样可以保证"扩展"出的新子集和是递增的)
  2. 初始化堆:把每块石头单独作为一个"种子"放入堆中,堆中每个元素是 (当前和, 最后选的石头下标)
  3. 逐个弹出:弹出堆顶(当前最小的子集和),然后"扩展"它——在最后选的石头之后继续添加石头,把新的子集和放回堆中
  4. 重复直到找到第 \(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 的思路

遇到 ?,最简单的想法就是:把每种可能的填法都试一遍,数一数有多少种合法的。这就像解填空题——不知道的格子一个一个试呗。

具体来说:

  1. 先检查"必死"的情况:扫描一遍字符串,如果已经确定的字符之间就冲突了(比如两个相邻的确切字母相同),那不管 ? 填什么都不可能合法,直接返回 0。这个优化非常关键!
  2. 收集所有 ? 的位置:记录哪些位置需要枚举
  3. 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\) 需要满足两个条件:

  1. 在最短路上\(dist[1][p] + edgeWeight(p, i) = dist[1][i]\)(即 \(p\)\(i\)\(1\) 的前一步)
  2. 字典序最小:如果有多个候选,选编号最小的
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³) 超时

给同学们的比赛建议

  1. 🎯 先保底,再冲高。不要一上来就死磕正解。先把能拿的部分分稳稳拿到手(比如用本文的方法),再考虑正解。一个部分分的 AC 胜过十个正解的 WA。

  2. 💪 暴力出奇迹。数据范围小的题,直接暴力枚举往往是最快最稳妥的策略。T1 就是典型——公式说不定会写错,但暴力绝对不会错。

  3. 📊 学会读数据范围。拿到题目第一眼就看数据范围:

    • \(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)\)
  4. ✂️ 善用剪枝优化。T3 的 fixed_conflict() 预判就是很好的例子——虽然 ? 很多,但固定字符已经冲突了,答案就是 0,一秒钟都不用算。

  5. 📝 优先级策略:先写暴力保底分 → 想优化多拿分 → 最后冲刺正解。这样即使正解没想出来,也有稳稳的分数在手。

posted @ 2026-07-09 08:15  surprise_ying  阅读(40)  评论(0)    收藏  举报