[CSP-S 2019] Emiya 家今天的饭

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

先算每种做法任选或不选的总方案数,再按食材枚举严格多数者,用差值 DP 统计坏方案并从总数中扣掉。

OJ: luogu

题目 ID: P5664

难度:提高+/省选-

标签:动态规划容斥组合计数计数dp思维

创建: 2026-06-20 07:35

更新: 2026-10-07 10:45

形式化题目

给定一个 n×mn \times m 的非负整数矩阵 aa,ai,ja_{i,j} 表示第 ii 种烹饪方法、第 jj 种食材能做的不同菜的数量。

要求选出一个非空方案,满足:

  • 每种烹饪方法至多选一道菜(每行至多选一个)
  • 设最终选了 kk 道菜,则每种食材出现的次数都不超过 ⌊k/2⌋\lfloor k/2 \rfloor

统计所有合法方案数,对 998 244 353998\,244\,353 取模。

暴力解法

思路

逐行枚举:对第 ii 行,要么不选,要么选某一种食材 jj(有 ai,ja_{i,j} 种具体菜)。

枚举完所有行后,检查是否存在某种食材出现次数严格超过一半。若没有,则把该方案的方案数累入答案。

代码

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-20 07:35
 * update_at: 2026-10-01 20:09
 */
// brute.cpp:小数据暴力解,逐行枚举"不选"或"选某一种食材",
// 到叶子节点检查是否存在某种食材严格超过一半。
#include <bits/stdc++.h>
using namespace std;

typedef long long ll;

const int MAXN = 15;
const int MAXM = 15;
const ll MOD = 998244353LL;

int n, m;
int a[MAXN][MAXM]; // 输入矩阵
int cnt[MAXM];     // cnt[j]:当前方案中食材 j 被选了多少次
ll ans;

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

// 检查当前方案是否合法:没有任何食材严格超过一半
bool check(int chosen) {
    for (int j = 1; j <= m; j++) {
        if (cnt[j] * 2 > chosen) {
            return false;
        }
    }
    return true;
}

// 逐行枚举:第 row 行要么不选,要么选食材 j(j 从 1 到 m)
void dfs(int row, int chosen, ll ways) {
    if (row > n) {
        if (chosen == 0) {
            return;
        }

        if (check(chosen)) {
            add_mod(ans, ways);
        }
        return;
    }

    // 不选第 row 行
    dfs(row + 1, chosen, ways);

    // 选第 row 行的食材 j
    for (int j = 1; j <= m; j++) {
        if (a[row][j] == 0) {
            continue;
        }

        cnt[j]++;
        dfs(row + 1, chosen + 1, ways * a[row][j] % MOD);
        cnt[j]--;
    }
}

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

    cin >> n >> m;
    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= m; j++) {
            cin >> a[i][j];
        }
    }

    dfs(1, 0, 1);
    cout << ans << '\n';

    return 0;
}

复杂度

  • 时间:O(2n⋅m)O(2^n \cdot m),每行有"不选"和"选食材 jj"共 m+1m+1 种选择
  • 空间:O(m)O(m),只记录每种食材被选次数

瓶颈

枚举量随 nn 指数增长,只能处理 n⩽15n \leqslant 15 左右的小数据。

正解

思路

先不考虑"每种食材不能超过一半"的限制,计算总方案数,再扣掉坏方案(存在某种食材严格超过一半)。

总方案数:第 ii 行有 11 种"不选"和 ∑jai,j\sum_j a_{i,j} 种"选",所以总方案数为:

∏i=1n(1+∑j=1mai,j)−1\prod_{i=1}^{n} \left(1 + \sum_{j=1}^{m} a_{i,j}\right) - 1

减一是去掉空方案。

坏方案计数:关键观察是——严格多数的食材至多只有一种。若两种食材都超过一半,则它们的出现次数之和大于总菜数,矛盾。

因此可以枚举哪一种食材 pp 是"严格多数者",分别计数后求和。

固定食材 pp 后的三种选择

把每一行的选择分成三类:

对第 ii 行的处理 方案数 差值变化
不选 11 00
选食材 pp ai,pa_{i,p} +1+1
选其它食材 ∑jai,j−ai,p\sum_j a_{i,j} - a_{i,p} −1-1

定义 diff=\mathit{diff} =(选食材 pp 的次数)−-(选其它食材的次数)。

