E. Tracking Segments - 题解
比赛与标签
比赛: Codeforces Round 881 (Div. 3) 标签: binary search, brute force, data structures, two pointers 难度: *1600
题目大意喵~
我们有一个长度为 n
,初始全是 0
的数组 a
。接着,我们拿到 m
个线段,每个线段由它的左右端点 [l, r]
定义。
一个线段如果它包含的 1
的数量严格大于 0
的数量,我们就称它为“美丽”的线段,喵~
然后呢,会有 q
次操作。每次操作会给出一个位置 x
,让我们把 a[x]
的值变成 1
。
我们的任务是,找出最早在哪一次操作之后,m
个线段中至少有一个变得“美丽”。如果所有 q
次操作都做完,还是没有美丽的线段,那就输出 -1
好了。
解题思路,开动脑筋!
嘿嘿,一看到“最早”、“最小”、“第一个”这样的字眼,聪明的你是不是和本猫娘一样,DNA里刻着的某个算法开始蠢蠢欲动了呀?对啦,就是二分查找!
让我们来分析一下这个问题为什么可以用二分来解决呐。
假设我们正在考虑一个答案 k
,意思是“执行前 k
次操作后,会不会出现美丽的线段?”。
- 如果执行前
k
次操作后,已经有美丽的线段了,那么执行前k+1
次操作(也就是多变一个1
),那个美丽的线段只会变得更“美丽”(1
的数量不会减少),所以肯定也满足条件。 - 如果执行前
k
次操作后,还没有美丽的线段,那么只执行前k-1
次操作,1
的数量更少,就更不可能有美丽的线段了。
看到了吗?这个性质就是单调性!对于答案(操作次数),如果 k
次操作满足条件,那么所有大于 k
的次数也都满足。这简直就是为二分查找量身定做的舞台嘛!
所以,我们的核心思路就是二分答案,在 [1, q]
的范围内查找最小的那个操作次数 k
。
对于二分查找的每一次尝试,我们都需要一个 check(mid)
函数来验证:“只进行前 mid
次操作,是否能产生一个美丽的线段?”
那么,check(mid)
函数要怎么实现呢?
- 模拟状态:我们先创建一个长度为
n
的临时数组,然后把前mid
个要修改的位置都标记为1
。 - 快速判断:现在,我们需要检查
m
个线段,看有没有一个是“美丽”的。一个一个去数每个线段里1
的个数太慢啦!这里就要请出我们的老朋友——前缀和! - 前缀和加速:我们可以先预处理一下这个临时数组的前缀和
pre
,其中pre[i]
表示从位置1
到i
总共有多少个1
。这样,想知道任意线段[l, r]
中1
的数量,只需要计算pre[r] - pre[l-1]
就好啦,时间复杂度是 O(1)! - 美丽判断:一个线段
[l, r]
的长度是len = r - l + 1
。它里面1
的数量是ones = pre[r] - pre[l-1]
。题目要求1
的数量严格大于0
的数量,也就是ones > (len - ones)
,变形一下就是2 * ones > len
。我们对所有m
个线段都用这个式子判断一下,只要有一个满足,check(mid)
就返回true
!
把它们组合起来:
- 我们二分查找答案
k
,范围是[1, q]
。 - 对于每个
mid
,我们用 O(n + m) 的时间复杂度来执行check(mid)
函数(O(n)建前缀和,O(m)检查所有线段)。 - 如果
check(mid)
为true
,说明mid
次操作是足够的,真正的答案可能就是mid
,或者更小,所以我们尝试在左半边继续找,即right = mid
。 - 如果
check(mid)
为false
,说明mid
次操作不够,我们需要更多次操作,所以去右半边找,即left = mid + 1
。
最后,二分查找结束时 left
指向的位置,就是我们想要的最小操作次数啦!如果 left
超出了 q
,就说明无解,输出 -1
。是不是很简单明了呢,喵~ (´。• ᵕ •。`) ♡
代码实现喵~
#include <iostream>
#include <vector>
using namespace std;
// 这是一个乐于助人的猫娘写的代码哦!
int main() {
// 加速输入输出,让程序跑得像猫一样快,喵~
ios_base::sync_with_stdio(false);
cin.tie(nullptr);
int t;
cin >> t;
while (t--) {
int n, m;
cin >> n >> m;
vector<pair<int, int>> segments(m);
for (int i = 0; i < m; i++) {
cin >> segments[i].first >> segments[i].second;
}
int q;
cin >> q;
vector<int> changes(q);
for (int i = 0; i < q; i++) {
cin >> changes[i];
}
// 二分查找答案的范围,left是可能的最小答案,right是上界
// 我们在 [0, q+1] 这个区间里找,left最终会停在第一个满足条件的位置
int left = 0, right = q + 1;
while (left < right) {
int mid = (left + right) / 2;
// 如果mid是0,说明不进行任何操作,肯定不行,直接跳过
if (mid == 0) {
left = mid + 1;
continue;
}
// --- 这部分就是我们的 check(mid) 函数 ---
// 1. 模拟数组状态:创建一个全0数组,然后执行前 mid 次操作
vector<int> arr(n + 1, 0);
for (int i = 0; i < mid; i++) {
arr[changes[i]] = 1;
}
// 2. 计算前缀和,方便快速查询区间和
vector<int> pre(n + 1, 0);
for (int i = 1; i <= n; i++) {
pre[i] = pre[i - 1] + arr[i];
}
// 3. 检查所有线段是否变“美丽”
bool found = false;
for (const auto& seg : segments) {
int l = seg.first;
int r = seg.second;
int total_ones = pre[r] - pre[l - 1]; // O(1) 查询1的数量
int len = r - l + 1;
// 判断条件:1的数量是否严格大于0的数量
if (2 * total_ones > len) {
found = true;
break; // 找到一个就够啦!
}
}
// --- check(mid) 结束 ---
// 根据check的结果,调整二分范围
if (found) {
// mid次操作是可行的,说明答案可能就是mid,或者更小
right = mid;
} else {
// mid次操作还不够,需要更多次操作
left = mid + 1;
}
}
// 输出结果
if (left == q + 1) {
// 如果left跑到了我们设置的上界之外,说明无解
cout << "-1\n";
} else {
// 否则left就是最小的满足条件的次数
cout << left << '\n';
}
}
return 0;
}
复杂度分析的说
时间复杂度: O((n + m) * log(q)) 的说。 二分查找本身需要
log(q)
次迭代。在每次迭代中,我们执行check
函数:创建一个大小为n
的数组并计算前缀和需要 O(n) 时间,然后遍历m
个线段进行检查需要 O(m) 时间。所以每次check
的总时间是 O(n + m)。合起来就是 O((n + m) * log(q)) 啦!对于这道题的数据范围来说,是完全可以通过的。空间复杂度: O(n) 的说。 在二分查找的每次迭代中,我们都需要创建临时的数组
arr
和前缀和数组pre
,它们的大小都和n
相关。所以,我们需要的额外空间是 O(n)。
知识点与总结
这道题真是一次美妙的思维之旅呢!它教会了我们:
- 识别单调性: 当题目要求“最小/最大...使得...满足条件”时,一定要检查问题是否具有单调性。这是使用二分答案的黄金信号!
- 二分答案模板: 将“求最优值”问题转化为“判定性”问题。我们不直接求答案,而是二分一个值
k
,然后去验证k
是否可行。 - 前缀和大法好: 遇到频繁的区间查询(特别是求和),前缀和永远是你的好朋友!它能把 O(L) 的区间查询优化到 O(1),是提高效率的利器。
- 问题转化: 把“1的数量 > 0的数量”这个条件转化为
2 * ones > len
,可以让代码更简洁,计算也更方便哦。
希望这次的讲解能帮助到你!算法的世界充满了无限的可能和乐趣,只要我们用心思考,就没有解不开的谜题。下次再遇到难题,也要像猫娘一样,充满好奇心和勇气去挑战哦!加油,喵~!(๑•̀ㅂ•́)و✧