三维偏序问题

一、问题描述

有 n n n个元素,第 i i i个元素有 a i , b i , c i a_i,b_i,c_i ai,bi,ci三个属性,设 f ( i ) f(i) f(i)表示满足 a j ≤ a i a_j\le a_i aj≤ai且 b j ≤ b i b_j\le b_i bj≤bi且 c j ≤ c i c_j\le c_i cj≤ci且 j ≠ i j\ne i j=i的 j j j的数量。

对于 d ∈ [ 0 , n ) d\in [0,n) d∈[0,n),求 f ( i ) = d f(i)=d f(i)=d的数量。( 1 ≤ n ≤ 1 0 5 , 1 ≤ a i , b i , c i ≤ 2 ∗ 1 0 5 1\le n\le 10^5, 1\le a_i,b_i,c_i\le 2*10^5 1≤n≤105,1≤ai,bi,ci≤2∗105)

二、问题标签

b i t s e t bitset bitset、分治、 C D Q CDQ CDQ分治、树状数组、 K − D T r e e K-D\ Tree K−D Tree。

三、问题求解

方法一:暴力求解

依据题意 Θ ( n 2 ) \Theta(n^2) Θ(n2)模拟即可,能通过 n ≤ 5000 n\le 5000 n≤5000的数据,

cpp 复制代码
#include <bits/stdc++.h>
#define A 100010

using namespace std;
int n, k, a[A], b[A], c[A], sum, f[A];

int main(int argc, char const *argv[]) {
	ios::sync_with_studio(false); cin.tie(0); cout.tie(0);
	cin >> n >> k;
	for (int i = 1; i <= n; i++) cin >> a[i] >> b[i] >> c[i];
	for (int i = 1; i <= n; i++) {
		for (int j = 1; j <= n; j++)
			if (a[j] <= a[i] and b[j] <= b[i] and c[j] <= c[i] and i != j)
				sum++;
		f[sum]++; sum = 0;
	}
	for (int i = 0; i < n; i++) cout << f[i] << "\n";
}
方法二:分治

可以先尝试简化题意,题目问的是三个属性时的情况,我们可以先思考只对一个属性或两个属性排序时的情况。

只有一个属性时:有 n n n个数,第 i i i个元素有属性 a i a_i ai,设 f ( i ) f(i) f(i)表示满足 a j ≤ a i a_j\le a_i aj≤ai且 j ≠ i j\ne i j=i的 j j j的数量。

记录元素初始位置 i i i,将元素按照 a i a_i ai从小到大排序即可,其排完序后在新数组中的位置即为 f [ i ] f[i] f[i]的值,复杂度为排序所需的复杂度 Θ ( n l o g n ) \Theta(nlogn) Θ(nlogn)。

有两个属性时:有 n n n个数,第 i i i个元素有 a i , b i a_i, b_i ai,bi两个属性,设 f ( i ) f(i) f(i)表示满足 a j ≤ a i a_j\le a_i aj≤ai且 b j ≤ b i b_j\le b_i bj≤bi且 j ≠ i j\ne i j=i的 j j j的数量。

此时每个元素有两个属性,我们将这些元素以第一个属性 a i a_i ai作为第一关键字、第二个属性 b i b_i bi作为第二关键字排序,此时整个序列的 a i a_i ai数组是递增的,当连续有几个元素的 a i a_i ai相同时, b i b_i bi会递增排列,除此情况外 b i b_i bi序列会上下摆动。对于每一个 i i i,只有 1 1 1到 i − 1 i-1 i−1的元素可能对它有贡献(满足 a j ≤ a i a_j\le a_i aj≤ai),所以只需要在区间 [ 1 , i − 1 ] [1,i-1] [1,i−1]中查询满足 b j ≤ b i b_j\le b_i bj≤bi的元素个数即可。具体实现可以用树状数组,遇到 b j b_j bj时将其插入树状数组,然后查询 [ 1 , i − 1 ] [1,i-1] [1,i−1]中的元素个数即可,复杂度 Θ ( n l o g n ) \Theta(nlogn) Θ(nlogn)。

