Disjoint Set Union

Disjoint Sets Union (DSU), hoặc Union-Find Disjoint Sets (UFDS), là một CTDL quản lí các tập hợp không giao nhau, tức là các tập hợp không có phần tử chung. DSU có thể trả lời hiệu quả nếu \(2\) phần tử có nằm trong cùng một tập hợp hay không, và có thể hợp \(2\) tập hợp lại với nhau.

Như tên gọi của mình, DSU bao gồm các thao tác chính:

  • MakeSet: Tạo một tập hợp có \(1\) phần tử là a.
  • Union: Hợp tập hợp chứa phần tử a và tập hợp chứa phần tử b thành một tập hợp, và
  • Find: Tìm phần tử đại diện của tập hợp chứa phần tử a.

Mỗi tập hợp sẽ có một phần tử làm phần tử đại diện, dùng để xác định tập hợp mà nó nằm trong. Đây cũng là giá trị trả về của thao tác Find.

Để kiểm tra nếu \(2\) phần tử có nằm trong cùng một tập hợp không thì ta kiểm tra nếu Find(a) = Find(b) hay nếu tập hợp chứa a và tập hợp chứa b đều có cùng một phần tử đại diện.

Cài đặt

Để cài đặt DSU, ta có mảng p có n phần tử dùng để lưu các phần tử đại diện của tập hợp - ví dụ: p[i] lưu phần tử đại diện của tập hợp chứa i.

Ta có cài đặt DSU bằng Quick-Find:

int MakeSet(int a){
	if(p[a] == -1){ // -1 biểu thị nếu tập hợp chưa được tạo
		p[a] = a;
	}
}
int Find(int a){
	return p[a];
}

void Union(int a, int b){
	a = Find(a);
	b = Find(b);
	for(int i = 1; i <= n; ++i){
		if(p[i] == a){
			p[i] = b;
		}
	}
}

MakeSet(a)Find(a) có độ phức tạp \(O(1)\), còn Union(a, b) là \(O(n)\).

Như ta có thể thấy, hàm Union có độ phức tạp thời gian quá lớn. Tuy nhiên, ta cũng có nhiều cách để tối ưu nó.

Tối ưu DSU

Quick-Union

Ta sẽ tối ưu hàm Union bằng cách mô tả các tập hợp bằng các đồ thị cây hay rừng cây. p[i] giờ đây sẽ chỉ cha của đỉnh i trong đồ thị cây. Khi thực hiện Union(a, b) ta chỉ cần thay đổi cha của gốc của cây chứa a thành gốc của cây chứa của b hay p[Find(a)] = Find(b). Và khi thực hiện Find(a) ta tìm gốc của cây bằng cách xét dãy a, p[a], p[p[a]], … cho tới khi tìm tìm được phần tử trong dãy có giá trị giống phần tử trước nó, tức là ta đã tìm được gốc của cây.

Thao tácMinh họa
Ban đầu
Ban đầu
Union(1, 2)
Union(1, 2)
Union(2, 3)
Union(2, 3)
Union(1, 4)
Union(1, 4)
int Find(int a){
	while(a != p[a]){
		a = p[a];
	}
	return a;
}

void Union(int a, int b){
	a = Find(a);
	b = Find(b);
	p[a] = b;
}

Lúc này độ phức tạp của Find là \(O(n)\) và Union là \(O(n)\).

Vì sao Find lại là \(O(n)\)? Giả sử việc Union sẽ tạo thành một cây được nối thành một đường thẳng. Khi đó việc Find sẽ có độ phức tạp \(O(n)\). Và cũng vì trong Union có sử dụng Find nên độ phức tạp của Union cũng là \(O(n)\).

Tuy vậy, cách thực hiện Union của Quick-Union vẫn nhanh hơn Union của Quick-Find. Union của Quick-Find thì duyệt các phần tử trong p, còn Union của Quick-Union thì phụ thuộc vào chiều cao của cây. Trên trung bình, các cây được tạo bởi Quick-Union có độ cao khá bé nên nó nhanh hơn Quick-Find.

Tối ưu Union

Như đã nói ở trên, trên trung bình, các cây có độ cao tương đối nhỏ. Dẫu vậy, làm cách nào để ta đảm bảo được chiều cao của các cây ấy đều nhỏ trong mọi trường hợp? Ta sẽ thay đổi cách thực hiện hàm Union.

Union theo thứ hạng

Ta có Union theo thứ hạng: Khi Union tập hợp chứa a và tâp hợp chứa b, cây nào có thứ hạng cao hơn sẽ là cha của cây có thứ hạng thấp hơn, nếu \(2\) cây có thứ hạng bằng nhau thì gốc của đỉnh nào làm cha cũng được, nhưng thứ hạng của cây có đỉnh được chọn phải tăng thêm \(1\).

Ta sẽ tạo một mảng r lưu thứ hạng của tập hợp chứa đỉnh i. Ban đầu, các giá trị trong r sẽ bằng \(0\).

Hàm Find sẽ tương tự với Find trong Quick-Union.

void Union(int a, int b){
	a = Find(a);
	b = Find(b);
	if(a == b) return;
	if(r[a] < r[b]) swap(a, b);
	p[b] = a;
	if(r[a] == r[b]) ++r[a];
}

Ta có giả thiết: Một cây có thứ hạng \(k\) sẽ có ít nhất \(2^{k}\) đỉnh.

Chứng minh: Nếu \(k\) bằng 0, thì điều này là chính xác vì khi cây có thứ hạng bằng 0 thì chỉ có \(1\) đỉnh. Ta cũng nhận thấy rằng để có cây có thứ hạng \(k\) thì nó phải được Union theo thứ hạng từ \(2\) cây có thứ hạng \(k - 1\), khi đó số đỉnh trong cây có thứ hạng \(k\) sẽ lớn hơn hoặc bằng \(2^{k - 1} + 2^{k - 1} = 2^k\).

