CDQ、整体二分、树套树

CDQ分治

CDQ分治是一种可以解决一些与偏序有关的题的算法。

陌上花开

这个题让我们求三维偏序。

我们先想一想二维偏序(逆序对)我们是怎么做的。

Stars

我们先把第一维排序,这样就满足了第一维的限制。

对于第二维,我们可以用一个类似于桶的数据结构来解决第二维。

每次先询问在这个数前小于它的第二维的数有多少个,询问完后再把它的第二维加进桶。

struct node {
	int x, y, id;
	bool operator < ( const node & rhs) const { return x == rhs.x ? y < rhs.y : x < rhs.x; }
} a[N];
int n, tr[N], ans[N];
void add( int u, int v) { for (; u <= 32001; u += (u & -u)) tr[u] += v; }
int ask( int u, int res = 0) { for (; u; u -= (u & -u)) res += tr[u]; return res; }
signed main() {
	n = read();
	for ( int i = 1; i <= n; i ++) a[i].x = read(), a[i].y = read(), a[i].id = i, a[i].y ++/*加一是因为树状数组下标不能为0*/;
	sort(a + 1, a + n + 1);
	for ( int i = 1; i <= n; i ++) ans[ask(a[i].y)] ++, add(a[i].y, 1);//把第二维放进桶里
	for ( int i = 0; i < n; i ++) printf("%d\n", ans[i]);
}

其实不难发现,这种偏序题都是在先满足一维的条件下去搞剩下的维。

那么三维是否也可以这么做呢?

我们先把第一维排序,这样就满足了第一维。

那么剩下的两维怎么办?

此时,有个神奇的东西要来了——归并。

我们回顾一下归并排序的过程,每次分成两个子区间一直递归下去,递归到最后一层时,合并 \([l,mid]\)\([mid+1,r]\) 的数,然后再一层一层合上去。

每次合并时,只会判断 \([l,mid]\) 之间的数和 \([mid+1,r]\) 之间的数的大小关系,并不会判断同个区间内数的大小关系,因为同个区间的数在上一层就已经被排好序了。

所以归并的核心就是“先递归,再合并”,类似于线段树的 pushup

void mysort( int l, int r) {
	if (l == r) return ; ; int mid = (l + r) >> 1;
	mysort(l, mid), mysort(mid + 1, r);//先递归 再合并
	int p = l, q = mid + 1, tot = l;
	while (p <= mid && q <= r) {
		if (a[p] < a[q]) tmp[tot ++] = a[p ++];
		else tmp[tot ++] = a[q ++];
	}
	while (p <= mid) tmp[tot ++] = a[p ++];
	while (q <= r) tmp[tot ++] = a[q ++];
	for ( int i = l; i <= r; i ++) a[i] = tmp[i];
}

说回三维偏序,现在我们已经解决了第一维,有了归并的思想后,不难想到第二维用归并来解决。

对于分治出的 \([l,mid]\)\([mid+1,r]\) 的两个区间,它们的第一维肯定已经满足,并且我们只在乎这两个区间之间的数的关系,不在乎同个区间内数的关系,所以直接给这两个区间以第二维为关键字排序,就算第一维被打乱了也没关系,因为同一个区间的数的关系我们是不在乎的(为什么能不在乎?因为在上一层这个区间就已经被计算完了)。

现在得到了两个按照第二维排序的区间,我们沿用归并排序的方式,用两个指针扫就行了,同时用一个桶来维护第三维。

完整代码。

