按失败人数做 DP,用 pending 延后结算大耐心人群,并在阈值增加时用组合数归属具体人员。
OJ: luogu
题目 ID: P14364
难度:省选/NOI-
标签:动态规划组合计数计数dp
创建: 2026-06-22 19:59
更新: 2026-10-07 10:45
形式化题目
每个人
求有多少个排列能让录用人数至少为
暴力解法
思路
枚举所有排列,按题意逐天模拟:失败人数已不少于
代码
/**
* 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;
}复杂度
- 时间:
- 空间:
瓶颈
阶乘级枚举,
正解
思路
录用人数至少为
cnt[x] = c_i = x 的人数
pre[x] = c_i <= x 的人数DP 的核心状态是:
dp[failed][pending]表示处理完若干天后,当前失败人数为 failed,并且有 pending 个已经安排过、但还没有确定具体耐心值的人。
为什么可以"不确定具体耐心值"?因为当失败人数是 failed 时,所有 failed+1 时,pending 中结算出来。
当失败人数从 failed 变为 failed+1 时,假设从 pending 个位置中结算出 failed+1 的人,方案数为:
C(pending, t) * C(cnt[failed+1], t) * t!三部分分别表示:选择哪些 pending 位置属于这一组、选择具体是哪
接着看每天怎么转移。
如果当天
- 选一个
的新人,他会被录用,失败人数不变,加入 pending; - 选一个
且还没用过的人,他会放弃,失败人数增加,并结算新阈值。
如果当天
- 当天一定会产生一个失败;
- 先结算新阈值
failed+1; - 再选择当天这个人:若他仍属于
,他参加面试后被拒绝,加入 pending;否则他立即按具体身份结算。
最后统计答案时,只保留失败人数
也就是所有 pending 里。它们的具体身份还可以任意排列,所以要乘上
代码
/**
* 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 数组和组合数表都是
空间 - 转移中枚举结算数量
,最坏时间复杂度
实际实现会跳过零状态,并且 pending 和
总结
本题难点在于不能直接记录每个耐心值用了多少人。pending 的作用是把当前还不会放弃的人暂时合并,等失败人数增长到某个阈值时,再用组合数一次性结算对应耐心值的人。
只要把"失败人数增加"视为状态边界,整个计数过程就能按天推进。
图示解析
这张图把本题的建模、关键转移、实现检查和训练方法压缩到一页,适合读完正文后复盘。
