T701955 mex

T701955 mex

题目链接

只找到多用例测试的题目,题目要求是一样的
https://vjudge.net/problem/CodeChef-MEXQUER
用户名 lzm
密码 123456

题目描述

有一个可重集合,其中值为 \(i(0 \le i < n)\) 的元素有 \(a_i\) 个。

定义 \(mex(S)\) 为集合 \(S\) 中最小的没有出现过的非负整数。

你可以进行若干次操作,每次操作形如将集合中的一个数 \(x\) 变为 \(2x\)\(2x + 1\)

请对于所有 \(0 \le k \le n\) 求出如果要使得整个可重集的 \(mex\) 等于 \(k\) 至少要进行多少 次操作。

输入格式

第一行一个整数 \(n\),表示值的范围。

接下来一行 \(n\) 个数,表示出现次数 \(a_0\)\(a_{n−1}\)

输出格式

输出一行 \(n + 1\) 个数。

\(k + 1\) 个数表示如果要使得整个可重集的 \(mex\) 等于 \(k\) 至少要进行多少次操作。 如果不能变成 \(k\),输出 -1。

输入输出样例 #1

输入 #1

3
1 0 1

输出 #1

1 0 -1 -1

输入输出样例 #2

输入 #2

5
2 1 4 0 0

输出 #2

2 1 4 0 2 3

输入输出样例 #3

输入 #3

其余大样例见附件下载。

输出 #3

其余大样例见附件下载。

说明/提示

样例 \(2\) 解释:

如果 \(k = 4\),将 \(0\) 进行两次 \(x\)\(2x + 1\) 的操作可以得到 \(3\),可以验证没有比这更优 的方案。

对于 \(20\%\) 的数据,\(n, a_i ≤ 10\)

存在另外 \(10\%\) 的数据,\(n ≤ 300, a_i ≤ 1\)

对于 \(60\%\) 的数据,\(n ≤ 300, a_i ≤ 300\)

对于所有数据,\(1 \le n \le 10^5,0 \le a_i \le 10^9.\)

题解

这是一个非常经典的贪心与构造类问题。我们需要对于每一个 \(k \in [0, n]\),计算使得集合的 \(mex = k\) 所需的最少操作次数。

1. 题目分析

\(mex(S) = k\) 的定义:

  • 集合 \(S\) 中必须包含 \(0, 1, \dots, k-1\) 所有数字。
  • 集合 \(S\)不能包含数字 \(k\)
  • 集合中大于 \(k\) 的数字不影响 \(mex\)\(k\)

操作的性质:

  • 操作是将 \(x\) 变为 \(2x\)\(2x+1\)
  • 这意味着我们只能将数字变大。或者从二进制的角度看,一个数 \(u\) 可以变成 \(v\) 当且仅当 \(u\)\(v\) 的二进制前缀(即 \(u\)\(v\) 在二进制树上的祖先)。
  • 从数值上看,\(u\) 能变成 \(v\) 的代价(操作次数)并不是简单的差值,而是它们在二进制树上的层数差,即二进制位长度的差。如果 \(u\) 能变成 \(v\),那么 \(u\) 可以通过反复除以 2(下取整)得到。

代价计算分解:
对于一个固定的 \(k\),我们需要满足两个条件:

  1. 构建集合 \(\{0, \dots, k-1\}\):我们需要从原始数组中选出一部分数,通过操作将它们变成 \(0, \dots, k-1\)。因为操作只能变大,所以我们只能用原始值 \(\le\) 目标值的数来生成目标。实际上,为了生成 \(t\),我们必须使用 \(t\) 的某个“祖先”(如 \(t, \lfloor t/2 \rfloor, \dots, 0\))。
  2. 消除数字 \(k\):如果原始集合中还有剩余的数字 \(k\),它们会破坏 \(mex=k\) 的条件(因为 \(k\) 不能存在)。既然操作只能变大,我们可以花 1 次操作将 \(k\) 变为 \(2k\)\(2k+1\),从而消除 \(k\)。如果有 \(a_k\)\(k\),这部分的代价就是 \(a_k\)

综上,对于每个 \(k\),总代价 = (构建 \(\{0, \dots, k-1\}\) 的最小代价) + (如果 \(k < n\),则加上 \(a_k\),否则为 0)

2. 增量贪心策略

