programming.in.th · ข้อ 2005

ปิดถนนกันซ้อม: ขอบเขตที่ดูเป็นของแถม คือกุญแจของทั้งข้อ

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

★★★★★ treedpstate compressionparity อ่าน 15 นาที เผยแพร่ 7 กันยายน 2026 อัปเดต 19 ตุลาคม 2026

โจทย์ · ตัดถนนให้ไม่เหลือเส้นทางซ้อมที่ยุติธรรม

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

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

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

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

EXAMPLE
InputOutput
5 8
2 1 0
3 2 0
4 3 0
5 4 0
1 3 2
3 5 2
2 4 5
2 5 1
5

ตรงไหนที่ทำให้ข้อนี้ไม่ง่าย

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

แต่ขอบเขตจริงมีถนนได้ 5,000 สาย ในนั้นเป็นถนนราดยาง N − 1 = 999 สาย ถนนไม่ราดยางจึงมีได้ถึง 4,001 สาย จำนวนเซตคือสองยกกำลัง 4,001

ด่านที่สองหนักกว่า ต่อให้มีคนยื่นเซตหนึ่งมาให้ การตรวจว่าใช้ได้ไหมต้องดูทุกวงปิดที่ยังเหลือ ซึ่งในภาษากราฟเรียกว่าวงจรอย่างง่าย (simple cycle คือวงที่ไม่ผ่านเมืองซ้ำ)

วงพวกนี้มีมากกว่าจำนวนถนนเยอะ ตัวอย่างเล็ก ๆ ในโจทย์มีถนนแค่ 8 สาย แต่มีวงจรอย่างง่ายอยู่ 12 วง แค่ตรวจคำตอบหนึ่งข้อก็แพงแล้ว

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

ใบ้

ถนนราดยางเป็นต้นไม้ ดังนั้นถนนไม่ราดยางหนึ่งเส้นที่เชื่อม A กับ B จะสร้างวงขึ้นมาพอดีหนึ่งวง คือเส้นทางบนต้นไม้จาก A ไป B บวกกับตัวมันเอง

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

ค่าปิด 2 ค่าปิด 2 ค่าปิด 5 ค่าปิด 1 1 2 3 4 5
ตัวอย่างในโจทย์ เมือง 5 เมืองเรียงตามถนนราดยาง 4 สาย ส่วนเส้นโค้งด้านบนคือถนนไม่ราดยาง 4 สายพร้อมค่าปิด ลองนับดูว่าแต่ละเส้นสร้างวงยาวเท่าไร (วาดประกอบโดยผู้เขียน)

ลองเอง · ปิดถนนให้ถูกที่สุด

ห้าเมืองเดียวกับที่บทความใช้

กดปิดถนนที่ไม่ราดยางได้ตามใจ แล้วกดตรวจว่าเหลือเส้นทางซ้อมที่ยุติธรรมไหม

จ่ายไปแล้ว 0 · ถูกที่สุดคือ ?

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

ตัวตรวจของเกมนี้ไม่ได้ใช้กฎย่อของเฉลยเลย มันไล่หาวงจรทุกวงจริง ๆ ถ้าเดาแล้วผิด แปลว่ากฎในหัวยังไม่ตรงกับของจริง

เฉลย ตอนที่ 1 · ไล่ทุกเซต ไล่ทุกวง

ก่อนหาทางลัด เริ่มจากวิธีที่ตรงกับนิยามของโจทย์ที่สุด ซึ่งก็คือสิ่งที่ตัวตรวจของเกมข้างบนทำ

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

ตั้งแต่ตรงนี้ไป น้ำหนักของถนนไม่ราดยางคือค่าปิดของมัน มองจากฝั่งที่เก็บไว้ เก็บถนนที่หนักไว้ได้มากเท่าไร ก็จ่ายค่าปิดน้อยลงเท่านั้น

บนตัวอย่างในโจทย์ วิธีนี้ลอง 16 แบบแล้วได้ 5 ตรงกับเฉลย แต่ที่ขอบเขตเต็มมันต้องลองสองยกกำลัง 4,001 แบบ ซึ่งไม่มีทางจบ

