Skip to content

E1. Submedians (Easy Version) - 题解

比赛与标签

比赛: Codeforces Round 1039 (Div. 2) 标签: binary search, data structures, dp, greedy, math 难度: *2200

喵喵,这道题是什么意思呀?

主人你好呀~!这道题是说,我们有一个长度为 n 的数组 a 和一个整数 k

首先,我们要明白什么是“中位数”(median)喵~ 对于一个长度为 m 的数组,如果一个数 v 大于等于其中至少 ⌈m/2⌉ 个元素,并且小于等于其中至少 ⌈m/2⌉ 个元素,那么 v 就是这个数组的中位数啦。

然后,题目定义了一个“子中位数”(submedian)。如果一个数 v,在数组 a 中存在一个长度至少为 k 的子数组,并且 v 是这个子数组的中位数,那么 v 就是一个子中位数。

我们的任务就是,在所有可能的子中位数中,找到那个最大的 v_max,并给出任意一个让它成为中位数的、长度不小于 k 的子数组的左右端点 lr,的说。

如何抓住最大的 "中位数" 呢?

这道题要求我们找到“最大”的子中位数,这种“最大/最小化某个值”的问题,通常都有一个很棒的 sifat(性质)——单调性

我们可以想一想,如果一个比较大的数 v 可以成为子中位数,那么一个比它小的数 v' 是不是更容易成为子中位数呢?答案是肯定的喵!因为把判断标准 v 降低,原来大于等于 v 的数就仍然大于等于 v',甚至可能有些原来小于 v 的数现在也大于等于 v' 了,这样满足中位数条件的可能性就更大了。

反过来,如果一个数 v 不能成为任何满足条件的子数组的中位数,那么一个比它更大的数 v' 就更不可能了,对吧?

这种单onotonicity(单调性)最适合用什么方法解决呢?当然是二分答案啦!我们可以在 [1, n] 的范围内二分我们想要检查的子中位数 v

Check函数的设计

二分答案的核心在于 check(v) 函数,它需要回答:“v 是否可以是一个子中位数?”

为了判断 v 是否是某个子数组 b 的中位数,我们只需要关心 b 中有多少数是 >= v 的。根据定义,这个数量需要至少是 ⌈|b|/2 rceil

这里有一个超级好用的技巧哦!我们可以把原数组 a 转化一下:

  • 如果 a[i] >= v,我们就把它看作 +1
  • 如果 a[i] < v,我们就把它看作 -1

这样一来,对于一个子数组,如果其中 +1 的数量(cnt_good)减去 -1 的数量(cnt_bad)大于 0,就说明 +1 的比 -1 的多。

我们来推导一下中位数的条件: cnt_good >= ⌈(cnt_good + cnt_bad) / 2⌉

  • 如果子数组长度 len = cnt_good + cnt_bad 是奇数,len = 2p+1,那么 ⌈len/2⌉ = p+1。条件是 cnt_good >= p+1。因为 cnt_bad = 2p+1 - cnt_good,所以 cnt_bad <= p。此时,这个子数组经过 +1/-1 变换后的和 cnt_good - cnt_bad >= (p+1) - p = 1
  • 如果子数组长度 len 是偶数,len = 2p,那么 ⌈len/2⌉ = p。条件是 cnt_good >= p。因为 cnt_bad = 2p - cnt_good,所以 cnt_bad <= p。此时,和 cnt_good - cnt_bad >= p - p = 0

总结一下,我们的 check(v) 函数就是要找一个长度 len >= k 的子数组 a[l...r],使得它在 +1/-1 变换后:

  • 如果 len 是奇数,其和 >= 1
  • 如果 len 是偶数,其和 >= 0

优化查找过程

要高效地计算子数组的和,当然要用前缀和啦!我们先预处理出 +1/-1 数组的前缀和 prefix

然后我们遍历所有可能的右端点 r(从 1n),对于每个 r,我们需要找到一个左端点 ll <= r-k+1),使得 prefix[r] - prefix[l-1] 满足上面的条件。

这个条件和子数组长度的奇偶性有关,而长度 r - l + 1 的奇偶性又和 rl-1 的奇偶性有关。这就有点麻烦了喵...

别急,我们可以分类讨论! 当我们固定 r 时,为了让 prefix[r] - prefix[l-1] 尽可能大,我们应该让 prefix[l-1] 尽可能小。

所以,在遍历 r 的同时,我们可以维护两个值:

  1. min0: 在 0r-k 范围内,所有偶数下标 i 对应的 prefix[i] 的最小值。
  2. min1: 在 0r-k 范围内,所有奇数下标 i 对应的 prefix[i] 的最小值。

这样,对于每个 r

  • 如果 r 是偶数:
    • 我们需要找一个偶数下标的 l-1,使得长度为偶数。条件是 prefix[r] - prefix[l-1] >= 0,即 prefix[r] >= min0
    • 或者找一个奇数下标的 l-1,使得长度为奇数。条件是 prefix[r] - prefix[l-1] >= 1,即 prefix[r] >= min1 + 1
  • 如果 r 是奇数:
    • 我们需要找一个奇数下标的 l-1,使得长度为偶数。条件是 prefix[r] - prefix[l-1] >= 0,即 prefix[r] >= min1
    • 或者找一个偶数下标的 l-1,使得长度为奇数。条件是 prefix[r] - prefix[l-1] >= 1,即 prefix[r] >= min0 + 1

