1. 问题背景与题目解析
今天我们来拆解一道来自灵神题单的贪心算法题目——"3010. 将数组分成最小总代价的子数组 I"。这道题在力扣上的定位是中等难度,但通过合理的贪心策略可以找到非常优雅的解法。
题目要求我们:给定一个整数数组nums,需要将其划分为若干连续子数组,使得所有子数组的"代价"之和最小。这里的"代价"定义为子数组中最大值与最小值的差。例如数组[3,1,2,4]可以划分为[3,1,2]和[4],代价分别为(3-1)+(4-4)=2。
这类数组划分问题在实际开发中很常见,比如:
- 分布式系统中数据分片的最优划分
- 图像处理中的区域分割
- 时间序列数据的分段聚合
2. 贪心算法思想的应用
2.1 为什么贪心算法适用
贪心算法在这类问题中表现出色,因为我们需要做出局部最优选择来达到全局最优。具体到本题:
- 最优子结构:整个数组的最优解包含其子数组的最优解
- 贪心选择性质:局部最优解能导致全局最优解
关键观察点:
- 当数组中相邻元素的差值较小时,将它们划分到同一个子数组不会显著增加代价
- 反之,当遇到数值突变点时,就应该考虑划分新的子数组
2.2 贪心策略设计
我们的贪心策略可以这样设计:
- 初始化当前子数组的min和max为第一个元素
- 遍历数组,维护当前子数组的min和max
- 当遇到一个新元素时,计算将其加入当前子数组会带来的代价增量
- 如果增量过大(超过某个阈值或使代价不优),则划分新子数组
- 否则继续扩展当前子数组
3. Java实现详解
3.1 基础实现
java复制public int minimumCost(int[] nums) {
if (nums.length == 0) return 0;
int totalCost = 0;
int currentMin = nums[0];
int currentMax = nums[0];
for (int i = 1; i < nums.length; i++) {
currentMin = Math.min(currentMin, nums[i]);
currentMax = Math.max(currentMax, nums[i]);
// 判断是否应该划分新子数组
if (currentMax - currentMin > someThreshold) {
totalCost += currentMax - currentMin;
currentMin = currentMax = nums[i];
}
}
// 加上最后一个子数组的代价
totalCost += currentMax - currentMin;
return totalCost;
}
3.2 阈值选择的艺术
这里的someThreshold需要根据题目要求确定。在本题中,我们发现最优解其实不需要显式设置阈值,因为最小化总代价的目标本身就隐含了划分策略:
java复制public int minimumCost(int[] nums) {
int n = nums.length;
if (n == 0) return 0;
int res = 0;
int left = 0;
while (left < n) {
int right = findSplitIndex(nums, left);
res += getCost(nums, left, right);
left = right + 1;
}
return res;
}
private int findSplitIndex(int[] nums, int start) {
int end = start;
int currentMin = nums[start];
int currentMax = nums[start];
for (int i = start + 1; i < nums.length; i++) {
int newMin = Math.min(currentMin, nums[i]);
int newMax = Math.max(currentMax, nums[i]);
// 如果扩展子数组不会使当前代价更差
if (newMax - newMin <= currentMax - currentMin) {
end = i;
currentMin = newMin;
currentMax = newMax;
} else {
break;
}
}
return end;
}
private int getCost(int[] nums, int start, int end) {
int min = Integer.MAX_VALUE;
int max = Integer.MIN_VALUE;
for (int i = start; i <= end; i++) {
min = Math.min(min, nums[i]);
max = Math.max(max, nums[i]);
}
return max - min;
}
4. 算法优化与复杂度分析
4.1 时间复杂度优化
原始实现的时间复杂度是O(n²),因为对于每个可能的划分点都需要向后扫描。我们可以通过以下优化将其降为O(n):
- 单次遍历法:在一次遍历中同时维护多个候选子数组
- 单调栈技巧:利用单调栈快速找到影响当前min和max的元素
优化后的实现:
java复制public int minimumCost(int[] nums) {
int n = nums.length;
if (n == 0) return 0;
int totalCost = 0;
int currentMin = nums[0];
int currentMax = nums[0];
for (int i = 1; i < n; i++) {
if (nums[i] >= currentMin && nums[i] <= currentMax) {
// 不改变当前子数组的代价
continue;
}
int newMin = Math.min(currentMin, nums[i]);
int newMax = Math.max(currentMax, nums[i]);
if (newMax - newMin <= currentMax - currentMin) {
// 扩展当前子数组更优
currentMin = newMin;
currentMax = newMax;
} else {
// 划分新子数组
totalCost += currentMax - currentMin;
currentMin = currentMax = nums[i];
}
}
totalCost += currentMax - currentMin;
return totalCost;
}
4.2 空间复杂度分析
所有实现都只使用了常数级别的额外空间,因此空间复杂度是O(1)。这是贪心算法的一大优势——通常不需要额外的存储空间。
5. 边界条件与测试用例
5.1 常见边界情况
- 空数组:应该返回0
- 单元素数组:代价为0
- 全相同元素数组:代价为0
- 严格递增/递减数组:可能需要特殊处理
5.2 测试用例设计
java复制@Test
public void testMinimumCost() {
Solution solution = new Solution();
// 普通情况
assertEquals(3, solution.minimumCost(new int[]{3,1,2,4}));
// 全相同元素
assertEquals(0, solution.minimumCost(new int[]{5,5,5,5}));
// 严格递增
assertEquals(3, solution.minimumCost(new int[]{1,2,3,4}));
// 严格递减
assertEquals(3, solution.minimumCost(new int[]{4,3,2,1}));
// 空数组
assertEquals(0, solution.minimumCost(new int[]{}));
// 单元素数组
assertEquals(0, solution.minimumCost(new int[]{7}));
// 复杂情况
assertEquals(7, solution.minimumCost(new int[]{6,5,4,3,2,1,7}));
}
6. 实际应用与变种问题
6.1 实际工程应用
- 数据库分片:将大表数据划分到不同节点时,需要考虑键值分布
- 资源分配:将任务分配给服务器时,需要平衡负载
- 图像压缩:将图像分成区域进行有损压缩
6.2 常见变种问题
- 限制子数组数量:将数组分成最多k个子数组
- 加权代价:代价计算方式不同(如方差)
- 多维数组:扩展到二维或更高维数组的划分
7. 贪心算法的适用场景总结
通过这道题,我们可以总结贪心算法适用的场景特征:
- 问题可以分解为子问题:整体最优解包含子问题的最优解
- 无后效性:当前选择不会影响后续子问题的结构
- 局部最优能导致全局最优:这是贪心算法的核心特征
在解决类似问题时,可以按照以下步骤思考:
- 分析问题是否具有贪心选择性质
- 设计合适的贪心策略
- 证明策略的正确性(或通过测试验证)
- 实现并优化算法
8. 从这道题学到的编程技巧
- 双指针维护子数组:使用left和right指针标记当前子数组范围
- 极值维护技巧:在遍历过程中动态更新min和max
- 提前终止条件:当发现不可能更优时提前结束循环
- 边界条件处理:特别注意空数组、单元素数组等情况
这些技巧不仅适用于这道题,在解决其他数组相关问题时也非常有用。比如在解决滑动窗口问题时,类似的指针维护技巧就能派上用场。
