[0]zero-index clytie0x/zero-index

29.1 union-find

โจทย์จำนวนมากในค่าย 2 และ TOI พูดถึง กลุ่ม ที่ค่อยๆ รวมกันใหญ่ขึ้น เช่น ใครเป็นเพื่อนของเพื่อนกับใคร เมืองไหนมีถนนไปถึงกันแล้ว บทที่ 27 หาส่วนเชื่อมต่อด้วย DFS หรือ BFS ได้เมื่อรู้เส้นเชื่อมครบทุกเส้นแล้ว แต่ถ้าเส้นเชื่อมทยอยเพิ่มเข้ามา และมีคำถามแทรกอยู่ระหว่างนั้น การเดินกราฟใหม่ทุกครั้งที่ถามช้าเกินไป หน้านี้สร้างโครงสร้างข้อมูลชื่อ union-find หรือ disjoint set union (DSU) ที่ทำได้สองอย่าง คือรวมสองกลุ่มเข้าด้วยกัน กับบอกว่าสองคนอยู่กลุ่มเดียวกันไหม เริ่มจากแบบตรงไปตรงมา แล้วเพิ่มสองเทคนิคที่ทำให้แต่ละครั้งเร็วจนเกือบเป็นเวลาคงที่ union-find เป็นชิ้นส่วนหลักของ Kruskal ในหน้า 29.3 ด้วย

โจทย์จับกลุ่มทำโครงงาน

ห้องหนึ่งมีนักเรียน N คน หมายเลข 1 ถึง N ตอนเริ่มทุกคนทำงานคนเดียว แล้วมีคำสั่งเข้ามาทีละบรรทัด สองแบบ

  • join a b กลุ่มของ a กับกลุ่มของ b ตกลงรวมเป็นกลุ่มเดียวกัน ถ้าอยู่กลุ่มเดียวกันอยู่แล้วก็ไม่มีอะไรเปลี่ยน
  • ask a b ตอนนี้ a กับ b อยู่กลุ่มเดียวกันหรือยัง

ต้องตอบ ask ตามสภาพ ณ ตอนนั้นทันที โจทย์ที่คำถามแทรกอยู่ระหว่างการเปลี่ยนแปลงแบบนี้เรียกว่าโจทย์แบบ ออนไลน์ (online) ถ้าเก็บ join ทุกเส้นเป็นกราฟแล้ว BFS ทุกครั้งที่มี ask หนึ่งคำถามใช้ O(N + M) เมื่อ M คือจำนวนเส้น แสนคำถามก็ช้าไปหลายสิบวินาที หน้า 29.2 จะวัดให้ดู

แต่ละกลุ่มคือต้นไม้

union-find เก็บแต่ละกลุ่มเป็นต้นไม้หนึ่งต้น ทุกคนจำแค่ parent คือคนที่อยู่เหนือตัวเองหนึ่งขั้น แบบอาเรย์ parent ในหน้า 26.1 รากของต้นไม้คือ หัวหน้ากลุ่ม ต่างจากบทที่ 26 ตรงที่รากชี้กลับมาที่ตัวเอง parent[r] = r แทนการเก็บ 0 จะได้เขียนเงื่อนไขหยุดได้สั้น

  • find หาหัวหน้ากลุ่มของ x คือเดินขึ้นตาม parent จนถึงคนที่ชี้ตัวเอง เหมือนหาความลึกด้วยการเดินขึ้นในหน้า 26.1
  • union รวมกลุ่มของ a กับ b คือหาหัวหน้าของทั้งสองฝั่ง ถ้าเป็นคนละคน ให้หัวหน้าฝั่งหนึ่งชี้ไปที่หัวหน้าอีกฝั่ง ทั้งต้นไม้จึงไปห้อยอยู่ใต้อีกต้นในก้าวเดียว
  • a กับ b อยู่กลุ่มเดียวกันก็ต่อเมื่อ find ของทั้งคู่ได้หัวหน้าคนเดียวกัน

รูปร่างของต้นไม้ไม่มีความหมายอะไร ใครอยู่ใต้ใครก็ได้ สิ่งเดียวที่สำคัญคือเดินขึ้นไปแล้วถึงรากเดียวกันไหม ข้อนี้ทำให้เราดัดรูปต้นไม้ได้ตามใจเพื่อให้เร็วขึ้น ซึ่งเป็นเนื้อหาครึ่งหลังของหน้านี้

แบบตรงไปตรงมา

#include <bits/stdc++.h>
using namespace std;

vector<int> parent;

