ปูพื้นฐาน

ต้นไม้บนแกนเวลา: เปลี่ยน "เพิ่มแล้วลบ" ให้เหลือแค่ "เพิ่มแล้วย้อนกลับ"

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

บทปูพื้นฐาน ★★★★☆ dsusegment treeofflinedivide and conquerพื้นฐาน อ่าน 32 นาที 10 กันยายน 2026

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

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

ถ้าถนนมีแต่เปิด นี่คือบท DSUตรงตัว เปิดถนนหนึ่งเส้นก็ unite ครั้งเดียว ปัญหาอยู่ที่วันที่ถนนปิด DSU ไม่มีคำสั่งแยกก๊วน และจะแยกก็ไม่รู้ด้วยซ้ำว่าก๊วนขาดจริงไหม เพราะอาจมีทางอ้อมอยู่ ส่วนการนับใหม่ทั้งเมืองทุกครั้ง ถ้าเมืองมี 10⁵ จุด ถนน 10⁵ เส้น และการเปลี่ยนแปลง 10⁵ ครั้ง ก็เป็นราว 2·10¹⁰ ก้าว

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

แกะคำศัพท์

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


ถ้ามีแต่ลบ ย้อนเวลาก็จบ แต่ถ้าปนกันล่ะ

บท DSU มีข้อหนึ่งที่มีแต่การฉีกเส้นใย (Anansi's Cobweb) ข้อนั้นแก้ด้วยการอ่านเหตุการณ์จากท้ายมาหน้า พอกลับทิศ การลบทุกครั้งกลายเป็นการเพิ่ม ซึ่ง DSU ถนัด

ท่านั้นพังทันทีเมื่อมีทั้งเพิ่มและลบปนกัน เพราะกลับทิศแล้วการเพิ่มก็กลายเป็นการลบ ปัญหาเดิมแค่ย้ายไปอยู่อีกฝั่ง ต้องหาทางที่ไม่ต้องลบเลยไม่ว่าจะเดินทิศไหน


สิ่งที่ต้องการจริงคือย้อนกลับ ไม่ใช่ลบ

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

DSU ลบไม่ได้ แต่ย้อนกลับได้ ถ้าแลกกับการยอมทิ้งกลบีบทางเดิน การรวมหนึ่งครั้งแก้ของแค่สองช่อง คือ par[b] ของหัวฝั่งที่ไปขึ้นกับอีกฝั่ง กับ sz[a] ของหัวที่รับไว้ จดตัว b ลงสแต็ก ตอนย้อนก็หยิบ b ออกมา อ่านแม่จาก par[b] คืนขนาด แล้วตั้ง par[b] = b

แกะคำศัพท์

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

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


ทุกเส้นมีช่วงอายุ

เปลี่ยนมุมมองจาก "เหตุการณ์ทีละครั้ง" เป็น "เส้นทีละเส้น" เส้นแต่ละเส้นเกิดตอนถูกเพิ่มและตายตอนถูกลบ ระหว่างนั้นมันมีชีวิตอยู่ต่อเนื่อง เรียกช่วงนี้ว่าช่วงอายุของเส้น (lifetime) บนแกนเวลาที่นับจุดเวลาที่ต้องตอบ เส้นที่ถูกลบตอนเหตุการณ์ที่ i อยู่ถึงจุดเวลา i - 1 ส่วนเส้นที่ไม่ถูกลบเลยอยู่จนจุดสุดท้าย

ตัวอย่างที่ใช้ทั้งบทมี 4 จุด เส้นตั้งต้น 1-2, 1-4, 2-3 แล้วมีสองเหตุการณ์ คือเพิ่ม 1-3 แล้วลบ 1-2 จุดเวลาจึงมี 3 จุด คือก่อนเหตุการณ์แรก หลังเหตุการณ์ที่หนึ่ง และหลังเหตุการณ์ที่สอง

t = 0 1 ก๊วน t = 1 1 ก๊วน t = 2 1 ก๊วน เส้น 1-2 [0, 1] เส้น 1-3 [1, 2] เส้น 1-4 [0, 2] เส้น 2-3 [0, 2]
แต่ละแถบคือช่วงอายุของหนึ่งเส้น จำนวนก๊วนที่จุดเวลา t ขึ้นกับแค่แถบที่ทับจุด t คำตอบของตัวอย่างนี้คือ 1, 1, 1 ก๊วน

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


ลองเอง · หั่นช่วงอายุลงปมของต้นไม้

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

ช่วงอายุยาวแปดจุดเวลา

ช่วงอายุ [8, 15] คลิกปมเพื่อเลือกหรือยกเลิก

เลือกปมที่คลุมช่วงสีทองให้พอดี

ยังไม่ได้เลือกปม

แถวบนสุดคือราก คลุมทั้งแกน แถวล่างสุดคือใบ คลุมจุดเวลาเดียว ปมที่เลือกแล้วเป็นสีเขียว

ลองเทียบชุด ข กับชุด ก ช่วงที่สั้นกว่าไม่ได้แปลว่าใช้ปมน้อยกว่า สิ่งที่ตัดสินคือขอบของช่วงไปตรงกับขอบของปมหรือเปล่า

ถ้ามีกฎที่หั่นช่วงไหนก็ได้ด้วยปมน้อยสุดโดยไม่ต้องลอง มันหน้าตาเป็นยังไง ลองคิดก่อนแล้วค่อยเลื่อนลงไป


หั่นช่วงลงต้นไม้ แบบเดียวกับอัปเดตช่วงของ segment tree

กฎคือการอัปเดตช่วงของต้นไม้ช่วงทุกตัวอักษร เริ่มที่ราก ถ้าช่วงของปมอยู่ในช่วงอายุทั้งปม ก็วางเส้นไว้ที่ปมนั้นแล้วหยุด ถ้าไม่ทับกันเลยก็หยุด ที่เหลือก็ลงไปถามลูกทั้งสอง ในบทนี้ฟังก์ชันนี้ชื่อ addRange ช่วงหนึ่งช่วงถูกวางไม่เกินชั้นละสองปม จึงไม่เกินราว 2 log₂ q ปม

0123456789101112131415 ราก คลุมทั้งแกน
ช่วงอายุ [3, 12] บนแกน 16 จุดเวลา ถูกวางลงปม 4 ปม (สีเขียว) ผมตรวจทุกช่วงบนแกนนี้ตอนสร้างหน้า กฎนี้ได้จำนวนปมน้อยสุดทุกช่วง และช่วงที่แย่ที่สุดใช้ 6 ปม

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


เดินลงต้นไม้ เข้าปมก็ใส่ ออกจากปมก็ย้อน

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

ตัวเดินข้างล่างใช้ตัวอย่างเดิม ต้นไม้บน 3 จุดเวลามี 5 ปม ของบนแต่ละปมคือ ปม 1[0,2]: 1-4, 2-3 · ปม 2[0,1]: 1-2 · ปม 3t=2: 1-3 · ปม 4t=0: ว่าง · ปม 5t=1: 1-3

dfs บนต้นไม้ช่วง พร้อม rollback

DSU · 4 จุด
จุด 1234
par 1234
sz 1111
stk [ ]
ปม ยังไม่เข้า
คำตอบ · · ·

ตรงขั้นที่เส้น 1-3 ถูกใส่ที่ใบ t=1 ให้สังเกตว่ามันไม่ได้รวมอะไรเลย เพราะ 1 กับ 3 ต่อกันผ่าน 1-2 กับ 2-3 อยู่แล้ว แต่เส้นเดียวกันนี้ถูกวางอีกครั้งที่ใบ t=2 และคราวนี้มันรวมจริง เพราะ 1-2 ถูกย้อนออกไปก่อนแล้วตอนออกจากปม [0,1] เส้นเดียวกันทำงานต่างกันในสองปม ตามว่าตอนนั้นมีเส้นอื่นอยู่ด้วยหรือเปล่า


ทำไมบีบทางเดินไม่ได้ ทั้งที่มันคือกลที่ดีที่สุดของ DSU

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

ตัวอย่างของบทนี้เล็กพอที่จะเห็นเรื่องนี้ครบ มันคืออินพุตที่เล็กที่สุดที่ทำให้รุ่นบีบทางเดินตอบผิด ซึ่งหาได้จากการไล่ทุกกรณีเรียงตามจำนวนเหตุการณ์ แล้วจำนวนจุด แล้วจำนวนเส้นตั้งต้น ในรุ่นที่บีบ ตอนตอบใบ t=1 การเรียก find(3) บีบ par[3] ให้ชี้ข้ามไปที่หัวทันที แล้วไม่มีอะไรลงสแต็ก พอออกจากปม [0,1] และถอด 2 ออก 3 ก็ยังชี้ค้างไปที่ 1 อยู่ ใบ t=2 จึงเห็น 1 กับ 3 อยู่ก๊วนเดียวกันแล้วไม่รวม

คำตอบรายจุดเวลา · ตัวอย่างของบท
รุ่น t = 0t = 1t = 2
เดินกราฟใหม่ทุกจุดเวลา 111
ต้นไม้ + rollback 111
ต้นไม้ + rollback + บีบทางเดิน 112
สามแถวนี้มาจากตัวจำลองที่รันตอนสร้างหน้า ถ้ารุ่นบีบทางเดินกลับมาตอบถูก หน้านี้จะบิลด์ไม่ผ่าน เพราะนั่นแปลว่าย่อหน้าข้างบนไม่จริงอีกต่อไป

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

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

ดูรุ่นที่บีบทางเดิน กับอินพุตที่ทำให้มันผิด
dynamic-connectivity-pathcomp.cpp
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100005, MAXQ = 100005;   // จุดเวลามี k+1 จุด
int n, m, k, Q, comps;
int par[MAXN], sz[MAXN];
vector<int> stk;                  // ลูกที่ถูกย้ายไปขึ้นกับแม่ เรียงตามลำดับที่รวม
vector<pair<int,int>> node[4 * MAXQ];
int ans[MAXQ];

// เวอร์ชันที่ผิด: บีบทางเดินเหมือน DSU ธรรมดา
// ช่อง par ที่ถูกบีบไม่มีใครจดลงสแต็ก rollback จึงคืนไม่ครบ
int find(int x) {
    if (par[x] == x) return x;
    return par[x] = find(par[x]);   // บรรทัดที่ผิด
}

// รวมตามขนาดอย่างเดียว ความลึกจึงไม่เกิน log2(n) และ find เป็น O(log n) จริงทุกครั้ง
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];
    stk.push_back(b);
    comps--;
    return true;
}

// การรวมหนึ่งครั้งแก้แค่ par[b] กับ sz[a] และ a ยังเป็น par[b] อยู่ จึงถอดคืนได้จากลูกตัวเดียว
void rollback(int keep) {
    while ((int)stk.size() > keep) {
        int b = stk.back(); stk.pop_back();
        sz[par[b]] -= sz[b];
        par[b] = b;
        comps++;
    }
}

// แตกช่วงอายุ [ql, qr] ลงปมที่คลุมพอดี แบบเดียวกับการอัปเดตช่วงของ segment tree
void addRange(int v, int l, int r, int ql, int qr, pair<int,int> item) {
    if (qr < l || r < ql) return;
    if (ql <= l && r <= qr) { node[v].push_back(item); return; }
    int mid = (l + r) / 2;
    addRange(2 * v, l, mid, ql, qr, item);
    addRange(2 * v + 1, mid + 1, r, ql, qr, item);
}

// เส้นบนปม v มีชีวิตตลอดช่วง [l, r] จึงใส่ครั้งเดียวแล้วใช้ได้กับทุกใบข้างใต้
void dfs(int v, int l, int r) {
    int keep = stk.size();
    for (auto [a, b] : node[v]) unite(a, b);
    if (l == r) ans[l] = comps;
    else {
        int mid = (l + r) / 2;
        dfs(2 * v, l, mid);
        dfs(2 * v + 1, mid + 1, r);
    }
    rollback(keep);
}