int n, k, tot;
struct node { int s, c, m, cnt, sum; 
	node( int _s = 0, int _c = 0, int _m = 0, int _cnt = 0, int _sum = 0) { s = _s, c = _c, m = _m, cnt = _cnt, sum = _sum; }
	bool operator < ( const node & rhs) {
		if (s == rhs.s) return c == rhs.c ? m < rhs.m : c < rhs.c;
		return s < rhs.s;
	}
} a[N], Q[N];
bool cmp( node a, node b) { return a.c == b.c ? a.s < b.s : a.c < b.c; }
int tr[N], ans[N];
void add( int u, int v) { for (; u <= k; u += (u & -u)) tr[u] += v; }
int ask( int u, int res = 0) { for (; u; u -= (u & -u)) res += tr[u]; return res; }
void Genshin( int l, int r) {
	if (l == r) return ;
	int mid = (l + r) >> 1;
	Genshin(l, mid), Genshin(mid + 1, r);//先递归 再合并
	sort(Q + l, Q + mid + 1, cmp), sort(Q + mid + 1, Q + r + 1, cmp);//按照第二维排序
	int p = l, q = mid + 1;
	while (p <= mid && q <= r) {
		if (Q[q].c >= Q[p].c) add(Q[p].m, Q[p].cnt), p ++;
		else Q[q].sum += ask(Q[q].m), q ++;
	}
	while (q <= r) Q[q].sum += ask(Q[q].m), q ++;
	for ( int i = l; i < p;  i++) add(Q[i].m, -Q[i].cnt);//清空桶
}
signed main() {
	n = read(), k = read();
	for ( int i = 1; i <= n; i ++) a[i].s = read(), a[i].c = read(), a[i].m = read();
	sort(a + 1, a + n + 1);//按照第一维排序
	Q[1] = a[1], Q[1].cnt = 1, tot = 1;
	for ( int i= 2; i <= n; i ++) {//去重,因为三个一样的会互相贡献
		if (a[i].s == Q[tot].s && a[i].c == Q[tot].c && a[i].m == Q[tot].m) Q[tot].cnt ++;
		else Q[++ tot] = a[i], Q[tot].cnt = 1;
	}
	Genshin(1, tot); 
	for ( int i = 1; i <= tot; i ++) ans[Q[i].sum + Q[i].cnt - 1] += Q[i].cnt;
	for ( int i = 0; i < n; i ++) printf("%d\n", ans[i]);
}

上面这个代码复杂度显然是 \(O(n\log n \log k)\),归并时只会有 \(\log n\) 层。


总之,CDQ分治是一个解决三维偏序的好方法,总结就是第一维直接排,第二维归并,第三维用数据结构。

许多题目都可以转换成三个偏序,这时就可以使用CDQ分治解决。

当然CDQ肯定不只限于求三维偏序,但这里就不再扩展。

发现CDQ是需要离线的。

整体二分

