[NOIP2025] 清仓甩卖

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

按原价降序刻画贪心失败的关键交换对,用组合数统计坏定价方案后从 2^n 中扣除。

OJ: luogu

题目 ID: P14636

难度:提高+/省选-

标签:贪心组合计数排序

日期: 2026-06-22 17:57

题意

n 颗糖果,第 i 颗糖果原价为 a_i。每颗糖果的清仓价可以独立定成 1 元或 2 元。

定价方案确定后,小 R 会把糖果按性价比 a_i / w_i 从大到小排序;性价比相同则原价高的先买,仍相同则编号小的先买。然后他按这个顺序扫描,钱够就买,否则跳过。

题目要求统计:在所有 2^n 种定价方案里,有多少种方案使得小 R 按这个贪心策略买到的原价总和,等于预算 m 下能买到的最大原价总和。答案对 998244353 取模。

输入第一行的第一个整数是测试点编号,可以读入但不用。

思路

先看一个能直接验证定义的暴力做法:枚举每种定价方案,模拟小 R 的性价比贪心,再用 0/1 背包求同一方案下真正的最优原价和,比较二者是否相等。

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

const int MAXN = 25;
const int MOD = 998244353;

int control_id, test_count;
int n, m;
long long a[MAXN];
int w[MAXN];

bool cmp_item(int x, int y) {
    long long left = a[x] * w[y];
    long long right = a[y] * w[x];
    if (left != right) {
        return left > right;
    }
    if (a[x] != a[y]) {
        return a[x] > a[y];
    }
    return x < y;
}

long long greedy_value() {
    vector<int> id;
    for (int i = 1; i <= n; i++) {
        id.push_back(i);
    }
    sort(id.begin(), id.end(), cmp_item);

    int money = m;
    long long sum = 0;
    for (int i = 0; i < (int)id.size(); i++) {
        int x = id[i];
        if (money >= w[x]) {
            money -= w[x];
            sum += a[x];
        }
    }
    return sum;
}

long long optimal_value() {
    vector<long long> dp(m + 1, -1);
    dp[0] = 0;

    for (int i = 1; i <= n; i++) {
        for (int money = m; money >= w[i]; money--) {
            if (dp[money - w[i]] < 0) {
                continue;
            }
            dp[money] = max(dp[money], dp[money - w[i]] + a[i]);
        }
    }

    long long best = 0;
    for (int money = 0; money <= m; money++) {
        best = max(best, dp[money]);
    }
    return best;
}

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

    long long ans = 0;
    int total = 1 << n;

    // 枚举每颗糖果定价为 1 或 2 元。
    for (int mask = 0; mask < total; mask++) {
        for (int i = 1; i <= n; i++) {
            if (mask & (1 << (i - 1))) {
                w[i] = 2;
            } else {
                w[i] = 1;
            }
        }

        if (greedy_value() == optimal_value()) {
            ans++;
        }
    }

    cout << ans % MOD << '\n';
}

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

    cin >> control_id >> test_count;
    while (test_count--) {
        solve_one();
    }

    return 0;
}

这个做法的瓶颈很明显:定价方案有 2^n 种,而 n 最大是 5000。所以我们反过来统计“不合法”的定价方案。

先把糖果按原价从大到小排序:

text
a_1 >= a_2 >= ... >= a_n

如果一个定价方案不合法,说明贪心买到的集合不是最优集合。由于清仓价格只有 12,一次最关键的失败可以看成:

  • 有一颗原价更高的糖果 i,它被定价为 2 元;
  • 有一颗原价更低的糖果 j,它被定价为 1 元;
  • 糖果 j 在贪心顺序里排在糖果 i 前面,并且被买走;
  • 到贪心考虑糖果 i 时,剩余钱已经不足 2 元;
  • j 换成 i 后,原价总和会变大。

这里必须有 a_i > a_j,否则交换不会让答案变大。还必须有:

text
a_j / 1 > a_i / 2

也就是:

text
2a_j > a_i

否则 j 不会排在 i 前面,也挡不住它。