int main() {
    ios::sync_with_stdio(false); cin.tie(nullptr);
    cin >> n >> m >> k;
    Q = k + 1;                    // จุดเวลา 0 คือก่อนเหตุการณ์แรก จุด i คือหลังเหตุการณ์ที่ i
    map<pair<int,int>, int> born; // เส้นที่ยังอยู่ -> จุดเวลาที่เกิด
    for (int i = 0; i < m; i++) {
        int a, b; cin >> a >> b;
        born[{min(a, b), max(a, b)}] = 0;
    }
    for (int i = 1; i <= k; i++) {
        int t, a, b; cin >> t >> a >> b;
        pair<int,int> e = {min(a, b), max(a, b)};   // ลบอาจเขียนสลับข้างกับตอนเพิ่ม
        if (t == 1) born[e] = i;
        else {
            addRange(1, 0, Q - 1, born[e], i - 1, e); // ถูกลบที่ i จึงอยู่ถึง i-1
            born.erase(e);
        }
    }
    for (auto [e, s] : born) addRange(1, 0, Q - 1, s, Q - 1, e);

    for (int i = 1; i <= n; i++) par[i] = i, sz[i] = 1;
    comps = n;
    dfs(1, 0, Q - 1);
    for (int i = 0; i < Q; i++) cout << ans[i] << (i + 1 < Q ? ' ' : '\n');
    return 0;
}

ต่างจากแม่แบบข้างล่างแค่ฟังก์ชัน find รุ่นนี้ผ่านตัวอย่างของโจทย์ CSES 2133 (ตอบ 2 2 2 1 ถูก) แต่ตอบอินพุตของบทนี้เป็น 1 1 2 แทน 1 1 1 การไล่ทุกกรณีต้องลองไป 11,524 อินพุตก่อนเจอตัวนี้ ส่วนอินพุตที่มีเหตุการณ์เดียวไม่มีตัวไหนผิดเลยเมื่อจุดไม่เกินหกจุด (ไล่ครบ 502,135 อินพุต)


แม่แบบ กับต้นทุนของมัน

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

dynamic-connectivity.cpp
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100005, MAXQ = 100005;   // จุดเวลามี k+1 จุด
int n, m, k, Q, comps;
int par[MAXN], sz[MAXN];
vector<int> stk;                  // ลูกที่ถูกย้ายไปขึ้นกับแม่ เรียงตามลำดับที่รวม
vector<pair<int,int>> node[4 * MAXQ];
int ans[MAXQ];

// ไม่บีบทางเดิน เพราะการบีบแก้ par หลายช่องที่สแต็กไม่ได้จดไว้ ย้อนกลับไม่ได้
int find(int x) {
    while (par[x] != x) x = par[x];
    return x;
}

// รวมตามขนาดอย่างเดียว ความลึกจึงไม่เกิน log2(n) และ find เป็น O(log n) จริงทุกครั้ง
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];
    stk.push_back(b);
    comps--;
    return true;
}

// การรวมหนึ่งครั้งแก้แค่ par[b] กับ sz[a] และ a ยังเป็น par[b] อยู่ จึงถอดคืนได้จากลูกตัวเดียว
void rollback(int keep) {
    while ((int)stk.size() > keep) {
        int b = stk.back(); stk.pop_back();
        sz[par[b]] -= sz[b];
        par[b] = b;
        comps++;
    }
}

// แตกช่วงอายุ [ql, qr] ลงปมที่คลุมพอดี แบบเดียวกับการอัปเดตช่วงของ segment tree
void addRange(int v, int l, int r, int ql, int qr, pair<int,int> item) {
    if (qr < l || r < ql) return;
    if (ql <= l && r <= qr) { node[v].push_back(item); return; }
    int mid = (l + r) / 2;
    addRange(2 * v, l, mid, ql, qr, item);
    addRange(2 * v + 1, mid + 1, r, ql, qr, item);
}

// เส้นบนปม v มีชีวิตตลอดช่วง [l, r] จึงใส่ครั้งเดียวแล้วใช้ได้กับทุกใบข้างใต้
void dfs(int v, int l, int r) {
    int keep = stk.size();
    for (auto [a, b] : node[v]) unite(a, b);
    if (l == r) ans[l] = comps;
    else {
        int mid = (l + r) / 2;
        dfs(2 * v, l, mid);
        dfs(2 * v + 1, mid + 1, r);
    }
    rollback(keep);
}

int main() {
    ios::sync_with_stdio(false); cin.tie(nullptr);
    cin >> n >> m >> k;
    Q = k + 1;                    // จุดเวลา 0 คือก่อนเหตุการณ์แรก จุด i คือหลังเหตุการณ์ที่ i
    map<pair<int,int>, int> born; // เส้นที่ยังอยู่ -> จุดเวลาที่เกิด
    for (int i = 0; i < m; i++) {
        int a, b; cin >> a >> b;
        born[{min(a, b), max(a, b)}] = 0;
    }
    for (int i = 1; i <= k; i++) {
        int t, a, b; cin >> t >> a >> b;
        pair<int,int> e = {min(a, b), max(a, b)};   // ลบอาจเขียนสลับข้างกับตอนเพิ่ม
        if (t == 1) born[e] = i;
        else {
            addRange(1, 0, Q - 1, born[e], i - 1, e); // ถูกลบที่ i จึงอยู่ถึง i-1
            born.erase(e);
        }
    }
    for (auto [e, s] : born) addRange(1, 0, Q - 1, s, Q - 1, e);

    for (int i = 1; i <= n; i++) par[i] = i, sz[i] = 1;
    comps = n;
    dfs(1, 0, Q - 1);
    for (int i = 0; i < Q; i++) cout << ans[i] << (i + 1 < Q ? ' ' : '\n');
    return 0;
}

นับต้นทุนจากข้างในออกมา find หนึ่งครั้งเดินไม่เกิน log₂ n ชั้น เส้นแต่ละเส้นถูกวางไม่เกินราว 2 log₂ q ปม และทุกครั้งที่วางก็ถูก unite หนึ่งครั้งตอนเข้าปมนั้น รวมเป็น O((m + k) · log q · log n) ถ้าเขียนแบบทั่วไป โครงสร้างที่เพิ่มของได้ในเวลา T(n) และย้อนได้ จะรองรับการลบแบบออฟไลน์ได้ในเวลา O(T(n) · log q) ต่อหนึ่งช่วงอายุ ซึ่งคือชื่อของหน้าต้นฉบับ

สิ่งที่แม่แบบนี้ไม่สนเลย

โครงต้นไม้บนแกนเวลาไม่รู้ว่าข้างในเป็น DSU มันต้องการแค่สองอย่าง คือใส่ของได้ และย้อนการใส่ล่าสุดได้ โจทย์ฝึกครึ่งหลังของบทนี้เปลี่ยนของข้างในเป็นอย่างอื่นทั้งหมด ทั้งกระเป๋าเป้ (knapsack) ฐานของ xor และ bitset และบางข้อใช้แค่ครึ่งเดียวของท่านี้


โจทย์ฝึก · หนึ่งท่า สิบหน้าตา

สิบข้อนี้เรียงเป็นสามกลุ่ม ข้อ 1 ถึง 5 ใช้ท่าเต็มกับ DSU แต่ละข้อเปลี่ยนสิ่งที่ DSU ต้องจำ หรือเปลี่ยนวิธีได้ช่วงอายุมา ข้อ 6 กับ 7 เปลี่ยนของข้างในเป็นอย่างอื่นที่ไม่ใช่ DSU เลย ข้อ 8 กับ 9 ใช้แค่ครึ่งหลังของท่า คือย้อนกลับโดยไม่มีต้นไม้ช่วง และข้อ 10 รวมทุกอย่างเข้าด้วยกัน สามข้อในนี้มาจากหน้าต้นฉบับของ cp-algorithms ส่วนที่เหลือผมเลือกเพิ่มให้ครบมุม

ฝึกข้อ 1 · นับก๊วนเมื่อถนนเปิดปิดได้ (CSES 2133 Dynamic Connectivity)

กราฟมี n จุด เส้นตั้งต้น m เส้น แล้วมีเหตุการณ์ k ครั้ง แต่ละครั้งสร้างเส้นใหม่หรือตัดเส้นเดิมทิ้ง ให้บอกจำนวนก๊วนก่อนเหตุการณ์แรก และหลังเหตุการณ์ทุกครั้ง

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

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

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

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

ช่วงอายุของตัวอย่าง · จุดเวลา 0 ถึง 3
เส้นเกิดอยู่ถึง
3-501
1-233
1-403
2-303
2-513
เส้น 3-5 ถูกตัดที่เหตุการณ์ที่ 2 จึงอยู่ถึงจุดเวลา 1 ส่วนเส้นที่เกิดจากเหตุการณ์ที่ i เริ่มมีชีวิตที่จุดเวลา i

ใบ้

ข้อนี้คือโจทย์ของหัวข้อข้างบนทุกประการ ที่ต้องระวังมีเรื่องเดียว คือเส้นถูกระบุด้วยคู่จุด โจทย์ไม่ได้สัญญาว่าตอนตัดจะเขียนคู่จุดเรียงเหมือนตอนสร้าง จะจับคู่ "สร้าง" กับ "ตัด" ของเส้นเดียวกันยังไง

เฉลยฝึกข้อ 1

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

ผมรู้จักท่านี้อยู่แล้วก่อนเปิดโจทย์ สิ่งที่ทำจริงคือเช็กว่าตัวเลขบังคับให้ใช้มันหรือเปล่า นับใหม่ทุกจุดเวลาคือ 10⁵ ครั้ง ครั้งละ 2·10⁵ ได้ 2·10¹⁰ ส่วนย้อนเวลาอย่างเดียวใช้ไม่ได้ เพราะมีทั้งสร้างและตัดปนกัน ที่เหลือคือเงื่อนไขที่ทำให้ท่านี้ใช้ได้ คือโจทย์ให้ input ทั้งหมดก่อนต้องตอบ

ประมาณต้นทุนแบบหยาบที่สุด เส้นไม่เกิน 2·10⁵ ช่วง ช่วงละไม่เกินราว 34 ปม ปมละ find สองครั้ง ครั้งละไม่เกิน 17 ชั้น ได้ราว 2·10⁸ ครั้งของการเดินขึ้นหนึ่งชั้นในกรณีแย่สุด ซึ่งพอสำหรับหนึ่งวินาที วัดจริงช้าสุด 0.2 วินาที บทเรียนคือ ท่าที่มีสอง log คูณกันดูน่ากลัวบนกระดาษ ให้คูณตัวเลขจริงก่อนตัดสิน

เก็บ map จากคู่ (min, max) ไปยังจุดเวลาที่เส้นนั้นเกิด เจอการตัดก็ดึงออกมาปิดช่วงแล้ววางลงต้นไม้ อ่านครบแล้ว เส้นที่ยังเหลือใน map คือเส้นที่อยู่ถึงจุดสุดท้าย คู่จุดต้องเรียงเองทุกครั้ง เพราะถ้าตอนสร้างเขียน 2 1 แต่ตอนตัดเขียน 1 2 แล้วเราใช้คู่ตามที่อ่านมาเป็นกุญแจ จะหาเส้นไม่เจอ แล้วเส้นนั้นจะถูกนับว่าไม่เคยตาย

โค้ดฝึกข้อ 1

โค้ดของข้อนี้คือแม่แบบในหัวข้อ "แม่แบบ กับต้นทุนของมัน" ข้างบนทุกบรรทัด

