[CSP-S 2025] 员工招聘

GitHub跳转原题关系图返回列表

按失败人数做 DP,用 pending 延后结算大耐心人群,并在阈值增加时用组合数归属具体人员。

OJ: luogu

题目 ID: P14364

难度:省选/NOI-

标签:动态规划组合计数计数dp

创建: 2026-06-22 19:59

更新: 2026-10-07 10:45

形式化题目

nn 个人来应聘,面试顺序是一个排列 pp。第 ii 天的题目难度 si∈{0,1}s_i \in \{0,1\}:si=1s_i = 1 时当天面试者被录用,si=0s_i = 0 时被拒绝。

每个人 xx 有耐心上限 cxc_x:若在他之前已经有不少于 cxc_x 人被拒绝或放弃,他直接放弃。拒绝和放弃都会让失败人数增加。

求有多少个排列能让录用人数至少为 mm,答案对 998 244 353998\,244\,353 取模。

暴力解法

思路

枚举所有排列,按题意逐天模拟:失败人数已不少于 cic_i 就放弃,否则按当天题目难度录用或拒绝。统计录用人数至少为 mm 的排列数。

代码

cpp
/**
 * Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
 * rbook: -> https://rbook.roj.ac.cn  https://rbook2.roj.ac.cn
 * rainboy的学习导航网站: https://idx.roj.ac.cn
 * create_at: 2026-06-22 19:59
 * update_at: 2026-10-01 22:48
 */
// brute.cpp:小数据暴力解,用来帮助理解题意并辅助对拍。
// 枚举所有面试顺序排列,按题意模拟录用/拒绝/放弃,只适合 n <= 9 左右。
#include <bits/stdc++.h>
using namespace std;

typedef long long ll;

const ll MOD = 998244353LL;
const int MAXN = 10;

int n, m;
string s;
int c[MAXN]; // c[i]:第 i 个人的耐心上限
int p[MAXN]; // 当前枚举的面试顺序排列

// 按题意模拟当前排列:统计录用人数是否至少为 m
bool check_perm() {
    int failed = 0;
    int hired = 0;

    for (int day = 0; day < n; day++) {
        int person = p[day];

        // 之前失败人数已不少于 c_i,这个人直接放弃
        if (failed >= c[person]) {
            failed++;
            continue;
        }

        if (s[day] == '1') {
            hired++;
        }
        else {
            failed++;
        }
    }

    return hired >= m;
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> n >> m;
    cin >> s;
    for (int i = 0; i < n; i++) {
        cin >> c[i];
        p[i] = i;
    }

    ll ans = 0;
    do {
        if (check_perm()) {
            ans++;
            if (ans >= MOD) {
                ans -= MOD;
            }
        }
    } while (next_permutation(p, p + n));

    cout << ans << '\n';
    return 0;
}

复杂度

  • 时间:O(n!⋅n)O(n! \cdot n)
  • 空间:O(n)O(n)

瓶颈

阶乘级枚举,n⩽500n \leqslant 500 时完全不可行。

正解

思路

录用人数至少为 mm,等价于最终失败人数至多 n−mn-m。一个人是否放弃,只取决于当前失败人数和他的 cc,所以可以按耐心值分组:

text
cnt[x] = c_i = x 的人数
pre[x] = c_i <= x 的人数

DP 的核心状态是:

text
dp[failed][pending]

表示处理完若干天后,当前失败人数为 failed,并且有 pending 个已经安排过、但还没有确定具体耐心值的人。

为什么可以"不确定具体耐心值"?因为当失败人数是 failed 时,所有 c>failedc > \mathit{failed} 的人行为完全相同:他们不会放弃。只有当失败人数涨到 failed+1 时,c=failed+1c = \mathit{failed}+1 的人会从"不会放弃"变成"之后会放弃",这时才需要把他们从 pending 中结算出来。

当失败人数从 failed 变为 failed+1 时,假设从 pending 个位置中结算出 tt 个耐心值为 failed+1 的人,方案数为:

text
C(pending, t) * C(cnt[failed+1], t) * t!

三部分分别表示:选择哪些 pending 位置属于这一组、选择具体是哪 tt 个人、把这些人分配到这些位置。

接着看每天怎么转移。

如果当天 s=1s = 1:

  • 选一个 c>failedc > \mathit{failed} 的新人,他会被录用,失败人数不变,加入 pending;
  • 选一个 c⩽failedc \leqslant \mathit{failed} 且还没用过的人,他会放弃,失败人数增加,并结算新阈值。

如果当天 s=0s = 0:

  • 当天一定会产生一个失败;
  • 先结算新阈值 failed+1;
  • 再选择当天这个人:若他仍属于 c>failed+1c > \mathit{failed}+1,他参加面试后被拒绝,加入 pending;否则他立即按具体身份结算。

最后统计答案时,只保留失败人数 failed⩽n−m\mathit{failed} \leqslant n-m 的状态。并且最终必须满足

pending=n−pre[failed]\mathit{pending} = n - pre[\mathit{failed}]

也就是所有 c⩽failedc \leqslant \mathit{failed} 的人已经结算完,剩下的大耐心人都还在 pending 里。它们的具体身份还可以任意排列,所以要乘上 pending!\mathit{pending}!。

代码

cpp
/**
 * Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
 * rbook: -> https://rbook.roj.ac.cn  https://rbook2.roj.ac.cn
 * rainboy的学习导航网站: https://idx.roj.ac.cn
 * create_at: 2026-06-22 19:59
 * update_at: 2026-10-01 22:48
 */
// main.cpp:按失败人数做 DP,用 pending 延后结算大耐心人群,
// 失败人数阈值增加时用组合数把具体人员归属结算出来。
#include <bits/stdc++.h>
using namespace std;

