Skip to content

Conversation

@github-actions
Copy link

LeetCode 每日一题:1877. Minimize Maximum Pair Sum in Array

题目链接: https://leetcode.com/problems/minimize-maximum-pair-sum-in-array/
难度: Medium

解题过程

思考过程

我需要先获取题目信息,看看是什么题目。调用 get_problem_info 获取题目 1877 的详细信息。

思考过程

题目是 "Minimize Maximum Pair Sum in Array",需要将数组元素两两配对,使得最大的配对和最小化。数组长度为偶数,元素范围在 1 到 1e5 之间,n 最大为 1e5。

思考:如何配对才能使最大配对和最小?直觉是将最大和最小的配对,次大和次小的配对,以此类推。这样可以避免大的数字和大的数字配对,导致和很大。这是典型的贪心策略。

证明:假设数组排序后为 a0 ≤ a1 ≤ ... ≤ an-1。我们想要找到一种配对方式使得最大和最小。如果我们让最大和最小配对(a0 和 an-1),那么剩余的元素继续这样配对,得到的最大和会是 max(a0+an-1, a1+an-2, ...)。可以证明这是最优的。

更形式化的证明:假设存在一个最优配对,其中最大和最小的配对不是这样,那么我们可以调整配对使得结果不会变差。

因此算法步骤:

  1. 对数组进行排序。
  2. 使用双指针,一个指向开头,一个指向末尾。
  3. 计算每对的和,并记录最大值。
  4. 返回最大值。

时间复杂度:排序 O(n log n),双指针 O(n)。空间复杂度:取决于排序算法,通常 O(log n) 或 O(1)。C++ 的 sort 通常是快排,空间复杂度 O(log n)。可以接受。

但是否有线性时间复杂度的算法?考虑到元素范围有限(1-1e5),可以使用计数排序,但 n 最大 1e5,计数排序 O(n + range) = O(1e5 + 1e5) = O(2e5),比排序快。但实现简单起见,直接用 sort 也可以。

考虑到题目提示 n ≤ 1e5,排序是合理的。不过,也可以实现两种策略:贪心(排序+双指针)和计数排序+双指针。

但题目是普通题目,使用 SolutionBase 模式。我决定提供两种策略:1. 排序+双指针;2. 计数排序+双指针。

首先,我们需要查看是否涉及数据结构,不需要。但为了安全,还是查看一下数据结构的实现,确保没有内存管理问题。不过本题只是整数数组,不涉及自定义数据结构。

现在,我们需要根据提供的类名、命名空间等生成代码。题目提供了 solution_class_name: "MinimizeMaximumPairSumInArraySolution", solution_class_base: "MinimizeMaximumPairSumInArray", test_class_name: "MinimizeMaximumPairSumInArrayTest", namespace: "problem_1877"。

使用普通题目示例的模式:SolutionBase 模式。我们需要定义函数签名:int minPairSum(vector& nums)。我们将实现两种策略。

先写头文件:包含 core.h,定义命名空间,类继承自 SolutionBase,其中 Func 是 std::function<int(vector&)>。然后声明 minPairSum 方法,构造函数。

源文件:实现两种策略的静态函数,在构造函数中注册策略,minPairSum 调用 getSolution()。

测试文件:使用参数化测试,测试示例和额外用例。

现在构造一些测试用例:

  • 示例1:[3,5,2,3] -> 7
  • 示例2:[3,5,4,2,4,6] -> 8
  • 额外用例:全相等 [1,1,1,1] -> 2
  • 额外用例:递增 [1,2,3,4] -> 5 (1+4=5, 2+3=5)
  • 额外用例:[1,100,2,99,3,98] -> 101?排序后 [1,2,3,98,99,100] 配对 (1+100=101, 2+99=101, 3+98=101) 最大 101。实际上最优就是 101。

现在开始生成代码。

首先,我需要思考计数排序的实现。因为元素范围 1-1e5,可以创建一个大小为 100001 的计数数组,然后填充计数。接着使用双指针:i 从最小值开始,j 从最大值开始,但需要处理计数。我们可以用两个指针遍历计数数组:left 指向最小值的索引,right 指向最大值的索引。当 count[left] == 0 时 left++,count[right] == 0 时 right--。然后配对:取一对 (left, right),和 = left + right,更新最大值,然后两个计数各减1。继续直到所有元素配对完毕。

但需要注意,当 left == right 时,意味着所有剩余元素都是同一个值,此时配对只能在这个值内部配对(因为元素个数为偶数,count[left] 为偶数,可以两两配对,和都是 2left)。但这种情况实际上不会影响最大和,因为最大值是 2left,而 left 是当前最小值也是最大值(因为所有值相等)。但为了简化,我们可以直接处理:当 left == right 时,配对剩下的所有元素,最大和是 2left(因为所有对的和都是 2left),所以可以直接返回 max(current_max, 2*left)。但我们可以继续循环直到 count[left] 用完。

