CSP-S 2025 复赛解析

已补充完整

[CSP-S 2025] 社团招新 / club

题目描述

小 L 是学校算法协会的成员。在今年的学校社团招新中,小 L 一共招收了 \(n\) 个新成员,其中 \(n\)偶数。现在小 L 希望将他们分到协会不同的部门。

算法协会共设有三个部门,其中第 \(i\) (\(1 \leq i \leq n\)) 个新成员对第 \(j\) (\(1 \leq j \leq 3\)) 个部门的满意度为 \(a_{i,j}\)。定义一个分配方案的满意度为所有新成员对分配到的部门的满意度之和,也就是说,若将第 \(i\) (\(1 \leq i \leq n\)) 个新成员分配到了第 \(d_i \in \{1,2,3\}\) 个部门,则该分配方案的满意度为 \(\sum_{i=1}^{n} a_{i,d_i}\)

小 L 不希望某一个部门的新成员数量过多。具体地,他要求在分配方案中,不存在一个部门被分配多于 \(\frac{n}{2}\) 个新成员。你需要帮助小 L 求出,满足他要求的分配方案的满意度的最大值。

输入格式

本题包含多组测试数据。

输入的第一行包含一个正整数 \(t\),表示测试数据组数。

接下来依次输入每组测试数据,对于每组测试数据:

  • 第一行包含一个正整数 \(n\),表示新成员的数量。
  • \(i+1\) (\(1 \leq i \leq n\)) 行包含三个非负整数 \(a_{i,1}, a_{i,2}, a_{i,3}\),分别表示第 \(i\) 个新成员对第 \(1,2,3\) 个部门的满意度。

输出格式

对于每组测试数据,输出一行一个非负整数,表示满足小 L 要求的分配方案的满意度的最大值。

输入 #1

3
4
4 2 1
3 2 4
5 3 4
3 5 1
4
0 1 0
0 1 0
0 2 0
0 2 0
2
10 9 8
4 0 0

输出 #1

18
4
13

【样例 1 解释】

该样例共包含三组测试数据。

对于第一组测试数据,可以将四个新成员分别分配到第 \(1,3,1,2\) 个部门,则三个部门的新成员数量分别为 \(2,1,1\),均不超过 \(\frac{4}{2} = 2\),满意度为 \(4 + 4 + 5 + 5 = 18\)

对于第二组测试数据,可以将四个新成员分别分配到第 \(1,1,2,2\) 个部门,则三个部门的新成员数量分别为 \(2,2,0\),均不超过 \(\frac{4}{2} = 2\),满意度为 \(0 + 0 + 2 + 2 = 4\)

对于第三组测试数据,可以将两个新成员分别分配到第 \(2,1\) 个部门,则三个部门的新成员数量分别为 \(1,1,0\),均不超过 \(\frac{2}{2} = 1\),满意度为 \(9 + 4 = 13\)

数据范围

对于所有测试数据,保证:

  • \(1 \leq t \leq 5\);
  • \(2 \leq n \leq 10^5\),且 \(n\) 为偶数;
  • 对于所有 \(1 \leq i \leq n\)\(1 \leq j \leq 3\),均有 \(0 \leq a_{i,j} \leq 2 \times 10^4\)

解析

可以先让每个人都直接选择三个部门中满意度最大的部门,求出最大总和,同时记录每个部门分别被哪些人选择了。

如果三个部门中选择人数最多的部门不超过一半,当前总和就是答案,直接输出即可。否则,就从选择人数超过一半的那个部门中挑选一些人,让他们选择满意度次大的部门。

假设共有 \(m\) 人选择了这个人数最多的部门,那么我们只需要从中挑选 \(m - \dfrac n 2\) 个人更改部门即可。可以发现在更改的过程中,选择另外两个部门的人数不可能超过总数的一半。

为了让最终满意度总和保持最大,我们可以让这个部门中的所有人按照 “最大满意度 - 次大满意度” 这一差值进行排序,这个值越小说明从最大满意度改为次大满意度所降低的满意度最少。排序后对前 \(m - \dfrac n 2\) 小的数值求和加入前面的最大总和即可作为最终答案。

单组数据时间复杂度 \(O(n\log n)\)

#include<bits/stdc++.h>
using namespace std;

int a[100005][3];

void solve()
{
    int n;
    cin >> n;
    
    int ans = 0;
    
    vector<int> G[3]; // 分别存储三个部门有哪些人加入
    for(int i = 1; i <= n; i++)
    {
        cin >> a[i][0] >> a[i][1] >> a[i][2];
        int mx = max({a[i][0], a[i][1], a[i][2]});
        ans += mx; // 先取最大值加入答案
        if(a[i][0] == mx)
            G[0].push_back(i);
        else if(a[i][1] == mx)
            G[1].push_back(i);
        else
            G[2].push_back(i);
    }
    
    if(max({G[0].size(), G[1].size(), G[2].size()}) <= n / 2) // 如果人数最多的部门也没有超过一半,直接输出
    {
        cout << ans << "\n";
        return;
    }
    
    int p; // 找人数最多的部门编号
    if(G[0].size() > n / 2)
        p = 0;
    else if(G[1].size() > n / 2)
        p = 1;
    else
        p = 2;
    int siz = G[p].size() - n / 2; // 需要改变多少个人的选择
    
    vector<int> T;
    for(int &i : G[p]) // 看一遍选择 p 这个部门的每个人
    {
        int mx = max({a[i][0], a[i][1], a[i][2]});
        int mn = min({a[i][0], a[i][1], a[i][2]});
        int mid = a[i][0] + a[i][1] + a[i][2] - mx - mn; // 求去掉最大最小后的中间值
        T.push_back(mx - mid); // 最大转为次大的差值
    }
    sort(T.begin(), T.end());
    
    for(int i = 0; i < siz; i++) // 取前 siz 小总和,作为改变部门的代价
        ans -= T[i];
    cout << ans << "\n";
}

