第二十七天:游戏组队问题
每日一道C++题:游戏组队问题
题目详述
一天,zzq 主持一项游戏,有 (n) 位同学参与,要求两两同学为一组上台玩游戏。然而,如果两位同学的颜值差距大于等于 (m),他们就会互相嫌弃,影响游戏体验。因此,zzq 在游戏开始前调查了所有 (n) 个同学的颜值,现在需要确定最多能凑出多少组同学一起上台,且一人只能出现在一个组中。
输入描述
- 多组输入。
- 第一行输入两个正整数 (n) 和 (m)((n \leq 10^5),(m \leq 10^9)),其中 (n) 表示同学的数量,(m) 为颜值差距的阈值。
- 第二行包含 (n) 个由空格分开的正整数 (x_i)((x_i \leq 10^9)),代表第 (i) 个同学的颜值。
输出描述
每一行输出一个数,表示最多能凑出的组数。
二、基础解法
(一)解题思路
- 排序:对所有同学的颜值进行排序。这是后续双指针法能够高效工作的基础,因为排序后,同学的颜值按照从小到大的顺序排列,方便比较相邻同学之间的颜值差距。
- 双指针法:使用两个指针,
left
初始指向数组的第一个元素,right
初始指向数组的第二个元素。通过移动这两个指针,遍历整个数组,判断当前left
和right
所指向同学的颜值差距是否小于 (m)。如果小于 (m),则这两位同学可以组成一组,然后更新指针位置继续寻找下一组;如果大于等于 (m),则只移动right
指针,尝试与下一个同学组成一组。
(二)代码实现(C++)
#include <iostream>
#include <vector>
#include <algorithm>using namespace std;int main() {int n, m;while (cin >> n >> m) {vector<int> scores(n);for (int i = 0; i < n; ++i) {cin >> scores[i];}sort(scores.begin(), scores.end());int pairs = 0;int left = 0, right = 1;while (right < n) {if (scores[right] - scores[left] < m) {pairs++;left = right + 1;right = left + 1;} else {right++;}}cout << pairs << endl;}return 0;
}
(三)代码详细解释
- 输入处理:
while (cin >> n >> m)
实现多组输入,每次循环读取一组 (n) 和 (m) 的值。这里利用了 C++ 输入流cin
的特性,cin
会从标准输入读取数据,>>
运算符用于提取输入的整数并赋值给相应变量。只要输入的数据格式正确(即能成功读取两个整数),循环就会持续执行,这在处理多个测试用例时非常方便,无需多次手动运行程序输入数据。vector<int> scores(n)
创建一个大小为 (n) 的整数向量scores
,用于存储 (n) 个同学的颜值。vector
是 C++ 标准模板库(STL)中的动态数组容器,它可以根据需要自动调整大小。for (int i = 0; i < n; ++i)
循环,通过cin >> scores[i]
逐行读取每个同学的颜值,并存储到scores
向量中。在输入时,需保证输入的整数之间用空格分隔,以便cin
按顺序正确读取。
- 排序:
sort(scores.begin(), scores.end())
使用 C++ STL 中的sort
函数对scores
向量进行升序排序。sort
函数通常基于快速排序、归并排序或堆排序等高效算法实现。以快速排序为例,它的基本原理是选择一个基准元素,将数组分为两部分,使得左边部分的元素都小于等于基准元素,右边部分的元素都大于等于基准元素,然后分别对左右两部分进行递归排序,平均时间复杂度为 (O(n \log n))。排序后,数据按顺序排列,为后续双指针法高效判断同学能否成组提供了便利。若数据无序,双指针法在判断时会增加大量比较操作,显著提高时间复杂度。
- 双指针法计算组数:
- 初始化两个指针
left = 0
和right = 1
,分别指向数组的第一个和第二个元素。双指针法是一种常用算法技巧,通过在数组或链表等数据结构上移动两个指针来遍历、搜索或解决特定问题。 while (right < n)
循环条件确保right
指针没有越界,只要right
指针在数组范围内,就持续执行循环体。- 在循环体中,
if (scores[right] - scores[left] < m)
判断当前right
指向的同学和left
指向的同学的颜值差距是否小于 (m)。- 如果满足条件,说明这两个同学可以组成一组,将
pairs
(记录组数的变量)加 (1)。然后更新指针,left = right + 1
将left
指针移动到right
指针的下一个位置,right = left + 1
再将right
指针移动到left
指针的下一个位置,继续寻找下一组。这种指针移动方式能保证每个同学只参与一次分组判断,避免重复计算。 - 如果不满足条件,即
scores[right] - scores[left] \geq m
,则只将right
指针向右移动一位(right++
),尝试让right
指向的同学与left
指向的同学组成一组。通过这种方式,双指针在一次遍历中就能高效地找到所有满足条件的同学组,时间复杂度为 (O(n)),相较于暴力嵌套循环的 (O(n^2)) 时间复杂度,效率大幅提升。
- 如果满足条件,说明这两个同学可以组成一组,将
- 初始化两个指针
- 输出结果:
- 当
while
循环结束后,pairs
中存储的值就是最多能凑出的组数,通过cout << pairs << endl
将结果输出到控制台。cout
是 C++ 的标准输出流,<<
是输出运算符,用于将数据输出到标准输出设备(通常是显示器)。
- 当
(四)时间复杂度和空间复杂度分析
- 时间复杂度:排序部分的时间复杂度为 (O(n \log n)),双指针遍历数组部分的时间复杂度为 (O(n))。总体时间复杂度主要由排序部分决定,所以整个算法的时间复杂度为 (O(n \log n))。
- 空间复杂度:代码中使用了一个大小为 (n) 的向量
scores
来存储同学的颜值,所以空间复杂度为 (O(n))。
三、拓展内容
(一)优化空间复杂度
- 优化思路:当前代码使用大小为 (n) 的数组存储所有同学的颜值,如果 (n) 非常大,可能会占用较多内存。优化方法是在读取输入时直接进行处理,而不存储所有值。具体来说,在读取每个颜值值后,立即尝试与之前已处理的合适值进行配对。
- 实现难点:这种方法实现起来相对复杂,需要更精细地控制指针和已处理值的状态。例如,需要额外的数据结构或标记来记录哪些值已经被考虑过,以及如何在不完整的数据存储情况下准确地移动指针和判断配对情况。同时,由于没有完整的数组,在某些情况下可能需要重新遍历之前处理过的值,这可能会增加时间复杂度。
(二)拓展为多维度条件
- 条件扩展:假设除了颜值差距,还需要考虑其他条件,比如身高差距、性格匹配度等。可以将每个同学表示为一个结构体,结构体中包含多个属性。例如:
struct Student {int appearance;int height;int personality;
};
- 算法调整:在比较时,需要同时满足所有条件才能组成一组。这就需要对当前的双指针法进行扩展,在判断条件中加入多个属性的比较逻辑。例如:
while (right < n) {if (abs(students[right].appearance - students[left].appearance) < appearanceThreshold &&abs(students[right].height - students[left].height) < heightThreshold &&abs(students[right].personality - students[left].personality) < personalityThreshold) {pairs++;left = right + 1;right = left + 1;} else {right++;}
}
这里假设 appearanceThreshold
、heightThreshold
和 personalityThreshold
分别是颜值、身高和性格匹配度的差距阈值。
(三)动态变化的条件
- 条件变化场景:考虑如果 (m)(颜值差距阈值)在游戏过程中会动态变化,例如每轮游戏后根据玩家反馈进行调整。
- 程序调整:这就需要在程序中实时处理 (m) 的变化,并重新计算最多能凑出的组数。可以通过添加额外的输入处理逻辑,在每次 (m) 变化时重新调用计算组数的函数。例如:
int main() {int n;cin >> n;vector<int> scores(n);for (int i = 0; i < n; ++i) {cin >> scores[i];}sort(scores.begin(), scores.end());int m;while (cin >> m) {int pairs = 0;int left = 0, right = 1;while (right < n) {if (scores[right] - scores[left] < m) {pairs++;left = right + 1;right = left + 1;} else {right++;}}cout << pairs << endl;}return 0;
}
在这个修改后的代码中,通过外层的 while (cin >> m)
循环,每次输入新的 (m) 值时,重新计算并输出最多能凑出的组数。
(四)分组数量最大化且组间差异最小化
- 目标分析:不仅要使分组数量最多,还希望组与组之间的综合差异最小化,以保证游戏的公平性和趣味性。这就需要在分组过程中不仅考虑当前两个同学能否成组,还要考虑整体分组的平衡性。实现这个目标较为复杂,因为简单的贪心策略可能无法找到全局最优解,需要更高级的算法。
- 动态规划思想:
- 原理:动态规划是一种将复杂问题分解为若干个子问题,并保存子问题的解,避免重复计算,从而提高算法效率的方法。在分组数量最大化且组间差异最小化的拓展中,如果采用动态规划思想,我们可以定义一个状态,例如用
dp[i][j]
表示考虑前 (i) 个同学,组成 (j) 组时的最小组间差异。然后通过状态转移方程,根据已有的状态计算新的状态。例如,dp[i][j]
可能由dp[i - 1][j]
(不将第 (i) 个同学加入当前组)和dp[i - 2][j - 1]
(将第 (i) 个同学与另一个同学组成新的一组)等状态推导而来。 - 应用:在本题拓展场景中,动态规划可以帮助我们在考虑所有可能的分组组合时,有效地利用已经计算过的子问题的解,避免重复计算,从而在合理的时间复杂度内找到最优的分组方案。但动态规划的难点在于如何定义合适的状态和状态转移方程,这需要对问题有深入的理解和分析。
- 原理:动态规划是一种将复杂问题分解为若干个子问题,并保存子问题的解,避免重复计算,从而提高算法效率的方法。在分组数量最大化且组间差异最小化的拓展中,如果采用动态规划思想,我们可以定义一个状态,例如用
- 启发式算法:
- 模拟退火算法:
- 原理:模拟退火算法源于对固体退火过程的模拟。在固体退火中,固体从高温开始,随着温度的降低,分子的热运动逐渐减弱,最终达到能量最低的稳定状态。模拟退火算法从一个初始的分组状态开始,计算当前分组的组数和组间差异(作为当前状态的能量)。然后通过一定的规则对分组进行随机调整,每次调整后计算新的组数和组间差异(新状态的能量)。如果新的状态更优(组数最多且组间差异最小,即能量更低),则接受新状态;否则,以一定概率接受较差的状态,这个概率随着时间(迭代次数)逐渐降低,通常使用一个降温函数来控制概率的下降速度。这样可以避免算法陷入局部最优解,从而更有可能找到全局最优的分组方案。
- 应用:在解决分组问题时,模拟退火算法可以通过不断尝试不同的分组调整,在一定程度上遍历解空间,有机会找到较优的分组方案。例如,在每次迭代中,随机选择两个同学交换分组,然后计算新的组数和组间差异。如果新方案更优则直接采用;若较差,则根据当前温度对应的概率决定是否采用,随着温度降低,接受较差方案的概率逐渐减小。
- 遗传算法:
- 原理:遗传算法是借鉴生物进化过程中的遗传、变异和自然选择机制的一种优化算法。将每个分组方案看作一个个体,用某种编码方式表示分组方案(例如,用一个数组表示每个同学所在的组)。通过选择、交叉和变异等遗传操作,不断生成新的个体(分组方案)。在每一代中,评估每个个体的适应度(可以定义为组数最多且组间差异最小的指标),选择适应度高的个体进行遗传操作,逐渐进化出更优的分组方案。选择操作通常基于个体的适应度比例进行,适应度高的个体有更大的概率被选中;交叉操作是将两个选中的个体的部分编码进行交换,生成新的个体;变异操作则是对个体的编码进行随机小幅度的改变,以引入新的基因特征,防止算法过早收敛到局部最优解。
- 应用:在分组问题中,首先随机生成一组初始的分组方案作为第一代种群。然后对每个方案计算适应度,根据适应度进行选择、交叉和变异操作,产生下一代种群。不断重复这个过程,种群中的个体(分组方案)会逐渐向最优解进化,最终得到一个相对较优的分组方案,满足分组数量最大化且组间差异最小化的要求。
- 模拟退火算法: