programming.in.th · ข้อ 2009

เกาะกับสะพาน: เมื่อรูปของกราฟถูกล็อกไว้แล้ว เส้นทางยาวสุดเหลือแค่สองทรง

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

★★★★★ functional graphtreedp อ่าน 14 นาที 7 กันยายน 2026

โจทย์ · เดินให้ไกลที่สุด แต่ห้ามซ้ำเกาะ

สวนสาธารณะมีเกาะ N เกาะ จากเกาะ i มีสะพานหนึ่งสะพานทอดไปยังอีกเกาะหนึ่ง ยาว L ทั้งสวนจึงมีสะพาน N สะพานพอดี สะพานเดินได้สองทาง และระหว่างเกาะคู่ไหนก็มีเรือแล่นถึงกันได้

เราอยากให้ผลรวมความยาวสะพานที่เดินข้ามมากที่สุด ภายใต้กฎว่า

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

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

EXAMPLE
InputOutput
7
3 8
7 2
4 2
1 4
1 9
3 4
2 3
24

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

ใบ้

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

พอเห็นรูปแล้ว ลองแยกว่า "เส้นทางที่ยาวที่สุด" ในรูปแบบนั้นมีได้กี่ทรง

ลองเอง · เดินให้ไกลที่สุดของทุกกลุ่ม

สี่เกาะกลุ่มเดียว พอให้ชินกับการเดินไม่ซ้ำเกาะ

เส้นทางตอนนี้ ยังไม่ได้เริ่มเดิน ยาว 0

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

รวมที่บันทึกไว้ 0 จากคำตอบเต็ม 0

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

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

เฉลย · ทุกกลุ่มคือวงหนึ่งวงที่มีต้นไม้ห้อยอยู่

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

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

ทีนี้เรื่องที่ผิดจริง โค้ดรอบแรกของผมคำนวณเส้นทางที่จมอยู่ในต้นไม้ห้อยเอาไว้เรียบร้อย เก็บใส่ตัวแปร best แล้วไม่ได้เอาไปใช้เลยสักบรรทัด สิ่งที่เหลือไว้คือ (void)best; โปรแกรมคิดแต่เส้นทางที่วิ่งข้ามวง ไม่มี error ไม่มี warning และตัวอย่างในโจทย์ตอบถูก คือ 24 ตรงเป๊ะ เพราะตัวอย่างของโจทย์บังเอิญไม่ได้กดดันกรณีนั้น ตัวสุ่มเทียบจับได้ที่ n = 6 โปรแกรมตอบ 18 ของจริงคือ 21 เพราะเส้นทางที่ยาวที่สุดของเคสนั้นคือ 5-4-6-3-1 ซึ่งอยู่ในต้นไม้ห้อยล้วน ๆ ไม่แตะวงเลย ทางแก้คือแจกหมายเลขกลุ่มลงต้นไม้ที่ห้อยอยู่ให้ครบก่อน แล้วค่อยเอาเส้นผ่านศูนย์กลางของทุกปมมาสู้กับคำตอบของกลุ่ม

รวมทั้งหมดผมสุ่ม 1,500 ชุดเทียบเฉลยเต็มกับตัวไล่ทุกทาง และอีก 800 ชุดเทียบเฉลยเต็มกับเฉลยชุดทดสอบย่อย

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

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

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

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

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

ทรงแรกหาได้ระหว่างปอกใบ

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

ทรงที่สองคือหน้าต่างเลื่อนบนวง

ให้ d[i] คือความลึกของต้นไม้ที่ห้อยอยู่ที่ปมบนวงตัวที่ i และ P[i] คือระยะสะสมรอบวง ค่าของเส้นทางที่ลงต้นไม้ที่ i ไต่ไปถึง j แล้วลงต้นไม้ที่ j คือ d[i] + d[j] + (P[j] − P[i])

จับกลุ่มใหม่เป็น (d[j] + P[j]) + (d[i] − P[i]) จะเห็นว่าก้อนหลังไม่ขึ้นกับ j เลย ดังนั้นเดิน j ไปข้างหน้า แล้วเก็บค่ามากสุดของก้อนหลังในหน้าต่างที่ยาวไม่เกิน c − 1 ก็จบในเวลาเชิงเส้น ส่วนการคลี่วงออกเป็นสองรอบ เป็นวิธีจัดการกับการไต่ที่วนข้ามจุดเริ่ม