ตรวจกับตัวที่เดินกราฟใหม่ทุกจุดเวลา 20,000 รอบ สุ่มสลับข้างของคู่จุดทั้งตอนสร้างและตอนตัด ตรงกันทุกรอบ เคสใหญ่สามแบบที่ n, m, k = 10⁵ ใช้ไม่เกิน 0.2 วินาที

ฝึกข้อ 2 · ตอบเฉพาะตอนถูกถาม (Codeforces Gym 100551A Connect and Disconnect)

กราฟเริ่มว่าง มี N จุด แล้วมีคำสั่ง K ข้อ + u v เพิ่มเส้น - u v ลบเส้น และ ? ถามจำนวนก๊วน ณ ตอนนั้น โจทย์นี้คือตัวอย่างหลักในหน้าต้นฉบับของ cp-algorithms

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

EXAMPLE
InputOutput
5 11
?
+ 1 2
+ 2 3
+ 3 4
+ 4 5
+ 5 1
?
- 2 3
?
- 4 5
?
5
1
1
2

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

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

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

คำสั่ง 11 ข้อ มีคำถาม 4 ข้อ
คำสั่งคำถามที่ก๊วน
?05
+ 1 2
+ 2 3
+ 3 4
+ 4 5
+ 5 1
?11
- 2 3
?21
- 4 5
?32
ห้าเส้นแรกต่อกันเป็นวง ลบ 2-3 ออกหนึ่งเส้นวงยังไม่ขาด ลบ 4-5 อีกเส้นถึงขาดเป็นสองก๊วน

ใบ้

ต่างจากข้อ 1 ตรงที่ไม่ต้องตอบทุกจุดเวลา ตอบเฉพาะตอนเจอ ? ถ้าให้ใบของต้นไม้เป็นทุกคำสั่ง ต้นไม้ยาว K เสมอ แม้ทั้งไฟล์จะมีคำถามข้อเดียว แกนเวลาควรนับอะไร

เฉลยฝึกข้อ 2

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

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

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

ให้แกนเวลาเป็น "คำถามลำดับที่ 0, 1, 2, ..." ตัวนับ Q คือจำนวน ? ที่อ่านมาแล้ว เส้นที่เกิดตอน Q = c เห็นได้ตั้งแต่คำถามที่ c และเส้นที่ตายตอน Q = d เห็นได้ถึงคำถามที่ d - 1 ของที่ได้ตามมาเองคือ เส้นที่เกิดแล้วตายโดยไม่มีคำถามคั่นจะได้ช่วง [c, c - 1] ซึ่งว่าง ข้ามได้เลย

ต้องระวังกรณีที่ไม่มี ? เลย ถ้าเรียก dfs(1, 0, -1) การแบ่งครึ่ง (0 + (-1)) / 2 ได้ 0 แล้วโค้ดจะเรียกตัวเองไม่รู้จบ จึงหยุดตั้งแต่ก่อนสร้างต้นไม้

โค้ดฝึกข้อ 2

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

const int MAXN = 300005, MAXQ = 300005;   // จุดเวลา = คำถาม ? ซึ่งมีไม่เกิน K
int n, K, Q, comps;
int par[MAXN], sz[MAXN];
vector<int> stk;                  // ลูกที่ถูกย้ายไปขึ้นกับแม่ เรียงตามลำดับที่รวม
vector<pair<int,int>> node[4 * MAXQ];
int ans[MAXQ];

// ไม่บีบทางเดิน เพราะการบีบแก้ par หลายช่องที่สแต็กไม่ได้จดไว้ ย้อนกลับไม่ได้
int find(int x) {
    while (par[x] != x) x = par[x];
    return 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];
    stk.push_back(b);
    comps--;
    return true;
}

void rollback(int keep) {
    while ((int)stk.size() > keep) {
        int b = stk.back(); stk.pop_back();
        sz[par[b]] -= sz[b];
        par[b] = b;
        comps++;
    }
}

void addRange(int v, int l, int r, int ql, int qr, pair<int,int> item) {
    if (qr < l || r < ql) return;
    if (ql <= l && r <= qr) { node[v].push_back(item); return; }
    int mid = (l + r) / 2;
    addRange(2 * v, l, mid, ql, qr, item);
    addRange(2 * v + 1, mid + 1, r, ql, qr, item);
}

void dfs(int v, int l, int r) {
    int keep = stk.size();
    for (auto [a, b] : node[v]) unite(a, b);
    if (l == r) ans[l] = comps;
    else {
        int mid = (l + r) / 2;
        dfs(2 * v, l, mid);
        dfs(2 * v + 1, mid + 1, r);
    }
    rollback(keep);
}

int main() {
    freopen("connect.in", "r", stdin);
    freopen("connect.out", "w", stdout);
    ios::sync_with_stdio(false); cin.tie(nullptr);
    cin >> n >> K;
    // แกนเวลานับเฉพาะคำถาม ? เพราะเป็นจุดเดียวที่ต้องรู้คำตอบ
    // เส้นที่เกิดตอนถามไปแล้ว c ข้อ จะเห็นได้ตั้งแต่คำถามที่ c
    vector<tuple<int,int,pair<int,int>>> lives;   // (คำถามแรก, คำถามสุดท้าย, เส้น)
    map<pair<int,int>, int> born;                 // เส้นที่ยังอยู่ -> คำถามแรกที่เห็นมัน
    Q = 0;
    for (int i = 0; i < K; i++) {
        char c; cin >> c;
        if (c == '?') { Q++; continue; }
        int u, v; cin >> u >> v;
        pair<int,int> e = {min(u, v), max(u, v)};   // ไม่มีทิศ: "- 2 1" คือเส้นเดียวกับ "+ 1 2"
        if (c == '+') born[e] = Q;
        else {
            lives.push_back({born[e], Q - 1, e});    // ช่วงว่างได้ ถ้าไม่มีคำถามคั่นระหว่างเกิดกับตาย
            born.erase(e);
        }
    }
    if (Q == 0) return 0;
    for (auto [e, s] : born) lives.push_back({s, Q - 1, e});
    for (auto [l, r, e] : lives) if (l <= r) addRange(1, 0, Q - 1, l, r, e);

    for (int i = 1; i <= n; i++) par[i] = i, sz[i] = 1;
    comps = n;
    dfs(1, 0, Q - 1);
    for (int i = 0; i < Q; i++) cout << ans[i] << '\n';
    return 0;
}

ตรวจกับตัวที่เดินกราฟใหม่ทุกคำถาม 30,000 รอบ สุ่มจำนวนคำสั่งตั้งแต่ศูนย์ และสุ่มกราฟจุดเดียวที่มีได้แค่คำถาม ตรงกันทุกรอบ ผมลองถอดการเรียงคู่จุดออกโดยตั้งใจเพื่อดูว่าตัวตรวจจับได้ไหม รุ่นนั้นยังผ่านตัวอย่างของโจทย์ เพราะตัวอย่างลบด้วยคู่ที่เรียงเหมือนตอนเพิ่มทุกครั้ง แต่อินพุต 2 3 / + 2 1 / - 1 2 / ? ทำให้มันตอบ 1 แทน 2

ฝึกข้อ 3 · ยังระบายสองสีได้ไหม (Codeforces 813F Bipartite Checking)

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

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

EXAMPLE
InputOutput
3 5
2 3
1 3
1 2
1 2
1 2
YES
YES
NO
YES
NO

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

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

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

หลังคำสั่ง 1 · YES 1 2 3 สลับ 2-3 หลังคำสั่ง 2 · YES 1 2 3 สลับ 1-3 หลังคำสั่ง 3 · NO 1 2 3 สลับ 1-2 หลังคำสั่ง 4 · YES 1 2 3 สลับ 1-2 หลังคำสั่ง 5 · NO 1 2 3 สลับ 1-2
กราฟหลังแต่ละคำสั่ง คำสั่งที่ 3 ปิดสามเหลี่ยม ซึ่งเป็นวงยาวคี่ ระบายสองสีไม่ได้ คำสั่งที่ 4 เป็นคู่เดิมจึงลบเส้นนั้นออก และคำสั่งที่ 5 ใส่กลับเข้ามา

ใบ้

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

เฉลยฝึกข้อ 3

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

ระบายสองสีใหม่ทุกคำสั่งคือ 10⁵ ครั้ง ครั้งละราว 2·10⁵ ต่อให้เวลาหกวินาทีก็ไม่พอ โครงต้นไม้บนแกนเวลาไม่ต้องแก้เลย เปลี่ยนแค่ของที่ DSU ต้องจำ ซึ่งคือท่าเดียวกับข้อ Parity ของบท DSU

ระหว่างเขียนมีบรรทัดเดียวที่ผมเอาออก รุ่นแรกของ rollback ตั้ง d[b] = 0 คืนด้วย ทั้งที่ find หยุดที่หัวโดยไม่อ่าน d ของหัว และ unite เขียน d ทับทุกครั้งที่หัวถูกย้าย บรรทัดนั้นจึงไม่มีผลกับอะไรเลย ลบแล้วรันตัวตรวจซ้ำก็ผ่านเหมือนเดิม บทเรียนคือ ของที่ไม่มีใครอ่านอีก ไม่ต้องจดและไม่ต้องคืน

d[x] บอกว่า x ต่างสีกับแม่หรือเปล่า สีของ x เทียบหัวคือ xor ของ d ตลอดทางขึ้น ตอนรวมสองหัว ตั้ง d ของหัวที่ไปขึ้นกับอีกฝั่งให้ปลายทั้งสองของเส้นต่างสีกัน คือ ca xor cb xor 1 ข้อนี้ unite คืน false เมื่อเส้นปิดวงคี่ ไม่ใช่เมื่อไม่ได้รวม เพราะสิ่งที่ DFS ต้องรู้คือกราฟยังระบายสองสีได้ไหม

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

โค้ดฝึกข้อ 3

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

const int MAXN = 100005, MAXQ = 100005;
int n, q;
int par[MAXN], sz[MAXN], d[MAXN];   // d[x] = x กับ par[x] ต่างสีกันไหม (0 สีเดียวกัน, 1 ต่างสี)
vector<int> stk;                     // ลูกที่ถูกย้ายไปขึ้นกับแม่ เรียงตามลำดับที่รวม
vector<pair<int,int>> node[4 * MAXQ];
bool ans[MAXQ];                      // ใบที่ DFS ไปไม่ถึงคือใบที่มีวงคี่แล้ว ค่าเริ่มต้น false จึงถูกอยู่แล้ว

// ไม่บีบทางเดิน คืนราก และสีของ x เทียบกับราก (xor ของ d ตลอดทาง)
pair<int,int> find(int x) {
    int c = 0;
    while (par[x] != x) c ^= d[x], x = par[x];
    return {x, c};
}

// คืน false เมื่อเส้น a-b ปิดวงคี่ (a กับ b อยู่ก๊วนเดียวกันและสีเดียวกันอยู่แล้ว)
bool unite(int a, int b) {
    auto [ra, ca] = find(a);
    auto [rb, cb] = find(b);
    if (ra == rb) return ca != cb;
    if (sz[ra] < sz[rb]) swap(ra, rb);
    par[rb] = ra;
    d[rb] = ca ^ cb ^ 1;   // ให้ a กับ b ต่างสีกัน: ca ^ (d[rb] ^ cb) = 1
    sz[ra] += sz[rb];
    stk.push_back(rb);
    return true;
}

void rollback(int keep) {
    while ((int)stk.size() > keep) {
        int b = stk.back(); stk.pop_back();
        sz[par[b]] -= sz[b];
        par[b] = b;   // d[b] ไม่ต้องคืน: รากไม่มีใครอ่าน d และจะถูกเขียนทับเมื่อ b ถูกรวมอีกครั้ง
    }
}