int main()
{
    ios::sync_with_stdio(0);
    cin.tie(0); cout.tie(0);
    
    int T;
    cin >> T;
    while(T--)
        solve();
    
    return 0;
}

[CSP-S 2025] 道路修复 / road

题目描述

C 国的交通系统由 \(n\) 座城市与 \(m\) 条连接两座城市的双向道路构成,第 \(i\) (\(1 \leq i \leq m\)) 条道路连接城市 \(u_i\)\(v_i\)任意两座城市都能通过若干条道路相互到达。

然而,近期由于一场大地震,所有 \(m\) 条道路都被破坏了,修复第 \(i\) (\(1 \leq i \leq m\)) 条道路的费用为 \(w_i\)。与此同时,C 国还有 \(k\) 个准备进行城市化改造的乡镇。对于第 \(j\) (\(1 \leq j \leq k\)) 个乡镇,C 国对其进行城市化改造的费用为 \(c_j\)。在城市化改造完第 \(j\) (\(1 \leq j \leq k\)) 个乡镇后,可以在这个乡镇与原来的 \(n\) 座城市间建造若干条道路,其中在它与第 \(i\) (\(1 \leq i \leq n\)) 座城市间建造一条道路的费用为 \(a_{j,i}\)。C 国可以在这 \(k\) 个乡镇中选择任意多个进行城市化改造,也可以不选择任何乡镇进行城市化改造。

为尽快恢复城市间的交通,C 国政府希望以最低的费用将原有\(n\) 座城市两两连通,也即任意两座原有的城市都能通过若干条修复或新建造的道路相互到达。你需要帮助他们求出,将原有的 \(n\) 座城市两两连通的最小费用。

输入格式

输入的第一行包含三个非负整数 \(n, m, k\),分别表示原有的城市数量、道路数量和准备进行城市化改造的乡镇数量。

输入的第 \(i+1\) (\(1 \leq i \leq m\)) 行包含三个非负整数 \(u_i, v_i, w_i\),表示第 \(i\) 条道路连接的两座城市与修复该道路的费用。

输入的第 \(j+m+1\) (\(1 \leq j \leq k\)) 行包含 \(n+1\) 个非负整数 \(c_j, a_{j,1}, a_{j,2}, \ldots, a_{j,n}\),分别表示将第 \(j\) 个乡镇进行城市化改造的费用与在该乡镇与原有的城市间建造道路的费用。

输出格式

输出一行一个非负整数,表示将原有的 \(n\) 座城市两两连通的最小费用。

输入 #1

4 4 2
1 4 6
2 3 7
4 2 5
4 3 4
1 1 8 2 4
100 1 3 2 4

输出 #1

13

【样例 1 解释】

C 国政府可以选择修复第 \(3\) 条和第 \(4\) 条道路,然后将第 \(1\) 个乡镇进行城市化改造,并建造它与第 \(1,3\) 座城市间的道路,总费用为 \(5 + 4 + 1 + 1 + 2 = 13\)。可以证明,不存在比 \(13\) 更小的费用能使原有的 \(4\) 座城市两两连通。

数据范围

对于所有测试数据,保证:

  • \(1 \leq n \leq 10^4\)\(1 \leq m \leq 10^6\)\(0 \leq k \leq 10\);
  • 对于所有 \(1 \leq i \leq m\),均有 \(1 \leq u_i, v_i \leq n\), \(u_i \neq v_i\)\(0 \leq w_i \leq 10^9\);
  • 对于所有 \(1 \leq j \leq k\),均有 \(0 \leq c_j \leq 10^9\);
  • 对于所有 \(1 \leq j \leq k\)\(1 \leq i \leq n\), 均有 \(0 \leq a_{j,i} \leq 10^9\);
  • 任意两座原有的城市都能通过若干条原有的道路相互到达。

解析

观察数据范围发现 \(0 \le k \le 10\),容易想到去枚举要对哪些乡镇进行城市化改造,共有 \(2^k\) 种改造方案。

在确定城市化改造的方案后,剩下的问题便是选择总费用最少的道路,使得原图中的 \(n\) 个城市和当前方案中被选择的乡镇能够通过选择的道路形成一整个连通块,这一步就是最小生成树模板。此时总边数为原图边数 \(m\) 以及当前选择的每个乡镇与每个城市的连边数 \(kn\),单个方案最大时间复杂度可达 \(O((m + kn) \log (m + kn))\),显然再套上一层枚举改造方案的 \(O(2^k)\) 枚举后会超时。

考虑优化边数。发现对于原图的这 \(m\) 条边而言,我们如果先通过这 \(m\) 条边求出原图的一棵最小生成树,那么所有没被这棵最小生成树选中的边肯定在每种改造方案中也肯定不会被选中。因为没被选中的这些边在做最小生成树时,肯定已经可以通过原图中边权更小的边让其两端点连通。而当某些乡镇被选择后,新加入的这些边只会让我们的最小生成树总权值要么不变要么变小,不可能变大。

