E. Yet Another Division Into Teams - 题解
比赛与标签
比赛: Codeforces Round 598 (Div. 3) 标签: dp, greedy, sortings 难度: *2000
喵~来理解一下题意吧!
主人你好呀~ 这道题是说,我们有一群可爱的学生,每个学生都有一个编程能力值,就像猫娘的战斗力一样喵!我们的任务是把他们分成好多好多小队,每个小队至少要有3个队员哦。
一个小队的“多样性”呢,就是队里最厉害的同学和最不擅长的同学(呜…)的能力差值。我们的目标是找到一种分组方法,让所有小队的“多样性”加起来的总和最小最小~ 最后还要告诉人家每个学生具体分到了哪个队里,喵~
输入:
- 第一行是一个整数
n
,表示学生的数量。 - 第二行是
n
个整数,表示每个学生的能力值。
输出:
- 第一行是最小的总多样性和分成的队伍数量
k
。 - 第二行是
n
个整数,表示原来顺序下的第i
个学生被分到了第t_i
个队。
猫娘的DP魔法时间!
这道题看起来有点复杂,但别怕,跟着猫娘的思路一步步来,问题就会迎刃而解啦!
第一步:排序大法好!
看到求 max - min
这种东西,猫娘的直觉告诉我,先把学生们按能力值排个序准没错!这样能力相近的同学就会站在一起啦,方便我们组队,对不对呐?
排好序之后,最棒的分组方案肯定是把 连续的一段 同学分成一个队。你想呀,如果一个队里有能力值为10和50的同学,却把中间能力值为30的同学分到别的队,那不是白白增大了这个队的多样性嘛?把能力值为30的同学拉回来,多样性 (50 - 10
) 不会变,但别的队可能会因为少了一个人而变得更好组合哦!所以,一个队伍里的成员在排好序的序列里,一定是连续的!
这样,问题就转化成了一个更清晰的模型:将一个排好序的数组,切分成若干个连续的子数组(代表队伍),每个子数组长度至少为3,目标是让所有子数组的(末尾值 - 开头值)之和最小。
第二步:DP闪亮登场!
这种“分割序列求最优解”的问题,简直就是为动态规划量身定做的嘛!
我们来定义一下DP的状态: dp[i]
:表示将前 i
个(排好序的)学生进行分组,所能得到的最小总多样性。
我们的最终目标就是求出 dp[n]
啦。
第三步:神奇的贪心优化!
要计算 dp[i]
,我们就需要考虑由第 i
个学生组成的“最后一队”有多少人。假设最后一队有 j
个人(j >= 3
),那么这一队就是由第 i-j+1
到第 i
个学生组成的。 这个状态转移方程初步看起来是: dp[i] = min(dp[i-j] + (a[i] - a[i-j+1]))
,其中 j >= 3
。
如果对所有可能的 j
都进行一次遍历,那复杂度可就高了呀... O(N^2) 会超时的说!
但是!让猫娘施展一个洞察魔法!喵! 一个队真的需要有6个或更多队员吗?
- 假设我们有一个6人队,其成员能力值为
{s_1, s_2, s_3, s_4, s_5, s_6}
。它的多样性是s_6 - s_1
。 - 如果我们把它拆分成一个3人队
{s_1, s_2, s_3}
和另一个3人队{s_4, s_5, s_6}
呢? - 新的总多样性是
(s_3 - s_1) + (s_6 - s_4)
。 - 比较一下:
(s_6 - s_1) - ((s_3 - s_1) + (s_6 - s_4)) = (s_6 - s_1 - s_3 + s_1 - s_6 + s_4) = s_4 - s_3
。 - 因为数组是排好序的,所以
s_4 - s_3 >= 0
。这意味着,拆分后的总多样性 小于或等于 原来的多样性!
这个结论可以推广到任意大小 k >= 6
的队伍,我们总能把它拆成一个3人队和一个 k-3
人的队,并且总多样性不会增加。所以,最优解中的任何队伍,其人数都只可能是 3、4 或 5!我们完全不需要考虑更大的队伍了!
第四步:最终的转移方程!
有了这个超棒的结论,我们的DP转移就变得超级简单了!dp[i]
只可能从 dp[i-3]
, dp[i-4]
, dp[i-5]
这三种状态转移而来:
dp[i] = min(
dp[i-3] + (skill[i-1] - skill[i-3])
(如果i >= 3
且dp[i-3]
可达)dp[i-4] + (skill[i-1] - skill[i-4])
(如果i >= 4
且dp[i-4]
可达)dp[i-5] + (skill[i-1] - skill[i-5])
(如果i >= 5
且dp[i-5]
可达))
(注意代码里是0-indexed,所以第i
个学生是students[i-1]
哦)
第五步:还原分组方案
算出了最小总多样性 dp[n]
还不够,还要知道怎么分的队。这也好办!我们在计算 dp[i]
的时候,顺手用一个 choice[i]
数组记下来它是从哪个状态(i-3
, i-4
, 还是 i-5
)转移过来的。也就是记录下最后一队的规模。
最后,我们从 current_pos = n
开始倒着推:
- 查看
choice[current_pos]
得知最后一队的规模s
。 - 给从
current_pos - s
到current_pos - 1
的这s
个学生分配一个新的队伍编号。 - 更新
current_pos = current_pos - s
。 - 重复这个过程直到
current_pos
变为0。
就像顺着毛线球找到线头一样简单,喵~
看猫娘的代码魔法!
#include <iostream>
#include <vector>
#include <algorithm>
#include <utility>
// A struct to hold a student's skill and their original index.
// 用一个结构体来保存学生的能力值和他们的原始编号,喵~
// 这样排序后我们还能找回他们原来的位置!
struct Student {
int skill;
int original_index;
};
// A custom comparison function to sort students based on their programming skill.
// 自定义比较函数,按能力值给学生排序~
bool compareStudents(const Student& a, const Student& b) {
return a.skill < b.skill;
}
int main() {
// Fast I/O is a good practice in competitive programming for performance.
// 加速输入输出,是竞赛编程的好习惯哦~
std::ios_base::sync_with_stdio(false);
std::cin.tie(NULL);
int n;
std::cin >> n;
std::vector<Student> students(n);
for (int i = 0; i < n; ++i) {
std::cin >> students[i].skill;
students[i].original_index = i;
}
// The core idea is that any optimal division into teams will correspond to
// a partition of the students *after* they are sorted by skill.
// 核心思想!先把所有学生按能力值排个序~
std::sort(students.begin(), students.end(), compareStudents);
// We use dynamic programming.
// dp[i] will store the minimum total diversity for the first `i` students from the sorted list.
// 我们用动态规划来解决,喵!
// dp[i] 表示前 i 个学生分组后的最小总多样性。
// We use -1 to represent an unreachable state (infinity), since costs are non-negative.
// 初始化为-1,代表一个还没算出来的状态。
std::vector<long long> dp(n + 1, -1);
// choice[i] will store the size of the last team (3, 4, or 5) in the optimal
// partition for the first `i` students. This is needed for reconstruction.
// choice[i] 用来记录在最优方案中,包含第 i 个学生的队伍大小是多少,方便我们回溯找答案。
std::vector<int> choice(n + 1, 0);
// Base case: for 0 students, the diversity is 0.
// 边界条件:0个学生,多样性自然是0啦。
dp[0] = 0;
// It can be proven that any optimal solution will only consist of teams of size 3, 4, or 5.
// So, we only need to consider these three team sizes for the last team.
// 正如我们分析的,最优解的队伍大小只可能是3, 4, 5,所以我们只用考虑这三种情况!
for (int i = 1; i <= n; ++i) {
// Option 1: The last team has size 3.
// This is possible if we have at least 3 students (i >= 3) and
// the state for the remaining (i-3) students is reachable.
// 情况一:最后一队是3个人
if (i >= 3 && dp[i - 3] != -1) {
long long team_cost = (long long)students[i - 1].skill - students[i - 3].skill;
long long total_cost = dp[i - 3] + team_cost;
if (dp[i] == -1 || total_cost < dp[i]) {
dp[i] = total_cost;
choice[i] = 3;
}
}
// Option 2: The last team has size 4.
// 情况二:最后一队是4个人
if (i >= 4 && dp[i - 4] != -1) {
long long team_cost = (long long)students[i - 1].skill - students[i - 4].skill;
long long total_cost = dp[i - 4] + team_cost;
if (dp[i] == -1 || total_cost < dp[i]) {
dp[i] = total_cost;
choice[i] = 4;
}
}
// Option 3: The last team has size 5.
// 情况三:最后一队是5个人
if (i >= 5 && dp[i - 5] != -1) {
long long team_cost = (long long)students[i - 1].skill - students[i - 5].skill;
long long total_cost = dp[i - 5] + team_cost;
if (dp[i] == -1 || total_cost < dp[i]) {
dp[i] = total_cost;
choice[i] = 5;
}
}
}
// The final answer for minimum diversity is dp[n].
long long min_diversity = dp[n];
// Reconstruct the solution by backtracking from n using the `choice` array.
// 激动人心的时刻!用 choice 数组从后往前倒推出每个队伍的成员!
std::vector<int> team_assignments(n);
int team_count = 0;
int current_pos = n;
while (current_pos > 0) {
team_count++;
int team_size = choice[current_pos];
// Assign the current team number to all members of this team.
// 把当前队伍的编号分配给这个队伍的所有成员。
for (int i = 0; i < team_size; ++i) {
// The members of the last team are at indices from current_pos - team_size to current_pos - 1
// in the sorted `students` array.
// 别忘了我们操作的是排序后的数组,要通过 original_index 找回它原来的位置!
int original_idx = students[current_pos - 1 - i].original_index;
team_assignments[original_idx] = team_count;
}
// Move to the state before this team was formed.
// 跳到组建这个队之前的状态。
current_pos -= team_size;
}
// Print the results in the required format.
// 按题目要求的格式输出答案~
std::cout << min_diversity << " " << team_count << "\n";
for (int i = 0; i < n; ++i) {
std::cout << team_assignments[i] << (i == n - 1 ? "" : " ");
}
std::cout << "\n";
return 0;
}
复杂度分析喵~
- 时间复杂度: O(N log N) 的说。瓶颈在于一开始对所有学生进行排序。后面的DP过程,每个状态
dp[i]
都只由前面固定的3个状态转移而来,所以是 O(N) 的。总的来说就是 O(N log N) 啦。 - 空间复杂度: O(N) 的说。我们需要
students
数组存学生信息,dp
数组存状态值,choice
数组存决策,team_assignments
数组存最终答案,这些都是和学生数量n成正比的,所以是 O(N) 空间。
猫娘的小课堂时间~
这道题真的很有趣,融合了好几种思想,让猫娘来总结一下吧!
- 排序预处理: 许多涉及区间
max-min
的问题,排序都是一个非常有效的预处理步骤。它能将看似混乱的问题转化为更有序、更结构化的情况,方便我们进行下一步的分析和规划。 - 线性DP: 这是典型的线性动态规划问题,状态
dp[i]
只依赖于它之前的几个状态,自底向上递推求解即可。 - 贪心优化DP: 这道题最亮眼的地方就是那个贪心证明!证明了最优解中队伍大小不需要超过5,直接把一个可能是 O(N^2) 的DP优化到了 O(N),太厉害了喵!这告诉我们,在做DP时,多分析一下问题的性质,大胆猜想,小心求证,可能会有惊人的发现哦。
- 路径回溯: 当题目不仅要求最优值,还要求输出具体方案时,就要记录下做决策的每一步。用一个额外的数组(比如这里的
choice
)来记录转移路径,是DP问题输出方案的常用手法,主人要记住哦~ - 结构体保序: 当排序会打乱原始顺序,但最后又需要按原始顺序输出时,用结构体或者
std::pair
把值和原始下标绑在一起,是个非常实用的小技巧!
好啦,这次的题解就到这里啦!主人有没有觉得豁然开朗呢?多做题,多思考,你也能像猫娘一样厉害的,喵~ 加油!