现在可以考虑三个属性的情况,仍然以 a i a_i ai为第一关键字、 b i b_i bi为第二关键字、 c i c_i ci为第三关键字排序,排序后就能保证第一维 ∀ i , j , 1 ≤ j < i ≤ n \forall i, j,\ 1\le j < i \le n ∀i,j, 1≤j<i≤n,有 a j < a i a_j<a_i aj<ai;此时如果保证第二维也是有序的,就可以像处理上一个问题一样统计答案。我们将排序后的序列一分为二,右侧区间的 a a a属性都大于左侧区间,也就是只有左侧区间的值能对右侧区间产生贡献。将区间分割后,我们以 b b b属性为第一关键字、 c c c属性为第二关键字排序,此时左右区间都分别以 b b b属性递增,维护 c c c属性的树状数组,若当前 b j ≤ b i b_j\le b_i bj≤bi,说明 j j j可以对后面加入的满足 c j ≤ c i c_j\le c_i cj≤ci的 i i i产生贡献了,就把 c j c_j cj加入树状数组,否则后面的j都不会对 i i i产生贡献了,查询树状数组 c i c_i ci的前缀和,统计之前累计的贡献。统计完这两个区间的答案后,要把树状数组清空,以便进行下一对区间的统计,清空时为了保证复杂度的 l o g log log,用树状数组减去负数的方式。时间复杂度 T ( n ) = Θ ( n l o g k ) + 2 T ( n 2 ) = Θ ( n l o g n l o g k ) T(n)=\Theta(nlogk)+2T(\frac{n}{2})=\Theta(nlognlogk) T(n)=Θ(nlogk)+2T(2n)=Θ(nlognlogk)。

注意序列中可能出现完全相同的元素,他们之前可以互相产生贡献,所以在计数前对数组去重,相同的元素只保留一个,用另一个数组存下某个元素出现的次数,统计答案的时候注意即可。

cpp 复制代码
#include <bits/stdc++.h>
#define A 200010

using namespace std;
struct node {
    int a, b, c, cnt, ans;
    bool operator < (const node &x) const {
        return a == x.a ? b == x.b ? c < x.c : b < x.b : a < x.a;
    }
    bool operator == (const node &x) const {
        return a == x.a and b == x.b and c == x.c;
    }
}e[A];
bool cmp(const node &x, const node &y) {
    return x.b == y.b ? x.c < y.c : x.b < y.b;
}
int n, m, k, ans[A], t[A], cnt;
int lowbit(int x) {return x & -x;}
void add(int x, int val) {
    while (x <= k) {
        t[x] += val;
        x += lowbit(x);
    }
}
int ask(int x, int sum = 0) {
    while (x) {
        sum += t[x];
        x -= lowbit(x);
    }
    return sum;
}
void cdq(int l, int r) {
    if (l == r) return;
    int m = (l + r) >> 1, pos = l;
    cdq(l, m); cdq(m + 1, r);
    sort(e + l, e + m + 1, cmp);
    sort(e + m + 1, e + r + 1, cmp);
    for (int i = m + 1; i <= r; i++) {
        for (; pos <= m and e[pos].b <= e[i].b; pos++) add(e[pos].c, e[pos].cnt);
        e[i].ans += ask(e[i].c);
    }
    for (int i = l; i < pos; i++) add(e[i].c, -e[i].cnt);
}

int main(int argc, char const *argv[]) {
    scanf("%d%d", &n, &k);
    for (int i = 1; i <= n; i++) scanf("%d%d%d", &e[i].a, &e[i].b, &e[i].c);
    sort(e + 1, e + n + 1);
    for (int i = 1, j = 1; i <= n; i = j + 1) {
        while (e[i] == e[j + 1]) j++;
        e[++cnt] = e[i], e[cnt].cnt = j - i + 1;
    }
    cdq(1, cnt);
    for (int i = 1; i <= cnt; i++) ans[e[i].ans + e[i].cnt - 1] += e[i].cnt;
    for (int i = 0; i < n; i++) printf("%d\n", ans[i]);
}
方法三:bitset

b i t s e t bitset bitset是 C + + S T L C++ \ STL C++ STL的一种数据结构,可以看成一个多位二进制数,每八位占用一个字节,因为支持基本的位运算,所以可用于状态压缩, n n n位 b i t s e t bitset bitset执行一次位运算的时间复杂度可视为 Θ ( n 32 ) \Theta(\frac{n}{32}) Θ(32n)。
b i t s e t bitset bitset基础操作:

cpp 复制代码
bitset<n> s;  // 定义一个n位的二进制数
位运算 ~ & | ^ << >>;
s[k]; // 返回第k个位置的值
s.set(); // 把s的所有位变成1
s.reset(); // 把s的所有位变成0
s.count(); // 返回s中1的个数