หัวใจของทั้งข้อ
// ค่าของเส้นทางที่ลงต้นไม้ที่ i ไต่วงไปทางเดียวจนถึง j แล้วลงต้นไม้ที่ j คือ
//     d[i] + d[j] + (P[j] - P[i])   =   (d[j] + P[j]) + (d[i] - P[i])
// ก้อนหลังไม่ขึ้นกับ j เลย จึงใช้หน้าต่างเลื่อนเก็บค่ามากสุดของมันได้
deque<int> dq;
for (int j = 0; j < 2 * c; j++) {
    while (!dq.empty() && dq.front() < j - (c - 1)) dq.pop_front();   // ไต่วงเกินรอบไม่ได้
    if (!dq.empty())
        local = max(local, d[j % c] + P[j] + d[dq.front() % c] - P[dq.front()]);
    ll cur = d[j % c] - P[j];
    while (!dq.empty() && d[dq.back() % c] - P[dq.back()] <= cur) dq.pop_back();
    dq.push_back(j);
}

ระวัง

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

แกะตัวอย่างในโจทย์

กลุ่มที่ 1 กดถัดไปเพื่อไล่เกาะบนวงทีละเกาะ

กลุ่มที่ 1 · วงคือเกาะ 1 ไป 3 ไป 4 แล้ววนกลับ
ลำดับบนวงเกาะ ต้นไม้ที่ห้อยลึก dสะพานไปตัวถัดไประยะสะสม P
0 1 9 8 0
1 3 4 2 8
2 4 0 4 10

กลุ่มที่ 2 กดถัดไปเพื่อไล่เกาะบนวงทีละเกาะ

กลุ่มที่ 2 · วงคือเกาะ 2 ไป 7 แล้ววนกลับ
ลำดับบนวงเกาะ ต้นไม้ที่ห้อยลึก dสะพานไปตัวถัดไประยะสะสม P
0 2 0 2 0
1 7 0 3 2

คู่ที่ดีที่สุดของแต่ละกลุ่มคือลงต้นไม้ที่เกาะ 1 ไป 3 และ 7 ไป 2 ส่วนเส้นทางที่จมอยู่ในต้นไม้ล้วน ๆ ยาวที่สุด 9 และ 0 คำตอบของกลุ่มคือค่าที่มากกว่าในสองอันนั้น

รวมทุกกลุ่มได้ 21 + 3 = 24 ตรงกับคำตอบในโจทย์

อีกมุมหนึ่ง: ตัดเส้นบนวงทิ้งทีละเส้น (ชุดทดสอบย่อย N ≤ 4,000)

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

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

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

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

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

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

ดูโค้ดของชุดทดสอบย่อย
islands_sub.cpp
// เฉลยชุดทดสอบย่อย N ไม่เกิน 4,000 (40 คะแนน)
// ความคิดหลัก: ตัดเส้นบนวงออกทีละเส้น กลุ่มนั้นจะกลายเป็นต้นไม้ทันที
// แล้วเส้นทางยาวสุดในต้นไม้ก็คือเส้นผ่านศูนย์กลาง ซึ่งหาได้ด้วยการค้นสองรอบ
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
int n;
struct E { int to, w, id; };
vector<vector<E>> g;                                // กราฟไม่มีทิศ สะพานสองเส้นระหว่างเกาะคู่เดียวกันเป็นคนละเส้น จึงต้องมีหมายเลขกำกับ

pair<int,ll> farthest(int s, const vector<char>& alive, int banId) {
    vector<ll> d(n + 1, -1);
    d[s] = 0;
    vector<int> st{s};
    int bi = s; ll bd = 0;
    while (!st.empty()) {
        int u = st.back(); st.pop_back();
        for (auto& e : g[u]) {
            int v = e.to;
            if (!alive[v] || d[v] >= 0 || e.id == banId) continue;   // ข้ามสะพานเส้นที่ตัดทิ้ง
            d[v] = d[u] + e.w;
            if (d[v] > bd) { bd = d[v]; bi = v; }
            st.push_back(v);
        }
    }
    return {bi, bd};
}

