整体二分和其拓展
整体二分是一种离线解决若干个可二分的询问的思想。
首先用一个例子来引入:
求静态区间第 \(k\) 大,但是要求 \(O(n)\) 空间。
如果没有空间限制,这个问题显然是主席树模板,但是现在,我们考虑使用整体二分来解决。
要想整体二分,必须满足每个问题都是可二分的,也就是说对于一个形如 \(l, r, k\) 的询问,我们考虑怎么二分。
首先二分一个答案 \(x\),然后统计区间中小于 \(x\) 的个数,和 \(k\) 去比较即可,每次都这样做的话时间复杂度是 \(O(qn\log n)\) 的,但是我们在每次二分的时候,很多查询是重复的。
我们想在一次二分中多利用一点信息,于是就有了整体二分。
具体来说,我们把题目中的原序列看作是值域序列(显然 \(k\) 大值一定出现过),分别记录每个点的值和位置,然后按照值从小到大排序。把询问称为询问序列。
我们通过一个 solve(x, y, l, r) 来进行整体二分,它的含义是在 \([x, y]\) 的询问序列中,答案都是在值域序列的 \([l, r]\) 范围内的。
先不管为什么询问序列一定是连续的,我们尝试来做这个问题。
边界情况显然是 \(l=r\) 时,此时剩下的所有询问的答案就都已知了。
然后我们就二分 mid = (l + r) / 2,把值域序列分成 \([l, mid]\) 和 \([mid + 1, r]\)。那么势必有些询问会被分到左边的值域区间,有些到右边,我们尝试判断一下。
对于一个询问 \([l, r, k]\) 我们实际上想知道的是在当前情况下,原序列 \([l, r]\) 中有多少个大于 \(x\) 的数字,我们现在把 \(x \in [l, mid]\) 都加入其中,具体的说,因为我们刚刚记录了一个位置信息,于是我们只要在这个位置加 \(1\) 即可,对于每个询问,我们想知道的信息是区间和的形式,这一步可以用树状数组轻松维护。
现在我们假设已经知道了这个询问的 \(sum\),也就是原序列 \([l, r]\) 中有多少个在 \([l, mid]\) 的数字,如果说 \(sum \ge k\),显然这个值太大了,得把这个询问分到左边去,否则要分到右边。
注意:分到右边的时候,\(k\) 要减去 \(sum\)。
这是因为我们只是知道原序列 \([l, r]\) 中有多少个在 \([l, mid]\) 的数字,不是在 \([1, x]\) 的数字,前面减去才能让后面的问题正确求解,这一思想在线段树上二分也经常出现。
此时我们把询问序列分成两半,分别递归即可,我们可以重新排列询问序列,来达到上文提到的询问序列一定是连续的效果。
我们以 P1533 可怜的狗狗 为例展示代码:
#include <bits/stdc++.h>
#define int long long
#define F(i, a, b) for (int i = (a); i <= (b); i++)
#define dF(i, a, b) for (int i = (a); i >= (b); i--)
using namespace std;
typedef long long ll;
typedef pair<int, int> pii;
const int N = 300005, M = (N << 1), inf = 1e16, mod = 1e9 + 7;
int n, m, an[N];
struct query {
int id, l, r, k;
} q[N];
struct node {
int val, pos;
} a[N];
struct BIT {
int t[N];
void add(int x, int v) {
for (; x <= n; x += x & -x) t[x] += v;
}
int query(int x) {
int r = 0;
for (; x; x -= x & -x) r += t[x];
return r;
}
int query(int x, int y) {
return query(y) - query(x - 1);
}
} tr;
void solve(int x, int y, int l, int r) {
if (x > y) return ;
if (l == r) {
F(i, x, y) an[q[i].id] = a[l].val;
return;
}
int mid = l + r >> 1;
F(i, l, mid) tr.add(a[i].pos, 1);
vector<query> v1, v2;
F(i, x, y) {
int sum = tr.query(q[i].l, q[i].r);
if (sum >= q[i].k) v1.push_back(q[i]);
else q[i].k -= sum, v2.push_back(q[i]);
}
F(i, l, mid) tr.add(a[i].pos, -1);
for (int i = 0; i < v1.size(); i++)
q[x + i] = v1[i];
for (int i = 0; i < v2.size(); i++)
q[x + i + v1.size()] = v2[i];
solve(x, x + v1.size() - 1, l, mid);
solve(x + v1.size(), y, mid + 1, r);
}
signed main() {
ios_base::sync_with_stdio(0);
cin.tie(0), cout.tie(0);
cin >> n >> m;
F(i, 1, n) cin >> a[i].val, a[i].pos = i;
sort(a + 1, a + n + 1, [](node x, node y) {
return x.val < y.val;
});
F(i, 1, m) {
int l, r, k;
cin >> l >> r >> k;
q[i] = {i, l, r, k};
}
solve(1, m, 1, n);
F(i, 1, m) cout << an[i] << '\n';
return 0;
}
这样的话时间复杂度是 \(O(n \log^2 n)\) 的,稍逊于主席树解法,空间复杂度 \(O(n)\)。
而我们的整体二分的强大之处还能带上修改操作。
还是以一道例题引入 P2617 Dynamic Rankings,实际上就是上面的例题加了一个修改。
有了修改操作就意味着后面的询问会依赖于之前的操作,也即我们不能仿照上一个问题来将值域序列和询问序列区分开来,这里我们认为两者统称为“操作”。
下面是我对于操作的定义,特别的,我们把一开始的原序列也看作 \(n\) 个添加操作。
struct query {
int type, x, y, k, id;
// type = 1:表示查询 [x, y] 内的 k 大值,id 为询问编号
// type = 0:表示将 a[x] 修改为 y,k = 1 / -1 表示是加入还是删除,id 无意义。
} q[N];
然后有一个很好的事情,我们在整体二分的时候当前区间的操作总是关于时间线有序的,那么可以直接从前往后扫描得到答案,同时修改,最后也是一样撤回即可,实现可以参考一下代码。
#include <bits/stdc++.h>
#define int long long
#define F(i, a, b) for (int i = (a); i <= (b); i++)
#define dF(i, a, b) for (int i = (a); i >= (b); i--)
using namespace std;
typedef long long ll;
typedef pair<int, int> pii;
const int N = 500005, M = (N << 1), inf = 1e16, mod = 1e9 + 7;
int n, m, cnt, a[N], an[N], flag[N];
struct query {
int type, x, y, k, id;
// type = 1:表示查询 [x, y] 内的 k 大值,id 为询问编号
// type = 0:表示将 a[x] 修改为 y,k = 1 / -1 表示是加入还是删除,id 无意义。
} q[N];
struct BIT {
int t[N];
void add(int x, int v) {
for (; x <= n; x += x & -x) t[x] += v;
}
int query(int x) {
int r = 0;
for (; x; x -= x & -x) r += t[x];
return r;
}
int query(int x, int y) {
return query(y) - query(x - 1);
}
} tr;
void solve(int x, int y, int l, int r) {
if (x > y) return;
if (l == r) {
F(i, x, y) if (q[i].type) an[q[i].id] = l;
return;
}
int mid = (l + r) >> 1;
vector<query> v1, v2;
F(i, x, y) {
if (q[i].type) {
int sum = tr.query(q[i].x, q[i].y);
if (sum >= q[i].k) v1.push_back(q[i]);
else q[i].k -= sum, v2.push_back(q[i]);
} else {
if (q[i].y <= mid)
tr.add(q[i].x, q[i].k), v1.push_back(q[i]);
else v2.push_back(q[i]);
}
}
for (int i = 0; i < v1.size(); i++) {
q[x + i] = v1[i];
if (!v1[i].type) tr.add(v1[i].x, -v1[i].k);
}
for (int i = 0; i < v2.size(); i++)
q[x + i + v1.size()] = v2[i];
solve(x, x + v1.size() - 1, l, mid);
solve(x + v1.size(), y, mid + 1, r);
}
signed main() {
ios_base::sync_with_stdio(0);
cin.tie(0), cout.tie(0);
cin >> n >> m;
F(i, 1, n) {
cin >> a[i];
q[++cnt] = {0, i, a[i], 1, 0};
}
F(i, 1, m) {
char op; int x, y, k;
cin >> op >> x >> y;
if (op == 'Q') {
cin >> k;
q[++cnt] = {1, x, y, k, i};
flag[i] = 1;
} else {
q[++cnt] = {0, x, a[x], -1, 0};
q[++cnt] = {0, x, y, 1, 0};
a[x] = y;
}
}
solve(1, cnt, 0, 1e9);
F(i, 1, m) {
if (flag[i]) {
cout << an[i] << '\n';
}
}
return 0;
}
一种 \(O(n\log n)\) 的整体二分。
利用线段树等结构天然的分治过程,即可同时分治和计算答案。
对于询问分治(决策单调性)
以 P6684 [BalticOI 2020 Day1] 小丑 为例,若使用整体二分做法,设 solve(x, y, l, r) 表示对于 f[x],

浙公网安备 33010602011771号