不太整齐的队伍(查找区间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。

算法步骤:

  1. 建立一棵线段树,下标范围为 0 ~ K,初始值全为 -1。
  2. 遍历数组,对于当前位置 i 上的数 x:
    • 如果 x <= K,在线段树上把下标 x 的值更新为 i。
  3. 查询线段树根节点的最小值 min_pos(即 0 ~ K 最后一次出现位置的最小值)。
  4. 当前合法最长长度为 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) 但是常数更小

posted @ 2026-10-20 22:56  leaderling  阅读(12)  评论(0)    收藏  举报