Skip to content

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 >= 3dp[i-3] 可达)
  • dp[i-4] + (skill[i-1] - skill[i-4]) (如果 i >= 4dp[i-4] 可达)
  • dp[i-5] + (skill[i-1] - skill[i-5]) (如果 i >= 5dp[i-5] 可达) )(注意代码里是0-indexed,所以第 i 个学生是 students[i-1] 哦)

第五步:还原分组方案

算出了最小总多样性 dp[n] 还不够,还要知道怎么分的队。这也好办!我们在计算 dp[i] 的时候,顺手用一个 choice[i] 数组记下来它是从哪个状态(i-3, i-4, 还是 i-5)转移过来的。也就是记录下最后一队的规模。

最后,我们从 current_pos = n 开始倒着推:

  1. 查看 choice[current_pos] 得知最后一队的规模 s
  2. 给从 current_pos - scurrent_pos - 1 的这 s 个学生分配一个新的队伍编号。
  3. 更新 current_pos = current_pos - s
  4. 重复这个过程直到 current_pos 变为0。

就像顺着毛线球找到线头一样简单,喵~

看猫娘的代码魔法!

cpp
#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) 空间。

猫娘的小课堂时间~

这道题真的很有趣,融合了好几种思想,让猫娘来总结一下吧!

  1. 排序预处理: 许多涉及区间 max-min 的问题,排序都是一个非常有效的预处理步骤。它能将看似混乱的问题转化为更有序、更结构化的情况,方便我们进行下一步的分析和规划。
  2. 线性DP: 这是典型的线性动态规划问题,状态 dp[i] 只依赖于它之前的几个状态,自底向上递推求解即可。
  3. 贪心优化DP: 这道题最亮眼的地方就是那个贪心证明!证明了最优解中队伍大小不需要超过5,直接把一个可能是 O(N^2) 的DP优化到了 O(N),太厉害了喵!这告诉我们,在做DP时,多分析一下问题的性质,大胆猜想,小心求证,可能会有惊人的发现哦。
  4. 路径回溯: 当题目不仅要求最优值,还要求输出具体方案时,就要记录下做决策的每一步。用一个额外的数组(比如这里的 choice)来记录转移路径,是DP问题输出方案的常用手法,主人要记住哦~
  5. 结构体保序: 当排序会打乱原始顺序,但最后又需要按原始顺序输出时,用结构体或者 std::pair 把值和原始下标绑在一起,是个非常实用的小技巧!

好啦,这次的题解就到这里啦!主人有没有觉得豁然开朗呢?多做题,多思考,你也能像猫娘一样厉害的,喵~ 加油!

Released under the MIT License.