ดูโค้ดของวิธีนี้ (ใช้เป็นตัวตรวจของทั้งหน้า)
train_check.cpp
// ตัวตรวจสอบที่ไม่เชื่อทฤษฎีอะไรเลย ใช้ได้แค่กราฟจิ๋ว ๆ ตอนสุ่มเทียบก่อนส่ง
// ไล่ทุกวิธีเลือกว่าจะ "เก็บ" ถนนไม่ราดยางเส้นไหนไว้บ้าง แล้วไล่หาวงจรอย่างง่ายทุกวง
// ถ้าเจอวงยาวคู่แม้แต่วงเดียวก็ตัดทิ้ง ที่เหลือค่อยเอาน้ำหนักที่เก็บได้มากที่สุด
#include <bits/stdc++.h>
using namespace std;
int n;
vector<array<int,2>> E;                     // เส้นเชื่อมของกราฟที่กำลังตรวจ
vector<vector<pair<int,int>>> adj;          // (เพื่อนบ้าน, หมายเลขเส้น)
bool found;

void walk(int start, int cur, vector<char>& usedEdge, vector<char>& vis, int len) {
    for (auto& [w, ei] : adj[cur]) {
        if (usedEdge[ei]) continue;
        if (w == start) { if (len + 1 >= 2 && (len + 1) % 2 == 0) found = true; continue; }
        if (vis[w] || w < start) continue;   // บังคับให้ปมเล็กสุดของวงเป็นจุดเริ่ม กันนับซ้ำ
        vis[w] = 1; usedEdge[ei] = 1;
        walk(start, w, usedEdge, vis, len + 1);
        vis[w] = 0; usedEdge[ei] = 0;
        if (found) return;
    }
}
bool hasEvenCycle() {
    found = false;
    for (int s = 1; s <= n && !found; s++) {
        vector<char> usedEdge(E.size(), 0), vis(n + 1, 0);
        vis[s] = 1;
        walk(s, s, usedEdge, vis, 0);
    }
    return found;
}
int main() {
    int m; scanf("%d %d", &n, &m);
    vector<array<int,3>> tree, extra;
    long long total = 0;
    for (int i = 0; i < m; i++) {
        int a, b, c; scanf("%d %d %d", &a, &b, &c);
        if (c == 0) tree.push_back({a, b, c});
        else { extra.push_back({a, b, c}); total += c; }
    }
    long long best = 0;
    for (int mask = 0; mask < (1 << (int)extra.size()); mask++) {
        E.clear();
        for (auto& e : tree) E.push_back({e[0], e[1]});
        long long w = 0;
        for (size_t j = 0; j < extra.size(); j++)
            if (mask >> j & 1) { E.push_back({extra[j][0], extra[j][1]}); w += extra[j][2]; }
        adj.assign(n + 1, {});
        for (size_t i = 0; i < E.size(); i++) {
            adj[E[i][0]].push_back({E[i][1], (int)i});
            adj[E[i][1]].push_back({E[i][0], (int)i});
        }
        if (!hasEvenCycle()) best = max(best, w);
    }
    printf("%lld\n", total - best);
}

ต้องหาทางตัดสินถนนทีละเส้นแทนการไล่เซต ไล่จากช้าไปเร็วมีสามวิธี วิธีที่ 1 คือตอนนี้ วิธีที่ 2 ใช้สองกฎจากตอนที่ 2 ถึง 4 และวิธีเต็มคือดีพีในตอนที่ 5 กับโค้ดในตอนที่ 6

วิธีที่ 1 · ตอนที่ 1

ไล่ทุกเซต แล้วไล่ทุกวง

สองยกกำลัง K เซต คูณจำนวนวง

ตรงกับนิยามที่สุด ไม่ต้องเชื่อทฤษฎีอะไร (K คือจำนวนถนนไม่ราดยาง)

ได้แค่กราฟจิ๋ว ใช้เป็นตัวตรวจ

วิธีที่ 2 · ตอนที่ 2 ถึง 4

สองกฎ แล้วไล่เซตของเส้นที่เหลือ

ยังเป็นสองยกกำลังจำนวนเส้น

กฎสองข้อทำให้ไม่ต้องหาวงอีกเลย ตรวจแค่ว่าเส้นทางบนต้นไม้ทับกันไหม แต่ยังต้องไล่เซต

ได้แค่ถนนไม่ราดยางไม่กี่เส้น

วิธีเต็ม · ตอนที่ 5 และ 6

สองกฎ แล้วดีพีบนต้นไม้

ราว M × N + M × 210

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

ครบขอบเขต

เฉลย ตอนที่ 2 · ถนนหนึ่งเส้น วงหนึ่งวง (กฎที่ 1)

ดูถนนไม่ราดยางทีละเส้นก่อน ถ้าเส้นทางบนต้นไม้ของมันยาว len สาย วงของมันยาว len + 1

กฎที่ 1

ถ้า len เป็นเลขคี่ วงยาวเป็นเลขคู่ ถนนเส้นนั้นต้องถูกปิดเสมอ ไม่ว่าจะเก็บเส้นอื่นไว้หรือไม่