int main() {
    scanf("%d", &n);
    vector<int> f(n + 1), L(n + 1);
    g.assign(n + 1, {});
    for (int i = 1; i <= n; i++) {
        scanf("%d %d", &f[i], &L[i]);
        g[i].push_back({f[i], L[i], i});
        g[f[i]].push_back({i, L[i], i});
    }
    vector<char> seen(n + 1, 0);
    ll ans = 0;
    for (int s = 1; s <= n; s++) {
        if (seen[s]) continue;
        // เก็บสมาชิกของกลุ่มนี้
        vector<int> comp; vector<char> alive(n + 1, 0);
        vector<int> st{s}; seen[s] = 1;
        while (!st.empty()) {
            int u = st.back(); st.pop_back(); comp.push_back(u); alive[u] = 1;
            for (auto& e : g[u]) if (!seen[e.to]) { seen[e.to] = 1; st.push_back(e.to); }
        }
        // หาวงด้วยการเดินตามลูกศรจนวนกลับมาที่เดิม
        vector<int> mark(n + 1, 0);
        int u = s; while (!mark[u]) { mark[u] = 1; u = f[u]; }
        vector<int> cyc; int v = u;
        do { cyc.push_back(v); v = f[v]; } while (v != u);
        ll best = 0;
        for (size_t i = 0; i < cyc.size(); i++) {
            int a = cyc[i];                           // สะพานหมายเลข a คือเส้นที่ออกจากเกาะ a
            auto p1 = farthest(a, alive, a);          // ตัดสะพานนั้นแล้วหาเส้นผ่านศูนย์กลางของต้นไม้
            auto p2 = farthest(p1.first, alive, a);
            best = max(best, p2.second);
        }
        ans += best;
    }
    printf("%lld\n", ans);
}

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

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

โค้ด C++

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

ดูโค้ดเต็ม
islands.cpp
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
int main(){
    int n; if(scanf("%d",&n)!=1) return 0;
    vector<int> f(n+1),L(n+1),indeg(n+1,0);
    for(int i=1;i<=n;i++){ scanf("%d %d",&f[i],&L[i]); indeg[f[i]]++; }
    vector<ll> t1(n+1,0),t2(n+1,0);                  // สองกิ่งที่ลึกที่สุดของแต่ละปม
    vector<int> q; q.reserve(n);
    for(int i=1;i<=n;i++) if(!indeg[i]) q.push_back(i);
    for(size_t h=0;h<q.size();h++){                  // ปอกใบทิ้งทีละชั้น เหลือไว้แต่ปมบนวง
        int u=q[h],p=f[u]; ll v=t1[u]+L[u];
        if(v>t1[p]){ t2[p]=t1[p]; t1[p]=v; } else if(v>t2[p]) t2[p]=v;
        if(--indeg[p]==0) q.push_back(p);
    }
    vector<int> comp(n+1,-1); vector<ll> res;
    vector<char> done(n+1,0);
    vector<int> cyc; vector<ll> d,P; vector<int> W;
    for(int s=1;s<=n;s++){
        if(indeg[s]==0||done[s]) continue;
        int id=(int)res.size(); cyc.clear();
        for(int u=s; !done[u]; u=f[u]){ done[u]=1; cyc.push_back(u); comp[u]=id; }
        int c=(int)cyc.size();
        d.assign(c,0); W.assign(c,0);
        for(int i=0;i<c;i++){ d[i]=t1[cyc[i]]; W[i]=L[cyc[i]]; }
        P.assign(2*c,0);
        for(int i=1;i<2*c;i++) P[i]=P[i-1]+W[(i-1)%c];
        ll local=0; deque<int> dq;                   // หน้าต่างเลื่อน หาค่ามากสุดของ d[i]-P[i]
        for(int j=0;j<2*c;j++){
            while(!dq.empty() && dq.front()<j-(c-1)) dq.pop_front();
            if(!dq.empty()) local=max(local, d[j%c]+P[j] + d[dq.front()%c]-P[dq.front()]);
            ll cur=d[j%c]-P[j];
            while(!dq.empty() && d[dq.back()%c]-P[dq.back()]<=cur) dq.pop_back();
            dq.push_back(j);
        }
        res.push_back(local);
    }
    for(int h=(int)q.size()-1;h>=0;h--) comp[q[h]]=comp[f[q[h]]];   // แจกหมายเลขกลุ่มลงต้นไม้ที่ห้อยอยู่
    for(int u=1;u<=n;u++) res[comp[u]]=max(res[comp[u]],t1[u]+t2[u]);
    ll ans=0; for(ll v:res) ans+=v;
    printf("%lld\n",ans);
}
ดูโค้ดที่ผมเขียนผิดรอบแรก และเคสที่เล็กที่สุดที่จับมันได้

