第五章:双指针与区间扫描

枚举全部数对或全部连续区间常常需要平方时间。如果一次比较能排除一整批候选,就不必逐个访问它们。双指针利用有序性或窗口约束,让左右边界只向一个方向移动;本章重点说明每次移动舍弃了什么,以及为什么不会漏掉答案。
阅读前需要掌握排序、区间端点和频次数组。同向双指针可以维护连续区间,但指针如何移动必须由题目的具体条件推出;固定长度区间的最值维护见第九章介绍的单调队列。
1. 首尾双指针
简易题面:将全部纪念品分组,每组至多两件且价格和不超过 ,求最少组数。
数据范围:,,。
直接枚举所有分组,需要尝试大量配对。先考虑最贵的纪念品:它必须被安排,而能与它同组的物品受剩余容量限制最多。把价格升序排列,令最便宜、最贵的剩余物品分别为 。
若两者价格和仍超过上限, 与其他物品也不可能同组,只能单独安排。若两者能够同组,则存在一个最优方案让它们配对:原方案中若 与 配对、 与 配对,交换为 、 后,前一组合法,后一组满足 。若其中一件原本单独成组,交换后也不会增加组数。因此可以固定这一选择,继续处理剩余物品。
例如上限为 ,价格为 ,先取 ,再取 ,共两组。这个论证依赖每组至多两件;允许三件时,不能直接沿用。
落实到本题,每轮都处理当前最贵的一件,并增加一组;若最便宜的一件可以与它配对,就一起取走。以下 已升序排列, 为单组上限;本题单价至多为 ,两件之和至多为 ,可用 。
int l = 1, r = n, ans = 0;
while (l <= r)
{
if (l < r && a[l] + a[r] <= w)
{
++l;
}
--r;
++ans;
}
只剩一件时也必须增加一组,因此条件使用 ;但不能把这件物品与自己配对,所以相加前另判 。排序与扫描合计为 ,扫描额外空间为 。
2. 同向双指针
2.1 快慢指针去重
需要升序保留每个不同值时,先排序,再让读取位置 扫描全部输入,让写入位置 记录已保留的长度。全局数组 容量覆盖 ,以下 初始为零。
sort(a + 1, a + n + 1);
int m = 0;
for (int i = 1; i <= n; ++i)
{
if (m == 0 || a[i] != a[m])
{
a[++m] = a[i];
}
}
扫描原序列, 指向已经保留的最后一个元素。
处理完位置 后,前 个元素恰好是原来前 个元素的不同值,且依然有序。,所以写入位置始终不超过已读位置。条件中的 先处理“还没有保留元素”,此时不应该把 当成有效值比较。
2.2 差值数对
例题:差值数对。
简易题面:给定 个非负整数和正整数 ,统计满足 的有序位置对 的数量。相同数值出现在不同位置时分别计数。
数据范围:,,。
枚举两个位置需要 时间。排序后,依次固定较小值 ,需要统计较大值 出现多少次。用 指向第一个不小于目标的位置, 指向第一个大于目标的位置,则相等值占据半开区间 ,贡献为 。
随着 增大,目标 不会减小。已经小于旧目标的位置也一定小于新目标,因此两个边界都不必回退。数组已经有序时,两个边界共移动至多 次。
以下 为全局整数数组,容量至少为 。目标值至多为 ,可用 ;数对数量可能超过 32 位整数范围,答案使用 。
sort(a + 1, a + n + 1);
int l = 1, r = 1;
long long ans = 0;
for (int i = 1; i <= n; ++i)
{
int x = a[i] + C;
while (l <= n && a[l] < x)
{
++l;
}
r = max(r, l);
while (r <= n && a[r] == x)
{
++r;
}
ans += r - l;
}
例如序列 ,。两个值为 的位置各找到两个值为 的位置,贡献 ;两个值为 的位置各找到一个值为 的位置,贡献 ,总答案为 。重复值不能去重,否则会丢失位置对。
排序需要 时间,扫描需要 时间。 保证目标严格大于当前值,不会把同一位置与自身配对。
2.3 维护最短覆盖区间
例题:逛画展。
简易题面:寻找包含全部 位画家作品的最短连续区间;并列时输出左端点最小的一组。
数据范围:,,,保证有解。
枚举左右端点,再逐幅检查画家是否齐全,会反复统计同一段作品。固定右端点时,可以复用上一个窗口的频次;左端只要还有同一画家的其他作品,就能删去一幅而不失去覆盖。
全局整数数组 分别保存作者和窗口频次,容量分别为 、, 初始为零。 表示当前种类数;频次从零变为一时才增加它。以下 初始为 , 保存答案端点。
int l = 1, kind = 0, ans = n + 1;
for (int r = 1; r <= n; ++r)
{
if (++cnt[a[r]] == 1)
{
++kind;
}
while (cnt[a[l]] > 1)
{
--cnt[a[l]];
++l;
}
if (kind == m && r - l + 1 < ans)
{
ans = r - l + 1;
x = l;
y = r;
}
}
例如作者序列 ,读到第四幅时,最左的一幅可以删除,得到 ;再删就缺少画家 。每次都保留当前右端点对应的最短完整窗口,枚举全部右端点便覆盖了最优答案。左右端点只向右移动,严格变短时才更新也能保留并列时更早的左端点。时间为 ,存图画与频次需要 空间。
这里的条件必须落实为频次判断: 表示已经覆盖全部画家, 表示删除最左作品仍保留该画家的其他作品。每次删除都不改变已出现的画家集合。若某个起点曾被删除,以后即使右端继续右移,也总能用保留下来的更晚起点得到同样完整且更短的区间,所以左端不需要回退。
这种维护连续区间的同向双指针也称为滑动窗口。名称本身并不能确定收缩条件:本题依据“同一画家还有其他作品”删除左端;换成其他约束,必须重新证明删除后仍满足什么条件、为什么不会漏掉答案。
3. 区间合并
例题:区间覆盖长度。
简易题面:给定数轴上的 个半开区间 ,其中 。
请计算这些区间的并集长度。被多个区间覆盖的部分只能计算一次。
数据范围:,。
先按左端点升序排列,维护尚未结算的最后一个合并区间 。新区间左端不超过 时,两段重叠或相接,可以合并;否则旧区间已经结束。由于后续区间左端只会更大,旧区间不会再与它们相接,可以把长度计入答案。
以下全局数组 保存区间,容量至少为 ,每项的 字段使用 ,比较函数 按左端点升序排列。题目保证至少有一个区间。
sort(a + 1, a + n + 1, cmp);
long long l = a[1].l, r = a[1].r, ans = 0;
for (int i = 2; i <= n; ++i)
{
if (a[i].l <= r)
{
r = max(r, a[i].r);
}
else
{
ans += r - l;
l = a[i].l;
r = a[i].r;
}
}
ans += r - l;
循环结束后仍有最后一段未结算,必须再累加一次。对半开区间求并集长度时, 与 可以合为 ,长度仍是 ;这不表示两段存在重叠。排序需要 时间,扫描需要 时间,扫描额外空间为 。
4. 扫描线
仍以“区间覆盖长度”为例。区间合并维护并集的端点;扫描线则按坐标顺序维护当前覆盖次数,也能进一步统计重叠情况。
4.1 把端点变成事件
每个半开区间 产生两个事件:在 处覆盖次数增加 ,在 处减少 。把全部事件按坐标排序。相邻事件坐标之间没有区间开始或结束,覆盖次数保持不变,因此只需处理端点,不必逐个枚举坐标。
设当前事件坐标为 ,上一个事件坐标为 ,处理当前事件前的覆盖次数为 。它对应的是 :若 ,答案增加 ;然后统一处理坐标 上的所有增减,得到右侧下一段的覆盖次数。
例如区间 、、:
| 当前坐标 | 此前一段 | 此前覆盖次数 | 增加的长度 | 处理同坐标事件后的覆盖次数 |
|---|---|---|---|---|
| 无 | ||||
总覆盖长度为 。坐标 上同时发生一次结束和一次开始,统一处理后的覆盖次数仍为 。
4.2 先结算长度,再更新状态
以下 是全局事件数组,容量至少为 ,每项包含坐标 和变化量 。已为每个区间存入 、,事件数 。坐标、距离和答案使用 ,覆盖次数使用 。比较函数 按事件坐标升序排列。
sort(e + 1, e + m + 1, cmp);
long long ans = 0, pre = e[1].x;
int cnt = 0, i = 1;
while (i <= m)
{
long long x = e[i].x;
if (cnt > 0)
{
ans += x - pre;
}
while (i <= m && e[i].x == x)
{
cnt += e[i].v;
++i;
}
pre = x;
}
必须先按旧覆盖次数结算 ,再更新坐标 处的事件。若先更新,会把右侧区间的状态错误地用于左侧长度。相同坐标统一处理,也能避免把事件处理到一半的临时计数当成真实重叠数。求最大重叠数时,应在同坐标事件全部处理完后记录覆盖次数。
端点规则由区间含义决定。连续半开区间在 处结束;整数闭区间 的差分才在 处撤销。这里直接排序原坐标即可处理大坐标,不要求先离散化;即使使用离散化,长度仍须用原坐标之差计算。
共 个事件,排序需要 时间,每个事件只处理一次,扫描需要 时间,事件数组占用 空间。