ส่วนเส้นที่ len เป็นเลขคู่ วงของมันยาวคี่ ถ้าอยู่คนเดียวก็ไม่ผิดกฎ ชุดที่ 3 ของเกมคือกรณีนี้

ตัวอย่างในโจทย์ · ถนนไม่ราดยางทั้ง 4 เส้น
ถนนค่าปิดเส้นทางบนต้นไม้ ยาวตัดสิน
1-3 2 1 - 2 - 3 2 เก็บได้ ถ้าไม่ทับใคร
3-5 2 3 - 4 - 5 2 เก็บได้ ถ้าไม่ทับใคร
2-4 5 2 - 3 - 4 2 เก็บได้ ถ้าไม่ทับใคร
2-5 1 2 - 3 - 4 - 5 3 ต้องปิดเสมอ (วงยาวคู่)
ค่าปิดรวมทุกเส้นคือ 10 มีเส้นที่ต้องปิดแน่นอน 1 เส้น รวมค่าปิด 1 เหลือ 3 เส้นที่ต้องตัดสินกันต่อ

เฉลย ตอนที่ 3 · สองวงที่ทับกัน (กฎที่ 2)

กฎที่ 1 ยังไม่พอ ถนนสองเส้นที่วงยาวคี่ทั้งคู่ อยู่คนเดียวได้ แต่เปิดพร้อมกันอาจเกิดวงคู่ (เกมชุดที่ 4 คือกรณีนี้)

ตัวอย่างในโจทย์ก็มีคู่แบบนั้น คือถนน 1-3 กับ 2-4 เส้นทางบนต้นไม้ของทั้งสองใช้ถนนราดยาง 2-3 ร่วมกัน

ถนน 1-3 · วง 3 ถนน 2-4 · วง 3 1 2 3 4 5 ทับกัน ตัดออก เขียว คือวงใหม่ ยาว 4
ถนน 1-3 กับ 2-4 แต่ละเส้นสร้างวงยาว 3 และ 3 ตัดถนนราดยางที่ทับกัน (สีแดง) ออก ที่เหลือ (สีเขียว) ต่อกันเป็นวงใหม่ยาว 4 ซึ่งเป็นเลขคู่

นับความยาวของวงใหม่

  • คำถาม: เอาสองวงมาต่อกันแล้วตัดส่วนที่ทับออก ได้วงยาวเท่าไร
  • สูตร: (วงแรก) + (วงที่สอง) − 2 × (จำนวนถนนราดยางที่ทับ)
  • แทนค่า: 3 + 3 − 2 × 1 = 4
  • นับถนนบนวงสีเขียวในภาพตรง ๆ ได้ 4 สายเหมือนกัน

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

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

กฎที่ 2

ถนนไม่ราดยางที่เก็บไว้ ห้ามมีสองเส้นไหนที่เส้นทางบนต้นไม้ใช้ถนนราดยางเส้นเดียวกัน

แตะกันแค่เมืองเดียวไม่นับว่าทับ เช่น 1-3 กับ 3-5 พบกันที่เมือง 3 อย่างเดียว วงสองวงนี้ต่อกันได้แค่แบบเดินผ่านเมือง 3 สองรอบ ซึ่งไม่นับเป็นเส้นทางซ้อม

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

ถึงตรงนี้ ปัญหาเหลือแค่นี้: เลือกถนนจากเส้นที่ผ่านกฎที่ 1 โดยเส้นทางไม่ทับกัน ให้น้ำหนักรวมมากที่สุด แล้วคำตอบคือค่าปิดทั้งหมดลบน้ำหนักที่เก็บได้

เลือกชุดที่ไม่ทับกัน จากเส้นที่เก็บได้ 3 เส้น
ชุดที่เลือกถนนราดยางที่ถูกใช้น้ำหนักรวม
1-3 เส้นเดียว 1-2, 2-3 2
3-5 เส้นเดียว 3-4, 4-5 2
2-4 เส้นเดียว 2-3, 3-4 5
1-3 กับ 3-5 1-2, 2-3, 3-4, 4-5 4
ชุดที่หนักที่สุดคือ 2-4 น้ำหนัก 5 คำตอบจึงเป็น 10 − 5 = 5 ตรงกับที่โจทย์เฉลย โจทย์ยังบอกเองด้วยว่าปิดแค่ 2-4 กับ 2-5 จะเสีย 6 ซึ่งแพงกว่า
ค่า 2 · ยาว 2 · วง 3 ค่า 2 · ยาว 2 · วง 3 ค่า 5 · ยาว 2 · วง 3 ค่า 1 · ยาว 3 · วง 4 1 2 3 4 5 เขียวทึบ เก็บไว้ได้จริง แดง วงเป็นเลขคู่ ต้องปิด
ภาพเดียวกับในคำใบ้ ระบายตามสองกฎ เส้นแดงต้องปิดตามกฎที่ 1 เส้นสีจางเก็บได้ตามกฎที่ 1 แต่เส้นทางทับกับเส้นที่คุ้มกว่า จึงไม่ได้เก็บตามกฎที่ 2 เส้นเขียวคือที่เก็บจริง น้ำหนัก 5 จากค่าปิดทั้งหมด 10 คำตอบจึงเป็น 5

