ปูพื้นฐาน
รวมกลุ่มก็ได้ ถามว่าอยู่กลุ่มเดียวกันไหมก็ได้ แต่ทำแบบซื่อ ๆ แล้วต้นไม้กลายเป็นโซ่ยาวเหยียด บทนี้เล่าว่าทำไมสองกลเล็ก ๆ ถึงทำให้มันเร็วจนแทบเป็นค่าคงที่ มีเกมให้ลองรวมกลุ่มโดยไม่ให้ต้นไม้สูง ตัวเดินที่ดูการบีบทางเดินทีละจังหวะ และโจทย์ฝึก 8 ข้อที่ดัด DSU ไปจับคู่คี่ ย้อนเวลา และรวมเล็กเข้าใหญ่
ลองนึกถึงกลุ่มเพื่อนที่ค่อย ๆ ก่อตัวขึ้นทีละคู่ วันนี้ต้นรู้จักแนน พรุ่งนี้แนนพาเก่งมาเจอ อีกวันเก่งไปสนิทกับกลุ่มของฝ้าย ผ่านไปไม่กี่สัปดาห์ก็มีคนถามว่า "ต้นกับฝ้ายอยู่ก๊วนเดียวกันหรือเปล่า" คนสองคนนี้อาจไม่เคยคุยกันเลย แต่เชื่อมถึงกันผ่านคนกลางหลายทอด
ถ้าตอบด้วยการเดินกราฟ (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 ภาพข้างล่างคืออาร์เรย์แถวเดียวกับป่าของต้นไม้ที่มันแทน
เท่านี้ก็เขียนโค้ดได้แล้ว find(x) เดินตาม par ขึ้นไปจนเจอหัว
ส่วน unite(a, b) ก็หาหัวของทั้งสองฝั่ง แล้วให้หัวฝั่งหนึ่งไปขึ้นกับอีกฝั่ง
ก๊วนทั้งก๊วนย้ายตามไปเองโดยไม่ต้องแตะสมาชิกคนอื่นเลย เพราะทุกคนในก๊วนนั้นเดินขึ้นไปก็ต้องผ่านหัวคนเดิมอยู่ดี
// แบบซื่อ ๆ: ถูกต้อง แต่ต้นไม้อาจกลายเป็นโซ่ยาว 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 ลองดูว่าเพราะอะไร
พอเห็นแล้วว่าเลือกแบบไหนเตี้ย ลองหากฎที่ใช้ได้ทุกครั้งโดยไม่ต้องคิดเป็นกรณี แล้วค่อยเลื่อนลงไปดู
ภาพข้างล่างคือคำสั่งชุดเดียวกัน unite(1, 2), unite(1, 3) ไปจนถึง unite(1, 8) ทางซ้ายใช้กติกาของโค้ดข้างบน ทางขวาใช้กติกาเดียวที่เพิ่มเข้ามา
คือก๊วนที่คนน้อยกว่าเป็นฝ่ายไปขึ้นกับก๊วนที่คนมากกว่า
กติกานี้ชื่อ union by size (รวมตามขนาด) ต้องเก็บอาร์เรย์เพิ่มอีกแถวคือ sz
ไว้ที่หัวของแต่ละก๊วน ว่าก๊วนนี้มีกี่คน ทำไมมันถึงคุมความลึกได้ ดูจากมุมของคนคนเดียว
คนคนหนึ่งจะลึกลงหนึ่งชั้นก็ต่อเมื่อก๊วนของเขาเป็นฝ่ายไปขึ้นกับก๊วนอื่น และตามกติกา
ก๊วนที่เขาไปขึ้นด้วยใหญ่อย่างน้อยเท่าก๊วนของเขาเอง ก๊วนใหม่ที่เขาอยู่จึงใหญ่ขึ้นอย่างน้อยเท่าตัว
ทุกครั้งที่เขาลึกลง ก๊วนเริ่มจากคนเดียว และใหญ่ได้ไม่เกิน n คน
จึงเพิ่มเท่าตัวได้ไม่เกิน log₂ n ครั้ง ความลึกของทุกคนจึงไม่เกิน log₂ n
ขอบนี้ไม่ได้หลวม ถ้าจับคู่ก๊วนขนาดเท่ากันรวมกันเป็นชั้น ๆ แบบชุด ข ในเกม คนที่ลึกสุดจะลึกพอดี log₂ n ผมตรวจทั้งสองด้านตอนสร้างหน้านี้ ที่ n = 1024 การจับคู่แบบนั้นลึก 10 ชั้นพอดี ส่วนการรวมแบบสุ่ม 200 รอบ ลึกสุดแค่ 6 ชั้น ไม่มีรอบไหนเกิน 10
อีกแบบที่ใช้กันมากคือ union by rank ให้หัวจำความสูงของต้นไม้แทนจำนวนคน
คำว่า rank อ่านว่า "แรงก์" แปลว่า "ยศ" หรืออันดับ
ยศใครต่ำกว่าคนนั้นไปขึ้นกับอีกฝ่าย ได้ขอบ log₂ n เท่ากัน
บทนี้ใช้ขนาดเพราะโจทย์ส่วนใหญ่อยากรู้ขนาดของก๊วนอยู่แล้ว เก็บครั้งเดียวได้ใช้สองต่อ
กลแรกคุมความลึกไว้ที่ log₂ n กลที่สองใช้ประโยชน์จากงานที่ find ทำไปแล้ว
ตอนเดินขึ้นไปหาหัว เราผ่านคนหลายคน และได้รู้ว่าทุกคนบนทางนั้นมีหัวคนเดียวกัน
ก็ให้ทุกคนบนทางชี้ตรงไปที่หัวเลย ครั้งหน้าจะได้ไม่ต้องเดินซ้ำ
กลนี้ชื่อ path compression อ่านว่า "พาธ คอมเพรสชัน" path คือทางเดิน compression คือการบีบอัด คำเดียวกับที่ใช้เรียกการบีบไฟล์ให้เป็น zip
ในโค้ดใช้แค่บรรทัดเดียว คือเปลี่ยนการเดินให้เป็นการเรียกตัวเอง แล้วเอาคำตอบที่ได้กลับมาเขียนทับ par
ของตัวเองระหว่างทางกลับ
ตัวเดินข้างล่างคือต้นไม้เดียวกับในภาพ เดินตามลำดับที่โค้ดเรียกตัวเองทำจริง
ช่วงแรกลงไปถามทีละชั้นจนถึงหัว ช่วงหลังคำตอบถูกส่งกลับขึ้นมา แล้วแต่ละคนเขียนคำตอบนั้นทับ par ของตัวเอง
find(7) พร้อมบีบทางเดิน
| คน | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|---|
| par | 1 | 1 | 2 | 2 | 3 | 3 | 5 | 1 |
| ลึก | 0 | 1 | 2 | 2 | 3 | 3 | 4 | 1 |
สังเกตว่าบีบทางเดินไม่ได้ย้ายใครออกจากก๊วน หัวยังเป็นคนเดิม ขนาดของก๊วนก็เท่าเดิม มันแก้แค่ทางเดิน ซึ่งไม่มีใครสนใจอยู่แล้วว่าเป็นทางไหน ขอแค่ไปถึงหัวคนเดิม
ตารางนี้วัดสี่แบบบนอินพุตรูปเดียวกันทุกแถว คือรวมทุกคนเข้ากับคนที่ 1 ทีละคน
แล้วถาม find อีก n ครั้ง รูปนี้คือรูปที่ทำให้แบบซื่อกลายเป็นโซ่
| 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 |
ในทางทฤษฎี ถ้าใช้สองกลพร้อมกัน ต้นทุนเฉลี่ยต่อหนึ่งคำสั่งคือ 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: รวมตามขนาด + บีบทางเดิน ลอกไปใช้ได้ทุกข้อ
#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 ของ cp-algorithms ส่วนที่เหลือผมเลือกเพิ่มให้ครบมุม
เมืองในประเทศหนึ่งยังไม่มีถนนเลย ทุกวันจะมีถนนใหม่หนึ่งเส้น หลังจบแต่ละวันให้บอกว่าตอนนี้ประเทศแตกเป็นกี่ก๊วน และก๊วนที่ใหญ่ที่สุดมีกี่เมือง
อินพุต / ขอบเขต / เอาต์พุต
n m แล้วถนน m เส้น เส้นละ a bn ≤ 10⁵, m ≤ 2·10⁵ เวลา 1 วินาทีm บรรทัด แต่ละบรรทัดคือจำนวนก๊วนกับขนาดของก๊วนใหญ่สุด| Input | Output |
|---|---|
| 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 เมือง
ใบ้
จำนวนก๊วนไม่ต้องนับใหม่ทุกวัน ถนนเส้นหนึ่งเปลี่ยนจำนวนก๊วนได้แค่สองแบบ ส่วนก๊วนใหญ่สุด ลองถามว่ามันลดลงได้ไหม ถ้าไม่ได้ วันไหนบ้างที่มันมีโอกาสเปลี่ยน
ที่มาของแนวคิดนี้
ข้อนี้ไม่มีช่วงลองผิดลองถูก ถนนมีแต่เพิ่มไม่มีลบ และคำถามคือใครอยู่ก๊วนเดียวกับใคร ซึ่งเป็นรูปของ DSU ตรงตัว สิ่งที่ต้องคิดจริงคือทางที่ตีราคาแล้วทิ้ง
ทางแรกคือเดินกราฟใหม่ทุกวัน วันละ n + m ก้าว คูณ m วันได้ 6·10¹⁰
ทางที่สองคือใช้ DSU แต่หลังรวมเสร็จไล่ดู sz ของทุกหัวเพื่อหาค่ามากสุด
วันละ n คูณ m วันยังเป็น 2·10¹⁰ ตัวเลขนี้เองที่บังคับให้คิดต่อว่า
ค่ามากสุดเปลี่ยนได้ตอนไหนบ้าง
บทเรียนที่หยิบไปใช้ต่อได้คือ ก่อนจะคำนวณค่าใหม่ทั้งหมด ให้ถามว่าเหตุการณ์หนึ่งครั้งเปลี่ยนค่านั้นได้แค่ไหน
จำนวนก๊วนเริ่มที่ n ถนนที่ unite คืน true ลดมันลงหนึ่ง
ถนนที่คืน false คือถนนที่สองปลายถึงกันอยู่แล้ว ไม่ลดอะไร
ส่วนก๊วนใหญ่สุด ไม่มีการลบถนน ก๊วนจึงไม่เคยเล็กลง ค่ามากสุดไม่มีวันลด และจะเพิ่มได้ก็เฉพาะวันที่เกิดการรวม
ซึ่งก๊วนที่เพิ่งรวมเป็นก๊วนเดียวที่ขนาดเปลี่ยน จึงเทียบ best กับ sz ของหัวใหม่แค่ครั้งเดียว
ประเทศหนึ่งมี n เมือง ถนน n - 1 เส้นพอดี แต่ถนนชุดนี้ยังไม่ได้ทำให้ทุกเมืองถึงกัน
รัฐบาลไม่มีงบสร้างถนนเพิ่มเฉย ๆ วันหนึ่งทำได้อย่างเดียว คือปิดถนนเดิมหนึ่งเส้นแล้วสร้างเส้นใหม่แทนทันที
ให้หาจำนวนวันที่น้อยที่สุด พร้อมแผนว่าแต่ละวันปิดเส้นไหน สร้างเส้นไหน
อินพุต / ขอบเขต / เอาต์พุต
n แล้วถนน n - 1 เส้น ไม่มีเส้นซ้ำ ไม่มีเส้นวนเข้าเมืองตัวเอง2 ≤ n ≤ 1000t แล้ว t บรรทัด i j u v คือปิดถนน i-j สร้าง u-v ถ้ามีหลายแผนตอบแผนไหนก็ได้| Input | Output |
|---|---|
| 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
ซึ่งก็ถูกเหมือนกัน
ใบ้
เริ่มจากขอบล่าง ถ้าตอนนี้มี k ก๊วน วันหนึ่งสร้างถนนได้เส้นเดียว ต้องใช้อย่างน้อยกี่วัน
แล้วถามต่อว่าจะมีถนนให้ปิดโดยไม่ทำให้ก๊วนแตกพอหรือเปล่า ลองนับจากจำนวนถนน n - 1 เส้น
ที่มาของแนวคิดนี้
สิ่งแรกที่ผมคิดไม่ใช่ว่าจะปิดเส้นไหน แต่เป็นว่าอย่างน้อยต้องกี่วัน ถ้ามี k ก๊วน
ถนนใหม่หนึ่งเส้นลดจำนวนก๊วนได้มากสุดหนึ่ง การปิดถนนมีแต่ทำให้ก๊วนเท่าเดิมหรือเพิ่ม
จึงต้องใช้อย่างน้อย k - 1 วัน
ข้อที่ทำให้มั่นใจว่า k - 1 วันพอเสมอคือการนับ ก๊วน k ก๊วนที่มี n เมือง
ใช้ถนนที่เชื่อมจริงพอดี n - k เส้น ถนนที่เหลือซึ่งไม่ได้เชื่อมอะไรใหม่จึงมี (n - 1) - (n - k) = k - 1 เส้น เท่ากับจำนวนวันที่ต้องใช้พอดี
ทางที่ตีราคาแล้วไม่เอาคือหาวงรอบด้วย DFS แล้วเลือกเส้นย้อนกลับ ทำได้เหมือนกันและ n ≤ 1000
เล็กมาก ที่ไม่เอาเพราะต้องเขียนสองขั้น ส่วน DSU ได้ทั้งเส้นเกินและก๊วนจากการอ่านรอบเดียว
บทเรียนคือ ในโจทย์ที่ถามว่า "อย่างน้อยกี่ครั้ง" ลองหาขอบล่างก่อนหาวิธี บ่อยครั้งขอบล่างนั้นเองคือคำตอบ
อ่านถนนทีละเส้นแล้ว unite เส้นที่ได้ false คือเส้นที่สองปลายถึงกันแล้วด้วยเส้นก่อนหน้า
ปิดทิ้งได้ฟรี อ่านครบแล้วเก็บหัวของทุกก๊วน จากนั้นใช้เส้นเกินเส้นที่ i
ต่อหัวของก๊วนแรกเข้ากับหัวของก๊วนที่ i + 1
แต่ละก๊วนถูกต่อเข้ากับก๊วนแรกครั้งเดียว ก๊วนทั้งหมดจึงเรียงเป็นรูปดาวที่ไม่มีวงรอบ และถนนใหม่ไม่มีทางซ้ำถนนเดิม เพราะสองปลายของมันอยู่คนละก๊วนตั้งแต่ต้น
ถนนทุกเส้นในประเทศพังหมด แต่ละเส้นมีค่าซ่อมของมันเอง ให้เลือกซ่อมบางเส้นให้ทุกเมืองเดินถึงกันได้
โดยจ่ายรวมน้อยที่สุด ถ้าซ่อมทุกเส้นแล้วยังไม่ถึงกันหมด ให้ตอบ IMPOSSIBLE
อินพุต / ขอบเขต / เอาต์พุต
n m แล้วถนน m เส้น เส้นละ a b c คือค่าซ่อม cn ≤ 10⁵, m ≤ 2·10⁵, c ≤ 10⁹ เวลา 1 วินาทีIMPOSSIBLE| Input | Output |
|---|---|
| 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
ใบ้
ลองเรียงถนนจากถูกไปแพงแล้วหยิบทีละเส้น ถนนเส้นไหนที่หยิบแล้วไม่ได้ช่วยให้ใครถึงกันเพิ่ม และจะรู้ได้เร็วแค่ไหนว่าเส้นนั้นไม่ช่วย
ที่มาของแนวคิดนี้
เลือกเส้นให้ทุกจุดถึงกันด้วยราคารวมต่ำสุด คือโจทย์ต้นไม้แผ่ทั่วที่เบาที่สุด
(minimum spanning tree) ข้อนี้ผมไม่ได้ลองอะไรผิดก่อน ที่ต้องตัดสินใจจริงมีสองเรื่อง
คือจะใช้วิธีไหน กับผลรวมจะล้น int หรือเปล่า
เรื่องหลังคิดก่อนเขียน ต้นไม้มี n - 1 เส้น เส้นละได้ถึง 10⁹
รวมได้ราว 10¹⁴ จึงใช้ long long ตั้งแต่แรก แล้วทำเคสที่ทุกเส้นราคา 10⁹ ไว้ยืนยัน ได้ 99999000000000 บทเรียนคือ คูณขอบบนของคำตอบให้เสร็จก่อนเลือกชนิดตัวแปร
วิธีนี้ชื่อ Kruskal อ่านว่า "ครัสคัล" ตามชื่อ Joseph Kruskal ที่ตีพิมพ์ในปี 1956 เรียงถนนจากถูกไปแพง แล้วถาม DSU ทีละเส้นว่าสองปลายอยู่ก๊วนเดียวกันหรือยัง ถ้ายัง ซ่อม ถ้าอยู่แล้ว ข้าม
เส้นที่ข้ามได้ปลอดภัย เพราะตอนที่ไล่มาถึง สองปลายถึงกันแล้วด้วยถนนที่ถูกกว่าหรือเท่ากันทั้งหมด
ซ่อมเพิ่มไม่ได้ทำให้ใครถึงกันเพิ่ม ส่วน IMPOSSIBLE ไม่ต้องเดินกราฟแยก
ถ้าไล่ครบแล้วซ่อมได้ไม่ถึง n - 1 เส้น แปลว่ามีก๊วนเหลือมากกว่าหนึ่ง
ใยแมงมุมมีปม N ปม เส้นใย M เส้น ศัตรูวางแผนไว้แล้วว่าจะฉีกเส้นไหนบ้างตามลำดับไหน
หลังการฉีกแต่ละครั้ง ให้บอกว่าใยขาดเป็นกี่ชิ้น
อินพุต / ขอบเขต / เอาต์พุต
N M แล้วเส้นใย M เส้น (เส้นที่ i คือบรรทัดที่ i) แล้ว Q กับหมายเลขเส้นที่ฉีกเรียงตามลำดับ ไม่ซ้ำกันN, M ≤ 10⁵, Q ≤ M เวลา 1 วินาทีQ จำนวนในบรรทัดเดียว คือจำนวนชิ้นหลังการฉีกแต่ละครั้ง| Input | Output |
|---|---|
| 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 ได้ จึงยังเป็นชิ้นเดียว
ใบ้
DSU รวมได้อย่างเดียว แยกไม่ได้ แต่ข้อนี้ให้แผนการฉีกมาครบทั้งหมดก่อนต้องตอบอะไรเลย และไม่มีเส้นไหนถูกต่อกลับ ลองดูภาพจากขวาไปซ้าย ถ้าเดินทางนั้น แต่ละก้าวเกิดอะไรขึ้นกับใย
ที่มาของแนวคิดนี้
ทางแรกที่ตัดทิ้งก่อนเขียนคือฉีกแล้วนับชิ้นใหม่ด้วย 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 ที่ย้อนการรวมล่าสุดได้ บวกกับการจัดว่าเส้นแต่ละเส้นมีชีวิตอยู่ช่วงไหน ซึ่งเป็นเรื่องทั้งบทของต้นไม้บนแกนเวลา
เพื่อนนึกสตริงของ 0 กับ 1 ไว้ในใจ เราถามได้ว่าตั้งแต่ตำแหน่ง l ถึง r
มีเลข 1 เป็นจำนวนคู่หรือคี่ เพื่อนตอบมาทีละข้อ แต่เราสงสัยว่าเพื่อนโกหก
ให้หาว่าคำตอบข้อไหนเป็นข้อแรกที่ขัดกับข้อก่อน ๆ จนไม่มีสตริงไหนเป็นไปได้
อินพุต / ขอบเขต / เอาต์พุต
l r even หรือ l r odd ไฟล์จบด้วย -110⁹ คำถามไม่เกิน 5000 ข้อต่อชุด เวลา 2 วินาที| Input | Output |
|---|---|
| 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 เป็นคี่" พูดถึงค่าสะสมที่ตำแหน่งไหนบ้าง ถ้าแต่ละคำถามคือเส้นเชื่อมสองจุด การโกหกหน้าตาเป็นแบบไหน
ที่มาของแนวคิดนี้
สองทางแรกทิ้งเพราะตัวเลข ลองทุกสตริงคือ 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
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 ตัวเดียวกับบีบทางเดิน แต่คราวนี้บีบเลขตำแหน่ง
ทัวร์นาเมนต์มีอัศวิน n คน สู้กัน m นัด นัดหนึ่งบอกช่วงหมายเลข [l, r]
กับผู้ชนะ x อัศวินทุกคนในช่วงนั้นที่ยังไม่ตกรอบลงสนาม แล้วทุกคนยกเว้น x ตกรอบไป ให้บอกว่าอัศวินแต่ละคนแพ้ให้ใคร แชมป์ให้ตอบ 0
อินพุต / ขอบเขต / เอาต์พุต
n m แล้ว m นัด นัดละ l r xn, m ≤ 3·10⁵ เวลา 3 วินาทีn ตัว ตัวที่ i คือคนที่ชนะอัศวิน i| Input | Output |
|---|---|
| 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 ชนะนัดสุดท้าย
ใบ้
คนที่ตกรอบแล้วไม่กลับมาอีก การตกรอบจึงเกิดรวมกันไม่เกิน n - 1 ครั้งทั้งทัวร์นาเมนต์
ที่ช้าจริงคือการเดินผ่านช่องสีทองซ้ำแล้วซ้ำอีก ถ้ามีอะไรสักอย่างบอกได้ทันทีว่า "คนที่ยังอยู่ถัดไปทางขวาคือใคร"
งานจะเหลือเท่าไร
ผมแก้ถูกตั้งแต่แรก แต่มันพังตอนวัดเวลา
รุ่นแรกใช้ 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) แบบเฉลี่ย
ต้นไม้มีราก n จุด จุด 1 เป็นราก ทุกจุดมีสีของตัวเอง ให้บอกว่าต้นย่อยของแต่ละจุดมีสีต่างกันกี่สี
ต้นย่อยของ v คือ v กับทุกจุดที่อยู่ใต้มัน
อินพุต / ขอบเขต / เอาต์พุต
n สีของทุกจุด แล้วเส้นเชื่อม n - 1 เส้นที่ไม่บอกทิศn ≤ 2·10⁵, สีไม่เกิน 10⁹ เวลา 1 วินาทีn จำนวน ตัวที่ v คือจำนวนสีต่างกันในต้นย่อยของ v| Input | Output |
|---|---|
| 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 ของบทนี้ ควรเทก้อนไหนใส่ก้อนไหน
ที่มาของแนวคิดนี้
ท่าตรง ๆ คือทุกจุดเดินลงไปทั้งต้นย่อยแล้วนับสีด้วย set งานรวมคือผลรวมขนาดของทุกต้นย่อย
บนต้นไม้โซ่ได้ราว n²/2 = 2·10¹⁰ ท่าถัดมาคือเทเซตของลูกขึ้นไปใส่พ่อ ซึ่งไม่ต้องเดินซ้ำ
แต่ถ้าเทตรง ๆ ทุกครั้ง บนโซ่ลูกที่ลึกกว่าถือสีเกือบทั้งหมด เทขึ้นหนึ่งชั้นก็ย้ายเกือบทั้งก้อนอีกรอบ กลับไปเป็น n²/2 เหมือนเดิม
ท่าที่ใช้ผมรู้จักอยู่ก่อนแล้ว จึงเขียนแบบสลับถังตั้งแต่แรก ตารางข้างล่างวัดทีหลังเพื่อให้เห็นเป็นตัวเลข โดยแก้บรรทัดเดียวให้เทก้อนใหญ่ใส่ก้อนเล็กแทน บนโซ่ที่ทุกจุดสีต่างกัน บทเรียนคือ เหตุผลของรวมตามขนาดไม่ได้ผูกกับ DSU มันใช้ได้กับการรวมอะไรก็ตามที่ต้นทุนเท่าขนาดของฝั่งที่ย้าย
กติกาคือเทก้อนที่เล็กกว่าใส่ก้อนที่ใหญ่กว่าเสมอ ถ้าถังของพ่อเล็กกว่าถังของลูก ก็สลับถังกันก่อน
ซึ่ง swap ของ std::set แค่สลับตัวชี้ ใช้เวลาคงที่
ท่านี้ชื่อ small-to-large อ่านว่า "สมอล ทู ลาร์จ" แปลตรงตัวว่า "เล็กเข้าใหญ่"
เหตุผลที่เร็วเป็นประโยคเดียวกับกลที่ 1 ทุกคำ สีหนึ่งค่าจะถูกหยิบย้ายก็ต่อเมื่ออยู่ในก้อนเล็ก
และหลังย้ายมันอยู่ในก้อนที่ใหญ่อย่างน้อยเท่าตัวของก้อนเดิม ขนาดเพิ่มเท่าตัวได้ไม่เกิน log₂ n ครั้ง
งานรวมจึงเป็น O(n log n) ครั้งของการใส่ แต่ละครั้ง O(log n)
| 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 นาที |
อีกจุดที่ต้องระวังคือโซ่ลึกสองแสนชั้น DFS แบบเรียกตัวเองเสี่ยงสแต็กล้นแบบเดียวกับข้อ 6
โค้ดจึงเรียงจุดแบบ BFS จากราก แล้วไล่จากท้ายแถว ลูกจึงถูกทำก่อนพ่อเสมอโดยไม่ต้องเรียกตัวเองเลย
และต้องอ่านคำตอบของ v ก่อนเทใส่พ่อ เพราะหลังสลับ ถังของ v อาจเป็นถังเดิมของพ่อไปแล้ว
สัตว์มีสามชนิด A กิน B, B กิน C และ C กิน A มีสัตว์ n ตัว แต่ละตัวเป็นหนึ่งในสามชนิดแต่เราไม่รู้ว่าตัวไหนชนิดอะไร
มีคนพูดประโยคมาทีละประโยค 1 x y แปลว่า x กับ y ชนิดเดียวกัน 2 x y แปลว่า x กิน y
ประโยคหนึ่งถือว่าโกหกถ้ามีสัตว์หมายเลขเกิน n ถ้าบอกว่าสัตว์กินตัวเอง
หรือถ้าขัดกับประโยคที่จริงก่อนหน้า ให้นับประโยคโกหก
อินพุต / ขอบเขต / เอาต์พุต
t แต่ละชุดมี n k แล้ว k ประโยค บรรทัดละ D X Yn ≤ 50,000, k ≤ 10⁵ เวลา 1 วินาที| Input | Output |
|---|---|
| 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 ต้องเปลี่ยนเป็นอะไร
ที่มาของแนวคิดนี้
ก่อนคิดอะไร ต้องได้ข้อความโจทย์ก่อน หน้า 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 | จริง | ตัวเดียวกันย่อมเป็นชนิดเดียวกัน |
DSU ถามแค่ว่าใครคือหัวหน้า รวมตามขนาดทำให้คำถามนี้เดินไม่เกิน log₂ n ชั้น
บีบทางเดินทำให้ครั้งต่อไปเดินแทบไม่ต้องเดิน และทุกอย่างที่คำนวณจากสองก๊วนได้ก็เก็บไว้ที่หัวได้
โครงสร้างนี้เก่งทางเดียว คือรวม ถ้าวันไหนโจทย์สั่งให้แยกก๊วนออกจากกัน มันทำไม่ได้เลย และนั่นคือจุดเริ่มของบทถัดไป
ในหน้านี้