programming.in.th · ข้อ 2025

อุโมงค์ตัวตุ่น: พอรู้ว่าจะปิดเส้นไหน ต่อที่ห้องกลางได้เสมอ

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

★★★★★ treedpbfs อ่าน 14 นาที เผยแพร่ 9 กันยายน 2026 อัปเดต 20 ตุลาคม 2026

โจทย์ · ตัวตุ่นที่ยอมรื้ออุโมงค์ได้แค่เส้นเดียว

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

ระยะระหว่างสองห้องคือจำนวนอุโมงค์ที่ต้องผ่าน

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

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

EXAMPLE
InputOutput
4
1 2
2 3
3 4
2
3 4
4 2
7
1 3
2 3
2 7
4 3
7 5
3 6
3
2 3
7 3
ผังเดิม ปิด 2-3 เปิด 7-3 1 2 3 4 5 6 7 1 2 3 4 5 6 7 ไกลสุด 4 อุโมงค์ ไกลสุด 3 อุโมงค์
ตัวอย่างที่สอง ซ้ายคือผังเดิม ขวาคือหลังปิดอุโมงค์ 2-3 (เส้นประแดง) แล้วเปิด 7-3 (เส้นโค้งเขียว) เส้นทองคือทางของคู่ห้องที่ไกลที่สุด

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

ใบ้

พอปิดอุโมงค์ไปหนึ่งเส้น ต้นไม้จะแตกเป็นสองก้อนเสมอ (กลุ่มห้องที่ยังเดินถึงกันเองได้) อุโมงค์ใหม่ต้องเชื่อมสองก้อนนั้น ไม่งั้นจะมีห้องที่เดินไปไม่ถึง

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

ลองเอง · เลือกเส้นที่จะปิด แล้วเลือกจุดที่จะต่อ

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

1 2 3 4 5 6

กดที่เส้นเพื่อเลือกอุโมงค์ที่จะปิดก่อน

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

ปิดหนึ่งเส้นแล้วผังจะแตกเป็นสองก้อน จากนั้นกดหนึ่งห้องในแต่ละก้อนเพื่อเปิดอุโมงค์ใหม่

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

เฉลย ตอนที่ 1 · ลองทุกแบบตรง ๆ ได้ 40 คะแนน

โจทย์แบ่งคะแนนตามขนาดของ N ไว้แบบนี้ ซึ่งบอกว่าวิธีช้า ๆ ก็เก็บคะแนนได้ก่อน

การให้คะแนน
ส่วนของคะแนนขอบเขต
40%N น้อยกว่า 30
70%N น้อยกว่า 3,000
100%N ถึง 300,000
ถ้าตอบบรรทัดแรกถูกแต่แผนผิด ยังได้ราว 70% ของคะแนนเคสนั้น

เริ่มจากวิธีที่ช้าแต่ถูกแน่นอนก่อน คือทำตามโจทย์ทุกตัวอักษร

  1. ลองปิดอุโมงค์ทีละเส้น ได้สองก้อน
  2. ลองต่อทุกคู่ห้อง หนึ่งห้องจากก้อนซ้าย หนึ่งห้องจากก้อนขวา
  3. หาคู่ห้องที่ไกลที่สุดของผังใหม่ เก็บแผนที่คู่นี้ใกล้ที่สุดไว้

ข้อ 3 วัดด้วยการเดินกว้าง (BFS ย่อมาจาก breadth-first search) คือเริ่มจากห้องหนึ่ง เยี่ยมทุกห้องที่ห่างหนึ่งอุโมงค์ แล้วทุกห้องที่ห่างสอง ไล่ไปเรื่อย ๆ จนครบ เดินกว้างจากทุกห้องก็ได้ระยะของทุกคู่

ระยะของคู่ห้องที่ไกลที่สุดในต้นไม้เรียกว่าเส้นผ่านศูนย์กลาง (diameter อ่านว่า ได-แอ-มิ-เตอร์) ยืมคำมาจากวงกลม บทนี้ใช้คำนี้บ่อย จะขอเรียกสั้น ๆ ว่าความยาวของต้นไม้หรือของก้อน

นับงานของวิธีนี้

  • หน่วยที่นับ: จำนวนครั้งที่โค้ดหยิบห้องหนึ่งห้องมาทำงาน ใช้หน่วยนี้กับทุกวิธีในบท
  • เดินกว้างหนึ่งรอบในตัวตรวจ: จองตารางระยะใหม่ เดิน และไล่หาค่ามากสุด อย่างละ N รวม 3N
  • หนึ่งคู่ห้องเดินกว้างจากทุกห้อง N รอบ รวม 3N²
  • ทางเดิน N ห้องเป็นกรณีที่มีคู่มากที่สุด ปิดเส้นที่ k ได้ก้อน k กับ N − k ห้อง มีคู่ k(N − k) รวมทุก k ได้ (N³ − N) / 6 คู่
  • ที่ N = 29: 4,060 คู่ × 3 × 841 = 10,243,380 ครั้ง ทันสบาย (ทางเดิน 29 ห้อง วัดจริง 0.04 วินาที)
  • ที่ N = 2,999: ราว 1.2×1017 ครั้ง ไม่มีทางทัน

