[CSP-S 2025] 员工招聘

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

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

OJ: luogu

题目 ID: P14364

难度:省选/NOI-

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

日期: 2026-06-22 19:59

题意

n 个应聘者,小 Z 可以安排他们的面试顺序。第 i 天的题目由 s_i 决定:

  • s_i = 1:参加面试的人会被录用;
  • s_i = 0:参加面试的人会被拒绝。

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

求有多少个排列能让录用人数至少为 m,答案对 998244353 取模。

思路

先看一个可以直接验证想法的朴素解:

cpp
// brute.cpp:小数据暴力解,用来帮助理解题意并辅助对拍。
#include <bits/stdc++.h>
using namespace std;

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

int n, m;
string s;
int c[MAXN];
int p[MAXN];

bool check_perm() {
    int failed = 0;
    int hired = 0;

    for (int day = 0; day < n; day++) {
        int person = p[day];
        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;
    }

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

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

暴力枚举所有排列,再按题意模拟。它适合验证,但 n <= 500 时不能使用。

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

设:

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

DP 的核心状态是:

text
dp[failed][pending]

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

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

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

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

三部分分别表示:

  • 选择哪些 pending 位置属于这一组;
  • 选择具体是哪 t 个人;
  • 把这些人分配到这些位置。

接着看每天怎么转移。

如果当天 s = 1

  • 选一个 c > failed 的新人,他会被录用,失败人数不变,加入 pending
  • 选一个 c <= failed 且还没用过的人,他会放弃,失败人数增加,并结算新阈值。

如果当天 s = 0

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

最后统计答案时,只保留失败人数 failed <= n-m 的状态。并且最终必须满足:

text
pending = n - pre[failed]

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

代码

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

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

int n, m;
string s;
int cnt[MAXN], pre[MAXN];
int fac[MAXN], comb[MAXN][MAXN];
int cur[MAXN][MAXN], nxt[MAXN][MAXN];

void add_mod(int &x, long long y) {
    x = (x + y) % MOD;
}

long long choose_pending(int value_c, int k, int t) {
    return 1LL * 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] = 1LL * 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;
            }
        }
    }
}

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++) {
                int 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++) {
                            long long ways = choose_pending(failed + 1, pending, t);
                            ways = ways * available_small % MOD;
                            add_mod(nxt[failed + 1][pending - t], 1LL * val * ways % MOD);
                        }
                    }
                } else {
                    // 题目太难,必定失败;失败人数从 failed 变成 failed + 1。
                    int max_t = min(cnt[failed + 1], pending);
                    for (int t = 0; t <= max_t; t++) {
                        long long 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],
                                    1LL * 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],
                                    1LL * 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];
            }
        }
    }

    int ans = 0;
    for (int failed = 0; failed <= n - m; failed++) {
        int pending = n - pre[failed];
        if (pending >= 0 && pending <= n) {
            add_mod(ans, 1LL * cur[failed][pending] * fac[pending] % MOD);
        }
    }

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

复杂度

滚动 DP 数组和组合数表都是 O(n2)O(n^2) 空间。

转移中会枚举结算数量 t,最坏时间复杂度可写作:

text
O(n^4)

实际实现会跳过零状态,并且 tpendingcnt[failed+1] 限制。

总结

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

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

一图流解析

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

一图流解析