int find_root(int x)
{
    while (parent[x] != x) {
        x = parent[x]; // (1)!
    }
    return x;
}

void unite(int a, int b) // (2)!
{
    int root_a = find_root(a);
    int root_b = find_root(b);
    if (root_a != root_b) {
        parent[root_a] = root_b; // (3)!
    }
}

int main()
{
    int n, q;
    cin >> n >> q;
    parent.resize(n + 1);
    for (int i = 1; i <= n; i++) {
        parent[i] = i; // (4)!
    }
    for (int k = 0; k < q; k++) {
        string op;
        int a, b;
        cin >> op >> a >> b;
        if (op == "join") {
            unite(a, b);
        } else {
            cout << "ask " << a << ' ' << b << ": " << (find_root(a) == find_root(b) ? "yes" : "no") << '\n';
        }
    }
    cout << "parent:";
    for (int i = 1; i <= n; i++) {
        cout << ' ' << parent[i];
    }
    cout << '\n';
    return 0;
}
  1. เดินขึ้นทีละขั้นจนถึงคนที่ parent เป็นตัวเอง ซึ่งก็คือหัวหน้ากลุ่ม
  2. ตั้งชื่อว่า unite เพราะ union เป็นคำสงวนของภาษาตั้งแต่หน้า 11.3 ใช้เป็นชื่อฟังก์ชันไม่ได้
  3. ต่อ หัวหน้า เข้ากับ หัวหน้า ไม่ใช่ a เข้ากับ b ตรงๆ ดูเหตุผลในส่วนลองทำให้พัง
  4. ตอนเริ่มทุกคนเป็นหัวหน้าของกลุ่มที่มีคนเดียว
8 9
join 1 2
join 3 4
ask 1 3
join 2 4
ask 1 3
join 5 6
join 6 7
ask 5 8
ask 7 5
ask 1 3: no
ask 1 3: yes
ask 5 8: no
ask 7 5: yes
parent: 2 4 4 4 6 7 7 8

บรรทัดสุดท้ายพิมพ์ parent ของทุกคนให้เห็นต้นไม้ข้างใน join 1 2 ให้ 1 ชี้ 2 join 3 4 ให้ 3 ชี้ 4 คำถามแรกจึงตอบ no เพราะหัวหน้าของ 1 คือ 2 ส่วนหัวหน้าของ 3 คือ 4 พอ join 2 4 หัวหน้า 2 ไปชี้หัวหน้า 4 คำถามเดิมรอบที่สองเดินจาก 1 ไป 2 ไป 4 และจาก 3 ไป 4 ได้หัวหน้าเดียวกัน จึงตอบ yes

4 2 3 1 7 6 5 8 หัวหน้ากลุ่ม หัวหน้ากลุ่ม อยู่คนเดียว parent 1 2 2 4 3 4 4 4 5 6 6 7 7 7 8 8

ต้นไม้ที่ยาวเป็นเส้น

find เดินกี่ก้าวขึ้นกับความสูงของต้นไม้ แบบตรงไปตรงมาไม่ได้ควบคุมความสูงเลย ลองรวมกลุ่มต่อกันเป็นทอดๆ

6 6
join 1 2
join 2 3
join 3 4
join 4 5
join 5 6
ask 1 6
ask 1 6: yes
parent: 2 3 4 5 6 6

แต่ละ join i i+1 ทำให้หัวหน้าของกลุ่มเดิมทั้งก้อนไปห้อยใต้คนใหม่ ต้นไม้จึงกลายเป็นเส้นตรง 1 ชี้ 2 ชี้ 3 ไปจนถึง 6 ถาม ask 1 6 ต้องเดิน 5 ก้าวกว่าจะเจอหัวหน้า ถ้ามี N คน เส้นนี้ยาว N - 1 ก้าว และถ้าถามเกี่ยวกับคนท้ายเส้น Q ครั้ง ก็ต้องเดินซ้ำทางเดิม Q ครั้ง รวมเป็น O(NQ) ปัญหาเดียวกับการเดินขึ้นบนต้นไม้เส้นตรงในหน้า 26.1 ที่ N กับ Q เป็นแสน คือราว 1010 ก้าว

path compression

