分治专题讲义

分治专题讲义:CDQ 分治、整体二分、线段树分治

这类算法解决的是同一类问题:单次询问会二分、会用数据结构,但询问有 \(10^5\) 个,每个都重做就会超时。我们的做法是把所有询问离线,让一次分治同时服务所有询问。

预计两节课能讲完,作业放在文末。


0. 适用范围

工具 分治的是什么 数据结构里放什么 典型题目
CDQ 分治 已经排好序的那一维(常常是时间或下标) 另一维,用树状数组 三维偏序、带修改的二维数点
整体二分 答案的值域 当前值域左半边已经确定的贡献 区间第 \(k\) 小、最大值最小化
线段树分治 时间轴上的区间 在整段时间里都存在的修改,离开时撤销 加边删边、可撤销并查集

共同点:

  • 每一对「修改、询问」只在某一层被统计一次。
  • 这一层统计完,数据结构要回到进来之前的样子。树状数组逐个减回去;并查集用栈撤销。不要 memset 整棵树。
  • 能离线才用。必须在线时,对应的是主席树、树套树,代码量和常数都更大。

分治优化 DP(决策单调性,如 CF 868F)和点分治(树上重心)名字里也有分治,模型和本讲不是一套,文末只划边界。


1. CDQ 分治

1.1 从二维偏序看贡献

一维偏序就是排序。二维偏序:按第一维排序之后,问题变成「前面的点里,第二维比我小的有多少个」,树状数组从左往右加、查询即可。归并排序求逆序对是同一件事,只是第二维用双指针,不必树状数组。

三维偏序多出来的那一维交给分治。分治保证左半边的第一维更小,当前层再按第二维排序,第三维仍然用树状数组。这就是 CDQ。

CDQ 在区间 \([l,r]\) 上做三件事:

  1. 递归算出左半边内部、右半边内部的答案。
  2. 此时左半边每个点的第一维都不大于右半边。只统计「左边对右边」的贡献。
  3. 统计时左右各自按第二维排序,第三维丢进树状数组。统计完把树状数组减回去。

任意两点如果第一维有序,它们会在某一层分居左右,贡献恰好算一次。同一侧的点留给子问题。所以递归前不要先按第二维打乱整段;先递归,再在当前层排序。子问题返回后,当前层再按第二维排是安全的,因为子问题的答案已经记在点上了。

复杂度:设值域为 \(k\)。每一层树状数组代价 \(O(n\\log k)\),层数 \(O(\\log n)\),总计 \(O(n\\log n\\log k)\)。每一层对两半各 sort 一次,同样是 \(O(n\\log^2 n)\)。

1.2 模板:三维偏序(洛谷 P3810 / BJOI2011 陌上花开)

P3810

有 \(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\),答案是

\[\sum_j |{i<j:a_i>a_j}|\times|{k>j:a_k<a_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\) 的点数,则

\[S(r_a,r_b)-S(l_a-1,r_b)-S(r_a,l_b-1)+S(l_a-1,l_b-1). \]

每个查询拆成 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)

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 保持上一节的代码。操作序列按时间排:

  1. 先放初始序列,每条是 coef = +1。
  2. 遇到把位置 \(x\) 从 old 改成 nw:先放一条 (pos=x, key=old, coef=-1),再放一条 (pos=x, key=nw, coef=+1),然后令 old = nw。
  3. 遇到询问,照旧放 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。合并是

\[mx=\max(mx_L,mx_R,suf_L+pre_R), \]

前缀在左儿子全是 1 时要接上右儿子的前缀,后缀对称。

询问有 \(10^5\) 个,逐个二分会多一个 \(\\log\)。改成整体二分,值域 \([1,10^9]\),mid 取上中点 \((L+R+1)/2\),因为要的是最大的合法高度。

这里不能做 k -= s。高度 \(\ge mid\) 的木板,在更小的阈值下仍然是 1,它们会把两边的矮木板粘成一段。左边已经形成的连续长度,不能从询问里扣掉再拿去右边用。

所以这一层的顺序是:

  1. 把当前操作里高度 \(\\ge mid\) 的木板临时写成 1,检查每个询问。最长连续 1 \(\\ge w\) 的,答案 \(\\ge mid\),去高的一侧;否则去低的一侧。
  2. 把这些临时的 1 改回 0。
  3. 先递归高的一侧 \([mid,R]\)。本层刚刚写上又撤销的那些 1 已经清掉,高侧自己会把更高的木板插进去。更外层留下来的木板只可能更高,它们继续是 1 是对的。
  4. 高侧返回后,把高度 \(\\ge mid\) 的木板重新写成 1,并且留在树上。
  5. 再递归低的一侧 \([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. 作业

按这个顺序写。前三题是把讲义代码改到能独立打出来,后面才是新模型。

  1. 洛谷 P3810,不看讲义重写一遍 CDQ。
  2. 洛谷 P3834,再改成洛谷 P2617 带修第 \(k\) 小。只改操作序列和离散化。
  3. CF 61E、CF 1093E、CF 484E、CF 813F。对拍用 \(n\le 20\) 的暴力即可,讲义里的实现就是按这个对过的。
  4. 洛谷 P3527 [POI2011] MET-Meteors。每个国家问「流星雨下到第几天,收集量才够」。答案关于天数单调,所有国家一起二分。某一层把编号落在值域左半的流星加进树状数组(陨石坑是环,拆成线段)。这是整体二分最标准的「贡献可加」题,比第 \(k\) 小更干净。
  5. HDU 5618 Jam's problem again,2016 多校。三维严格偏序,P3810 把 \(\le\) 改成 \(<\),注意相等的处理。
  6. 洛谷 P3157 [CQOI2011] 动态逆序对。删除时间、位置、权值。先算初始逆序对,再对每个删除时刻算它毁掉了多少对。
  7. HDU 5126 Stars。四维偏序,CDQ 套 CDQ。外层切一维,内层就是本讲的三维 CDQ。
  8. CF 576E Painting Edges。线段树分治加可撤销并查集,在 813F 上加颜色限制。
  9. 进阶,不要求本讲写完:CF 848C Goodbye Souvenir(区间内每个权值的最后出现位置减最前出现位置之和,CDQ 加线段树),CF 765F Souvenirs(区间最小绝对差,CDQ 维护候选)。

区域赛里这三套算法很少以裸模板出现,常见形态是「离线之后发现贡献带时间」或者「一堆询问的答案都单调」。多校和 CF Div.1 D/E 里,上面这些题就是原题级别的训练。

posted @ 2026-10-01 19:46  EternalEpic  阅读(22)  评论(0)    收藏  举报