分治专题讲义
分治专题讲义:CDQ 分治、整体二分、线段树分治
这类算法解决的是同一类问题:单次询问会二分、会用数据结构,但询问有 \(10^5\) 个,每个都重做就会超时。我们的做法是把所有询问离线,让一次分治同时服务所有询问。
预计两节课能讲完,作业放在文末。
0. 适用范围
| 工具 | 分治的是什么 | 数据结构里放什么 | 典型题目 |
|---|---|---|---|
| CDQ 分治 | 已经排好序的那一维(常常是时间或下标) | 另一维,用树状数组 | 三维偏序、带修改的二维数点 |
| 整体二分 | 答案的值域 | 当前值域左半边已经确定的贡献 | 区间第 \(k\) 小、最大值最小化 |
| 线段树分治 | 时间轴上的区间 | 在整段时间里都存在的修改,离开时撤销 | 加边删边、可撤销并查集 |
共同点:
- 每一对「修改、询问」只在某一层被统计一次。
- 这一层统计完,数据结构要回到进来之前的样子。树状数组逐个减回去;并查集用栈撤销。不要
memset整棵树。 - 能离线才用。必须在线时,对应的是主席树、树套树,代码量和常数都更大。
分治优化 DP(决策单调性,如 CF 868F)和点分治(树上重心)名字里也有分治,模型和本讲不是一套,文末只划边界。
1. CDQ 分治
1.1 从二维偏序看贡献
一维偏序就是排序。二维偏序:按第一维排序之后,问题变成「前面的点里,第二维比我小的有多少个」,树状数组从左往右加、查询即可。归并排序求逆序对是同一件事,只是第二维用双指针,不必树状数组。
三维偏序多出来的那一维交给分治。分治保证左半边的第一维更小,当前层再按第二维排序,第三维仍然用树状数组。这就是 CDQ。
CDQ 在区间 \([l,r]\) 上做三件事:
- 递归算出左半边内部、右半边内部的答案。
- 此时左半边每个点的第一维都不大于右半边。只统计「左边对右边」的贡献。
- 统计时左右各自按第二维排序,第三维丢进树状数组。统计完把树状数组减回去。
任意两点如果第一维有序,它们会在某一层分居左右,贡献恰好算一次。同一侧的点留给子问题。所以递归前不要先按第二维打乱整段;先递归,再在当前层排序。子问题返回后,当前层再按第二维排是安全的,因为子问题的答案已经记在点上了。
复杂度:设值域为 \(k\)。每一层树状数组代价 \(O(n\\log k)\),层数 \(O(\\log n)\),总计 \(O(n\\log n\\log k)\)。每一层对两半各 sort 一次,同样是 \(O(n\\log^2 n)\)。
1.2 模板:三维偏序(洛谷 P3810 / BJOI2011 陌上花开)
有 \(n\) 朵花,每朵有属性 \((a_i,b_i,c_i)\)。\(f(i)\) 表示有多少朵其他花三维都 \(\le\) 它。对每个 \(d\in[0,n)\),输出 \(f(i)=d\) 的花有多少朵。\(n\le 10^5\),\(1\le a_i,b_i,c_i\le k\le 2\cdot 10^5\)。允许三元组完全相同。
做法:
- 按 \(a\)、\(b\)、\(c\) 排序,把完全相同的三元组合并,记下出现次数
cnt。 - CDQ:左半边向右半边贡献。左点 \(b\) 更小(或相等)时,把它的
cnt加到以 \(c\) 为下标的树状数组。右点查询 \(c\) 的前缀,加进ans。 - 一朵花真正的 \(f\) 是
ans + cnt - 1。ans是其他组的贡献,cnt - 1是同组里其余那些花。
相等为什么不会漏:数组先按 \(a,b,c\) 排序,CDQ 只让左边贡献右边。第一维相等时,\(b\) 较小的那个已经在左边。若 \(b\) 也相等,\(c\) 较小的在左边,它能贡献给右边;右边那朵 \(c\) 更大,本来就不该算进左边那朵的 \(f\)。两边都该算到对方头上的情况,只剩下三维全部相等,而那种已经合并进 cnt。
手算四个点,排序后是 \((1,2,3),\ (2,1,1),\ (2,3,2),\ (3,3,3)\)。各自的 \(f\) 是 \(0,0,1,3\)。
顶层 mid 把前两个和后两个切开。按 \(b\) 排完,左边是 \((2,1,1),\ (1,2,3)\),右边是 \((2,3,2),\ (3,3,3)\)。
- 处理 \((2,3,2)\):两个左点 \(b\) 都 \(\le 3\)。\(c\le 2\) 的只有 \((2,1,1)\),贡献 \(1\)。和手算一致。
- 处理 \((3,3,3)\):两个左点都在树状数组里,\(c\le 3\) 查到 \(2\)。右半边内部,\((2,3,2)\) 还要再贡献 \(1\),那是子问题的事。合计 \(3\)。
输出按 \(f\) 从 \(0\) 到 \(n-1\) 计数:这四个点得到
2
1
0
1
易错点:
- 树状数组上界是 \(k\),不是 \(n\)。
- 撤回时从
l减到i-1,i是双指针停下来的位置,后面的左点根本没加进去。 - 合并相同三元组时,
f不要忘了cnt - 1。 - 桶的下标最大是 \(n-1\),输出 \(n\) 行。
const int N = 1e5 + 5, K = 2e5 + 5;
int n, k, m, buc[N], tr[K];
struct Node {
int a, b, c, cnt, ans;
} p[N];
bool cmp1(const Node &x, const Node &y) {
if (x.a != y.a) return x.a < y.a;
if (x.b != y.b) return x.b < y.b;
return x.c < y.c;
}
bool cmp2(const Node &x, const Node &y) {
if (x.b != y.b) return x.b < y.b;
return x.c < y.c;
}
void add(int x, int v) {
for (; x <= k; x += lowbit(x)) tr[x] += v;
}
int ask(int x) {
int s = 0;
for (; x; x -= lowbit(x)) s += tr[x];
return s;
}
void cdq(int l, int r) {
if (l == r) return;
int mid = (l + r) >> 1;
cdq(l, mid);
cdq(mid + 1, r);
sort(p + l, p + mid + 1, cmp2);
sort(p + mid + 1, p + r + 1, cmp2);
int i = l;
for (int j = mid + 1; j <= r; j++) {
while (i <= mid && p[i].b <= p[j].b) add(p[i].c, p[i].cnt), i++;
p[j].ans += ask(p[j].c);
}
for (int j = l; j < i; j++) add(p[j].c, -p[j].cnt);
}
signed main(void) {
read(n); read(k);
for (int i = 1; i <= n; i++) read(p[i].a), read(p[i].b), read(p[i].c);
sort(p + 1, p + n + 1, cmp1);
m = 0;
for (int i = 1; i <= n; i++)
if (m && p[m].a == p[i].a && p[m].b == p[i].b && p[m].c == p[i].c) p[m].cnt++;
else p[++m] = p[i], p[m].cnt = 1, p[m].ans = 0;
cdq(1, m);
for (int i = 1; i <= m; i++) buc[p[i].ans + p[i].cnt - 1] += p[i].cnt;
for (int i = 0; i < n; i++) writeln(buc[i]);
return 0;
}
/**/
1.3 CF 61E Enemy is weak
题目,Codeforces Beta Round #57 (Div. 2) E。
\(n\) 个两两不同的数,\(3\le n\le 10^6\),\(1\le a_i\le 10^9\)。求满足 \(i<j<k\) 且 \(a_i>a_j>a_k\) 的三元组个数。
固定中间点 \(j\),答案是
下标已经是第一维,而且严格递增,不需要 CDQ。离散化之后:
- 从右往左扫,树状数组里是右边已经出现的数,
ask(c[j]-1)就是右边更小的个数,记到rs[j]。 - 把这些数逐个减掉,树清空。
- 从左往右扫,
ask(tot) - ask(c[j])是左边更大的个数,乘上rs[j]加入答案,再把 \(a_j\) 插入。
答案最大约 \(\binom{10^6}{3}\),用 long long。\(n=10^6\) 时再套一层 CDQ 是 \(O(n\log^2 n)\),常数很容易贴着 5 秒,这题就该停在树状数组。
const int N = 1e6 + 5;
int n, tot, a[N], b[N], c[N], rs[N], bit[N];
void add(int x, int v) {
for (; x <= tot; x += lowbit(x)) bit[x] += v;
}
int ask(int x) {
int s = 0;
for (; x; x -= lowbit(x)) s += bit[x];
return s;
}
signed main(void) {
read(n);
for (int i = 1; i <= n; i++) read(a[i]), b[i] = a[i];
sort(b + 1, b + n + 1);
tot = unique(b + 1, b + n + 1) - b - 1;
for (int i = 1; i <= n; i++) c[i] = lower_bound(b + 1, b + tot + 1, a[i]) - b;
for (int i = n; i; i--) rs[i] = ask(c[i] - 1), add(c[i], 1);
for (int i = 1; i <= n; i++) add(c[i], -1);
ll ans = 0;
for (int i = 1; i <= n; i++) {
ans += (ll)(ask(tot) - ask(c[i])) * rs[i];
add(c[i], 1);
}
writeln(ans);
return 0;
}
/**/
1.4 CF 1093E Intersection of Permutations
题目,Codeforces Round #526 (Div. 2) E。\(2\le n\le 2\cdot 10^5\),\(1\le m\le 2\cdot 10^5\),时限 6 秒。
两个 \(1..n\) 的排列 \(a,b\)。
1 la ra lb rb:有多少个数既出现在 \(a\) 的 \([l_a,r_a]\),又出现在 \(b\) 的 \([l_b,r_b]\)。2 x y:交换 \(b_x\) 和 \(b_y\)。
\(a\) 从不修改。令 \(\mathrm{posa}[v]\) 为值 \(v\) 在 \(a\) 中的位置。\(b\) 的第 \(i\) 个位置上的值,对应平面上的点 \((\mathrm{posa}[b_i],\\ i)\)。一次查询就是数矩形 \([l_a,r_a]\\times[l_b,r_b]\) 里的点数。交换是删掉两个旧点、加上两个新点。
矩形用前缀拆开。记 \(S(x,y)\) 为满足 \(\\mathrm{posa}\\le x\) 且位置 \(\\le y\) 的点数,则
每个查询拆成 4 个带符号的前缀询问,每次交换拆成 4 个 \(\\pm 1\) 的点更新。事件总数 \(n+4m\\le 10^6\)。
三维分别是:
- 时间:事件在数组里的下标,CDQ 按这个下标切。
- \(x=\\mathrm{posa}\):当前层把左右两半按 \(x\) 排序,双指针保证树状数组里只有 \(x\) 更小的左边更新。
- \(y=b\) 中的位置:树状数组。
左边的更新贡献给右边的询问。右边区间内部的贡献由右递归负责,所以一次交换被 mid 切成「左边只有删除、右边才有插入」也是对的:询问若在整个交换之后,删除从跨 mid 的贡献里进来,插入在右递归里进来。
\(x=0\) 或 \(y=0\) 的前缀询问直接留下。ask(0) 是 0,没有点的 \(x\) 是 0,贡献自然是 0。
初始点先放进事件数组,再按时间放入 \(m\) 个操作。cdq 之前不要按 \(x\) 整体排序,时间序就是数组顺序。
复杂度 \(O((n+m)\\log^2 n)\)。
易错点:
posa和「\(b\) 每个位置当前对应的 \(\\mathrm{posa}\)」分成两个数组。边读 \(b\) 边覆盖posa会把还没查过的值写掉。- 树状数组按下标撤回,和 P3810 一样。
- 询问系数是 \(+1,-1,-1,+1\),漏一个符号就是一组错解。
signed main(void) {
read(n); read(m);
for (int i = 1; i <= n; i++) {
int x; read(x); posa[x] = i;
}
for (int i = 1; i <= n; i++) {
int x; read(x);
cur[i] = posa[x];
q[++cnt] = (Ev){0, cur[i], i, 1, 0};
}
for (int i = 1; i <= m; i++) {
int op; read(op);
if (op == 1) {
int la, ra, lb, rb;
read(la); read(ra); read(lb); read(rb);
qcnt++;
q[++cnt] = (Ev){1, ra, rb, 1, qcnt};
q[++cnt] = (Ev){1, la - 1, rb, -1, qcnt};
q[++cnt] = (Ev){1, ra, lb - 1, -1, qcnt};
q[++cnt] = (Ev){1, la - 1, lb - 1, 1, qcnt};
} else {
int x, y; read(x); read(y);
q[++cnt] = (Ev){0, cur[x], x, -1, 0};
q[++cnt] = (Ev){0, cur[y], y, -1, 0};
swap(cur[x], cur[y]);
q[++cnt] = (Ev){0, cur[x], x, 1, 0};
q[++cnt] = (Ev){0, cur[y], y, 1, 0};
}
}
cdq(1, cnt);
for (int i = 1; i <= qcnt; i++) writeln(ans[i]);
return 0;
}
/**/
1.5 什么时候往 CDQ 上想
看到「三维都要有序」,或者「二维数点,但是点会插入删除」,先写事件:更新是点,询问是前缀(矩形再拆)。保证数组按时间有序,分治只让左边更新右边询问,第三维给树状数组。
动态逆序对(洛谷 P3157)是同一模型:每个数的删除时间、位置、权值构成三维偏序,初始逆序对减去被删掉的那些。四维偏序(HDU 5126 Stars)是 CDQ 里面再套一层 CDQ,本讲的模板套两次即可,作业里再写。
2. 整体二分
2.1 它在二分什么
普通二分是对一个询问二分答案,check 一遍。询问多了,每次 check 都扫全序列,就是 \(O(m\log V\cdot T\_{check})\)。
整体二分把值域 \([L,R]\) 和当前还没确定答案的所有操作放在一起:
- 取
mid。 - 用数据结构算出「值落在 \([L,\mathrm{mid}]\) 的那些修改」对每个询问的贡献。
- 贡献已经够的询问,答案在 \([L,\mathrm{mid}]\);不够的,答案在 \((\\mathrm{mid},R]\),并且把已经得到的贡献从需求里扣掉。
- 数据结构清空,两边递归。
和 CDQ 的差别在切的轴:CDQ 切时间,统计左边对右边;整体二分切答案,把操作分成「答案在左」和「答案在右」。带修第 \(k\) 小两套都能写。第 \(k\) 小的贡献能相减,整体二分更直接;二维数点带修改、贡献是矩形,CDQ 更直接。
每个操作出现在 \(O(\log V)\) 层,每层树状数组 \(O(\log n)\),总计 \(O((n+m)\log V\log n)\)。
有一类题贡献不能减,比如「连续段长度」。那种不能写 k -= s,处理顺序要改,见 CF 484E。
2.2 模板:静态区间第 \(k\) 小(洛谷 P3834)
序列长度和询问都 \(\\le 2\\cdot 10^5\),\(a_i\) 在 \([-10^9,10^9]\)。每次给 \(l,r,k\),求 \([l,r]\) 中第 \(k\) 小。在线做法是主席树。可以离线时,整体二分加一棵树状数组就够。
操作统一成一种结构:
typ = 1:在位置pos加入权值key,系数coef。静态题里每个 \(a_i\) 是一条coef = +1的修改,并且全部排在询问前面。typ = 2:询问 \([l,r]\) 的第k小。
solve(l, r, vl, vr) 处理操作下标 \([l,r]\)、值域 \([vl,vr]\)(离散化之后)。
-
vl == vr:这段里的询问答案就是vl。 -
否则
mid = (vl+vr)>>1。从左往右扫:- 修改的
key <= mid:加进位置树状数组,分到左边。 - 询问:令 \(s\) 为 \([l,r]\) 上当前树状数组的和,也就是这段里、出现在该询问之前、且值 \(\\le mid\) 的元素个数。\(s\\ge k\) 则答案在左边,\(k\) 不动;否则 \(k\) 减去 \(s\),询问去右边。
- 修改的
-
把刚才加进树状数组的修改减掉。划分时保持原来的相对顺序,再递归。
静态题里修改都在询问前,所以扫到询问时,该进树的元素已经进了。带修时只要按时间把修改和询问交错放好,同一份 solve 不用改。系数写成 coef,就是为了带修时能塞 -1。
例子:序列 \(3,1,4,2\),问 \([1,4]\) 第 3 小,答案是 \(3\)。离散化后值就是 \(1..4\)。
- 值域 \([1,4]\),
mid = 2。值 \(\le 2\) 的是位置 2 和位置 4,\(s=2<3\),于是 \(k\) 变成 \(1\),去 \([3,4]\)。 mid = 3。值 \(\le 3\) 且还留在这一支的,是位置 1 上的 \(3\),\(s=1\ge k\),去 \([3,3]\)。- 答案 \(3\)。\(4\) 没有被算进这个询问。
易错点:
- 所有出现过的值一起离散化,包括后面修改出来的新值。静态题只有原序列。
- 去右边时减的是 \(k\),左边那一支的 \(k\) 保持原样。
- 清空树状数组只减本层加过的修改。
- 输出时把离散化排名映射回原值。
const int N = 2e5 + 5;
int n, m, tot, cnt, bit[N], b[N], ans[N];
struct Node {
int typ, pos, key, coef, l, r, k, id;
} q[N << 1], q1[N << 1], q2[N << 1];
void add(int x, int v) {
for (; x <= n; x += lowbit(x)) bit[x] += v;
}
int ask(int x) {
int s = 0;
for (; x; x -= lowbit(x)) s += bit[x];
return s;
}
void solve(int l, int r, int vl, int vr) {
if (l > r) return;
if (vl == vr) {
for (int i = l; i <= r; i++)
if (q[i].typ == 2) ans[q[i].id] = vl;
return;
}
int mid = (vl + vr) >> 1, c1 = 0, c2 = 0;
for (int i = l; i <= r; i++) {
if (q[i].typ == 1) {
if (q[i].key <= mid) add(q[i].pos, q[i].coef), q1[++c1] = q[i];
else q2[++c2] = q[i];
} else {
int s = ask(q[i].r) - ask(q[i].l - 1);
if (s >= q[i].k) q1[++c1] = q[i];
else q[i].k -= s, q2[++c2] = q[i];
}
}
for (int i = 1; i <= c1; i++)
if (q1[i].typ == 1) add(q1[i].pos, -q1[i].coef);
for (int i = 1; i <= c1; i++) q[l + i - 1] = q1[i];
for (int i = 1; i <= c2; i++) q[l + c1 + i - 1] = q2[i];
solve(l, l + c1 - 1, vl, mid);
solve(l + c1, r, mid + 1, vr);
}
signed main(void) {
read(n); read(m);
for (int i = 1; i <= n; i++) {
int x; read(x);
q[++cnt] = (Node){1, i, x, 1, 0, 0, 0, 0};
b[i] = x;
}
sort(b + 1, b + n + 1);
tot = unique(b + 1, b + n + 1) - b - 1;
for (int i = 1; i <= n; i++)
q[i].key = lower_bound(b + 1, b + tot + 1, q[i].key) - b;
for (int i = 1; i <= m; i++) {
int l, r, k; read(l); read(r); read(k);
q[++cnt] = (Node){2, 0, 0, 0, l, r, k, i};
}
solve(1, cnt, 1, tot);
for (int i = 1; i <= m; i++) writeln(b[ans[i]]);
return 0;
}
/**/
2.3 同一份模板怎么做带修
洛谷 P2617、ZOJ 2112 Dynamic Rankings:单点改值,询问区间第 \(k\) 小。\(n,m\\le 10^5\)。
solve 保持上一节的代码。操作序列按时间排:
- 先放初始序列,每条是
coef = +1。 - 遇到把位置 \(x\) 从
old改成nw:先放一条(pos=x, key=old, coef=-1),再放一条(pos=x, key=nw, coef=+1),然后令old = nw。 - 遇到询问,照旧放
typ = 2。
正确性:扫到一个询问时,树状数组里是「时间更早、并且 key <= mid」的修改净额。划分是按扫描顺序分别追加到左右两个数组的,时间顺序还在。净额 \(\\ge k\) 说明第 \(k\) 小落在 \(\\le mid\) 的那些值里;否则把这 \(s\) 个名额扣掉,到更大的值里去找第 \(k-s\) 小。
改值产生的新数一定要参与离散化。
2.4 CF 484E Sign on Fence
题目,Codeforces Round #276 (Div. 1) E。\(n,m\le 10^5\),高度 \(1\le h_i\le 10^9\)。
篱笆每块木板宽 1、高 \(h_i\)。每次询问给出区间 \([l,r]\) 和宽度 \(w\),要求一块宽恰好为 \(w\) 的牌子完全落在这个区间的某 \(w\) 块连续木板上。牌子的高度不能超过它盖住的最矮木板。求这个高度的最大值。
等价说法:在 \([l,r]\) 内选一个长度为 \(w\) 的子区间,让区间最小值最大。
单次询问可以二分高度 \(h\):把 \(\ge h\) 的位置看成 1,问 \([l,r]\) 里最长连续 1 是否 \(\ge w\)。连续 1 的长度至少为 \(w\),就含有一个长度为 \(w\) 的子段。最长连续 1 用线段树维护,每个节点记区间长度、最长 1 前缀、最长 1 后缀、最长连续 1。合并是
前缀在左儿子全是 1 时要接上右儿子的前缀,后缀对称。
询问有 \(10^5\) 个,逐个二分会多一个 \(\\log\)。改成整体二分,值域 \([1,10^9]\),mid 取上中点 \((L+R+1)/2\),因为要的是最大的合法高度。
这里不能做 k -= s。高度 \(\ge mid\) 的木板,在更小的阈值下仍然是 1,它们会把两边的矮木板粘成一段。左边已经形成的连续长度,不能从询问里扣掉再拿去右边用。
所以这一层的顺序是:
- 把当前操作里高度 \(\\ge mid\) 的木板临时写成 1,检查每个询问。最长连续 1 \(\\ge w\) 的,答案 \(\\ge mid\),去高的一侧;否则去低的一侧。
- 把这些临时的 1 改回 0。
- 先递归高的一侧 \([mid,R]\)。本层刚刚写上又撤销的那些 1 已经清掉,高侧自己会把更高的木板插进去。更外层留下来的木板只可能更高,它们继续是 1 是对的。
- 高侧返回后,把高度 \(\\ge mid\) 的木板重新写成 1,并且留在树上。
- 再递归低的一侧 \([L,mid-1]\)。低侧看到的 1,已经包含所有更高的木板,这些木板会把矮木板粘在一起。
更新是赋值成 0 或 1,同一位置写两次 1 没有副作用。高侧返回时,高度 \(\\ge mid\) 的位置已经是 1,父层再赋一次 1 是同一个状态。
线段树查询如果横跨 mid,要拿两边的返回值做一次和 pushup 相同的合并。返回值里的 len 必须是这次询问覆盖到的长度,不能用节点的整段长度去接前缀,所以只有完全覆盖的节点直接返回,跨界时用合并函数现算。
复杂度 \(O((n+m)\\log A\\log n)\),\(A=10^9\),大约 30 层。
样例 1 2 2 3 3:
- 询问 \([1,5],\\ w=3\)。长度 3 的子段最小值最大是 2,来自 \((2,2,3)\) 或 \((2,3,3)\)。
- 询问 \([2,5],\\ w=2\)。\((3,3)\) 的最小值是 3。
- 询问 \([1,5],\\ w=5\)。五块一起的最小值是 1。
易错点:
- 先做高侧,再把高木板留在树上去做低侧。顺序反了,低侧会缺掉本该是 1 的高木板。
mid - 1在L < R时一定 \(\\ge L\),因为上中点至少是 \(L+1\)。L==R要先返回。- 插入操作和询问操作放在同一个数组里一起划分。插入用
id = 0标记。
const int N = 1e5 + 5;
const int INF = 1e9;
int n, m, cnt, ans[N], tag[N << 1];
struct Info {
int len, pre, suf, mx;
} tr[N << 2];
struct Op {
int id, l, r, w;
} ope[N << 1], to1[N << 1], to2[N << 1];
void pushup(int p) {
int ls = p << 1, rs = p << 1 | 1;
tr[p].len = tr[ls].len + tr[rs].len;
tr[p].mx = tr[ls].mx;
chkmax(tr[p].mx, tr[rs].mx);
chkmax(tr[p].mx, tr[ls].suf + tr[rs].pre);
tr[p].pre = tr[ls].pre;
if (tr[ls].pre == tr[ls].len) tr[p].pre += tr[rs].pre;
tr[p].suf = tr[rs].suf;
if (tr[rs].suf == tr[rs].len) tr[p].suf += tr[ls].suf;
}
void build(int p, int l, int r) {
if (l == r) {
tr[p].len = 1;
tr[p].pre = tr[p].suf = tr[p].mx = 0;
return;
}
int mid = (l + r) >> 1;
build(p << 1, l, mid);
build(p << 1 | 1, mid + 1, r);
pushup(p);
}
void upd(int p, int l, int r, int x, int v) {
if (l == r) {
tr[p].pre = tr[p].suf = tr[p].mx = v;
return;
}
int mid = (l + r) >> 1;
if (x <= mid) upd(p << 1, l, mid, x, v);
else upd(p << 1 | 1, mid + 1, r, x, v);
pushup(p);
}
Info merge(Info a, Info b) {
Info c;
c.len = a.len + b.len;
c.mx = a.mx;
chkmax(c.mx, b.mx);
chkmax(c.mx, a.suf + b.pre);
c.pre = a.pre;
if (a.pre == a.len) c.pre += b.pre;
c.suf = b.suf;
if (b.suf == b.len) c.suf += a.suf;
return c;
}
Info qry(int p, int l, int r, int ql, int qr) {
if (ql <= l && r <= qr) return tr[p];
int mid = (l + r) >> 1;
if (qr <= mid) return qry(p << 1, l, mid, ql, qr);
if (ql > mid) return qry(p << 1 | 1, mid + 1, r, ql, qr);
return merge(qry(p << 1, l, mid, ql, qr), qry(p << 1 | 1, mid + 1, r, ql, qr));
}
void solve(int ql, int qr, int L, int R) {
if (ql > qr) return;
if (L == R) {
for (int i = ql; i <= qr; i++)
if (ope[i].id) ans[ope[i].id] = L;
return;
}
int mid = (L + R + 1) >> 1, c1 = 0, c2 = 0;
for (int i = ql; i <= qr; i++) {
if (!ope[i].id) {
if (ope[i].r >= mid) upd(1, 1, n, ope[i].l, 1), to2[c2++] = ope[i], tag[i] = 2;
else to1[c1++] = ope[i], tag[i] = 0;
} else if (qry(1, 1, n, ope[i].l, ope[i].r).mx >= ope[i].w) to2[c2++] = ope[i], tag[i] = 1;
else to1[c1++] = ope[i], tag[i] = 0;
}
for (int i = ql; i <= qr; i++)
if (tag[i] == 2) upd(1, 1, n, ope[i].l, 0);
int t = ql;
for (int i = 0; i < c1; i++) ope[t++] = to1[i];
for (int i = 0; i < c2; i++) ope[t++] = to2[i];
solve(ql + c1, qr, mid, R);
for (int i = ql + c1; i <= qr; i++)
if (!ope[i].id) upd(1, 1, n, ope[i].l, 1);
solve(ql, ql + c1 - 1, L, mid - 1);
}
signed main(void) {
read(n);
for (int i = 1; i <= n; i++) {
int x; read(x);
ope[++cnt] = (Op){0, i, x, 0};
}
read(m);
for (int i = 1; i <= m; i++) {
int l, r, w; read(l); read(r); read(w);
ope[++cnt] = (Op){i, l, r, w};
}
build(1, 1, n);
solve(1, cnt, 1, INF);
for (int i = 1; i <= m; i++) writeln(ans[i]);
return 0;
}
/**/
3. 线段树分治
3.1 时间轴上的区间
前两套分治统计的是「点对点」的贡献。还有一种修改自带生命周期:一条边在时刻 \(L\) 加入、时刻 \(R\) 删除,中间每个时刻都在。
把时间 \([1,q]\) 建成线段树。边的存在区间 \([L,R]\) 可以拆成线段树上 \(O(\\log q)\) 个被整段覆盖的节点,和区间加标记相同。然后 DFS 这棵树:
- 进入节点,把挂在这里的边全部加进数据结构。
- 若是叶子,当前数据结构就是这个时刻的答案。
- 否则递归左右儿子。儿子会再加上自己的边。
- 返回前,把本节点加的边撤销。
一条从根到叶子的路径上的边,并起来就是该时刻存在的全部边。每条边只挂在 \(O(\\log q)\) 个节点上,总代价是「单次加入」乘 \(O(q\\log q)\)。
撤销要求数据结构能回到上一个版本。并查集做得到:按大小合并,不路径压缩,栈里记下被改的儿子和父亲。路径压缩会把沿途父亲都改掉,撤销就不再是 \(O(1)\) 一条边。
3.2 CF 813F Bipartite Checking
题目,Educational Codeforces Round 22 F。\(2\le n,q\le 10^5\),时限 6 秒。
\(n\) 个点,一开始没有边。每次给两个点:这条边不存在就加上,存在就删掉。每次操作后输出图是否为二分图。
二分图等价于没有奇环。只有加边时,扩展域并查集可以在线做。这题要删边,并查集不能任意删除一条历史边,但可以撤销最后一次修改。于是把每条边的若干存在区间拆出来,走线段树分治。
扩展域:点 \(u\) 和 \(u+n\) 表示两个颜色。无向边 \(u-v\) 变成两条合并:\((u,\\ v+n)\) 和 \((v,\\ u+n)\)。若某个点的两个颜色进了同一集合,即 find(u) == find(u+n),就有奇环。
操作是翻转,用 map 记每条边最近一次被加入的时刻。再次出现就是删除,存在区间是 \([加入时刻,\\ 删除时刻-1]\)。一直留到最后的边,区间是 \([加入时刻,\\ q]\)。
DFS 到某个节点时,如果本节点的边已经造出奇环,那么这个节点覆盖的整段时间都有这些边,整段都是 NO,不用再往下走。否则叶子处记 YES。输出按时间顺序,叶子从左到右正好是 \(1..q\)。
复杂度 \(O(q\log q\log n)\)。并查集单次是 \(O(\\log n)\),因为没有路径压缩。
样例三次加边 \(2-3\)、\(1-3\)、\(1-2\) 形成三角形,第三次是 NO;删掉 \(1-2\) 恢复 YES;再加回来又是 NO。
易错点:
- 撤销要按栈的逆序,先减掉大小再把父亲改回去。减大小时儿子的
sz还是它被挂上去时的值,所以必须后加的先撤。 - 两个端点已经在同一集合时,
merge直接返回,不要往栈里塞空记录,否则撤销会把不存在的修改减掉。 - 区间是 \([L,R]\) 且 \(L\le R\) 才插入。删除发生在加入的下一刻时,区间可以是单点。
- 扩展域开 \(2n\)。判断奇环时两个端点都查一次,防止只查到其中一个。
const int N = 1e5 + 5;
int n, q, ec, top, eu[N], ev[N], fa[N << 1], sz[N << 1], ans[N];
vector <int> g[N << 2];
pii stk[N << 2];
map <pii, int> pre;
int find(int x) {
while (fa[x] != x) x = fa[x];
return x;
}
void merge(int x, int y) {
x = find(x); y = find(y);
if (x == y) return;
if (sz[x] > sz[y]) swap(x, y);
stk[++top] = Mp(x, y);
fa[x] = y;
sz[y] += sz[x];
}
void undo(int bot) {
while (top > bot) {
int x = stk[top].first, y = stk[top].second;
sz[y] -= sz[x];
fa[x] = x;
top--;
}
}
void ins(int p, int l, int r, int L, int R, int id) {
if (L <= l && r <= R) {
g[p].push_back(id);
return;
}
int mid = (l + r) >> 1;
if (L <= mid) ins(p << 1, l, mid, L, R, id);
if (R > mid) ins(p << 1 | 1, mid + 1, r, L, R, id);
}
void dfs(int p, int l, int r) {
int bot = top, ok = 1;
for (int i = 0; i < (int)g[p].size(); i++) {
int id = g[p][i], u = eu[id], v = ev[id];
merge(u, v + n);
merge(v, u + n);
if (find(u) == find(u + n) || find(v) == find(v + n)) ok = 0;
}
if (!ok)
for (int i = l; i <= r; i++) ans[i] = 0;
else if (l == r) ans[l] = 1;
else {
int mid = (l + r) >> 1;
dfs(p << 1, l, mid);
dfs(p << 1 | 1, mid + 1, r);
}
undo(bot);
}
signed main(void) {
read(n); read(q);
for (int i = 1; i <= q; i++) {
int u, v; read(u); read(v);
if (u > v) swap(u, v);
pii t = Mp(u, v);
if (pre.count(t)) {
eu[++ec] = u; ev[ec] = v;
ins(1, 1, q, pre[t], i - 1, ec);
pre.erase(t);
} else pre[t] = i;
}
for (map <pii, int>::iterator it = pre.begin(); it != pre.end(); ++it) {
eu[++ec] = it->first.first;
ev[ec] = it->first.second;
ins(1, 1, q, it->second, q, ec);
}
for (int i = 1; i <= n << 1; i++) fa[i] = i, sz[i] = 1;
dfs(1, 1, q);
for (int i = 1; i <= q; i++) puts(ans[i] ? "YES" : "NO");
return 0;
}
/**/
同一模型的下一题是 CF 576E Painting Edges:边带颜色,每次问「只保留某几种颜色时是否仍是二分图」。还是时间线段树加可撤销并查集,颜色用按位或挂到节点上。
4. 名字相近、模型不同的两样东西
分治优化 DP。转移形如 \(f_i=\min\_{j<i} f_j+cost(j+1,i)\),并且最优决策点随 \(i\) 单调。分治的是决策区间:算 \([L,R]\) 这些位置的 DP 时,决策点整体落在 \([ql,qr]\),先算中点,再把决策范围切给左右。cost 往往用双指针在单调的调用顺序里 \(O(1)\) 摊还。代表题是 CF 868F Yet Another Minimization Problem、CF 321E Ciel and Gondolas。它不维护「左边对右边的偏序贡献」。
点分治。找重心把树拆成若干子树,统计经过重心的路径,再递归。分治的对象是树,撤销的是「当前子树的标记」。和本讲的时间轴、值域无关。
5. 上场前对一下
CDQ:
- 数组按时间或第一维有序,先递归再排序第二维。
- 只让左边的更新进入树状数组,只让右边的询问查询。
- 树状数组逐个撤回。
- 三维全部相等要先合并。
整体二分:
- 贡献能减:\(s\ge k\) 去左边,否则 \(k-=s\) 去右边,划分保持时间顺序。
- 贡献不能减:先在空结构上做「更大的答案」,再把更大的元素留在结构里去做更小的答案。
- 带修就是
+1和-1两条修改,solve不用重写。
线段树分治:
- 修改的生命周期挂到时间线段树的 \(O(\\log)\) 个节点。
- 进入时加入,返回时按栈撤销。
- 并查集按大小合并,不路径压缩。
6. 作业
按这个顺序写。前三题是把讲义代码改到能独立打出来,后面才是新模型。
- 洛谷 P3810,不看讲义重写一遍 CDQ。
- 洛谷 P3834,再改成洛谷 P2617 带修第 \(k\) 小。只改操作序列和离散化。
- CF 61E、CF 1093E、CF 484E、CF 813F。对拍用 \(n\le 20\) 的暴力即可,讲义里的实现就是按这个对过的。
- 洛谷 P3527 [POI2011] MET-Meteors。每个国家问「流星雨下到第几天,收集量才够」。答案关于天数单调,所有国家一起二分。某一层把编号落在值域左半的流星加进树状数组(陨石坑是环,拆成线段)。这是整体二分最标准的「贡献可加」题,比第 \(k\) 小更干净。
- HDU 5618 Jam's problem again,2016 多校。三维严格偏序,P3810 把 \(\le\) 改成 \(<\),注意相等的处理。
- 洛谷 P3157 [CQOI2011] 动态逆序对。删除时间、位置、权值。先算初始逆序对,再对每个删除时刻算它毁掉了多少对。
- HDU 5126 Stars。四维偏序,CDQ 套 CDQ。外层切一维,内层就是本讲的三维 CDQ。
- CF 576E Painting Edges。线段树分治加可撤销并查集,在 813F 上加颜色限制。
- 进阶,不要求本讲写完:CF 848C Goodbye Souvenir(区间内每个权值的最后出现位置减最前出现位置之和,CDQ 加线段树),CF 765F Souvenirs(区间最小绝对差,CDQ 维护候选)。
区域赛里这三套算法很少以裸模板出现,常见形态是「离线之后发现贡献带时间」或者「一堆询问的答案都单调」。多校和 CF Div.1 D/E 里,上面这些题就是原题级别的训练。

浙公网安备 33010602011771号