ระหว่างที่ find เดินจาก 1 ขึ้นไปถึง 6 มันผ่าน 2 3 4 5 และรู้แล้วว่าหัวหน้าของทุกคนบนทางนี้คือ 6 ถ้าจดผลนี้ไว้ ให้ทุกคนบนทางชี้ตรงไปที่ 6 เลย ครั้งต่อไปทุกคนก็ถึงหัวหน้าในก้าวเดียว เทคนิคนี้เรียกว่า path compression (บีบทางเดินให้สั้น) ยังเป็นกลุ่มเดิมทุกอย่าง เพราะหัวหน้าไม่เปลี่ยน เปลี่ยนแค่รูปร่างของต้นไม้ ซึ่งเราบอกไว้แล้วว่าไม่มีความหมาย

เขียนด้วย recursion ได้สั้นที่สุด หาหัวหน้าของ parent[x] ก่อน ได้แล้วให้ x ชี้ไปที่หัวหน้านั้นตรงๆ ทุกชั้นของ recursion ทำแบบเดียวกันตอนขากลับ ทุกคนบนทางเดินจึงได้ชี้ตรงไปที่หัวหน้า

#include <bits/stdc++.h>
using namespace std;

vector<int> parent;

int find_root(int x)
{
    if (parent[x] == x) {
        return x;
    }
    parent[x] = find_root(parent[x]); // (1)!
    return parent[x];
}

void unite(int a, int b)
{
    int root_a = find_root(a);
    int root_b = find_root(b);
    if (root_a != root_b) {
        parent[root_a] = root_b;
    }
}

int main()
{
    int n, q;
    cin >> n >> q;
    parent.resize(n + 1);
    for (int i = 1; i <= n; i++) {
        parent[i] = i;
    }
    for (int k = 0; k < q; k++) {
        string op;
        int a, b;
        cin >> op >> a >> b;
        if (op == "join") {
            unite(a, b);
        } else {
            cout << "ask " << a << ' ' << b << ": " << (find_root(a) == find_root(b) ? "yes" : "no") << '\n';
        }
    }
    cout << "parent:";
    for (int i = 1; i <= n; i++) {
        cout << ' ' << parent[i];
    }
    cout << '\n';
    return 0;
}
  1. ต่างจากโปรแกรมก่อนแค่ฟังก์ชันนี้ บรรทัดนี้ทั้งหาหัวหน้าและจดผลลง parent[x] ไว้ใช้ครั้งหน้า
6 6
join 1 2
join 2 3
join 3 4
join 4 5
join 5 6
ask 1 6
ask 1 6: yes
parent: 6 6 6 6 6 6

ตอน join ทั้งห้าครั้ง find เรียกกับคนที่เป็นหัวหน้าอยู่แล้วทุกครั้ง ต้นไม้จึงยังเป็นเส้นตรงเหมือนเดิม แต่ ask 1 6 ครั้งเดียวทำให้ parent ของทุกคนกลายเป็น 6

6 5 4 3 2 1 ก่อนถาม ask 1 6 6 1 2 3 4 5 หลังถาม ask 1 6 find_root(1) เดิน 5 ก้าว

union by size

path compression แก้ทางเดินหลังจากเดินไปแล้วหนึ่งรอบ แต่ find ครั้งแรกบนเส้นที่ยาวยังต้องเดินครบทั้งเส้น และ recursion ก็ลึกเท่าความยาวเส้น ซึ่งมีปัญหาตามส่วนลองทำให้พัง อีกเทคนิคหนึ่งกันไม่ให้ต้นไม้สูงตั้งแต่ตอนรวม ตอน unite เรามีสิทธิ์เลือกว่าจะเอาหัวหน้าฝั่งไหนไปห้อยใต้อีกฝั่ง ลองดูกลุ่มสามคน 1 2 3 กับกลุ่มคนเดียวของ 4

4 1 2 3 1 ไปอยู่ใต้ 4 สูง 2 ชั้น 1 2 3 4 4 ไปอยู่ใต้ 1 สูง 1 ชั้น

ทุกคนในกลุ่มที่ถูกเอาไปห้อยจะอยู่ลึกลงหนึ่งขั้น ถ้าเอากลุ่มใหญ่ไปห้อย คนสามคนลึกขึ้น ถ้าเอากลุ่มเล็กไปห้อย มีคนเดียวที่ลึกขึ้น จึงจำ group_size ขนาดของกลุ่มไว้ที่หัวหน้า แล้ว เอาหัวหน้าของกลุ่มที่เล็กกว่าไปห้อยใต้หัวหน้าของกลุ่มที่ใหญ่กว่าเสมอ เรียกว่า union by size

ต้นไม้สูงไม่เกิน log2 N

