按失败人数做 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 取模。
思路
先看一个可以直接验证想法的朴素解:
// 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,所以可以按耐心值分组。
设:
cnt[x] = c_i = x 的人数
pre[x] = c_i <= x 的人数DP 的核心状态是:
dp[failed][pending]表示已经处理若干天后,当前失败人数为 failed,并且有 pending 个已经安排过、但还没有确定具体耐心值的人。
为什么可以“不确定具体耐心值”?因为当失败人数是 failed 时,所有 c > failed 的人行为完全相同:他们不会放弃。只有当失败人数涨到 failed+1 时,c = failed+1 的人会从“不会放弃”变成“之后会放弃”,这时才需要把他们从 pending 中结算出来。
当失败人数从 failed 变为 failed+1 时,假设从 pending 个位置中结算出 t 个耐心值为 failed+1 的人,方案数为:
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 的状态。并且最终必须满足:
pending = n - pre[failed]也就是所有 c <= failed 的人已经结算完,剩下的大耐心人都还在 pending 里。它们的具体身份还可以任意排列,所以要乘上 pending!。
代码
#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 数组和组合数表都是
转移中会枚举结算数量 t,最坏时间复杂度可写作:
O(n^4)实际实现会跳过零状态,并且 t 受 pending 和 cnt[failed+1] 限制。
总结
本题难点在于不能直接记录每个耐心值用了多少人。pending 的作用是把当前还不会放弃的人暂时合并,等失败人数增长到某个阈值时,再用组合数一次性结算对应耐心值的人。
只要把“失败人数增加”视为状态边界,整个计数过程就能按天推进。
一图流解析
这张图把本题的建模、关键转移、实现检查和训练方法压缩到一页,适合读完正文后复盘。