void addRange(int v, int l, int r, int ql, int qr, pair<int,int> item) {
    if (qr < l || r < ql) return;
    if (ql <= l && r <= qr) { node[v].push_back(item); return; }
    int mid = (l + r) / 2;
    addRange(2 * v, l, mid, ql, qr, item);
    addRange(2 * v + 1, mid + 1, r, ql, qr, item);
}

void dfs(int v, int l, int r) {
    int keep = stk.size();
    bool odd = false;
    for (auto [a, b] : node[v]) if (!unite(a, b)) { odd = true; break; }
    // เส้นบนปมนี้อยู่ครบทุกใบข้างใต้ วงคี่จึงอยู่ในทุกใบ ไม่ต้องลงไปต่อ
    if (!odd) {
        if (l == r) ans[l] = true;
        else {
            int mid = (l + r) / 2;
            dfs(2 * v, l, mid);
            dfs(2 * v + 1, mid + 1, r);
        }
    }
    rollback(keep);
}

int main() {
    ios::sync_with_stdio(false); cin.tie(nullptr);
    cin >> n >> q;
    map<pair<int,int>, int> born;    // เส้นที่ยังอยู่ -> คำถามแรกที่มีมัน
    for (int i = 0; i < q; i++) {
        int x, y; cin >> x >> y;     // โจทย์รับประกัน x < y จึงใช้เป็นกุญแจได้ตรง ๆ
        auto it = born.find({x, y});
        if (it == born.end()) born[{x, y}] = i;
        else {
            addRange(1, 0, q - 1, it->second, i - 1, it->first);   // ถูกลบที่คำถาม i
            born.erase(it);
        }
    }
    for (auto [e, s] : born) addRange(1, 0, q - 1, s, q - 1, e);

    for (int i = 1; i <= n; i++) par[i] = i, sz[i] = 1;
    dfs(1, 0, q - 1);
    for (int i = 0; i < q; i++) cout << (ans[i] ? "YES" : "NO") << '\n';
    return 0;
}

ตรวจกับตัวที่ระบายสองสีด้วย BFS ใหม่ทุกคำสั่ง 30,000 รอบ ในนั้นมีคำตอบ NO 102,375 บรรทัด ตรงกันทุกบรรทัด เคสใหญ่สุดใช้ 0.13 วินาที ผมลองลืม xor 1 โดยตั้งใจ รุ่นนั้นยังผ่านตัวอย่างของโจทย์ แต่อินพุต 4 4 / 3 4 / 2 4 / 1 3 / 1 2 ทำให้มันตอบ NO ที่คำสั่งสุดท้าย ทั้งที่กราฟเป็นวงยาวสี่ ซึ่งระบายสองสีได้

ฝึกข้อ 4 · มุมที่สี่งอกเอง (Codeforces 1140F Extending Set of Points)

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

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

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

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

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

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

หลังคำถาม 6 · ขนาด 6 y=1y=2y=3x=1x=2 หลังคำถาม 7 · ขนาด 3 y=1y=2y=3x=1x=2
วงทึบคือจุดในเซต วงโปร่งคือจุดที่งอกขึ้นมาเอง หลังคำถามที่ 6 เซตมีสี่จุด ส่วนขยายมี 6 จุด พอคำถามที่ 7 เอาจุด (2, 1) ออก สองแถวก็ขาดจากกัน เหลือ 3

ใบ้

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

เฉลยฝึกข้อ 4

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

สามจุด (x1,y1) (x1,y2) (x2,y1) คือทางเดิน x2 → y1 → x1 → y2 ในกราฟแถวกับหลัก และจุดที่งอกคือเส้นลัด x2 → y2 พอเห็นเป็นทางเดิน ก็เดาต่อได้ว่าก๊วนที่มี a แถวกับ b หลัก จะงอกจนเต็มเป็น a × b จุดพอดี คำตอบคือผลรวมของ a × b ทุกก๊วน

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

ถ้ามีแต่ใส่จุด นี่คือ DSU ธรรมดาที่หัวจำ cx (จำนวนแถว) กับ cy (จำนวนหลัก) และเก็บผลรวมไว้ในตัวแปรเดียว ตอนรวม ลบผลคูณของสองก้อนเก่าออก บวกผลคูณของก้อนใหม่เข้า การเอาจุดออกคือส่วนที่ต้องใช้ท่าของบทนี้ จุดแต่ละจุดเข้าออกได้หลายรอบ แต่ละรอบเป็นช่วงอายุแยกกัน

จุดเดียวที่ต่างจากแม่แบบคือ rollback ต้องคืนผลรวมด้วย ลำดับที่ถูกคือลบผลคูณของก้อนรวมออกก่อน แล้วค่อยหัก cx, cy ของลูกออกจากแม่ แล้วบวกผลคูณของทั้งสองก้อนกลับเข้าไป ถ้าสลับลำดับ จะลบผลคูณของก้อนที่ถูกหักไปแล้วแทนก้อนรวม

โค้ดฝึกข้อ 4

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

// ปม 1..C คือ "แถว x" ปม C+1..2C คือ "หลัก y" จุด (x, y) คือเส้นเชื่อมแถว x กับหลัก y
const int C = 300000;
vector<int> par, sz, cx, cy;   // cx, cy = จำนวนแถว / หลักในก๊วนที่ปมนี้เป็นหัว
vector<int> stk;               // ลูกที่ถูกย้ายไปขึ้นกับแม่ เรียงตามลำดับที่รวม
ll ans;                        // ผลรวม cx*cy ของทุกก๊วน = ขนาดของ E(S)

int find(int x) {              // ไม่บีบทางเดิน เพราะต้อง rollback ได้
    while (par[x] != x) x = par[x];
    return x;
}

void unite(int a, int b) {
    a = find(a), b = find(b);
    if (a == b) return;
    if (sz[a] < sz[b]) swap(a, b);
    ans -= (ll)cx[a] * cy[a] + (ll)cx[b] * cy[b];
    par[b] = a, sz[a] += sz[b], cx[a] += cx[b], cy[a] += cy[b];
    ans += (ll)cx[a] * cy[a];
    stk.push_back(b);
}

void rollback(int keep) {
    while ((int)stk.size() > keep) {
        int b = stk.back(), a = par[b];
        stk.pop_back();
        // แยกก๊วนกลับเป็นสองก้อน แล้วคิดผลคูณของทั้งสองก้อนใหม่
        ans -= (ll)cx[a] * cy[a];
        sz[a] -= sz[b], cx[a] -= cx[b], cy[a] -= cy[b];
        par[b] = b;
        ans += (ll)cx[a] * cy[a] + (ll)cx[b] * cy[b];
    }
}

int Q;
vector<vector<pair<int,int>>> node;   // node[v] = จุดที่มีชีวิตตลอดช่วงของปม v

void addRange(int v, int l, int r, int ql, int qr, pair<int,int> item) {
    if (qr < l || r < ql) return;
    if (ql <= l && r <= qr) { node[v].push_back(item); return; }
    int m = (l + r) / 2;
    addRange(2 * v, l, m, ql, qr, item);
    addRange(2 * v + 1, m + 1, r, ql, qr, item);
}

vector<ll> res;

void dfs(int v, int l, int r) {
    int keep = stk.size();
    for (auto [x, y] : node[v]) unite(x, C + y);
    if (l == r) res[l] = ans;
    else {
        int m = (l + r) / 2;
        dfs(2 * v, l, m);
        dfs(2 * v + 1, m + 1, r);
    }
    rollback(keep);
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    cin >> Q;
    par.resize(2 * C + 1), sz.assign(2 * C + 1, 1);
    cx.assign(2 * C + 1, 0), cy.assign(2 * C + 1, 0);
    for (int i = 1; i <= 2 * C; i++) par[i] = i;
    for (int i = 1; i <= C; i++) cx[i] = 1, cy[C + i] = 1;
    stk.clear(), ans = 0;
    node.assign(4 * Q, {}), res.assign(Q, 0);

    map<pair<int,int>, int> since;   // จุดที่อยู่ใน S ตอนนี้ -> คำถามที่มันถูกเติมเข้ามา
    for (int i = 0; i < Q; i++) {
        int x, y;
        cin >> x >> y;
        auto it = since.find({x, y});
        if (it == since.end()) since[{x, y}] = i;
        else {
            addRange(1, 0, Q - 1, it->second, i - 1, {x, y});
            since.erase(it);
        }
    }
    for (auto [p, t] : since) addRange(1, 0, Q - 1, t, Q - 1, p);

    dfs(1, 0, Q - 1);
    for (int i = 0; i < Q; i++) cout << res[i] << (i + 1 < Q ? ' ' : '\n');
    return 0;
}

ตรวจกับตัวที่เติมจุดตามนิยามจนนิ่ง 5,000 รอบบนพิกัดไม่เกินห้า ตรงกันทุกรอบ ซึ่งยืนยันข้อสรุปเรื่อง a × b ไปด้วย เคสใหญ่สุดใช้ 0.51 วินาที จากเวลาที่ให้ 3.5 วินาที

ฝึกข้อ 5 · ไวรัสที่มีวันหมดอายุ (Codeforces 1423H Virus)

เมืองหนึ่งมี n คน ทุกวันมีข่าวว่าใครพบใคร ไวรัสมีระยะฟักตัว k วัน ใครที่สัมผัสกันทางตรงหรือทางอ้อมภายใน k วันล่าสุด นับเป็นกลุ่มเสี่ยงเดียวกัน คำสั่ง 1 x y คือวันนี้ x พบ y 2 z ถามว่าตอนนี้ z อยู่กลุ่มเดียวกับกี่คน และ 3 คือขึ้นวันใหม่

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

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

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

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

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

การพบกันในตัวอย่าง · k = 1
พบกันวันที่คำสั่งที่นับอยู่ถึงคำสั่งที่
1-2005
1-3015
3-4025
1-21710
3-21810
ตัวอย่างนี้ k = 1 การพบกันจึงนับอยู่แค่วันเดียว หมดอายุตอนเริ่มวันถัดไป คำถาม 2 1 หลังขึ้นวันใหม่จึงได้ 1 เพราะทุกคนที่ 1 เคยพบหมดอายุไปแล้ว

ใบ้

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

เฉลยฝึกข้อ 5

ส่วนที่ยากที่สุดคือการอ่านว่านับวันยังไง

ตัวโจทย์ไม่ได้บอกตรง ๆ ว่าการพบวันนี้นับอยู่ถึงวันไหน มีแค่โน้ตหนึ่งบรรทัดกับตัวอย่างสามชุด ผมตั้งสมมติฐานว่า การพบในวันที่ d นับอยู่ตลอดวัน d ถึง d + k - 1 แล้วเช็กกับตัวอย่าง ผ่านทั้งสามชุด

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

ระหว่างอ่าน จด dayStart[d] คือคำสั่งแรกของวันที่ d ไว้ การพบในวันที่ d ที่คำสั่งที่ i จึงมีช่วงอายุ [i, dayStart[d + k] - 1] ถ้าวันที่ d + k ไม่มีจริงก็ยาวถึงคำสั่งสุดท้าย ที่เหลือคือแม่แบบทุกบรรทัด ถึงใบที่เป็นคำถามก็ตอบ sz[find(z)]

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

โค้ดฝึกข้อ 5

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

vector<int> par, sz;
vector<int> stk;   // ลูกที่ถูกย้ายไปขึ้นกับแม่ เรียงตามลำดับที่รวม

int find(int x) {  // ไม่บีบทางเดิน เพราะต้อง rollback ได้
    while (par[x] != x) x = par[x];
    return x;
}

