今天,我们通过一道非常经典的"幕布覆盖"问题,彻底剖析双指针滑动的两种思维:"定左探右"以及"定右缩左"的标准滑动骨架,并给出两套满分模板。
一、 题目描述
【题目大意】 在一张网格图中,每一列有且仅有一个格子被小蜗涂了色,现在给你每一列被涂色的格子的高度 ai。 现在有一块高度为 h 的长方形幕布(挂在高度 x 的时候能盖住的高度区间为 [x−h+1,x]),可以在列方向延伸(宽度可以是任意值)。 问你固定高度后,一块布最多可以覆盖住连续的多少列的格子?
【输入格式】 第一行给出两个数,代表列数 n 和高度 h。 接下来一行给出每一列被涂色的格子高度 a[i]。
【样例输入】
7 4
1 4 5 3 6 1 4
【样例输出】
4
【数据范围】 1≤n≤100000,1≤h,a[i]≤10^6。
二、 题目分析与核心不等式
这道题的物理外衣是"幕布覆盖",我们需要翻译成纯粹的数学语言。 幕布挂在高度x时,覆盖的最低点是x−h+1。 这意味着,在幕布能盖住的任意一段连续区间内,最高点与最低点的差值,最大只能是h−1。 换句话说,对于区间[l,r],合法的条件是:
max(a[l...r])−min(a[l...r])<h
(注:题目规定高度差加1不大于幕布高度,即极差≤h−1,等价于极差<h)。
题目要求我们找到满足合法条件的最长连续区间长度。为了实现O(N)的降维打击,我们需要结合滑动窗口(双指针)与单调队列。
三、 解法一:定左探右
这是很多初学者的第一直觉:固定左端点i,让右端点侦察兵j往未来探索。 易错点: 这种写法极易导致"试探未来污染队列"和"漏算末尾元素"。 方案**:** 我们必须利用C++ 的||短路特性,并在循环内外打上极其严密的逻辑补丁。
核心算法设计:
-
每次i更新时(新的一天),先把队列里<i的过期老将清理掉。
-
侦察兵j不断往右试探。循环条件必须是极其精妙的:
j<=n&&(j<i||a[maq]-a[miq]< h)。-
j<=n保证最后一个元素有机会结账。 -
j<i是空队列保护机制:当队列因左指针推进被清空时,利用||短路特性直接放行,防止后续求极差时越界。
-
-
先结账,后入队 :每次进入循环,先结算
ma=max(ma, j-i+1),确认合法后再执行入队操作,彻底杜绝"假账"。 -
黄金刹车片 :结账后j会自增,若j>n必须立刻
break,防止后续入队越界。
解法一完整代码:
cpp
//覆盖
//求出每个区间最大值和最小值
//最大值和最小值之差+1不大于幕布高度即可
#include <iostream>
using namespace std;
int n,h;
int a[1000010];//每一列被涂色的格子的高度
int miq[1000010];//记录最小高度的队列
int maq[1000010];//记录最大高度的队列
int mif=1,mir=0;//最小高度队首队尾
int maf=1,mar=0;//最大高度队首队尾
int ma;//最多可以覆盖多少列格子
int main(){
ios::sync_with_stdio(false);
cin.tie(0);
cin>>n>>h;
for(int i=1;i<=n;i++) cin>>a[i];
int j=0;//幕布右端
for(int i=1;i<=n;i++){//遍历幕布左端
//当队列非空且队首过期 就把队首清除
while(mif<=mir&&miq[mif]<i) mif++;
while(maf<=mar&&maq[maf]<i) maf++;
//当幕布右端不超过边界(幕布右端未超过幕布左端)且最高点-最低点差值要小于h
//否则幕布盖不过来
while(j<=n&&(j<i||a[maq[maf]]-a[miq[mif]]<h)){
//每一轮开始时统计最大格子列数
ma=max(ma,j-i+1);
j++;
//防止最后一轮j越界然后访问a[j]
if(j>n) break;
//当最小高度队列非空且幕布右端高度小于等于队尾时
//队尾出队
while(mif<=mir&&a[j]<=a[miq[mir]]){
mir--;
}
//j入队
miq[++mir]=j;
//当最大高度队列非空且幕布右端高度大于等于队尾时
//队尾出队
while(maf<=mar&&a[j]>=a[maq[mar]]){
mar--;
}
//j入队
maq[++mar]=j;
}
}
cout<<ma;
return 0;
}
四、 解法二:定右缩左
相比于第一种防御性编程,竞赛中更推崇的标程写法是转换视角:"只处理当下,绝不试探未来"。
核心算法设计:
-
让右边界r像推土机一样每天无脑往前走,新元素无条件入队。
-
每次入队后,回头查账。如果发现极差≥h(超标了),我们就强迫左边界l往前收缩。
-
收缩时,把下标<l的过期老将踢出。
-
终极防爆边界 :循环收缩的前提加上
l<r。当l追上r时(区间仅剩一个元素),极差为0,绝对合法。这一步强行刹车,保证队列永远不为空,彻底斩断越界风险。 -
存活出审查循环的 [l,r] 绝对合法,每日结算一次。
解法二完整代码:
cpp
//覆盖 第二种做法
//固定右端点,移动左端点
#include <iostream>
using namespace std;
int n,h;
int a[1000010];//每一列被涂色的格子的高度
int miq[1000010];//记录最小高度的队列
int maq[1000010];//记录最大高度的队列
int mif=1,mir=0;//最小高度队首队尾
int maf=1,mar=0;//最大高度队首队尾
int ma;//最多可以覆盖多少列格子
int main(){
ios::sync_with_stdio(false);
cin.tie(0);
cin>>n>>h;
for(int i=1;i<=n;i++) cin>>a[i];
int l=1;//左端
for(int r=1;r<=n;r++){//遍历右端
//当最小高度队列非空且右端点高度小于等于最小高度队尾时
//队尾出队
while(mif<=mir&&a[r]<=a[miq[mir]]){
mir--;
}
//右端点入队
miq[++mir]=r;
//当最大高度队列非空且右端点高度大于等于最大高度队尾时
//队尾出队
while(maf<=mar&&a[r]>=a[maq[mar]]){
mar--;
}
//右端点入队
maq[++mar]=r;
//l不超过边界且判断接下来检查最大高度-最小高度如果大于等于h
//代表幕布不能完全盖住,就把幕布左端往右锁
while(l<r&&a[maq[maf]]-a[miq[mif]]>=h){
//如果不小于h就把l往右移动
l++;
//当队列非空且队首过期时
//就清理过期的
while(mif<=mir&&miq[mif]<l) mif++;
while(maf<=mar&&maq[maf]<l) maf++;
}
//接下来肯定是符合要求的
ma=max(ma,r-l+1);
}
cout<<ma;
return 0;
}
五、 时空复杂度与易错点总结
-
时间复杂度: O(N)。两个解法中,每个元素最多入队一次、出队一次。均摊下来是严格的线性时间。
-
空间复杂度: O(N)。需要开辟原数组和两个单调队列。
避坑指南:
-
队列里永远只存下标。 绝不存具体的值,否则清理过期元素时无法对比位置。
-
警惕
-1和+1: 数组如果是1-indexed,左边界l必须从 1 开始。如果从0开始,答案会永远比正确结果多 1。 -
&&的短路求值: 判空条件(如mif<=mir)必须放在逻辑与&&的最前面,利用短路特性充当安全带。