O(1)判斷兩點之間是否有邊


O(1)判斷兩點之間是否有邊

問題描述

給定一張 \(n\) 個點,\(m\) 條邊的有向圖。

多次詢問,要求每次 \(\mathcal{O}(1)\) 判斷兩點之間是否有邊(你可以忽略輸入、輸出等問題)。

數據范圍:\(2\leq n\leq 4\times 10^5\)\(0\leq m\leq 8\times 10^5\)

空間限制:\(512\texttt{MB}\)

做法

朴素做法有三種:

  • 對每個點 \(u\),用一個 \(\texttt{vector}\) 存從它出發的邊。將這些邊按另一端點的大小排序。每次查詢時,在 \(u\)\(\texttt{vector}\) 里二分查找。這樣單次詢問的時間復雜度是 \(\mathcal{O}(\log n)\) 的。如果對每個點維護一個 \(\texttt{map}\)\(\texttt{set}\),本質是一樣的。
  • 用一個二維 \(\texttt{bool}\) 型數組 \(\texttt{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\)。一個 \(\texttt{unsigned long long}\)\(64\) 位,所以剛好可以用一個 \(\texttt{unsigned long long}\) 描述其狀態。

在上述做法里,我們總共需要 \(\frac{n^2}{2^x}\)\(\texttt{int}\),和 \(m\cdot 2^{x - 6}\)\(\texttt{unsigned long long}\)。為了估算方便,不妨假設 \(m = 2n\)。那么所需的字節數是:\(4\cdot \frac{n^2}{2^x} + 8\cdot 2n\cdot 2^{x - 6}\),令他們相等,解得 \(x = 11\) 時該式取到最小值。剛好 \(500\texttt{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));
}

另外,\(n\leq 2\times 10^5\)\(m\leq 4\times 10^5\) 時,上述代碼只需要改變 MAXNMAXM 的值,其他參數不變,空間消耗就降到 \(171\texttt{MB}\) 了。

進一步的思考

上述做法里,我們只分了兩層,這是為了介紹該算法的核心思路。其實,如果不考慮時間上的常數,我們還可以分更多層,以此來進一步優化我們的空間消耗。

例如,在 \(n\leq 10^6\)\(m\leq 2\times 10^6\) 時,如果分四層,則空間消耗僅需 \(360\texttt{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\) 開了一個 \(\text{01-Trie}\)

我們只需要記住,層數越多,時間上消耗越大,空間上消耗越小。本算法的精髓就是在它們之間找到符合實際需求的平衡點。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM