Skip to content

喵~ 主人,下午好呀!今天我们要来攻克一道叫做「Kevin and And」的题目。它看起来好像有很多数字和操作,有点吓人,但别担心,只要跟着我的爪印一步步走,你就会发现它其实是一道非常有趣的逻辑题,就像解开一个缠在一起的毛线球一样,充满了成就感呢,喵!

题目大意

我们来理一理题目到底要我们做什么吧,喵~

Kevin 有一个长度为 n 的整数数列 a,还有 m 种不同的魔法,每种魔法都由一个整数 b_j 代表。

他最多可以施展 k 次魔法。每一次施法,他可以:

  1. 选择数列 a 中的一个数 a_i
  2. 选择一种魔法 b_j
  3. a_i 的值更新为 a_i & b_j。(这里的 & 是计算机科学里很酷的按位与运算哦!)

我们的任务是,在最多 k 次操作之后,让数列 a 中所有数字的总和变得尽可能小。

简单来说,就是给你 k 次机会,让你用魔法 b 去 "打磨" 数列 a 里的数字,目标是让最终的总和最小!

题解方法

想让总和最小,等价于让所有操作带来的总减少量最大化。每一次操作 a_i = a_i & b_j 带来的减少量(或者叫“收益”)就是 a_i - (a_i & b_j)

这听起来就很适合用贪心算法来解决!我们是不是可以在每一步都选择那个能带来最大“收益”的操作呢?答案是肯定的,喵!我们可以用一个**优先队列(大顶堆)**来动态地维护当前所有可能操作中收益最大的那个。

但是,这里有一个小小的复杂点:我们可以对同一个 a_i 进行多次操作。

比如,我们对 a_i 先用魔法 b_x,再用魔法 b_y,那么 a_i 会变成 (a_i & b_x) & b_y。根据位运算的结合律,这其实就等于 a_i & (b_x & b_y)

这意味着,对一个 a_i 使用多种不同的魔法,其效果等价于用这些魔法数值的按位与总和,对 a_i 进行一次操作!

注意到题目中魔法的种类 m 非常小(m <= 10),这强烈地暗示我们可以预处理所有魔法组合的情况!这里就要用到一个非常强大的技巧——位掩码 (Bitmask)。我们可以用一个 m 位的二进制数来代表 m 种魔法的一个子集。

所以,我们的解题策略就清晰起来啦:

  1. 预处理魔法组合:我们用一个数组 b_masks 来存储所有魔法子集的按位与结果。b_masks[mask] 就表示由 mask 所代表的魔法子集中,所有 b_j 按位与起来的结果。这个过程可以用动态规划在 O(m * 2^m) 的时间里完成。

  2. 计算每个 a_i 的潜在收益:对于数列中的每一个 a_i,我们需要知道对它使用 p 次不同种类的魔法,最多能把它变到多小。

    • 我们定义 min_val_p[p] 为对 a_i 使用最多 p 种不同魔法后能达到的最小值。
    • min_val_p[0] 就是 a_i 本身。
    • min_val_p[p] 可以通过 min_val_p[p-1] 和所有用 p 种魔法组合作用于 a_i 的结果来递推得到。
  3. 定义“边际收益”:当我们已经对 a_i 用了 p-1 次操作后,再多用一次(即第 p 次)能带来的额外收益是多少呢?这个“边际收益”就是 min_val_p[p-1] - min_val_p[p]。这个收益肯定是非负的,因为 min_val_p[p] 不会比 min_val_p[p-1] 更大。

  4. 贪心执行

    • 首先,我们计算出对每个 a_i 进行第 1 次操作能带来的收益,即 a_i - min_val_p[1]。将这些收益大于零的 {收益, {a_i的下标, 1}} 元组全部放入一个大顶堆中。
    • 然后,我们进行 k 次循环(或者直到堆变空):
      • 从堆顶取出当前最大的收益 {gain, i, p}
      • 将我们的总和 total_sum 减去这个 gain
      • 这代表我们对 a_i 执行了第 p 次操作。现在,我们可以考虑对它进行第 p+1 次操作了。我们计算出新的边际收益 min_val_p[p] - min_val_p[p+1],如果这个收益大于 0,就将 {新收益, {i, p+1}} 放入堆中。
    • 循环结束后,total_sum 就是我们能得到的最小总和啦!喵~

题解

好啦,理论说完了,来看看代码是怎么把这个想法变成现实的吧!我已经加上了可爱的注释哦~

cpp
#include <iostream>
#include <vector>
#include <numeric>
#include <algorithm>
#include <queue>

// 一些编译器需要这个来用位运算内建函数,喵~
#if defined(__GNUC__) || defined(__clang__)
// __builtin_popcount 和 __builtin_ctz 可以用
#else
// 微软的编译器要用这个哦
#include <intrin.h>
#define __builtin_popcount __popcnt
#define __builtin_ctz(x) _tzcnt_u32(x)
#endif

