ปูพื้นฐาน

ดิสจอยต์เซต: รู้ในพริบตาว่าสองคนอยู่ก๊วนเดียวกันไหม แม้ก๊วนจะรวมกันไปแล้วเป็นแสนครั้ง

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

บทปูพื้นฐาน ★★☆☆☆ dsutreeพื้นฐาน อ่าน 27 นาที 10 กันยายน 2026

ปัญหาที่บทนี้แก้

ลองนึกถึงกลุ่มเพื่อนที่ค่อย ๆ ก่อตัวขึ้นทีละคู่ วันนี้ต้นรู้จักแนน พรุ่งนี้แนนพาเก่งมาเจอ อีกวันเก่งไปสนิทกับกลุ่มของฝ้าย ผ่านไปไม่กี่สัปดาห์ก็มีคนถามว่า "ต้นกับฝ้ายอยู่ก๊วนเดียวกันหรือเปล่า" คนสองคนนี้อาจไม่เคยคุยกันเลย แต่เชื่อมถึงกันผ่านคนกลางหลายทอด

ถ้าตอบด้วยการเดินกราฟ (BFS) จากต้นไปเรื่อย ๆ ว่าถึงฝ้ายไหม หนึ่งคำถามก็ต้องไล่ทั้งกลุ่ม ลองเอาขอบเขตของโจทย์ฝึกข้อแรกในบทนี้มาคูณดู คน 10⁵ คน ความสัมพันธ์ 2·10⁵ คู่ ถามหลังทุกคู่ใหม่ ก็ได้ราว 6·10¹⁰ ก้าว ในขณะที่เวลาที่ให้คือหนึ่งวินาที

สิ่งที่โจทย์แบบนี้ต้องการจริง ๆ มีสองอย่าง คือรวมสองก๊วนเข้าด้วยกัน กับถามว่าสองคนอยู่ก๊วนเดียวกันไหม และไม่มีใครถามเลยว่าเชื่อมกันผ่านใคร บทนี้คือเครื่องมือที่ทำสองอย่างนั้นได้เร็วจนแทบไม่ต้องนับเวลา

ชื่อของเครื่องมือนี้

โครงสร้างนี้ชื่อ disjoint set union ย่อว่า DSU อ่านว่า "ดิสจอยต์ เซต ยูเนียน" คำว่า disjoint มาจาก dis (ไม่) กับ joint (ข้อต่อ) แปลตรงตัวว่า "ไม่มีส่วนที่ต่อกัน" คือเซตที่ไม่มีสมาชิกร่วมกันเลย ซึ่งก็คือก๊วนนั่นเอง คนหนึ่งอยู่ได้ก๊วนเดียว บางตำราเรียกว่า union-find ตามชื่อสองคำสั่งของมัน คือ union (รวม) กับ find (หา)


ถามหาหัวหน้า แทนการเดินทั้งกราฟ

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

หัวหน้าของก๊วนเรียกว่า representative อ่านว่า "เรพรีเซนเททีฟ" แปลว่า "ผู้แทน" คำเดียวกับที่ใช้เรียก ส.ส. ในอังกฤษ ทั้งก๊วนส่งคนคนเดียวมาเป็นหน้าตาของกลุ่ม

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

ของทั้งหมดนี้เก็บได้ในอาร์เรย์แถวเดียว ชื่อ par (ย่อจาก parent แม่) par[x] คือคนที่ x ขึ้นตรงด้วย ส่วนหัวหน้าให้ชี้หาตัวเอง คือ par[x] = x ภาพข้างล่างคืออาร์เรย์แถวเดียวกับป่าของต้นไม้ที่มันแทน

คน par 1 1 2 1 3 2 4 1 5 5 6 5 7 6 8 8 9 8 10 8 1 2 3 4 5 6 7 8 9 10
อาร์เรย์ 10 ช่องนี้แทนก๊วน 3 ก๊วน ช่องที่ชี้หาตัวเอง (สีเขียว) คือหัวหน้า ลูกศรทุกเส้นชี้จากลูกขึ้นไปหาแม่ ตามค่าในช่อง par ของลูก

เท่านี้ก็เขียนโค้ดได้แล้ว find(x) เดินตาม par ขึ้นไปจนเจอหัว ส่วน unite(a, b) ก็หาหัวของทั้งสองฝั่ง แล้วให้หัวฝั่งหนึ่งไปขึ้นกับอีกฝั่ง ก๊วนทั้งก๊วนย้ายตามไปเองโดยไม่ต้องแตะสมาชิกคนอื่นเลย เพราะทุกคนในก๊วนนั้นเดินขึ้นไปก็ต้องผ่านหัวคนเดิมอยู่ดี

naive.cpp
// แบบซื่อ ๆ: ถูกต้อง แต่ต้นไม้อาจกลายเป็นโซ่ยาว n ชั้น
int par[100005];

void init(int n) {
    for (int i = 1; i <= n; i++) par[i] = i;   // ตอนเริ่ม ทุกคนเป็นหัวก๊วนของตัวเอง
}

int find(int x) {
    while (par[x] != x) x = par[x];            // ถามต่อขึ้นไปจนเจอคนที่ไม่มีหัวหน้า
    return x;
}

void unite(int a, int b) {
    par[find(a)] = find(b);                    // หัวก๊วนของ a ไปขึ้นกับหัวก๊วนของ b
}

โค้ดนี้ถูกต้องทุกกรณี ปัญหาอยู่ที่ความลึก find ช้าเท่าจำนวนชั้นที่ต้องเดิน และบรรทัด par[find(a)] = find(b) ไม่ได้ดูเลยว่าฝั่งไหนสูงกว่ากัน ก่อนจะไปดูว่ามันพังยังไง ลองเป็นคนตัดสินใจเองก่อน


ลองเอง · รวมก๊วนให้ต้นไม้เตี้ย

แต่ละชุดมีคำสั่งรวมเรียงมาให้แล้ว ทุกครั้งคุณเลือกได้ว่าหัวของก๊วนไหนจะไปขึ้นกับหัวของอีกก๊วน ผลลัพธ์ที่ได้คือก๊วนเดียวกันไม่ว่าจะเลือกทางไหน ที่ต่างกันคือรูปของต้นไม้ เป้าหมายคือให้คนที่อยู่ลึกที่สุดลึกน้อยที่สุด เพราะนั่นคือจำนวนก้าวที่ find ต้องเดินในกรณีแย่สุด

คนที่ 1 ชวนเพื่อนเข้าก๊วนทีละคน ห้าครั้งติดกัน

รอเริ่ม

เลือกว่าหัวไหนจะไปขึ้นกับหัวไหน

ยังไม่ได้รวมเลย

วงสีเขียวคือหัวก๊วน วงสีทองคือหัวสองคนที่คำสั่งนี้กำลังจะรวม ตัวเลขใต้ต้นไม้คือความลึกของคนที่ลึกที่สุดในก๊วนนั้น

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

พอเห็นแล้วว่าเลือกแบบไหนเตี้ย ลองหากฎที่ใช้ได้ทุกครั้งโดยไม่ต้องคิดเป็นกรณี แล้วค่อยเลื่อนลงไปดู


กลที่ 1 · ให้ก๊วนเล็กไปขึ้นกับก๊วนใหญ่

ภาพข้างล่างคือคำสั่งชุดเดียวกัน unite(1, 2), unite(1, 3) ไปจนถึง unite(1, 8) ทางซ้ายใช้กติกาของโค้ดข้างบน ทางขวาใช้กติกาเดียวที่เพิ่มเข้ามา คือก๊วนที่คนน้อยกว่าเป็นฝ่ายไปขึ้นกับก๊วนที่คนมากกว่า

หัวของคนแรกไปขึ้นกับคนหลัง · ลึก 7 ก๊วนเล็กไปขึ้นกับก๊วนใหญ่ · ลึก 1 1 2 3 4 5 6 7 8 1 2 3 4 5 6 7 8
คำสั่งเดียวกันทุกตัว ทางซ้ายลึก 7 ชั้น เพราะก๊วนที่โตขึ้นเรื่อย ๆ ถูกยกไปขึ้นกับคนที่เพิ่งมาใหม่ทุกครั้ง ทางขวาลึกแค่ 1 ชั้น เพราะคนใหม่แต่ละคนไปต่อใต้หัวคนเดิม

กติกานี้ชื่อ union by size (รวมตามขนาด) ต้องเก็บอาร์เรย์เพิ่มอีกแถวคือ sz ไว้ที่หัวของแต่ละก๊วน ว่าก๊วนนี้มีกี่คน ทำไมมันถึงคุมความลึกได้ ดูจากมุมของคนคนเดียว

คนคนหนึ่งจะลึกลงหนึ่งชั้นก็ต่อเมื่อก๊วนของเขาเป็นฝ่ายไปขึ้นกับก๊วนอื่น และตามกติกา ก๊วนที่เขาไปขึ้นด้วยใหญ่อย่างน้อยเท่าก๊วนของเขาเอง ก๊วนใหม่ที่เขาอยู่จึงใหญ่ขึ้นอย่างน้อยเท่าตัว ทุกครั้งที่เขาลึกลง ก๊วนเริ่มจากคนเดียว และใหญ่ได้ไม่เกิน n คน จึงเพิ่มเท่าตัวได้ไม่เกิน log₂ n ครั้ง ความลึกของทุกคนจึงไม่เกิน log₂ n

ขอบนี้ไม่ได้หลวม ถ้าจับคู่ก๊วนขนาดเท่ากันรวมกันเป็นชั้น ๆ แบบชุด ข ในเกม คนที่ลึกสุดจะลึกพอดี log₂ n ผมตรวจทั้งสองด้านตอนสร้างหน้านี้ ที่ n = 1024 การจับคู่แบบนั้นลึก 10 ชั้นพอดี ส่วนการรวมแบบสุ่ม 200 รอบ ลึกสุดแค่ 6 ชั้น ไม่มีรอบไหนเกิน 10

