1. 问题背景与需求分析
今天我们来拆解力扣(LeetCode)上的3507题——"移除最小数对使数组有序 I"。这是一道典型的数组操作类算法题,主要考察对数组遍历和条件判断的掌握程度。题目要求我们通过特定操作使数组最终呈现非递减顺序,同时需要找到最优的操作策略。
题目描述的核心操作规则是:每次可以选择相邻元素中和最小的一对数进行移除。如果存在多个和相同的数对,则选择最左边的那一对。这个操作可以执行任意次数,直到数组满足非递减顺序为止。
2. 核心算法思路解析
2.1 问题转化与理解
首先我们需要明确几个关键点:
- 操作对象是相邻的元素对
- 每次移除的是和最小的那一对
- 当存在多个最小和时,优先处理最左边的
- 操作可以执行任意次数
- 最终目标是使数组非递减
这个问题的本质是通过特定的删除操作来优化数组的有序性。与常规的排序算法不同,这里的操作受到严格限制——只能移除特定条件的相邻元素对。
2.2 贪心算法选择
基于题目特性,最合适的解法是采用贪心算法(Greedy Algorithm)。贪心算法在每一步都做出局部最优选择,希望这样能够导致全局最优解。在这个问题中,每次移除当前和最小的相邻数对,正是贪心策略的体现。
为什么贪心算法适用?
- 问题具有最优子结构:每个子问题的最优解能递推到最终问题的最优解
- 无后效性:当前的选择不会影响后续子问题的结构
- 局部最优能导向全局最优:每次移除最小和数对确实能有效改善数组有序性
3. 详细实现步骤
3.1 算法流程设计
- 初始化标志位changed为true
- 当changed为true时循环:
a. 设置changed为false
b. 遍历数组,找到所有相邻数对的和
c. 找出其中的最小和
d. 找到最左边的具有该最小和的数对
e. 移除该数对
f. 设置changed为true - 当数组变为非递减或无法继续操作时结束
3.2 Java代码实现
java复制public int[] makeArrayIncreasing(int[] nums) {
boolean changed = true;
while (changed) {
changed = false;
int minSum = Integer.MAX_VALUE;
int minIndex = -1;
// 寻找最小和数对
for (int i = 0; i < nums.length - 1; i++) {
int currentSum = nums[i] + nums[i + 1];
if (currentSum < minSum) {
minSum = currentSum;
minIndex = i;
}
}
// 如果找到可移除的数对
if (minIndex != -1) {
// 检查移除后是否会使数组更有序
if (nums.length > 2) {
int left = minIndex > 0 ? nums[minIndex - 1] : Integer.MIN_VALUE;
int right = minIndex + 2 < nums.length ? nums[minIndex + 2] : Integer.MAX_VALUE;
if (left > right) {
continue; // 移除会破坏有序性,跳过
}
}
// 执行移除操作
int[] newNums = new int[nums.length - 2];
System.arraycopy(nums, 0, newNums, 0, minIndex);
System.arraycopy(nums, minIndex + 2, newNums, minIndex, nums.length - minIndex - 2);
nums = newNums;
changed = true;
}
// 检查数组是否已有序
boolean isSorted = true;
for (int i = 0; i < nums.length - 1; i++) {
if (nums[i] > nums[i + 1]) {
isSorted = false;
break;
}
}
if (isSorted) break;
}
return nums;
}
3.3 关键点解析
- 循环控制:使用changed标志位控制循环,只有真正移除了数对才会继续下一轮操作
- 最小和查找:遍历数组找出和最小的相邻数对,记录其位置
- 有序性检查:在移除前检查操作是否会破坏整体有序性
- 数组更新:使用System.arraycopy高效地创建新数组
- 终止条件:每次操作后检查数组是否已满足非递减条件
4. 复杂度分析与优化
4.1 时间复杂度
最坏情况下,每次操作移除2个元素,对于长度为n的数组:
- 外层循环最多执行n/2次
- 每次循环需要O(n)时间查找最小和
- 每次需要O(n)时间检查有序性和复制数组
因此总时间复杂度为O(n²)
4.2 空间复杂度
由于每次操作都创建新数组,空间复杂度为O(n)
4.3 优化方向
- 原地操作:可以尝试在原地修改数组,避免频繁创建新数组
- 提前终止:当发现无法通过移除操作改善有序性时提前终止
- 堆优化:使用优先队列维护当前所有相邻数对的和,加速最小和的查找
5. 边界条件与测试用例
5.1 常见边界情况
- 空数组或单元素数组:直接返回
- 已经有序的数组:无需操作
- 所有相邻数对和相同:按规则移除最左边的
- 无法通过操作使数组有序的情况
5.2 测试用例示例
java复制@Test
public void testMakeArrayIncreasing() {
Solution solution = new Solution();
// 已有序情况
assertArrayEquals(new int[]{1,2,3}, solution.makeArrayIncreasing(new int[]{1,2,3}));
// 需要一次操作
assertArrayEquals(new int[]{3,4}, solution.makeArrayIncreasing(new int[]{1,2,3,4}));
// 需要多次操作
assertArrayEquals(new int[]{5}, solution.makeArrayIncreasing(new int[]{1,4,2,3,5}));
// 无法完全有序
assertArrayEquals(new int[]{3,1,2}, solution.makeArrayIncreasing(new int[]{3,1,2}));
// 边界情况
assertArrayEquals(new int[]{1}, solution.makeArrayIncreasing(new int[]{1}));
assertArrayEquals(new int[]{}, solution.makeArrayIncreasing(new int[]{}));
}
6. 同类问题扩展
6.1 力扣类似题目
- 1574. 删除最短的子数组使剩余数组有序:需要删除一个子数组而非特定数对
- 945. 使数组唯一的最小增量:通过增加而非删除操作使数组满足条件
- 1673. 找出最具竞争力的子序列:通过删除元素获取最具竞争力的子序列
6.2 算法模式总结
这类数组操作问题通常有以下特点:
- 有特定的操作规则限制
- 需要通过有限的操作达到目标状态
- 往往可以使用贪心策略
- 需要考虑边界条件和操作顺序
7. 实际应用场景
虽然这类算法题看起来是纯理论练习,但实际上有许多现实应用:
- 数据清洗:在数据预处理中,有时需要移除特定条件的异常数据对
- 资源分配:在资源有限情况下,需要做出局部最优的分配决策
- 路径优化:在路径规划中,可能需要移除某些不经济的路径段
- 信号处理:在数字信号处理中,可能需要过滤掉特定条件的信号片段
8. 常见错误与调试技巧
8.1 常见错误
- 无限循环:忘记设置终止条件或changed标志位
- 数组越界:在处理边界元素时未做适当检查
- 错误的有序性判断:只检查了局部有序而忽略了全局有序
- 性能问题:对于大规模数据,未优化的算法可能导致超时
8.2 调试建议
- 打印每次操作后的数组状态
- 对特殊测试用例进行单步调试
- 使用可视化工具观察数组变化过程
- 编写完备的单元测试覆盖各种边界情况
9. 进阶思考
对于这个问题,我们可以进一步思考:
- 如果允许每次移除任意位置的数对(不一定是相邻的),算法该如何调整?
- 如果操作代价不同(如移除不同数对消耗不同),如何找到最小总代价的方案?
- 如果目标不是严格非递减,而是满足某种特定模式,算法该如何修改?
- 如何证明这个贪心算法的正确性?
这些思考可以帮助我们更深入地理解贪心算法的应用场景和局限性。
10. 个人实现心得
在实际编码过程中,我发现有几个关键点需要特别注意:
- 数组拷贝效率:频繁创建新数组会影响性能,在实际应用中应考虑更高效的内存管理方式
- 有序性检查的时机:不必每次操作后都检查,可以在特定条件下触发检查
- 提前终止条件:当发现无法通过进一步操作改善有序性时,应立即终止算法
- 测试用例设计:要特别注意包含重复元素、全部相同元素等特殊情况
这个题目很好地展示了贪心算法的思维模式——通过局部最优选择逐步逼近全局最优解。虽然贪心算法不是万能的,但在满足特定条件的问题上,它能提供简单高效的解决方案。