因此这 \(m\) 条边中只会有 \(n-1\) 条边会在之后的每种方案中被用到,可以先预处理出来。此时总边数的级别便降到了 \(kn\) 级,单组方案最大时间复杂度为 \(O(kn \log kn)\),但再套上一层 \(O(2^k)\) 枚举后仍然还是多个 \(\log\)

发现这里的 \(\log\) 主要还是出现在每次要对所有会用到的边按边权进行的排序上,普通并查集在带上路径压缩后的时间复杂度一般默认为 \(O(n\alpha(n))\),可以当作常数较大的线性。

考虑将排序从处理每组方案的内部提出来,可以将所有可能用到的 \((n-1) + kn\) 条边在外层直接进行排序,那么每次处理方案时,只需要把排好序的 \((n-1) + kn\) 条边全部看一遍,如果某条边属于当前方案没用到的某个乡镇的话,就跳过不看他即可。这样单组方案的最大时间复杂度便变成了 \(O(kn)\)

总时间复杂度为 \(O(m\log m + kn \log kn + 2^kkn)\)

#include<bits/stdc++.h>
using namespace std;
typedef long long ll;

struct edge
{
    int u, v, w;
    bool operator < (const edge &e) const
    {
        return w < e.w; // 按边权从小到大排序 做最小生成树
    }
};

int n, m, k;
vector<edge> G1; // 输入的原图
vector<edge> G2; // 真正会用到的边 共 n-1+kn 条
int c[15]; // 建设每个城镇的额外花费

int fa[10015];
void init() // 初始化并查集
{
    for(int i = 1; i <= n + k; i++)
        fa[i] = i;
}
int find(int p)
{
    return p == fa[p] ? p : (fa[p] = find(fa[p]));
}
void merge(int a, int b)
{
    fa[find(a)] = find(b);
}

int main()
{
    ios::sync_with_stdio(0);
    cin.tie(0); cout.tie(0);
    
    cin >> n >> m >> k;
    for(int i = 1; i <= m; i++)
    {
        edge e;
        cin >> e.u >> e.v >> e.w;
        G1.push_back(e);
    }
    for(int i = 1; i <= k; i++)
    {
        cin >> c[i];
        for(int j = 1; j <= n; j++)
        {
            edge e;
            e.u = n + i;
            e.v = j;
            cin >> e.w;
            G2.push_back(e);
        }
    }
    
    // 在原图中求最小生成树,将选择的边加入 G2
    init();
    sort(G1.begin(), G1.end());
    for(edge &e : G1)
    {
        if(find(e.u) == find(e.v))
            continue;
        merge(e.u, e.v);
        G2.push_back(e);
    }
    
    ll ans = 1e18;
    sort(G2.begin(), G2.end());
    for(int sta = 0; sta < (1 << k); sta++) // 枚举要使用的城市化建设城镇的集合
    {
        init();
        int cnt = n + __builtin_popcount(sta); // 此时最小生成树上的点数
        ll sum = 0; // 这种方案下的总花费
        
        for(int i = 0; i < k; i++) // 先把建设城镇的花费计入
            if(sta >> i & 1)
                sum += c[i + 1];
        
        for(edge &e : G2)
        {
            if(e.u > n && (sta >> (e.u - n - 1) & 1) == 0) // 这条边对应的城镇当前不建设
                continue;
            if(find(e.u) == find(e.v))
                continue;
            merge(e.u, e.v);
            sum += e.w;
            if(--cnt == 1)
                break;
        }
        ans = min(ans, sum);
    }
    cout << ans;
    
    return 0;
}

[CSP-S 2025] 谐音替换 / replace

题目描述

小 W 是一名喜欢语言学的算法竞赛选手。在语言学中,谐音替换是指将原有的字词替换为读音相同或相近的字词。小 W 发现,谐音替换的过程可以用字符串来进行描述。具体地,小 W 将谐音替换定义为以下字符串问题:

给定 \(n\) 个字符串二元组,第 \(i\) (\(1 \leq i \leq n\)) 个字符串二元组为 \((s_{i,1}, s_{i,2})\),满足 \(|s_{i,1}| = |s_{i,2}|\),其中 \(|s|\) 表示字符串 \(s\) 的长度。

