序列並查集的線性演算法

_bzy發表於2021-01-31

本文提供了序列上並查集的線性合併與查詢的演算法及其 C++ 實現。一種更具有普適性但實現細節與此不同的樹上線性並查集演算法見參考文獻[1,3],其中 Tarjan 的論文[3] 中包含處理動態加點的樹上並查集問題的方法。

Introduction

序列上的並查集問題可以描述為:

1.初始序列上有 \(n\) 個元素,各自為一個元素段。
2.進行 \(m\) 次操作與詢問。

  • 將兩個相鄰的元素段合併。
  • 查詢某個元素所在的段。

直接使用傳統的並查集做法,時間複雜度為 \(O(m\alpha(m+n,n)+n)\),空間複雜度為 \(O(n)\) [3]。

其中 \(\alpha\) 函式是我們熟知的 Ackermann 函式的反函式。

具體地 \(\alpha(m,n)=\min\{i>1|\mathop{Ackermann}(i,\lfloor\frac{m}{n}\rfloor)>\log_2 n\}\) [4]。

下文將提供一種時間複雜度為 \(O(n+m)\),空間複雜度為 \(O(n/\log n )\) 的演算法解決這個問題。

Algorithm

考慮將序列分塊,每塊的大小為 \(b\),不妨認為 \(b<w\),其中 w 為計算機的字長。

然後將每塊左端的元素提出來,用傳統並查集維護,這部分的時間是 \(O(\frac{m}{b}\alpha(n+m,n)+n)\)

然後每塊用一個整數儲存塊內的狀態,其中整數二進位制的第 \(i\) 位表示塊內從右往左數第 \(i\) 號元素是否向左合併過,其中 0 表示合併過,1 表示沒有。

考慮合併兩段的操作,先找到右側合併段最左側元素對應在塊內對應的位,將其從 1 賦位 0。之後,如果該元素位於塊內最左端則將該塊合併合併到前一塊上。

考慮查詢某元素所在的段,先查詢該元素塊內其自身與左側第一個 \(1\) 的位置,這部分可以用先按位右移去除右側元素的干擾,然後 lowbit 找出第一個 1 的位置。如果存在 1,則該元素所在段的最左端即為塊內所查到的 1 的位置。否則,查詢該塊並查集的 root 塊,再查詢 root 塊的 lowbit 即可。

單次合併查詢操作的非並查集部分的時間複雜度是 \(O(1)\)

\(b\)\(\log_2 n\) 時,總時間複雜度為 \(O(n+m)\),空間複雜度為 \(O(n/\log n)\)

特別地,並查集只使用路徑壓縮優化而未按秩合併複雜度依然是 \(O(n+m)\),因為這樣子並查集部分的時間複雜度為 \(O(m \log_{1+m/n}{n})\) [2]。

c++ 實現 :

const int N = 1000000;

int lowbit( int x ) {             // 二進位制下最低位
	return x & -x;
}

namespace LinearSequenceDisjointSet {
	
	const int MaxN = N;           // 最大的 n 值
	const int BlkL = log2(N);     // 塊長
	
	const int Mask = ( 1 << BlkL ) - 1; 
	
	int pre[ MaxN / BlkL ];       // 並查集陣列
	int dat[ MaxN / BlkL ];       // 塊內資訊
	
	void init() {
		for( int i = 0; i < MaxN / BlkL; i ++ ) {
			pre[i] = i;
			dat[i] = Mask;
		}
	}
	
	int findP( int x ) {          // 路徑壓縮並查集的查詢操作
		if( x == pre[x] ) return x;
		return pre[x] = find( pre[x] );
	}
	
	int find( int x ) {           // 找到該段最左端的元素
		int b = x / BlkL;
		int p = x % BlkL;
		int s = b * BlkL;
		
		int m = dat[b] & ( Mask + 1 - (1 << p) );
		
		if( !m ) {
			s -= b - findP(b);
			m = dat[findP(b)];
		} 
		
		return s * BlkL + BlkL - log2( lowbit(m) ) - 1;
	}
	
	void join( int x ) {          // 將 x 元素與前一個位置合併
		int b = x / BlkL;
		int p = x % BlkL;
		
		p = BlkL - p - 1;
		
		dat[b] &= ( Mask - (1 << p) );
		
		if( p == BlkL - 1 and b ) {
			pre[ findP(b) ] = findP(b - 1);
		}
	}
	
}

Application

例題 : 有一個序列 \({a_n}\),初始值輸入,然後有 \(m\) 次操作,每次將一個字首加上一個常數,並返回全域性最大值。

考慮維護序列的單調棧,每個元素維護初始值與附加值,字首加操作時找到單調棧內最右側能被操作覆蓋到的元素,並將其附加值加上操作的常數。

如果該元素的初始值與附加值的和大於其右側元素的初始值,則彈出右側元素。

我們發現單調棧內每個元素控制著以它為左端點的原序列上的一段,而彈出操作則需要合併相鄰的兩段,於是可以用分塊序列並查集維護。

總時間複雜度 \(O(n+m)\)

References

  1. ljt12138,RMQ標準演算法和線性樹上並查集
    https://ljt12138.blog.uoj.ac/blog/4874
  2. wang3312362136,路徑壓縮優化並查集的時間複雜度
    https://blog.csdn.net/wang3312362136/article/details/86475324
  3. H.N.Gabow & R.E.Tarjan,A Linear-Time Algorithm for a Special Case of Disjoint Set Union,JOURNAL OF COMPUTER AND SYSTEM SCIENCES 30, 209-221 (1985)
  4. inverse Ackermann function
    https://xlinux.nist.gov/dads/HTML/inverseAckermann.html

相關文章