typedef long long ll;

const int MAXN = 505;
const ll MOD = 998244353LL;

int n, m;
string s;

int cnt[MAXN]; // cnt[x]:耐心上限恰好为 x 的人数
int pre[MAXN]; // pre[x]:耐心上限 <= x 的人数前缀和

ll fac[MAXN];          // fac[t]:t 的阶乘(模 MOD)
ll comb[MAXN][MAXN];   // comb[i][j]:组合数 C(i, j)(模 MOD)
ll cur[MAXN][MAXN];    // cur[failed][pending]:当前天结束后的方案数
ll nxt[MAXN][MAXN];    // 下一天的滚动数组

// 模意义下加法:x = (x + y) % MOD,要求 y < MOD
void add_mod(ll &x, ll y) {
    x += y;
    if (x >= MOD) {
        x -= MOD;
    }
}

// 从 pending 个未结算位置里结算出 t 个耐心值恰为 value_c 的人:
// 选位置 C(k, t) * 选人 C(cnt[value_c], t) * 分配 t!
ll choose_pending(int value_c, int k, int t) {
    return comb[k][t] * comb[cnt[value_c]][t] % MOD * fac[t] % MOD;
}

// 预处理阶乘和组合数表
void init_comb() {
    fac[0] = 1;
    for (int i = 1; i < MAXN; i++) {
        fac[i] = fac[i - 1] * i % MOD;
    }

    for (int i = 0; i < MAXN; i++) {
        comb[i][0] = comb[i][i] = 1;
    }
    for (int i = 1; i < MAXN; i++) {
        for (int j = 1; j < i; j++) {
            comb[i][j] = comb[i - 1][j] + comb[i - 1][j - 1];
            if (comb[i][j] >= MOD) {
                comb[i][j] -= MOD;
            }
        }
    }
}

// 清零滚动数组 nxt(只清会用到的 0..n 范围)
void clear_next() {
    for (int i = 0; i <= n; i++) {
        for (int j = 0; j <= n; j++) {
            nxt[i][j] = 0;
        }
    }
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    init_comb();

    cin >> n >> m;
    cin >> s;

    for (int i = 1; i <= n; i++) {
        int c;
        cin >> c;
        cnt[c]++;
    }

    pre[0] = cnt[0];
    for (int i = 1; i <= n; i++) {
        pre[i] = pre[i - 1] + cnt[i];
    }

    cur[0][0] = 1;

    for (int day = 0; day < n; day++) {
        clear_next();

        for (int failed = 0; failed <= day; failed++) {
            for (int pending = 0; pending <= day; pending++) {
                ll val = cur[failed][pending];
                if (val == 0) {
                    continue;
                }

                if (s[day] == '1') {
                    // 选一个 c > failed 的人,他会被录用;具体是谁延后统计。
                    if (n - pre[failed] - pending > 0) {
                        add_mod(nxt[failed][pending + 1], val);
                    }

                    // 选一个 c <= failed 的人,他会放弃,失败人数增加。
                    int available_small = pre[failed] - (day - pending);
                    if (available_small > 0) {
                        int max_t = min(cnt[failed + 1], pending);
                        for (int t = 0; t <= max_t; t++) {
                            ll ways = choose_pending(failed + 1, pending, t);
                            ways = ways * available_small % MOD;
                            add_mod(nxt[failed + 1][pending - t], val * ways % MOD);
                        }
                    }
                }
                else {
                    // 题目太难,必定失败;失败人数从 failed 变成 failed + 1。
                    int max_t = min(cnt[failed + 1], pending);
                    for (int t = 0; t <= max_t; t++) {
                        ll ways = choose_pending(failed + 1, pending, t);
                        int after_pending = pending - t;

                        // 当天这个人若 c > failed + 1,继续作为待结算人员。
                        if (n - pre[failed + 1] - after_pending > 0) {
                            add_mod(nxt[failed + 1][after_pending + 1], val * ways % MOD);
                        }

                        // 当天这个人若 c <= failed + 1,立即结算具体身份。
                        int available_small = pre[failed + 1] - (day - after_pending);
                        if (available_small > 0) {
                            add_mod(nxt[failed + 1][after_pending],
                                    val * ways % MOD * available_small % MOD);
                        }
                    }
                }
            }
        }

        for (int i = 0; i <= n; i++) {
            for (int j = 0; j <= n; j++) {
                cur[i][j] = nxt[i][j];
            }
        }
    }

    // 录用至少 m 人 <=> 失败人数 <= n - m;
    // 最终所有 c <= failed 的人已结算完,剩余 pending 个大耐心人可任意排列。
    ll ans = 0;
    for (int failed = 0; failed <= n - m; failed++) {
        int pending = n - pre[failed];
        if (pending >= 0 && pending <= n) {
            add_mod(ans, cur[failed][pending] * fac[pending] % MOD);
        }
    }

    cout << ans << '\n';
    return 0;
}

复杂度

  • 滚动 DP 数组和组合数表都是 O(n2)O(n^2) 空间
  • 转移中枚举结算数量 tt,最坏时间复杂度 O(n4)O(n^4)

实际实现会跳过零状态,并且 tt 受 pending 和 cnt[failed+1]cnt[\mathit{failed}+1] 限制。

总结

本题难点在于不能直接记录每个耐心值用了多少人。pending 的作用是把当前还不会放弃的人暂时合并,等失败人数增长到某个阈值时,再用组合数一次性结算对应耐心值的人。

只要把"失败人数增加"视为状态边界,整个计数过程就能按天推进。

图示解析

这张图把本题的建模、关键转移、实现检查和训练方法压缩到一页,适合读完正文后复盘。

一图流解析