整体二分是一个能对一个整体二分的方法(。

但是确实是这样。

先想这样一个问题:给 \(n\) 个数,找一个区间 \([l,r]\) 内的第 \(k\) 小的数。

通常我们可以把 \([l,r]\) 这个区间的数提取出来,再排个序,就可以直接输出第 \(k\) 小。

但还有一个方法,就是先二分出一个答案 \(mid\),然后再在 \([l,r]\) 中检验 \(mid\) 是否是答案,然后再调整 \(mid\),这就是二分答案。

这两个方法的复杂度都是 \(O(n\log n)\)


把这个问题升级一下:给 \(n\) 个数和 \(m\) 个区间,找每个区间 \([l_i,r_i]\) 的第 \(k_i\) 小的数。

从一个询问变成了 \(m\) 个询问。

K-th Number

如果沿用二分答案的做法,对于每个区间都要二分一遍答案,复杂度就来到了 \(O(nm\log n)\),肯定无法接受。

那怎么办呢?我们是否可以对所有区间只进行一次二分呢?

肯定是可以的,但是要改一下二分方法,我们可以把二分放在递归上进行。

首先确定一个答案所在的区间 \([l,r]\),和一个答案在 \([l,r]\) 内的询问区间 \([ql,qr]\)(不一定只是询问,也可以是修改)。

我们把答案所在的区间分成两个 \([l,mid]\)\([mid+1,r]\),经过一些检验,可以发现 \([ql,qr]\) 的某些询问的答案在 \([l,mid]\),某些询问的答案在 \([mid+1,r]\)

将询问区间分好类后,就可以继续递归下去。

显然当 \(l=r\) 时,\([ql,qr]\) 的所有询问的答案都是 \(l\)

这样我们就实现了询问和答案的一起递归,也就是整体二分。

不难发现顺序是先分类再递归。

检验时一般会用到一些数据结构。

int n, m, tot, len;
int a[N], ind[N], tr[N], ans[N];
struct que {
	int l, r, k, op, id;
	que( int _l = 0, int _r = 0, int _k = 0, int _op = 0, int _id = 0) { l = _l, r = _r, k = _k, op = _op, id = _id; }
} q[M], q1[M], q2[M];
//q1 为适合 [l,mid] 的区间,q2 为适合 [mid+1,r] 的区间 
void add( int u, int v) { for (; u <= n; u += (u & -u)) tr[u] += v; }
int ask( int u) { int res = 0; for (; u; u -= (u & -u)) res += tr[u]; return res; }
void Genshin( int l, int r, int ql, int qr) {
	if (ql > qr) return ;
	if (l == r) { for ( int i = ql; i <= qr; i++) if (q[i].op == 2) ans[q[i].id] = l; return ;}
	int mid = (l + r) >> 1, c1 = 0, c2 = 0;
    //我们可以把这个 mid 理解为二分的答案
	for ( int i = ql; i <= qr; i ++) {
		if (q[i].op == 1) {
			if (q[i].l <= mid) add(q[i].id, 1), q1[++ c1] = q[i];//现在假设 mid 是答案,所以只需要把小于 mid 的数加进桶
			else q2[++ c2] = q[i];
		}
		else {
			int s = ask(q[i].r) - ask(q[i].l - 1);//在 [l,r] 内小于 mid 的数的个数
			if (q[i].k <= s) q1[++ c1] = q[i];//答案在 [l,mid] 内 
			else q[i].k -= s, q2[++ c2] = q[i];//答案在 [mid+1,r] 内,注意:在 [mid+1,r] 内找的是第 k-s 小 
		}
	}
	for ( int i = 1; i <= c1; i ++) if (q1[i].op == 1) add(q1[i].id, -1);
	for ( int i = 1; i <= c1; i ++) q[ql + i - 1] = q1[i];
	for ( int i = 1; i <= c2; i ++) q[ql + c1 + i - 1] = q2[i];
	Genshin(l, mid, ql, ql + c1 - 1), Genshin(mid + 1, r, ql + c1, qr);
}
signed main() {
	n = read(), m = read();
	for ( int i = 1; i <= n; i ++) ind[i]= a[i] = read();//离散化,因为树状数组存不下 
	sort(ind + 1, ind + 1 + n), len = unique(ind + 1, ind + 1 + n) - ind - 1;
	for ( int i = 1; i <= n; i ++) a[i] = lower_bound(ind + 1, ind + 1 + len, a[i]) - ind, q[++ tot]= que(a[i], -1, -1, 1, i);
	for ( int i = 1, l, r, k; i <= m; i ++) l = read(), r = read(), k = read(), q[++ tot]= que(l, r, k, 2, i);
	Genshin(1, len, 1, tot);
	for ( int i = 1; i <= m; i ++) printf("%d\n", ind[ans[i]]);
}

虽然复杂度是 \(O(n\log^2 n)\),但是没比 \(O(n\log n)\) 的主席树慢多少。

发现整体二分是需要离线的。

树套树

这个东西十分垃圾,但是它泛用啊(其实也不是很泛用,比如说树套树不能求最长上升三维偏序,但CDQ可以,因为CDQ是支持DP的)。

CDQ分治和整体二分有一个条件,就是需要把操作离线下来,如果题目强制在线,那么它们两个做不了一点。

树套树就出来了,它诠释了什么是完美的暴力。

俗话讲,树套树就是两个 \(\log\) 级别的数据结构套起来,但两个数据结构中必有一个是支持动态开点的,不然的话空间开不下。

常见的一般是树状数组套动态开点线段树,平衡树套动态开点线段树,不管怎么套,都绕不开动态开点。

陌上花开

还是这个题,但现在假设它强制在线,CDQ做不了。

只能考虑树套树怎么做。

还是老样子,把第一维排个序,剩下两维的做法就很暴力了。

我们考虑树状数组套动态开点线段树,树状数组处理第二维,动态开点线段树处理第三维。

这个题,树状数组的下标就是第二维,线段树的下标就是第三维。

具体实现就是以每个树状数组节点为根建立一个线段树,一个树状数组节点的线段树维护的信息都包含了自己和自己所有的儿子节点的线段树维护的信息。

很好理解吧。

int n, k, tot, cnt;
vector< int> q;
struct node { int a, b, c, cnt; } s[N], p[N];
bool cmp( node a, node b) { if (a.a == b.a) { if (a.b == b.b) return a.c < b.c; return a.b < b.b; } return a.a < b.a; }
int sum[N * 18 * 18], ls[N * 18 * 18], rs[N * 18 * 18], rt[N], ans[N], vis[N];
#define mid ((l+ r)>> 1) 
void insert( int & k, int l, int r, int x) {
	if (! k) k = ++tot; ; if (l == r) return sum[k] += 1, void();
	x <= mid ? insert(ls[k], l, mid, x) : insert(rs[k], mid + 1, r, x), sum[k] = sum[ls[k]] + sum[rs[k]];
}
void add( int u, int x) { for (; u <= k; u += (u & -u)) insert(rt[u], 1, k, x); }
void query( int u) { for (; u; u -= (u & -u)) q.push_back(rt[u]); }
int ask( int l, int r, int x, int y, vector< int> tmp) {
	int res = 0; vector< int> now; 
	if (x <= l && r <= y) { for ( auto v : tmp) res += sum[v]; return res; }
	if (x <= mid) { now = tmp; for ( auto & v : now) v = ls[v]; res += ask(l, mid, x, y, now); }
	if (y > mid) { now = tmp; for ( auto & v : now) v = rs[v]; res += ask(mid + 1, r, x, y, now); }
	return res;
}
signed main() {
	n = read(), k = read();
	for ( int i = 1; i <= n; i ++) s[i].a = read(), s[i].b = read(), s[i].c = read();
	sort(s + 1, s + n + 1, cmp);
	for ( int i = 1; i < n; i ++) if (s[i].a == s[i + 1].a && s[i].b == s[i + 1].b && s[i].c == s[i + 1].c) vis[i + 1]= 1;
	for ( int i = 1; i <= n; i ++) { if (! vis[i]) p[++ cnt] = s[i]; p[cnt].cnt ++; }
	for ( int i = 1; i <= cnt; i ++) {
		q.clear(), query(p[i].b); int t = ask(1, k, 1, p[i].c, q);
		for ( int j = 1; j <= p[i].cnt; j ++) add(p[i].b, p[i].c), ans[t - 1 + p[i].cnt] ++;
	}
	for ( int i = 0; i < n; i ++) printf("%d\n", ans[i]);
}

时间复杂度是 \(O(n\log^2 k)\),空间复杂度是 \(O(n\log^2 k)\)

树套树虽然时间常数大,但是至少和CDQ与整体二分都是 \(n\log^2\) 的水平。它差就差在空间复杂度,因为 \(n\log^2\) 级别的和 \(n\) 级别的空间复杂还是有很大差距的。

总结就是强制在线才用,其他时候别用。

总结

这三个算法就说完了,各自都有自己的有点和缺点。

CDQ多用于偏序的一系列问题,要求离线;整体二分用于答案具有二分性,且多组询问的问题,要求离线;树套树多用于一些在线的数数问题。

泛用性:CDQ和整体二分分别解决两种不同类型的问题(仅限离线)。CDQ和整体二分能做的树套树基本能做,在线的问题就只能树套树了。

时空复杂度:CDQ和整体二分薄纱树套树。

代码复杂度:感觉差不多吧。

posted @ 2025-08-14 10:18  咚咚的锵  阅读(14)  评论(0)    收藏  举报