อีกแบบที่ใช้กันมากคือ union by rank ให้หัวจำความสูงของต้นไม้แทนจำนวนคน คำว่า rank อ่านว่า "แรงก์" แปลว่า "ยศ" หรืออันดับ ยศใครต่ำกว่าคนนั้นไปขึ้นกับอีกฝ่าย ได้ขอบ log₂ n เท่ากัน บทนี้ใช้ขนาดเพราะโจทย์ส่วนใหญ่อยากรู้ขนาดของก๊วนอยู่แล้ว เก็บครั้งเดียวได้ใช้สองต่อ


กลที่ 2 · บีบทางเดินให้สั้นลงระหว่างที่เดิน

กลแรกคุมความลึกไว้ที่ log₂ n กลที่สองใช้ประโยชน์จากงานที่ find ทำไปแล้ว ตอนเดินขึ้นไปหาหัว เราผ่านคนหลายคน และได้รู้ว่าทุกคนบนทางนั้นมีหัวคนเดียวกัน ก็ให้ทุกคนบนทางชี้ตรงไปที่หัวเลย ครั้งหน้าจะได้ไม่ต้องเดินซ้ำ

กลนี้ชื่อ path compression อ่านว่า "พาธ คอมเพรสชัน" path คือทางเดิน compression คือการบีบอัด คำเดียวกับที่ใช้เรียกการบีบไฟล์ให้เป็น zip ในโค้ดใช้แค่บรรทัดเดียว คือเปลี่ยนการเดินให้เป็นการเรียกตัวเอง แล้วเอาคำตอบที่ได้กลับมาเขียนทับ par ของตัวเองระหว่างทางกลับ

ก่อน find(7) · 7 ลึก 4 หลัง find(7) · 7 ลึก 1 1 2 3 4 5 6 7 8 1 2 3 4 5 6 7 8
find(7) เดินผ่าน 7, 5, 3, 2, 1 คนบนทาง (สีทอง) ทุกคนมีหัวคนเดียวกันคือ 1 หลังบีบ มี 3 คนที่เปลี่ยนไปชี้ 1 ตรง ๆ (ลูกศรเขียว) คนนอกทางไม่ถูกแตะเลย

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

find(7) พร้อมบีบทางเดิน

PAR · 8 คน
คน 12345678
par 11223351
ลึก 01223341

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


ใช้สองกลพร้อมกัน เร็วแค่ไหน

ตารางนี้วัดสี่แบบบนอินพุตรูปเดียวกันทุกแถว คือรวมทุกคนเข้ากับคนที่ 1 ทีละคน แล้วถาม find อีก n ครั้ง รูปนี้คือรูปที่ทำให้แบบซื่อกลายเป็นโซ่

วัดหัวต่อหัว · g++ 14.2 -O2 · เร็วสุดของสามรอบ
n แบบซื่อ ตามขนาด บีบทาง ทั้งสองกล
20,000 0.49 วินาที ไม่ถึง 1 ms ไม่ถึง 1 ms ไม่ถึง 1 ms
50,000 3.07 วินาที ไม่ถึง 1 ms ไม่ถึง 1 ms ไม่ถึง 1 ms
200,000 ไม่ได้รอ 1 ms 1 ms 1 ms
แบบซื่อโตตามกำลังสองของ n ขยับจากสองหมื่นเป็นห้าหมื่นก็ช้าลงราวหกเท่า แถวสองแสนจึงไม่ได้รอให้จบ ส่วนสามแบบที่เหลือเร็วเกินกว่าที่นาฬิกาจะแยกออกจากกันได้บนอินพุตรูปนี้

ในทางทฤษฎี ถ้าใช้สองกลพร้อมกัน ต้นทุนเฉลี่ยต่อหนึ่งคำสั่งคือ O(α(n)) ตัว α คือฟังก์ชันผกผันของฟังก์ชันแอคเคอร์มันน์ ซึ่งโตช้าจนสำหรับ n ทุกค่าที่เก็บลงหน่วยความจำของคอมพิวเตอร์ได้ มันมีค่าไม่เกิน 4 ในทางปฏิบัติจึงนับเป็นค่าคงที่ได้เลย

แกะคำศัพท์

ชื่อ Ackermann อ่านว่า "อัคเคอร์มันน์" มาจาก Wilhelm Ackermann นักคณิตศาสตร์ชาวเยอรมัน ที่ตีพิมพ์ฟังก์ชันนี้ในปี 1928 เพื่อแสดงว่ามีฟังก์ชันที่คำนวณได้แต่โตเร็วเกินกว่าจะเขียนด้วยลูปซ้อนจำนวนจำกัด มันโตเร็วจนตัวผกผันของมันโตช้าที่สุดเท่าที่นักเรียนอัลกอริทึมจะได้เจอ ส่วนคนที่พิสูจน์ว่า DSU เร็วระดับนี้คือ Robert Tarjan ในปี 1975

ขอบเขตสองแบบที่ต้องแยกให้ออก

คำว่า "เฉลี่ย" ตรงนี้คือ amortized แบบเดียวกับที่บท Knuth อธิบายไว้ บางครั้ง find ครั้งหนึ่งแพง แต่มันจ่ายล่วงหน้าให้ครั้งต่อ ๆ ไปถูกลง รวมทั้งชุดแล้วจึงถูก ส่วนรวมตามขนาดอย่างเดียวให้ขอบ O(log n) กับทุกครั้ง ไม่ต้องเฉลี่ย เพราะความลึกไม่เคยเกิน log₂ n

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


แม่แบบที่ลอกไปใช้ได้ทุกข้อ

ทุกข้อในบทนี้ใช้ชื่อชุดเดียวกัน par, sz, find, unite และ unite คืน bool ว่ารวมจริงหรือเปล่า ค่านี้มีประโยชน์กว่าที่คิด เพราะเกือบทุกโจทย์อยากรู้ว่าเส้นเชื่อมเส้นนี้เชื่อมอะไรใหม่หรือเปล่า

dsu.cpp
// แม่แบบ DSU: รวมตามขนาด + บีบทางเดิน ลอกไปใช้ได้ทุกข้อ
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 200005;
int par[MAXN], sz[MAXN];

void init(int n) {
    for (int i = 1; i <= n; i++) par[i] = i, sz[i] = 1;
}

int find(int x) {
    // เรียกตัวเองได้อย่างปลอดภัย เพราะรวมตามขนาดทำให้ลึกไม่เกิน log2 n ชั้น
    return par[x] == x ? x : par[x] = find(par[x]);
}

bool unite(int a, int b) {
    a = find(a), b = find(b);
    if (a == b) return false;            // อยู่ก๊วนเดียวกันอยู่แล้ว
    if (sz[a] < sz[b]) swap(a, b);       // ให้ a เป็นก๊วนที่ใหญ่กว่าเสมอ
    par[b] = a;                          // ก๊วนเล็กไปขึ้นกับก๊วนใหญ่
    sz[a] += sz[b];
    return true;
}

int main() {
    int n, q;
    scanf("%d %d", &n, &q);
    init(n);
    while (q--) {
        char op; int a, b;
        scanf(" %c %d %d", &op, &a, &b);
        if (op == '+') unite(a, b);
        else puts(find(a) == find(b) ? "YES" : "NO");
    }
    return 0;
}

ผมตรวจแม่แบบนี้กับตัวเดินกราฟใหม่ทุกคำถาม บนกราฟสุ่มขนาดไม่เกิน 40 จุด 20,000 รอบ รวมคำถามว่าอยู่ก๊วนเดียวกันไหม 404,246 คำถาม ตรงกันทุกคำถาม และหลังจบแต่ละรอบ ไม่มีใครลึกเกิน log₂ n เลย

เก็บข้อมูลของทั้งก๊วนไว้ที่หัว

sz เป็นแค่ตัวอย่างแรก อะไรก็ตามที่รวมสองก๊วนแล้วคำนวณค่าของก๊วนใหม่ได้จากค่าของสองก๊วนเดิม ก็เก็บไว้ที่หัวได้ ค่าน้อยสุด ค่ามากสุด ผลรวม จำนวนเส้นเชื่อม ตอน unite ให้หัวใหม่รวมค่าของหัวเก่าเข้ามา แล้วค่าที่หัวเก่าถืออยู่ก็ไม่มีใครอ่านอีก


โจทย์ฝึก · ท่าที่งอกออกมาจาก DSU

แปดข้อนี้เรียงให้แต่ละข้อสอนมุมใหม่หนึ่งมุม ครึ่งแรกใช้แม่แบบตรง ๆ ครึ่งหลังเริ่มดัดมัน ให้ถืออย่างอื่นนอกจากขนาด หรือใช้มันกับสิ่งที่ไม่ได้หน้าตาเหมือนกราฟ สามข้อในนี้มาจากรายการท้ายหน้า DSU ของ cp-algorithms ส่วนที่เหลือผมเลือกเพิ่มให้ครบมุม

ฝึกข้อ 1 · สร้างถนนทีละเส้น (CSES 1676 Road Construction)

เมืองในประเทศหนึ่งยังไม่มีถนนเลย ทุกวันจะมีถนนใหม่หนึ่งเส้น หลังจบแต่ละวันให้บอกว่าตอนนี้ประเทศแตกเป็นกี่ก๊วน และก๊วนที่ใหญ่ที่สุดมีกี่เมือง

อินพุต / ขอบเขต / เอาต์พุต

EXAMPLE
InputOutput
5 3
1 2
1 3
4 5
4 2
3 3
2 3

อ่านตัวอย่างนี้ยังไง

มี 5 เมือง ถนน 3 วัน เอาต์พุตบรรทัดละหนึ่งวัน ตัวเลขแรกคือจำนวนก๊วน ตัวที่สองคือขนาดก๊วนใหญ่สุด วันที่ 1 ถนน 1-2 เหลือ 4 ก๊วน ใหญ่สุด 2 เมือง · วันที่ 2 ถนน 1-3 เหลือ 3 ก๊วน ใหญ่สุด 3 เมือง · วันที่ 3 ถนน 4-5 เหลือ 2 ก๊วน ใหญ่สุด 3 เมือง