开 n n n个 b i t s e t bitset bitset, b i , j = 1 b_{i,j}=1 bi,j=1表示 j j j的每一维都不超过 i i i,初始所有 b i , j = 1 b_{i,j}=1 bi,j=1。枚举每一维,分别以该维度为唯一关键字排序。对每一纬度开一个新的 b i t s e t bitset bitset称为 s s s,单独对这个维度统计答案,每次用 b [ i ] & s b[i]\&s b[i]&s,就得到了三个属性都满足的元素个数,本质上是一种分块暴力。

由于 1 e 5 1e5 1e5的 b i t s e t bitset bitset开不下,所以将 b i t s e t bitset bitset分组使用,每次只求出一部分点的 b i t s e t bitset bitset。

cpp 复制代码
#include <bits/stdc++.h>
#define A 100010

using namespace std;
int n, k, p[3][A], a[3][A], w[A];
bitset<A> b[9999], s;

int main(int argc, char const *argv[]) {
	scanf("%d%d", &n, &k);
	for (int i = 1; i <= n; i++)
		for (int j = 0; j < 3; j++)
			scanf("%d", &a[j][i]), p[j][i] = i;
	for (int i = 0; i < 3; i++)
		sort(p[i] + 1, p[i] + n + 1, [=](int x, int y) {return a[i][x] < a[i][y];});
	for (int l = 1, r; l <= n; l = r + 1) {
		r = min(l + 9991, n);
		for (int i = l; i <= r; i++) b[i - l].set();
		for (int i = 0; i < 3; i++) {
			int *tp = p[i], *ta = a[i], k = 1;
			s.reset();
			for (int j = 1; j <= n; j++) {
				int now = tp[j];
				while (k <= n and ta[tp[k]] <= ta[now]) s[tp[k++]] = 1;
				if (l <= now and now <= r) b[now - l] &= s;
			}
		}
		for (int i = l; i <= r; i++) ++w[b[i - l].count()];
	}
	for (int i = 1; i <= n; i++) printf("%d\n", w[i]);
}
方法四:树状数组套动态开点权值线段树

先按第一维 a a a属性排序,查询前首先将 a a a相等的向量全部插入,插入时用树状数组维护 b b b属性数量的前缀和,用权值线段树维护 c c c属性数量的前缀和,查询时由于已经将 a a a小于等于当前向量 a a a的向量全插入了,所以只用考虑 b b b和 c c c的限制,所以我们要查询的就是 b b b小于等于当前向量的所有已插入向量中, c c c小于当前向量的向量的数量。我们用树状数组维护了 b b b的前缀,所以在查询的时候,只用把 b b b小于等于当前 b b b的那一段前缀拿出来,在它们的权值线段树上查 c c c小于等于当前 c c c的向量的总量就可以。

cpp 复制代码
#include <bits/stdc++.h>
#define A 100010
#define B 10000010

using namespace std;
struct node {int a, b, c;} e[A];
int n, m, ls[B], rs[B], val[B], rt_tot, rt[2 * A];
int b[5 * A], cnt;
int q[1010], tot, ans[A];

bool cmp(node a, node b) {return a.a < b.a;}
int lowbit(int x) {return x & -x;}
int query(int l, int r, int k) {
	if (l == r) {
		int sum = 0;
		for (int i = 1; i <= tot; i++) sum += val[q[i]];
		return sum;
	}
	int mid = (l + r) >> 1;
	if (mid >= k) {
		for (int i = 1; i <= tot; i++) q[i] = ls[q[i]];
		return query(l, mid, k);
	}
	else {
		int sum = 0;
		for (int i = 1; i <= tot; i++) sum += val[ls[q[i]]], q[i] = rs[q[i]];
		return sum + query(mid + 1, r, k);
	}
}
int calc(int num, int num2) {
	tot = 0;
	int pos = lower_bound(b + 1, b + cnt + 1, num) - b;
	for (int x = pos; x; x -= lowbit(x)) q[++tot] = rt[x];
	int vp = lower_bound(b + 1, b + cnt + 1, num2) - b;
	return query(1, cnt, vp);
}
int ins(int pr, int l, int r, int v) {
	int rt = pr;
	if (!rt) rt = ++rt_tot;
	ls[rt] = ls[pr], rs[rt] = rs[pr], val[rt] = val[pr] + 1;
	if (l == r) return rt;
	int mid = (l + r) >> 1;
	if (mid >= v) ls[rt] = ins(ls[pr], l, mid, v);
	else rs[rt] = ins(rs[pr], mid + 1, r, v);
	return rt;
}
void add(int num, int num2) {
	num = lower_bound(b + 1, b + cnt + 1, num) - b;
	num2 = lower_bound(b + 1, b + cnt + 1, num2) - b;
	for (int x = num; x <= cnt; x += lowbit(x)) rt[x] = ins(rt[x], 1, cnt, num2);
}