เฉลย ตอนที่ 4 · ไม่ทับกันแล้วปลอดภัยจริงไหม

ทำไมไม่เชื่อแค่การพิสูจน์

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

ผมจึงใช้โค้ดของตอนที่ 1 เป็นตัวตรวจ ตัวนี้ไม่รู้จักสองกฎเลย ถ้าคำตอบตรงกับโค้ดจริง ก็เป็นหลักฐานที่ไม่ได้อาศัยกฎที่กำลังตรวจ (ผลการสุ่มเทียบอยู่ในตอนที่ 6)

ส่วนเหตุผลแบบไม่ต้องสุ่ม มองวงเป็นเซตของถนน แล้วไปทีละขั้น

อยากเห็นเหตุผลของทิศกลับ
  • วงใด ๆ ในกราฟ (มองเป็นเซตของถนน) เท่ากับเอาวงของถนนไม่ราดยางทุกเส้นที่มันใช้มากองรวมกัน แล้วทิ้งถนนราดยางที่โผล่เป็นจำนวนคู่ครั้ง เพราะมันหักล้างกันเอง
  • ตัวอย่าง: วงใหม่ในภาพของตอนที่ 3 ใช้ถนน 1-3 กับ 2-4 กองวงของสองเส้นรวมกัน ถนนราดยาง 2-3 โผล่สองครั้งจึงหายไป ที่เหลือคือ 1-2, 3-4 กับถนนไม่ราดยางสองเส้น ตรงกับวงสีเขียวพอดี
  • เมื่อเส้นทางไม่ทับกัน วงของถนนแต่ละเส้นไม่มีถนนราดยางร่วมกันเลย จึงไม่มีอะไรหักล้าง วงใดก็ตามจึงเท่ากับวงของถนนแต่ละเส้นที่มันใช้ วางเรียงกันเฉย ๆ
  • วงจรอย่างง่ายไม่มีวงที่เล็กกว่าซ่อนอยู่ข้างใน ถ้ามันประกอบจากวงของถนนสองเส้นขึ้นไป วงของเส้นเดียวก็จะเป็นวงที่ซ่อนอยู่ มันจึงเป็นวงของถนนเส้นเดียว ซึ่งยาวคี่ตามกฎที่ 1

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

เฉลย ตอนที่ 5 · ดีพีบนต้นไม้

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

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

เลข 10 เข้ากันพอดีกับการเก็บเซตของถนนรอบเมืองหนึ่งไว้ใน mask (หน้ากากบิต คือเลขฐานสองตัวเดียว บิตละหนึ่งสมาชิก) สองยกกำลัง 10 คือ 1,024 แบบ ไล่ครบได้ทุกเมือง

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

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

ตอนที่ 3 เหลือโจทย์ข้อเดียว คือเลือกถนนที่เส้นทางไม่ทับกันให้น้ำหนักมากสุด ไล่เซตไม่ได้ (ตอนที่ 1) ต้องตัดสินทีละเมือง

เริ่มจากปักรากต้นไม้ไว้ที่เมือง 1 แล้วแขวนเมืองอื่นลงมาเป็นชั้น ๆ คำที่ใช้ตลอดตอนนี้มีสี่คำ

  • พ่อ ของเมืองหนึ่ง คือเมืองที่อยู่ติดกันและอยู่ชั้นบนกว่า (รากไม่มีพ่อ)
  • ลูก คือเมืองที่อยู่ติดกันและอยู่ชั้นล่างกว่า หนึ่งเมืองมีลูกได้หลายคน
  • ใบ คือเมืองที่ไม่มีลูก
  • ต้นไม้ย่อย (subtree) ของเมือง v คือ v กับทุกเมืองที่อยู่ใต้มันลงไป

เราจะคิดจากล่างขึ้นบน เมืองหนึ่งถูกคิดหลังลูกของมันทุกคนคิดเสร็จแล้วเสมอ ลำดับแบบนี้เรียกว่าลำดับหลังลูก (post-order)