我们可以从 \(k=0\)\(n\) 依次计算。
注意到 \(\{0, \dots, k\}\)\(\{0, \dots, k-1\} \cup \{k\}\)。这意味着我们可以利用 \(k-1\) 时的构建结果,再额外满足目标 \(k\),就能得到 \(k+1\) 时的构建状态。

维护的数据结构:

  • 一个“资源池”(pool),存储当前所有可用的、未被使用的原始数字。
  • 一个变量 current_cost,记录构建当前 \(\{0, \dots, k-1\}\) 集合的累积代价。

流程推演:

  1. 初始化\(k=0\),此时需要构建空集,代价为 0。
  2. 循环 \(k\) 从 0 到 \(n\)
    • 输出答案:当前的 current_cost 加上 \(a_k\)(消除现有 \(k\) 的代价)。
    • 更新状态以备下一次循环(即满足目标 \(k\)):
      • 首先,对于 \(mex=k+1\),原来的“坏数字” \(k\)(数量 \(a_k\))现在变成了可用的资源,我们将它们加入 pool
      • 然后,我们需要在 pool 中找到一个数 \(u\),使得 \(u\) 可以通过操作变成 \(k\)
      • 贪心策略:为了让总代价最小,且保留更有用的数字(较小的数字如 0 可以生成任意数,而较大的数如 \(k\) 只能生成 \(k\) 及其子树),我们应该选择代价最小数值最大的祖先。即优先检查 \(k\) 是否在 pool 中,然后是 \(\lfloor k/2 \rfloor\),直到 0。
      • 找到最佳祖先 \(u\) 后,将操作次数加到 current_cost,并从 pool 中消耗一个 \(u\)
      • 如果找不到这样的 \(u\),说明无法构成 \(0 \dots k\),那么对于当前及之后的所有 \(k\),答案都是 -1。

3. C++ 代码实现

代码中使用了 vector<long long> pool 作为桶来计数。因为我们只关心 \(\le n\) 的数字,且每次只处理到 \(k\),加入的数字也都是 \(\le k\) 的,所以直接用数组索引即可,效率极高。

#include <iostream>
#include <vector>

using namespace std;

int main() {
    // 优化输入输出效率
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n;
    if (!(cin >> n)) return 0;

    // 读入每个数字的初始数量
    // 注意题目输入是 a_0 到 a_{n-1}
    vector<int> a(n);
    for (int i = 0; i < n; ++i) {
        cin >> a[i];
    }

    // pool[x] 表示当前未使用的数字 x 的数量
    // 使用 long long 防止溢出(虽然题目中 a_i 很大,但在本题逻辑中减法只发生 n 次)
    // 大小开 n 足够,因为我们只处理到 n-1 的输入
    vector<long long> pool(n, 0);

    long long current_cost = 0;
    bool possible = true;

    // 我们需要输出 n+1 个数,对应 k = 0 到 n
    for (int k = 0; k <= n; ++k) {
        // --- 1. 输出当前 k 的答案 ---
        if (!possible) {
            cout << -1 << (k == n ? "" : " ");
        } else {
            long long ans = current_cost;
            // 如果 k < n,我们需要消除所有的 k,代价为 a[k]
            // 如果 k = n,输入中没有 n,也就没有需要消除的数
            if (k < n) {
                ans += a[k];
            }
            cout << ans << (k == n ? "" : " ");
        }

        // --- 2. 为下一次循环做准备:满足目标 k ---
        
        // 将 a[k] 个 k 加入资源池
        // 因为在 mex=k+1 的情况下,这些 k 是构建集合 $\{0...k\}$ 的重要资源
        if (k < n) {
            pool[k] += a[k];
        } else {
            // k=n 时循环即将结束,不需要再处理目标 n
            continue;
        }

        // 如果之前已经不可能了,就直接跳过
        if (!possible) continue;

        // 贪心匹配:寻找 k 的最佳祖先
        // 最佳祖先是离 k 最近的(操作数最少),也就是数值最大的
        int best_u = -1;
        int best_cost = 0;
        
        // 依次检查 k, k/2, k/4, ... 0
        int curr = k;
        int dist = 0;
        while (true) {
            if (pool[curr] > 0) {
                best_u = curr;
                best_cost = dist;
                break;
            }
            if (curr == 0) break;
            curr >>= 1; // 相当于除以 2
            dist++;
        }

        if (best_u != -1) {
            current_cost += best_cost;
            pool[best_u]--;
        } else {
            // 如果找不到任何祖先能生成 k,说明无法构建集合
            possible = false;
        }
    }
    cout << endl;

    return 0;
}

