O(1)判断两点之间是否有边

O(1)判断两点之间是否有边

问题描述

给定一张 (n) 个点,(m) 条边的有向图。

多次询问,要求每次 (mathcal{O}(1)) 判断两点之间是否有边(你可以忽略输入、输出等问题)。

数据范围:(2leq nleq 4 imes 10^5)(0leq mleq 8 imes 10^5)

空间限制:(512 exttt{MB})

做法

朴素做法有三种:

  • 对每个点 (u),用一个 ( exttt{vector}) 存从它出发的边。将这些边按另一端点的大小排序。每次查询时,在 (u)( exttt{vector}) 里二分查找。这样单次询问的时间复杂度是 (mathcal{O}(log n)) 的。如果对每个点维护一个 ( exttt{map})( exttt{set}),本质是一样的。
  • 用一个二维 ( exttt{bool}) 型数组 ( exttt{a[u][v]}),表示点 (u, v) 之间是否有边。这样单次询问时间复杂度是 (mathcal{O}(1)) 的,但是空间复杂度高达 (mathcal{O}(n^2)),无法承受。
  • 哈希。本文不讨论。

考虑将前两种做法结合。

(x = 11)。把每 (2^x) 个点分为一类。这样共有 (frac{n}{2^x}) 类。用一个大小为 (frac{n^2}{2^x}) 的数组,就能实现判断:每个点向每一类点之间是否有连边。

如果一个点 (u) 向某一类点 (t) 之间有连边,我们称之为一个“事件”。容易发现,事件至多只有 (m)

考虑每个事件,它对应的入点至多只有 (2^x) 个。将这 (2^x) 个点再分类。把每 (2^6) 个点分为一类,会分出 (2^{x - 6}) 类。每一类点里编号都小于 (2^6 = 64)。一个 ( exttt{unsigned long long})(64) 位,所以刚好可以用一个 ( exttt{unsigned long long}) 描述其状态。

在上述做法里,我们总共需要 (frac{n^2}{2^x})( exttt{int}),和 (mcdot 2^{x - 6})( exttt{unsigned long long})。为了估算方便,不妨假设 (m = 2n)。那么所需的字节数是:(4cdot frac{n^2}{2^x} + 8cdot 2ncdot 2^{x - 6}),令他们相等,解得 (x = 11) 时该式取到最小值。刚好 (500 exttt{MB}) 不到。

参考代码:

const int MAXN = 4e5, MAXM = 8e5;
const int FULL5 = (1 << 5) - 1;
const int FULL6 = (1 << 6) - 1;

int b1[MAXN + 5][MAXN / (1 << 11) + 5], cnt_b1;
ull b2[MAXM + 5][FULL5 + 1];

void add_edge(int u, int v) {
	if (!b1[u][v >> 11]) b1[u][v >> 11] = ++cnt_b1;
	b2[b1[u][v >> 11]][(v >> 6) & FULL5] |= 1ull << (v & FULL6);
}
bool have_edge(int u, int v) {
	if (!b1[u][v >> 11]) return false;
	return b2[b1[u][v >> 11]][(v >> 6) & FULL5] & (1ull << (v & FULL6));
}

另外,(nleq 2 imes 10^5)(mleq 4 imes 10^5) 时,上述代码只需要改变 MAXNMAXM 的值,其他参数不变,空间消耗就降到 (171 exttt{MB}) 了。

进一步的思考

上述做法里,我们只分了两层,这是为了介绍该算法的核心思路。其实,如果不考虑时间上的常数,我们还可以分更多层,以此来进一步优化我们的空间消耗。

例如,在 (nleq 10^6)(mleq 2 imes 10^6) 时,如果分四层,则空间消耗仅需 (360 exttt{MB})。代码如下:

const int MAXN = 1e6, MAXM = 2e6;
const int FULL3 = (1 << 3) - 1;
const int FULL6 = (1 << 6) - 1;

int b1[MAXN + 5][MAXN / (1 << 15) + 5], cnt_b1;
int b2[MAXM + 5][1 << 3], cnt_b2;
int b3[MAXM + 5][1 << 3], cnt_b3;
ull b4[MAXM + 5][1 << 3];

void add_edge(int u, int v) {
	if (!b1[u][v >> 15])
		b1[u][v >> 15] = ++cnt_b1;
	int id1 = b1[u][v >> 15];
	
	if (!b2[id1][(v >> 12) & FULL3])
		b2[id1][(v >> 12) & FULL3] = ++cnt_b2;
	int id2 = b2[id1][(v >> 12) & FULL3];
	
	if (!b3[id2][(v >> 9) & FULL3])
		b3[id2][(v >> 9) & FULL3] = ++cnt_b3;
	int id3 = b3[id2][(v >> 9) & FULL3];
	
	b4[id3][(v >> 6) & FULL3] |= 1ull << (v & FULL6);
}
bool have_edge(int u, int v) {
	if (!b1[u][v >> 15])
		return false;
	int id1 = b1[u][v >> 15];
	
	if (!b2[id1][(v >> 12) & FULL3])
		return false;
	int id2 = b2[id1][(v >> 12) & FULL3];
	
	if (!b3[id2][(v >> 9) & FULL3])
		return false;
	int id3 = b3[id2][(v >> 9) & FULL3];
	
	return b4[id3][(v >> 6) & FULL3] & (1ull << (v & FULL6));
}

之所以能不断向下分层,而且使空间消耗奇迹般地减小,它的核心是:不论怎么分,每层的事件都至多只有 (m) 个。

把这种思路推到极致,如果分出 (log n) 层,则时间复杂度将回到 (mathcal{O}(log n)),此时相当于给每个点 (u) 开了一个 ( ext{01-Trie})

我们只需要记住,层数越多,时间上消耗越大,空间上消耗越小。本算法的精髓就是在它们之间找到符合实际需求的平衡点。

原文地址:https://www.cnblogs.com/dysyn1314/p/14438199.html