เส้นทางของถนนแต่ละเส้นไต่ขึ้นจากปลายทั้งสองข้าง ไปพบกันที่เมืองที่สูงที่สุดบนเส้นทาง เมืองนั้นเรียกว่าปมร่วมที่ต่ำที่สุด (lowest common ancestor ย่อว่า LCA) ของปลายทั้งสอง

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

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

1 2 3 4 5 6 7 8 9 10 11 12
ต้นไม้ตัวอย่างของตอนนี้ ปักรากที่เมือง 1 เมืองที่อยู่ใต้กันคือพ่อกับลูก ถนนไม่ราดยางทั้ง 7 เส้นอยู่ในตารางถัดไป
ถนนไม่ราดยางของตัวอย่าง
ถนนน้ำหนักเส้นทางบนต้นไม้ปมร่วมลูกของปมร่วมที่ใช้
4-5 3 4 - 2 - 5 2 4, 5
2-8 2 2 - 5 - 8 2 5
3-9 2 3 - 7 - 9 3 7
6-7 3 6 - 3 - 7 3 6, 7
4-6 9 4 - 2 - 1 - 3 - 6 1 2, 3
2-3 4 2 - 1 - 3 1 2, 3
10-12 3 10 - 11 - 12 10 11
คอลัมน์ขวาสุดคือถนนราดยางที่ต่อปมร่วมกับลูกของมัน ซึ่งเส้นทางเส้นนี้จะจองไว้

นึกภาพ

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

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

สถานะ dp[v][mask]

v คือเมือง (ในภาษากราฟเรียกว่าปม หรือ node) และ mask คือเซตของลูกของ v ที่ถนนราดยางเส้น v ถึงลูกคนนั้นถูกเส้นทางจากข้างบนจองไปแล้ว ค่าของมันคือน้ำหนักรวมมากที่สุดของถนนที่เก็บได้ โดย

  • นับเฉพาะถนนที่ปมร่วมอยู่ในต้นไม้ย่อย (subtree) ของ v
  • ไม่นับกิ่งของลูกที่อยู่ใน mask ทั้งกิ่ง เส้นทางที่จองลูกคนนั้นจะคิดกิ่งนั้นให้เอง
  • ห้ามใช้ถนนราดยางที่ต่อ v กับลูกใน mask
  • เส้นทางของถนนที่เก็บต้องไม่ทับกัน (กฎที่ 2)

ลูกมีไม่เกิน 10 คน mask จึงมีไม่เกิน 1,024 แบบ คำตอบของส่วนนี้คือ dp[1][∅]

เติม dp[v][mask] สองทาง เอาทางที่มากกว่า

  • ไม่เก็บถนนที่ v: รวม dp[ลูก][∅] ของลูกทุกคนที่ไม่อยู่ใน mask
  • เก็บถนน e หนึ่งเส้นที่ v (ลูกที่ e จอง เรียกว่า se ต้องไม่ชนกับ mask): น้ำหนักของ e + ผลรวม dp ของเมืองบนเส้นทางของ e ที่อยู่ใต้ v + dp[v][mask ∪ se]

ผลรวมตามเส้นทาง: แต่ละเมืองบนเส้นทางใช้ค่า dp ที่จองลูกคนที่เส้นทางเพิ่งไต่ขึ้นมา ส่วนเมืองปลายทางใช้ [∅] ถ้าปลายข้างหนึ่งคือ v เอง (เช่นถนน 2-8) ฝั่งนั้นไม่มีอะไรให้ไต่ ไต่แค่อีกฝั่งเดียว

mask ∪ se คือเซตของลูกที่ถูกจองหลังเก็บ e ไปแล้ว ในโค้ดเขียนว่า mask | se คือเอาบิตของสองเซตมารวมกัน (OR) พจน์นี้ถามว่าลูกที่ยังว่างอยู่ ทำต่อได้อีกเท่าไร

ลองกับตัวอย่าง: ถนน 4-6 ที่เมือง 1

  • คำถาม: ถ้าเก็บถนน 4-6 ไว้ ได้น้ำหนักรวมเท่าไร
  • สูตร: น้ำหนัก + dp ของทุกเมืองบนเส้นทาง 4 - 2 - 1 - 3 - 6 ยกเว้นเมือง 1 + dp[1][{2,3}]
  • แทนค่า: 9 + dp[4][∅] 0 + dp[2][{4}] 2 + dp[6][∅] 0 + dp[3][{6}] 2 + dp[1][{2,3}] 3 = 16
  • dp[1][{2,3}] = 3 มาจากลูกที่ยังว่าง: dp[10][∅] 3
  • ทางอื่นที่เมือง 1 ได้น้อยกว่า: ไม่เก็บถนนที่เมือง 1 ได้ 9 · เก็บ 2-3 แทนได้ 13
  • ได้ dp[1][∅] = 16 ตรงกับการไล่ทุกเซตตรง ๆ (ตอนที่ 1)