Từ đây ta có thể nhận định rằng thứ hạng cao nhất có thể khi liên tiếp thực hiện Union theo thứ hạng n phần tử là \(\log{n}\).

Union theo kích thước

Ta có Union theo kích thước: Khi Union tập hợp chứa a và tâp hợp chứa b, cây có nhiều đỉnh hơn sẽ là cha của gốc của cây có ít đỉnh hơn, nếu \(2\) cây có số lượng đỉnh bằng nhau thì lấy cây nào làm cha cũng được.

Ta sẽ tạo một mảng sz lưu kích thước của tập hợp chứa đỉnh i. Ban đầu, các giá trị trong sz sẽ bằng 1.

Hàm Find sẽ tương tự với Find trong Quick-Union.

void Union(int a, int b){
	a = Find(a);
	b = Find(b);
	if(a == b) return;
	if(sz[a] < sz[b]) swap(a, b);
	p[b] = a;
	sz[a] += sz[b];
}

Tương tự với Union theo thứ hạng, ta có giả thiết: Một đỉnh \(x\) bất kì sẽ không có chiều cao quá \(\log{n}\).

Chứng minh: Chiều cao của đỉnh \(x\) nằm trong cây \(T_1\) sẽ không thay đổi trừ khi được hợp lại với cây \(T_2\) có kích thước lớn hơn \(T_1\).

Khi này:

  • Chiều cao của \(x\) tăng lên 1
  • Kích thước của cây mới \(T_3\) chứa đỉnh \(x\) sẽ gấp đôi cây cũ hoặc hơn: \[size(T_3) = size(T_1) + size(T_2) \ge 2 \times size(T_1)\]

Giống với Union theo thứ hạng, ta có thể nhận định rằng chiều cao lớn nhất có thể khi liên tiếp Union theo thứ hạng n phần tử là \(\log{n}\).


Bằng việc áp dụng Quick-Union với Union theo kích thước/thứ hạng, ta có thể đảm bảo rằng chiều cao của cây sẽ không vượt quá \(\log{n}\) và độ phức tạp cho FindUnion sẽ giảm xuống còn \(O(\log{n})\).

Tối ưu Find

Việc áp dụng cách cài đặt Quick-Union đã làm gia tăng độ phức tạp của hàm Find, nhưng không vì thế mà ta không có cách tối ưu nó.

Kĩ thuật nén đường đi

Kĩ thuật nén đường đi rất đơn giản: Khi đã tìm được gốc của cây bằng dãy a, p[a], p[p[a]],… như ở phần Quick-Union, gán cha của tất cả các phần tử trong dãy đó thành gốc đã tìm được.

int Find(int a){
	if(a != p[a]) p[a] = Find(p[a]);
	return p[a];
}

Hàm Find này chạy rất nhanh, với độ phức tạp \(O(\log^* n)\) trên trung bình nếu kết hợp với Union theo thứ hạng/kích thước hoặc \(O(\log{n})\) nếu sử dụng đơn lẻ.

\(\log^* n\) hay logarit lặp là số lần áp dụng hàm \(\log_2\) với chính nó cho tới khi giá trị đạt được không lớn hơn \(1\).

VD: \(65536 = 2^{16} \rightarrow 16 = 2^4 \rightarrow 4 = 2^2 \rightarrow 2 \rightarrow 1 \implies log^* 65536 = 4\)

Vì trong hầu hết các trường hợp, \(\log^* n\) rất nhỏ nên ta coi nó như \(O(1)\).

Tổng hợp các cách cài đặt DSU

Bảng dưới đây so sánh độ phức tạp thời gian của 2 hàm FindUnion của các cách cài đặt DSU được nói ở trên:

Cách cài đặtFindUnion
Quick-Find\(O(1)\)\(O(n)\)
Quick-Union\(O(n)\)\(O(n)\)
Quick-Union theo kích thước/thứ hạng\(O(\log{n})\)\(O(\log{n})\)
Quick-Union + nén đường đi\(O(\log{n})\)\(O(\log{n})\)
Quick-Union theo kích thước/thứ hạng + nén đường đi\(O(log^* n)\)\(O(log^* n)\)

Dưới đây là cài đặt DSU theo thứ hạng + nén đường đi trong một cấu trúc, kèm theo một số hàm hỗ trợ khác.

struct UnionFind{
	vector<int> p, r, sz;
	int cc; // số tập hợp
	UnionFind(int _n): cc(_n){
		p.resize(cc);
		r.resize(cc, 0);
		sz.resize(cc, 1);
		iota(p.begin(), p.end(), 0); // gán p[0] = 0, p[1] = 1, ..., p[cc - 1] = cc - 1
	}
	int find(int u) { return p[u] == u ? u : p[u] = find(p[u]); } // cách viết rút gọn
	int disjointSet() { return cc; }                              // số tập hợp
	int sizeOfSet(int u) { return sz[find(u)]; }                  // số lượng phần tử trong tập hợp
	bool isSameSet(int u, int v) { return find(u) == find(v); }   // kiểm tra hai phần tử thuộc 
    bool Union(int u, int v) {                                    // cùng tập hợp hay không 
		u = find(u);                                              // hàm Union sẽ trả về nếu hai 
		v = find(v);                                              // tập hợp được hợp lại với nhau
		if (u == v) return 0;
		if(r[u] < r[v]) swap(u, v);
    	p[v] = u;
    	r[u] += r[u] == r[v];
    	sz[u] += sz[v];
    	--cc;
    	return 1;
    }
};