ทั้งไฟล์เหมือนโค้ดข้างบนทุกบรรทัด ต่างกันแค่ท้ายฟังก์ชัน ตรงที่เอาค่าของต้นไม้ห้อยมารวมกับคำตอบของกลุ่ม

islands_wrong.cpp · เฉพาะท้ายฟังก์ชัน
    // ... ปอกใบ เก็บ t1/t2 และหาคำตอบฝั่งที่ข้ามวงมาเรียบร้อยแล้ว ...

    for(int h=(int)q.size()-1;h>=0;h--) comp[q[h]]=comp[f[q[h]]];   // แจกหมายเลขกลุ่มลงต้นไม้ที่ห้อยอยู่

    ll best=0;
    for(int u=1;u<=n;u++) best=max(best,t1[u]+t2[u]);   // เส้นทางที่จมอยู่ในต้นไม้ห้อย คิดถูกแล้ว
    (void)best;                                        // <-- บรรทัดที่ผิด: คิดเสร็จแล้วทิ้ง ไม่ได้เอาไปสู้กับ res เลย

    ll ans=0; for(ll v:res) ans+=v;                    // res มีแต่คำตอบฝั่งที่ข้ามวง
    printf("%lld\n",ans);
}

เคสที่เล็กที่สุดที่ผมเจอว่าสองเวอร์ชันตอบไม่ตรงกัน มีแค่ 6 เกาะ

INPUT
6
3 3
6 3
6 3
6 10
4 5
2 1

วงของกลุ่มนี้คือเกาะ 2 กับเกาะ 6 ส่วนเกาะ 1, 3, 4, 5 ห้อยอยู่ใต้เกาะ 6 ทั้งหมด ทางที่ยาวที่สุดจริง ๆ คือ 5-4-6-3-1 ยาว 5 + 10 + 3 + 3 = 21 มันลงกิ่งฝั่งหนึ่งของเกาะ 6 ไต่ขึ้นมา แล้วลงกิ่งอีกฝั่ง โดยไม่แตะสะพานบนวงสักเส้น เวอร์ชันที่ทิ้ง best มองไม่เห็นทางนี้ มันเลยไปได้ทางที่ดีที่สุดของฝั่งที่ข้ามวงแทน คือ 2-6-4-5 ยาว 3 + 10 + 5 = 18

ตัวอย่างในโจทย์ตอบ 24 เท่ากันทั้งสองเวอร์ชัน เพราะทุกกลุ่มในตัวอย่างมีทางที่ยาวที่สุดข้ามวงอยู่แล้ว ผมจึงไม่มีทางรู้จากตรงนั้นเลยว่าพลาด

เวลาที่วัดได้บนเครื่องผม สวน 1,000,000 เกาะ ทั้งแบบที่วงสั้นเยอะ ๆ และแบบที่เป็นวงเดียวยาวหนึ่งล้าน ใช้เวลา 606 มิลลิวินาที เท่ากันทั้งสองแบบ จากลิมิต 2.2 วินาที

ท่าปอกใบกับการแยกวงในกราฟแบบนี้ใช้ซ้ำได้กับอีกหลายข้อ อ่านเวอร์ชันเต็มได้ที่ กราฟที่ทุกปมมีทางออกทางเดียว ส่วนเรื่องเส้นผ่านศูนย์กลางของต้นไม้อยู่ใน ดีพีบนต้นไม้

อีกท่าหนึ่งของค่าเดียวกัน · BFS สองรอบ ที่สั้นกว่าครึ่ง

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

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

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