最终 diff>0\mathit{diff} > 0 就意味着食材 pp 比其它所有食材加起来还多,即严格超过一半。

于是做一维 DP:

  • dp[d]dp[d] 表示当前差值为 dd 的方案数(dd 用偏移量 n+1n+1 映射到非负下标)
  • 每一行按上表做三种转移
  • 最后把所有 diff>0\mathit{diff} > 0 的状态加起来,就是"食材 pp 成为严格多数"的坏方案数

把所有食材的坏方案数加起来,从总方案数里减掉即得答案。

代码

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-20 07:35
 * update_at: 2026-10-01 20:09
 */
#include <bits/stdc++.h>
using namespace std;

typedef long long ll;

const int MAXN = 105;
const int MAXM = 2005;
const int MAXD = MAXN * 2 + 5;
const ll MOD = 998244353LL;

int n, m;
int a[MAXN][MAXM]; // a[i][j]:第 i 种做法用食材 j 的方案数,值域 < MOD,用 int
ll row_sum[MAXN];  // 第 i 行所有食材方案数之和(模 MOD)
ll dp[MAXD];       // dp[d]:当前差值为 d 的方案数,d 用偏移量 offset = n+1 映射到非负下标
ll ndp[MAXD];      // 下一行滚动数组

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

// 统计"食材 food 成为严格多数"的坏方案数。
// diff = 选 food 的次数 - 选其它食材的次数,
// 最终 diff > 0 表示 food 严格超过一半。
ll count_bad_for_food(int food) {
    int offset = n + 1;
    int left = offset;
    int right = offset;

    memset(dp, 0, sizeof(dp));
    dp[offset] = 1;

    for (int i = 1; i <= n; i++) {
        memset(ndp, 0, sizeof(ndp));

        ll same = a[i][food];           // 选食材 food 的方案数
        ll other = row_sum[i] - same;   // 选非 food 食材的方案数
        if (other < 0) {
            other += MOD;
        }

        for (int d = left; d <= right; d++) {
            ll cur = dp[d];
            if (cur == 0) {
                continue;
            }

            // 第 i 种做法不选。
            add_mod(ndp[d], cur);

            // 选一个食材是 food 的菜,差值 +1。
            if (same != 0) {
                add_mod(ndp[d + 1], cur * same % MOD);
            }

            // 选一个食材不是 food 的菜,差值 -1。
            if (other != 0) {
                add_mod(ndp[d - 1], cur * other % MOD);
            }
        }

        left--;
        right++;
        memcpy(dp, ndp, sizeof(dp));
    }

    ll ans = 0;
    for (int d = offset + 1; d <= right; d++) {
        add_mod(ans, dp[d]);
    }

    return ans;
}

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

    cin >> n >> m;
    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= m; j++) {
            cin >> a[i][j];
            row_sum[i] += a[i][j];
            if (row_sum[i] >= MOD) {
                row_sum[i] -= MOD;
            }
        }
    }

    // 不考虑"某种食材不能超过一半"时:
    // 每种做法要么不选,要么任选一种食材做一道菜。
    ll total = 1;
    for (int i = 1; i <= n; i++) {
        total = total * (row_sum[i] + 1) % MOD;
    }
    total = (total - 1 + MOD) % MOD; // 去掉空方案

    // 枚举哪一种食材成为"严格多数",把所有坏方案扣掉。
    ll bad = 0;
    for (int food = 1; food <= m; food++) {
        add_mod(bad, count_bad_for_food(food));
    }

    ll ans = (total - bad) % MOD;
    if (ans < 0) {
        ans += MOD;
    }

    cout << ans << '\n';

    return 0;
}

复杂度

  • 时间:O(nm+mn2)O(nm + mn^2),读入 O(nm)O(nm),枚举 mm 种食材各做一次 O(n2)O(n^2) 的 DP
  • 空间:O(nm)O(nm),输入矩阵占主要空间,DP 数组 O(n)O(n)

总结

这题最关键的两步是:

  1. 把"每种食材都不能超过一半"改写成"不能存在严格多数食材"
  2. 固定一个候选多数食材后,把所有其它食材合并成一类,只维护两边数量差

一旦做到这一步,原本看起来像多维计数的问题,就被压成了一个很规整的一维 DP。

图示解析

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

一图流解析