ดูคนหนึ่งคน สมมติชื่อ x ความลึกของ x เพิ่มขึ้นได้ทางเดียว คือตอนที่หัวหน้าของ x ถูกเอาไปห้อยใต้หัวหน้าอีกกลุ่ม ซึ่งเกิดเฉพาะเมื่อกลุ่มของ x เล็กกว่าหรือเท่ากับอีกกลุ่ม กลุ่มที่รวมเสร็จจึงใหญ่อย่างน้อยสองเท่าของกลุ่มเดิมของ x ทุกครั้งที่ x ลึกขึ้นหนึ่งขั้น กลุ่มของ x ใหญ่ขึ้นอย่างน้อยสองเท่า กลุ่มเริ่มที่ 1 คนและใหญ่ได้ไม่เกิน N คน จึงเป็นสองเท่าได้ไม่เกิน log2 N ครั้ง x จึงลึกไม่เกิน log2 N ขั้น N เป็นแสนลึกไม่เกิน 16 ขั้น N เป็นล้านไม่เกิน 19 ขั้น

รวมทั้งสองเทคนิคเข้าด้วยกันเป็นโปรแกรมที่จะใช้ตลอดบทนี้

#include <bits/stdc++.h>
using namespace std;

vector<int> parent, group_size; // (1)!

int find_root(int x)
{
    if (parent[x] == x) {
        return x;
    }
    parent[x] = find_root(parent[x]);
    return parent[x];
}

void unite(int a, int b)
{
    int root_a = find_root(a);
    int root_b = find_root(b);
    if (root_a == root_b) {
        return;
    }
    if (group_size[root_a] < group_size[root_b]) {
        swap(root_a, root_b); // (2)!
    }
    parent[root_b] = root_a;
    group_size[root_a] += group_size[root_b]; // (3)!
}

int main()
{
    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;
    }
    for (int k = 0; k < q; k++) {
        string op;
        int a, b;
        cin >> op >> a >> b;
        if (op == "join") {
            unite(a, b);
        } else {
            cout << "ask " << a << ' ' << b << ": " << (find_root(a) == find_root(b) ? "yes" : "no") << '\n';
        }
    }
    cout << "parent:";
    for (int i = 1; i <= n; i++) {
        cout << ' ' << parent[i];
    }
    cout << '\n';
    return 0;
}
  1. อย่าตั้งชื่อตัวแปรนอก main ว่า size ดูแบบที่ 2 ในส่วนลองทำให้พัง
  2. สลับให้ root_a เป็นกลุ่มที่ใหญ่กว่าหรือเท่ากันเสมอ บรรทัดถัดไปจึงเขียนแบบเดียวได้
  3. ขนาดที่ถูกต้องมีแค่ที่หัวหน้า ขนาดที่ค้างอยู่ใน group_size[root_b] ไม่ได้ใช้อีกแล้ว เพราะ root_b ไม่ใช่หัวหน้าแล้ว
8 9
join 1 2
join 3 4
ask 1 3
join 2 4
ask 1 3
join 5 6
join 6 7
ask 5 8
ask 7 5
ask 1 3: no
ask 1 3: yes
ask 5 8: no
ask 7 5: yes
parent: 1 1 1 3 5 5 5 8

คำตอบเหมือนแบบตรงไปตรงมาทุกบรรทัด แต่ต้นไม้ข้างในต่างกัน join 1 2 สองกลุ่มขนาดเท่ากัน จึงไม่สลับ 2 ไปห้อยใต้ 1 join 2 4 รวมกลุ่ม {1, 2} กับ {3, 4} ซึ่งใหญ่เท่ากัน หัวหน้า 3 จึงไปห้อยใต้หัวหน้า 1 ส่วน join 6 7 กลุ่มของ 6 มีสองคน ใหญ่กว่ากลุ่มของ 7 ที่มีคนเดียว 7 จึงไปห้อยใต้ 5 ไม่ใช่ 5 ไปห้อยใต้ 7 แบบเดิม ถ้าป้อนเส้นตรงหกคนชุดเดิม จะได้ parent: 1 1 1 1 1 1 เพราะทุกครั้งคนใหม่ที่อยู่คนเดียวไปห้อยใต้กลุ่มใหญ่

ความซับซ้อน

แบบfind หนึ่งครั้ง
ตรงไปตรงมาO(N) ในกรณีแย่ที่สุด
path compression อย่างเดียวเฉลี่ย O(log N) ต่อครั้งเมื่อทำหลายครั้ง แต่ครั้งเดียวยังเป็น O(N) ได้
union by size อย่างเดียวO(log N) ทุกครั้ง
ทั้งสองอย่างเฉลี่ย O(α(N)) ต่อครั้ง

