ปูพื้นฐาน
พอรู้ว่าแต่ละกลุ่มคือวงหนึ่งวงที่มีต้นไม้ห้อยอยู่ คำถามเรื่องเดินไกลเป็นพันล้านก้าวก็กลายเป็นการหารเอาเศษ บทนี้ให้ท่าปอกใบทิ้งที่สั้นกว่าและพลาดยากกว่าการไล่หาวงตรง ๆ
โจทย์บางข้อให้ข้อมูลมาในรูป "แต่ละอันชี้ไปอีกอันเดียว" เช่น เกาะแต่ละเกาะมีสะพานออกไปเส้นเดียว คนแต่ละคนส่งของต่อให้อีกคนเดียว หรือแต่ละตำแหน่งกระโดดไปตำแหน่งเดียว กราฟแบบนี้เรียกว่า กราฟฟังก์ชัน (functional graph)
แกะคำศัพท์
functional graph อ่านว่า "ฟังก์ชันนัล กราฟ" แปลตรงตัวว่า
กราฟที่มาจากฟังก์ชัน ชื่อนี้ตรงมาก เพราะข้อมูลที่โจทย์ให้คือ f(i)
ซึ่งเป็นฟังก์ชันจากปมไปหาปม ปมหนึ่งปมจึงมีลูกศรออกได้เส้นเดียวเท่านั้น
เหมือนที่ฟังก์ชันหนึ่งอินพุตให้ค่าออกมาค่าเดียว
ข่าวดีคือกราฟแบบนี้มีรูปร่างตายตัว พอรู้รูปแล้ว โจทย์ที่ดูยากจะกลายเป็นเลขคณิตธรรมดา บทนี้จะให้รูปนั้น กับท่ามาตรฐานสองท่าที่ใช้กับมันได้เกือบทุกข้อ
เดินตามลูกศรจากปมไหนก็ตาม เนื่องจากปมมีจำนวนจำกัด เราต้องเหยียบปมเดิมซ้ำสักวัน วันที่เหยียบซ้ำคือวันที่เข้าวง ดังนั้น
แต่ละกลุ่มของกราฟฟังก์ชันมีวงพอดีหนึ่งวง และที่เหลือคือต้นไม้ที่ห้อยลงมาจากปมบนวง
ท่าที่คนคิดออกก่อนคือเดินตามลูกศรแล้วทำเครื่องหมาย แต่มันเขียนยากกว่าที่คิด เพราะต้องแยกให้ออกว่า ปมที่เจอซ้ำเป็นปมของรอบนี้หรือรอบก่อน ท่าที่สั้นกว่าและพลาดยากกว่าคือ ปอกใบทิ้ง
นับว่าแต่ละปมมีลูกศรชี้เข้ากี่เส้น ปมที่ไม่มีใครชี้เข้าเลยคือใบ ตัดทิ้งแล้วลดตัวนับของปมที่มันชี้ไป ทำซ้ำจนไม่มีใบเหลือ สิ่งที่ปอกไม่ออกคือปมบนวงพอดี เพราะปมบนวงมีเพื่อนบนวงชี้เข้าหาเสมอ
| ปม | ชี้ไป | ปอกออกไหม | อยู่บนวงไหม |
|---|---|---|---|
| 1 | 3 | ปอกออก | |
| 2 | 3 | ปอกออก | |
| 3 | 4 | ปอกไม่ออก | ใช่ |
| 4 | 5 | ปอกไม่ออก | ใช่ |
| 5 | 3 | ปอกไม่ออก | ใช่ |
| 6 | 7 | ปอกไม่ออก | ใช่ |
| 7 | 6 | ปอกไม่ออก | ใช่ |
| 8 | 2 | ปอกออก |
เคล็ดลับ
เก็บคิวเป็นเวกเตอร์เดียวโดยไม่ป็อปทิ้ง ใช้ตัวชี้หัวไล่ไปข้างหน้าแทน จะได้ของฟรีสองอย่างคือ มันเป็นคิวอยู่แล้ว และเวกเตอร์ที่เหลือค้างไว้คือลำดับทอพอโลยีของต้นไม้ที่ห้อยอยู่ ซึ่งเอาไปแจกข้อมูลจากรากลงใบต่อได้เลยโดยไม่ต้องเรียกซ้ำ
หกปม มีหางห้อยอยู่หน่อยหนึ่ง ลองเดินดูก่อน
เลือกคำถาม
กดเดินทีละก้าว หรือถ้าเห็นวงแล้วก็ข้ามไปตอบเลย
คำถาม · ตอนนี้อยู่ปม 1 · เหลืออีก 0 ก้าว
ช่องที่ระบายไว้คือปมที่อยู่บนวง เข้าไปแล้วออกไม่ได้อีก
พอเจอคำถามที่ขอเป็นพันก้าว การกดทีละก้าวจะเริ่มไร้เหตุผล ลองคิดว่าอะไรที่ทำให้ข้ามทีเดียวหลายก้าวได้
เมื่อรู้รูปแล้ว คำถามว่า "จากปม v เดิน k ก้าวไปโผล่ที่ไหน"
ก็แตกเป็นสองกรณีที่ตอบได้ทันที
k ไม่เกินระยะจาก v ถึงวง คำตอบคือบรรพบุรุษลำดับที่ k ของ
v ซึ่งอ่านจากเส้นทางที่เราถืออยู่ตอนเดินต้นไม้ได้เลย
k เกินระยะนั้น ก็หักระยะออกก่อน ที่เหลือคือการวนอยู่บนวง ซึ่งเป็นแค่การหารเอาเศษ
ด้วยความยาววง
หลายคนจะนึกถึงตารางกระโดดที่เก็บว่าเดิน 1, 2, 4, 8 ก้าวไปไหน ซึ่งใช้ได้เหมือนกัน
แต่กินหน่วยความจำ n log k ที่ n เป็นล้านและ k ถึง 10^18
ก็คือหกสิบเท่าของอาเรย์ ซึ่งมักเกินลิมิต ส่วนท่าข้างบนใช้หน่วยความจำแค่ไม่กี่อาเรย์
ให้ f ของทุกปมและเลข k ตอบว่าแต่ละปมเดิน k ก้าวแล้วไปอยู่ที่ปมไหน
อินพุต / ขอบเขต / เอาต์พุต
n และ k บรรทัดที่สองคือ f ของปม 1 ถึง nn ≤ 1,000,000, k ≤ 10^18| Input | Output |
|---|---|
| 8 5 3 3 4 5 3 7 6 2 | 4 4 5 3 4 7 6 3 |
อ่านตัวอย่างนี้ยังไง
บรรทัดแรกคือจำนวนปมกับจำนวนก้าว บรรทัดที่สองคือ f ของปม 1 ถึง 8
อ่านว่าตัวที่ i ในบรรทัดนี้คือปมที่ปม i ชี้ไป เช่นตัวแรกเป็น 3
แปลว่าปม 1 ชี้ไปปม 3 ทุกปมมีลูกศรออกพอดีหนึ่งเส้นเสมอ ซึ่งเป็นนิยามของกราฟแบบนี้
เอาต์พุตคือปลายทางของทุกปม เรียงตามหมายเลข ไม่ใช่ของปมเดียว เช่นปม 1 เดิน 5 ก้าวได้เส้นทาง 1 ไป 3 ไป 4 ไป 5 ไป 3 ไป 4 จึงจบที่ปม 4
ที่ต้องระวังคือ k ของจริงใหญ่ถึงสิบยกกำลังสิบแปด การเดินทีละก้าวแบบตัวอย่างนี้
จึงเป็นแค่วิธีอ่านโจทย์ ไม่ใช่วิธีแก้
เรื่องจริงที่เกิดขึ้นตามลำดับ
เริ่มจากว่าทำไมถึงเลือกปอกใบทิ้ง แทนที่จะเดินตามลูกศรไปเรื่อย ๆ แล้วทำเครื่องหมาย ท่าเดินตามลูกศรเป็นท่าที่คิดออกก่อน แต่พอเดินไปเจอปมที่เคยเจอแล้ว ต้องแยกให้ออกว่ามันเป็นปมของ รอบเดินนี้ หรือของรอบก่อนหน้า ซึ่งต้องเพิ่มสถานะมาคุมอีกชั้นและพลาดง่าย การปอกปมที่ไม่มีลูกศรชี้เข้ามาทิ้งไปเรื่อย ๆ ไม่ต้องเจอคำถามนั้นเลย ของที่เหลือหลังปอกจนหมดคือปมบนวงพอดี
ทีนี้บักจริงที่ผมทำในโค้ดข้อนี้ ผมคำนวณระยะจากปมบนวงลงมาถึงตัวเอง ก่อนที่จะผลักตัวเองเข้าสแต็ก
ผลคือพอ k เป็นศูนย์ มันไปอ่านเลยขอบอาเรย์ ตัวสุ่มเทียบ 3,000 ชุดจับได้ทันทีที่ k = 0 โผล่มาเป็นชุดแรก
ผลที่ปริ๊นต์ออกมาคือ 1 363 3 แทนที่จะเป็น 1 2 3
เลข 363 คือของที่บอกว่าเกิดอะไรขึ้น มันไม่ใช่คำตอบที่ผิดนิดหน่อย มันคือขยะจากหน่วยความจำที่ไม่ใช่ของเรา และบักประเภทนี้ตอบถูกได้เกือบทุกชุด จนกว่าจะเจอค่าขอบพอดี
บทเรียนที่ยกไปข้ออื่นได้คือ ตัวสุ่มเทียบต้องสุ่มค่าขอบเข้าไปด้วยจริง ๆ ให้ k แตะศูนย์ได้ ให้ N เป็นหนึ่งได้ ถ้าตัวสุ่มเลี่ยงค่าขอบไว้เพื่อความเรียบร้อย มันจะไม่มีวันจับบักแบบนี้ได้เลย
ปอกใบทิ้งเพื่อหาปมบนวง แล้วสำหรับปมบนวงแต่ละตัวก็เดินต้นไม้ที่ห้อยอยู่ โดยถือเส้นทางจากปมบนวง
ลงมาถึงตัวเองไว้ในสแต็ก ปมที่อยู่ลึก d ที่มี k ≤ d
ตอบได้ทันทีจาก path[d − k] ส่วนที่เหลือก็หารเอาเศษบนวง
// เดินตามลูกศร k ก้าวจากทุกปม โดย k ใหญ่ได้ถึง 10^18 ทำงานในเวลาเชิงเส้น
// แนวคิด: ปอกใบทิ้งเพื่อหาปมบนวง แล้วเดินต้นไม้ที่ห้อยอยู่โดยถือ "เส้นทางจากวงลงมาถึงตัวเอง" ไว้ในสแต็ก
// ก้าวที่ยังไม่ถึงวงตอบจากสแต็กได้ทันที ส่วนก้าวที่เลยวงไปแล้วเป็นแค่การหารเอาเศษ
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
int main() {
int n; ll k;
scanf("%d %lld", &n, &k);
vector<int> f(n + 1), indeg(n + 1, 0);
for (int i = 1; i <= n; i++) { scanf("%d", &f[i]); indeg[f[i]]++; }
vector<int> peeled;
for (int i = 1; i <= n; i++) if (!indeg[i]) peeled.push_back(i);
for (size_t h = 0; h < peeled.size(); h++)
if (--indeg[f[peeled[h]]] == 0) peeled.push_back(f[peeled[h]]);
vector<char> onCyc(n + 1, 0);
for (int i = 1; i <= n; i++) onCyc[i] = indeg[i] > 0;
// ลูกของ v คือปมที่ชี้มาหา v และตัวมันเองไม่ได้อยู่บนวง
vector<int> head(n + 1, -1), nxt(n + 1, -1);
for (int i = 1; i <= n; i++)
if (!onCyc[i]) { nxt[i] = head[f[i]]; head[f[i]] = i; }
vector<int> ans(n + 1, 0);
vector<char> done(n + 1, 0);
vector<int> cyc, path;
for (int s = 1; s <= n; s++) {
if (!onCyc[s] || done[s]) continue;
cyc.clear();
for (int u = s; !done[u]; u = f[u]) { done[u] = 1; cyc.push_back(u); }
ll c = (ll)cyc.size();
for (ll i = 0; i < c; i++) ans[cyc[i]] = cyc[(i + k) % c];
for (ll i = 0; i < c; i++) { // เดินต้นไม้ที่ห้อยจากปมบนวงตัวนี้
path.assign(1, cyc[i]);
vector<int> it(1, head[cyc[i]]);
while (!path.empty()) {
if (it.back() < 0) { path.pop_back(); it.pop_back(); continue; }
int v = it.back();
it.back() = nxt[v];
path.push_back(v);
ll d = (ll)path.size() - 1; // ระยะจากปมบนวงลงมาถึง v
if (k <= d) ans[v] = path[d - k]; // ยังไม่ถึงวง ตอบจากสแต็กได้เลย
else ans[v] = cyc[(i + (k - d)) % c]; // เข้าวงแล้ว ที่เหลือคือเศษ
it.push_back(head[v]);
}
}
}
for (int v = 1; v <= n; v++) printf("%d%c", ans[v], v == n ? '\n' : ' ');
}
ที่ต้องระวังคือการผลักตัวเองเข้าสแต็กก่อนแล้วค่อยคำนวณ ไม่งั้นตอน k
เป็นศูนย์จะอ่านเลยขอบอาเรย์ ผมพลาดจุดนี้ตอนเขียนรอบแรก และตัวสุ่มเทียบจับได้ตั้งแต่ชุดที่ k = 0
โผล่มาชุดแรก
เวลาที่วัดได้บนเครื่องผม กราฟห้าแสนปมที่ครึ่งหนึ่งเป็นสายยาวและครึ่งหนึ่งสุ่ม ใช้เวลา 890 มิลลิวินาที โดยงานเกือบทั้งหมดคือการอ่านอินพุต
ผมสุ่มกราฟเล็ก ๆ 3,000 ชุดเทียบกับการเดินทีละก้าวจริง ตรงกันหมด
ให้ f ของทุกปม ตอบว่ามีปมกี่ปมที่อยู่บนวง
| Input | Output |
|---|---|
| 8 3 3 4 5 3 7 6 2 | 5 |
อ่านตัวอย่างนี้ยังไง
อินพุตหน้าตาเหมือนข้อแรกแต่ไม่มี k เอาต์พุตคือจำนวนปม
ไม่ใช่จำนวนวง กราฟชุดนี้มีปมที่อยู่บนวง 5 ปม จากทั้งหมด 8 ปม
อีก 3 ปมเป็นปมที่ไหลเข้าวงแต่ตัวเองไม่ได้อยู่บนวง
นิยามที่ใช้ตัดสินคือ ปมนั้นเดินตามลูกศรแล้ววกกลับมาหาตัวเองได้ไหม ปมที่ไหลลงวงแล้วไม่มีทางย้อนขึ้นมา จึงไม่นับ ในภาพของข้อที่แล้ว ปมสีทองคือปมที่นับ
ใบ้
ข้อนี้ตั้งใจให้สั้น ถ้าคิดว่าต้องไล่หาวงทีละวง แสดงว่ากำลังทำงานเกินไปแล้ว ลองอ่านย่อหน้าเรื่องปอกใบทิ้งอีกรอบ แล้วถามว่า "ปมที่ปอกไม่ออก" คือใครกันแน่
ไม่ต้องหาวงเลยครับ ปอกใบทิ้งอย่างเดียว แล้วตอบว่า n ลบจำนวนปมที่ปอกออกได้
เพราะปมที่ปอกไม่ออกคือปมบนวงพอดี ทั้งโปรแกรมยาวไม่ถึงสิบบรรทัด
// นับว่ามีปมกี่ปมที่อยู่บนวง ทำได้ด้วยการปอกใบทิ้งอย่างเดียว ไม่ต้องค้นหาวงเลย
#include <bits/stdc++.h>
using namespace std;
int main() {
int n; scanf("%d", &n);
vector<int> f(n + 1), indeg(n + 1, 0);
for (int i = 1; i <= n; i++) { scanf("%d", &f[i]); indeg[f[i]]++; }
vector<int> q;
for (int i = 1; i <= n; i++) if (!indeg[i]) q.push_back(i);
for (size_t h = 0; h < q.size(); h++) if (--indeg[f[q[h]]] == 0) q.push_back(f[q[h]]);
printf("%d\n", n - (int)q.size()); // ที่ปอกไม่ออกคือปมบนวงพอดี
} ที่ยกข้อนี้มาเพราะมันชี้ให้เห็นว่าการเลือกท่าที่ถูกทำให้โค้ดหายไปเกือบทั้งหมด เวอร์ชันที่ไล่หาวงจริง ๆ ต้องแยกให้ออกระหว่าง "เจอปมที่เคยเดินในรอบนี้" กับ "เจอปมที่เคยเดินในรอบก่อน" ซึ่งเป็นจุดที่คนพลาดกันบ่อยที่สุด และปอกใบทิ้งไม่ต้องเจอปัญหานั้นเลย
ผมสุ่มกราฟเล็ก ๆ 1,500 ชุดเทียบกับตัวไล่เช็กทีละปมแบบซื่อ ๆ ตรงกันหมด
ท่าปอกใบในบทนี้หาว่าใครอยู่บนวงได้ครบ แต่มันต้องมีอาเรย์นับดีกรีและคิวขนาดเท่าจำนวนปม
คือใช้หน่วยความจำ O(n) ซึ่งปกติไม่ใช่ปัญหา แต่มีสถานการณ์ที่มันเป็นปัญหาจริง
คือตอนที่เราไม่มีกราฟทั้งใบอยู่ในมือ เรามีแค่ฟังก์ชันที่ถามได้ทีละก้าวว่า
"จากตรงนี้ไปไหนต่อ" เช่นลำดับสุ่มที่คำนวณค่าถัดไปจากค่าปัจจุบัน
ในสถานการณ์นั้นยังหาวงได้ ด้วยหน่วยความจำคงที่ ไม่ขึ้นกับขนาดของวงเลย ท่านั้นชื่อ เต่ากับกระต่าย (tortoise and hare หรือเรียกตามชื่อคนคิดว่า Floyd)
เดินสองตัวพร้อมกันจากจุดเดียวกัน ตัวหนึ่งก้าวละหนึ่ง อีกตัวก้าวละสอง ถ้ามีวงอยู่ ตัวเร็วจะวนกลับมาไล่ตัวช้าทันในวงแน่ ๆ เพราะระยะห่างระหว่างสองตัวเพิ่มขึ้นทีละหนึ่งก้าว เมื่อทั้งคู่อยู่ในวงแล้ว ระยะห่างจึงต้องมาลงตรงศูนย์พอดีในที่สุด
จุดที่เจอกันยังไม่ใช่ปมแรกของวง แต่มันมีคุณสมบัติที่ใช้ต่อได้ คือระยะจากจุดเริ่มถึงปมแรกของวง เท่ากับระยะจากจุดที่เจอกันถึงปมแรกของวงพอดี จึงย้ายตัวหนึ่งกลับไปที่จุดเริ่ม แล้วเดินสองตัวพร้อมกันทีละก้าว จุดที่มาเจอกันครั้งนี้คือปมแรกที่อยู่บนวง จากนั้นวนรอบวงหนึ่งรอบก็ได้ความยาววง
// เต่ากับกระต่าย (Floyd): หาความยาวหางกับความยาววงบนกราฟฟังก์ชัน
// ด้วยหน่วยความจำคงที่ ไม่ใช้อาเรย์ visited เลย
// อินพุต: n start แล้ว nxt[0..n-1]
// เอาต์พุต: mu lambda (mu = จำนวนก้าวจาก start ถึงปมแรกที่อยู่บนวง, lambda = ความยาววง)
#include <bits/stdc++.h>
using namespace std;
int main() {
int n, start;
if (scanf("%d %d", &n, &start) != 2) return 0;
vector<int> nxt(n);
for (int i = 0; i < n; i++) scanf("%d", &nxt[i]);
// เฟส 1 หาจุดพบกันในวง เต่าเดินหนึ่งก้าว กระต่ายเดินสองก้าว
int slow = nxt[start];
int fast = nxt[nxt[start]];
while (slow != fast) {
slow = nxt[slow];
fast = nxt[nxt[fast]];
}
// เฟส 2 ย้ายเต่ากลับจุดเริ่ม แล้วเดินพร้อมกันทีละก้าว จุดที่เจอกันคือปมแรกบนวง
int mu = 0;
slow = start;
while (slow != fast) {
slow = nxt[slow];
fast = nxt[fast];
mu++;
}
// เฟส 3 วนรอบวงหนึ่งรอบ นับความยาว
int lambda = 1;
fast = nxt[slow];
while (fast != slow) {
fast = nxt[fast];
lambda++;
}
printf("%d %d\n", mu, lambda);
return 0;
} เกณฑ์เลือกระหว่างสองท่าชัดมาก ถ้ามีกราฟทั้งใบอยู่ในมือและต้องรู้ว่าทุกปมอยู่บนวงหรือไม่ ให้ปอกใบ เพราะรอบเดียวได้คำตอบของทุกปม ถ้ามีแค่ฟังก์ชันที่เดินได้ทีละก้าวและสนใจวงที่ปมเดียว เดินไปเจอ ให้ใช้เต่ากับกระต่าย เพราะไม่ต้องจองหน่วยความจำตามขนาดของสิ่งที่มองไม่เห็น
เมื่อทุกปมมีทางออกทางเดียว รูปของกราฟถูกล็อกไว้แล้วคือวงหนึ่งวงต่อกลุ่มพร้อมต้นไม้ที่ห้อยลงมา หาปมบนวงด้วยการปอกใบทิ้ง แล้วคำถามเรื่องการเดินไกล ๆ ก็กลายเป็นการหารเอาเศษ
ในหน้านี้