หลังวันที่ 1 · 4 ก๊วน ใหญ่สุด 2 1 2 3 4 5 หลังวันที่ 2 · 3 ก๊วน ใหญ่สุด 3 1 2 3 4 5 หลังวันที่ 3 · 2 ก๊วน ใหญ่สุด 3 1 2 3 4 5
ป่าของ DSU หลังจบแต่ละวันของตัวอย่าง ใช้กติกาของแม่แบบ คือขนาดเท่ากันให้ตัวหลังไปขึ้นกับตัวแรก เมืองที่ยังเดี่ยวอยู่ก็คือก๊วนขนาดหนึ่ง

ใบ้

จำนวนก๊วนไม่ต้องนับใหม่ทุกวัน ถนนเส้นหนึ่งเปลี่ยนจำนวนก๊วนได้แค่สองแบบ ส่วนก๊วนใหญ่สุด ลองถามว่ามันลดลงได้ไหม ถ้าไม่ได้ วันไหนบ้างที่มันมีโอกาสเปลี่ยน

เฉลยฝึกข้อ 1

ที่มาของแนวคิดนี้

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

ทางแรกคือเดินกราฟใหม่ทุกวัน วันละ n + m ก้าว คูณ m วันได้ 6·10¹⁰ ทางที่สองคือใช้ DSU แต่หลังรวมเสร็จไล่ดู sz ของทุกหัวเพื่อหาค่ามากสุด วันละ n คูณ m วันยังเป็น 2·10¹⁰ ตัวเลขนี้เองที่บังคับให้คิดต่อว่า ค่ามากสุดเปลี่ยนได้ตอนไหนบ้าง

บทเรียนที่หยิบไปใช้ต่อได้คือ ก่อนจะคำนวณค่าใหม่ทั้งหมด ให้ถามว่าเหตุการณ์หนึ่งครั้งเปลี่ยนค่านั้นได้แค่ไหน

จำนวนก๊วนเริ่มที่ n ถนนที่ unite คืน true ลดมันลงหนึ่ง ถนนที่คืน false คือถนนที่สองปลายถึงกันอยู่แล้ว ไม่ลดอะไร

ส่วนก๊วนใหญ่สุด ไม่มีการลบถนน ก๊วนจึงไม่เคยเล็กลง ค่ามากสุดไม่มีวันลด และจะเพิ่มได้ก็เฉพาะวันที่เกิดการรวม ซึ่งก๊วนที่เพิ่งรวมเป็นก๊วนเดียวที่ขนาดเปลี่ยน จึงเทียบ best กับ sz ของหัวใหม่แค่ครั้งเดียว

โค้ดฝึกข้อ 1

ดูโค้ด C++
road-construction.cpp
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100005;
int par[MAXN], sz[MAXN];

int find(int x) {
    // บีบทางเดิน: ทุกตัวบนทางชี้ตรงไปหาหัวก๊วน รอบหน้าเดินก้าวเดียวก็ถึง
    return par[x] == x ? x : par[x] = find(par[x]);
}

bool unite(int a, int b) {
    a = find(a), b = find(b);
    if (a == b) return false;             // อยู่ก๊วนเดียวกันอยู่แล้ว ถนนเส้นนี้ไม่ได้เชื่อมอะไรใหม่
    if (sz[a] < sz[b]) swap(a, b);        // เอาก๊วนเล็กไปห้อยใต้ก๊วนใหญ่ ต้นไม้จึงสูงไม่เกิน log n
    par[b] = a;
    sz[a] += sz[b];
    return true;
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    int n, m;
    cin >> n >> m;
    for (int i = 1; i <= n; i++) par[i] = i, sz[i] = 1;

    int comps = n, best = 1;               // เริ่มแต่ละเมืองเป็นก๊วนเดี่ยว ขนาด 1
    while (m--) {
        int a, b;
        cin >> a >> b;
        if (unite(a, b)) {
            comps--;
            // ก๊วนจะโตได้ก็ตอนรวมเท่านั้น จึงเช็กแค่ก๊วนที่เพิ่งรวมก็พอ
            best = max(best, sz[find(a)]);
        }
        cout << comps << ' ' << best << '\n';
    }
    return 0;
}

ตรวจกับตัวเดินกราฟใหม่ทุกวันซึ่งไม่มี DSU อยู่ข้างในเลย 5,000 รอบบวกตัวอย่างของโจทย์ ตรงกันทุกรอบ เคสใหญ่สุด n = 10⁵, m = 2·10⁵ ใช้ 89 ms รวมเวลาเปิดโปรแกรม

ฝึกข้อ 2 · ปิดถนนเกิน เอาไปต่อก๊วนที่ขาด (Codeforces 25D Roads not only in Berland)

ประเทศหนึ่งมี n เมือง ถนน n - 1 เส้นพอดี แต่ถนนชุดนี้ยังไม่ได้ทำให้ทุกเมืองถึงกัน รัฐบาลไม่มีงบสร้างถนนเพิ่มเฉย ๆ วันหนึ่งทำได้อย่างเดียว คือปิดถนนเดิมหนึ่งเส้นแล้วสร้างเส้นใหม่แทนทันที ให้หาจำนวนวันที่น้อยที่สุด พร้อมแผนว่าแต่ละวันปิดเส้นไหน สร้างเส้นไหน

อินพุต / ขอบเขต / เอาต์พุต

EXAMPLE
InputOutput
7
1 2
2 3
3 1
4 5
5 6
6 7
1
3 1 3 7

อ่านตัวอย่างนี้ยังไง

เมือง 1, 2, 3 ต่อกันเป็นสามเหลี่ยม ส่วน 4 ถึง 7 ต่อกันเป็นเส้นตรง รวมเป็น 2 ก๊วน คำตอบของโจทย์คือปิดถนน 3-1 แล้วสร้าง 3-7 แทน วันเดียวจบ แผนที่ตอบนี้ไม่ได้มีแบบเดียว โค้ดของบทนี้ตอบ 3 1 1 4 ซึ่งก็ถูกเหมือนกัน

1 2 3 4 5 6 7
ถนนสีแดงประคือเส้นที่ปิดได้โดยไม่มีใครขาดจากกัน เพราะสองปลายยังเดินอ้อมถึงกัน เส้นสีเขียวประคือถนนใหม่ที่โค้ดของบทนี้สร้าง ต่อหัวของสองก๊วนเข้าหากัน

ใบ้

เริ่มจากขอบล่าง ถ้าตอนนี้มี k ก๊วน วันหนึ่งสร้างถนนได้เส้นเดียว ต้องใช้อย่างน้อยกี่วัน แล้วถามต่อว่าจะมีถนนให้ปิดโดยไม่ทำให้ก๊วนแตกพอหรือเปล่า ลองนับจากจำนวนถนน n - 1 เส้น

เฉลยฝึกข้อ 2

ที่มาของแนวคิดนี้

สิ่งแรกที่ผมคิดไม่ใช่ว่าจะปิดเส้นไหน แต่เป็นว่าอย่างน้อยต้องกี่วัน ถ้ามี k ก๊วน ถนนใหม่หนึ่งเส้นลดจำนวนก๊วนได้มากสุดหนึ่ง การปิดถนนมีแต่ทำให้ก๊วนเท่าเดิมหรือเพิ่ม จึงต้องใช้อย่างน้อย k - 1 วัน

ข้อที่ทำให้มั่นใจว่า k - 1 วันพอเสมอคือการนับ ก๊วน k ก๊วนที่มี n เมือง ใช้ถนนที่เชื่อมจริงพอดี n - k เส้น ถนนที่เหลือซึ่งไม่ได้เชื่อมอะไรใหม่จึงมี (n - 1) - (n - k) = k - 1 เส้น เท่ากับจำนวนวันที่ต้องใช้พอดี

ทางที่ตีราคาแล้วไม่เอาคือหาวงรอบด้วย DFS แล้วเลือกเส้นย้อนกลับ ทำได้เหมือนกันและ n ≤ 1000 เล็กมาก ที่ไม่เอาเพราะต้องเขียนสองขั้น ส่วน DSU ได้ทั้งเส้นเกินและก๊วนจากการอ่านรอบเดียว บทเรียนคือ ในโจทย์ที่ถามว่า "อย่างน้อยกี่ครั้ง" ลองหาขอบล่างก่อนหาวิธี บ่อยครั้งขอบล่างนั้นเองคือคำตอบ

อ่านถนนทีละเส้นแล้ว unite เส้นที่ได้ false คือเส้นที่สองปลายถึงกันแล้วด้วยเส้นก่อนหน้า ปิดทิ้งได้ฟรี อ่านครบแล้วเก็บหัวของทุกก๊วน จากนั้นใช้เส้นเกินเส้นที่ i ต่อหัวของก๊วนแรกเข้ากับหัวของก๊วนที่ i + 1

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

โค้ดฝึกข้อ 2

ดูโค้ด C++
berland-roads.cpp
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 1005;
int par[MAXN], sz[MAXN];

int find(int x) {
    return par[x] == x ? x : par[x] = find(par[x]);
}

bool unite(int a, int b) {
    a = find(a), b = find(b);
    if (a == b) return false;
    if (sz[a] < sz[b]) swap(a, b);
    par[b] = a;
    sz[a] += sz[b];
    return true;
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    int n;
    cin >> n;
    for (int i = 1; i <= n; i++) par[i] = i, sz[i] = 1;

    // ถนนที่ unite ไม่สำเร็จคือถนนที่ปิดวงรอบ ปิดทิ้งได้โดยไม่มีใครขาดจากกัน
    vector<pair<int, int>> extra;
    for (int i = 0; i < n - 1; i++) {
        int a, b;
        cin >> a >> b;
        if (!unite(a, b)) extra.push_back({a, b});
    }

    // หัวของทุกก๊วน: มีถนน n-1 เส้น เสียไป k เส้น จึงเหลือก๊วนพอดี k+1 ก๊วน
    vector<int> heads;
    for (int i = 1; i <= n; i++)
        if (find(i) == i) heads.push_back(i);

    cout << extra.size() << '\n';
    for (size_t i = 0; i < extra.size(); i++)
        // ใช้ถนนเกินเส้นที่ i ต่อก๊วนแรกเข้ากับก๊วนที่ i+1 ทุกก๊วนจึงถูกต่อครั้งเดียว ไม่เกิดวงรอบใหม่
        cout << extra[i].first << ' ' << extra[i].second << ' '
             << heads[0] << ' ' << heads[i + 1] << '\n';
    return 0;
}