α(N) คือ inverse Ackermann function เป็นฟังก์ชันที่โตช้ามากจนมีค่าไม่เกิน 4 สำหรับ N ทุกค่าที่เก็บในคอมพิวเตอร์ได้ ในทางปฏิบัติจึงถือว่าเป็นเวลาคงที่ การพิสูจน์ยาวเกินขอบเขตของเอกสารนี้ สิ่งที่ต้องจำคือ union-find ที่มีทั้งสองเทคนิค ทำ N คนกับคำสั่ง Q ครั้งได้ใน O(N + Q α(N)) ซึ่งแทบเท่ากับแค่อ่านข้อมูล

เร็วขึ้นแค่ไหน

วัดบนโน้ตบุ๊ก ASUS Vivobook S 14 ซีพียู AMD Ryzen AI 9 HX 370 ระบบ Windows 11 คอมไพล์ด้วย GCC 14.2 ของ Code::Blocks ข้อมูลคือข้อมูลแย่ที่สุดของแบบตรงไปตรงมา join 1 2 join 2 3 ไปจนถึง join N-1 N ให้ต้นไม้เป็นเส้นตรงยาว แล้วถาม Q ครั้งว่าคนที่ 1 กับคนหมายเลขที่กระจายไปทั่ว อยู่กลุ่มเดียวกันไหม จับเวลาด้วย clock() แบบหน้า 18.2 เฉพาะช่วง join กับ ask รันแต่ละแบบ 5 รอบ ตารางเป็นค่ากลาง

แบบN = Q = 104
Code::Blocks
N = Q = 104
-O2
N = Q = 105
Code::Blocks
N = Q = 105
-O2
ตรงไปตรงมา0.387 วินาที0.262 วินาที39.0 วินาที (5 รอบอยู่ระหว่าง 34.5 ถึง 41.6)22.8 วินาที (5 รอบอยู่ระหว่าง 21.4 ถึง 25.9)
path compression อย่างเดียว0.001 วินาที0.000 วินาทีพัง 0xC00000FD0.003 วินาที
union by size อย่างเดียว0.000 วินาที0.000 วินาที0.003 วินาที0.001 วินาที
ทั้งสองอย่าง0.000 วินาที0.000 วินาที0.003 วินาที0.001 วินาที

แบบตรงไปตรงมาที่ N เป็นแสนต้องเดินราว 1.5 × 1010 ก้าว ช้ากว่าแบบที่มีเทคนิคราว 13,000 เท่า (39.0 กับ 0.003 วินาทีแบบ Code::Blocks) ส่วน path compression อย่างเดียวเร็วพอๆ กับแบบอื่นในข้อมูลชุดนี้ เพราะ ask ครั้งแรกบีบทั้งเส้นให้แบนแล้ว แต่ ask ครั้งแรกนั้นต้อง recursion ลึกแสนชั้น แบบ Code::Blocks จึงพังด้วย stack overflow ส่วนแบบ -O2 รอดที่แสนแต่ไปพังที่ใหญ่กว่านั้น union by size ไม่เคยปล่อยให้ต้นไม้สูงเกิน 16 ชั้นตั้งแต่แรก จึงไม่มีปัญหานี้

ลองทำให้พัง

แบบที่ 1 path compression แบบ recursion โดยไม่มี union by size

ใช้โปรแกรม groups-compress ที่ไม่มี group_size ป้อนเส้นตรง N คนแบบเดียวกับข้อมูลเข้าข้างบน คือ join i i+1 ทุก i แล้วถาม ask 1 N รันจาก PowerShell ค่าละ 5 ครั้ง

NCode::Blocks (-Wall)-O2
10,000 20,000 และ 30,000ผ่านทั้ง 5 ครั้งผ่านทั้ง 5 ครั้ง
40,000 50,000 และ 100,000พังทุกครั้งผ่านทั้ง 5 ครั้ง
150,000 และ 200,000พังทุกครั้งพังทุกครั้ง

ทุกครั้งที่พังได้

Process returned -1073741571 (0xC00000FD)

