资讯动态

双指针与滑动窗口:固定长度线段最大覆盖点数的经典解法

发布时间:2026/10/13 13:00:50 来源:尧图企业网站定制
1. 题目定位与核心思维模型1.1 这道题到底在考什么“8593 最大覆盖问题”是算法训练里很经典的一道题编号来自常见的在线评测系统题库。我第一次看到这个题名时第一反应是“这又是个什么花哨的贪心题”等仔细读完题面才发现它考查的东西非常朴素在一条数轴上给出若干个点选一个固定长度的线段让它覆盖尽可能多的点。说白了它就是“给定一个区间集合求固定长度窗口能覆盖的最多目标数”。这个模型在很多场景里都出现过基站选址覆盖最多用户、监控摄像头覆盖最多路口、时间窗口内安排最多任务甚至物流配送中厢式货车的站点停靠规划本质都是同一套思维。题目的典型描述一般是输入一个整数n表示有n个点的坐标再输入一个整数m通常代表线段长度或覆盖半径求长度为m或直径m的区间最多能覆盖多少个给定的点。坐标可能给出排序的也可能不给数据规模通常在几千到几十万不等。如果大家去查题会发现这道题在很多OJ上都有收录经典程度和我当年刷《挑战程序设计竞赛》里“坐标离散化滑动窗口”那一章不相上下。1.2 为什么第一反应不该是“暴力枚举”很多新手拿到这道题第一反应就是把所有可能的线段位置都试一遍。比如坐标从-10000到10000线段长度是L那就从最左端点开始每移动一个单位跑一次统计把覆盖点数记录下来最后取最大值。这种做法思路完全正确但复杂度是O(坐标范围×n)一旦坐标范围是10^9这个量级或者n到10^5就会直接超时。我当年第一次做这道题时就踩了这个坑拿了一个坐标范围±1000的小数据过了样例一提交直接TLE。那时候才意识到算法竞赛里没有“想法对就能过”这回事数据规模决定你必须把复杂度压到O(n·log n)甚至O(n)。这里的核心思维模型是三句话排序可以把无序的散点变成有序的序列这是后续一切操作的前提。双指针滑动窗口可以在单次遍历中维护“当前线段覆盖范围”这一状态不需要反复从头统计。线段的最优位置一定可以“卡在某个点上”——也就是说最优解里线段的左端点或右端点一定与某个给定点重合。这个性质是很多贪心证明的基础也是我们敢用双指针扫的底气。2. 算法推导与细节拆解2.1 从暴力到双指针的完整推导过程假设输入是n个点的坐标数组a已经按从小到大排好序线段长度为L。先想一个最朴素的优化版暴力枚举每个点a[i]作为线段左端点碰到的那个点然后向后找最大的j使得a[j] - a[i] L那么j - i 1就是覆盖点数的候选值。这个做法比枚举坐标轴上的每个位置高效得多因为候选线段位置从“无穷多个”压缩到了“最多n个”复杂度O(n²)。能过n1000的数据但过不了n10^5。双指针的做法本质上就是把这个“对于每个i找最大的j”的过程做了去重。我们维护两个指针i和j都从0开始j一直往后走走到a[j] - a[i] L时停下那么当前窗口内的点数就是j - i。接着i往后挪一个j继续从当前位置往后走。因为i变大之后a[i]变大原本a[j] - a[i] L的情况在i增大后可能重新变成L所以j不需要回退只往前进就行。这里最关键的一点是j只前进不后退。每个指针最多移动n次总复杂度就是O(n)。排序一遍是O(n·log n)整体通过。2.2 为什么要以点对齐作为窗口的基准很多初学者会问线段明明可以放在任意位置为什么只枚举“左端点与某个点重合”的情况答案是如果线段没有与任何给定点的左边界贴合那把它向左或向右平移直到恰好卡住一个点时覆盖的点数只可能增加或不变不可能减少。这个性质用生活化的说法就是你有一块固定长度的木板要盖住地上尽可能多的硬币木板的最佳位置一定有一个边界是顶在某枚硬币上的。如果两边都没有顶到硬币那就说明木板两个方向都还有富余空隙往任意方向平移一下覆盖个数不会变少直到顶到某一枚硬币为止。所以枚举“左端点贴点”这一策略不会漏掉最优解。基于这个原理我才敢放心使用“以每个点为左端点基准”的枚举方式。在实际代码里我习惯让左指针指在窗口的左端点对应的点上右指针向右扩展直到窗口长度超出L然后取max。3. 代码实现与参数细节3.1 一个可以直接交的参考实现我先放一段C的核心代码这是我从刷题到现在一直觉得最好写、最好改的版本再逐行解释为什么这么写。#include bits/stdc.h using namespace std; int main() { int n, L; cin n L; vectorint a(n); for (int i 0; i n; i) cin a[i]; sort(a.begin(), a.end()); int ans 0; int j 0; for (int i 0; i n; i) { while (j n a[j] - a[i] L) { j; } ans max(ans, j - i); } cout ans endl; return 0; }这段代码只有两个关键点外层for循环的i是窗口左端点对应一次枚举。内层while负责把右指针j推向“第一个导致超过L的位置”此时区间[i, j)内的所有点都满足条件点数是j - i。注意while条件里是a[j] - a[i] L等于号一定要带。如果线段长度是L两个点坐标之差恰好等于L时这两个点是可以同时被一条线段覆盖的。很多人在这个地方漏掉等于号样例数据只要设计得巧妙一点就能卡掉一片。3.2 Python版的写法与边界细节如果是Python刷题代码更短但有几个性能上的坑。先说写法n, L map(int, input().split()) a list(map(int, input().split())) a.sort() j 0 ans 0 for i in range(n): while j n and a[j] - a[i] L: j 1 ans max(ans, j - i) print(ans)Python版本在数据量达到10^6时while循环的常数会比C大不少。我在实际测试中n5×10^5、随机整数坐标时Python单次运行大约在0.8秒到1.2秒之间勉强能过一些OJ的2秒时限但如果平台是TLE比较紧的建议改用PyPy提交。还有一个细节如果输入坐标在一行里给完用sys.stdin.buffer.read()一次性读取再切分比input().split()快很多这个技巧在大数据量下能省将近一半时间。3.3 排序方式与数据范围的影响这道题假设坐标是整数但线段长度L可能是浮点数。如果L是浮点比如2.5那么判断条件就要把a[j] - a[i] L写成a[j] - a[i] L eps其中eps取1e-9左右。为什么因为浮点数减法会有精度误差两个整数坐标之差恰好等于2.5比如0.0和2.5时在计算机里可能被表示为2.4999999999999996导致误判为大于L覆盖点数少算一个。这个坑我在某次练习赛里实际遇到过排查了很久才意识到是浮点精度问题。如果坐标范围是负数排序后依然从小到大排列窗口判断是基于差值的绝对值所以完全不需要额外处理。比如点在-5和3L8两个点坐标差为8可以覆盖判断逻辑不变。4. 实操过程与核心环节实现4.1 从读题到提交的全流程复盘我拿一组真实数据走一遍完整流程方便大家对比自己的理解。输入5 3 -1 0 2 5 7排序后坐标是-1 0 2 5 7线段长度L3。i0a[0]-1j从0开始a[0]-(-1)03j1a[1]-(-1)13j2a[2]-(-1)33j3a[3]-(-1)63停。此时覆盖点数3-03对应点[-1, 0, 2]ans3。i1a[1]0j当前停留在3a[3]-053不动。覆盖点数3-12ans保持3。i2a[2]2a[3]-233j4a[4]-253停。覆盖点数4-22ans保持3。i3a[3]5a[4]-523j5。覆盖点数5-32ans保持3。i4a[4]7j5越界覆盖点数5-41。最终输出3。也就是说长度为3的线段可以覆盖[-1, 0, 2]三个点这是最优解。把线段放在[-1, 2]这个区间内三点全中。4.2 这个题在OJ上的变体与陷阱这套题在实际的OJ上存在好几个变体。最常遇到的三种输入不保证有序必须自己排序。这个最简单sort一下就行。坐标有重复。代码不需要任何改动因为重复点之间距离为0窗口会自然把它们包含进去。我遇到过一次测试数据放了几百个相同坐标的点若是有人用“去重后再统计”的思路去写反而会出错。线段长度定义为直径而不是半径。有些题说“长度为L的线段”有的说“直径为L的圆覆盖最多的点”后者等价于线段的长度就是L。没有本质区别但读题时要注意是“半径”还是“直径”半径的话判断条件要改成2×L。我特别提醒一个容易被忽略的点如果这题要求的是“覆盖区间端点”而非“覆盖点”那就变成另一类问题——区间选点与最多重叠区间数量思路就完全变了。大家在做题前一定先确认题面描述的到底是点在数轴上还是区间在数轴上。8593这道题从编号和经典考察点来看是“点覆盖”不是“区间重叠”但很多同类题目标题相似、内容却不同谨慎为上。4.3 参数选择与复杂度权衡说到数据规模我见过这题被放到不同OJ上n的范围有1e3、1e4、1e5还有极端的1e6。这里给一个实际的复杂度对照n规模暴力O(n²)排序双指针O(n log n)1e3可过轻松1e4大概率TLE轻松1e5必TLE轻松1e6不可能Python用PyPy有风险C轻松从这个表就能看出双指针解法不是“锦上添花”而是“非它不可”。特别是n到1e6时排序本身就要花一部分时间如果还在内部做O(n²)的二次遍历数据一上来直接跑不动。5. 常见问题与排查技巧实录5.1 三个必踩的坑和对应的排查办法坑一漏写等于号。这个非常隐蔽尤其当测试数据里没有“差恰好等于L”的用例时本地测试全绿一交上去WA。排查方法很简单自己构造一组刚好差为L的坐标比如0和5L5正确答案应该是2。如果输出1恭喜你找到了常见的“差一错误”。坑二j没有在循环外定义导致每次i循环都从头扫。很多人把j定义在for循环里每轮j都初始化为i这样就退化成了O(n²)。最直接的排查方式在关键位置打印j的起始值确认它是否保持在上一轮的位置。坑三坐标差值用绝对值。因为数组有序a[j] a[i]j i恒成立所以a[j] - a[i]本身就是非负数不需要fabs。但如果代码里写了fabs并且默认坐标都是正数负坐标一进来就会出问题——fabs(-5 - (-1)) 4看着没问题但如果你写的是abs(a[j] - a[i])结果其实也正确导致这个问题常常被掩盖。真正的风险在浮点版本里如果用了fabs一旦坐标或L是浮点等号判断就会产生误差。5.2 调试技巧与心法这类题调试起来最重要的一个技巧是构造“最简反例”。当你WA之后别急着看数据先手动构造几组小数据所有点都挤在一个长度为L的范围内答案必须是n。所有点两两距离都大于L答案必须是1。只有两个点距离恰好等于L答案必须是2。坐标为负数和正数交错验证排序稳定性。这四组数据能覆盖95%以上的逻辑错误。我每次给这题做Code Review时都会第一时间跑这四组基本上一跑就知道代码有没有问题。5.3 关于复杂度的一个细节有人可能觉得排序是多余的题目如果保证输入有序就可以直接双指针。这里有个权衡排序多付出O(n log n)的复杂度但让代码的鲁棒性强很多不用依赖题目有没有排序的说明。在实际比赛中我从来默认不信任输入顺序每条输入都先sort一遍这个习惯帮我避免了很多“漏看条件”导致的失误。如果n1e6排序大约是0.2秒到0.4秒C整体依然很快不影响通过。5.4 这类题的排查速查表现象可能原因解决方案本地样例通过但提交WA漏了等于号或浮点精度构造差值为L的边界用例检查大数据TLEj没有保持全局推进确认j在for外部定义负坐标结果不对使用了不适当的绝对值逻辑确认有序后的差值直接运算浮点L结果偏小浮点精度截断加上1e-9的eps容差输出为0忘记排序或数组越界检查sort和j的越界保护6. 从最大覆盖到“覆盖系列”的迁移思维6.1 同一套思想的三个变种题掌握8593之后我强烈建议大家把覆盖系列一次学透因为这类题在算法竞赛里出镜率极高而且变种都围绕同样的“排序扫描线/双指针”核心。最少点覆盖最多区间给定若干区间问最少选几个点使每个区间至少包含一个点。解法是贪心按右端点排序每次选当前区间右端点。这和最大覆盖点是“对偶问题”思维方式可以互相印证。最大重叠区间数给定一堆区间问同一时刻最多有几个区间重叠。解法是把所有端点排序左端点1、右端点-1扫描过程中取最大值。这也是“扫描线”的入门题型。最小长度覆盖所有点给定所有点求能覆盖全部点的最短线段长度。直接用max(a)-min(a)即可。这三类题都在考一个共同能力如何把几何直觉转化成序列上的线性扫描。8593的就是最基础的“固定长度最大覆盖”把这个问题吃透其他变种上手会快很多。6.2 实际业务场景中的等价问题这一类问题不只是竞赛题工作里也经常碰到。我做物联网相关项目时遇到过一个问题沿一条公路部署信号基站基站覆盖半径固定为R需要在给定的车辆轨迹点集合中选择基站位置让单个基站覆盖尽可能多的轨迹点——这就是“8593”的具象化。很多云厂商的CDN节点规划也是这个模型在一维线路上给出用户分布选择边缘节点的覆盖范围让单一节点覆盖最多用户。甚至外卖平台的配送站选址在同一条主干道上的场景下也可以抽象成“固定配送范围覆盖最多的商家”。所以这道题的价值不仅在刷题更在于训练一种思维把现实问题拆成“一维”“固定长度”“最多覆盖”三个关键词然后意识到它已经被经典算法解决过无数遍了。这种能力才是刷题最终要内化的东西。我个人在反复写这题的几年里最大的体会是双指针这个技巧真正的门槛不在于理解它的逻辑而在于“信任它不会漏解”。第一次接触时总担心j不回退会错过某种组合直到自己画了几十组数据、手推了几轮之后才彻底放心。如果你现在也在学这个建议别光看代码花半小时在纸上推一遍i走一步、j接着走的轨迹把所有覆盖情况罗列出来看看是不是真的没有遗漏。通了这一关滑动窗口系列就基本进门了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