void solve() {
    int n;
    unsigned int m;
    long long k;
    std::cin >> n >> m >> k;
    std::vector<int> a(n);
    long long total_sum = 0;
    for (int i = 0; i < n; ++i) {
        std::cin >> a[i];
        total_sum += a[i];
    }
    std::vector<int> b(m);
    for (unsigned int i = 0; i < m; ++i) {
        std::cin >> b[i];
    }

    // 如果一次操作都不能做,那就直接输出原始总和啦
    if (k == 0) {
        std::cout << total_sum << "\n";
        return;
    }

    // 步骤1: 预处理所有魔法子集的按位与结果
    std::vector<int> b_masks(1U << m);
    for (unsigned int i = 0; i < m; ++i) {
        b_masks[1U << i] = b[i];
    }
    for (unsigned int mask = 1; mask < (1U << m); ++mask) {
        // 如果 mask 里面不止一个 1
        if (__builtin_popcount(mask) > 1) {
            // 找到最低位的 1 (lsb)
            unsigned int lsb_idx = __builtin_ctz(mask);
            // b_masks[mask] = b_masks[lsb] & b_masks[mask去掉lsb]
            b_masks[mask] = b_masks[1U << lsb_idx] & b_masks[mask ^ (1U << lsb_idx)];
        }
    }

    // 优先队列,用来存 {收益, {a的下标, 已用操作数}}
    // 用 pair 实现,默认是大顶堆,正好是我们想要的,喵!
    std::priority_queue<std::pair<long long, std::pair<int, int>>> pq;
    
    // 存下每个 a_i 在进行 p+1 次操作时的边际收益
    std::vector<std::vector<long long>> all_gains(n, std::vector<long long>(m + 1, 0));

    // 步骤2 & 3: 计算每个 a_i 的潜在收益
    for (int i = 0; i < n; ++i) {
        // cost_val[p] 存储用 *恰好* p 种魔法能达到的最小值
        std::vector<int> cost_val(m + 1, a[i]);
        for (unsigned int mask = 1; mask < (1U << m); ++mask) {
            unsigned int p = __builtin_popcount(mask);
            cost_val[p] = std::min(cost_val[p], a[i] & b_masks[mask]);
        }

        // min_val_p[p] 存储用 *最多* p 种魔法能达到的最小值
        std::vector<int> min_val_p(m + 1);
        min_val_p[0] = a[i];
        for (unsigned int p = 1; p <= m; ++p) {
            min_val_p[p] = std::min(min_val_p[p - 1], cost_val[p]);
        }
        
        // 计算边际收益
        for (unsigned int p = 1; p <= m; ++p) {
            all_gains[i][p] = (long long)min_val_p[p - 1] - min_val_p[p];
        }

        // 把第一次操作的收益放进优先队列
        if (all_gains[i][1] > 0) {
            pq.push({all_gains[i][1], {i, 1}});
        }
    }

    // 步骤4: 贪心执行 k 次操作
    for (long long op = 0; op < k; ++op) {
        if (pq.empty()) {
            break; // 没有更多可以减少的操作了,提前结束
        }
        auto top = pq.top();
        pq.pop();

        long long gain = top.first;
        int item_idx = top.second.first;
        int ops_count = top.second.second;

        total_sum -= gain; // 更新总和,nya~

        // 如果这个元素还能继续操作 (操作次数 < m)
        if (ops_count < m) {
            // 把下一次操作的收益推进队列
            if (all_gains[item_idx][ops_count + 1] > 0) {
                pq.push({all_gains[item_idx][ops_count + 1], {item_idx, ops_count + 1}});
            }
        }
    }

    std::cout << total_sum << "\n";
}

int main() {
    std::ios_base::sync_with_stdio(false);
    std::cin.tie(NULL);
    int t;
    std::cin >> t;
    while (t--) {
        solve();
    }
    return 0;
}

知识点介绍

为了解决这个问题,我们用到了一些很有趣的工具呢,让我来给你介绍一下吧,喵~

  1. 贪心算法 (Greedy Algorithm)

    • 这是一种非常直观的算法思想。简单来说,就是在每一步选择中,都采取当前状态下最好或最优的选择,从而希望能导致全局结果是最好或最优的。就像猫咪看到一堆毛线球,总是会先去扑那个最大最显眼的!在这个问题里,我们每次都选择能让数值减少最多的操作,就是典型的贪心思想。
  2. 优先队列 (Priority Queue)

    • 优先队列是一种特殊的队列,它不像普通队列那样“先进先出”,而是每次都能取出优先级最高(在这里是数值最大)的元素。它通常用“堆(Heap)”这种数据结构实现。这简直是为我们的贪心策略量身定做的!我们需要反复找到“当前最大的收益”,用优先队列再合适不过了。
  3. 位运算 (Bitwise Operations)

    • 这是直接对整数在内存中的二进制位进行操作。题目中的 & 就是“按位与”运算。只有当两个操作数的对应位都为1时,结果的那一位才为1,否则为0。例如 5 & 3 就是 (101)_2 & (011)_2 = (001)_2 = 1。位运算速度非常快,是算法竞赛中的常客哦。
  4. 位掩码动态规划 (Bitmask DP)

    • 当问题状态的维度之一数量很少时(比如本题的 m <= 10),我们可以用一个整数的二进制位来表示一个集合的状态。这就被称为“位掩码”或“状态压缩”。
    • 我们用一个 m 位的整数 mask 来代表 b 数组的一个子集。然后通过动态规划来计算子集的信息,比如 b_masks[mask] = b_masks[mask_without_one_bit] & b[that_bit]
    • __builtin_popcount(mask) 是一个神奇的函数,可以快速计算一个整数的二进制表示中有多少个1。
    • __builtin_ctz(mask) 同样神奇,可以快速找到一个整数的二进制表示中末尾有多少个0,这可以帮我们快速定位到最低位的那个1。

总结

好啦,这样我们就把这个问题彻底解决了!喵~ 从贪心选择最大的收益,到用位掩码预处理所有魔法组合,再到用优先队列来动态地维护我们的选择,每一步都紧密相连,最终导向了正确的答案。希望我的讲解对主人有帮助哦!如果还有其他问题,随时可以再来找我玩,喵~

Released under the MIT License.