ทำไม dp[2][{4}] ได้ 2: เมือง 2 ถูกจองลูก 4 ไปแล้ว ถนน 4-5 ต้องผ่านลูกคนนั้นจึงใช้ไม่ได้ เหลือ 2-8 น้ำหนัก 2 ที่ยังเก็บได้

ลองเดินทั้งต้นไม้ทีละเมืองตามลำดับหลังลูก แต่ละขั้นเส้นสีคือเส้นทางของถนนที่ตัดสินที่เมืองนั้น บรรทัดที่ขึ้นต้นด้วย [ ] บอกว่ากำลังเติม mask ไหน

ต้นไม้ 12 เมือง ถนนไม่ราดยาง 7 เส้น คิดจากใบขึ้นไปหาราก

1 2 3 4 5 6 7 8 9 10 11 12
เมืองลูกถนนที่ตัดสินที่นี่dp ตามลูกที่ถูกจอง
4 ใบ · ∅ 0
8 ใบ · ∅ 0
5 8 · ∅ 0 · {8} 0
2 4, 5 4-5, 2-8 ∅ 3 · {4} 2 · {5} 0 · {4,5} 0
6 ใบ · ∅ 0
9 ใบ · ∅ 0
7 9 · ∅ 0 · {9} 0
3 6, 7 3-9, 6-7 ∅ 3 · {6} 2 · {7} 0 · {6,7} 0
12 ใบ · ∅ 0
11 12 · ∅ 0 · {12} 0
10 11 10-12 ∅ 3 · {11} 0
1 2, 3, 10 4-6, 2-3 ∅ 16 · {2} 6 · {3} 6 · {2,3} 3 · {10} 13 · {2,10} 3 · {3,10} 3 · {2,3,10} 0

ในโค้ด d0[j] คือ dp[ลูกคนที่ j][∅], cs คือถนนที่ปมร่วมอยู่ที่ v และ se คือบิตของลูกที่ถนนนั้นจอง ส่วน g คือแถว dp[v] ที่กำลังเติม

ลูปเติม dp[v] ของหนึ่งเมือง
// g[mask] = น้ำหนักมากที่สุดในต้นไม้ย่อยของ v เมื่อลูกในเซต mask ถูกจองไปแล้ว
for (int mk = 0; mk < full; mk++) {
    long long s = sum;                         // ค่าเริ่มต้นคือปล่อยลูกทุกคนทำงานของตัวเอง
    for (int j = 0; j < k; j++) if (mk >> j & 1) s -= d0[j];
    g[mk] = s;
}
// ลูปนอกไล่ mk จากค่ามากลงมาหาค่าน้อย (เหตุผลอยู่ในข้อ 3 ของเช็กความเข้าใจ)
for (int mk = full - 1; mk >= 0; mk--)
    for (auto& c : cs)                         // cs = ถนนไม่ราดยางที่มีจุดร่วมสูงสุดอยู่ที่ v
        if (!(c.se & mk)) g[mk] = max(g[mk], c.val + g[mk | c.se]);

เฉลย ตอนที่ 6 · โค้ดและเวลา

ที่ N ไม่เกิน 1000 การหาปมร่วมไม่ต้องใช้ท่าหรู ไต่ขึ้นตามพ่อทีละชั้นก็พอ ถนนแต่ละเส้นไต่ไม่เกิน N ชั้น ทั้งการหาปมร่วมและการรวม dp ตามเส้นทาง

  • ไต่ตามเส้นทาง: ไม่เกิน M × N = 5,000,000 ก้าว
  • ลองเก็บถนน: แต่ละเส้นถูกลองกับทุก mask ของปมร่วม ซึ่งมี 2จำนวนลูก แบบ เพดานคือ M × 210 = 5,120,000 ก้าว ปมร่วมส่วนใหญ่ลูกน้อยกว่านั้น ของจริงจึงน้อยกว่ามาก
  • เติมค่าไม่เก็บ: เมืองที่มีลูก k คนใช้ราว 2k × k ก้าว จำนวนลูกรวมทั้งต้นไม้คือ N − 1 จึงไม่เกิน (N − 1) × 210 = 1,022,976 ก้าว
  • งบเวลา 0.3 วินาที ใช้กฎหยาบ ๆ ว่าเครื่องทำได้ราว 108 ก้าวต่อวินาที ได้ราว 30,000,000 ก้าว ทั้งสามก้อนอยู่ในงบ
