不太整齐的队伍(查找区间mex 双指针 / 线段树)
不太整齐的队伍(查找区间mex 双指针 / 线段树)
一、题目描述
给定一个长度为 n 的非负整数数组 a。
对于一个连续区间 [l, r],记 S 为区间内出现过的标签集合,mex(S) 表示不属于 S 的最小非负整数。
求满足 mex(S) <= K 的连续区间的最大长度。
对应题目:牛客网 不太整齐的队伍
二、核心观察
mex(S) <= K 意味着什么?
根据 mex 的定义,mex(S) <= K 等价于:集合 S 中至少缺了 0 ~ K 中的某一个数。
也就是说,区间内不能同时包含 0, 1, 2, ..., K 这 K+1 个数。
反之,如果区间同时包含了 0 ~ K,那么 mex(S) >= K+1,不合法。
结论:问题转化为——求最长的连续子区间,使得该区间内不同时包含 0, 1, ..., K 这 K+1 个数。
特殊情况
当 K > n 时,因为数组长度只有 n,不可能包含 K+1 个不同的数,所以必然合法,答案直接是 n。
三、方法一:双指针(滑动窗口)
思路
维护一个滑动窗口 [L, R],用一个变量 sum 记录窗口内出现了多少个 0 ~ K 之间的不同数字。
- 不断扩展右端点
R,遇到0 ~ K的数就更新计数和sum - 当
sum == K + 1时,说明窗口内同时包含了0 ~ K,不合法,此时需要不断收缩左端点L,直到sum < K + 1 - 在每次合法时(
sum <= K),用R - L更新答案
AC 代码
#include<bits/stdc++.h>
using namespace std;
const int N = 2e5 + 5;
int a[N];
int cnt[N];
int num[N];
typedef long long ll;
int main()
{
int T;
cin >> T;
while (T--)
{
int n;
ll k;
cin >> n >> k;
memset(cnt, 0, sizeof(cnt));
memset(num, 0, sizeof(num));
for (int i = 1; i <= n; i++) cin >> a[i];
if (k > n)
{
cout << n << endl;
continue;
}
int L = 1, R = 1;
int sum = 0, res = 0;
while (R <= n)
{
// 拓展右端点
while (sum < k + 1 && R <= n)
{
if (a[R] <= k)
{
if (cnt[a[R]] == 0) cnt[a[R]] = 1, sum++;
num[a[R]]++;
}
R++;
}
// 更新答案
if (sum == k + 1) res = max(res, R - L - 1);
else res = max(res, R - L);
if (R > n) break;
// 收缩左端点,直到 sum < k + 1
while (sum == k + 1)
{
if (a[L] <= k)
{
if (num[a[L]] >= 2) num[a[L]]--;
else if (num[a[L]] == 1) num[a[L]]--, sum--, cnt[a[L]] = 0;
}
L++;
}
}
cout << res << endl;
}
return 0;
}
复杂度
- 时间复杂度:O(n),每个元素最多被左右指针各遍历一次。
- 空间复杂度:O(n),用于计数数组。
四、方法二:线段树维护最后出现位置
思路
如果区间 [L, R] 包含了 0 ~ K 中的所有数,那么对于 0 ~ K 中的每一个数 x,它在 [L, R] 内必然至少出现一次。
假设 0 ~ K 这些数在区间 [0, R] 内的最后一次出现位置的最小值记为 min_pos。
那么,只要左端点 L > min_pos,区间 [L, R] 就会漏掉某个数,从而合法。
为了求最长区间,左端点应该尽量靠左,所以最左的合法位置就是 min_pos + 1。
此时,以 R 为右端点的最长合法区间长度为 R - min_pos。
算法步骤:
- 建立一棵线段树,下标范围为
0 ~ K,初始值全为-1。 - 遍历数组,对于当前位置
i上的数x:- 如果
x <= K,在线段树上把下标x的值更新为i。
- 如果
- 查询线段树根节点的最小值
min_pos(即0 ~ K最后一次出现位置的最小值)。 - 当前合法最长长度为
i - min_pos,更新全局答案。
AC 代码
#include<bits/stdc++.h>
using namespace std;
const int N = 2e5 + 5;
struct node
{
int L, R;
int pos;
} seg[N << 2];
int a[N];
void push_up(int p)
{
seg[p].pos = min(seg[p << 1].pos, seg[p << 1 | 1].pos);
}
void build(int p, int L, int R)
{
seg[p].L = L, seg[p].R = R;
seg[p].pos = -1; // 初始全为 -1
if (L == R) return;
int mid = (L + R) >> 1;
build(p << 1, L, mid);
build(p << 1 | 1, mid + 1, R);
push_up(p);
}
void update(int p, int pos, int v)
{
if (seg[p].L == seg[p].R)
{
seg[p].pos = v;
return;
}
int L = seg[p].L, R = seg[p].R;
int mid = (L + R) >> 1;
if (pos <= mid) update(p << 1, pos, v);
else update(p << 1 | 1, pos, v);
push_up(p);
}
int main()
{
ios::sync_with_stdio(false);
cin.tie(0);
int T;
cin >> T;
while (T--)
{
int n, k;
cin >> n >> k;
for (int i = 0; i < n; i++) cin >> a[i];
if (k > n)
{
cout << n << "\n";
continue;
}
build(1, 0, k);
int ans = 0;
for (int i = 0; i < n; i++)
{
if (a[i] <= k) update(1, a[i], i);
int min_pos = seg[1].pos;
ans = max(ans, i - min_pos);
}
cout << ans << '\n';
}
return 0;
}
复杂度
- 时间复杂度:O(n log K),每次更新和查询线段树都是 log 级别。
- 空间复杂度:O(K),线段树数组大小。
五、总结对比
| 方法 | 核心思想 | 时间复杂度 | 空间复杂度 | 适用场景 |
|---|---|---|---|---|
| 双指针 | 滑动窗口维护包含的数字种类数 | O(n) | O(n) | 通用,写起来快,常数小 |
| 线段树 | 维护 0~K 最后一次出现位置的最小值 |
O(n log K) | O(K) | 适合考察线段树基本功,思维更巧妙 |
推荐:比赛时首选双指针,代码短且不易写错;如果在学习线段树,方法二是一道很好的思维练习题。
六、补充:关于查找区间 mex 的思考
在解决本题时,我们其实都在隐式或显式地处理 mex。如果我们直接面对「查询区间 [L, R] 的 mex」这个问题,两种数据结构会展现出截然不同的效果。
1.用「滑动窗口 + 线段树」多次查询并维护 mex
如果题目真的要求你在滑动窗口滑动时,动态地输出当前的 mex(而不是求最长区间),我们可以结合维护种类数和线段树二分来实现。
建一棵权值线段树(范围 0 ~ n),但节点维护的是「当前窗口内该区间出现的数字种类数(sum)」,而不是出现次数。
- 当窗口加入一个数
x时,如果它原本的出现次数是 0,就把线段树上x位置的sum改为 1。 - 当窗口移除一个数
x时,如果它原本的出现次数是 1,就把线段树上x位置的sum改为 0。
此时,线段树完美适配二分查找 mex,因为:
- 某个节点管辖的区间长度为
len = R - L + 1。 - 如果该节点的
sum < len,说明这个区间里至少有一个数字没出现过,mex一定藏在里面!
代码模板如下:
// 找当前树上第一个缺失(sum < 区间长度)的叶子,即 mex
int find_first_zero(int p, int l, int r) {
if (l == r) return l; // 找到了,这就是 mex
int mid = (l + r) >> 1;
// 左孩子管辖的区间长度是 mid - l + 1
// 左孩子的 sum 存的是左半边出现的种类数
// 如果 sum < 长度,说明左半边“缺人”(有没出现过的数字)
if (tree[p * 2].sum < mid - l + 1) {
return find_first_zero(p * 2, l, mid); // 去左半边找
} else {
return find_first_zero(p * 2 + 1, mid + 1, r); // 左边全勤,去右半边找
}
}
2. 用「线段树 + 树上二分」,记录数字最后出现位置的最小值
如果题目要求我们处理 (Q) 次独立的查询,每次询问某个固定区间 ([L, R]) 的 mex。我们可以通过离线的方式,利用线段树维护“最后出现位置”在 (O((n+Q)*log n)) 的时间内解决。
核心原理
我们按查询的右端点 (R) 从小到大对查询进行排序,用一个扫描指针从左到右遍历数组。
当扫描指针走到某个查询的右端点 (R) 时,我们把这 (1 \sim R) 个数的最后一次出现位置记录在线段树里:
- 线段树的叶子节点 (x) 存的值是:数字 (x) 在 (1 \sim R) 中最后出现的下标
last_pos[x]。 - 如果一个数字 (x) 在 ([L, R]) 中没出现过,那么它最后一次出现的位置必然满足:
last_pos[x] < L。 - 我们要找的 mex,就是第一个
last_pos[x] < L的数字。
树上二分查找
线段树每个节点维护其管辖范围内 last_pos 的最小值 min_pos。
- 找第一个
last_pos[x] < L的数字时,如果左孩子的min_pos < L,说明左孩子里“藏着”最后出现位置在 (L) 左边的数字,mex一定在左半边! - 直接往左递归;如果左孩子不满足,再去右孩子找。
代码模板如下:
// 在根节点 [0, n] 范围内找第一个 last_pos < L 的位置
int query_mex(int p, int l, int r, int L) {
if (l == r) return l; // 找到了,这就是 mex
int mid = (l + r) >> 1;
// 如果左半部分的最小最后出现位置 < L,说明左侧有数字没在 [L, R] 出现过
if (tree[p * 2].min_pos < L) {
return query_mex(p * 2, l, mid, L); // 去左边找
} else {
return query_mex(p * 2 + 1, mid + 1, r, L); // 左侧全在 [L, R] 出现过,去右侧找
}
}
3. 对比
法一:本质是还是通过滑动窗口,依此遍历统计各个数,统计是否通过,找第一个0,通过线段树和二分来找,简化查找的复杂度,但是对于区间的左端点需要不断移动;而法二:统计的是0, R每个数的最后一次出现位置,二分查找的时候直接和L比较,左端点无需修改,虽然复杂度和法一一样,都为n*log(n) 但是常数更小

浙公网安备 33010602011771号