programming.in.th · ข้อ 2029

ปล้นตู้เอทีเอ็ม: กลุ่มที่วิ่งวนถึงกัน คือกองเงินกองเดียวที่ได้ครบเสมอ

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

★★★★☆ sccdfsdp อ่าน 13 นาที 9 กันยายน 2026

โจทย์ · ถนนทางเดียว ตู้เอทีเอ็มทุกแยก และผับที่ต้องไปจบ

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

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

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

EXAMPLE
InputOutput
6 7
1 2
2 3
3 5
2 4
4 1
2 6
6 5
10
12
8
16
1
5
1 4
4 3 5 6
47

เส้นทางที่ได้ 47 คือ 1 ไป 2 ไป 4 กลับมา 1 แล้วไป 2 ไป 3 ไป 5 สังเกตว่าเขาเดินผ่านแยก 1 และ 2 สองครั้ง แต่ได้เงินจากมันครั้งเดียว

ใบ้

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

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

ลองเอง · ขับรถปล้นเอง

สามแยกแรกวิ่งวนกันได้ ลองดูว่าการวนกลับมามีประโยชน์ตรงไหน

1 4 2 7 3 6 4 9 5 3

รถอยู่ที่แยกที่มีลูกศรเข้าจากใจกลางเมือง กดแยกที่มีถนนออกจากแยกปัจจุบันเพื่อขับไป

ปล้นไปแล้ว 0

วงกลมสองชั้นคือแยกที่มีผับ ตัวเลขบนคือหมายเลขแยก ตัวเลขล่างคือเงินในตู้

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

เฉลย ตอนที่ 1 · กลุ่มที่วิ่งวนถึงกัน คือกองเงินกองเดียว

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

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

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

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

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

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

หัวใจของทั้งข้อ
// ถ้าสองแยกวิ่งถึงกันได้ทั้งไปและกลับ มันอยู่กลุ่มเดียวกัน
// และการเข้ากลุ่มนั้นได้ แปลว่าเก็บเงินในกลุ่มได้ครบทุกตู้เสมอ เพราะวิ่งวนได้ไม่จำกัดรอบ
for(int v = 1; v <= n; v++) sum[comp[v]] += cash[v];
1 10 2 12 3 8 4 16 5 1 6 5
ผังในโจทย์ แยก 1, 2, 4 วิ่งถึงกันได้ครบทั้งไปและกลับ จึงเป็นกลุ่มเดียวกันและรวมเงินได้ 38 พอยุบกลุ่มนี้เป็นปมเดียว กราฟที่เหลือไม่มีวงจรอีกเลย เส้นทางที่ดีที่สุดจึงหาได้ด้วยการไล่ค่าไปข้างหน้าทางเดียว

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

เฉลย ตอนที่ 2 · พอไม่มีวงจร ก็ไล่ค่าไปข้างหน้าได้เลย

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

โน้ต · ไม่ต้องเรียงลำดับทอพอโลยีเอง

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

จุดที่ต้องระวังคือขนาดของคำตอบ ผลรวมสูงสุดที่เป็นไปได้คือ 500,000 แยก คูณเงินตู้ละ 4,000 เท่ากับ 2,000,000,000 ซึ่งยังพอดีกับจำนวนเต็ม 32 บิต ที่รับได้ถึง 2,147,483,647 แต่เหลือขอบแค่ราว 7 เปอร์เซ็นต์ ผมจึงใช้ 64 บิตไปเลย เพราะที่นี่มันไม่มีต้นทุนอะไรเลย และเคสทดสอบที่ผมสร้างขึ้นเองก็ชนเพดานนั้นพอดี

เดินตารางให้ดูหนึ่งผัง

ไล่กลุ่มตามลำดับทอพอโลยี บนผังในโจทย์

กลุ่มหลังยุบ และเงินที่เก็บได้
แยกในกลุ่มเงินในกลุ่มเก็บได้ถึงกลุ่มนี้มีผับ
1, 2, 4 38 38 มี
6 5 43 มี
3 8 46 มี
5 1 47 มี

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

โค้ด

ดูโค้ดเต็ม
atm.cpp
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
int n, m;
vector<int> hd, nx, to_;                     // กราฟแบบรายการต่อกัน
vector<int> comp, low, num, stk, cash;