วิธีนี้เก็บได้แค่ชุด 40% แต่มันมีค่ามากในฐานะตัวตรวจ เพราะมันไม่เชื่ออะไรเลยนอกจากการวัดจริง ทุกวิธีที่เร็วกว่าจะถูกเทียบกับมัน

เฉลย ตอนที่ 2 · ปิดเส้นไหนก็ตาม ต่อที่ห้องกลางของแต่ละก้อนได้ค่าดีที่สุด

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

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

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

สมมติปิดเส้นหนึ่งไปแล้ว ได้ก้อนซ้ายยาว d1 กับก้อนขวายาว d2 แล้วต่อห้อง x ของก้อนซ้ายกับห้อง y ของก้อนขวา ทางระหว่างสองห้องใด ๆ ในผังใหม่มีแค่สามแบบ

ทางสามแบบหลังต่ออุโมงค์ใหม่
ทางยาวสุดเท่าไรขึ้นกับ x, y ไหม
อยู่ในก้อนซ้ายทั้งหมดd1ไม่ขึ้น อุโมงค์ใหม่ไม่ได้ทำให้ทางในก้อนเดียวกันสั้นลง เพราะเดินออกไปอีกก้อนแล้วต้องกลับทางเดิม
อยู่ในก้อนขวาทั้งหมดd2ไม่ขึ้น ด้วยเหตุผลเดียวกัน
ข้ามอุโมงค์ใหม่ (ทางข้าม)ระยะไกลสุดจาก x ในก้อนซ้าย + 1 + ระยะไกลสุดจาก y ในก้อนขวาขึ้น และเป็นตัวเดียวที่เราคุมได้

สองแถวแรกตายตัว เหลือแถวที่สามที่ห้องที่ต่อมีผล และสองฝั่งของมันแยกกันคิดได้

  • แต่ละห้องในก้อนมีระยะไกลสุดของตัวเอง คือระยะไปยังห้องที่ไกลจากมันที่สุดในก้อนเดียวกัน
  • ห้องที่ระยะไกลสุดน้อยที่สุดในก้อนเรียกว่าจุดกึ่งกลาง (center) และค่านั้นเรียกว่ารัศมี (radius)
  • ทางข้ามสั้นที่สุดเมื่อ x กับ y เป็นจุดกึ่งกลางของก้อนของตัวเอง ได้ รัศมี + 1 + รัศมี

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

1 2 3 4 5 6 7 จุดกึ่งกลาง ไกลสุด 3 อุโมงค์ ทั้งสองทาง
ทางเดิน 7 ห้องที่ยาว 6 ห้องกลางไกลจากปลายทั้งสองข้าง 3 อุโมงค์ ห้องอื่นทุกห้องไกลจากปลายใดปลายหนึ่งมากกว่านั้น ถ้าความยาวเป็นเลขคี่ เช่น 5 ห้องกลางสองห้องไกลสุด 3 คือ 5 หารสองปัดขึ้น

ต่อไปจะใช้เครื่องหมาย ⌈ ⌉ แปลว่าปัดขึ้น เช่น ⌈5/2⌉ = 3 และ ⌊ ⌋ แปลว่าปัดลง เช่น ⌊5/2⌋ = 2

รัศมีของต้นไม้ที่ยาว d เท่ากับ ⌈d/2⌉ เสมอ เหตุผลมีสองครึ่ง ให้ a กับ b เป็นคู่ห้องที่ไกลกัน d

  • ห้องไหนก็ตาม ระยะไป a บวกระยะไป b อย่างน้อย d ปลายข้างที่ไกลกว่าเลยห่างอย่างน้อย ⌈d/2⌉
  • ให้ m เป็นห้องบนทาง a ถึง b ที่ห่าง a เท่ากับ ⌈d/2⌉ และห่าง b เท่ากับ ⌊d/2⌋
  • ถ้ามีห้อง z ที่ห่าง m อย่างน้อย ⌈d/2⌉ + 1 ทางจาก z มาชนทาง a ถึง b ที่ฝั่งใดฝั่งหนึ่งของ m ให้เดินต่อผ่าน m ไปยังปลายอีกฟาก ได้ยาวอย่างน้อย ⌈d/2⌉ + 1 + ⌊d/2⌋ = d + 1 ซึ่งยาวกว่าคู่ที่ไกลที่สุด เป็นไปไม่ได้ ห้อง m จึงไกลสุดไม่เกิน ⌈d/2⌉

สูตรของหนึ่งเส้น

ปิดเส้นที่แบ่งได้ก้อนยาว d1 กับ d2 แล้วต่อจุดกึ่งกลางกับจุดกึ่งกลาง ค่าที่ได้คือ

มากสุดของ ( d1 , d2 , ⌈d1/2⌉ + 1 + ⌈d2/2⌉ )

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