对于字符串 \(s\),定义 \(s\)替换如下:

  • 对于 \(s\) 的某个子串 \(y\),若存在 \(1 \leq i \leq n\) 满足 \(y = s_{i,1}\),则将 \(y\) 替换为 \(y' = s_{i,2}\)。具体地,设 \(s = x + y + z\),其中 \(x\)\(z\) 可以为空,“+” 表示字符串拼接,则 \(s\) 的替换将得到字符串 \(s' = x + y' + z\)

小 W 提出了 \(q\) 个问题,第 \(j\) (\(1 \leq j \leq q\)) 个问题会给定两个不同的字符串 \(t_{j,1}, t_{j,2}\),她想知道有多少种字符串 \(t_{j,1}\) 的替换能够得到字符串 \(t_{j,2}\)。两种 \(s\) 的替换不同当且仅当子串 \(y\) 的位置不同或用于替换的二元组 \((s_{i,1}, s_{i,2})\) 不同,即 \(x, z\) 不同或 \(i\) 不同。你需要回答小 W 提出的所有问题。

输入格式

输入的第一行包含两个正整数 \(n, q\),分别表示字符串二元组的数量和小 W 提出的问题的数量。

输入的第 \(i+1\) (\(1 \leq i \leq n\)) 行包含两个字符串 \(s_{i,1}, s_{i,2}\),表示第 \(i\) 个字符串二元组。

输入的第 \(j+n+1\) (\(1 \leq j \leq q\)) 行包含两个字符串 \(t_{j,1}, t_{j,2}\),表示小 W 提出的第 \(j\) 个问题。

输出格式

输出 \(q\) 行,其中第 \(j\) (\(1 \leq j \leq q\)) 行包含一个非负整数,表示替换后得到字符串 \(t_{j,2}\) 的字符串 \(t_{j,1}\) 的替换的数量。

输入 #1

4 2
xabcx xadex
ab cd
bc de
aa bb
xabcx xadex
aaaa bbbb

输出 #1

2
0

【样例 1 解释】

对于小 W 的第一个询问,共有 \(2\)\(t_{1,1}\) 的替换能够得到 \(t_{1,2}\):

  1. \(x, z\) 均为空串,\(y = \text{xabcx}\), \(i = 1\),则 \(y' = \texttt{xadex}\),替换后得到 \(\text{xadex}\)
  2. \(x = \texttt{xa}\), \(y = \texttt{bc}\), \(z = \texttt{x}\), \(i = 3\),则 \(y' = \texttt{de}\),替换后得到 \(\texttt{xadex}\)

输入 #2

3 4
a b
b c
c d
aa bb
aa b
a c
b a

输出 #2

0
0
0
0

数据范围

\(L_1 = \sum_{i=1}^{n} |s_{i,1}| + |s_{i,2}|\), \(L_2 = \sum_{j=1}^{q} |t_{j,1}| + |t_{j,2}|\)。对于所有测试数据,保证:

  • \(1 \leq n, q \leq 2 \times 10^5\);
  • \(2 \leq L_1, L_2 \leq 5 \times 10^6\);
  • 对于所有 \(1 \leq i \leq n\), \(s_{i,1}, s_{i,2}\) 均仅包含小写英文字母,且 \(|s_{i,1}| = |s_{i,2}|\);
  • 对于所有 \(1 \leq j \leq q\), \(t_{j,1}, t_{j,2}\) 均仅包含小写英文字母,且 \(t_{j,1} \neq t_{j,2}\)

解析

首先:

  • 由于保证 \(|s_{i,1}| = |s_{i,2}|\),因此如果 \(|t_{j,1}| \ne |t_{j, 2}|\),可以直接判为无解。
  • 由于保证 \(t_{j,1} \neq t_{j,2}\),因此如果 \(s_{i,1} = s_{i,2}\),也可以直接判为无解。

考虑一个例子:

\[(s_1, s_2) = (\texttt{aabbcc},\ \texttt{aaddcc}) \\ (t_1, t_2) = (\texttt{eaabbccf},\ \texttt{eaaddccf}) \]

发现我们可以将 \((s_1, s_2)\)\((t_1, t_2)\)最长公共前缀最长公共后缀单独取出(可以为空串),将字符串分为三部分,这样每个字符串便可以看作是:

\[(s_1, s_2) = (\texttt{aa bb cc},\ \texttt{aa dd cc}) \\ (t_1, t_2) = (\texttt{eaa bb ccf},\ \texttt{eaa dd ccf}) \]

如果我们将 \((s_1, s_2)\) 看作 \((x+y+z, x+y'+z)\),将 \((t_1, t_2)\) 看作 \((a+b+c, a+b'+c)\),可以发现如果 \((s_1, s_2)\)\((t_1, t_2)\) 的某个替换二元组,那么以下条件必须满足:

  • \(y = b\)
  • \(y' = b'\)
  • \(x\)\(a\) 的后缀
  • \(z\)\(b\) 的前缀

考虑将这里出现的所有字符串二元组通过某种映射变成单个字符串,明显对于 \((s_1, s_2)\) 这个二元组来说,有 \(x, y, y', z\) 这四个子串可以用于描述其特征,同理对于 \((t_1, t_2)\) 来说,有 \(a, b, b', c\) 这四个子串可以用于描述其特征。

最简单的方法是将四个特征子串直接按顺序前后连接在一起,其间以一个特殊字符隔开,例如 \(x, y, y', z\) 映射为 \(x + \texttt{?} + y + \texttt{?} + y' + \texttt{?} + z\),这里的 \(+\) 表示字符串前后拼接,\(\texttt{?}\) 表示任意用于分隔的特殊字符。 (当然第二个 \(\texttt{?}\) 也可以选择不加,因为 \(y\)\(y'\) 的长度是相同的,直接拼接也可以唯一表示一种方案。)

于是发现在这种映射方式下,如果 \((s_1, s_2)\)\((t_1, t_2)\) 的某个替换二元组,那么映射后的 \(x + \texttt{?} + y + \texttt{?} + y' + \texttt{?} + z\) 一定是 \(a + \texttt{?} + b + \texttt{?} + b' + \texttt{?} + c\) 的子串。

于是可以将给定的每个替换二元组 \((s_{i,1}, s_{i,2})\) 映射后插入到 Trie 树内并建立 AC 自动机,每次询问也按照相同的方式映射字符串,然后在 Trie 树上维护多模式串匹配的结果即可。

时间复杂度 \(O(L_1 + L_2)\)

#include<bits/stdc++.h>
using namespace std;
typedef long long ll;

int trie[5000005][27], fail[5000005], tot = 1;
int cnt[5000005]; // cnt[i] 表示 i 结点(及其后缀)匹配上了多少个模式串

// 将字符串 s 插入字典树
void insert(string s)
{
    int p = 1;
    for(char &c : s)
    {
        int id = c - 'a';
        if(trie[p][id] == 0)
            trie[p][id] = ++tot;
        p = trie[p][id];
    }
    cnt[p]++;
}

// 构建 AC 自动机
void build()
{
    queue<int> q;
    for(int i = 0; i < 27; i++)
    {
        if(trie[1][i] != 0)
        {
            int v = trie[1][i];
            fail[v] = 1; // 第一个字符就匹配失败,fail 指向根结点
            q.push(v);
        }
        else
            trie[1][i] = 1; // 直接失配回到根结点
    }
    while(!q.empty())
    {
        int u = q.front();
        q.pop();
        for(int i = 0; i < 27; i++)
        {
            if(trie[u][i] != 0)
            {
                int v = trie[u][i];
                // 如果下个点 v 发生失配,可以通过 u 失配后跳到的结点再往下走相同字符 i 得到 fail 指针
                fail[v] = trie[fail[u]][i];
                q.push(v);
            }
            else
            {
                // 之后的匹配过程中如果往 i 方向跳,直接视作失配即可,从当前失配点出发继续往 i 方向跳
                trie[u][i] = trie[fail[u]][i];
            }
        }
        // fail 指针指向的后缀 标记下传
        cnt[u] += cnt[fail[u]];
    }
}

// 求字符串 s 匹配上了多少个字符串
ll check(string s)
{
    ll res = 0;
    int p = 1;
    for(char &c : s)
    {
        int id = c - 'a';
        p = trie[p][id];
        res += cnt[p];
    }
    return res;
}

// a = x + y + z
// b = x + y' + z
// map to ->    x + '~' + y + y' + '~' + z
string f(string &a, string &b)
{
    int siz = a.size();
    int l = 0, r = siz - 1;
    while(a[l] == b[l])
        l++;
    while(a[r] == b[r])
        r--;
    // [0, l-1] [l, r] [r+1, siz-1]
    return a.substr(0, l)
         + '{'                    // ASCII = 'z' + 1
         + a.substr(l, r-l+1)
         + '{'
         + b.substr(l, r-l+1)
         + '{'
         + a.substr(r+1, siz-r-1);
}

int main()
{
    ios::sync_with_stdio(0);
    cin.tie(0); cout.tie(0);
    
    int n, m;
    cin >> n >> m;
    for(int i = 1; i <= n; i++)
    {
        string a, b;
        cin >> a >> b;
        if(a == b)
            continue;
        insert(f(a, b));
    }
    build();
    for(int i = 1; i <= m; i++)
    {
        string a, b;
        cin >> a >> b;
        if(a.size() != b.size())
        {
            cout << "0\n";
            continue;
        }
        cout << check(f(a, b)) << "\n";
    }
    
    return 0;
}

[CSP-S 2025] 员工招聘 / employ

题目描述

小 Z 和小 H 想要合伙开一家公司,共有 \(n\) 人前来应聘,编号为 \(1 \sim n\)。小 Z 和小 H 希望录用至少 \(m\) 人。

小 H 是面试官,将在接下来 \(n\) 天每天面试一个人。小 Z 负责决定应聘人前来面试的顺序。具体地,小 Z 可以选择一个 \(1 \sim n\) 的排列 \(p\),然后在第 \(i\) (\(1 \leq i \leq n\)) 天通知编号为 \(p_i\) 的人前来面试。

小 H 准备了 \(n\) 套难度不一的面试题。由于 \(n\) 个前来应聘的人水平大致相同,因此对于同一套题,所有人的作答结果是一致的。具体地,第 \(i\) (\(1 \leq i \leq n\)) 天的面试题的难度为 \(s_i \in \{0,1\}\),其中 \(s_i = 0\) 表示这套题的难度较高,没有人能够做出;\(s_i = 1\) 表示这套题的难度较低,所有人都能做出。小 H 会根据面试者的作答结果决定是否录用,即如果面试者没有做出面试题,则会拒绝,否则会录用。

然而,每个人的耐心都有一定的上限,如果在他面试之前未录用的人数过多,则他会直接放弃参加面试。具体地,编号为 \(i\) (\(1 \leq i \leq n\)) 的人的耐心上限可以用非负整数 \(c_i\) 描述,若在他之前已经有不少于 \(c_i\) 人被拒绝或放弃参加面试,则他也将放弃参加面试。

小 Z 想知道一共有多少种面试的顺序 \(p\) 能够让他们录用至少 \(m\) 人。你需要帮助小 Z 求出,能够录用至少 \(m\) 人的排列 \(p\) 的数量。由于答案可能较大,你只需要求出答案对 \(998\,244\,353\) 取模后的结果。

输入格式

输入的第一行包含两个正整数 \(n, m\),分别表示前来应聘的人数和希望录用的人数。

输入的第二行包含一个长度为 \(n\) 的字符串 \(s_1 \dots s_n\),表示每一天的面试题的难度。

输入的第三行包含 \(n\) 个非负整数 \(c_1, c_2, \dots, c_n\),表示每个人的耐心上限。

输出格式

输出一行一个非负整数,表示能够录用至少 \(m\) 人的排列 \(p\) 的数量对 \(998\,244\,353\) 取模后的结果。

输入 #1

3 2
101
1 1 2

输出 #1

2

【样例 1 解释】

共有以下 2 种面试的顺序 \(p\) 能够让小 Z 和小 H 录用至少 2 人:

  1. \(p = [1,2,3]\), 依次录用编号为 1 的人和编号为 3 的人;
  2. \(p = [2,1,3]\), 依次录用编号为 2 的人和编号为 3 的人。

输入 #2

10 5
1101111011
6 0 4 2 1 2 5 4 3 3

输出 #2

2204128

数据范围

对于所有测试数据,保证:

  • \(1 \leq m \leq n \leq 500\);
  • 对于所有 \(1 \leq i \leq n\),均有 \(s_i \in \{0,1\}\);
  • 对于所有 \(1 \leq i \leq n\),均有 \(0 \leq c_i \leq n\)

解析

首先可以发现,每次面试要选择何种类型的人是可以作出决策的。

例如在 \(s_i = 1\) 时,假设前面未录用的人数为 \(j\),那么如果这次面试我们希望有人能通过,那就得选择一个耐心 \(\gt j\) 的人进行,这并不会影响到未录用的总人数;而如果这次面试我们不希望有人通过,那么就选择一个耐心 \(\le j\) 的人进行,这个人会放弃参加面试,而导致未录用的总人数增加。

因此可以考虑借助动态规划来根据每一步的决策维护答案。


简单地定义 \(\text{dp}[i][j]\) 表示在前 \(i\) 场面试中,未录用的人数为 \(j\) 人的方案数量,这两维状态还是比较好想的。

但我们重新考虑上面的分类讨论,在 \(s_i = 1\) 时我们作出的两种决策要么会选择一个耐心 \(\gt j\) 的人,要么会选择一个耐心 \(\le j\) 的人。

由于未录用的人数 \(j\) 在单组方案内一定是单调非递减的,因此如果本次打算选择一个耐心 \(\le j\) 的人,发现不论此人的耐心具体为多少,结果都是相同的(放弃面试),因此所有耐心 \(\le j\) 的人均可选。我们可以考虑**再加一维状态 \(k\) **用于表示在此之前已经选择了多少耐心 \(\le j\) 的人。在本题输入时可以通过计数数组+前缀和预处理得出总共有多少耐心 \(\le j\) 的人,记作 \(\text{pre}[j]\),那么明显 \(\text{pre}[j] - k\) 就是当前这场面试的选法数量。

而如果本次打算选择一个耐心 \(\gt j\) 的人,我们则必须要确定选择的这个人耐心具体为多少,因为这会影响到后续我们选择耐心值与其相同的人的方案总数。但我们不能直接改计数数组,也不能把每种耐心的人数都当作动规的状态加入,这就不大好去判断每次要面试的这个人还有多少种选择方案了。

所以这里只能借助“贡献延后计算”的技巧来维护最终答案。


所谓“贡献延后计算”,顾名思义就是把当前决策对于答案的贡献放在更后面的位置再计算。

对于本题而言,当我们打算选择一个耐心 \(\gt j\) 的人来参加面试时,先不要去考虑具体选谁,而是可以把这场面试先当作一个空位。

根据上面的讨论,此时 \(\text{dp}[i][j][k]\) 表示的是进行了 \(i\) 场面试,未录用的总人数为 \(j\),且已经选择了 \(k\) 位耐心 \(\le j\) 的人参与面试。也就是说,在前面 \(i\) 场面试中共有 \(i - k\) 个人的耐心是 \(\gt j\) 的,但现在这些人都还没具体确定,我们只知道前面有 \(i - k\) 个空位还可以安排人。

至于什么时候再去安排这些空位的人选,无非就以下两种情况:

  • 随着未录取的人数 \(j\) 慢慢变大,之前某些耐心 \(\gt j\) 的人变成了耐心 \(\le j\) 的人,此时便可以确定当前耐心 $ = j$ 的这些人的选法。
    • 这一步我们便可以借助枚举来确定当前的 \(i - k\) 个空位中具体会有多少个空位是由耐心 $ = j$ 的这些人占据的。
  • 在所有面试全部考虑完成后,剩余的所有空位可以由剩余的没确定位置的人任意选择。
    • 令最后剩余空位数量为 \(x\),明显方案数为 \(x\) 的全排列 \(\text{A}_x^x = x!\)

考虑完方案数的具体计算方法后,开始进行状态转移。假设当前状态为 \(\text{dp}[i][j][k]\) (进行了 \(i\) 场面试,未录用的总人数为 \(j\),且已经选择了 \(k\) 位耐心 \(\le j\) 的人参与面试),\(\text{cnt}[i]\) 表示耐心 \(=i\) 的总人数,\(\text{pre}[i]\) 表示耐心 \(\le i\) 的总人数。

\(s_{i+1} = 1\) 时,与上面的例子相同,我们可以考虑下一场面试录用或者不录用人:

  • 如果下场面试要录用人,也就是选了一个耐心 \(\gt j\) 的人:
    • 首先判断未面试的人中是否还有耐心 \(\gt j\) 的人,即 \((n - \text{pre}[j]) - (i - k) \gt 0\) 是否成立。如果成立,则下一场面试的位置可以先当作是一个空位。
    • 对于下一个状态,面试多了一场,但未录用的人数以及耐心 \(\le j\) 的人数均没有增加。
    • \(\large \text{dp}[i+1][j][k] \leftarrow \text{dp}[i][j][k]\)
  • 如果下场面试不录用人,也就是选了一个耐心 \(\le j\) 的人:
    • 此时耐心 \(\le j\) 且未参加面试的总人数共 \(\text{pre}[j] - k\) 人,任选一人均能参加下一场面试。
    • 但由于此时选择了一个耐心 \(\le j\) 的人,这个人一定会放弃面试,然后导致未录取的总人数从 \(j\) 变为 \(j+1\)。那么这一步操作会使得所有耐心 $ = j+1$ 的人从原本的 \(\gt j\) 状态转为 \(\le j\) 状态,根据上面的”贡献延后计算“方法,此时就可以把这些人安排到前面出现的空位中,将其位置方案确定下来。
    • 考虑枚举 \(u = 0 \sim \min(\text{cnt}[j+1], i - k)\),表示前面的 \(i - k\) 个空位中共有多少个空位要选择耐心 $ = j+1$ 的人。空位的选择方案共 \(\text{C}_{i-k}^u\) 种。
    • 然后考虑从耐心 \(= j + 1\) 的这 \(\text{cnt}[j+1]\) 个人中选出 \(u\) 个人,放在这些空位上。由于顺序不同也表示不同方案,这是个排列问题,方案共 \(\text{A}_{\text{cnt}[j+1]}^{u}\) 种。
    • 对于下一个状态,面试多了一场,未录用的人数 \(+1\),耐心 \(\le j+1\) 的人数增加了 \(u+1\) 人。
    • \(\large \text{dp}[i+1][j+1][k+u+1] \leftarrow \text{dp}[i][j][k] \times (\text{pre}[j] - k) \times \text{C}_{i-k}^u \times \text{A}_{\text{cnt}[j+1]}^u\)

\(s_{i+1} = 0\) 时,此时不可能录用到人,未录用的人数一定会变为 \(j+1\)。同上,此时所有耐心 $ = j+1$ 的人便可以确定位置方案,枚举 \(u = 0 \sim \min(\text{cnt}[j+1], i-k)\)

对于下一场面试,还是可以根据要选的人是否能直接确定位置来进行分类讨论,计算方法基本等同于上面对于不录用人的情况的讨论:

  • 如果下场面试选择的人不能直接确定位置,也就是选了一个耐心 \(\gt j+1\) 的人:
    • 首先判断未面试的人中是否还有耐心 \(\gt j+1\) 的人,即 \((n - \text{pre}[j+1]) - [i - (k + u)] \gt 0\) 是否成立。如果成立,则下一场面试的位置可以先当作是一个空位。
    • 考虑往前面 \(u\) 个空位中全部安排耐心 \(=j+1\) 的人,方案数共 \(\text{C}_{i-k}^u \times \text{A}_{\text{cnt}[j+1]}^u\) 种。
    • 对于下一个状态,面试多了一场,未录用的人数 \(+1\),耐心 \(\le j+1\) 的人数增加了 \(u\) 人。
    • \(\large \text{dp}[i+1][j+1][k+u] \leftarrow \text{dp}[i][j][k] \times \text{C}_{i-k}^u \times \text{A}_{\text{cnt}[j+1]}^u\)
  • 如果下场面试选择的人可以直接确定位置,也就是选了一个耐心 \(\le j+1\) 的人:
    • 此时耐心 \(\le j+1\) 且未参加面试的总人数共 \(\text{pre}[j+1] - (k+u)\) 人,任选一人均能参加下一场面试。
    • 考虑往前面 \(u\) 个空位中全部安排耐心 \(=j+1\) 的人,方案数共 \(\text{C}_{i-k}^u \times \text{A}_{\text{cnt}[j+1]}^u\) 种。
    • 对于下一个状态,面试多了一场,未录用的人数 \(+1\),耐心 \(\le j+1\) 的人数增加了 \(1+u\) 人。
    • \(\large \text{dp}[i+1][j+1][k+u+1] \leftarrow \text{dp}[i][j][k] \times [\text{pre}[j+1] - (k+u)] \times \text{C}_{i-k}^u \times \text{A}_{\text{cnt}[j+1]}^u\)

转移结束后,枚举最终实际未录取的总人数 \(j = 0 \sim n-m\),所有人中耐心 \(\le j\) 的人共 \(\text{pre}[j]\) 人,故最终状态取 \(\text{dp}[n][j][\text{pre}[j]]\)

对于未录取人数为 \(j\) 的情况,此时耐心 \(\gt j\) 的人全都没有确定位置,共 \(n - \text{pre}[j]\) 人,明显空位也只有 \(n - \text{pre}[j]\) 个,方案数即 \(\text{A}_{n - \text{pre}[j]}^{n - \text{pre}[j]} = (n - \text{pre}[j])!\)

最终答案即 \(\sum\limits_{j=0}^{n-m} \text{dp}[n][j][\text{pre}[j]] \times (n - \text{pre}[j])!\)


时间复杂度看上去需要 \(O(n^4)\),但枚举 \(k\) 时最大值不超过 \(i\),枚举 \(u\) 时最大值不超过 \(i-k\),这两层循环可以当作是一个 \(O(n)\),因此时间复杂度为 \(O(n^3)\)

至于空间,直接定义 \(n^3\) 的数组显然不可做,但发现转移过程永远是从第 \(i\) 场面试转移至第 \(i+1\) 场面试,因此可以借助 0/1 背包问题的倒序做法或是滚动数组的技巧优化掉第一维。

#include<bits/stdc++.h>
using namespace std;
typedef long long ll;

const ll mod = 998244353;

ll fac[505], inv[505];

ll qpow(ll a, ll n)
{
    ll r = 1;
    while(n)
    {
        if(n & 1)
            r = r * a % mod;
        a = a * a % mod;
        n >>= 1;
    }
    return r;
}

void init()
{
    fac[0] = 1;
    for(int i = 1; i <= 500; i++)
        fac[i] = fac[i - 1] * i % mod;
    inv[500] = qpow(fac[500], mod - 2);
    for(int i = 499; i >= 0; i--)
        inv[i] = inv[i + 1] * (i + 1) % mod;
}

// n 个数中选出 m 个数的组合方案数
ll getC(int n, int m)
{
    return fac[n] * inv[n - m] % mod * inv[m] % mod;
}

// n 个数中选出 m 个数的排列方案数
ll getA(int n, int m)
{
    return fac[n] * inv[n - m] % mod;
}

int n, m, c[505];
char s[505];
int cnt[505]; // cnt[i] 表示耐心为 i 的人的数量
int pre[505]; // pre[i] 表示耐心 <= i 的人的数量

ll dp[2][505][505];
// dp[i][j][k] 表示在前 i 场面试当中 有 j 人未被录用
// 且来面试的这 i 人当中有 k 个人的耐心 <= j 的方案数量

int main()
{
    init();
    
    cin >> n >> m;
    cin >> s;
    for(int i = 1; i <= n; i++)
    {
        cin >> c[i];
        cnt[c[i]]++;
    }
    pre[0] = cnt[0];
    for(int i = 1; i <= n; i++)
        pre[i] = pre[i - 1] + cnt[i];
    
    dp[0][0][0] = 1;
    for(int i = 0; i < n; i++) // 已经面试了多少场
    {
        int cur = i & 1;
        int nxt = cur ^ 1; // 滚动数组,从 cur 转移到 nxt
        memset(dp[nxt], 0, sizeof dp[nxt]);
        for(int j = 0; j <= i; j++) // 前 i 人中有多少人没有通过
            for(int k = 0, kmax = min(i, pre[j]); k <= kmax; k++) // 前 i 人中有多少人耐心 <= j
            {
                // (上面字符串下标从 0 开始,所以这里直接用 s[i] 表示下一场面试的难度)
                if(s[i] == '1') // 这场面试可以有人通过
                {
                    // 如果这场面试要录用人
                    // 挑一个耐心 > j 的人当作第 i 个面试的人,这个人的具体选法暂不固定
                    if((n - pre[j]) - (i - k) > 0) // 未面试的人里还有耐心 > j 的人
                    {
                        dp[nxt][j][k] += dp[cur][j][k];
                        dp[nxt][j][k] %= mod;
                    }
                    
                    // 如果这场面试不录用人
                    // 挑一个耐心 <= j 的人当作第 i 个人面试,这个人的选法可以直接计算
                    // 此时未录用的人数将来到 j+1,前面挑选的所有耐心恰好 j+1 的人就必须要把位置固定下来
                    for(int u = 0, umax = min(i - k, cnt[j + 1]); u <= umax; u++) // 前 i 人中有多少人耐心 = j+1
                    {
                        // 选择一个还没面试的耐心 <= j 的人的方案数量为 pre[j]-k
                        // 前面有 i-k 个人的耐心 > j,其中耐心 = j+1 的人共 u 个,这 u 个人能分配的位置的组合方案数为 C[i-k][u]
                        // 往这些位置“按顺序”分配耐心 = j+1 的人,方案数即从所有耐心 = j+1 的人中挑出 u 个人的排列方案数 A[cnt[j+1]][u]
                        // 耐心 <= j+1 的人除了新挑选面试的人以外,还要加上原本耐心 = j+1 的人数,即 u+1
                        dp[nxt][j + 1][k + (u + 1)] += dp[cur][j][k] * (pre[j] - k) % mod * getC(i - k, u) % mod * getA(cnt[j + 1], u) % mod;
                        dp[nxt][j + 1][k + (u + 1)] %= mod;
                    }
                }
                else // 这场面试不可能有人通过
                {
                    // 不论挑谁都会导致未录用的人数来到 j+1,前面挑选的所有耐心恰好 j+1 的人就必须要把位置固定下来
                    for(int u = 0, umax = min(i - k, cnt[j + 1]); u <= umax; u++) // 同上,枚举前 i 人中有多少人耐心 = j+1
                    {
                        // 如果挑耐心 > j+1 的人参与这场面试,这个人的具体选法暂不固定
                        if((n - pre[j + 1]) - (i - (k + u)) > 0) // 未面试的人中还有耐心 > j+1 的人
                        {
                            // 对于前面耐心 = j+1 的人的讨论与计算同上
                            dp[nxt][j + 1][k + u] += dp[cur][j][k] * getC(i - k, u) % mod * getA(cnt[j + 1], u) % mod;
                            dp[nxt][j + 1][k + u] %= mod;
                        }
                        
                        // 如果挑耐心 <= j+1 的人参与这场面试,这个人的选法可以直接计算,方案数量为 pre[j+1]-(k+u)
                        // 对于前面耐心 = j+1 的人的讨论与计算同上
                        dp[nxt][j + 1][k + (u + 1)] += dp[cur][j][k] * (pre[j + 1] - (k + u)) % mod * getC(i - k, u) % mod * getA(cnt[j + 1], u) % mod;
                        dp[nxt][j + 1][k + (u + 1)] %= mod;
                    }
                }
            }
    }
    
    ll ans = 0;
    for(int j = 0; j <= n - m; j++) // 考虑最终没有通过的总人数 j
    {
        // 此时耐心 > j 的那些人具体选法还没有确定下来
        // 现在就可以随便选了,所有耐心 > j 的人可以在剩余位置任意取全排列,共 (n - pre[j])! 种方案
        ans += dp[n & 1][j][pre[j]] * fac[n - pre[j]] % mod;
        ans %= mod;
    }
    cout << ans;
    
    return 0;
}
posted @ 2025-11-06 02:52  StelaYuri  阅读(463)  评论(1)    收藏  举报