โจทย์นี้ยอมรับหลายคำตอบ จึงเทียบข้อความตรงตัวไม่ได้ ตัวตรวจทำสองชั้น ชั้นแรกหาจำนวนวันน้อยสุดด้วยการค้นทุกสถานะของชุดถนน ซึ่งไม่ได้เชื่อข้ออ้าง k - 1 เลย ใช้กับ n ≤ 6 อีกชั้นเล่นแผนจริงทีละวัน ว่าถนนที่ปิดมีอยู่จริง ถนนใหม่ไม่ซ้ำ และสุดท้ายถึงกันหมด รวม 6,000 รอบ ผ่านทุกรอบ

ฝึกข้อ 3 · ซ่อมถนนให้ถูกที่สุด (CSES 1675 Road Reparation)

ถนนทุกเส้นในประเทศพังหมด แต่ละเส้นมีค่าซ่อมของมันเอง ให้เลือกซ่อมบางเส้นให้ทุกเมืองเดินถึงกันได้ โดยจ่ายรวมน้อยที่สุด ถ้าซ่อมทุกเส้นแล้วยังไม่ถึงกันหมด ให้ตอบ IMPOSSIBLE

อินพุต / ขอบเขต / เอาต์พุต

EXAMPLE
InputOutput
5 6
1 2 3
2 3 5
2 4 2
3 4 8
5 1 7
5 4 4
14

อ่านตัวอย่างนี้ยังไง

ห้าเมือง ถนนหกเส้น ตัวเลขตัวที่สามของแต่ละบรรทัดคือค่าซ่อม ต้นไม้ที่เชื่อมห้าเมืองต้องใช้ถนนสี่เส้นพอดี ชุดที่ถูกที่สุดคือ 2-4 (2), 1-2 (3), 5-4 (4), 2-3 (5) รวม 14

3 5 2 8 7 4 1 2 3 4 5
เส้นสีเขียวคือถนนที่ถูกซ่อม ถ้าไล่จากถูกไปแพง ถนน 5-1 กับ 3-4 ถูกข้ามเพราะตอนที่ไล่ถึง สองปลายถึงกันแล้วด้วยถนนที่ถูกกว่า

ใบ้

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

เฉลยฝึกข้อ 3

ที่มาของแนวคิดนี้

เลือกเส้นให้ทุกจุดถึงกันด้วยราคารวมต่ำสุด คือโจทย์ต้นไม้แผ่ทั่วที่เบาที่สุด (minimum spanning tree) ข้อนี้ผมไม่ได้ลองอะไรผิดก่อน ที่ต้องตัดสินใจจริงมีสองเรื่อง คือจะใช้วิธีไหน กับผลรวมจะล้น int หรือเปล่า

เรื่องหลังคิดก่อนเขียน ต้นไม้มี n - 1 เส้น เส้นละได้ถึง 10⁹ รวมได้ราว 10¹⁴ จึงใช้ long long ตั้งแต่แรก แล้วทำเคสที่ทุกเส้นราคา 10⁹ ไว้ยืนยัน ได้ 99999000000000 บทเรียนคือ คูณขอบบนของคำตอบให้เสร็จก่อนเลือกชนิดตัวแปร

วิธีนี้ชื่อ Kruskal อ่านว่า "ครัสคัล" ตามชื่อ Joseph Kruskal ที่ตีพิมพ์ในปี 1956 เรียงถนนจากถูกไปแพง แล้วถาม DSU ทีละเส้นว่าสองปลายอยู่ก๊วนเดียวกันหรือยัง ถ้ายัง ซ่อม ถ้าอยู่แล้ว ข้าม

เส้นที่ข้ามได้ปลอดภัย เพราะตอนที่ไล่มาถึง สองปลายถึงกันแล้วด้วยถนนที่ถูกกว่าหรือเท่ากันทั้งหมด ซ่อมเพิ่มไม่ได้ทำให้ใครถึงกันเพิ่ม ส่วน IMPOSSIBLE ไม่ต้องเดินกราฟแยก ถ้าไล่ครบแล้วซ่อมได้ไม่ถึง n - 1 เส้น แปลว่ามีก๊วนเหลือมากกว่าหนึ่ง

โค้ดฝึกข้อ 3

ดูโค้ด C++
road-reparation.cpp
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

const int MAXN = 100005;
int par[MAXN], sz[MAXN];

int find(int x) {
    return par[x] == x ? x : par[x] = find(par[x]);
}

bool unite(int a, int b) {
    a = find(a), b = find(b);
    if (a == b) return false;
    if (sz[a] < sz[b]) swap(a, b);
    par[b] = a;
    sz[a] += sz[b];
    return true;
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    int n, m;
    cin >> n >> m;
    vector<array<int, 3>> roads(m);                // {ค่าซ่อม, a, b} ใส่ค่าซ่อมไว้หน้าสุดเพื่อให้ sort เรียงตามราคา
    for (auto& [c, a, b] : roads) cin >> a >> b >> c;
    sort(roads.begin(), roads.end());
    for (int i = 1; i <= n; i++) par[i] = i, sz[i] = 1;

    ll total = 0;                                   // n-1 เส้น เส้นละ 1e9 เกิน int ได้
    int used = 0;
    for (auto& [c, a, b] : roads)
        // ถูกสุดก่อน และซ่อมเฉพาะเส้นที่ต่อสองก๊วนที่ยังแยกกัน เส้นที่ปิดวงรอบมีเส้นที่ถูกกว่าทำงานแทนไปแล้ว
        if (unite(a, b)) total += c, used++;

    if (used == n - 1) cout << total << '\n';
    else cout << "IMPOSSIBLE\n";                    // ซ่อมครบทุกเส้นที่ช่วยได้แล้วยังไม่ถึง n-1 แปลว่ากราฟขาดจากกันตั้งแต่ต้น
    return 0;
}

ตัวตรวจไม่เรียงเส้นและไม่มี DSU ถ้ามีถนนไม่เกิน 16 เส้น มันไล่ทุกสับเซตของถนนแล้วดูว่าเชื่อมกันหมดไหม ถ้ามากกว่านั้นใช้ Prim แบบตาราง และในรอบที่เล็กพอ สองวิธีนี้ถูกเทียบกันเองด้วย รวม 6,000 รอบ ค่าซ่อมสุ่มในช่วงแคบให้ราคาชนกันบ่อย ผ่านทุกรอบ เคสใหญ่สุดใช้ 104 ms

