喵~ 主人,下午好呀!今天我们要来攻克一道叫做「Kevin and And」的题目。它看起来好像有很多数字和操作,有点吓人,但别担心,只要跟着我的爪印一步步走,你就会发现它其实是一道非常有趣的逻辑题,就像解开一个缠在一起的毛线球一样,充满了成就感呢,喵!
题目大意
我们来理一理题目到底要我们做什么吧,喵~
Kevin 有一个长度为 n
的整数数列 a
,还有 m
种不同的魔法,每种魔法都由一个整数 b_j
代表。
他最多可以施展 k
次魔法。每一次施法,他可以:
- 选择数列
a
中的一个数a_i
。 - 选择一种魔法
b_j
。 - 将
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
种魔法的一个子集。
所以,我们的解题策略就清晰起来啦:
预处理魔法组合:我们用一个数组
b_masks
来存储所有魔法子集的按位与结果。b_masks[mask]
就表示由mask
所代表的魔法子集中,所有b_j
按位与起来的结果。这个过程可以用动态规划在O(m * 2^m)
的时间里完成。计算每个
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
的结果来递推得到。
- 我们定义
定义“边际收益”:当我们已经对
a_i
用了p-1
次操作后,再多用一次(即第p
次)能带来的额外收益是多少呢?这个“边际收益”就是min_val_p[p-1] - min_val_p[p]
。这个收益肯定是非负的,因为min_val_p[p]
不会比min_val_p[p-1]
更大。贪心执行:
- 首先,我们计算出对每个
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
就是我们能得到的最小总和啦!喵~
- 首先,我们计算出对每个
题解
好啦,理论说完了,来看看代码是怎么把这个想法变成现实的吧!我已经加上了可爱的注释哦~
#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;
}
知识点介绍
为了解决这个问题,我们用到了一些很有趣的工具呢,让我来给你介绍一下吧,喵~
贪心算法 (Greedy Algorithm)
- 这是一种非常直观的算法思想。简单来说,就是在每一步选择中,都采取当前状态下最好或最优的选择,从而希望能导致全局结果是最好或最优的。就像猫咪看到一堆毛线球,总是会先去扑那个最大最显眼的!在这个问题里,我们每次都选择能让数值减少最多的操作,就是典型的贪心思想。
优先队列 (Priority Queue)
- 优先队列是一种特殊的队列,它不像普通队列那样“先进先出”,而是每次都能取出优先级最高(在这里是数值最大)的元素。它通常用“堆(Heap)”这种数据结构实现。这简直是为我们的贪心策略量身定做的!我们需要反复找到“当前最大的收益”,用优先队列再合适不过了。
位运算 (Bitwise Operations)
- 这是直接对整数在内存中的二进制位进行操作。题目中的
&
就是“按位与”运算。只有当两个操作数的对应位都为1时,结果的那一位才为1,否则为0。例如5 & 3
就是(101)_2 & (011)_2 = (001)_2 = 1
。位运算速度非常快,是算法竞赛中的常客哦。
- 这是直接对整数在内存中的二进制位进行操作。题目中的
位掩码动态规划 (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。
- 当问题状态的维度之一数量很少时(比如本题的
总结
好啦,这样我们就把这个问题彻底解决了!喵~ 从贪心选择最大的收益,到用位掩码预处理所有魔法组合,再到用优先队列来动态地维护我们的选择,每一步都紧密相连,最终导向了正确的答案。希望我的讲解对主人有帮助哦!如果还有其他问题,随时可以再来找我玩,喵~