void unite(int a, int b) {
    a = find(a), b = find(b);
    if (a == b) return;
    if (sz[a] < sz[b]) swap(a, b);
    par[b] = a, sz[a] += sz[b];
    stk.push_back(b);
}

void rollback(int keep) {
    while ((int)stk.size() > keep) {
        int b = stk.back();
        stk.pop_back();
        sz[par[b]] -= sz[b];
        par[b] = b;
    }
}

int Q;
vector<vector<pair<int,int>>> node;   // node[v] = การพบกันที่ยังนับอยู่ตลอดช่วงของปม v

void addRange(int v, int l, int r, int ql, int qr, pair<int,int> item) {
    if (qr < l || r < ql) return;
    if (ql <= l && r <= qr) { node[v].push_back(item); return; }
    int m = (l + r) / 2;
    addRange(2 * v, l, m, ql, qr, item);
    addRange(2 * v + 1, m + 1, r, ql, qr, item);
}

vector<int> type, who, res;

void dfs(int v, int l, int r) {
    int keep = stk.size();
    for (auto [x, y] : node[v]) unite(x, y);
    if (l == r) {
        if (type[l] == 2) res[l] = sz[find(who[l])];
    } else {
        int m = (l + r) / 2;
        dfs(2 * v, l, m);
        dfs(2 * v + 1, m + 1, r);
    }
    rollback(keep);
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    int n, k;
    cin >> n >> Q >> k;
    par.resize(n + 1), sz.assign(n + 1, 1);
    for (int i = 1; i <= n; i++) par[i] = i;
    stk.clear();
    node.assign(4 * Q, {});
    type.assign(Q, 0), who.assign(Q, 0), res.assign(Q, 0);

    // dayStart[d] = คำถามแรกของวันที่ d (วันแรกคือวันที่ 0 และขึ้นวันใหม่หลังคำสั่งแบบ 3)
    vector<int> dayStart = {0};
    vector<array<int,4>> meet;   // x, y, คำถามที่พบ, วันที่พบ
    for (int i = 0; i < Q; i++) {
        cin >> type[i];
        if (type[i] == 1) {
            int x, y;
            cin >> x >> y;
            meet.push_back({x, y, i, (int)dayStart.size() - 1});
        } else if (type[i] == 2) {
            cin >> who[i];
        } else {
            dayStart.push_back(i + 1);
        }
    }
    // พบกันวันที่ d ยังนับอยู่ถึงวันที่ d+k-1 จึงหมดอายุตอนเริ่มวันที่ d+k
    for (auto [x, y, i, d] : meet) {
        int end = d + k < (int)dayStart.size() ? dayStart[d + k] - 1 : Q - 1;
        addRange(1, 0, Q - 1, i, end, {x, y});
    }

    dfs(1, 0, Q - 1);
    for (int i = 0; i < Q; i++)
        if (type[i] == 2) cout << res[i] << '\n';
    return 0;
}

ตัวตรวจเก็บการพบทุกครั้งพร้อมวัน แล้วเดินกราฟใหม่ทุกคำถามโดยคัดเฉพาะการพบที่ วันนี้ - d < k 20,000 รอบบวกตัวอย่างสามชุด ตรงกันทุกรอบ เคสใหญ่สุดใช้ 0.22 วินาทีจากเวลาที่ให้ 5 วินาที

ฝึกข้อ 6 · ค่าสูงสุดที่เป็นไปได้ (Codeforces 981E Addition on Segments)

อาร์เรย์ยาว n เริ่มเป็นศูนย์ มีคำสั่ง q ข้อ ข้อหนึ่งคือบวก x ให้ทุกช่องตั้งแต่ l ถึง r เลือกคำสั่งมาทำเป็นชุดใดก็ได้ แล้วดูค่าสูงสุดของอาร์เรย์ ให้หาว่าจำนวน y ตัวไหนบ้างในช่วง 1 ถึง n ที่เป็นค่าสูงสุดนั้นได้

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

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

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

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

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

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

ใบ้

ถ้าค่าสูงสุดเป็น y และเกิดที่ช่อง p คำสั่งที่ไม่คลุม p มีประโยชน์อะไรไหม ถ้าไม่มี คำถามทั้งข้อก็เหลือแค่เรื่องของช่องเดียว แล้วช่องที่อยู่ติดกันใช้งานร่วมกันได้แค่ไหน

เฉลยฝึกข้อ 6

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

ทางแรกคือทำ subset sum ด้วย bitset ใหม่ที่ทุกช่อง ได้ราว n · q · n / 64 ≈ 1.6·10¹⁰ ช้าเกินหลายร้อยเท่า สิ่งที่ซ้ำคือช่องที่อยู่ติดกันถูกคลุมด้วยคำสั่งชุดเกือบเดียวกัน ทางที่สองคือกวาดจากซ้ายไปขวา คำสั่งไหนเริ่มก็ใส่ คำสั่งไหนจบก็ถอด ติดตรงการถอด เพราะ reach |= reach << x ย้อนไม่ได้ OR ไม่จำว่าบิตเปิดมาจากคำสั่งไหน

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

ข้อสังเกตแรก ถ้าเลือกเฉพาะคำสั่งที่คลุมช่อง p ช่องอื่นได้แค่บางตัวในชุดนั้น เพราะ x เป็นบวกทุกตัว จึงไม่มีช่องไหนเกินค่าที่ p ค่า y จึงเป็นไปได้ก็ต่อเมื่อมีสักช่องที่ทำผลรวม y ได้จากคำสั่งที่คลุมมัน

ข้อนี้ใช้ครึ่งแรกของท่าเท่านั้น แกนของต้นไม้เป็นตำแหน่ง ไม่ใช่เวลา แจกคำสั่งลงปมด้วย addRange แล้ว DFS ด้วย bitset หนึ่งแถวต่อความลึก ลูกทำงานบนสำเนา แม่ยังถืออันเดิมไว้ การ "ย้อน" จึงเกิดเองโดยไม่ต้องถอดอะไร ต้นไม้บนหนึ่งหมื่นตำแหน่งลึกไม่เกิน 14 ชั้น จึงใช้ bitset แค่ 16 แถว

โค้ดฝึกข้อ 6

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

const int N = 10005;  // n สูงสุด 10^4 บวกช่องสำหรับค่า 0
const int LOG = 16;   // ต้นไม้ช่วงบน 10^4 ตำแหน่งลึกไม่เกิน 14 ชั้น

int n, q;
vector<int> node[4 * N];     // คำสั่งที่ครอบช่วงของปมนี้ทั้งช่วง เก็บแค่ค่า x
bitset<N> reach[LOG];        // reach[dep] = ผลรวมที่ทำได้ ณ ความลึก dep (บิต s เปิด = ทำผลรวม s ได้)
bitset<N> ans;

// แกนของต้นไม้คือ "ตำแหน่ง" ในอาร์เรย์ ไม่ใช่เวลา
void addRange(int v, int l, int r, int ql, int qr, int item) {
    if (qr < l || r < ql) return;
    if (ql <= l && r <= qr) { node[v].push_back(item); return; }
    int m = (l + r) / 2;
    addRange(2 * v, l, m, ql, qr, item);
    addRange(2 * v + 1, m + 1, r, ql, qr, item);
}

// reach[dep] ถูกเตรียมจากแม่ไว้แล้ว ปมนี้เติมของตัวเองลงไป แล้วส่งสำเนาต่อให้ลูก
// ไม่ต้องลบอะไรเลย เพราะสำเนาของแม่ที่ reach[dep-1] ยังอยู่ครบ
void dfs(int v, int l, int r, int dep) {
    for (int x : node[v]) reach[dep] |= reach[dep] << x;  // บิตที่เลื่อนเกิน n หลุดทิ้งเอง
    if (l == r) { ans |= reach[dep]; return; }
    int m = (l + r) / 2;
    reach[dep + 1] = reach[dep];
    dfs(2 * v, l, m, dep + 1);
    reach[dep + 1] = reach[dep];
    dfs(2 * v + 1, m + 1, r, dep + 1);
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    cin >> n >> q;
    for (int i = 0; i < q; i++) {
        int l, r, x;
        cin >> l >> r >> x;
        addRange(1, 1, n, l, r, x);
    }
    reach[0][0] = 1;  // ไม่เลือกคำสั่งใดเลย ผลรวมเป็น 0
    dfs(1, 1, n, 0);
    // ถ้าตำแหน่ง p ทำผลรวม y ได้ด้วยคำสั่งชุดที่คลุม p ทุกตัว ตำแหน่งอื่นได้แค่บางตัวในชุดนั้น
    // จึงไม่เกิน y ค่าสูงสุดของอาร์เรย์เลยเท่ากับ y พอดี
    vector<int> out;
    for (int y = 1; y <= n; y++)
        if (ans[y]) out.push_back(y);
    cout << out.size() << "\n";
    for (int y : out) cout << y << " ";
    cout << "\n";
}

ตัวตรวจลองทุกชุดคำสั่ง ทำจริงลงอาร์เรย์ แล้วดูค่าสูงสุด 5,000 รอบบวกตัวอย่างสามชุด ตรงกันทุกรอบ เคสที่หั่นปมมากที่สุดใช้ 0.06 วินาที

ฝึกข้อ 7 · ขโมยของในพิพิธภัณฑ์ (Codeforces 601E A Museum Robbery)

พิพิธภัณฑ์มีของจัดแสดง แต่ละชิ้นมีราคาและมวล มีเหตุการณ์สามแบบ ของใหม่เข้าตู้ ของเลข x ถูกเก็บเข้าห้องนิรภัย และคำถามว่า ถ้าขโมยของที่จัดแสดงอยู่ได้มวลรวมไม่เกิน m จะได้ราคาสูงสุดเท่าไร ต้องตอบทุก m ตั้งแต่ 1 ถึง k แล้วรวมเป็นตัวเลขตัวเดียวด้วยสูตร hash ของโจทย์

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

EXAMPLE
InputOutput
3 10
30 4
60 6
5 1
9
3
1 42 5
1 20 3
3
2 2
2 4
3
1 40 6
3
556674384
168191145
947033915
181541912

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

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

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

ของทุกชิ้นในตัวอย่าง · คำถามที่ 0 ถึง 3
เลขราคามวลเห็นตั้งแต่คำถามถึงคำถาม
130403
260601
35103
442511
520313
640633
ช่วงอายุนับบนแกนของคำถามเท่านั้น ของที่ถูกเก็บก่อนมีคำถามใดเลยจะได้ช่วงว่าง ซึ่งคือจุดที่ข้อนี้มีบั๊กจริงให้เล่า

ใบ้

ใส่ของหนึ่งชิ้นลง knapsack ใช้ O(k) แต่ถอดออกไม่ได้ เพราะ max ไม่จำว่าได้ค่ามาจากทางไหน ถ้าจะใช้ท่าของบทนี้ ต้องย้อนกลับได้ การใส่ของหนึ่งชิ้นแก้ได้ถึง k ช่อง จะย้อนด้วยอะไรถึงจะคุ้ม

เฉลยฝึกข้อ 7

เรื่องจริงที่เกิดขึ้นตามลำดับ

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

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

แก้ด้วยค่าคงที่ ALIVE = INT_MAX บทเรียนคือ ค่าพิเศษที่แทน "ไม่มี" ต้องอยู่นอกช่วงของค่าจริงทั้งหมด -1 ดูเหมือนอยู่นอกช่วงเลขคำถาม จนกว่าจะเจอกรณีที่ยังไม่มีคำถามเลย