ฝึกข้อ 4 · ใยแมงมุมที่ถูกฉีกทีละเส้น (Timus 1671 Anansi's Cobweb)

ใยแมงมุมมีปม N ปม เส้นใย M เส้น ศัตรูวางแผนไว้แล้วว่าจะฉีกเส้นไหนบ้างตามลำดับไหน หลังการฉีกแต่ละครั้ง ให้บอกว่าใยขาดเป็นกี่ชิ้น

อินพุต / ขอบเขต / เอาต์พุต

EXAMPLE
InputOutput
4 4
1 2
2 3
1 3
3 4
3
2 4 3
1 2 3

อ่านตัวอย่างนี้ยังไง

เส้นใยมีหมายเลขตามลำดับบรรทัด เส้นที่ 1 คือ 1-2 เส้นที่ 2 คือ 2-3 ไปจนเส้นที่ 4 คือ 3-4 บรรทัดสุดท้ายบอกว่าจะฉีกเส้นที่ 2, 4, 3 ตามลำดับ ครั้งแรกฉีก 2-3 แต่ปม 2 กับ 3 ยังอ้อมผ่านปม 1 ได้ จึงยังเป็นชิ้นเดียว

หลังฉีกครั้งที่ 1 · 1 ชิ้น 1 2 3 4 หลังฉีกครั้งที่ 2 · 2 ชิ้น 1 2 3 4 หลังฉีกครั้งที่ 3 · 3 ชิ้น 1 2 3 4
ใยหลังการฉีกแต่ละครั้ง เส้นแดงประคือเส้นที่เพิ่งถูกฉีก เส้นที่ถูกฉีกไปก่อนหน้าหายไปแล้ว คำตอบคือ 1, 2, 3 ชิ้น

ใบ้

DSU รวมได้อย่างเดียว แยกไม่ได้ แต่ข้อนี้ให้แผนการฉีกมาครบทั้งหมดก่อนต้องตอบอะไรเลย และไม่มีเส้นไหนถูกต่อกลับ ลองดูภาพจากขวาไปซ้าย ถ้าเดินทางนั้น แต่ละก้าวเกิดอะไรขึ้นกับใย

เฉลยฝึกข้อ 4

ที่มาของแนวคิดนี้

ทางแรกที่ตัดทิ้งก่อนเขียนคือฉีกแล้วนับชิ้นใหม่ด้วย BFS รอบละ N + M = 2·10⁵ ก้าว ทำ 10⁵ ครั้งเป็น 2·10¹⁰ ส่วน DSU ตรง ๆ ก็ใช้ไม่ได้ เพราะพอฉีกเส้นหนึ่ง เราไม่รู้ด้วยซ้ำว่าก๊วนจะขาดหรือไม่ ขึ้นกับว่ามีทางอ้อมหรือเปล่า และข้อมูลนั้นไม่ได้อยู่ใน par เลย

จุดที่ทำให้ข้อนี้ง่ายคือสองประโยคในโจทย์ ลำดับการฉีกให้มาครบใน input และเส้นที่ฉีกแล้วไม่ถูกต่อกลับ พอกลับทิศเวลา การฉีกทุกครั้งกลายเป็นการต่อ ซึ่งคือสิ่งเดียวที่ DSU ถนัด บทเรียนคือ ถ้ารู้เหตุการณ์ทั้งหมดล่วงหน้า ลองอ่านมันจากท้ายมาหน้าก่อนจะสรุปว่าเครื่องมือทำไม่ได้

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

ท่านี้พึ่งสองอย่างพร้อมกัน

อย่างแรกคือรู้ทุกเหตุการณ์ล่วงหน้า อย่างที่สองคือทุกเหตุการณ์ไปทางเดียว มีแต่ฉีก ถ้าโจทย์ให้ฉีกแล้วต่อกลับได้ ท่านี้พังทันที ลองดูใยสามปม เส้น 1-2 กับ 2-3 แล้วมีเหตุการณ์ ฉีก 1-2, ต่อ 1-2 กลับ, ฉีก 2-3 พอเดินถอยหลัง ก้าวแรกคือต่อ 2-3 กลับ ทำได้ แต่ก้าวที่สองคือถอด 1-2 ออก ซึ่ง DSU ทำไม่ได้อีกแล้ว

โจทย์ที่มีทั้งเพิ่มและลบปนกันต้องใช้ DSU ที่ย้อนการรวมล่าสุดได้ บวกกับการจัดว่าเส้นแต่ละเส้นมีชีวิตอยู่ช่วงไหน ซึ่งเป็นเรื่องทั้งบทของต้นไม้บนแกนเวลา

โค้ดฝึกข้อ 4

ดูโค้ด C++
anansi.cpp
#include <bits/stdc++.h>
using namespace std;

vector<int> par, sz;
int comps; // จำนวนชิ้นของใยตอนนี้

int find(int x) {
    // บีบทางเดิน: ให้ทุกตัวบนทางชี้ตรงไปที่หัวหน้า
    return par[x] == x ? x : par[x] = find(par[x]);
}

bool unite(int a, int b) {
    a = find(a), b = find(b);
    if (a == b) return false;      // อยู่ชิ้นเดียวกันอยู่แล้ว เส้นนี้ไม่ช่วยเชื่อมอะไรเพิ่ม
    if (sz[a] < sz[b]) swap(a, b); // เอาต้นเล็กไปห้อยใต้ต้นใหญ่
    par[b] = a;
    sz[a] += sz[b];
    comps--;
    return true;
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    int n, m;
    cin >> n >> m;
    vector<int> ea(m + 1), eb(m + 1);
    for (int i = 1; i <= m; i++) cin >> ea[i] >> eb[i];
    int q;
    cin >> q;
    vector<int> tear(q);
    vector<bool> torn(m + 1, false);
    for (int i = 0; i < q; i++) {
        cin >> tear[i];
        torn[tear[i]] = true;
    }

    par.resize(n + 1);
    sz.assign(n + 1, 1);
    iota(par.begin(), par.end(), 0);
    comps = n;

    // เริ่มจากสภาพสุดท้าย: ใส่เฉพาะเส้นที่ไม่มีวันถูกฉีก
    for (int i = 1; i <= m; i++)
        if (!torn[i]) unite(ea[i], eb[i]);

    // ย้อนเวลา: ans[i] คือสภาพหลังฉีกครั้งที่ 0..i
    // ถอยจากท้ายมา การ "ฉีกเส้น tear[i]" กลายเป็น "ต่อเส้น tear[i] กลับ"
    // ต่อกลับแล้วจะได้สภาพก่อนฉีกครั้งที่ i ซึ่งก็คือหลังฉีกครั้งที่ i-1
    vector<int> ans(q);
    ans[q - 1] = comps;
    for (int i = q - 1; i > 0; i--) {
        unite(ea[tear[i]], eb[tear[i]]);
        ans[i - 1] = comps;
    }

    for (int i = 0; i < q; i++) cout << ans[i] << (i + 1 < q ? ' ' : '\n');
    return 0;
}

ตรวจกับตัวที่เดินไปข้างหน้าตามเรื่องจริงแล้ว BFS นับชิ้นใหม่ทุกครั้ง ซึ่งไม่รู้จักการย้อนเวลาเลย 5,000 รอบบวกตัวอย่างสองข้อของโจทย์ ตรงกันทุกรอบ เคสใหญ่สุดใช้ 76 ms

ฝึกข้อ 5 · จับโกหกเรื่องเลขคู่เลขคี่ (Timus 1003 Parity)

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

อินพุต / ขอบเขต / เอาต์พุต

EXAMPLE
InputOutput
10
5
1 2 even
3 4 odd
5 6 even
1 6 even
7 10 odd
-1
3

อ่านตัวอย่างนี้ยังไง

สตริงยาว 10 ตัว มีคำถาม 5 ข้อ สามข้อแรกเป็นไปได้พร้อมกัน เช่นสตริงที่ขึ้นต้นด้วย 000100 แต่ข้อที่สี่บอกว่าช่วง 1 ถึง 6 มีเลข 1 เป็นจำนวนคู่ ในขณะที่สามช่วงย่อยที่ต่อกันพอดีเป็น 1 ถึง 6 มีเลข 1 รวมกันเป็นคี่ คำตอบจึงเป็น 3 เอาต์พุตไม่ใช่หมายเลขข้อที่โกหก แต่เป็นจำนวนข้อที่ยังเชื่อได้

ใบ้

สตริงยาวพันล้านตัวเก็บทั้งหมดไม่ได้ ลองนึกถึงผลรวมสะสมของเลข 1 แล้วถามว่าคำว่า "ช่วง l ถึง r เป็นคี่" พูดถึงค่าสะสมที่ตำแหน่งไหนบ้าง ถ้าแต่ละคำถามคือเส้นเชื่อมสองจุด การโกหกหน้าตาเป็นแบบไหน

เฉลยฝึกข้อ 5

ที่มาของแนวคิดนี้

สองทางแรกทิ้งเพราะตัวเลข ลองทุกสตริงคือ 2 ยกกำลังพันล้าน ส่วนเก็บผลรวมสะสมของทุกตำแหน่ง ต้องใช้อาร์เรย์พันล้านช่อง แค่ int ก็สี่กิกะไบต์ เทียบกับหน่วยความจำ 64 MB แต่ตัวเลขที่สองนี้เองชี้ทางออก คำถามห้าพันข้อแตะตำแหน่งรวมกันไม่เกินหนึ่งหมื่นจุด ที่เหลือไม่มีใครพูดถึง

ทางถัดมาคือทุกครั้งที่เพิ่มคำถาม ระบายสองสีใหม่ทั้งกราฟ ต่อชุดราว 7.5·10⁷ ก้าว ยังพอไหว แต่โจทย์ไม่ได้บอกว่ามีกี่ชุด และทุกครั้งที่เพิ่มเส้น มีแค่สองจุดที่ความสัมพันธ์เปลี่ยน ซึ่งเป็นกลิ่นของ DSU บทเรียนคือ ถ้าโจทย์ถามว่า "เพิ่มข้อมูลทีละชิ้นแล้วเริ่มขัดกันเมื่อไร" ให้นึกถึง DSU ที่จำความต่างไว้ด้วย

ให้ pre[i] คือความเป็นคู่หรือคี่ของจำนวนเลข 1 ในตำแหน่ง 1 ถึง i คำถาม "ช่วง l ถึง r เป็นคี่" เท่ากับ pre[l-1] xor pre[r] = 1 แต่ละคำถามจึงเป็นเส้นเชื่อมระหว่างสองจุดพร้อมป้าย 0 หรือ 1 และชุดคำถามจะขัดกันก็ต่อเมื่อมีวงที่ xor ของป้ายรอบวงเป็น 1

ข้อ 1 · คู่ (0) ข้อ 2 · คี่ (1) ข้อ 3 · คู่ (0) ข้อ 4 · คู่ (0) 0 2 4 6 pre[ตำแหน่ง]
จุดบนเส้นคือค่าสะสมที่คำถามพูดถึง 0-2 even, 2-4 odd, 4-6 even ต่อกันเป็นทางจาก 0 ไปถึง 6 ที่ xor รวมเป็น 1 คำถามที่ 4 (เส้นแดง) ปิดวงนี้ด้วยป้าย 0 จึงขัด

DSU ของข้อนี้เก็บเพิ่มอีกหนึ่งแถว d[x] คือ xor จาก x ไปถึงแม่ของมัน ตอนบีบทางเดิน d ต้องสะสม xor ของแม่เข้ามาด้วย เพื่อให้ยังหมายถึง xor ถึงหัวคนใหม่ ตอนเพิ่มคำถาม (a, b, w) มีสองกรณี

  • อยู่คนละก๊วน ความสัมพันธ์ระหว่างสองก๊วนยังไม่เคยถูกกำหนด เชื่อมได้เสมอ แค่ต้องตั้ง d ของหัวที่ไปขึ้นกับอีกฝั่งให้ครบวง คือ d[a] xor w xor d[b]
  • อยู่ก๊วนเดียวกัน ความสัมพันธ์ถูกกำหนดไปแล้วโดยคำถามเก่า เทียบ d[a] xor d[b] กับ w ตรงกันก็ผ่าน ไม่ตรงกันก็คือข้อแรกที่โกหก

ข้อนี้ unite ยังคืน bool เหมือนแม่แบบ แต่ความหมายเปลี่ยน false แปลว่าประโยคนี้ขัดกับที่รู้แล้ว ไม่ใช่แค่ว่าอยู่ก๊วนเดียวกันแล้ว ส่วนตำแหน่งที่ไกลถึงพันล้านถูกบีบให้เหลือเฉพาะจุดที่คำถามพูดถึง ด้วยการเรียงแล้วตัดตัวซ้ำ ท่านี้เรียกว่า coordinate compression อ่านว่า "โคออร์ดิเนต คอมเพรสชัน" แปลว่า "บีบพิกัด" คำว่า compression ตัวเดียวกับบีบทางเดิน แต่คราวนี้บีบเลขตำแหน่ง

โค้ดฝึกข้อ 5

ดูโค้ด C++
parity.cpp
#include <bits/stdc++.h>
using namespace std;

vector<int> par, sz, d; // d[x] = ความเป็นคู่/คี่ของ pre[x] xor pre[par[x]]

int find(int x) {
    if (par[x] == x) return x;
    int r = find(par[x]);
    d[x] ^= d[par[x]]; // ตอนนี้ d[par[x]] คือระยะจากแม่ถึงหัวหน้าแล้ว
    par[x] = r;
    return r;
}

// บันทึกว่า pre[a] xor pre[b] = w  คืน false ถ้าขัดกับที่รู้มาก่อน
bool unite(int a, int b, int w) {
    int ra = find(a), rb = find(b);
    if (ra == rb) return (d[a] ^ d[b]) == w;
    if (sz[ra] < sz[rb]) swap(ra, rb); // สลับหัวหน้าได้ เพราะ xor สลับข้างได้
    par[rb] = ra;
    d[rb] = d[a] ^ d[b] ^ w;           // pre[rb] xor pre[ra] ที่ทำให้เงื่อนไขนี้จริง
    sz[ra] += sz[rb];
    return true;
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    long long len;
    while (cin >> len && len != -1) {
        int q;
        cin >> q;
        vector<long long> lo(q), hi(q);
        vector<int> w(q);
        vector<long long> xs;
        for (int i = 0; i < q; i++) {
            string s;
            cin >> lo[i] >> hi[i] >> s;
            lo[i]--; // ช่วง [l, r] มีเลขคี่ตัว  <=>  pre[r] xor pre[l-1] = 1
            w[i] = (s == "odd");
            xs.push_back(lo[i]);
            xs.push_back(hi[i]);
        }
        // ความยาวถึง 10^9 แต่มีจุดที่ถูกพูดถึงไม่เกิน 2q จุด
        sort(xs.begin(), xs.end());
        xs.erase(unique(xs.begin(), xs.end()), xs.end());
        int k = xs.size();
        par.resize(k);
        iota(par.begin(), par.end(), 0);
        sz.assign(k, 1);
        d.assign(k, 0);

        int ans = q; // ถ้าไม่มีข้อไหนขัด คำตอบคือจำนวนคำถามทั้งหมด
        for (int i = 0; i < q; i++) {
            int a = lower_bound(xs.begin(), xs.end(), lo[i]) - xs.begin();
            int b = lower_bound(xs.begin(), xs.end(), hi[i]) - xs.begin();
            if (!unite(a, b, w[i])) {
                ans = i; // ข้อ 0..i-1 ยังเป็นไปได้ ข้อที่ i พัง
                break;   // คำถามที่เหลืออ่านมาครบแล้ว หยุดได้เลย
            }
        }
        cout << ans << '\n';
    }
    return 0;
}

ตัวตรวจไม่รู้จัก DSU เลย มันลองทุกสตริง 0/1 ความยาวไม่เกิน 8 แล้วหาข้อแรกที่ไม่มีสตริงไหนเข้ากับทุกข้อก่อนหน้า สุ่ม 20,000 ไฟล์ ไฟล์ละหนึ่งถึงสามชุด เพื่อจับกรณีลืมล้างสถานะระหว่างชุด รวม 39,847 ชุด ตรงกันทุกชุด ชุดใหญ่สุดหนึ่งชุดใช้ 59 ms

ฝึกข้อ 6 · อัศวินตกรอบ ใครชนะใคร (Codeforces 356A Knight Tournament)

ทัวร์นาเมนต์มีอัศวิน n คน สู้กัน m นัด นัดหนึ่งบอกช่วงหมายเลข [l, r] กับผู้ชนะ x อัศวินทุกคนในช่วงนั้นที่ยังไม่ตกรอบลงสนาม แล้วทุกคนยกเว้น x ตกรอบไป ให้บอกว่าอัศวินแต่ละคนแพ้ให้ใคร แชมป์ให้ตอบ 0

อินพุต / ขอบเขต / เอาต์พุต

EXAMPLE
InputOutput
8 4
3 5 4
3 7 6
2 8 8
1 8 1
0 8 4 6 4 8 6 1

อ่านตัวอย่างนี้ยังไง

แต่ละแถวในภาพข้างล่างคือหนึ่งนัด ช่องสีเขียวคือผู้ชนะ ช่องสีแดงคือคนที่ตกรอบในนัดนั้น ช่องสีทองคือคนที่หมายเลขอยู่ในช่วง แต่ตกรอบไปก่อนแล้ว จึงไม่ได้ลงสนาม เอาต์พุตตัวที่ 1 เป็น 0 เพราะอัศวิน 1 ชนะนัดสุดท้าย

12345678 [3,5] ชนะ 4 · · แพ้ 4 ชนะ แพ้ 4 · · · [3,7] ชนะ 6 · · ข้าม แพ้ 6 ข้าม ชนะ แพ้ 6 · [2,8] ชนะ 8 · แพ้ 8 ข้าม ข้าม ข้าม แพ้ 8 ข้าม ชนะ [1,8] ชนะ 1 ชนะ ข้าม ข้าม ข้าม ข้าม ข้าม ข้าม แพ้ 1
ทั้งทัวร์นาเมนต์มีช่องสีทอง 12 ช่อง คือช่องที่วิธีตรง ๆ ต้องเดินผ่านทั้งที่ไม่มีอะไรให้ทำ ในเคสที่ช่วงกว้างทุกนัด ช่องแบบนี้คืองานเกือบทั้งหมด

ใบ้

คนที่ตกรอบแล้วไม่กลับมาอีก การตกรอบจึงเกิดรวมกันไม่เกิน n - 1 ครั้งทั้งทัวร์นาเมนต์ ที่ช้าจริงคือการเดินผ่านช่องสีทองซ้ำแล้วซ้ำอีก ถ้ามีอะไรสักอย่างบอกได้ทันทีว่า "คนที่ยังอยู่ถัดไปทางขวาคือใคร" งานจะเหลือเท่าไร

เฉลยฝึกข้อ 6

ผมแก้ถูกตั้งแต่แรก แต่มันพังตอนวัดเวลา

รุ่นแรกใช้ find แบบเรียกตัวเองบรรทัดเดียว เหมือนแม่แบบของบทนี้ ผ่านตัวอย่างของโจทย์ทั้งสองข้อ และผ่านการเทียบกับตัวจำลองตรง ๆ 20,000 รอบบนอินพุตเล็ก ถ้าหยุดตรงนั้นก็คงส่งไปแล้ว

ตอนวัดเวลา ผมตั้งใจสร้างเคสที่โซ่ยาวที่สุดโดยไม่มีใครมาบีบ คือนัด (k, k+1, k+1) สำหรับทุก k แต่ละนัดตั้ง par[k] = k+1 แล้ว find ถัดไปก็เริ่มที่ k+1 ไม่เคยย้อนมาบีบ k เลย พอนัดสุดท้ายเรียก find(1) โค้ดต้องเรียกตัวเองซ้อนกันราวสามแสนชั้น บนเครื่องนี้โปรแกรมตายด้วยสแต็กล้นโดยไม่พิมพ์อะไรออกมาเลย ไล่ขนาดดูแล้ว ที่ n = 100,000 ยังรอด ส่วนที่ 110,000 ขึ้นไปพังทุกรอบ

ต้นเหตุคือแม่แบบเรียกตัวเองได้อย่างปลอดภัยเพราะรวมตามขนาดคุมความลึกไว้ ข้อนี้รวมตามขนาดไม่ได้ จึงไม่มีอะไรคุมเลย บทเรียนคือ เมื่อถอดกลใดกลหนึ่งออกจากแม่แบบ ให้ถามว่าส่วนอื่นของแม่แบบพึ่งกลนั้นอยู่หรือเปล่า ผมไม่ได้ลองส่งรุ่นแรกขึ้นระบบตรวจของ Codeforces จึงไม่รู้ว่าสแต็กที่นั่นพอหรือไม่

ให้ find(i) ตอบว่า "อัศวินที่ยังไม่ตกรอบซึ่งหมายเลขน้อยสุดที่ไม่น้อยกว่า i คือใคร" ใครตกรอบก็ตั้ง par[i] = i + 1 ส่งคำถามต่อไปทางขวา แล้วบีบทางเดินทำให้โซ่ของคนที่ตกรอบยุบเหลือก้าวเดียว ต้องมีช่อง n + 1 ไว้เป็นกำแพงท้ายแถว ให้ find มีที่หยุดเมื่อไม่เหลือใครทางขวา

ข้อนี้คือ DSU ที่ห้ามรวมตามขนาด เพราะทิศถูกบังคับ คนที่ตกรอบต้องชี้ไปทางขวาเสมอ ไม่งั้น find จะหยุดเป็นคำตอบของ "คนถัดไปทางขวา" ทันที ขอบเวลาจึงมาจากบีบทางเดินอย่างเดียว ซึ่งเป็น O(log n) แบบเฉลี่ย

โค้ดฝึกข้อ 6

ดูโค้ด C++
knight-tournament.cpp
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 300005;
// find(i) = อัศวินหมายเลขน้อยสุดที่ >= i และยังไม่แพ้ (ถ้าไม่มีเลย จะได้ n+1)
int par[MAXN];

// ข้อนี้รวมตามขนาดไม่ได้ (คนแพ้ต้องชี้ไปทางขวาเสมอ) โซ่จึงยาวได้เป็นแสนช่อง
// find แบบเรียกตัวเองจะล้นสแต็ก เลยเขียนเป็นสองรอบ: รอบแรกหาหัว รอบสองให้ทุกตัวบนทางชี้ตรงไปที่หัว
int find(int x) {
    int root = x;
    while (par[root] != root) root = par[root];
    while (par[x] != root) {
        int next = par[x];
        par[x] = root;
        x = next;
    }
    return root;
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    int n, m;
    cin >> n >> m;
    for (int i = 1; i <= n + 1; i++) par[i] = i;   // n+1 เป็นกำแพงท้ายแถว find จะไม่วิ่งเลยออกไป
    vector<int> beat(n + 1, 0);

    while (m--) {
        int l, r, x;
        cin >> l >> r >> x;
        // กระโดดข้ามคนที่แพ้ไปแล้วทั้งหมด แตะเฉพาะคนที่ยังอยู่ในช่วง
        for (int i = find(l); i <= r; i = find(i + 1)) {
            if (i == x) continue;                  // ผู้ชนะยังอยู่ต่อ
            beat[i] = x;
            par[i] = i + 1;                        // แพ้แล้ว ชี้ไปทางขวา ใครมาถามก็ถูกส่งต่อ
        }
    }
    for (int i = 1; i <= n; i++) cout << beat[i] << " \n"[i == n];
    return 0;
}

ตรวจกับตัวจำลองตรง ๆ ที่เดินทุกช่องในช่วงทุกนัด 20,000 รอบบวกตัวอย่างสองข้อของโจทย์ ตรงกันทุกรอบ เคสใหญ่สามแบบที่ n = 3·10⁵ (โซ่ยาว, สุ่ม, ช่วงกว้าง) ใช้ไม่เกิน 98 ms

ดูรุ่นที่สแต็กล้น
knight-tournament-stack-overflow.cpp
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 300005;
// find(i) = อัศวินหมายเลขน้อยสุดที่ >= i และยังไม่แพ้ (ถ้าไม่มีเลย จะได้ n+1)
int par[MAXN];

int find(int x) {
    return par[x] == x ? x : par[x] = find(par[x]);   // ผิดตรงนี้: เรียกตัวเองลึกเท่าความยาวโซ่ โซ่ยาวแสนช่องล้นสแต็กเริ่มต้นของ Windows
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    int n, m;
    cin >> n >> m;
    for (int i = 1; i <= n + 1; i++) par[i] = i;   // n+1 เป็นกำแพงท้ายแถว find จะไม่วิ่งเลยออกไป
    vector<int> beat(n + 1, 0);

    while (m--) {
        int l, r, x;
        cin >> l >> r >> x;
        // กระโดดข้ามคนที่แพ้ไปแล้วทั้งหมด แตะเฉพาะคนที่ยังอยู่ในช่วง
        for (int i = find(l); i <= r; i = find(i + 1)) {
            if (i == x) continue;                  // ผู้ชนะยังอยู่ต่อ
            beat[i] = x;
            par[i] = i + 1;                        // แพ้แล้ว ชี้ไปทางขวา ใครมาถามก็ถูกส่งต่อ
        }
    }
    for (int i = 1; i <= n; i++) cout << beat[i] << " \n"[i == n];
    return 0;
}

ต่างจากรุ่นที่ถูกแค่ฟังก์ชัน find รุ่นนี้ตอบถูกทุกเคสที่สแต็กไม่ล้น จึงไม่มี "อินพุตเล็กที่สุดที่ทำให้ผิด" ให้โชว์ อินพุตที่ทำให้มันพังคือโซ่ที่ยาวราวแสนช่องขึ้นไป และขอบนี้ขึ้นกับขนาดสแต็กของเครื่องที่รัน

ฝึกข้อ 7 · นับสีในทุกต้นย่อย (CSES 1139 Distinct Colors)

ต้นไม้มีราก n จุด จุด 1 เป็นราก ทุกจุดมีสีของตัวเอง ให้บอกว่าต้นย่อยของแต่ละจุดมีสีต่างกันกี่สี ต้นย่อยของ v คือ v กับทุกจุดที่อยู่ใต้มัน

อินพุต / ขอบเขต / เอาต์พุต

EXAMPLE
InputOutput
5
2 3 2 2 1
1 2
1 3
3 4
3 5
3 1 2 1 1

อ่านตัวอย่างนี้ยังไง

บรรทัดที่สองคือสีของจุด 1 ถึง 5 ตามลำดับ ต้นย่อยของจุด 3 มีจุด 3, 4, 5 สีเป็น 2, 2, 1 จึงมีสองสี ส่วนจุด 1 เป็นรากเห็นทั้งต้น มีสามสี จุดที่เป็นใบเห็นแค่สีของตัวเอง ได้ 1 เสมอ

1 สี 2 · ตอบ 3 2 สี 3 · ตอบ 1 3 สี 2 · ตอบ 2 4 สี 2 · ตอบ 1 5 สี 1 · ตอบ 1
ต้นไม้ของตัวอย่าง สีของวงคือสีของจุด ตัวเลขข้างวงคือคำตอบของต้นย่อยนั้น ต้นย่อยของจุด 3 มีสีซ้ำอยู่คู่หนึ่ง จึงได้ 2 ไม่ใช่ 3

ใบ้

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

เฉลยฝึกข้อ 7

ที่มาของแนวคิดนี้

ท่าตรง ๆ คือทุกจุดเดินลงไปทั้งต้นย่อยแล้วนับสีด้วย set งานรวมคือผลรวมขนาดของทุกต้นย่อย บนต้นไม้โซ่ได้ราว n²/2 = 2·10¹⁰ ท่าถัดมาคือเทเซตของลูกขึ้นไปใส่พ่อ ซึ่งไม่ต้องเดินซ้ำ แต่ถ้าเทตรง ๆ ทุกครั้ง บนโซ่ลูกที่ลึกกว่าถือสีเกือบทั้งหมด เทขึ้นหนึ่งชั้นก็ย้ายเกือบทั้งก้อนอีกรอบ กลับไปเป็น n²/2 เหมือนเดิม

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

กติกาคือเทก้อนที่เล็กกว่าใส่ก้อนที่ใหญ่กว่าเสมอ ถ้าถังของพ่อเล็กกว่าถังของลูก ก็สลับถังกันก่อน ซึ่ง swap ของ std::set แค่สลับตัวชี้ ใช้เวลาคงที่ ท่านี้ชื่อ small-to-large อ่านว่า "สมอล ทู ลาร์จ" แปลตรงตัวว่า "เล็กเข้าใหญ่"

เหตุผลที่เร็วเป็นประโยคเดียวกับกลที่ 1 ทุกคำ สีหนึ่งค่าจะถูกหยิบย้ายก็ต่อเมื่ออยู่ในก้อนเล็ก และหลังย้ายมันอยู่ในก้อนที่ใหญ่อย่างน้อยเท่าตัวของก้อนเดิม ขนาดเพิ่มเท่าตัวได้ไม่เกิน log₂ n ครั้ง งานรวมจึงเป็น O(n log n) ครั้งของการใส่ แต่ละครั้ง O(log n)

บนต้นไม้โซ่ที่ทุกจุดสีต่างกัน · g++ 14.2 -O2
n เล็กเข้าใหญ่ ใหญ่เข้าเล็ก
10,000 40 ms 2.4 วินาที
20,000 51 ms 10.2 วินาที
40,000 52 ms 42.6 วินาที
200,000 113 ถึง 119 ms ราว 24.7 นาที
สองคอลัมน์ต่างกันแค่เครื่องหมายเทียบในบรรทัดเดียว และตอบเลขชุดเดียวกันทุกไฟล์ ใหญ่เข้าเล็กโตเป็นกำลังสอง n เพิ่มสองเท่า เวลาเพิ่มราวสี่เท่า แถวสุดท้ายวัดครั้งเดียว

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

โค้ดฝึกข้อ 7

ดูโค้ด C++
distinct-colors.cpp
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    int n;
    cin >> n;
    vector<set<int>> s(n + 1); // s[v] = สีทั้งหมดในต้นย่อยของ v (สร้างเสร็จเมื่อลูกทุกตัวรวมเข้ามาแล้ว)
    for (int v = 1; v <= n; v++) {
        int c;
        cin >> c;
        s[v].insert(c);
    }
    vector<vector<int>> g(n + 1);
    for (int i = 0; i < n - 1; i++) {
        int a, b;
        cin >> a >> b;
        g[a].push_back(b);
        g[b].push_back(a);
    }

    // เรียงจุดแบบ BFS จากราก ลูกจะอยู่หลังพ่อเสมอ
    // ไล่จากท้ายมาจึงได้ทำลูกครบก่อนพ่อ โดยไม่ต้อง DFS เวียนเกิดลึก 2*10^5 ชั้น
    vector<int> order, parent(n + 1, 0);
    order.reserve(n);
    order.push_back(1);
    for (int i = 0; i < n; i++) {
        int u = order[i];
        for (int v : g[u])
            if (v != parent[u]) parent[v] = u, order.push_back(v);
    }

    vector<int> ans(n + 1);
    for (int i = n - 1; i >= 0; i--) {
        int v = order[i];
        ans[v] = s[v].size();
        int p = parent[v];
        if (p == 0) continue; // ราก ไม่มีใครให้รวมเข้า
        // รวมเล็กเข้าใหญ่: ให้ s[p] เป็นก้อนที่ใหญ่กว่าเสมอ แล้วเทก้อนเล็กใส่
        // swap ของ set แค่สลับตัวชี้ ไม่ได้คัดลอกสมาชิก
        if (s[p].size() < s[v].size()) swap(s[p], s[v]);
        for (int c : s[v]) s[p].insert(c);
        set<int>().swap(s[v]); // คืนหน่วยความจำ ต้นย่อยนี้ตอบไปแล้ว
    }

    for (int v = 1; v <= n; v++) cout << ans[v] << (v < n ? ' ' : '\n');
    return 0;
}