stack overflow แบบเดียวกับหน้า 26.3 find ครั้งแรกเรียกตัวเองลึกเท่าความยาวของเส้น โจทย์ union-find ให้ N ถึงแสนหรือสองแสนเป็นปกติ และชุดทดสอบที่ดีมีข้อมูลที่ทำให้ต้นไม้ยาวเสมอ โปรแกรม groups-size ที่มี union by size ด้วย ผ่านทุกขนาดที่ลองตั้งแต่ 10,000 ถึง 200,000 ทั้งสองแบบ เพราะ recursion ลึกไม่เกินความสูงของต้นไม้ ซึ่งไม่เกิน log2 N ขั้น ถ้าอยากใช้ path compression อย่างเดียว ให้เขียน find เป็นลูปสองรอบ รอบแรกเดินหาหัวหน้า รอบที่สองเดินทางเดิมซ้ำแล้วให้ทุกคนชี้หัวหน้า

int find_root(int x)
{
    int root = x;
    while (parent[root] != root) {
        root = parent[root];
    }
    while (parent[x] != root) {
        int next = parent[x];
        parent[x] = root;
        x = next;
    }
    return root;
}

แบบที่ 2 ตั้งชื่ออาเรย์ว่า size

หลายคนประกาศอาเรย์ไว้นอก main ให้ทุกฟังก์ชันใช้ได้ แล้วตั้งชื่อตามความหมายตรงๆ

int parent[100001];
int size[100001];
groups.cpp:22:9: error: reference to 'size' is ambiguous
   22 |     if (size[a] < size[b]) {
      |         ^~~~
groups.cpp:5:5: note:                 'int size [100001]'
    5 | int size[100001];
      |     ^~~~

ข้อความจริงยาว 78 บรรทัด error เดียวกันขึ้นซ้ำทุกจุดที่ใช้ size และแต่ละจุดมี note ชี้ไปที่ไฟล์ของ STL อีกหลายบรรทัด ที่ตัดมาคือ error ตัวแรกกับ note ที่ชี้ตัวแปรของเรา คำว่า ambiguous แปลว่ากำกวม เพราะตั้งแต่ C++17 ใน std มีฟังก์ชันชื่อ size อยู่แล้ว และ using namespace std; ดึงชื่อนั้นออกมาชนกับตัวแปรของเรา compiler จึงไม่รู้ว่าหมายถึงตัวไหน ชื่อ rank ที่เห็นบ่อยในโค้ด union-find ของคนอื่นก็ชนแบบเดียวกัน ตั้งชื่อให้ไม่ซ้ำ เช่น group_size หรือเก็บใน vector ที่ประกาศใน main

แบบที่ 3 รวมโดยไม่หาหัวหน้า

ในโปรแกรม groups-naive เขียน unite ให้สั้นลงเป็น parent[a] = b; เพราะคิดว่าให้ a ไปอยู่กลุ่มของ b ก็พอแล้ว

4 4
join 1 2
join 1 3
ask 2 3
ask 1 3
ask 2 3: no
ask 1 3: yes
parent: 3 2 3 4

2 กับ 3 ควรอยู่กลุ่มเดียวกัน เพราะทั้งคู่รวมกับ 1 มาแล้ว แต่ได้ no join 1 2 ตั้ง parent[1] = 2 แล้ว join 1 3 เขียนทับเป็น parent[1] = 3 1 จึงหลุดออกจากกลุ่มของ 2 ทิ้ง 2 ไว้คนเดียว การเขียนทับ parent ของคนที่ไม่ใช่หัวหน้าทำให้ต้นไม้ขาด ต้องต่อที่หัวหน้าเท่านั้น เพราะหัวหน้าเป็นคนเดียวในกลุ่มที่ parent ยังว่างอยู่

สรุป

  • union-find เก็บแต่ละกลุ่มเป็นต้นไม้ด้วยอาเรย์ parent หัวหน้ากลุ่มคือรากซึ่งชี้ตัวเอง สองคนอยู่กลุ่มเดียวกันเมื่อหัวหน้าเป็นคนเดียวกัน
  • รวมกลุ่มต้องต่อหัวหน้ากับหัวหน้าเสมอ
  • path compression ให้ทุกคนบนทางเดินชี้หัวหน้าตรงๆ union by size เอากลุ่มเล็กไปห้อยใต้กลุ่มใหญ่ ต้นไม้จึงสูงไม่เกิน log2 N
  • ใช้ทั้งสองเทคนิค แต่ละครั้งเฉลี่ยเกือบคงที่ ข้อมูลเส้นตรงแสนคนเร็วกว่าแบบตรงไปตรงมาราว 13,000 เท่า
  • find แบบ recursion ที่ไม่มี union by size พังด้วย stack overflow บนเส้นตรงยาว และอย่าตั้งชื่อตัวแปรนอก main ว่า size หรือ rank