int main(int argc, char const *argv[]) {
	scanf("%d%d", &n, &m);
	for (int i = 1; i <= n; i++) {
		scanf("%d%d%d", &e[i].a, &e[i].b, &e[i].c);
		b[++cnt] = e[i].a;
		b[++cnt] = e[i].b;
		b[++cnt] = e[i].c;
	}
	sort(b + 1, b + cnt + 1);
	cnt = unique(b + 1,b + cnt + 1) - b - 1;
	sort(e + 1, e + n + 1, cmp);
	int ed = 1;
	for (int i = 1; i <= n;) {
		while (e[ed + 1].a == e[i].a) ed++;
		for (int j = i; j <= ed; j++) add(e[j].b, e[j].c);
		while (i <= ed) {
			ans[calc(e[i].b, e[i].c)]++;
			i++;
		}
	}
	for (int i = 1; i <= n; i++) printf("%d\n", ans[i]);
}

四、总结

分治的一个经典例子是归并排序,归并排序每次将区间 [ l , r ] [l,r] [l,r]拆分成 [ l , m i d ] [l,mid] [l,mid]和 [ m i d + 1 , r ] [mid+1,r] [mid+1,r],然后再 Θ ( n ) \Theta(n) Θ(n)合并两个有序数组,再将 [ l , r ] [l,r] [l,r]区间的答案传到其上面一层去。归并排序的经典应用便是求逆序对,当在合并两个有序数组的时候,若 a [ p ] > a [ q ] ( p ∈ [ l , m i d ] , q ∈ [ m i d + 1 , r ] ) a[p]>a[q](p\in [l,mid],q\in [mid+1,r]) a[p]>a[q](p∈[l,mid],q∈[mid+1,r]),则 a [ p ] ∼ a [ m i d ] a[p]\sim a[mid] a[p]∼a[mid]的数一定都比 a [ q ] a[q] a[q]大,这时就可以批量统计答案, a n s + = m i d − p + 1 ans+=mid-p+1 ans+=mid−p+1。 C D Q CDQ CDQ分治便是沿用这个思想,在此基础上通过高级数据结构来优化时间复杂度。

对于低维偏序问题,一维排序、二维树状数组、三维 C D Q CDQ CDQ分治。对于四维偏序问题,可以用 C D Q CDQ CDQ套 C D Q CDQ CDQ套树状数组,也就是在对第二维 C D Q CDQ CDQ的时候,记下元素是属于左区间还是右区间,在对第三维 C D Q CDQ CDQ保持前两维的有序时加一个树状数组,复杂度会相对二维多一个 l o g n logn logn。

对于更高维的偏序问题,显然 C D Q CDQ CDQ分治无法解决,时间复杂度已经接近上限。 K − D T r e e K-D\ Tree K−D Tree由于常数较大,在解决高维偏序问题时的表现也不尽人意,高维偏序问题的较优解其实还是 b i t s e t bitset bitset,因为其本质上是对 n 2 n^2 n2做法的暴力分块优化。

相关推荐
Charles Ray11 分钟前
C++学习笔记 —— 内存分配 new
c++·笔记·学习
重生之我在20年代敲代码12 分钟前
strncpy函数的使用和模拟实现
c语言·开发语言·c++·经验分享·笔记
limingade2 小时前
手机实时提取SIM卡打电话的信令和声音-新的篇章(一、可行的方案探讨)
物联网·算法·智能手机·数据分析·信息与通信
jiao000015 小时前
数据结构——队列
c语言·数据结构·算法
迷迭所归处6 小时前
C++ —— 关于vector
开发语言·c++·算法
leon6256 小时前
优化算法(一)—遗传算法(Genetic Algorithm)附MATLAB程序
开发语言·算法·matlab
CV工程师小林6 小时前
【算法】BFS 系列之边权为 1 的最短路问题
数据结构·c++·算法·leetcode·宽度优先
Navigator_Z7 小时前
数据结构C //线性表(链表)ADT结构及相关函数
c语言·数据结构·算法·链表
Aic山鱼7 小时前
【如何高效学习数据结构:构建编程的坚实基石】
数据结构·学习·算法
white__ice7 小时前
2024.9.19
c++