4. 详细解释与复杂度

  1. 时间复杂度

    • 主循环运行 \(n+1\) 次。
    • 内部寻找祖先的 while 循环,对于 \(10^5\) 范围内的 \(k\),最多执行约 \(\log_2(10^5) \approx 17\) 次。
    • 数组访问是 \(O(1)\) 的。
    • 总时间复杂度为 \(O(n \log n)\),完全可以处理 \(n=10^5\) 的数据(1秒限时内)。
  2. 空间复杂度

    • 需要存储输入数组 a 和资源池 pool,均为 \(O(n)\)
  3. 贪心正确性

    • 为什么选“最近的祖先”是对的?
      • 代价角度:最近的祖先意味着操作次数最少。
      • 资源保留角度:\(x\) 的祖先链是 \(x \to \lfloor x/2 \rfloor \to \dots \to 0\)。数值越大的祖先(越接近 \(x\)),其生成的数字范围越窄(即子树越小)。例如,0 可以生成任何数,而 \(x\) 只能生成 \(x\) 及其后代。因此,应该优先消耗“能力较弱”(数值较大)的资源,保留“万能”的 0 以备后用。
  4. 边界与细节

    • 数据范围:\(a_i\) 可达 \(10^9\),累积代价可能超过 int,必须使用 long long
    • 输出格式:注意最后一个数后面不要有多余空格。
    • 无法达成的情况:一旦某一步无法构建出目标 \(k\),后续所有更大的 \(mex\) 都不可能达成,因为它们都需要包含 \(\{0, \dots, k\}\) 这个子集。

另一种写法

#include <bits/stdc++.h>
using namespace std;
using ll = long long;

void solve()
{
    int n;
    cin >> n;
    vector<ll> a(n);
    for (int i = 0; i < n; ++i) cin >> a[i];

    // cnt:当前可用的每个值的份数(会在为 0..v 分配时被消耗)
    vector<ll> cnt = a;

    // pref_cost[k] 表示使得 0..k-1 每个至少出现一次的最小操作数
    // pref_cost[0] = 0
    vector<ll> pref_cost(n + 1, -1);
    pref_cost[0] = 0;

    ll acc = 0; // 累计代价,等于 pref_cost[cur_v+1] 的值
    bool impossible = false;

    for (int v = 0; v < n; ++v) {
        if (impossible) {
            pref_cost[v + 1] = -1;
            continue;
        }
        int p = v;
        int steps = 0;
        // 向上寻找最近有剩余的祖先
        while (p > 0 && cnt[p] == 0) {
            p >>= 1; // p = p / 2
            ++steps;
        }
        // 如果到 p == 0 仍然没有(cnt[0] == 0),那就失败
        if (p == 0 && cnt[0] == 0) {
            impossible = true;
            pref_cost[v + 1] = -1;
        } else {
            // 找到了 p(可能 p==v 或 p==0 或中间某个祖先),消耗一份
            // 需要的步数是 steps(因为我们从 v 往上走了 steps 次到 p)
            // 注意:如果最开始 p==v 且 cnt[v]>0 则 steps==0
            // 同时要对 cnt[p] 做减一操作
            cnt[p]--;
            acc += steps;
            pref_cost[v + 1] = acc;
        }
    }

    // 输出答案:对于 k = 0..n
    // 若 pref_cost[k] == -1 输出 -1
    // 否则 输出 pref_cost[k] + a_k (注意 a_n 视为 0)
    vector<ll> ans(n + 1);
    for (int k = 0; k <= n; ++k) {
        if (pref_cost[k] == -1) {
            // 无法构造 0..k-1
            cout << -1;
        } else {
            ll extra = 0;
            if (k < n) extra = a[k]; // 需把所有等于 k 的元素移走,每个至少 1 次操作
            cout << (pref_cost[k] + extra);
        }
        if (k < n) cout << ' ';
    }
    cout << '\n';
}
int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    int T;
    cin>>T;
    while(T--) solve();
 
    return 0;
}

posted @ 2025-12-01 15:53  katago  阅读(4)  评论(0)    收藏  举报