只要其中任何一个条件满足,check(v) 就返回 true,表示 v 是一个合格的子中位数。如果遍历完所有的 r 都不满足,就返回 false

通过二分搜索,我们就能找到最大的那个 v,同时在 check(v) 函数返回 true 的时候记录下对应的 lr,问题就解决啦!

代码实现喵~

cpp
#include <iostream>
#include <vector>
#include <algorithm>
#include <climits>

using namespace std;
const long long INF = 1e18;

// check函数,判断v是否可以成为一个子中位数
// 如果可以,返回 {true, {l, r}}
// 如果不可以,返回 {false, {-1, -1}}
pair<bool, pair<int, int>> check(int v, const vector<int>& a, int k, int n) {
    // b[i] = 1 if a[i] >= v, else -1
    // prefix[i+1] 存储 b[0...i] 的和
    vector<long long> prefix(n + 1, 0);
    for (int i = 0; i < n; i++) {
        prefix[i + 1] = prefix[i] + (a[i] >= v ? 1 : -1);
    }

    // min0: 在 [0, r-k] 范围内偶数下标的最小前缀和
    // min1: 在 [0, r-k] 范围内奇数下标的最小前缀和
    long long min0 = INF;
    long long min1 = INF;
    // min0_index/min1_index 记录取得最小值时的下标
    int min0_index = -1;
    int min1_index = -1;

    bool found = false;
    int last_l = -1, last_r = -1;

    // 遍历所有可能的右端点 r
    for (int r = 1; r <= n; r++) {
        // 当子数组长度可以满足 >= k 时,我们开始更新最小前缀和
        if (r >= k) {
            int d = r - k; // l-1 的最大可能下标
            // 根据 d 的奇偶性更新 min0 或 min1
            if (d % 2 == 0) {
                if (prefix[d] < min0) {
                    min0 = prefix[d];
                    min0_index = d;
                }
            } else {
                if (prefix[d] < min1) {
                    min1 = prefix[d];
                    min1_index = d;
                }
            }
        }
        
        // 我们只需要找到一个满足条件的子数组即可,所以l,r可以一直更新
        if (r % 2 == 0) { // 如果 r 是偶数
            // 找奇数 l-1 (len=r-(l-1)为奇), 条件: prefix[r] - prefix[l-1] >= 1
            if (prefix[r] >= min1 + 1) {
                found = true;
                last_l = min1_index + 1;
                last_r = r;
            } 
            // 找偶数 l-1 (len=r-(l-1)为偶), 条件: prefix[r] - prefix[l-1] >= 0
            else if (prefix[r] >= min0) {
                found = true;
                last_l = min0_index + 1;
                last_r = r;
            }
        } else { // 如果 r 是奇数
            // 找偶数 l-1 (len=r-(l-1)为奇), 条件: prefix[r] - prefix[l-1] >= 1
            if (prefix[r] >= min0 + 1) {
                found = true;
                last_l = min0_index + 1;
                last_r = r;
            } 
            // 找奇数 l-1 (len=r-(l-1)为偶), 条件: prefix[r] - prefix[l-1] >= 0
            else if (prefix[r] >= min1) {
                found = true;
                last_l = min1_index + 1;
                last_r = r;
            }
        }
    }

    if (found) {
        return {true, {last_l, last_r}};
    } else {
        return {false, {-1, -1}};
    }
}

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(nullptr);

    int t;
    cin >> t;

    while (t--) {
        int n, k;
        cin >> n >> k;
        vector<int> a(n);
        for (int i = 0; i < n; i++) {
            cin >> a[i];
        }

        // 二分答案 [1, n]
        int low = 1, high = n;
        int ans_v = 0;
        int ans_l = -1, ans_r = -1;

        while (low <= high) {
            int mid = low + (high - low) / 2;
            auto result = check(mid, a, k, n);
            if (result.first) { // 如果 mid 可以作为子中位数
                ans_v = mid; // 更新答案
                ans_l = result.second.first;
                ans_r = result.second.second;
                low = mid + 1; // 尝试更大的 v
            } else { // 如果 mid 不行
                high = mid - 1; // 只能尝试更小的 v
            }
        }

        cout << ans_v << " " << ans_l << " " << ans_r << "\n";
    }

    return 0;
}

时间与空间,都要精打细算!

  • 时间复杂度: O(N log N) 的说。 二分答案的过程需要 log N 次迭代。在每次迭代中,check 函数需要 O(N) 的时间来计算前缀和并遍历一次数组。所以总的时间复杂度就是 O(N log N) 啦。

  • 空间复杂度: O(N) 的说。 我们主要需要一个 prefix 数组来存储前缀和,它的长度是 N+1,所以空间复杂度是 O(N)

猫娘的小总结

这道题真是一次有趣的冒险呢,喵~ 它把好几个知识点巧妙地融合在了一起:

  1. 二分答案: 看到“最大化最小值”或“最小化最大值”这类问题,一定要想想是否具有单调性,能不能用二分来解决哦!
  2. +1/-1 变换: 这是一个非常经典的技巧!当问题涉及到与某个阈值比较的元素个数时,可以尝试将问题转化为求和问题,这往往能大大简化问题。
  3. 前缀和与奇偶性: 前缀和是处理子数组问题的利器。这道题还额外考察了根据下标/长度的奇偶性进行分类讨论,这是对思维细致度的一个小考验。

希望这篇题解能帮到主人哦!多做这样的题目,你的算法思维一定会越来越敏捷的,加油喵~!

Released under the MIT License.