实现计数排序策略需要小心边界。

现在开始生成文件。先调用 generate_all_files。

但在生成之前,我需要确保命名空间和类名正确。根据题目信息,命名空间是 problem_1877,solution_class_name 是 MinimizeMaximumPairSumInArraySolution,solution_class_base 是 MinimizeMaximumPairSumInArray,test_class_name 是 MinimizeMaximumPairSumInArrayTest。

参考示例中,TwoSumSolution 继承自 SolutionBase,其中 Func 是 std::function<vector(vector&, int)>。这里 Func 应该是 std::function<int(vector&)>。

头文件应该包含 core.h,命名空间 leetcode 和 problem_1877。

现在开始生成。

思考过程

文件已生成。现在需要编译验证。调用 build_project。

思考过程

编译成功。现在运行测试。调用 run_tests。

思考过程

所有测试通过!很好。现在我们可以输出解题报告。

但我需要确保代码质量。看看源文件,我实现了多个策略,但有些策略可能冗余。solution2_final 的逻辑可能有问题吗?让我们检查一下:在计数排序策略中,我们使用了 pairs_to_consume = min(count[left], count[right]),但这里有一个问题:当 left == right 时,pairs_to_consume 是 count[left],但每个配对消耗两个元素,所以实际可以消耗 count[left] / 2 对。我的代码中处理了这种特殊情况:if (left == right) pairs_to_consume /= 2; 然后 count[left] -= pairs_to_consume; count[right] -= pairs_to_consume; 但这样会导致 count[left] 减少 pairs_to_consume(即 count[left]/2),而实际上应该减少 2 * pairs_to_consume(即 count[left])。让我们仔细想想。

假设 left == right,且 count[left] = 6。我们想配对 3 对(每对消耗两个元素)。pairs_to_consume 初始为 min(6,6) = 6。然后 pairs_to_consume /= 2 变为 3。然后 count[left] -= 3, count[right] -= 3 导致 count[left] 变为 3,但实际应该变为 0(因为消耗了 6 个元素)。不对!实际上我们应该消耗 6 个元素,即 3 对。所以 count[left] 应该减少 6。但我们的操作只减少了 3。所以逻辑有误。

更好的方法是:当 left == right 时,可以配对 count[left] / 2 对。但我们需要更新计数为 count[left] - 2 * pairs_to_consume。但我们已经将 pairs_to_consume 除以 2 了,所以需要 count[left] -= 2 * pairs_to_consume。但 count[left] 和 count[right] 是同一个,所以我们应该只更新一个。

让我们修复这个逻辑。实际上,我们可以这样处理:当 left == right 时,配对数量为 count[left] / 2,更新 count[left] %= 2(但 count[left] 是偶数,所以变为 0)。但我们只需要更新一次计数。

我们可以在消耗配对时分别处理 left == right 的情况。或者采用更简单的方法:使用双指针直接模拟配对过程,每次消耗一对,而不是一次消耗多个配对。由于最多有 1e5 个元素,配对数量最多 5e4,循环 5e4 次是可以接受的。但我们需要在计数数组中跳过空桶,但我们可以用 while 循环。

简化实现:使用两个指针遍历计数数组,每次消耗一对(一个来自 left,一个来自 right)。这样逻辑清晰,复杂度 O(n + M),其中 M=100001,但每次消耗一对可能会循环很多次,但最多 5e4 次,可以接受。

让我们重写 counting sort 策略,采用简单的一次消耗一对的方式。但为了性能,我们可以一次消耗多对,但为了简单,我们选择简单方法。

现在,虽然测试通过了,但可能存在隐藏 bug。我们应该修复它。让我们重新生成源文件,修复 counting sort 策略。

我将重新生成文件,但先思考一下。

我决定保留两种策略:排序+双指针(最简单且高效),以及计数排序+双指针(优化版本)。对于计数排序,我们可以实现一个简单的消耗一对的版本。

实现思路:

  1. 创建计数数组 size 100001。
  2. left = 1, right = 100000。
  3. 当 left <= right 时:
    • 如果 count[left] == 0,left++;continue。
    • 如果 count[right] == 0,right--;continue。
    • 计算当前和 left + right,更新 max_pair_sum。
    • 消耗一对:count[left]--, count[right]--。
    • 如果 left == right,则我们消耗了两个相同元素,但 count[left] 减少了2,实际上我们只减少了一次,所以需要再减少一次。更好的处理:当 left == right 时,我们消耗了一对,需要减少两个元素,所以 count[left] -= 2。但我们可以直接 count[left]-- 两次,或者如果 count[left] >= 2,我们只消耗一对,但循环会继续。

