十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

贪心算法解决区间选点问题:从藏匿的刺客到最少监控点覆盖

贪心算法解决区间选点问题:从藏匿的刺客到最少监控点覆盖 1. 问题引入从“藏匿的刺客”到区间覆盖的经典模型最近在整理蓝桥杯的算法训练题翻到了ALGO-986这道名为“藏匿的刺客”的题目。初看这个标题你可能会联想到一些策略游戏或者推理情节但在算法竞赛的语境下它其实是一个披着故事外衣的、非常经典的“区间选点”问题。这类问题在各大OJ平台和竞赛中出现的频率极高比如POJ上的“Radar Installation”雷达安装问题其核心思想几乎一模一样。如果你能彻底吃透这道题那么以后遇到任何关于“用最少的点覆盖所有区间”的变种你都能迅速抓住本质。题目大意通常是这样描述的在一条一维的直线上可以想象成一条时间线或者一条路有若干个刺客的藏匿区间。每个区间由左右端点[L, R]表示意味着刺客在这个区间内的任意位置都可能出现。现在你作为守卫每次可以在一个具体的坐标点上布置一个守卫。如果一个守卫布置在点x上那么所有包含点x的藏匿区间即满足L x R的区间都会被这个守卫发现或者说“覆盖”。你的目标是使用最少数量的守卫确保所有的藏匿区间都至少被一个守卫覆盖。这听起来像是一个资源最优配置问题。在现实中它的应用场景非常广泛比如用最少的监控摄像头覆盖所有需要监控的区域用最少的服务器节点处理所有用户的请求时间段或者用最少的测试点来验证代码所有可能的执行路径。理解并掌握其解法是算法能力从“会写代码”到“会建模、会优化”的关键一步。2. 贪心策略的核心思想与正确性证明面对“藏匿的刺客”或者“区间选点”问题一个高效的解法是贪心算法。贪心算法的精髓在于每一步都做出当前看起来最优的选择并期望通过这一系列局部最优的选择最终达到全局最优。对于这道题一个被广泛证明有效的贪心策略如下排序首先将所有藏匿区间按照右端点R从小到大进行排序。如果右端点相同则可以按左端点任意排序通常也按左端点升序。初始化设置一个变量guard_position来记录当前最后一个守卫布置的位置。初始时可以将其设置为一个非常小的数比如负无穷或者直接处理第一个区间。遍历与决策从左到右遍历排序后的区间。如果当前区间[L_i, R_i]的左端点L_i大于当前的guard_position说明当前这个守卫布置在guard_position无法覆盖这个新区间。此时我们必须在当前区间内布置一个新的守卫。为了能让这个新守卫“潜力”最大即尽可能覆盖后面更多的区间最贪心的做法就是将这个新守卫布置在当前区间的右端点R_i上。然后更新guard_position R_i并将守卫数量count加一。如果当前区间的左端点L_i小于等于当前的guard_position说明当前守卫已经能覆盖这个区间无需新增守卫直接跳过。这个策略为什么是有效的我们可以从“交换论证”或“贪心选择性质”的角度来理解其正确性。核心逻辑因为我们按照右端点排序所以当前遍历到的区间i是所有还未被覆盖的区间中右端点最靠左的一个。为了覆盖它我们必须在其区间内[L_i, R_i]选一个点。无论我们选这个区间内的哪个点比如中点或左端点这个点可能覆盖一些后面的区间。但是选择右端点R_i有一个无可比拟的优势它是最靠右的选择。对于任何后续的区间j只要它的左端点L_j R_i它就能被这个设在R_i的守卫覆盖。换句话说选择右端点使得这个守卫“向右覆盖”的能力达到了在当前区间内的最大值。这确保了在覆盖当前区间的前提下为覆盖后续区间留下了最大的可能性即R_i是当前区间内能覆盖到最靠右的后续区间的点。我们可以用反证法简单思考假设我们不把守卫放在当前区间i的右端点R_i而是放在某个更靠左的点PP R_i。那么对于任何一个后续区间j如果其左端点L_j满足P L_j R_i这个区间就无法被P点覆盖需要额外布置守卫。而如果当初把守卫放在R_i这个区间j就能被覆盖。因此放在R_i不会比放在任何P R_i更差只可能更好覆盖更多。所以每一步选择右端点都是局部最优的并且这个局部最优选择能导向全局最优解。3. 算法实现详解与C代码模板理解了贪心策略实现起来就非常清晰了。下面我将给出一个详细的C实现并逐行解释关键点。这里假设输入格式是第一行一个整数n表示区间数接下来n行每行两个整数L和R。#include iostream #include vector #include algorithm using namespace std; int main() { int n; cin n; vectorpairint, int intervals(n); // 使用pair存储区间first是左端点Lsecond是右端点R // 1. 读入数据 for (int i 0; i n; i) { cin intervals[i].first intervals[i].second; } // 2. 关键步骤按照区间右端点进行升序排序 sort(intervals.begin(), intervals.end(), [](const pairint, int a, const pairint, int b) { // 优先按右端点排序右端点相同则按左端点排序左端点顺序不影响核心逻辑但更规范 if (a.second b.second) { return a.first b.first; } return a.second b.second; }); // 3. 贪心遍历 int count 0; // 守卫数量 int guard_pos -1e9; // 当前最后一个守卫的位置初始化为一个非常小的数 for (const auto interval : intervals) { int L interval.first; int R interval.second; // 如果当前守卫无法覆盖此区间即区间左端点 当前守卫位置 if (L guard_pos) { count; // 需要新增一个守卫 guard_pos R; // 将这个新守卫放置在当前区间的右端点 } // 否则 (L guard_pos)当前守卫已能覆盖无需操作 } // 4. 输出结果 cout count endl; return 0; }代码关键点解析与注意事项数据结构选择使用vectorpairint, int存储区间非常方便pair的first和second天然对应左、右端点。也可以定义结构体但pair更简洁。排序Lambda表达式sort函数的第三个参数是一个比较函数这里用了Lambda表达式。它定义了排序规则首先比较右端点 (a.second b.second)如果右端点相等再比较左端点 (a.first b.first)。确保右端点升序是算法正确的基石。guard_pos的初始化初始值设置为一个极小的数如-1e9可以确保第一个区间一定会触发L guard_pos的条件从而布置第一个守卫。也可以将guard_pos初始化为第一个区间的右端点减一或者直接在循环外处理第一个区间。上述写法逻辑统一更简洁。条件判断L guard_pos这是核心逻辑。注意是大于而不是大于等于。因为如果L guard_pos意味着当前守卫正好位于区间的左边界上该区间[guard_pos, R]仍然被覆盖。只有当新区间完全在当前守卫的右侧时才需要新守卫。更新guard_pos R一旦决定新增守卫就将其位置更新为当前区间的右端点。这个位置将用于判断后续区间是否需要新的守卫。注意在实际的蓝桥杯竞赛中题目对输入输出的格式、数据范围可能有特定要求。例如端点可能为浮点数这时需要将int改为double并注意浮点数比较的精度问题或者区间是闭区间/开区间的区别。上述代码是解决标准整数闭区间问题的模板需要根据具体题目描述进行调整。4. 从理论到实战模拟推演与边界情况分析为了加深理解我们用一个具体的例子来模拟整个算法的执行过程。假设有5个藏匿区间[1, 3],[2, 5],[4, 6],[5, 7],[6, 9]。排序按照右端点排序后序列为[1,3],[2,5],[4,6],[5,7],[6,9]。初始化guard_pos -∞,count 0。遍历区间[1,3]:L1 guard_pos(-∞)成立新增守卫。count1,guard_pos 3。区间[2,5]:L2 guard_pos(3)当前守卫在3覆盖了[2,5]跳过。区间[4,6]:L4 guard_pos(3)成立新增守卫。count2,guard_pos 6。区间[5,7]:L5 guard_pos(6)被覆盖跳过。区间[6,9]:L6 guard_pos(6)被覆盖跳过。结果count 2。我们只需要在位置3和位置6布置两个守卫即可覆盖所有区间。你可以手动验证位置3覆盖了[1,3]和[2,5]位置6覆盖了[4,6],[5,7],[6,9]。边界情况与易错点分析单点区间如果区间是[a, a]的形式即左右端点相等算法依然有效。排序时它会被放在右端点等于a的位置。当遍历到它时如果a guard_pos则会在a点布置守卫正好覆盖它。区间包含如果一个区间完全包含另一个区间例如[1,10]和[3,4]。排序后可能是[3,4],[1,10]。算法会在4处布置一个守卫覆盖[3,4]当遍历到[1,10]时因为1 4所以它也被覆盖了。结果是1个守卫这是正确的。如果排序后是[1,10],[3,4]算法会在10处布置守卫覆盖[1,10]遍历到[3,4]时3 10也被覆盖。结果也是1个守卫。排序依据是右端点所以大区间不一定在前面但这不影响最终结果。区间不相交如果所有区间都不重叠例如[1,2],[3,4],[5,6]。算法会在2, 4, 6分别布置守卫数量等于区间数这是最优解。输入区间数为0这是一个重要的边界。如果n0我们的代码中guard_pos初始值为负无穷循环不会执行count保持为0输出0。这是符合逻辑的没有刺客自然不需要守卫。在竞赛中一定要考虑这种极端输入。大数据量算法的时间复杂度是O(n log n)主要来自排序。后续的贪心遍历是O(n)。对于n高达10^5甚至更大的情况这个复杂度是完全可接受的。空间复杂度是O(n)用于存储区间。5. 举一反三同类问题变种与解题思路迁移掌握了“藏匿的刺客”的基本解法你就可以尝试解决一系列变种问题。这些问题的核心模型都是“区间覆盖”但目标和约束稍有不同需要你灵活调整贪心策略。变种1区间分组问题最少分组问题问题描述给定若干活动区间每个活动需要占用一个资源如教室。有冲突的活动即区间重叠的活动不能在同一资源上进行。问至少需要多少个资源才能安排所有活动代表题目AcWing 906. 区间分组 LeetCode 253. 会议室 II。思路迁移这不再是选点而是将区间分成尽可能少的组使得每组内的区间两两不重叠。一个经典的贪心解法是将所有区间按左端点排序。使用一个最小堆优先队列维护当前所有组的最大右端点即该组最后一个活动的结束时间。遍历每个区间如果当前区间的左端点L大于等于堆顶所有组中结束最早的组的结束时间说明可以接在该组后面更新该组的结束时间为当前区间的右端点R即弹出堆顶压入R。否则说明当前区间与所有现有组都冲突需要新建一个组将R压入堆中。最终堆的大小就是最少所需组数。 这与“藏匿的刺客”不同因为目标从“选点覆盖”变成了“分组隔离”。排序对象和贪心判断标准都发生了变化。变种2最大不相交区间数量问题问题描述从给定的多个区间中选出尽可能多的区间使得这些区间彼此之间没有重叠部分即使是端点重叠也算重叠视题目而定。代表题目AcWing 905. 区间选点其实和本题很接近但目标是选区间而非点 LeetCode 435. 无重叠区间。思路迁移这个问题可以看作是“藏匿的刺客”的“对偶”问题。我们可以用类似的“按右端点排序”的贪心策略来解决按右端点排序。依次选择右端点最小且不与上一个已选区间重叠的区间。为什么可行每次选择右端点最小的不重叠区间相当于为后续选择留出了尽可能大的空间因为结束得早。这本质上和“用最少的点覆盖所有区间”是相通的最大不相交区间数就等于覆盖所有区间所需的最少点数在端点可共享的情况下。这是一个非常重要的结论。变种3带权区间调度问题问题描述每个区间有一个权重价值要选出一些互不重叠的区间使得总权重最大。思路迁移这个问题无法用简单的贪心解决通常需要动态规划DP。定义dp[i]为考虑前i个区间按右端点排序能获得的最大权重。状态转移时对于区间i有两种选择不选它则dp[i] dp[i-1]选它则需要找到最后一个右端点小于L_i的区间j则dp[i] dp[j] weight[i]。可以用二分查找来快速找到这个j。这比基础的贪心模型复杂得多。通过对比这些变种你会发现“按右端点排序”的贪心策略是解决许多区间问题的有力武器但其具体应用形式是选点、分组还是选区间需要根据问题目标来调整。核心在于理解“选择最早结束的”这一贪心选择性质它往往能为后续操作留出最大余地。6. 算法竞赛中的实战技巧与调试心得在像蓝桥杯这样的竞赛中仅仅写出正确的算法是不够的还需要考虑编码效率、调试技巧和稳定性。结合“藏匿的刺客”这类题目我分享几个实战心得。技巧一输入输出优化对于C选手当n很大比如10^6时关闭流同步和解除cin/cout与stdio的绑定可以显著提升速度。ios::sync_with_stdio(false); cin.tie(0); cout.tie(0);在竞赛中如果时间紧张且输入简单甚至可以直接用scanf/printf它们通常比cin/cout未优化更快。技巧二使用清晰的数据结构与变量名虽然为了速度有时会写得很紧凑但在思路清晰的前提下好的命名能极大减少调试时间。例如// 清晰的命名 vectorpairint, int hiding_ranges; int last_guard_pos; int min_guards_required; // 对比模糊的命名 vectorpairint, int v; int pos; int ans;在时间允许的情况下尽量选择前者。尤其是在处理复杂逻辑时清晰的变量名就是最好的注释。技巧三构造极端测试数据自己测试时不要只用手算的简单例子。尝试构造以下数据n0和n1。所有区间都重叠[1,100], [2,99], [3,98]答案应为1。所有区间都不重叠[1,2], [3,4], [5,6]答案应为3。一个大区间包含所有小区间[1,100], [10,20], [30,40], [50,60]。端点值非常大或非常小考虑使用int还是long long。随机生成大量数据用暴力算法O(n^2)对小规模n验证结果确保贪心逻辑正确。技巧四理解排序的稳定性在我们的代码中排序比较函数写的是if (a.second b.second) { return a.first b.first; // 右端点相同时按左端点升序 }这个“左端点升序”在本题中不是必须的因为贪心逻辑只依赖右端点。但这是一个好习惯。在某些变种问题中当右端点相同时左端点的顺序可能会影响遍历时的逻辑判断例如在处理“点覆盖区间”时如果先遇到左端点更大的区间可能会误判。保持一个确定的、一致的排序规则可以让程序行为更可预测避免因数据顺序不同而产生的隐蔽bug。技巧五画图辅助对于区间问题在纸上或白板上画出数轴标出每个区间然后手动模拟贪心算法的执行过程是理解算法和调试代码最直观的方式。当你的代码输出与预期不符时画图能帮你迅速定位是排序错了、条件判断错了还是更新逻辑错了。最后关于“藏匿的刺客”这道题虽然它本质上是区间选点问题但掌握它意味着你掌握了贪心算法中一类非常重要的模型。在竞赛中遇到新题如果你能识别出它本质上是“用最少的点覆盖所有区间”那么你就已经成功了一大半。剩下的就是根据具体的输入输出格式和数据范围将模板代码稍作调整。这种将实际问题抽象成经典模型的能力是算法竞赛训练的核心目标之一。多练习类似的题目总结规律你会在遇到新题时越来越得心应手。
返回列表