ตัวตรวจทำ DFS แยกให้ทุกจุด นับสีในต้นย่อยตรง ๆ โดยไม่ส่งข้อมูลระหว่างจุดเลย สุ่ม 5,000 รอบ สลับเลขจุดและลำดับเส้นด้วย ตรงกันทุกรอบ ต้นไม้สุ่มขนาด 2·10⁵ ใช้ 331 ms

ฝึกข้อ 8 · ห่วงโซ่อาหารสามชนิด (SPOJ CHAIN Strange Food Chain)

สัตว์มีสามชนิด A กิน B, B กิน C และ C กิน A มีสัตว์ n ตัว แต่ละตัวเป็นหนึ่งในสามชนิดแต่เราไม่รู้ว่าตัวไหนชนิดอะไร มีคนพูดประโยคมาทีละประโยค 1 x y แปลว่า x กับ y ชนิดเดียวกัน 2 x y แปลว่า x กิน y ประโยคหนึ่งถือว่าโกหกถ้ามีสัตว์หมายเลขเกิน n ถ้าบอกว่าสัตว์กินตัวเอง หรือถ้าขัดกับประโยคที่จริงก่อนหน้า ให้นับประโยคโกหก

อินพุต / ขอบเขต / เอาต์พุต

EXAMPLE
InputOutput
1
100 7
1 101 1
2 1 2
2 2 3
2 3 3
1 1 3
2 3 1
1 5 5
3