int main(){
    if(scanf("%d %d", &n, &m) != 2) return 0;
    hd.assign(n + 1, -1); nx.resize(m); to_.resize(m);
    for(int i = 0; i < m; i++){ int a, b; scanf("%d %d", &a, &b); to_[i] = b; nx[i] = hd[a]; hd[a] = i; }
    cash.assign(n + 1, 0);
    for(int i = 1; i <= n; i++) scanf("%d", &cash[i]);
    int s, p; scanf("%d %d", &s, &p);
    vector<char> pub(n + 1, 0);
    for(int i = 0; i < p; i++){ int x; scanf("%d", &x); pub[x] = 1; }

    // ทาร์ยานแบบวนลูป ไม่ใช้สแต็กของระบบ เพราะกราฟลึกได้ถึงห้าแสนชั้น
    comp.assign(n + 1, -1); low.assign(n + 1, 0); num.assign(n + 1, 0);
    vector<char> onstk(n + 1, 0);
    vector<int> it(n + 1, -1), callStk;
    int timer = 0, nc = 0;
    for(int s0 = 1; s0 <= n; s0++){
        if(num[s0]) continue;
        callStk.push_back(s0);
        num[s0] = low[s0] = ++timer; stk.push_back(s0); onstk[s0] = 1; it[s0] = hd[s0];
        while(!callStk.empty()){
            int u = callStk.back();
            if(it[u] != -1){
                int e = it[u]; it[u] = nx[e]; int v = to_[e];
                if(!num[v]){ num[v] = low[v] = ++timer; stk.push_back(v); onstk[v] = 1; it[v] = hd[v]; callStk.push_back(v); }
                else if(onstk[v]) low[u] = min(low[u], num[v]);
            } else {
                callStk.pop_back();
                if(!callStk.empty()) low[callStk.back()] = min(low[callStk.back()], low[u]);
                if(low[u] == num[u]){
                    while(true){ int w = stk.back(); stk.pop_back(); onstk[w] = 0; comp[w] = nc; if(w == u) break; }
                    nc++; }
            }
        }
    }

    // ยุบเป็นกราฟไร้วงจร แล้วหาทางที่ได้เงินมากสุด
    vector<ll> sum(nc, 0);
    for(int v = 1; v <= n; v++) sum[comp[v]] += cash[v];
    vector<char> hasPub(nc, 0);
    for(int v = 1; v <= n; v++) if(pub[v]) hasPub[comp[v]] = 1;
    // ลำดับที่ทาร์ยานปิดองค์ประกอบ เป็นลำดับทอพอโลยีกลับหัวอยู่แล้ว
    vector<int> ord(nc); for(int i = 0; i < nc; i++) ord[i] = nc - 1 - i;
    vector<ll> dp(nc, -1); dp[comp[s]] = sum[comp[s]];
    vector<vector<int>> adj(nc);
    for(int u = 1; u <= n; u++) for(int e = hd[u]; e != -1; e = nx[e]){
        int v = to_[e]; if(comp[u] != comp[v]) adj[comp[u]].push_back(comp[v]); }
    for(int idx = 0; idx < nc; idx++){
        int c = ord[idx]; if(dp[c] < 0) continue;
        for(int d : adj[c]) if(dp[c] + sum[d] > dp[d]) dp[d] = dp[c] + sum[d];
    }
    ll ans = 0;
    for(int c = 0; c < nc; c++) if(hasPub[c] && dp[c] > ans) ans = dp[c];
    printf("%lld\n", ans);
}
ดูตัวตรวจที่ใช้เทียบ
brute_atm.cpp
// ตัวตรวจ ค้นทุกสถานะ (จุดที่ยืน, เซตของตู้ที่ปล้นแล้ว)
// ไม่มีคำว่าองค์ประกอบเชื่อมโยงเข้มอยู่ในนี้เลย ใช้ได้เฉพาะกราฟจิ๋ว
#include <bits/stdc++.h>
using namespace std;
int main(){
    int n, m; scanf("%d %d", &n, &m);
    vector<vector<int>> g(n);
    for(int i = 0; i < m; i++){ int a, b; scanf("%d %d", &a, &b); g[a-1].push_back(b-1); }
    vector<int> w(n); for(auto &x : w) scanf("%d", &x);
    int s, p; scanf("%d %d", &s, &p); s--;
    vector<int> pub(n, 0); for(int i = 0; i < p; i++){ int x; scanf("%d", &x); pub[x-1] = 1; }
    vector<vector<int>> best(n, vector<int>(1 << n, -1));
    deque<pair<int,int>> q; int st = 1 << s; best[s][st] = w[s]; q.push_back({s, st});
    while(!q.empty()){
        auto [u, mask] = q.front(); q.pop_front(); int cur = best[u][mask];
        for(int v : g[u]){
            int nm = mask | (1 << v);
            int nv = cur + ((mask >> v & 1) ? 0 : w[v]);
            if(nv > best[v][nm]){ best[v][nm] = nv; q.push_back({v, nm}); } }
    }
    int ans = -1;
    for(int u = 0; u < n; u++) if(pub[u]) for(int mk = 0; mk < (1 << n); mk++) ans = max(ans, best[u][mk]);
    printf("%d\n", ans);
}

ตัวตรวจเก็บสถานะเป็นคู่ของแยกที่ยืนกับเซตของตู้ที่ปล้นแล้ว จึงไม่มีความรู้เรื่องการยุบกลุ่มเลย ผมสุ่มกราฟไม่เกิน 8 แยกและ 16 ถนน โดยจงใจให้มีวงเยอะ รวม 1,023 เคสที่ไปถึงผับได้ แล้วเทียบกัน ตรงกันหมด หน้าเว็บนี้ก็รันด่านตรวจเดียวกันตอนบิลด์ 260 ชุด

เวลาที่วัดได้บนเครื่องผม กราฟ 500,000 แยก 500,000 ถนน แบบทางยาวบวกถนนสุ่ม ใช้เวลา 0.41 วินาที ส่วนกรณีสุดขั้วที่ทั้งเมืองเป็นวงเดียว คือห้าแสนแยกวนกลับมาที่เดิม ใช้เวลา 0.38 วินาที และให้คำตอบ 2,000,000,000 พอดี จากลิมิต 1.5 วินาที

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

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

ทบทวนพื้นฐาน · ทำไมกราฟที่ยุบแล้วจึงไม่มีวงจร

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

ท่าเดียวกันนี้ปรากฏในโจทย์อื่นบ่อยมาก ถ้าอยากดูกราฟที่รูปถูกล็อกไว้ด้วยกติกาแทน ลองอ่าน กราฟที่ทุกปมมีทางออกทางเดียว ซึ่งวงจรก็เป็นตัวเอกเหมือนกัน

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

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

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

แหล่งที่มา

  1. โจทย์ ATM บน programming.in.th ข้อ 2029 programming.in.th/tasks/2029 (สืบค้น 9 กันยายน 2026)
  2. ต้นทางของโจทย์คือ Asia-Pacific Informatics Olympiad 2009