แผนที่ของโค้ด
ช่วงในโค้ดทำอะไรอ่านคู่กับ
bfsTreeปักรากที่เมือง 1 จดพ่อ ความลึก และลำดับของลูกแต่ละคน (เป็นตำแหน่งบิต)ตอนที่ 5
lcaOfไต่ขึ้นทีละชั้นจนพบปมร่วมที่ต่ำที่สุดตอนที่ 5
len%2==1ทิ้งถนนที่เส้นทางยาวคี่ (ปิดเสมอ)ตอนที่ 2
exAt[l]แขวนถนนไว้ที่ปมร่วมของมันตอนที่ 5
pathSumรวม dp ของเมืองบนเส้นทาง แต่ละเมืองจองลูกที่เส้นทางเพิ่งขึ้นมาตอนที่ 5
สองลูปของ gเติมแถว dp[v] ไล่ mask จากใหญ่ไปเล็ก กฎที่ 2 ซ่อนอยู่ในการเช็กว่าบิตไม่ชนตอนที่ 3, 5
total - dp[1][0]ค่าปิดทั้งหมดลบน้ำหนักที่เก็บได้ตอนที่ 3
ดูโค้ดเต็ม
train.cpp
#include <bits/stdc++.h>
using namespace std;
int n,mE;
vector<int> ch[1005];              // ลูกของแต่ละปม
int par[1005],dep[1005],idxInPar[1005];
vector<vector<int>> dp;            // dp[v][mask]
struct Ex{ int a,b,w,lca,sa,sb; }; // sa,sb = บิตของลูกที่เส้นทางใช้ (0 ถ้าไม่มี)
vector<Ex> exAt[1005];
vector<pair<int,int>> tadj[1005];

void bfsTree(){
    vector<int> st{1}; par[1]=0; dep[1]=0;
    vector<char> vis(n+1,0); vis[1]=1;
    for(size_t i=0;i<st.size();i++){ int u=st[i];
        for(auto&[v,w]:tadj[u]) if(!vis[v]){ vis[v]=1; par[v]=u; dep[v]=dep[u]+1; idxInPar[v]=(int)ch[u].size(); ch[u].push_back(v); st.push_back(v); }
    }
}
int lcaOf(int a,int b){ while(dep[a]>dep[b]) a=par[a]; while(dep[b]>dep[a]) b=par[b]; while(a!=b){ a=par[a]; b=par[b]; } return a; }
// ผลรวม dp ของปมบนเส้นทางจาก x ขึ้นไปหา v (ไม่รวม v) โดยแต่ละปมห้ามใช้ลูกที่เส้นทางเพิ่งผ่านมา
long long pathSum(int x,int v,int&firstChild){
    long long s=0; int prev=-1; firstChild=0;
    while(x!=v){ int mask = prev<0 ? 0 : (1<<idxInPar[prev]); s+=dp[x][mask]; prev=x; x=par[x]; }
    if(prev>=0) firstChild = 1<<idxInPar[prev];
    return s;
}
int main(){
    scanf("%d %d",&n,&mE);
    vector<array<int,3>> extra;
    long long total=0;
    for(int i=0;i<mE;i++){ int a,b,c; scanf("%d %d %d",&a,&b,&c);
        if(c==0){ tadj[a].push_back({b,0}); tadj[b].push_back({a,0}); }
        else { extra.push_back({a,b,c}); total+=c; } }
    bfsTree();
    for(auto&e:extra){
        int a=e[0],b=e[1],w=e[2],l=lcaOf(a,b);
        int len=dep[a]+dep[b]-2*dep[l];
        if(len%2==1) continue;                       // เส้นทางยาวคี่ วงจะเป็นคู่ ต้องตัดทิ้งเสมอ
        exAt[l].push_back({a,b,w,l,0,0});
    }
    dp.assign(n+1,{});
    // ไล่แบบ post-order ด้วยสแต็ก
    vector<int> order; order.reserve(n); { vector<int> st{1}; while(!st.empty()){ int u=st.back(); st.pop_back(); order.push_back(u); for(int c:ch[u]) st.push_back(c); } }
    for(int i=(int)order.size()-1;i>=0;i--){
        int v=order[i]; int k=(int)ch[v].size(); int full=1<<k;
        vector<long long> d0(k); long long sum=0;
        for(int j=0;j<k;j++){ d0[j]=dp[ch[v][j]][0]; sum+=d0[j]; }
        vector<long long> g(full);
        for(int mk=0;mk<full;mk++){ long long s=sum; for(int j=0;j<k;j++) if(mk>>j&1) s-=d0[j]; g[mk]=s; }
        // เตรียมค่าของเส้นพิเศษที่มี lca = v
        struct Cand{ int se; long long val; };
        vector<Cand> cs;
        for(auto&e:exAt[v]){
            int ca=0,cb=0;
            long long s=pathSum(e.a,v,ca)+pathSum(e.b,v,cb);
            cs.push_back({ca|cb, s+e.w});
        }
        for(int mk=full-1;mk>=0;mk--)
            for(auto&c:cs) if(!(c.se&mk)) g[mk]=max(g[mk],c.val+g[mk|c.se]);
        dp[v]=vector<int>(full);
        for(int mk=0;mk<full;mk++) dp[v][mk]=(int)g[mk];
    }
    printf("%lld\n", total - dp[1][0]);
}
  • ผ่านตัวอย่างทั้งสองชุดของโจทย์ (ชุดที่ 2 ได้ 48 ตรงกับเฉลย)
  • สุ่มเทียบกับตัวตรวจของตอนที่ 1 สามแบบ ไม่มีชุดไหนต่างกัน: ต้นไม้ไม่เกิน 7 เมืองกับถนนไม่ราดยางไม่เกิน 5 เส้น 1,000 ชุด · แบบเดียวกันแต่ยอมให้ถนนซ้ำคู่เมือง (ไม่เกิน 6 เส้น) 1,000 ชุด · ต้นไม้ 11 ถึง 14 เมืองที่เมืองหนึ่งมีลูกได้ถึง 10 คน ถนนไม่ราดยางไม่เกิน 10 เส้น 400 ชุด
  • ขอบเขตของการสุ่มเทียบมีแค่กราฟเล็ก เพราะตัวตรวจไล่ทุกเซตและทุกวง กราฟใหญ่ตรวจได้แค่เวลา ไม่ได้ตรวจคำตอบ
  • เคสใหญ่ 9 ชุด (N = 1,000 ถนนเกือบ 5,000 สาย ทุกเมืองไม่เกิน 10 สาย ต้นไม้สามรูปทรง: ลึก แตกกิ่ง และสุ่ม) ช้าที่สุด 11 มิลลิวินาที จากงบ 300