สูตรของหนึ่งเส้นในภาษา C++
// ปิดเส้นหนึ่งแล้วได้สองก้อน ยาว d1 กับ d2
// ต่อที่จุดกึ่งกลางของทั้งสองก้อน ทางข้ามอุโมงค์ใหม่ยาว รัศมี + 1 + รัศมี
// ค่าของเส้นนี้คือตัวที่มากที่สุดในสามตัว
int s = max(max(d1, d2), (d1 + 1) / 2 + (d2 + 1) / 2 + 1);

ลองกับต้น 9 ห้องในเกม

  • คำถาม: ปิดอุโมงค์ 2-4 แล้วดีที่สุดได้เท่าไร
  • สองก้อน: ก้อนของห้อง 2 ยาว 3 รัศมี 2 · ก้อนของห้อง 4 ยาว 2 รัศมี 1
  • แทนค่า: มากสุดของ (3, 2, 2 + 1 + 1 = 4)
  • คำตอบ: 4 ทางข้ามชนะ ต่อห้อง 1 กับห้อง 6

ลองต่อทุกคู่ห้องของเส้นนี้ (18 คู่) แบบลองทุกแบบ ได้ค่าดีที่สุด 4 ตรงกับสูตร

เฉลย ตอนที่ 3 · ปิดทีละเส้นได้ 70 คะแนน แต่ยังไม่ทันที่ N เต็ม

สูตรของหนึ่งเส้นต้องการแค่ความยาวของสองก้อน งานต่อเส้นเหลือแค่วัดสองก้อนนั้น วิธีมาตรฐานคือเดินกว้างสองรอบ

ทบทวนพื้นฐาน · หาความยาวกับจุดกึ่งกลางด้วยการเดินกว้างสองรอบ

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

ทำไมรอบแรกจบที่ปลายของคู่ที่ไกลที่สุดเสมอ: ให้ a ถึง b เป็นคู่ที่ไกลที่สุด ถ้า u ไม่ได้เป็นปลายแบบนั้น ทางจาก u มาชนทาง a ถึง b แล้วเดินต่อไปยังปลายที่ไกลกว่า จะได้ทางที่ยาวกว่า d เหตุผลเดียวกับเรื่องรัศมีข้างบน

เหตุผลนี้ใช้ได้เพราะต้นไม้มีทางเดียวระหว่างสองห้อง บนกราฟที่มีวงวิธีนี้อาจผิดโดยไม่มีอะไรเตือน รายละเอียดเต็มอยู่ใน DP บนต้นไม้ ฝึกข้อ 2

ลองเดินดูบนต้น 9 ห้อง ผังเดิมยาว 6 ในรูป เส้นประแดงคือเส้นที่ปิด สีฟ้ากับสีทองคือสองก้อนที่ได้

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

ปิดทีละเส้นบนต้น 9 ห้อง

1 2 3 5 9 4 7 6 8
ค่าของทุกเส้นที่ปิดได้
ปิดก้อนปลายแรกก้อนปลายสองทางข้ามค่า
1-2 3 3 5 5
1-3 6 0 4 6
2-4 3 2 4 4
1-5 5 1 5 5
4-6 4 1 4 4
5-7 5 0 4 5
6-8 5 0 4 5
1-9 6 0 4 6

ผังนี้ลดจาก 6 เหลือ 4 ได้ 2 เส้น คือ 2-4 กับ 4-6 โค้ดถือเส้นแรกที่เจอไว้ คือ 2-4 แถวที่ค่ามาจากก้อนที่ยาวเอง ต่อห้องไหนก็ไม่ช่วย

โค้ดของวิธีนี้วนทุกเส้น เดินกว้างสองรอบต่อก้อน แล้วเก็บเส้นที่ได้ค่าน้อยที่สุด นับงานต่อเส้นได้ดังนี้

  • เดินกว้างสี่รอบ (สองรอบต่อก้อน สองก้อนรวมกันคือ N ห้อง): 2N
  • ไล่หาห้องที่ไกลสุดหลังแต่ละรอบ: 2N
  • ล้างระยะของห้องที่รอบก่อนเดินผ่าน (ราว): 2N
  • เดินย้อนครึ่งทางไปหาจุดกึ่งกลาง (ไม่เกิน): 1N

รวมราว 7N ต่อเส้น มี N − 1 เส้น ทั้งหมดราว 7N²

ดูโค้ดปิดทีละเส้น
mole_slow.cpp
// ลองปิดทีละเส้น แต่ไม่ลองคู่ห้องแล้ว ต่อที่จุดกึ่งกลางของสองก้อนเสมอ
// งานราว 2N ต่อเส้น รวมราว 2N² ทันกับ N ไม่ถึง 3,000
#include <bits/stdc++.h>
using namespace std;
int N;
vector<vector<int>> g;
vector<int> d, pa, seen;

// เดินกว้างจาก s โดยห้ามข้ามเส้น (ba, bb) เก็บระยะ พ่อ และลำดับห้องที่ไปถึง
void bfs(int s, int ba, int bb){
    for(int v : seen) d[v] = -1;              // ล้างเฉพาะห้องของรอบก่อน
    seen.clear(); seen.push_back(s); d[s] = 0; pa[s] = 0;
    for(size_t i = 0; i < seen.size(); i++){ int u = seen[i];
        for(int v : g[u]){
            if((u == ba && v == bb) || (u == bb && v == ba)) continue;
            if(d[v] < 0){ d[v] = d[u] + 1; pa[v] = u; seen.push_back(v); } } }
}