ย้อนด้วยสำเนา ไม่ใช่สแต็ก การใส่ของหนึ่งชิ้นแก้ได้ถึง k + 1 ช่อง จดทุกช่องลงสแต็กก็ได้ แต่งานเท่ากับคัดลอกทั้งแถวอยู่แล้ว เก็บอาร์เรย์ dp หนึ่งแถวต่อความลึก ลูกทำงานบนแถวของตัวเอง แม่ยังถืออันเดิม ต้นไม้บนสามหมื่นใบลึก 15 ชั้น ใช้ไม่ถึง 140 KB

และเลือกแกนเวลาเป็นคำถาม เหมือนข้อ 2 ของบทนี้ ทุกใบจึงเป็นคำถามจริง ค่า s(m) สูงได้ราว 1.5·10¹⁰ ต้องมอดก่อนคูณกับกำลังของ p ไม่งั้น long long ล้น

โค้ดฝึกข้อ 7

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

const int Q = 30005;        // จำนวนเหตุการณ์ประเภท 3 มากสุด 30000
const int K = 1005;         // มวลที่สนใจมากสุด 1000
const int LOG = 17;         // ต้นไม้ช่วงบน 30000 ใบลึกไม่เกิน 15 ชั้น
const ll P = 10000019, MOD = 1000000007;

int k;
vector<pair<int, int>> node[4 * Q];  // ของ (ราคา, มวล) ที่อยู่ในตู้ตลอดช่วงของปมนี้
ll dp[LOG][K];              // dp[dep][m] = ราคารวมสูงสุดเมื่อมวลรวมไม่เกิน m ณ ความลึก dep
ll ans[Q];

void addRange(int v, int l, int r, int ql, int qr, pair<int, int> item) {
    if (qr < l || r < ql) return;
    if (ql <= l && r <= qr) { node[v].push_back(item); return; }
    int m = (l + r) / 2;
    addRange(2 * v, l, m, ql, qr, item);
    addRange(2 * v + 1, m + 1, r, ql, qr, item);
}

// knapsack ถอดของออกไม่ได้ จึงไม่ถอด: ลูกทำงานบนสำเนา dp[dep+1]
// พอกลับขึ้นมา dp[dep] ของแม่ยังไม่ถูกแตะ นี่คือ "ย้อนกลับ" ด้วยการเก็บสำเนา
void dfs(int v, int l, int r, int dep) {
    ll* f = dp[dep];
    for (auto [val, w] : node[v])
        for (int m = k; m >= w; m--) f[m] = max(f[m], f[m - w] + val);
    if (l == r) {
        ll h = 0, pw = 1;
        for (int m = 1; m <= k; m++) {
            h = (h + f[m] % MOD * pw) % MOD;
            pw = pw * P % MOD;
        }
        ans[l] = h;
        return;
    }
    int m = (l + r) / 2;
    copy(f, f + k + 1, dp[dep + 1]);
    dfs(2 * v, l, m, dep + 1);
    copy(f, f + k + 1, dp[dep + 1]);
    dfs(2 * v + 1, m + 1, r, dep + 1);
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    int n, q;
    cin >> n >> k;
    // from = คำถามแรกที่เห็นของชิ้นนี้, to = คำถามสุดท้ายที่ยังเห็น
    // ของที่ไม่เคยถูกเก็บเข้าห้องนิรภัยใช้ค่า ALIVE ห้ามใช้ -1 เพราะของที่ถูกเก็บก่อนคำถามแรกได้ to = -1 จริง
    const int ALIVE = INT_MAX;
    vector<int> v(n), w(n), from(n, 0), to(n, ALIVE);
    for (int i = 0; i < n; i++) cin >> v[i] >> w[i];
    cin >> q;
    int T = 0;  // จำนวนคำถาม (เหตุการณ์ประเภท 3) ใบของต้นไม้คือคำถามลำดับ 0..T-1
    for (int e = 0; e < q; e++) {
        int type;
        cin >> type;
        if (type == 1) {
            int a, b;
            cin >> a >> b;
            v.push_back(a); w.push_back(b);
            from.push_back(T); to.push_back(ALIVE);
        } else if (type == 2) {
            int x;
            cin >> x;
            to[x - 1] = T - 1;
        } else {
            T++;
        }
    }
    for (int i = 0; i < (int)v.size(); i++) {
        int r = to[i] == ALIVE ? T - 1 : to[i];
        if (from[i] <= r) addRange(1, 0, T - 1, from[i], r, {v[i], w[i]});
    }
    dfs(1, 0, T - 1, 0);  // dp[0] เป็นศูนย์ทั้งแถว (ยังไม่มีของ)
    for (int i = 0; i < T; i++) cout << ans[i] << "\n";
}

ตัวตรวจทำ knapsack ใหม่ทั้งชุดทุกคำถาม 5,000 รอบเล็ก บวก 30 รอบขนาดกลางที่ของเยอะและราคาใกล้ล้าน เพื่อให้ค่าเกินมอดุโลจริง ตรงกันทุกรอบ เคสที่หั่นปมมากที่สุดใช้ 0.12 วินาที

ดูรุ่นที่ใช้ -1 แทน "ยังไม่ถูกเก็บ"
museum-robbery-minus-one.cpp
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

const int Q = 30005;        // จำนวนเหตุการณ์ประเภท 3 มากสุด 30000
const int K = 1005;         // มวลที่สนใจมากสุด 1000
const int LOG = 17;         // ต้นไม้ช่วงบน 30000 ใบลึกไม่เกิน 16 ชั้น
const ll P = 10000019, MOD = 1000000007;

int k, T;                   // T = จำนวนคำถาม (เหตุการณ์ประเภท 3) ใบของต้นไม้คือคำถามลำดับ 0..T-1
vector<pair<int, int>> node[4 * Q];  // ของ (ราคา, มวล) ที่อยู่ในตู้ตลอดช่วงของปมนี้
ll dp[LOG][K];              // dp[dep][m] = ราคารวมสูงสุดเมื่อมวลรวมไม่เกิน m ณ ความลึก dep
ll ans[Q];

void addRange(int v, int l, int r, int ql, int qr, pair<int, int> item) {
    if (qr < l || r < ql) return;
    if (ql <= l && r <= qr) { node[v].push_back(item); return; }
    int m = (l + r) / 2;
    addRange(2 * v, l, m, ql, qr, item);
    addRange(2 * v + 1, m + 1, r, ql, qr, item);
}

// knapsack ถอดของออกไม่ได้ จึงไม่ถอด: ลูกทำงานบนสำเนา dp[dep+1]
// พอกลับขึ้นมา dp[dep] ของแม่ยังไม่ถูกแตะ นี่คือ "ย้อนกลับ" ด้วยการเก็บสำเนา
void dfs(int v, int l, int r, int dep) {
    ll* f = dp[dep];
    for (auto [val, w] : node[v])
        for (int m = k; m >= w; m--) f[m] = max(f[m], f[m - w] + val);
    if (l == r) {
        ll h = 0, pw = 1;
        for (int m = 1; m <= k; m++) {
            h = (h + f[m] % MOD * pw) % MOD;
            pw = pw * P % MOD;
        }
        ans[l] = h;
        return;
    }
    int m = (l + r) / 2;
    copy(f, f + k + 1, dp[dep + 1]);
    dfs(2 * v, l, m, dep + 1);
    copy(f, f + k + 1, dp[dep + 1]);
    dfs(2 * v + 1, m + 1, r, dep + 1);
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    int n, q;
    cin >> n >> k;
    // ของแต่ละชิ้น: ราคา, มวล, คำถามแรกที่เห็นมัน (นับจำนวนคำถามก่อนหน้าตอนที่ของเข้าตู้)
    vector<int> v(n), w(n), from(n, 0), to(n, -1);
    for (int i = 0; i < n; i++) cin >> v[i] >> w[i];
    cin >> q;
    T = 0;
    for (int e = 0; e < q; e++) {
        int type;
        cin >> type;
        if (type == 1) {
            int a, b;
            cin >> a >> b;
            v.push_back(a); w.push_back(b);
            from.push_back(T); to.push_back(-1);
        } else if (type == 2) {
            int x;
            cin >> x;
            to[x - 1] = T - 1;  // คำถามสุดท้ายที่ยังเห็นของชิ้นนี้
        } else {
            T++;
        }
    }
    for (int i = 0; i < (int)v.size(); i++) {
        int r = to[i] == -1 ? T - 1 : to[i];
        if (from[i] <= r) addRange(1, 0, T - 1, from[i], r, {v[i], w[i]});
    }
    dfs(1, 0, T - 1, 0);  // dp[0] เป็นศูนย์ทั้งแถว (ยังไม่มีของ)
    for (int i = 0; i < T; i++) cout << ans[i] << "\n";
}

อินพุต 1 1 / 1 1 / 2 / 2 1 / 3 รุ่นนี้ตอบ 1 ที่ถูกคือ 0 เพราะของที่ถูกเก็บก่อนคำถามแรกยังถูกวางลงต้นไม้ทั้งแกน

ฝึกข้อ 8 · เส้นที่อิจฉา (Codeforces 891C Envy)

กราฟมีน้ำหนักต่อกันครบ ต้นไม้แผ่ทั่วที่เบาที่สุด (ย่อว่า MST) อาจมีได้หลายต้น แต่ละคำถามให้ชุดของเส้นมา ให้ตอบว่ามี MST สักต้นที่มีเส้นในชุดนั้นครบทุกเส้นไหม

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

EXAMPLE
InputOutput
5 7
1 2 2
1 3 2
2 3 1
2 4 1
3 4 1
3 5 2
4 5 2
4
2 3 4
3 3 4 5
2 1 7
2 1 2
YES
NO
YES
NO

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

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

กราฟนี้มีต้นไม้แผ่ทั่วที่เบาที่สุดอยู่ 12 ต้น ทุกต้นหนัก 6 เท่ากัน คำถามแรกขอเส้น 3 กับ 4 ซึ่งมีต้นที่รับไว้ได้ทั้งคู่ จึงตอบ YES คำถามที่สองขอเส้น 3, 4, 5 ครบทั้งสาม แต่สามเส้นนี้คือสามเหลี่ยม 2-3-4 ใส่พร้อมกันแล้วเป็นวง จึงตอบ NO คำถามที่สามขอเส้น 1 กับ 7 ซึ่งอยู่คนละมุมของกราฟ เส้นหนึ่งลากจุด 1 เข้ามา อีกเส้นลากจุด 5 เข้ามา ต้นเดียวรับได้ทั้งคู่ จึงตอบ YES ส่วนคำถามสุดท้ายขอเส้น 1 กับ 2 ซึ่งหนัก 2 ทั้งคู่และต่างก็ลากจุด 1 เข้ามาเหมือนกัน ต้นเดียวต้องการแค่เส้นเดียวพอ รับทั้งคู่เมื่อไหร่ก็หนักเกิน 6 จึงตอบ NO

#1 w2 #2 w2 #3 w1 #4 w1 #5 w1 #6 w2 #7 w2 1 2 3 4 5
ตัวเลขสีทองคือเลขเส้น ตัวเลขข้างมันคือน้ำหนัก MST ของกราฟนี้หนัก 6 และมีทั้งหมด 12 ต้น เส้น 3, 4, 5 น้ำหนัก 1 ทุกเส้น แต่ละเส้นอยู่ใน MST ได้ แต่สามเส้นเป็นสามเหลี่ยม อยู่พร้อมกันไม่ได้

ใบ้

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

เฉลยฝึกข้อ 8

สองทางที่ผมลองก่อน แล้วทิ้ง