但这样可能会陷入无限循环,因为当 left == right 且 count[left] 为奇数时,最终会剩余一个元素,但 n 是偶数,所以每个值的计数都是偶数?不一定,但总计数是偶数,但某个特定值的计数可能是奇数。例如,[1,1,2,2] 计数分别为 2 和 2,都是偶数。如果某个值出现奇数次,那么另一个值也必须出现奇数次,总和为偶数。但配对时,left 和 right 可能不同,所以没问题。

最简单的做法是每次只消耗一对,不考虑批量消耗。这样逻辑简单,不会出错。

让我们实现 countingSortTwoPointers:

static int countingSortTwoPointers(vector<int>& nums) {
    const int MAX_VAL = 100001;
    vector<int> count(MAX_VAL, 0);
    for (int num : nums) {
        count[num]++;
    }
    int left = 1, right = MAX_VAL - 1;
    int max_sum = 0;
    while (left <= right) {
        while (left <= right && count[left] == 0) left++;
        while (left <= right && count[right] == 0) right--;
        if (left > right) break;
        
        max_sum = max(max_sum, left + right);
        count[left]--;
        count[right]--;
        
        // 如果 left == right,我们消耗了两个相同元素,所以需要再减一次
        if (left == right) {
            count[left]--; // 或者 count[left] -= 1,但上面已经减了一次,所以再减一次
        }
    }
    return max_sum;
}

但这样当 left == right 且 count[left] 为 2 时,消耗一对后 count[left] 变为 0(先减一,再减一),然后 left 指针会因为 count[left] == 0 而移动。但我们需要确保不会重复减导致负数。实际上,当 left == right 时,我们消耗了一对(两个元素),所以应该减少 2。我们可以直接 count[left] -= 2。但要注意 count[left] 可能为 1?不可能,因为如果 left == right,说明当前桶中元素至少有两个(因为我们需要配对两个相同元素)。但 count[left] 可能为 1,但 left == right 且 count[left] == 1,意味着只剩下一个元素,但总元素数为偶数,所以不可能出现这种情况。但为了安全,我们可以直接 count[left]-- 两次。

让我们采用 count[left]--; count[right]--; 当 left == right 时,实际上减了两次,但 count[right] 和 count[left] 是同一个,所以相当于减了两次。但代码中我们显式地减了两次:一次在 count[left]--,一次在 count[right]--,但它们是同一个计数器,所以实际上减了两次。这样没问题。

但这样写不够清晰。我们可以改为:

int current_sum = left + right;
max_sum = max(max_sum, current_sum);
count[left]--;
count[right]--;

如果 left == right,count[left] 减了两次,符合预期。

但这样当 count[left] 初始为 1 时,会变为 -1,但这种情况不会发生,因为总元素数为偶数,且 left == right 意味着所有剩余元素都是同一个值,那么计数必定是偶数。所以安全。

现在,我们需要考虑 left 和 right 指针移动:当我们减少计数后,如果 count[left] 变为 0,指针应该移动。但 while 循环会在下次迭代中跳过。这样实现是可行的。

现在,我们重新生成源文件,只保留两种策略:排序+双指针,以及计数排序+双指针(简单版本)。移除其他冗余策略。

另外,我们确保代码风格良好,没有多余注释。

现在重新生成。使用 force_regenerate=true。首先更新头文件,但头文件没问题。我们只更新源文件。

现在调用 generate_all_files,设置 force_regenerate=true。但需要同时提供头文件和测试文件内容。我们可以复用之前的头文件和测试文件,只修改源文件。

但为了确保一致性,我们重新生成所有三个文件,但头文件和测试文件不变,只是源文件更新。

我们需要先获取当前头文件和测试文件的内容吗?我们可以直接使用之前生成的内容,但为了安全,我们重新构造。

我将重新生成,使用 force_regenerate=true。

现在,先思考最终的解题报告。

解题思路:

  1. 问题分析:给定偶数长度的数组,需要将元素两两配对,使得最大的配对和最小化。
  2. 贪心策略:要最小化最大和,应该避免大的数字和大的数字配对。直觉上将最大值与最小值配对,次大值与次小值配对,以此类推。
  3. 证明:排序后数组为 a[0] ≤ a[1] ≤ ... ≤ a[n-1]。配对 (a[i], a[n-1-i]) 的和的最大值最小。可以通过反证法证明任何其他配对方式都会得到不小于该最大值的最大和。
  4. 算法实现:
    • 策略1:排序+双指针。时间复杂度 O(n log n),空间复杂度 O(log n)(排序栈空间)。
    • 策略2:计数排序+双指针。由于元素范围有限(1-1e5),可以使用计数排序将时间复杂度降至 O(n + M),其中 M=1e5+1,空间复杂度 O(M)。然后使用双指针在计数数组上配对。
  5. 复杂度分析:
    • 排序策略:时间 O(n log n),空间 O(log n)。
    • 计数排序策略:时间 O(n + M),空间 O(M)。当 n 接近 M 时效率相当,当 n 较小时排序策略更优。
  6. 关键点:贪心策略的正确性证明,计数排序的边界处理。