// เดินกว้างสองรอบ ได้ความยาวของก้อนที่ s อยู่ กับห้องกึ่งกลางของมัน
pair<int,int> diamCenter(int s, int ba, int bb){
    bfs(s, ba, bb);
    int u = s; for(int x : seen) if(d[x] > d[u]) u = x;
    bfs(u, ba, bb);
    int w = u; for(int x : seen) if(d[x] > d[w]) w = x;
    int len = d[w], cur = w;
    for(int k = 0; k < len / 2; k++) cur = pa[cur];   // ถอยกลับครึ่งทาง
    return {len, cur};
}

int main(){
    if(scanf("%d", &N) != 1) return 0;
    g.assign(N + 1, {}); d.assign(N + 1, -1); pa.assign(N + 1, 0);
    vector<pair<int,int>> E(N - 1);
    for(auto &e : E){ if(scanf("%d %d", &e.first, &e.second) != 2) return 0;
        g[e.first].push_back(e.second); g[e.second].push_back(e.first); }
    if(N == 1){ printf("0\n"); return 0; }

    int best = INT_MAX; int bi = 0, bx = 0, by = 0;
    for(int i = 0; i < N - 1; i++){
        auto [a, b] = E[i];
        auto [d1, c1] = diamCenter(a, a, b);
        auto [d2, c2] = diamCenter(b, a, b);
        int s = max(max(d1, d2), (d1 + 1) / 2 + (d2 + 1) / 2 + 1);
        if(s < best){ best = s; bi = i; bx = c1; by = c2; } }
    printf("%d\n%d %d\n%d %d\n", best, E[bi].first, E[bi].second, bx, by);
}

ที่ N = 2,999 (ต้นไม้สุ่ม ช้าสุดในสามรูปทรงที่ลอง) ใช้ 0.21 วินาที ทันชุด 70%

แต่บนทางเดิน 30,000 ห้องใช้ไปแล้ว 19.88 วินาที งานคือ 7 × 30,000 × 29,999 ครั้ง เครื่องนี้ทำได้ราว 3.2×108 ครั้งต่อวินาที งบ 3 วินาทีคือราว 9.5×108 ครั้ง

ที่ N = 300,000 วิธีนี้ต้องใช้ราว 6.3×1011 ครั้ง เกินงบราว 663 เท่า ลองเทียบสามวิธี

วิธีที่ 1

ลองทุกเส้น ทุกคู่ห้อง วัดจริง

ราว 3N² × (N³ − N)/6

ตัวตรวจของทั้งบท ไม่เชื่อสูตรอะไรเลย

ได้ 40 คะแนน

วิธีที่ 2

ลองทุกเส้น ต่อที่จุดกึ่งกลาง

7N ต่อเส้น รวมราว 7N²

ตัดการลองคู่ห้องทิ้งด้วยสูตรของหนึ่งเส้น

ได้ 70 คะแนน

วิธีเต็ม

เก็บตัวเลขสองรอบ ลงกับขึ้น

14N

รอบลงเก็บค่าที่มองลงไปข้างใต้แต่ละห้อง รอบขึ้นเก็บค่าที่มองขึ้นไปทางราก ได้ความยาวสองก้อนของทุกเส้น (ตอนที่ 4)

ได้ครบ 100 คะแนน

106101210181024 ลองทุกแบบปิดทีละเส้นสองรอบ ลงกับขึ้น 1.2×1027 เกินงบ6.3×1011 เกินงบ4.2×106 ทัน งบ 3 วินาที
จำนวนครั้งที่แตะห้องเมื่อ N = 300,000 สเกลแบบ log (ขีดละล้านเท่า) เส้นทองคืองบ 3 วินาที คิดจากอัตราที่วัดได้จากโค้ดปิดทีละเส้นข้างบน

คิดย้อนว่างบพอให้ทำอะไรได้แค่ไหน: งบหารด้วยเส้น 299,999 เส้น เหลือราว 3,169 ครั้งต่อเส้น

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

เฉลย ตอนที่ 4 · ความยาวของสองก้อน ของทุกเส้นพร้อมกัน

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

  • ราก (root): ห้องที่เลือกให้อยู่บนสุด บทนี้ใช้ห้อง 1
  • พ่อ กับลูก: ห้องที่ติดกันด้วยอุโมงค์ ห้องที่ใกล้รากกว่าเป็นพ่อ อีกห้องเป็นลูก
  • ใบ (leaf): ห้องที่ไม่มีลูก
  • กิ่งของ v: ห้อง v กับทุกห้องที่อยู่ใต้มันลงไป

ทุกอุโมงค์เชื่อมห้อง v หนึ่งห้องกับพ่อของมัน พอปิดอุโมงค์เส้นนั้น สองก้อนที่ได้คือกิ่งของ v กับส่วนที่เหลือทั้งหมด

1 2 3 5 9 4 7 6 8 กิ่งของ 4 · ยาว 2 ส่วนที่เหลือ · ยาว 3
ต้น 9 ห้องปักรากที่ห้อง 1 ปิดอุโมงค์ 2-4 แล้วได้กิ่งของ 4 (ทอง) กับส่วนที่เหลือ (ฟ้า)

ถ้ามีตัวเลขสองตัวเก็บไว้ที่ทุกห้อง คือความยาวของกิ่ง (เรียกว่า dd) กับความยาวของส่วนที่เหลือ (เรียกว่า ud) ทุกเส้นก็ได้ d1 กับ d2 ทันที

ชื่อมาจากโค้ด ตัวหน้า d ย่อจาก down (มองลงไปในกิ่ง) ตัวหน้า u ย่อจาก up (มองขึ้นไปทางพ่อ) ตัวหลัง d คือ diameter และ h คือ height ความสูง

การคำนวณแบบนี้คือ DP (dynamic programming) คือเก็บคำตอบของชิ้นเล็กไว้ แล้วประกอบเป็นคำตอบของชิ้นใหญ่ พื้นฐานอยู่ใน DP บนต้นไม้

ขาลง · ค่าที่มองลงไปในกิ่ง

กิ่งของ v ประกอบจากกิ่งของลูกทั้งหมด ต้องคิดลูกให้เสร็จก่อนพ่อ ขาลงไล่จากใบขึ้นไปหาราก และเก็บสองค่า

ค่าของขาลง

  • h[v] ความสูงของกิ่ง: ทางลงจาก v ที่ยาวที่สุด ทางลงผ่านลูก c ยาว h[c] + 1
  • dd[v] ความยาวของกิ่ง: มากสุดของ dd ของลูกทุกตัว (ทางที่ไม่ผ่าน v) กับสองทางลงยาวสุดต่อกันที่ v

สิ่งที่ควรดู: ห้องสีทองคือห้องที่กำลังคิด สีเขียวคือลูกที่ส่งค่ามา บรรทัดคำอธิบายขึ้นต้นด้วย h กับ dd ของห้องนั้น แล้วบอกว่ามาจากลูกตัวไหน

ขาลง ไล่จากใบขึ้นราก

1 2 3 5 9 4 7 6 8
ขาลง
ห้องลูกhdd
8ไม่มี00
6811
7ไม่มี00
4622
9ไม่มี00
5711
3ไม่มี00
2433
12, 3, 5, 946

ขาขึ้น · ค่าที่มองขึ้นไปทางพ่อ

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

เริ่มจากพ่อที่มีลูกสองห้อง

ห้อง p มีลูก c กับ x ทางที่ออกจาก c ขึ้นไปหา p ไปต่อได้สองทาง คือขึ้นต่อจาก p (ยาว uh[p]) หรือลงไปในกิ่ง x (ยาว h[x] + 1)

uh[c] = 1 + มากสุดของ ( uh[p] , h[x] + 1 )

ถ้า p มีลูกหลายห้อง h[x] + 1 กลายเป็นทางลงจาก p ที่ยาวที่สุดโดยไม่เข้ากิ่ง c

คำว่าไม่เข้ากิ่ง c กำหนดว่าแต่ละห้องต้องเก็บอะไรไว้บ้าง

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

ค่าของขาขึ้น (ห้อง c มีพ่อ p)

  • uh[c] ทางที่ยาวที่สุดจาก c ที่ขึ้นไปทางพ่อก่อน = 1 + มากสุดของ uh[p] กับทางลงยาวสุดจาก p ที่ไม่เข้ากิ่ง c
  • ud[c] ความยาวของส่วนที่เหลือ = มากสุดของสามอย่างนี้
  1. ud[p]: ทางที่อยู่ในส่วนที่เหลือของ p ทั้งเส้น
  2. dd ที่มากสุดของลูกตัวอื่นของ p: ทางที่อยู่ในกิ่งพี่น้องกิ่งเดียว
  3. สองทางยาวสุดที่ออกจาก p คนละทางแล้วต่อกันที่ p โดยไม่เข้ากิ่ง c (ทางขึ้น uh[p] นับเป็นหนึ่งทาง)

รากไม่มีอะไรอยู่ข้างบน uh กับ ud ของรากเป็น 0

นึกภาพ

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

ข่าวเบื้องบนคือ uh[p] กับ ud[p] รายงานของลูกทีมคือ h กับ dd ของลูกแต่ละคน และการข้ามรายงานของ c คือคำว่าไม่เข้ากิ่ง c

ในต้นนี้ ห้อง 1 มีทางลง 4, 2, 1 และกิ่ง 2 คืออันดับหนึ่ง ตอนคิดให้ห้อง 2 ต้องทิ้งอันดับหนึ่ง แล้วใช้อันดับสองกับสาม (2 กับ 1)

สิ่งที่ควรดู: ห้องสีทองคือห้องที่กำลังคิด สีเขียวคือส่วนที่เหลือของมัน เส้นประแดงคือเส้นที่แถวนี้ตอบ บรรทัดคำอธิบายขึ้นต้นด้วย uh, ud และสองก้อนที่ได้ แล้วค่อยแจกแจงว่าแต่ละค่ามาจากไหน

ขาขึ้น ไล่จากรากลงใบ

1 2 3 5 9 4 7 6 8
ขาขึ้น
ห้อง (พ่อ)uhudปิดเส้นนี้ได้ก้อน
2 (1) 3 3 3 กับ 3
3 (1) 5 6 0 กับ 6
5 (1) 5 5 1 กับ 5
9 (1) 5 6 0 กับ 6
4 (2) 4 3 2 กับ 3
7 (5) 6 5 0 กับ 5
6 (4) 5 4 1 กับ 4
8 (6) 6 5 0 กับ 5

แถวของห้อง 4 ให้ก้อน 2 กับ 3 ตรงกับเส้นที่ชนะในเครื่องเดินปิดทีละเส้น ตอนสร้างหน้า ทุกแถวของทั้งสองตารางถูกเทียบกับการเดินกว้างวัดสองก้อนจริง

เฉลย ตอนที่ 5 · หาตำแหน่งจริงเฉพาะเส้นที่ชนะ

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

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

  • เดินกว้างจากห้อง 1 เพื่อเรียงห้อง: 1N
  • ขาลง: 1N
  • ขาขึ้น (วนลูกของแต่ละห้องสองรอบ): 2N
  • วนเลือกเส้นด้วยสูตร: 1N
  • ล้างตารางระยะทั้งตารางก่อนเดินกว้างแต่ละรอบ (สี่รอบ): 4N
  • เดินกว้างสี่รอบบนสองก้อนของเส้นที่ชนะ: 2N
  • ไล่หาห้องที่ไกลสุดหลังแต่ละรอบ: 2N
  • เดินย้อนครึ่งทางไปหาจุดกึ่งกลาง (ไม่เกิน): 1N

รวม 14N ราว 4.2×106 ครั้งที่ N เต็ม ต่ำกว่างบราว 226 เท่า

กรณีขอบ: N = 1 ไม่มีอุโมงค์ให้ปิด โจทย์ยังอนุญาตค่านี้ โค้ดพิมพ์ 0 บรรทัดเดียว ส่วน N = 2 มีอุโมงค์เส้นเดียว ปิดแล้วเปิดเส้นเดิมกลับ ได้ 1

ระวัง · ความลึกของต้นไม้

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

โค้ดนี้เรียงห้องด้วยการเดินกว้างเก็บไว้ในอาร์เรย์ แล้วไล่อาร์เรย์ย้อนหลังแทนขาลง ไล่ไปข้างหน้าแทนขาขึ้น

ดูโค้ดเต็ม
mole.cpp
#include <bits/stdc++.h>
using namespace std;
int N;
vector<int> head_, nxt_, to_;
void addEdge(int u, int v){ to_.push_back(v); nxt_.push_back(head_[u]); head_[u] = (int)to_.size() - 1; }
vector<int> par, ord_, h, dd, uh, ud;

// เดินจาก s ในกราฟที่ตัดเส้น (ba, bb) ออกแล้ว คืนระยะทางกับพ่อของทุกปมที่ไปถึง
void bfs(int s, int ba, int bb, vector<int>&d, vector<int>&pa, vector<int>&seen){
    d[s] = 0; pa[s] = 0; seen.clear(); seen.push_back(s);
    for(size_t i = 0; i < seen.size(); i++){ int u = seen[i];
        for(int e = head_[u]; e != -1; e = nxt_[e]){ int v = to_[e];
            if((u == ba && v == bb) || (u == bb && v == ba)) continue;
            if(d[v] < 0){ d[v] = d[u] + 1; pa[v] = u; seen.push_back(v); } } }
}

int main(){
    if(scanf("%d", &N) != 1) return 0;
    head_.assign(N + 1, -1);
    for(int i = 0; i < N - 1; i++){ int a, b; scanf("%d %d", &a, &b); addEdge(a, b); addEdge(b, a); }
    if(N == 1){ printf("0\n"); return 0; }              // ไม่มีอุโมงค์ให้ปิด

    par.assign(N + 1, 0); ord_.reserve(N);
    h.assign(N + 1, 0); dd.assign(N + 1, 0); uh.assign(N + 1, 0); ud.assign(N + 1, 0);
    // เรียงแบบกว้างก่อนแล้วไล่ย้อน แทนการเรียกซ้ำ ต้นไม้สูงสามแสนชั้นจึงไม่ทำสแต็กแตก
    vector<char> vis(N + 1, 0); ord_.push_back(1); vis[1] = 1;
    for(size_t i = 0; i < ord_.size(); i++){ int u = ord_[i];
        for(int e = head_[u]; e != -1; e = nxt_[e]){ int v = to_[e];
            if(!vis[v]){ vis[v] = 1; par[v] = u; ord_.push_back(v); } } }

    // ขาลง h = ความสูงของกิ่ง, dd = เส้นผ่านศูนย์กลางภายในกิ่ง
    for(int i = N - 1; i >= 0; i--){ int v = ord_[i]; int a1 = 0, a2 = 0;
        for(int e = head_[v]; e != -1; e = nxt_[e]){ int c = to_[e]; if(c == par[v]) continue;
            int a = h[c] + 1; if(a > a1){ a2 = a1; a1 = a; } else if(a > a2) a2 = a;
            dd[v] = max(dd[v], dd[c]); }
        h[v] = a1; dd[v] = max(dd[v], a1 + a2); }

    // ขาขึ้น uh = ระยะไกลสุดที่ออกไปทางพ่อ, ud = เส้นผ่านศูนย์กลางของส่วนที่เหลือ
    for(int i = 0; i < N; i++){ int p = ord_[i];
        int a1 = 0, a2 = 0, a3 = 0, i1 = -1, i2 = -1;     // สามอันดับของระยะลงไปในลูก
        int d1 = 0, d2 = 0, j1 = -1;                       // สองอันดับของเส้นผ่านศูนย์กลางในลูก
        for(int e = head_[p]; e != -1; e = nxt_[e]){ int c = to_[e]; if(c == par[p]) continue;
            int a = h[c] + 1;
            if(a > a1){ a3 = a2; a2 = a1; i2 = i1; a1 = a; i1 = c; }
            else if(a > a2){ a3 = a2; a2 = a; i2 = c; }
            else if(a > a3){ a3 = a; }
            int d = dd[c];
            if(d > d1){ d2 = d1; d1 = d; j1 = c; } else if(d > d2) d2 = d; }
        for(int e = head_[p]; e != -1; e = nxt_[e]){ int c = to_[e]; if(c == par[p]) continue;
            int b1 = (i1 == c) ? a2 : a1;                  // ระยะลงที่ยาวสุด ไม่นับกิ่งของ c
            int b2 = (i1 == c) ? a3 : ((i2 == c) ? a3 : a2);
            int bd = (j1 == c) ? d2 : d1;                  // เส้นผ่านศูนย์กลางของลูกตัวอื่น
            int s1 = uh[p], s2 = 0;                        // สองทางที่ยาวสุดจาก p รวมทางขึ้นข้างบน
            if(b1 > s1){ s2 = s1; s1 = b1; } else s2 = b1;
            if(b2 > s2) s2 = b2;
            ud[c] = max(max(ud[p], bd), s1 + s2);
            uh[c] = 1 + max(uh[p], b1); } }

    int best = INT_MAX, bv = -1;
    for(int v = 2; v <= N; v++){
        int d1 = dd[v], d2 = ud[v];
        int s = max(max(d1, d2), (d1 + 1) / 2 + (d2 + 1) / 2 + 1);
        if(s < best){ best = s; bv = v; } }

    // รู้แล้วว่าปิดเส้นไหน จึงค่อยเดินกว้างสองรอบต่อข้าง เพื่อหาจุดกึ่งกลางจริง ๆ
    int pu = par[bv];
    vector<int> d(N + 1, -1), pa(N + 1, 0), seen;
    auto centerOf = [&](int s) -> int {
        fill(d.begin(), d.end(), -1); bfs(s, pu, bv, d, pa, seen);
        int u = s; for(int x : seen) if(d[x] > d[u]) u = x;
        fill(d.begin(), d.end(), -1); bfs(u, pu, bv, d, pa, seen);
        int w = u; for(int x : seen) if(d[x] > d[w]) w = x;
        int steps = d[w] / 2, cur = w; while(steps--) cur = pa[cur];
        return cur; };
    printf("%d\n%d %d\n%d %d\n", best, pu, bv, centerOf(bv), centerOf(pu));
}
แผนที่ของโค้ดเต็ม
ส่วนในโค้ดอธิบายอยู่ที่
bfs (เดินกว้างโดยข้ามเส้นที่ปิด)เดินกว้างสองรอบ ในตอนที่ 3
เรียงแบบกว้างก่อนแล้วไล่ย้อน (ord_)ตอนที่ 5 (กล่องเรื่องความลึกของต้นไม้)
ขาลง h, ddตอนที่ 4 ขาลง
ขาขึ้น uh, ud (ทางลงสามอันดับ a1..a3 พร้อมเจ้าของ i1, i2 และ dd สองอันดับ d1, d2 พร้อมเจ้าของ j1)ตอนที่ 4 ขาขึ้น
for v = 2..N เลือกเส้นด้วยสูตรตอนที่ 2 (สูตรของหนึ่งเส้น)
centerOf (หาจุดกึ่งกลางของเส้นที่ชนะ)ตอนที่ 5
ดูตัวตรวจที่ใช้เทียบ

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

brute_mole.cpp
// ตัวตรวจ ปิดทุกเส้น ต่อทุกคู่ห้อง แล้ววัดระยะไกลสุดจริงทุกครั้ง
// ไม่มีคำว่ารัศมีหรือจุดกึ่งกลางอยู่ในนี้เลย
#include <bits/stdc++.h>
using namespace std;
int N; vector<pair<int,int>> E;
int diamOf(vector<vector<int>>&g, vector<int>&comp){
    int best = 0;
    for(int s : comp){ vector<int> d(N + 1, -1); queue<int> q; q.push(s); d[s] = 0;
        while(!q.empty()){ int u = q.front(); q.pop();
            for(int v : g[u]) if(d[v] < 0){ d[v] = d[u] + 1; q.push(v); } }
        for(int v : comp) best = max(best, d[v]); }
    return best; }
int main(){
    scanf("%d", &N); E.resize(N - 1);
    for(auto &e : E) scanf("%d %d", &e.first, &e.second);
    int ans = INT_MAX;
    for(int rm = 0; rm < N - 1; rm++){
        vector<vector<int>> g(N + 1);
        for(int i = 0; i < N - 1; i++) if(i != rm){
            g[E[i].first].push_back(E[i].second); g[E[i].second].push_back(E[i].first); }
        vector<int> col(N + 1, 0), A, B;
        { queue<int> q; q.push(E[rm].first); col[E[rm].first] = 1;
          while(!q.empty()){ int u = q.front(); q.pop(); A.push_back(u);
            for(int v : g[u]) if(!col[v]){ col[v] = 1; q.push(v); } } }
        for(int v = 1; v <= N; v++) if(!col[v]) B.push_back(v);
        for(int x : A) for(int y : B){
            g[x].push_back(y); g[y].push_back(x);
            vector<int> all; for(int v = 1; v <= N; v++) all.push_back(v);
            ans = min(ans, diamOf(g, all));
            g[x].pop_back(); g[y].pop_back(); } }
    printf("%d\n", ans);
}

ตรวจอะไรไปแล้วบ้าง

  • ตอนสร้างหน้า: ต้นสุ่มไม่เกิน 8 ห้อง 120 ต้น สูตรตรงกับการลองทุกคู่ ขาลงขาขึ้นตรงกับการวัดสองก้อนจริงทุกเส้น
  • นอกหน้า: ต้นสุ่ม 2 ถึง 14 ห้อง 1,500 ต้น ทั้งสองโปรแกรมตรงกับตัวตรวจ และแผนที่พิมพ์รื้อแล้วได้ค่าตามที่พิมพ์
  • ต้น 300,000 ห้องทุกรูปทรงในตารางข้างล่าง แผนที่พิมพ์รื้อแล้วได้ค่าตามที่พิมพ์

นี่คือการตรวจ ส่วนที่ครอบคลุมทุกขนาดคือเหตุผลเรื่องทางสามแบบกับรัศมี ขาลงขาขึ้นตรวจแล้วแต่ไม่ได้พิสูจน์

เวลาและหน่วยความจำของโค้ดเต็ม ที่ N = 300,000
รูปทรงวินาทีเมกะไบต์
ทางเดินยาวเส้นเดียว0.0620.1
ดาว (ห้องกลางหนึ่งห้อง ที่เหลือเป็นใบ)0.0621.7
ต้นไม้สุ่ม0.2220.5
หนอนผีเสื้อ (ทางยาวครึ่งหนึ่ง มีใบห้อยตามทาง)0.1120.1
ไม้กวาด (ทางยาวครึ่งหนึ่ง ปลายเป็นดาว)0.0720.5
ต้นไม้ที่ทุกห้องมีลูกไม่เกินสอง0.0720.2
วัดบน WSL (g++ 11.4, -O2) ด้วยโค้ดที่ดึงออกมาจากหน้านี้ ทุกตัวเลขเวลาในบทนี้วัดแบบเดียวกัน รันสามรอบ เก็บรอบที่ช้าที่สุด หน่วยความจำคือค่าสูงสุดที่ /usr/bin/time -v รายงาน ช้าสุด 0.22 วินาทีจากลิมิต 3 และ 21.7 จาก 128 เมกะไบต์

ทดสอบตัวเอง

ทางเดินยาว 6 ห้อง (ผังแรกในเกม) ควรปิดเส้นไหน และได้เท่าไร

ปิด 3-4 ได้ 3 ต่อห้อง 2 กับ 5

ดาว 6 ห้อง (ห้องกลางหนึ่งห้อง ที่เหลือเป็นใบ) รื้อแล้วดีขึ้นไหม

ไม่ดีขึ้น ได้ 2 เท่าเดิม ต้นไม้ตั้งแต่สามห้องยาวอย่างน้อย 2 เสมอ ปิดใบหนึ่งใบแล้วต่อกลับที่ห้องกลางก็ได้ผังเดิม

ต้องต่อที่จุดกึ่งกลางเท่านั้นหรือเปล่า ถึงจะได้ค่าดีที่สุด

ไม่จำเป็น ในต้น 9 ห้อง เส้น 2-4 มีคู่ห้องให้ต่อ 18 คู่ ได้ 4 อยู่ 2 คู่

สรุป

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

แหล่งที่มา

  1. โจทย์ Mole บน programming.in.th ข้อ 2025 programming.in.th/tasks/2025 (สืบค้น 9 กันยายน 2026)
  2. ต้นทางของโจทย์คือ COCI 2008/2009 Contest #1 วันที่ 18 ตุลาคม 2008