接下来固定这对关键糖果 (i, j)。糖果 j 是一个已经买走的 1 元糖果,糖果 i 是被漏掉的 2 元糖果。为了让贪心考虑 i 时剩余钱少于 2,前面必须已经花掉至少 m - 1 元。

在这个关键结构里,除了糖果 j 之外,还需要从 j 前面的糖果里选出:

text
m - i - 1

个会被贪心买走的 1 元糖果。可选位置有 j - 2 个,因此贡献:

text
C(j - 2, m - i - 1)

还剩一类自由选择:如果某个后缀糖果 k 满足:

text
a_k + a_j < a_i

那么它无论定成 1 还是 2,都不会改变由 (i,j) 定位的这次关键失败。设满足条件的后缀起点为 p,这部分贡献:

text
2^(n - p + 1)

于是每个关键对 (i,j) 对坏方案数的贡献为:

text
C(j - 2, m - i - 1) * 2^(n - p + 1)

枚举所有合法的 (i,j),累加坏方案数 bad,最后答案就是:

text
2^n - bad

实现时预处理组合数 C(i,j)2^i。内层枚举 j 时,用指针 p 维护满足 a[p] + a[j] < a[i] 的后缀起点。组合数参数不合法时直接视为 0

代码

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

const int MAXN = 5005;
const int MOD = 998244353;

int control_id, test_count;
int n, m;
long long a[MAXN];
int comb[MAXN][MAXN]; // comb[i][j] = C(i, j)
int pow2[MAXN];       // pow2[i] = 2^i

void prepare() {
    pow2[0] = 1;
    for (int i = 1; i < MAXN; i++) {
        pow2[i] = pow2[i - 1] * 2LL % MOD;
    }

    comb[0][0] = 1;
    for (int i = 1; i < MAXN; i++) {
        comb[i][0] = comb[i][i] = 1;
        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;
            }
        }
    }
}

int get_comb(int x, int y) {
    if (x < 0 || y < 0 || y > x) {
        return 0;
    }
    return comb[x][y];
}

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

    sort(a + 1, a + n + 1, greater<long long>());

    long long bad = 0;

    // 枚举最优解会买的 2 元糖果 i,以及贪心会用来卡住它的 1 元糖果 j。
    for (int i = 1; i <= n; i++) {
        int need_extra = m - i - 1;
        if (need_extra < 0) {
            break;
        }

        int p = n + 1;
        int start_j = max(i + 1, m - i + 1);

        for (int j = start_j; j <= n; j++) {
            if (a[i] == a[j]) {
                continue;
            }
            if (a[i] >= 2LL * a[j]) {
                break;
            }

            // p 是最靠前的位置,使得 [p, n] 的糖果都能作为“较低性价比的 1 元糖果”。
            while (p > 1 && a[p - 1] + a[j] < a[i]) {
                p--;
            }

            bad += 1LL * get_comb(j - 2, need_extra) * pow2[n - p + 1] % MOD;
            if (bad >= MOD) {
                bad -= MOD;
            }
        }
    }

    int ans = (pow2[n] - (int)(bad % MOD) + MOD) % MOD;
    cout << ans << '\n';
}

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

    prepare();

    cin >> control_id >> test_count;
    while (test_count--) {
        solve_one();
    }

    return 0;
}

复杂度

预处理组合数的时间复杂度为 O(MAXN2)O(MAXN^2),空间复杂度为 O(MAXN2)O(MAXN^2)

每组数据排序复杂度为 O(nlogn)O(n log n),枚举关键对的复杂度为 O(n2)O(n^2)。额外空间为 O(n)O(n)

总结

这题的关键不是模拟小 R 的贪心,而是找出贪心失败的最小结构:一个被买走的 1 元低原价糖果,挡住了一颗更高原价的 2 元糖果。

找到这个结构后,题目就变成了组合计数:固定关键对 (i,j),前面选多少个 1 元糖果由组合数统计,后缀中不会影响关键失败的糖果用二次幂统计。最后用总方案数减去坏方案数即可。