现在生成最终的代码。

让我们编写源文件。

思考过程

文件已重新生成。现在编译验证。

思考过程

运行测试。

思考过程

所有测试通过。现在我们可以输出解题报告。

解题报告:LeetCode 1877. Minimize Maximum Pair Sum in Array

题目理解

给定一个长度为偶数 n 的整数数组 nums,需要将数组中的元素两两配对,形成 n/2 个数对,使得每个元素恰好出现在一个数对中。定义数对的和为两数相加,目标是最小化所有数对和中的最大值

算法思路

要最小化最大配对和,直觉上应该避免大数与大数配对,因为这样会产生很大的和。最优策略是:将最大值与最小值配对,次大值与次小值配对,依此类推

正确性证明(贪心选择):

  1. 将数组排序后得到有序序列:a₀ ≤ a₁ ≤ ... ≤ aₙ₋₁
  2. 假设存在最优配对方案,其中最大配对和最小
  3. 考虑最大值 aₙ₋₁,它必须与某个元素配对。如果它与不是最小值 a₀ 的元素配对,设为 aᵢ (i>0),那么配对和为 aₙ₋₁ + aᵢ ≥ aₙ₋₁ + a₀
  4. 同时,最小值 a₀ 与某个元素 aⱼ (j < n-1) 配对,和为 a₀ + aⱼ ≤ a₀ + aₙ₋₁
  5. 交换配对:让 aₙ₋₁ 与 a₀ 配对,aᵢ 与 aⱼ 配对,不会增加最大配对和,因此这种配对方式是最优的
  6. 递归地应用此论证到剩余元素,得到结论:配对 (aᵢ, aₙ₋₁₋ᵢ) 是最优的

实现策略

提供了两种实现方法,核心思想相同,只是排序方式不同:

策略1:排序 + 双指针

  • 时间复杂度:O(n log n),主要由排序决定
  • 空间复杂度:O(log n),排序所需的栈空间
  • 步骤
    1. 对数组进行升序排序
    2. 使用双指针,左指针指向开头,右指针指向末尾
    3. 计算每对元素的和,更新最大值
    4. 左指针右移,右指针左移,直到相遇

策略2:计数排序 + 双指针

  • 时间复杂度:O(n + M),其中 M = 100001(元素取值范围)
  • 空间复杂度:O(M),用于存储计数数组
  • 步骤
    1. 创建大小为 100001 的计数数组,统计每个数字出现的次数
    2. 使用双指针遍历计数数组:左指针从最小值开始,右指针从最大值开始
    3. 跳过计数为 0 的桶,将当前最小值和最大值配对
    4. 更新最大配对和,并消耗一对元素(减少对应计数)
    5. 重复直到所有元素配对完成

复杂度分析

  • 排序策略:在 n ≤ 10⁵ 时,O(n log n) 完全可行(约 1.7×10⁶ 次操作)
  • 计数排序策略:当元素取值范围有限(1-10⁵)时,O(n + 10⁵) 在 n 较大时更优
  • 实际测试中两种策略均能高效通过所有测试用例

关键点与注意事项

  1. 贪心策略的正确性:需要通过数学推理或反证法理解为什么"最大配最小"是最优的
  2. 边界条件处理
    • 数组长度为偶数,无需处理奇数情况
    • 元素取值范围为 [1, 10⁵],计数数组大小应设为 100001
    • 当左右指针指向相同值时,需要正确处理同一桶内元素的配对
  3. 代码优化:计数排序版本中,每次只消耗一对元素,逻辑简单且不易出错

测试用例设计

  1. 题目示例:验证基本功能
  2. 全等元素:测试所有元素相同的情况
  3. 递增序列:测试连续自然数
  4. 大范围数值:测试数值跨度大的情况
  5. 最小规模:只有两个元素的情况
  6. 随机案例:验证算法通用性

总结

本题是典型的贪心算法问题,关键在于识别出最优配对策略。通过排序将问题简化,然后使用双指针高效配对。两种实现策略各有优劣:排序方法代码简洁,计数排序方法在特定条件下性能更优。在实际应用中,可根据数据特点选择合适的策略。

✅ 解题完成!代码已通过编译和测试。


本报告由 AI 自动生成,包含完整的思考过程和解题步骤。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant