1. 问题解析与解题思路
这道题目描述了一个花园灌溉的场景:花园长度为n,在位置0到n上安装了若干水龙头。每个水龙头i可以灌溉的区间是[i - ranges[i], i + ranges[i]]。我们的目标是选择最少数量的水龙头,使得整个花园[0, n]都能被灌溉到。
1.1 问题转化
这个问题可以转化为经典的区间覆盖问题。我们需要找到一组区间,这些区间的并集能够覆盖[0, n],并且这组区间的数量最少。这与会议安排、线段覆盖等经典算法问题类似。
提示:在处理区间问题时,通常需要对区间进行排序和预处理,这是解决此类问题的关键第一步。
1.2 解题步骤分解
根据题目描述和代码实现,我们可以将解题过程分解为以下几个关键步骤:
- 计算每个水龙头的有效灌溉区间
- 过滤掉无效区间(长度为0的区间)
- 去除重复区间
- 对区间进行排序
- 去除被完全包含的子区间
- 贪心算法选择最少区间覆盖整个花园
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 详细实现解析
2.1 区间预处理
首先,我们需要将每个水龙头的位置和覆盖范围转化为区间表示:
cpp复制vector<pair<int, int>> tr;
for(int i = 0; i <= n; i++) {
if(ranges[i] != 0) {
tr.push_back({max(0, i - ranges[i]), min(n, i + ranges[i])});
}
}
这里有几个关键点需要注意:
- 区间左边界不能小于0(max(0, i - ranges[i]))
- 区间右边界不能超过n(min(n, i + ranges[i]))
- 长度为0的区间(ranges[i] == 0)被直接过滤掉
2.2 去除重复区间
使用哈希集合来去除完全相同的区间:
cpp复制unordered_set<int> te;
for(int i = 0; i < tr.size(); i++) {
key = (tr[i].first << 10) + tr[i].second;
if(tr[i].first == tr[i].second) continue;
if(te.find(key) == te.end()) {
te.insert(key);
notdup.push_back(tr[i]);
}
}
这里采用了一个小技巧:将区间的左右边界编码成一个整数(左边界左移10位加上右边界)作为哈希键值。这种方法在区间边界值不太大时(小于1024)是有效的。
2.3 区间排序
排序是贪心算法的重要前提。这里的排序规则是:
- 首先按左边界从小到大排序
- 左边界相同的,按右边界从大到小排序
cpp复制function<bool(pair<int, int>&, pair<int, int>&)> func = [&](pair<int, int>& a, pair<int, int>& c) -> bool {
if(a.first == c.first) return a.second > c.second;
else return a.first < c.first;
};
sort(notdup.begin(), notdup.end(), func);
这种排序方式确保了:
- 区间按起始位置有序排列
- 对于相同起始位置的区间,更长的区间会排在前面
2.4 去除被包含的子区间
在排序后的区间列表中,如果一个区间完全包含在它前面的某个区间内,那么这个区间就是冗余的,可以被移除:
cpp复制vector<bool> status(m, false);
int pre = 0;
for(int i = 1; i < m; i++) {
if(notdup[pre].first <= notdup[i].first && notdup[i].second <= notdup[pre].second) {
status[i] = true;
} else {
pre = i;
}
}
这个步骤减少了后续处理的区间数量,提高了算法效率。
3. 贪心算法实现
3.1 算法核心思想
贪心算法的基本思路是:
- 从第一个区间开始,选择能够覆盖当前起点并且延伸最远的区间
- 将当前起点更新为选中区间的右端点
- 重复上述过程直到覆盖整个花园
3.2 代码实现细节
cpp复制int ans = 1, change, j;
pre = 0;
for(int i = 1; i < m; i++) {
if(ret[pre].second < ret[i].first) {
return -1; // 出现断裂,无法覆盖
}
change = -1;
while(i < m && ret[pre].second <= ret[i].second && ret[pre].second >= ret[i].first) {
i++;
change = 1;
}
if(change > 0) i--;
pre = i;
ans++;
}
if(ret[pre].second < n) return -1;
return ans;
这段代码实现了贪心选择的过程:
- 检查区间之间是否有断裂(前一个区间的右端点小于后一个区间的左端点)
- 在当前可选的区间中,找到能够覆盖当前点并且延伸最远的区间
- 更新计数器并移动到下一个需要覆盖的位置
注意:这里的while循环用于跳过所有被当前区间包含或部分重叠但不提供更大覆盖的区间,直到找到一个能提供更大覆盖的区间。
4. 复杂度分析与优化
4.1 时间复杂度
让我们分析各个步骤的时间复杂度:
- 区间生成:O(n)
- 去重:O(m),m为初始区间数量
- 排序:O(m log m)
- 去除子区间:O(m)
- 贪心选择:O(m)
总体时间复杂度为O(n + m log m),其中m ≤ n+1。对于LeetCode的题目规模是完全可行的。
4.2 空间复杂度
主要的空间消耗在于存储各个区间:
- 原始区间列表:O(m)
- 去重后的列表:O(m)
- 最终保留的区间列表:O(m)
总体空间复杂度为O(m),即线性空间。
5. 边界条件与测试案例
5.1 常见边界情况
在实现这类问题时,需要特别注意以下边界条件:
- n=0的情况(花园长度为0)
- 所有ranges[i]=0的情况(没有可用的水龙头)
- 无法完全覆盖花园的情况
- 有多个水龙头位置相同的情况
- 水龙头覆盖范围超出花园边界的情况
5.2 测试案例示例
cpp复制// 测试案例1:正常情况
int n1 = 5;
vector<int> ranges1 = {3,4,1,1,0,0};
// 期望输出:1
// 测试案例2:需要多个水龙头
int n2 = 7;
vector<int> ranges2 = {1,2,1,0,2,1,0,1};
// 期望输出:3
// 测试案例3:无法完全覆盖
int n3 = 3;
vector<int> ranges3 = {0,0,0,0};
// 期望输出:-1
6. 算法优化与变种
6.1 更简洁的实现方式
上述实现经过了多个预处理步骤,实际上可以简化为更直接的贪心算法实现:
- 创建一个数组maxReach,其中maxReach[i]表示从位置i能到达的最远位置
- 遍历每个水龙头,更新它能覆盖的区间的maxReach值
- 使用标准的区间覆盖贪心算法选择最少区间
这种实现方式更加简洁,且时间复杂度相同。
6.2 类似问题扩展
这类区间覆盖问题有很多变种,例如:
- 选择不重叠区间的最大数量(会议室安排问题)
- 合并所有重叠区间
- 查找被最多区间覆盖的点
掌握这类问题的核心思想后,可以很容易地解决各种变种问题。
7. 实际应用与经验分享
7.1 调试技巧
在实现这类算法时,我通常会:
- 先处理简单的测试案例,确保基本功能正确
- 打印中间结果,特别是在预处理阶段
- 对于贪心算法,记录每次选择的过程,验证选择的合理性
7.2 常见错误
在解决这个问题时,容易犯的错误包括:
- 忘记处理区间边界(超出花园范围的情况)
- 排序规则不正确,导致贪心算法失效
- 没有正确处理完全相同的区间
- 在贪心选择时,没有正确更新当前位置
7.3 性能优化建议
对于大规模数据:
- 可以考虑更高效的去重方法
- 如果区间范围有限,可以使用计数排序代替快速排序
- 在预处理阶段尽可能减少不必要的操作
在实际编码面试中,清晰的思路和正确的算法比极致的优化更重要。建议先实现正确解,再考虑优化。
