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
的子数组的左右端点 l
和 r
,的说。
如何抓住最大的 "中位数" 呢?
这道题要求我们找到“最大”的子中位数,这种“最大/最小化某个值”的问题,通常都有一个很棒的 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
(从 1
到 n
),对于每个 r
,我们需要找到一个左端点 l
(l <= r-k+1
),使得 prefix[r] - prefix[l-1]
满足上面的条件。
这个条件和子数组长度的奇偶性有关,而长度 r - l + 1
的奇偶性又和 r
与 l-1
的奇偶性有关。这就有点麻烦了喵...
别急,我们可以分类讨论! 当我们固定 r
时,为了让 prefix[r] - prefix[l-1]
尽可能大,我们应该让 prefix[l-1]
尽可能小。
所以,在遍历 r
的同时,我们可以维护两个值:
min0
: 在0
到r-k
范围内,所有偶数下标i
对应的prefix[i]
的最小值。min1
: 在0
到r-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
的时候记录下对应的 l
和 r
,问题就解决啦!
代码实现喵~
#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/-1 变换: 这是一个非常经典的技巧!当问题涉及到与某个阈值比较的元素个数时,可以尝试将问题转化为求和问题,这往往能大大简化问题。
- 前缀和与奇偶性: 前缀和是处理子数组问题的利器。这道题还额外考察了根据下标/长度的奇偶性进行分类讨论,这是对思维细致度的一个小考验。
希望这篇题解能帮到主人哦!多做这样的题目,你的算法思维一定会越来越敏捷的,加油喵~!