ระวัง ข้ออ้างนี้ใช้ได้แค่บนต้นไม้

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

tree_diameter.cpp
// เส้นผ่าศูนย์กลางของต้นไม้ ด้วย BFS สองรอบ
// รอบแรกจากปมใดก็ได้ หาปมที่ไกลสุด รอบสองเริ่มจากปมนั้น ระยะที่ไกลสุดคือคำตอบ
// อินพุต: n แล้ว n-1 บรรทัด u v   เอาต์พุต: ความยาวเส้นผ่าศูนย์กลาง (นับเป็นจำนวนเส้น)
#include <bits/stdc++.h>
using namespace std;

/** คืนคู่ (ปมที่ไกลสุด, ระยะถึงปมนั้น) เมื่อเริ่มจาก s */
pair<int, int> far(int s, const vector<vector<int>>& g) {
    int n = (int)g.size();
    vector<int> dist(n, -1);
    dist[s] = 0;
    vector<int> q{s};
    for (int h = 0; h < (int)q.size(); h++)
        for (int v : g[q[h]])
            if (dist[v] == -1) {
                dist[v] = dist[q[h]] + 1;
                q.push_back(v);
            }
    int best = s;
    for (int i = 0; i < n; i++) if (dist[i] > dist[best]) best = i;
    return {best, dist[best]};
}

int main() {
    int n;
    if (scanf("%d", &n) != 1) return 0;
    vector<vector<int>> g(n);
    for (int i = 0; i + 1 < n; i++) {
        int u, v;
        scanf("%d %d", &u, &v);
        g[u].push_back(v);
        g[v].push_back(u);
    }
    if (n == 1) { printf("0\n"); return 0; }
    int a = far(0, g).first;      // ปมปลายทางหนึ่งของเส้นผ่าศูนย์กลาง
    printf("%d\n", far(a, g).second);
    return 0;
}
ตัวตรวจที่ทำให้กล้าเชื่อโค้ดข้างบน

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

brute_diameter.cpp
// ตัวตรวจอิสระของเส้นผ่าศูนย์กลาง: BFS จากทุกปม เอาระยะที่มากที่สุดของทั้งหมด
// ไม่ใช้ข้ออ้างว่า "ปมที่ไกลสุดจากปมใดก็ได้ เป็นปลายของเส้นผ่าศูนย์กลาง" เลย
#include <bits/stdc++.h>
using namespace std;

int main() {
    int n;
    if (scanf("%d", &n) != 1) return 0;
    vector<vector<int>> g(n);
    for (int i = 0; i + 1 < n; i++) {
        int u, v;
        scanf("%d %d", &u, &v);
        g[u].push_back(v);
        g[v].push_back(u);
    }
    int best = 0;
    for (int s = 0; s < n; s++) {
        vector<int> dist(n, -1);
        dist[s] = 0;
        vector<int> q{s};
        for (int h = 0; h < (int)q.size(); h++)
            for (int v : g[q[h]])
                if (dist[v] == -1) {
                    dist[v] = dist[q[h]] + 1;
                    q.push_back(v);
                }
        for (int i = 0; i < n; i++) best = max(best, dist[i]);
    }
    printf("%d\n", best);
    return 0;
}

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

ท่าที่ติดมือกลับไป

โครงสร้างที่ข้อนี้เดินอยู่มีชื่อเรียก คือกราฟฟังก์ชัน (functional graph) กราฟที่ทุกปมมีลูกศรออก เส้นเดียวพอดี ข้อจำกัดข้อเดียวนั้นบังคับรูปร่างไว้หมด คือทุกกลุ่มต้องเป็นวงหนึ่งวงที่มีต้นไม้ห้อยเข้าหาวง ซึ่งเป็นเหตุผลที่เราแยกกรณีได้แค่สองแบบตลอดทั้งเฉลย

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

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

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

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

แหล่งที่มา

  1. โจทย์ Islands บน programming.in.th ข้อ 2009 programming.in.th/tasks/2009 (สืบค้น 6 กันยายน 2026)
  2. ต้นทางของโจทย์คือ 20th International Olympiad in Informatics ที่กรุงไคโร ประเทศอียิปต์ วันแข่งที่ 1