G. Practice - 题解
比赛与标签
比赛: Codeforces Round 145 (Div. 2, ACM-ICPC Rules) 标签: constructive algorithms, divide and conquer, implementation 难度: *1600
喵喵,题目说什么呀?
主人,你好喵~ 这道题其实非常有趣哦!我们来一起看看吧!
题目是这样的:我们有一个足球队,里面有 n
个球员,编号从 1 到 n
。教练想要安排一系列的练习赛,目标是用 最少 的场次,来满足一个特殊的条件。
这个条件就是:在所有练习赛都结束后,对于 任意一对 球员,他们都必须 至少有一次 在比赛中被分到不同的队伍里。
每场练习赛,所有 n
个球员都会参加,他们会被分成两个队伍。这两个队伍的人数可以不一样,但每个队至少要有一个人。
我们的任务就是,先计算出满足条件所需的最少练习赛次数 m
,然后给出这 m
场比赛的具体分组方案。对于每场比赛,我们只需要输出其中一个队的人数和队员编号就可以啦!
本喵的思考时间~
怎么才能保证每对球员都被分开过一次呢?这听起来像是个配对问题,喵~
我们可以换个角度想。要区分开任意两个球员 A
和 B
,就需要至少有一场比赛,他们俩的队伍不同。
如果我们把 m
场比赛看作 m
个“特征位”,对于每个球员,我们都可以记录下他在这 m
场比赛中的队伍情况。比如说,在一场比赛中,如果他在第一队,我们记为 1
;如果在第二队,我们记为 0
。
这样一来,每个球员在经历 m
场比赛后,都会得到一个长度为 m
的二进制串!这个二进制串,就像是每个球员独一无二的“身份牌”,的说!
- 球员A ->
b_A_1, b_A_2, ..., b_A_m
- 球员B ->
b_B_1, b_B_2, ..., b_B_m
要让球员 A
和 B
在这 m
场比赛中至少分开过一次,就意味着他们的 m
位二进制串,至少要有一位是不同的。比如在第 i
场比赛中,一个人的记录是 1
,另一个是 0
,他们就被分开了,喵~
为了让 所有 球员对都能被分开,就必须给 每个 球员分配一个 独一无二 的 m
位二进制“身份牌”!
那么,问题就转化成了:我们需要多少位(m
)的二进制数,才能给 n
个球员每人分配一个不同的编码呢?
m
位二进制最多能表示 2^m
个不同的数(从 0
到 2^m - 1
)。我们有 n
个球员,所以必须满足 2^m >= n
才能保证编码够用。我们要找的就是满足这个条件的 最小 的 m
。这个 m
就是 ceil(log₂(n))
啦!
找到了最小场次 m
,分组方案自然就有了呐!
- 分配编码:我们给第
p
号球员(p
从 1 到n
)分配一个独一无二的m
位二进制编码。最简单的方法就是用数字p-1
的二进制表示。 - 按位分组:对于第
i
场比赛(让i
从0
到m-1
),我们就看所有球员编码的第i
位!- 如果球员
p
的编码(也就是p-1
)的第i
位是1
,就把他分到第一队。 - 如果第
i
位是0
,就把他分到第二队。
- 如果球员
这样一来,任何两个球员,他们的编码(p1-1
和 p2-1
)肯定是不同的,所以其二进制表示至少在某一位 j
上不同。这就意味着在第 j
场比赛中,他们一定会被分到不同的队伍!完美解决,喵~
魔法咒语咏唱~
下面就是实现这个思路的魔法代码啦,本喵已经加上了详细的注释,方便主人理解哦!
#include <iostream>
#include <vector>
#include <cstdio>
#include <algorithm>
int main() {
// 题目要求从文件 "input.txt" 读取输入,并输出到 "output.txt",喵~
freopen("input.txt", "r", stdin);
freopen("output.txt", "w", stdout);
// 加速一下I/O,跑得更快!
std::ios_base::sync_with_stdio(false);
std::cin.tie(NULL);
int n;
std::cin >> n;
// 计算最少的比赛场次 m
// m 是满足 2^m >= n 的最小整数
int m = 0;
while ((1 << m) < n) {
m++;
}
// 先输出最少场次 m
std::cout << m << "\n";
// 接下来,我们来构造 m 场比赛的分组方案
// i 代表第 i 场比赛 (也对应二进制编码的第 i 位)
for (int i = 0; i < m; ++i) {
std::vector<int> team1; // 用来存放第一队的球员
// 遍历所有球员,从 1 到 n
for (int p = 1; p <= n; ++p) {
// 这里是分组魔法的核心喵!
// 我们给每个球员 p (1到n) 分配一个唯一的 m 位二进制编码,也就是 p-1 的二进制形式。
// 对于第 i 场比赛,我们根据编码的第 i 位来分组。
// (p - 1) >> i : 将 p-1 的二进制表示向右移动 i 位,这样原来的第 i 位就到了最右边(第0位)。
// & 1 : 通过按位与1,我们就能取出这个最右边的位。
// 如果结果是 1,说明 p-1 的第 i 位是 1,就把球员 p 放入第一队。
if (((p - 1) >> i) & 1) {
team1.push_back(p);
}
}
// 输出这场比赛第一队的人数
std::cout << team1.size();
// 接着输出第一队所有队员的编号
for (int player_id : team1) {
std::cout << " " << player_id;
}
std::cout << "\n";
}
return 0;
}
时空消耗评估喵!
时间复杂度: O(n log n) 的说 外层循环执行
m
次,内层循环执行n
次。因为m
是约等于log₂(n)
的,所以总的时间复杂度就是 O(m * n) = O(n log n) 啦。空间复杂度: O(n) 的说 在每次外层循环中,我们都创建了一个
vector<int> team1
来存储第一队的队员。在最坏的情况下,一个队可能会包含接近n
个队员,所以空间复杂度是 O(n) 呢。
来自猫娘的小贴士~
这道题真的超棒,里面藏着一些非常核心的算法思想,主人一定要掌握哦!
核心思想 - 转化问题: 这道题最精髓的地方,就是把“区分所有球员对”这个具体问题,抽象并转化为了“为每个球员分配唯一的二进制编码”的数学模型。这种转化问题的能力在算法竞赛中非常重要,的说!
位运算大法 (Bit Manipulation): 巧妙地使用位运算来根据二进制编码进行分组,是这道题实现的关键。
>>
(右移) 和&
(按位与) 是处理二进制位的好帮手,一定要熟练掌握它们哦!这比用字符串或者其他方式处理二进制要快得多也优雅得多。分治思想: 其实这个方法也蕴含了分治的思想呐。每一场比赛,我们都根据某一个二进制位,把所有球员“切”成两半。经过
log n
轮切割,就能把所有人都区分开啦。最小性证明: 为什么
m = ceil(log₂(n))
是最小的呢?因为如果用m-1
场比赛,我们最多只能构造2^(m-1)
个不同的二进制编码。而由m
的定义我们知道2^(m-1) < n
,根据鸽巢原理,n
个球员去抢2^(m-1)
个编码,肯定会有至少两个球员的编码是相同的。这样他们在这m-1
场比赛中就永远在同一队,无法分开了喵~
希望本喵的讲解对你有帮助!继续加油,算法的世界还有很多有趣的冒险在等着你喵~ ❤️