เวลาวัดบนเครื่องที่ใช้เขียน รวมเวลาเปิดโปรแกรมแล้ว ยังไม่ได้ส่งเข้าเครื่องตรวจของ programming.in.th

เช็กความเข้าใจ

สามข้อนี้ตอบได้จากสิ่งที่อ่านมา ลองคิดก่อนแล้วค่อยเปิดคำตอบ

ข้อ 1. ถ้าโจทย์กลับด้าน ให้ปิดถนนจนไม่เหลือวงที่ยาวคี่แทน ต้องใช้ดีพีไหม

ดูคำตอบข้อ 1

ไม่ต้อง ถนนที่เส้นทางยาวคู่สร้างวงคี่ จึงต้องปิดทุกเส้น ส่วนเส้นที่เส้นทางยาวคี่สร้างวงคู่ สองวงคู่ที่ทับกันต่อได้แค่วงคู่ (คู่บวกคู่หักคู่) เก็บไว้ได้หมด

อีกมุมหนึ่ง กราฟที่ไม่มีวงคี่คือกราฟที่ระบายสองสี (bipartite coloring) ได้ ต้นไม้ระบายสลับสีตามความลึกได้แบบเดียว ถนนที่ปลายสองข้างคนละสีคือเส้นที่เส้นทางยาวคี่ ซึ่งตรงกับข้างบน

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

ดูคำตอบข้อ 2

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

ข้อ 3. ทำไมในแต่ละเมืองต้องไล่ mask จากใหญ่ไปเล็ก

ดูคำตอบข้อ 3

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

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

สองกฎในเฉลยเป็นข้อสรุปเรื่องคู่คี่ (parity) ทั้งคู่ เราไม่ได้สนใจว่าเส้นทางยาวเท่าไร สนใจแค่ว่ายาวคู่หรือคี่ พอถามแค่นั้น ถนนทั้งกราฟก็แบ่งเป็นสองพวกทันที

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

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

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

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

แหล่งที่มา

  1. โจทย์ Training บน programming.in.th ข้อ 2005 programming.in.th/tasks/2005 (สืบค้น 6 กันยายน 2026)
  2. ต้นทางของโจทย์คือ International Olympiad in Informatics 2007 ที่เมืองซาเกร็บ ประเทศโครเอเชีย วันแข่งที่ 2