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) 时,上述代码只需要改变 MAXN
和 MAXM
的值,其他参数不变,空间消耗就降到 (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})。
我们只需要记住,层数越多,时间上消耗越大,空间上消耗越小。本算法的精髓就是在它们之间找到符合实际需求的平衡点。