อ่านตัวอย่างนี้ยังไง

บรรทัดแรกคือจำนวนชุด ตัวอย่างนี้มีชุดเดียว สัตว์ 100 ตัว 7 ประโยค ประโยคโกหกเป็นประโยคที่มาจากคนละเหตุผลกัน ข้อ 1 มีสัตว์หมายเลข 101 ข้อ 4 บอกว่า 3 กินตัวเอง ส่วนข้อ 5 ต้องอาศัยข้อ 2 กับ 3 ที่เชื่อไปแล้ว ถ้า 1 กิน 2 และ 2 กิน 3 แล้ว 1 กับ 3 จะเป็นชนิดเดียวกันไม่ได้

ใบ้

ข้อนี้คือข้อ 5 ที่ป้ายบนเส้นมีสามค่าแทนสองค่า ถ้าให้ชนิดเป็นเลข 0, 1, 2 ประโยค "x กิน y" พูดถึงความต่างของชนิดว่าอะไร แล้ว xor ของข้อ 5 ต้องเปลี่ยนเป็นอะไร

เฉลยฝึกข้อ 8

ที่มาของแนวคิดนี้

ก่อนคิดอะไร ต้องได้ข้อความโจทย์ก่อน หน้า SPOJ ปฏิเสธการดึงอัตโนมัติทุกทาง ผมจึงอ่านจากสำเนาหน้า SPOJ ที่ archive.org เก็บไว้เมื่อปี 2022 แล้วเทียบกับโจทย์ต้นเรื่องคือ POJ 1182 กติกาตรงกัน แต่รูปแบบอินพุตไม่ตรง SPOJ มีบรรทัดจำนวนชุดนำหน้า POJ ไม่มี ถ้าเขียนตาม POJ จะอ่านเลขจำนวนชุดเป็น n แล้วพังตั้งแต่บรรทัดแรก