ทางแรกคือบังคับเส้นของคำถามเข้าไปก่อนแล้วรัน Kruskal ต่อจนได้ต้นไม้ แล้วเทียบน้ำหนัก ถูกแต่ช้า Kruskal หนึ่งรอบแตะห้าแสนเส้น คูณห้าแสนคำถามเป็น 2.5·10¹¹ ทางที่สองคือหาว่าเส้นแต่ละเส้นอยู่ใน MST ได้ไหม ซึ่งคำถามที่สองของตัวอย่างหักทิ้งทันที เส้น 3, 4, 5 อยู่ใน MST ได้ทุกเส้น แต่อยู่พร้อมกันไม่ได้

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

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

ก่อนลองกลุ่มน้ำหนัก w รวมถาวรทุกเส้นที่เบากว่า w จด keep ลองเส้นของกลุ่ม แล้ว rollback(keep) การรวมถาวรของ Kruskal อยู่ใต้ keep เสมอจึงไม่ถูกย้อนไปด้วย ข้อนี้จึงใช้ครึ่งหลังของท่าโดยไม่มีต้นไม้ช่วงเลย เพราะการย้อนมีแค่ชั้นเดียว

โค้ดฝึกข้อ 8

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

vector<int> par, sz;
vector<int> stk;   // ลูกที่ถูกย้ายไปขึ้นกับแม่ เรียงตามลำดับที่รวม

int find(int x) {  // ไม่บีบทางเดิน เพราะต้อง rollback ได้
    while (par[x] != x) x = par[x];
    return 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];
    stk.push_back(b);
    return true;
}

void rollback(int keep) {
    while ((int)stk.size() > keep) {
        int b = stk.back();
        stk.pop_back();
        sz[par[b]] -= sz[b];
        par[b] = b;
    }
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    int n, m;
    cin >> n >> m;
    vector<int> U(m + 1), V(m + 1), W(m + 1);
    for (int i = 1; i <= m; i++) cin >> U[i] >> V[i] >> W[i];
    par.resize(n + 1), sz.assign(n + 1, 1);
    for (int i = 1; i <= n; i++) par[i] = i;
    stk.clear();

    int q;
    cin >> q;
    // แตกแต่ละคำถามเป็นกลุ่มย่อยตามน้ำหนัก: (น้ำหนัก, คำถาม, เส้นในกลุ่ม)
    vector<tuple<int,int,vector<int>>> groups;
    for (int j = 0; j < q; j++) {
        int k;
        cin >> k;
        vector<int> e(k);
        for (int& x : e) cin >> x;
        sort(e.begin(), e.end(), [&](int a, int b) { return W[a] < W[b]; });
        for (int s = 0, t; s < k; s = t) {
            for (t = s; t < k && W[e[t]] == W[e[s]]; t++) {}
            groups.emplace_back(W[e[s]], j, vector<int>(e.begin() + s, e.begin() + t));
        }
    }
    sort(groups.begin(), groups.end(),
         [](const auto& a, const auto& b) { return get<0>(a) < get<0>(b); });

    vector<int> order(m);
    iota(order.begin(), order.end(), 1);
    sort(order.begin(), order.end(), [&](int a, int b) { return W[a] < W[b]; });

    vector<bool> ok(q, true);
    int p = 0;   // เส้นใน order[0..p) ถูกรวมถาวรแล้ว (Kruskal ที่เดินไปถึงก่อนน้ำหนักปัจจุบัน)
    for (auto& [w, j, e] : groups) {
        // เส้นที่เบากว่า w ทุกเส้นต้องรวมแล้ว ส่วนเส้นน้ำหนัก w เองยังห้ามรวม
        while (p < m && W[order[p]] < w) unite(U[order[p]], V[order[p]]), p++;
        if (!ok[j]) continue;
        int keep = stk.size();
        for (int x : e)
            if (!unite(U[x], V[x])) { ok[j] = false; break; }
        rollback(keep);   // ของที่ลองรวมเป็นของคำถามนี้คนเดียว คำถามอื่นต้องไม่เห็น
    }
    for (int j = 0; j < q; j++) cout << (ok[j] ? "YES" : "NO") << '\n';
    return 0;
}

ตัวตรวจไล่ทุกต้นไม้แผ่ทั่ว 20,000 รอบบนกราฟไม่เกินหกจุด น้ำหนัก 1 ถึง 3 ให้เท่ากันบ่อย รวม 69,598 คำถาม ตอบ YES 41,256 ข้อ ตรงกันทุกข้อ เคสใหญ่สุดใช้ 0.31 วินาที

ฝึกข้อ 9 · ตู้หนังสือย้อนเวอร์ชันได้ (Codeforces 707D Persistent Bookcase)

ตู้หนังสือ n ชั้น ชั้นละ m ช่อง คำสั่งมีสี่แบบ วางหนังสือ หยิบหนังสือ กลับด้านทั้งชั้น และ 4 k ทำให้ตู้กลับไปเหมือนหลังคำสั่งที่ k หลังทุกคำสั่งให้ตอบว่าตู้มีหนังสือกี่เล่ม

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

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

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

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

เริ่มจากตู้ว่าง คำสั่ง 3 2 กลับด้านชั้น 2 ทั้งชั้น ชั้นนั้นจึงเต็มสองเล่ม ได้ 2 คำสั่ง 2 2 2 หยิบเล่มที่ช่อง 2 ของชั้น 2 ออก เหลือ 1 คำสั่ง 3 3 กลับด้านชั้น 3 เพิ่มมาอีกสองเล่ม รวม 3 คำสั่ง 3 2 กลับด้านชั้น 2 อีกครั้ง ชั้นนั้นยังมีเล่มเดียวเท่าเดิม แค่ย้ายช่อง ยอดจึงค้างที่ 3 สองคำสั่งท้ายหยิบเล่มนั้นออก แล้วกลับด้านชั้น 2 ได้ 2 กับ 4

ตัวอย่างนี้ไม่มีคำสั่งแบบ 4 (ย้อนกลับไปสภาพหลังคำสั่งก่อนหน้าข้อใดข้อหนึ่ง) เลยสักข้อ รูปข้างล่างจึงวาดจากตัวอย่างอีกชุดของโจทย์ที่มีคำสั่งนั้น

เวอร์ชัน 2 3 2 เวอร์ชัน 1 1 1 1 เวอร์ชัน 3 4 0 เวอร์ชัน 0 ตู้ว่าง
ต้นไม้เวอร์ชันของตัวอย่างแรกในโจทย์ (2 ชั้น 3 ช่อง) คำสั่ง 4 0 ไม่ได้ต่อจากเวอร์ชัน 2 แต่ต่อจากตู้ว่าง จึงเป็นกิ่งใหม่จากราก คำตอบของเวอร์ชันไหนก็ขึ้นกับคำสั่งบนทางจากรากถึงเวอร์ชันนั้นเท่านั้น

ใบ้

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

เฉลยฝึกข้อ 9

ถูกตั้งแต่แรก แต่พังบนเครื่องที่สแต็กเล็ก

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

นี่คือเรื่องเดียวกับข้อ Knight Tournament ในบท DSU ความลึกที่ไม่มีใครคุม โค้ดบนหน้านี้จึงเดินต้นไม้ด้วยสแต็กของตัวเอง ผลลัพธ์ตรงกับรุ่นเรียกตัวเองที่ขยายสแต็กแล้วทุกบรรทัดบนเคสใหญ่ทั้งสี่แบบ

คำสั่ง v ต่อจากเวอร์ชัน v - 1 ส่วน 4 k ต่อจากเวอร์ชัน k แล้วไม่ทำอะไร เวอร์ชันทั้งหมดจึงเป็นต้นไม้ที่มีตู้ว่างเป็นราก เดินจากราก เข้าปมก็ทำคำสั่งของปมนั้นแล้วอ่านคำตอบ ออกจากปมก็ย้อนคำสั่งนั้น นี่คือครึ่งหลังของท่าเดียวกัน บนต้นไม้ที่โจทย์ให้มา แทนต้นไม้ช่วงที่เราสร้างเอง

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

โค้ดฝึกข้อ 9

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

int n, m, q;
vector<vector<char>> a;       // a[i][j] ก่อนกลับด้าน: มีหนังสือจริงเมื่อ a[i][j] != inv[i]
vector<int> inv, cnt;         // inv[i] = ชั้น i ถูกกลับด้านอยู่ไหม, cnt[i] = จำนวนหนังสือบนชั้น i
int total;
vector<int> t, x, y, res;
vector<vector<int>> child;    // child[v] = คำสั่งที่ต่อจากเวอร์ชัน v

// กลับด้านชั้น i ทำซ้ำอีกครั้งก็คือการย้อน
void flipShelf(int i) {
    inv[i] ^= 1;
    total += m - 2 * cnt[i];
    cnt[i] = m - cnt[i];
}

// ทำคำสั่ง v คืนว่าตู้เปลี่ยนจริงไหม วางบนช่องที่มีหนังสือหรือหยิบจากช่องว่างไม่เปลี่ยนอะไร
bool apply(int v) {
    if (t[v] == 3) { flipShelf(x[v]); return true; }
    if (t[v] != 1 && t[v] != 2) return false;   // เวอร์ชัน 0 กับคำสั่งแบบ 4 ไม่แตะตู้
    int i = x[v], j = y[v], d = t[v] == 1 ? 1 : -1;
    bool has = a[i][j] != inv[i];
    if (has == (t[v] == 1)) return false;
    a[i][j] ^= 1, cnt[i] += d, total += d;
    return true;
}

// rollback คำสั่ง v ที่เปลี่ยนตู้จริง
void undo(int v) {
    if (t[v] == 3) { flipShelf(x[v]); return; }
    int i = x[v], j = y[v], d = t[v] == 1 ? 1 : -1;
    a[i][j] ^= 1, cnt[i] -= d, total -= d;
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    cin >> n >> m >> q;
    a.assign(n + 1, vector<char>(m + 1, 0));
    inv.assign(n + 1, 0), cnt.assign(n + 1, 0), total = 0;
    t.assign(q + 1, 0), x.assign(q + 1, 0), y.assign(q + 1, 0), res.assign(q + 1, 0);
    child.assign(q + 1, {});
    // เวอร์ชัน 0 คือตู้ว่าง คำสั่ง v ต่อจากเวอร์ชัน v-1 ยกเว้นแบบ 4 ที่ต่อจากเวอร์ชัน k
    for (int v = 1; v <= q; v++) {
        cin >> t[v] >> x[v];
        if (t[v] <= 2) cin >> y[v];
        child[t[v] == 4 ? x[v] : v - 1].push_back(v);
    }

    // เดินต้นไม้เวอร์ชันด้วยสแต็กของตัวเอง ต้นไม้ลึกได้ถึง q ชั้น (ถ้าไม่มีแบบ 4 เลย)
    // ถ้าเรียกตัวเองลึกขนาดนั้น สแต็กของโปรแกรมอาจล้น
    vector<char> changed(q + 1, 0);
    vector<pair<int, int>> st = {{0, 0}};   // (เวอร์ชัน, ลูกคนถัดไปที่ต้องลงไป)
    while (!st.empty()) {
        int v = st.back().first;
        int& next = st.back().second;
        if (next < (int)child[v].size()) {
            int c = child[v][next++];
            changed[c] = apply(c);
            res[c] = total;                    // ตู้ในหน่วยความจำตอนนี้คือเวอร์ชัน c พอดี
            st.push_back({c, 0});
        } else {
            if (changed[v]) undo(v);           // ออกจากเวอร์ชัน v ย้อนตู้กลับเป็นเวอร์ชันแม่
            st.pop_back();
        }
    }
    for (int v = 1; v <= q; v++) cout << res[v] << '\n';
    return 0;
}

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

ฝึกข้อ 10 · ทางเดิน xor ที่สั้นที่สุด (Codeforces 938G Shortest Path Queries)

