29.2 นับกลุ่ม ขนาดกลุ่ม และตรวจวงจร
union-find ในหน้า 29.1 ตอบได้แค่ว่าสองคนอยู่กลุ่มเดียวกันไหม แต่โจทย์มักถามต่อ เช่น ตอนนี้มีกี่กลุ่ม กลุ่มของคนนี้มีกี่คน กลุ่มที่ใหญ่ที่สุดมีกี่คน หรือถนนเส้นนี้ทำให้เกิดทางวนหรือเปล่า คำถามเหล่านี้ตอบได้ด้วยตัวแปรเพิ่มอีกสองสามตัว โดยไม่ทำให้ช้าลง หน้านี้รวมทุกอย่างไว้ในโปรแกรมเดียว แล้วเทียบกับการเดินกราฟด้วย BFS ของบทที่ 27 ว่าเมื่อไรควรใช้อะไร
โจทย์ถนนในหมู่บ้าน
หมู่บ้านมีบ้าน N หลัง ยังไม่มีถนนเลย เทศบาลสร้างถนนทีละเส้น ถนนวิ่งได้สองทาง ระหว่างนั้นมีคนถามเข้ามา คำสั่งมีสามแบบ
road a bสร้างถนนระหว่างบ้าน a กับบ้าน b แล้วพิมพ์ว่าตอนนี้บ้านแบ่งเป็นกี่กลุ่มที่ไปมาหากันได้ และกลุ่มใหญ่สุดมีกี่หลังsame a bจากบ้าน a ขับรถไปบ้าน b ได้หรือยังsize aจากบ้าน a ขับไปได้กี่หลัง นับบ้าน a ด้วย
กลุ่มของบ้านที่ไปมาหากันได้ก็คือส่วนเชื่อมต่อ (connected component) ของกราฟในบทที่ 27 ถนนหนึ่งเส้นคือคำสั่ง join ในหน้า 29.1 สิ่งที่ต้องเพิ่มมีสามอย่าง
- จำนวนกลุ่ม เริ่มที่ N เพราะทุกหลังอยู่กลุ่มของตัวเอง ทุกครั้งที่
uniteรวมสองกลุ่มที่ต่างกันได้จริง จำนวนกลุ่มลดลงหนึ่งพอดี ถ้าสองบ้านอยู่กลุ่มเดียวกันอยู่แล้ว จำนวนไม่เปลี่ยนuniteจึงคืนค่าboolบอกว่ารวมจริงหรือไม่ - ขนาดกลุ่ม มีอยู่แล้วใน
group_sizeที่ใช้ทำ union by size แต่ค่าที่ถูกต้องอยู่ที่หัวหน้าเท่านั้น ต้องอ่านgroup_size[find_root(a)] - กลุ่มใหญ่สุด กลุ่มมีแต่รวมกันใหญ่ขึ้น ไม่เคยแยก ขนาดใหญ่สุดจึงเปลี่ยนได้เฉพาะตอนรวม เทียบกับขนาดของกลุ่มที่เพิ่งรวมเสร็จก็พอ
#include <bits/stdc++.h>
using namespace std;
vector<int> parent, group_size;
int groups, largest;
int find_root(int x)
{
if (parent[x] == x) {
return x;
}
parent[x] = find_root(parent[x]);
return parent[x];
}
bool unite(int a, int b)
{
int root_a = find_root(a);
int root_b = find_root(b);
if (root_a == root_b) {
return false; // (1)!
}
if (group_size[root_a] < group_size[root_b]) {
swap(root_a, root_b);
}
parent[root_b] = root_a;
group_size[root_a] += group_size[root_b];
groups--; // (2)!
largest = max(largest, group_size[root_a]); // (3)!
return true;
}
int main()
{
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, q;
cin >> n >> q;
parent.resize(n + 1);
group_size.assign(n + 1, 1);
for (int i = 1; i <= n; i++) {
parent[i] = i;
}
groups = n;
largest = 1;
for (int k = 0; k < q; k++) {
string op;
cin >> op;
if (op == "road") {
int a, b;
cin >> a >> b;
unite(a, b);
cout << "groups=" << groups << " largest=" << largest << '\n';
} else if (op == "same") {
int a, b;
cin >> a >> b;
cout << (find_root(a) == find_root(b) ? "yes" : "no") << '\n';
} else {
int a;
cin >> a;
cout << "size of " << a << " = " << group_size[find_root(a)] << '\n'; // (4)!
}
}
return 0;
}
- อยู่กลุ่มเดียวกันอยู่แล้ว ไม่ต้องทำอะไร และบอกคนเรียกว่าไม่ได้รวม
- มาถึงบรรทัดนี้ได้ก็ต่อเมื่อเป็นสองกลุ่มที่ต่างกันจริง จำนวนกลุ่มจึงลดลงหนึ่ง
group_size[root_a]คือขนาดของกลุ่มที่เพิ่งรวมเสร็จ- อ่านขนาดที่หัวหน้าของ a ไม่ใช่ที่ a
7 10
road 1 2
road 3 4
road 2 3
size 4
road 1 4
road 5 6
same 1 6
road 4 6
same 1 6
size 7
groups=6 largest=2
groups=5 largest=2
groups=4 largest=4
size of 4 = 4
groups=4 largest=4
groups=3 largest=4
no
groups=2 largest=6
yes
size of 7 = 1
ถนน 1-2 3-4 และ 2-3 ทำให้บ้าน 1 ถึง 4 เป็นกลุ่มเดียวกันขนาด 4 ถนน 1-4 ต่อสองบ้านที่ไปถึงกันได้อยู่แล้ว จำนวนกลุ่มจึงยังเป็น 4 เหมือนเดิม ถนนเส้นนี้ไม่ได้ช่วยให้ไปที่ใหม่ได้ แค่ทำให้เกิดทางวน ซึ่งเป็นเรื่องของหัวข้อตรวจวงจรข้างล่าง บ้าน 7 ไม่มีถนนเลย จึงอยู่กลุ่มขนาด 1 จนจบ ทุกคำสั่งใช้เวลาเท่ากับ find ไม่กี่ครั้ง ซึ่งเกือบคงที่ตามหน้า 29.1
BFS หรือ union-find
บทที่ 27 หาส่วนเชื่อมต่อด้วย BFS หรือ DFS ได้ใน O(N + M) เมื่อ N คือจำนวนโหนด และ M คือจำนวนเส้นเชื่อม ซึ่งบทที่ 27 เขียนเป็น O(V + E) สองวิธีนี้ไม่ได้แข่งกัน แต่เหมาะกับโจทย์คนละแบบ
| โจทย์ | ใช้ | เหตุผล |
|---|---|---|
| ให้เส้นเชื่อมมาครบก่อน แล้วค่อยถาม | BFS หรือ DFS ครั้งเดียว หรือ union-find ก็ได้ | เดินกราฟครั้งเดียวติดป้ายหมายเลขกลุ่มให้ทุกโหนด แล้วตอบทุกคำถามจากป้าย |
| เส้นเชื่อมทยอยเพิ่ม มีคำถามแทรก | union-find | กลุ่มเปลี่ยนหลังทุกเส้น ป้ายที่ติดไว้ใช้ไม่ได้อีก BFS ต้องเดินใหม่ทุกครั้งที่ถาม |
| ต้องรู้เส้นทาง ระยะทาง หรือลำดับที่ไปถึง | BFS หรือ DFS | union-find รู้แค่ว่าอยู่กลุ่มเดียวกันไหม ไม่รู้ว่าไปทางไหน |
| มีการลบเส้นเชื่อม | ไม่ใช่ union-find แบบนี้ | union-find รวมได้อย่างเดียว แยกกลุ่มกลับไม่ได้ |
ถ้ามีแต่การลบ ให้ทำย้อนหลัง
โจทย์บางข้อเริ่มจากกราฟเต็ม แล้วลบเส้นทีละเส้นพร้อมถามแทรก ถ้าอ่านคำสั่งทั้งหมดได้ก่อนตอบ ให้เริ่มจากกราฟหลังลบครบแล้ว แล้วทำคำสั่งจากท้ายมาหัว การลบเมื่อมองย้อนหลังก็คือการเพิ่ม ซึ่ง union-find ทำได้ เก็บคำตอบไว้ในอาเรย์แล้วพิมพ์กลับด้านตอนจบ วิธีนี้เรียกว่าทำแบบ ออฟไลน์ (offline) คือรู้คำถามทั้งหมดก่อนตอบ
เร็วขึ้นแค่ไหน
วัดบนเครื่องเดียวกับหน้า 29.1 บ้าน N หลัง สร้างถนน N เส้นระหว่างบ้านสุ่ม และถามว่าสองบ้านสุ่มไปถึงกันได้ไหม N ครั้ง ถนนกับคำถามสลับกันแบบสุ่ม วิธีตรงไปตรงมาเก็บถนนเป็น adjacency list แล้ว BFS จากบ้าน a ทุกครั้งที่ถาม หยุดทันทีที่เจอบ้าน b ส่วนอีกวิธีคือโปรแกรม village ที่ตัดส่วนพิมพ์ออก จับเวลาเฉพาะช่วงทำคำสั่ง รันแต่ละแบบ 5 รอบ
| N | วิธี | Code::Blocks (-Wall) | -O2 |
|---|---|---|---|
| 104 | BFS ทุกคำถาม | 0.462 วินาที | 0.176 วินาที |
| union-find | 0.001 วินาที | 0.000 วินาที | |
| 105 | BFS ทุกคำถาม | 65 วินาที (5 รอบอยู่ระหว่าง 56 ถึง 78) | 25 วินาที (5 รอบอยู่ระหว่าง 22 ถึง 32) |
| union-find | 0.008 วินาที | 0.003 วินาที |
ถนนสุ่มจำนวนเท่ากับบ้าน ทำให้บ้านส่วนใหญ่รวมเป็นกลุ่มใหญ่กลุ่มเดียว แต่สองบ้านสุ่มอยู่คนละกลุ่มเป็นส่วนมาก ข้อมูลชุดแสนหลังตอบ yes แค่ 16009 ครั้งจากแสนคำถาม คำถามที่ตอบ no ทำให้ BFS ต้องเดินทั่วกลุ่มของ a จนหมดก่อนจะรู้ว่าไม่เจอ งานต่อคำถามจึงโตตาม N และงานรวมโตราว N2 ข้อมูลใหญ่ขึ้นสิบเท่า BFS ช้าลงราว 140 เท่าทั้งสองแบบ ส่วน union-find ยังใช้ไม่ถึงหนึ่งในร้อยวินาที
ตรวจวงจรในกราฟไม่มีทิศทาง
ถนน 1-4 ในโปรแกรม village ไม่ได้รวมกลุ่มใหม่ เพราะบ้าน 1 กับ 4 ไปถึงกันได้อยู่แล้วผ่าน 2 กับ 3 พอเพิ่มถนนเส้นนี้ ก็เกิดทางวน 1-2-3-4-1 ขึ้น ซึ่งคือวงจร (cycle) ตามนิยามในหน้า 27.1 หน้า 27.2 ตรวจวงจรด้วย DFS ไปแล้ว คือเจอโหนดที่จดแล้วซึ่งไม่ใช่พ่อ แต่ต้องมีเส้นเชื่อมครบก่อนแล้วค่อยเดิน union-find ตรวจได้ทีละเส้นตอนที่เส้นทยอยเข้ามา หลักคิดคือ
เส้นที่ปิดวงจร
ใส่เส้นเชื่อมทีละเส้น ถ้าเส้น a-b ต่อสองโหนดที่อยู่กลุ่มเดียวกันอยู่แล้ว แปลว่ามีทางจาก a ไป b อยู่ก่อน เส้นใหม่จึงปิดทางนั้นเป็นวงจร ถ้าไม่มีเส้นไหนเป็นแบบนี้เลย กราฟก็ไม่มีวงจร
unite ที่คืน false คือสัญญาณนี้พอดี โปรแกรมนี้อ่านเส้นเชื่อมของกราฟ แล้วบอกว่าเส้นไหนปิดวงจรบ้าง
#include <bits/stdc++.h>
using namespace std;
vector<int> parent, group_size;
int find_root(int x)
{
if (parent[x] == x) {
return x;
}
parent[x] = find_root(parent[x]);
return parent[x];
}
bool unite(int a, int b)
{
int root_a = find_root(a);
int root_b = find_root(b);
if (root_a == root_b) {
return false;
}
if (group_size[root_a] < group_size[root_b]) {
swap(root_a, root_b);
}
parent[root_b] = root_a;
group_size[root_a] += group_size[root_b];
return true;
}
int main()
{
int n, m;
cin >> n >> m;
parent.resize(n + 1);
group_size.assign(n + 1, 1);
for (int i = 1; i <= n; i++) {
parent[i] = i;
}
int extra = 0;
for (int i = 1; i <= m; i++) {
int a, b;
cin >> a >> b;
if (!unite(a, b)) { // (1)!
cout << "edge " << i << " (" << a << ", " << b << ") closes a cycle" << '\n';
extra++;
}
}
cout << "edges that close a cycle: " << extra << '\n';
cout << (extra == 0 ? "forest" : "has a cycle") << '\n';
return 0;
}
- รวมไม่ได้เพราะอยู่กลุ่มเดียวกันแล้ว เส้นนี้จึงปิดวงจร ถ้ารวมได้
uniteก็รวมให้ในบรรทัดเดียวกัน
6 7
1 2
2 3
4 5
3 1
5 6
6 4
2 5
edge 4 (3, 1) closes a cycle
edge 6 (6, 4) closes a cycle
edges that close a cycle: 2
has a cycle
เส้นที่ 4 ต่อ 3 กับ 1 ซึ่งถึงกันอยู่แล้วผ่าน 2 เส้นที่ 6 ต่อ 6 กับ 4 ซึ่งถึงกันอยู่แล้วผ่าน 5 ส่วนเส้นที่ 7 ต่อสองสามเหลี่ยมที่ยังไม่ถึงกัน จึงไม่ปิดวงจร แม้จะมาทีหลังสุดก็ตาม สังเกตว่าเส้นที่ถูกรายงานขึ้นกับลำดับที่ใส่ ถ้าสลับให้เส้น 3-1 มาก่อนเส้น 2-3 เส้นที่ถูกรายงานจะเป็น 2-3 แทน แต่จำนวนเส้นที่ปิดวงจรเท่าเดิมเสมอ เพราะ
จำนวนเส้นที่ปิดวงจร = M - (N - C)
เมื่อ C คือจำนวนกลุ่มตอนจบ ทุกเส้นที่รวมกลุ่มได้ทำให้จำนวนกลุ่มลดลงหนึ่ง จาก N ลงมาเหลือ C จึงมีเส้นที่รวมได้ N - C เส้นพอดี เส้นที่เหลือปิดวงจรทั้งหมด ตัวอย่างนี้ได้ 7 - (6 - 1) = 2 ผลที่ตามมาอีกข้อคือ กราฟที่มี N โหนด ต่อถึงกันหมด และไม่มีวงจร ต้องมีเส้นเชื่อม N - 1 เส้นพอดี ซึ่งก็คือต้นไม้ที่จะใช้ในหน้า 29.3
เส้นที่ต่อโหนดกับตัวเอง เช่น 2 2 และเส้นที่ซ้ำกับเส้นเดิม ถูกนับเป็นเส้นที่ปิดวงจรด้วย ลองป้อน 3 3 ตามด้วยเส้น 1 2 2 2 1 2 โปรแกรมรายงานเส้นที่ 2 และ 3 ถ้าโจทย์นับแบบอื่นต้องอ่านให้ดี
ใช้กับกราฟมีทิศทางไม่ได้
union-find ไม่รู้จักทิศของเส้น กราฟมีทิศทาง 1→2 1→3 2→3 ไม่มีวงจร เพราะไม่มีทางเดินตามลูกศรกลับมาที่เดิมได้ แต่โปรแกรมนี้รายงานว่าเส้นที่ 3 ปิดวงจร การหาวงจรในกราฟมีทิศทางใช้ Kahn หรือ DFS แบบสามสถานะในหน้า 30.1 ซึ่งมาพร้อมกับ topological sort
ลองทำให้พัง
แบบที่ 1 ไม่เช็กว่าอยู่กลุ่มเดียวกันแล้ว
ในโปรแกรม village ลบสามบรรทัดที่คืน false เมื่อ root_a == root_b ออก เพราะคิดว่าต่อหัวหน้ากับตัวเองก็ไม่เสียหาย ป้อนข้อมูลเข้าชุดเดิม
groups=6 largest=2
groups=5 largest=2
groups=4 largest=4
size of 4 = 4
groups=3 largest=8
groups=2 largest=8
no
groups=1 largest=10
yes
size of 7 = 1
ถนน 1-4 ทำให้จำนวนกลุ่มลดจาก 4 เป็น 3 และกลุ่มใหญ่สุดกลายเป็น 8 หลัง ทั้งที่หมู่บ้านมีแค่ 7 หลัง ตอนจบโปรแกรมบอกว่าเหลือกลุ่มเดียว แต่บ้าน 7 ยังไม่มีถนนเลย parent[root_b] = root_a ที่หัวหน้าเป็นคนเดียวกันไม่ทำให้ต้นไม้เสีย แต่ group_size[root_a] += group_size[root_b] บวกกลุ่มเข้ากับตัวเองจนขนาดเป็นสองเท่า และ groups-- นับการรวมที่ไม่ได้เกิดขึ้นจริง ชุดทดสอบที่ไม่มีถนนซ้ำหรือถนนที่ทำให้วนจะผ่าน แล้วตกในชุดที่มี
แบบที่ 2 อ่านขนาดที่ตัวเองแทนที่หัวหน้า
ในโปรแกรม village เปลี่ยน group_size[find_root(a)] เป็น group_size[a]
groups=6 largest=2
groups=5 largest=2
groups=4 largest=4
size of 4 = 1
groups=4 largest=4
groups=3 largest=4
no
groups=2 largest=6
yes
size of 7 = 1
size 4 ได้ 1 แทนที่จะเป็น 4 บ้าน 4 ไม่ใช่หัวหน้า ค่าใน group_size[4] คือขนาดของกลุ่มตอนที่ 4 ยังเป็นหัวหน้าอยู่ ซึ่งค้างไว้ตั้งแต่ก่อนรวม ส่วน size 7 บังเอิญถูก เพราะ 7 เป็นหัวหน้าของตัวเอง ทดสอบด้วยคนที่ไม่ใช่หัวหน้าเสมอ
สรุป
- จำนวนกลุ่มเริ่มที่ N และลดลงหนึ่งทุกครั้งที่
uniteรวมได้จริง ให้uniteคืนbool - ขนาดกลุ่มอ่านที่หัวหน้า
group_size[find_root(x)]กลุ่มใหญ่สุดอัปเดตตอนรวม - เส้นเชื่อมทยอยเพิ่มและมีคำถามแทรก ใช้ union-find ถ้าต้องรู้เส้นทางหรือระยะทาง ใช้ BFS หรือ DFS
- เส้น a-b ที่ a กับ b อยู่กลุ่มเดียวกันแล้วคือเส้นที่ปิดวงจร มีทั้งหมด M - (N - C) เส้น ใช้ได้กับกราฟไม่มีทิศทางเท่านั้น
- union-find แยกกลุ่มไม่ได้ ถ้าโจทย์มีแต่การลบและรู้คำสั่งทั้งหมดก่อน ให้ทำย้อนหลัง