ท่าที่ทิ้งคือลองทุกวิธีแจกชนิด 3 ยกกำลังห้าหมื่น กับระบายสามสีใหม่ทุกประโยค ราว 1.5·10¹⁰ ต่อชุด พอเห็นว่ามันคือข้อ 5 ที่ป้ายมีสามค่า เหลือแค่สองจุดที่ต้องคิดใหม่ บทเรียนคือ เมื่อยกท่าจาก xor ไปใช้กับมอดุโลอื่น ให้ถามทุกบรรทัดว่ามันพึ่งการที่ลบกับบวกเป็นเรื่องเดียวกันหรือเปล่า

ให้ชนิดเป็น 0, 1, 2 แล้ว "x กิน y" คือ ชนิด(y) = ชนิด(x) + 1 มอดุโล 3 เพราะ A กิน B, B กิน C, C กิน A เป็นการบวกหนึ่งวนรอบพอดี ประโยคทั้งสองแบบจึงเป็น "ความต่างของชนิดเท่ากับ w" โดย w เป็น 0 หรือ 1 และ d[x] คือชนิดของ x ลบชนิดของแม่ มอดุโล 3

ต่างจากข้อ 5 ตรงที่การลบไม่เท่ากับการบวกอีกต่อไป ใน xor สองอย่างนี้เป็นอันเดียวกัน จึงไม่เคยต้องคิด มีสองจุดที่เปลี่ยน

  • ตอนเช็กก๊วนเดียวกัน ความต่างคือ d[y] - d[x] ถ้าสลับข้าง 1 จะกลายเป็น 2 และคำตอบผิดทันที
  • ตอนสลับให้ก๊วนใหญ่เป็นแม่ ความต่างที่คำนวณไว้ต้องกลับเครื่องหมาย เพราะมองจากอีกฝั่ง

และ % ของ C++ ให้ค่าติดลบได้ จึงบวก 3 ก่อนมอดทุกครั้งที่มีการลบ ประโยคที่โกหกไม่ถูกบันทึกโดยธรรมชาติ เพราะกรณีที่ขัดคือกรณีที่สองตัวอยู่ก๊วนเดียวกัน ซึ่ง unite ไม่ได้แตะ par หรือ d เลย

ตัวอย่างทีละประโยค
ข้อ ประโยค ผล เหตุผล
1 1 101 1 โกหก มีสัตว์หมายเลขเกิน 100
2 2 1 2 จริง ยังไม่เคยรู้ความสัมพันธ์ของสองตัวนี้ รับไว้เป็นความจริง
3 2 2 3 จริง ยังไม่เคยรู้ความสัมพันธ์ของสองตัวนี้ รับไว้เป็นความจริง
4 2 3 3 โกหก บอกว่าสัตว์กินตัวเอง
5 1 1 3 โกหก ขัดกับที่รู้แล้ว 3 กิน 1
6 2 3 1 จริง ตรงกับที่รู้แล้ว
7 1 5 5 จริง ตัวเดียวกันย่อมเป็นชนิดเดียวกัน
ตารางนี้มาจากตัวแปลความที่รันตอนสร้างหน้า ถ้าผลไม่ตรงกับคำใบ้ของโจทย์ว่าข้อ 1, 4, 5 โกหก หน้านี้จะบิลด์ไม่ผ่าน ข้อ 7 บอกว่า 5 กับ 5 เป็นชนิดเดียวกัน ซึ่งไม่ติดกฎข้อไหน จึงนับเป็นจริง

โค้ดฝึกข้อ 8

ดูโค้ด C++
food-chain.cpp
#include <bits/stdc++.h>
using namespace std;

// ชนิดสัตว์เป็น 0, 1, 2 และ x กิน y  <=>  ชนิด(y) = ชนิด(x) + 1 (มอดุโล 3)
// d[x] = ชนิด(x) - ชนิด(par[x]) มอดุโล 3  (0 = พวกเดียวกับแม่, 1 = แม่กิน x, 2 = x กินแม่)
vector<int> par, sz, d;

int find(int x) {
    if (par[x] == x) return x;
    int r = find(par[x]);
    d[x] = (d[x] + d[par[x]]) % 3; // ต่อระยะ x ถึงแม่ กับแม่ถึงหัวหน้า
    par[x] = r;
    return r;
}

// บันทึกว่า ชนิด(y) - ชนิด(x) = w (มอดุโล 3)  คืน false ถ้าขัดกับที่เชื่อไปแล้ว
bool unite(int x, int y, int w) {
    int rx = find(x), ry = find(y);
    if (rx == ry) return ((d[y] - d[x]) % 3 + 3) % 3 == w;
    // ให้ ry ไปห้อยใต้ rx: ชนิด(ry) - ชนิด(rx) = d[x] + w - d[y]
    int t = ((d[x] + w - d[y]) % 3 + 3) % 3;
    if (sz[rx] < sz[ry]) {
        swap(rx, ry);
        t = (3 - t) % 3; // มองกลับด้าน ความต่างก็กลับเครื่องหมาย
    }
    par[ry] = rx;
    d[ry] = t;
    sz[rx] += sz[ry];
    return true;
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    int t;
    cin >> t;
    while (t--) {
        int n, k;
        cin >> n >> k;
        par.resize(n + 1);
        iota(par.begin(), par.end(), 0);
        sz.assign(n + 1, 1);
        d.assign(n + 1, 0);
        int lies = 0;
        for (int i = 0; i < k; i++) {
            int D, x, y;
            cin >> D >> x >> y;
            // เงื่อนไขโกหกสองข้อแรก ต้องเช็คก่อนแตะ DSU (x หรือ y อาจเกิน n)
            if (x > n || y > n || (D == 2 && x == y)) {
                lies++;
                continue;
            }
            // D = 1 พวกเดียวกัน ความต่าง 0  /  D = 2 x กิน y ความต่าง 1
            if (!unite(x, y, D - 1)) lies++; // ประโยคที่ขัดไม่ถูกบันทึก เพราะมันไม่ใช่ความจริง
        }
        cout << lies << '\n';
    }
    return 0;
}

ตัวตรวจเก็บทุกวิธีแจกชนิดที่ยังเข้ากับประโยคจริงทั้งหมด (สัตว์ไม่เกินหกตัว คือ 729 แบบ) แล้วถือว่าประโยคใหม่จริงถ้ายังเหลือสักแบบที่เข้ากับมัน ตรงกับนิยามของโจทย์ทุกตัวอักษร สุ่ม 20,000 ไฟล์ รวม 40,065 ชุด บางประโยคให้หมายเลขเกิน n ตรงกันทุกชุด ชุดใหญ่สุดหนึ่งชุดใช้ 78 ms


สรุปบรรทัดเดียว

DSU ถามแค่ว่าใครคือหัวหน้า รวมตามขนาดทำให้คำถามนี้เดินไม่เกิน log₂ n ชั้น บีบทางเดินทำให้ครั้งต่อไปเดินแทบไม่ต้องเดิน และทุกอย่างที่คำนวณจากสองก๊วนได้ก็เก็บไว้ที่หัวได้

โครงสร้างนี้เก่งทางเดียว คือรวม ถ้าวันไหนโจทย์สั่งให้แยกก๊วนออกจากกัน มันทำไม่ได้เลย และนั่นคือจุดเริ่มของบทถัดไป

ที่มาและอ่านต่อ

  1. cp-algorithms, "Disjoint Set Union" ต้นทางของบทนี้ ทั้งสองกล ท่าขยาย และรายการโจทย์ฝึกท้ายหน้า cp-algorithms.com
  2. B. A. Galler, M. J. Fischer, "An improved equivalence algorithm", Communications of the ACM 7(5), 1964 งานแรกที่เก็บก๊วนเป็นป่าของต้นไม้
  3. R. E. Tarjan, "Efficiency of a Good But Not Linear Set Union Algorithm", Journal of the ACM 22(2), 1975 ที่มาของขอบ α(n)
  4. M. L. Fredman, M. E. Saks, "The cell probe complexity of dynamic data structures", STOC 1989 ขอบล่างที่บอกว่าเร็วกว่านี้ไม่ได้แล้ว
  5. J. B. Kruskal, "On the shortest spanning subtree of a graph and the traveling salesman problem", Proceedings of the AMS, 1956
  6. โจทย์: CSES 1676, 1675, 1139 · Codeforces 25D, 356A · Timus 1003, 1671 · SPOJ CHAIN (สืบค้น 10 กันยายน 2026)
  7. ในคลังนี้: BFS บนกราฟสถานะ คือทางที่ DSU มาแทนเมื่อคำถามมีมาก และ ต้นไม้บนแกนเวลา คือบทที่ทำให้ DSU ลบได้