กราฟต่อกันเป็นก้อนเดียว ความยาวของทางเดินคือ xor ของน้ำหนักทุกเส้นที่เดินผ่าน เดินซ้ำเส้นก็ xor ซ้ำ คำสั่งมีสามแบบ เพิ่มเส้น ลบเส้น (ลบแล้วกราฟยังต่อกัน) และถามความยาวที่สั้นที่สุดจาก x ไป y

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

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

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

บรรทัดแรกคือจำนวนจุดกับจำนวนเส้นตั้งต้น ตามด้วยเส้นละบรรทัด (ปลายสองข้างกับน้ำหนัก) แล้วบอกจำนวนคำสั่ง และคำสั่งข้อละบรรทัด เอาต์พุตออกเฉพาะคำสั่งแบบ 3 ตัวอย่างนี้มีคำสั่ง 5 ข้อ แต่มีคำตอบแค่ 3 บรรทัด ตัวเลขที่ตอบคือค่า xor ของน้ำหนักตลอดทางเดิน ไม่ใช่จำนวนเส้นที่เดินผ่าน

คำสั่งแรกถามจาก 1 ไป 5 ตอนนั้นเส้น 1-5 น้ำหนัก 1 ยังอยู่ เดินเส้นเดียวได้ 1 คำสั่งที่สองเพิ่มเส้น 1-3 น้ำหนัก 1 เข้ามา ถามซ้ำก็ยังได้ 1 เพราะทางลัดเดิมยังไม่หายไปไหน คำสั่งที่สี่ลบเส้น 1-5 ทิ้ง คำถามสุดท้ายจึงต้องอ้อมไปทาง 1-3-4-5 ได้ 1 xor 5 xor 6 เท่ากับ 2

3 4 5 6 1 1 1 2 3 4 5
เส้นประสีเขียวคือ 1-3 ที่ถูกเพิ่มทีหลัง เส้นประสีแดงคือ 1-5 ที่ถูกลบก่อนคำถามสุดท้าย พอไม่มีทางลัด 1-5 ทางที่ดีที่สุดจาก 1 ไป 5 คือ 1-3-4-5 ได้ 1 xor 5 xor 6 = 2

ใบ้

ถ้าเดินไปวนวงหนึ่งแล้วเดินกลับทางเดิม ขาไปกับขากลับหักล้างกัน เหลือแค่ค่าของวง ทางเดินไหนก็ได้จาก x ไป y จึงเท่ากับทางใดทางหนึ่งบนต้นไม้ xor กับวงบางชุด บนกราฟที่ไม่เปลี่ยน โจทย์นี้ใช้ของสองอย่าง แล้วเมื่อกราฟเปลี่ยนได้ ของสองอย่างนั้นย้อนกลับได้หรือเปล่า

เฉลยฝึกข้อ 10

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

ข้อนี้ประกอบจากสองท่าที่รู้จักอยู่แล้ว ไม่ได้ค้นพบระหว่างทำ ท่าแรกคือเวอร์ชันกราฟคงที่ คำตอบคือ xor ของระยะจากรากถึงสองจุด แล้วลดให้เล็กที่สุดด้วยฐานของ xor (linear basis) ของค่าวงทั้งหมด ท่าที่สองคือทั้งบทนี้ งานจริงคือเลือกว่าแต่ละส่วนย้อนด้วยวิธีไหน

ตัวตรวจต้องไม่ใช้ฐาน xor ไม่งั้นมันก็เชื่อสิ่งเดียวกับตัวจริง จึงให้ BFS บนสถานะ (จุด, xor ที่สะสม) ซึ่งคือนิยามของโจทย์ตรง ๆ ใช้ได้เมื่อน้ำหนักเล็ก บิตสูงตรวจแยกด้วยการคูณน้ำหนักทุกเส้นด้วย 2²⁷ บทเรียนคือ โครงสร้างหลายตัวในโปรแกรมเดียวย้อนกลับคนละวิธีได้ ขอแค่ทุกตัวถูกย้อนที่จุดเดียวกัน คือตอนออกจากปม

DSU เก็บ d[x] คือ xor จาก x ถึงแม่ ท่าเดียวกับข้อ 3 แต่เป็นตัวเลขเต็มแทนบิตเดียว ตอนรวม ตั้ง d ของหัวที่ไปขึ้นกับอีกฝั่งให้ทางในต้นไม้จาก x ไป y มีค่าเท่ากับน้ำหนักเส้นจริงพอดี ถ้าสองจุดอยู่ก๊วนเดียวกันแล้ว เส้นนี้ไม่ต่อก๊วนแต่ปิดวง ค่าของวงคือ dx xor dy xor w ส่งเข้าฐาน

DSU ย้อนด้วยสแต็กเพราะแต่ละการรวมแก้ไม่กี่ช่องในอาร์เรย์ขนาด n ส่วนฐานมีแค่ 30 ช่อง จึงย้อนด้วยสำเนาหนึ่งแถวต่อความลึก แบบเดียวกับ knapsack ในข้อ 7 สองวิธีอยู่ในโปรแกรมเดียวกันได้โดยไม่ต้องรักษาลำดับให้ตรงกัน

โค้ดฝึกข้อ 10

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

const int N = 200005;
const int Q = 200005;
const int B = 30;          // น้ำหนักมีไม่เกิน 30 บิต
const int LOG = 20;        // ต้นไม้ช่วงบน 2·10^5 ใบลึกไม่เกิน 18 ชั้น

struct Edge { int x, y, w; };

int par[N], sz[N], d[N];   // d[v] = xor ของเส้นจาก v ขึ้นไปถึง par[v]
vector<int> stk;           // ลูกที่ถูกย้ายไปขึ้นกับแม่ ตามลำดับการรวม
vector<Edge> node[4 * Q];
int basis[LOG][B];         // basis[dep][b] = ตัวในฐาน xor ที่บิตสูงสุดคือ b (0 = ว่าง)
int qx[Q], qy[Q];          // คำถามที่เวลา t (qx = 0 ถ้าเวลานั้นไม่ใช่คำถาม)

// ไม่บีบทางเดิน เพื่อให้การรวมหนึ่งครั้งแก้แค่ par, sz, d ของรากเดียว ย้อนกลับได้
// คืนราก และเขียน xor จาก x ถึงรากลงใน dist
int find(int x, int& dist) {
    dist = 0;
    while (par[x] != x) { dist ^= d[x]; x = par[x]; }
    return x;
}

void insertBasis(int* bs, int val) {
    for (int b = B - 1; b >= 0 && val; b--) {
        if (!(val >> b & 1)) continue;
        if (!bs[b]) { bs[b] = val; return; }
        val ^= bs[b];
    }
}

// เส้น x-y น้ำหนัก w: ถ้าอยู่คนละก๊วน ต่อราก ถ้าอยู่ก๊วนเดียวกัน เกิดวงใหม่ ส่ง xor ของวงเข้าฐาน
bool unite(int x, int y, int w, int* bs) {
    int dx, dy;
    int a = find(x, dx), b = find(y, dy);
    if (a == b) { insertBasis(bs, dx ^ dy ^ w); return false; }
    if (sz[a] < sz[b]) swap(a, b);
    // เลือก d[b] ให้ xor จาก x ไป y ผ่านต้นไม้เท่ากับ w พอดี: dx ^ d[b] ^ dy = w
    // สลับ a กับ b แล้วสูตรเดิมยังถูก เพราะ xor สลับที่ได้
    par[b] = a; d[b] = dx ^ dy ^ w; sz[a] += sz[b];
    stk.push_back(b);
    return true;
}

void rollback(int keep) {
    while ((int)stk.size() > keep) {
        int b = stk.back(); stk.pop_back();
        sz[par[b]] -= sz[b];
        par[b] = b;   // d[b] ไม่ต้องคืน: รากไม่มีใครอ่าน d และจะถูกเขียนทับเมื่อ b ถูกรวมอีกครั้ง
    }
}

void addRange(int v, int l, int r, int ql, int qr, Edge item) {
    if (qr < l || r < ql) return;
    if (ql <= l && r <= qr) { node[v].push_back(item); return; }
    int m = (l + r) / 2;
    addRange(2 * v, l, m, ql, qr, item);
    addRange(2 * v + 1, m + 1, r, ql, qr, item);
}

// DSU ย้อนด้วยสแต็ก ส่วนฐาน xor ย้อนด้วยสำเนาตามความลึก (ฐานมีแค่ 30 ช่อง คัดลอกถูกกว่าจดทุกการแก้)
void dfs(int v, int l, int r, int dep) {
    int keep = stk.size();
    int* bs = basis[dep];
    for (auto& e : node[v]) unite(e.x, e.y, e.w, bs);
    if (l == r) {
        if (qx[l]) {
            int dx, dy;
            find(qx[l], dx); find(qy[l], dy);
            int val = dx ^ dy;
            // ลดค่าจากบิตสูงลงต่ำ: ถ้า xor กับตัวในฐานแล้วเล็กลงก็เอา
            for (int b = B - 1; b >= 0; b--) val = min(val, val ^ bs[b]);
            cout << val << "\n";
        }
    } else {
        int m = (l + r) / 2;
        copy(bs, bs + B, basis[dep + 1]);
        dfs(2 * v, l, m, dep + 1);
        copy(bs, bs + B, basis[dep + 1]);
        dfs(2 * v + 1, m + 1, r, dep + 1);
    }
    rollback(keep);
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    int n, m, q;
    cin >> n >> m;
    map<pair<int, int>, pair<int, int>> open;  // (x, y) -> (น้ำหนัก, เวลาที่เริ่มมี)
    for (int i = 0; i < m; i++) {
        int x, y, w;
        cin >> x >> y >> w;
        open[{x, y}] = {w, 0};
    }
    cin >> q;
    // เวลา t = 0..q-1 คือคำสั่งลำดับที่ t เส้นที่ถูกเพิ่มที่เวลา t มีผลตั้งแต่ t
    // เส้นที่ถูกลบที่เวลา t มีผลถึง t-1
    for (int t = 0; t < q; t++) {
        int type, x, y;
        cin >> type >> x >> y;
        if (type == 1) {
            int w;
            cin >> w;
            open[{x, y}] = {w, t};
        } else if (type == 2) {
            auto it = open.find({x, y});
            if (it->second.second <= t - 1)
                addRange(1, 0, q - 1, it->second.second, t - 1, {x, y, it->second.first});
            open.erase(it);
        } else {
            qx[t] = x; qy[t] = y;
        }
    }
    for (auto& [key, val] : open)
        addRange(1, 0, q - 1, val.second, q - 1, {key.first, key.second, val.first});
    for (int i = 1; i <= n; i++) { par[i] = i; sz[i] = 1; }
    dfs(1, 0, q - 1, 0);  // basis[0] เริ่มว่างทั้งแถว
}

ตัวตรวจทำ BFS บนสถานะ 20,000 รอบบนน้ำหนักไม่เกินสามบิต 40 รอบขนาดกลาง และ 5,000 รอบที่คูณน้ำหนักด้วย 2²⁷ เพื่อทดสอบบิตบนสุด ตรงกันทุกรอบ เคสใหญ่สุดใช้ 0.33 วินาทีจากเวลาที่ให้ 3.5 วินาที


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

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

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

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

  1. cp-algorithms, "Deleting from a data structure in O(T(n) log n)" ต้นทางของบทนี้และสามโจทย์ฝึก (ข้อ 2, 4, 6) cp-algorithms.com
  2. โจทย์: CSES 2133 · Codeforces Gym 100551A, 813F, 1140F, 1423H, 981E, 601E, 891C, 707D, 938G (สืบค้น 10 กันยายน 2026)
  3. ในคลังนี้: ดิสจอยต์เซต คือบทที่ต้องอ่านก่อน และ ต้นไม้ช่วง คือที่มาของ addRange