programming.in.th · ข้อ 2014

กำแพงกับน้ำท่วม: ประโยคว่าพังทีละชั่วโมง แปลเป็นระยะทางบนกราฟได้ตรง ๆ

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

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

โจทย์ · น้ำท่วมทีละชั้น กำแพงพังทีละชั้น

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

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

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

ทำซ้ำจนน้ำท่วมทั่ว งานของเราคือบอกว่ากำแพงเส้นไหนบ้างที่ยังยืนอยู่

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

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

EXAMPLE
InputOutput
15
1 1
8 1
4 2
7 2
2 3
4 3
6 3
2 5
4 5
6 5
4 6
7 6
1 8
4 8
8 8
17
1 2
2 15
15 14
14 13
13 1
14 11
11 12
12 4
4 3
3 6
6 5
5 8
8 9
9 11
9 10
10 7
7 6
4
6
15
16
17

ใบ้

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

ถ้าเราหาได้ว่าน้ำถึงแต่ละผืนเมื่อชั่วโมงที่เท่าไร คำถามทั้งข้อจะเหลืออะไร

ลองเอง · ทายว่ากำแพงเส้นไหนจะรอด

ผังนี้มีห้องปิดหนึ่งห้อง กับกำแพงเดี่ยว ๆ ที่ไม่ได้ล้อมอะไรเลย

1 2 3 4 5

กดเลือกกำแพงที่คิดว่าจะรอด (ที่เส้นในรูปหรือที่ปุ่ม) แล้วกดตรวจ กำแพงที่ไม่ได้เลือกคือกำแพงที่คิดว่าจะพัง

เลือกไว้ 0 เส้น จากทั้งหมด 0 เส้น

น้ำเริ่มจากทะเลรอบนอก และทะลุกำแพงได้ชั่วโมงละหนึ่งชั้น

เฉลย ตอนที่ 1 · ปล่อยน้ำตามโจทย์ทีละชั่วโมง

เริ่มจากวิธีที่ตรงที่สุด คือทำตามโจทย์ทุกตัวอักษรบนผังตัวอย่าง (ชุดที่ 2 ของเกม) ก่อนอื่นมาดูหน้าตาของผังนี้ให้ชัด

ABCD 1234567891011121314151617 123456789101112131415
ผังตัวอย่าง จุด 15 จุด (เลขสีเทา) กำแพง 17 เส้น (เลขในวงกลม) แบ่งระนาบเป็น 4 ผืน คือ A, B, C, D โดย A คือทะเลรอบนอก

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

กดถัดไปเพื่อปล่อยน้ำทีละชั่วโมง

ABCD 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17

สีฟ้าคือผืนที่เปียกแล้ว เส้นแดงคือกำแพงที่จะพังในชั่วโมงถัดไป เส้นประคือกำแพงที่พังไปแล้ว เส้นเขียวคือกำแพงที่ยืนจนจบ

กำแพง 17 เส้น จับกลุ่มตามผืนสองฝั่ง
ผืนสองฝั่งน้ำถึง (ชั่วโมง)กำแพงผล
A กับ B 0 กับ 1 1, 2, 3, 4, 5 พัง
B ทั้งสองฝั่ง 1 กับ 1 6 รอด
B กับ C 1 กับ 2 11, 12, 13 พัง
B กับ D 1 กับ 2 7, 8, 9, 10, 14 พัง
C กับ D 2 กับ 2 15, 16, 17 รอด
กำแพงในกลุ่มเดียวกันคั่นผืนคู่เดียวกัน ผลของทั้งกลุ่มเลยเหมือนกัน

แถวที่รอดมีสองแบบ กำแพง 6 มีผืน B อยู่ทั้งสองฝั่ง ส่วนกำแพง 15, 16, 17 คั่นผืน C กับ D ซึ่งน้ำถึงในชั่วโมง 2 เหมือนกัน

กฎที่ได้

กำแพงยังยืนอยู่ ก็ต่อเมื่อน้ำถึงผืนสองฝั่งของมันในชั่วโมงเดียวกัน (กำแพงที่มีผืนเดียวกันทั้งสองฝั่งก็เข้ากฎนี้ เพราะผืนเดียวกันย่อมเปียกพร้อมกัน)

0 1 พัง น้ำถึงคนละชั่วโมง 2 2 รอด น้ำถึงพร้อมกัน
ซ้าย: ชั่วโมงสองฝั่งต่างกัน กำแพงพัง ขวา: ชั่วโมงเท่ากัน กำแพงรอด

เหตุผลที่กฎนี้ใช้ได้กับทุกผัง ไล่ทีละข้อ

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

ชั่วโมงของผืนคือระยะทางสั้นที่สุด เมื่อการข้ามกำแพงหนึ่งเส้นนับเป็นหนึ่งก้าว เครื่องมือมาตรฐานสำหรับระยะแบบนี้คือ BFS (breadth-first search แปลว่าการค้นตามแนวกว้าง)

BFS ทำงานยังไง

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

อธิบายเต็มพร้อมตัวอย่างอยู่ในบทกราฟที่โจทย์ไม่ได้ให้มา

ส่วนที่เหลือของบทคือการหาชั่วโมงของทุกผืนให้เร็วพอ

เฉลย ตอนที่ 2 · ตารางช่องละหนึ่งหน่วย (40 คะแนน)

ไล่จากช้าไปเร็วมีสี่ขั้น ตอนนี้กับตอนถัดไปคือสองขั้นกลาง

วิธีที่ 1

ปล่อยน้ำทีละชั่วโมง

ราวจำนวนช่อง × จำนวนชั่วโมง

ทำตามโจทย์ตรง ๆ แบบตอนที่ 1 น้ำไหล หากำแพงที่มีน้ำข้างเดียว ทำซ้ำ

ไม่ได้ส่ง ใช้เป็นตัวตรวจของหน้านี้

วิธีที่ 2

ตาราง + BFS แบบ 0-1

ราวจำนวนช่อง

ได้ชั่วโมงของทุกช่องในรอบเดียว แล้วใช้กฎของตอนที่ 1

40 คะแนน (พิกัดไม่เกิน 500)

วิธีที่ 3

บีบพิกัด + ตารางเดิม

ไม่เกิน (N + 2)² ช่อง

แทนพิกัดด้วยลำดับที่ของมัน ตารางเล็กลงตามจำนวนจุด (ตอนที่ 3)

อีก 15 คะแนน (N ไม่เกิน 500) รวม 55

วิธีเต็ม

เดินรอบผืน + BFS บนกราฟคู่

ราว 12 ก้าวต่อกำแพง

ไม่ใช้ช่องเลย หาผืนจากกำแพงตรง ๆ (ตอนที่ 4 และ 5)

100 คะแนน

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

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

วิธีที่ 2 แบ่งระนาบเป็นช่องละหนึ่งหน่วย กำแพงกลายเป็นขอบระหว่างช่องสองช่องที่ติดกัน เดินข้ามขอบที่ไม่มีกำแพงมีค่าข้าม 0 ข้ามขอบที่มีกำแพงมีค่าข้าม 1

0 1 0 เส้นหนา = กำแพง ข้ามขอบว่าง ค่าข้าม 0 ข้ามกำแพง ค่าข้าม 1
ตารางเล็ก ๆ ที่มีกำแพงยาวสองหน่วย ข้ามขอบว่างมีค่าข้าม 0 ข้ามกำแพงมีค่าข้าม 1 แถวล่างอยู่พ้นปลายกำแพงไปแล้ว จึงข้ามได้ด้วยค่าข้าม 0

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

BFS แบบ 0-1 (0-1 BFS)

  1. ค่าข้ามมีแค่ 0 กับ 1 เลยใช้คิวที่ใส่ได้ทั้งหัวและท้าย ภาษาอังกฤษเรียกว่า deque (double-ended queue)
  2. ข้ามขอบว่าง (ค่าข้าม 0): ช่องใหม่ได้ระยะเท่าเดิม ใส่ไว้หัวคิว
  3. ข้ามกำแพง (ค่าข้าม 1): ช่องใหม่ได้ระยะบวกหนึ่ง ต่อท้ายคิว
  4. คิวยังเรียงตามระยะจากน้อยไปมากเสมอ ช่องที่หยิบออกมาได้ระยะน้อยที่สุดแล้ว เหมือน BFS ธรรมดา

มีอธิบายพร้อมตัวอย่างในบทกราฟที่โจทย์ไม่ได้ให้มาเช่นกัน

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

ทำไมคิดทีละช่องแล้วยังถูก

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

ที่พิกัดไม่เกิน 500 ตารางมีไม่เกิน 503 × 503 = 253,009 ช่อง เร็วและเล็กพอ โค้ดอยู่ท้ายตอนที่ 3 เพราะเติมขั้นบีบพิกัดไว้ในตัวเดียวกันแล้ว

เฉลย ตอนที่ 3 · บีบพิกัด (อีก 15 คะแนน) แล้วชนเพดาน

ชุดย่อยที่สองให้จุดไม่เกิน 500 จุด แต่พิกัดยังไปถึง 1,000,000 ตารางตรง ๆ มีราว 1.0 × 10¹² ช่อง ใส่หน่วยความจำไม่ได้

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

บีบพิกัด (coordinate compression) คือแทนพิกัด x แต่ละค่าด้วยลำดับที่ของมันในบรรดาพิกัด x ที่ไม่ซ้ำกัน แล้วทำแบบเดียวกันกับ y ตัวอย่างในโจทย์ได้แบบนี้

บีบพิกัดของตัวอย่าง
แกนพิกัดเดิมที่ไม่ซ้ำลำดับที่
x1 2 4 6 7 81 2 3 4 5 6
y1 2 3 5 6 81 2 3 4 5 6
บีบแล้วตารางกว้างเท่าจำนวนค่าที่ไม่ซ้ำบวก 2 (ทะเลด้านละหนึ่งแถว) ตัวอย่างหดจาก 11 × 11 เป็น 8 × 8 ช่อง และกำแพงที่รอดยังเป็นชุดเดิม คือ 6, 15, 16, 17 (ตรวจตอนบิลด์)

ทำไมรูปผังไม่เปลี่ยน

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

เมื่อจุดไม่เกิน 500 จุด แต่ละแกนมีพิกัดต่างกันไม่เกิน 500 ค่า ตารางไม่เกิน 502 × 502 ช่อง ช่องละ 6 ไบต์ รวมราว 1.5 เมกะไบต์

ดูโค้ดตาราง + บีบพิกัด (ได้ 55 คะแนน)
flood_grid.cpp
// เฉลยชุดทดสอบย่อย (พิกัดไม่เกิน 500 หรือจุดไม่เกิน 500) มองพื้นที่เป็นตารางช่องละหนึ่งหน่วย
#include <bits/stdc++.h>
using namespace std;
int G;
static inline int cid(int i,int j){ return i*G+j; }
// แทนพิกัดด้วยลำดับที่ของมันในพิกัดที่ไม่ซ้ำกัน เริ่มจาก 1 เพื่อเหลือวงช่องรอบนอกไว้เป็นทะเล
// ลำดับซ้ายขวาบนล่างของทุกจุดเหมือนเดิม รูปผังจึงเหมือนเดิม แค่ถูกบีบให้แน่นขึ้น
int squeeze(vector<int>& v){
    vector<int> s(v.begin()+1,v.end());
    sort(s.begin(),s.end()); s.erase(unique(s.begin(),s.end()),s.end());
    for(size_t i=1;i<v.size();i++) v[i]=int(lower_bound(s.begin(),s.end(),v[i])-s.begin())+1;
    return (int)s.size();
}
int main(){
    int n; if(scanf("%d",&n)!=1) return 0;
    vector<int> X(n+1),Y(n+1);
    for(int i=1;i<=n;i++) scanf("%d %d",&X[i],&Y[i]);
    int w; scanf("%d",&w);
    vector<int> A(w+1),B(w+1);
    for(int i=1;i<=w;i++) scanf("%d %d",&A[i],&B[i]);
    G=max(squeeze(X),squeeze(Y))+2;
    vector<char> blkH((size_t)G*G,0), blkV((size_t)G*G,0);   // blkH[i][j] = กำแพงระหว่างช่อง (i,j-1) กับ (i,j)
    for(int e=1;e<=w;e++){
        int a=A[e],b=B[e];
        if(Y[a]==Y[b]){ int yy=Y[a],x1=min(X[a],X[b]),x2=max(X[a],X[b]);
            for(int i=x1;i<x2;i++) blkH[cid(i,yy)]=1; }
        else { int xx=X[a],y1=min(Y[a],Y[b]),y2=max(Y[a],Y[b]);
            for(int j=y1;j<y2;j++) blkV[cid(xx,j)]=1; }
    }
    // BFS แบบ 0-1: ข้ามขอบว่างราคา 0 ใส่หน้าคิว ข้ามกำแพงราคา 1 ต่อท้ายคิว
    const int INF=INT_MAX;
    vector<int> dist((size_t)G*G,INF);
    deque<int> dq; dist[cid(0,0)]=0; dq.push_back(cid(0,0));
    while(!dq.empty()){
        int u=dq.front(); dq.pop_front();
        int i=u/G,j=u%G,d=dist[u];
        auto go=[&](int ni,int nj,int cost){
            if(ni<0||nj<0||ni>=G||nj>=G) return;
            int v=cid(ni,nj); if(d+cost<dist[v]){ dist[v]=d+cost; if(cost) dq.push_back(v); else dq.push_front(v); }
        };
        go(i,j+1, blkH[cid(i,j+1)]);
        go(i,j-1, blkH[cid(i,j)]);
        go(i+1,j, blkV[cid(i+1,j)]);
        go(i-1,j, blkV[cid(i,j)]);
    }
    // กำแพงรอดเมื่อช่องสองข้างของมันถูกน้ำถึงในชั่วโมงเดียวกัน
    vector<int> ans;
    for(int e=1;e<=w;e++){
        int a=A[e],b=B[e]; bool alive;
        if(Y[a]==Y[b]){ int yy=Y[a],x1=min(X[a],X[b]); alive = dist[cid(x1,yy-1)]==dist[cid(x1,yy)]; }
        else { int xx=X[a],y1=min(Y[a],Y[b]); alive = dist[cid(xx-1,y1)]==dist[cid(xx,y1)]; }
        if(alive) ans.push_back(e);
    }
    printf("%d\n",(int)ans.size());
    for(int e:ans) printf("%d\n",e);
}

ที่ N = 100,000 การบีบพิกัดไปต่อไม่ไหว เพราะแต่ละแกนอาจมีพิกัดต่างกันถึง N ค่า ลองเทียบจำนวนงานของสามทางกับงบเวลา

10610810101012 ตารางพิกัดดิบตารางบีบพิกัดเดินรอบผืน 1.0×1012 เกินงบ 5,000 เท่า1.0×1010 เกินงบ 50 เท่า2.4×106 ทัน งบ 2 วินาที
จำนวนงานเมื่อ N = 100,000 และพิกัดถึง 1,000,000 สเกลแบบ log (ขีดละร้อยเท่า) เส้นทองคืองบ 2 วินาที โดยคิดหยาบ ๆ ว่าเครื่องทำงานง่าย ๆ ได้ราว 10⁸ ก้าวต่อวินาที แท่งตารางนับช่อง แท่งเดินรอบผืนนับ 12 ก้าวต่อกำแพง ตามที่นับในตอนที่ 4

หน่วยความจำแย่กว่าเวลาอีก ตารางบีบพิกัดที่ N = 100,000 มีราว 10¹⁰ ช่อง ช่องละ 6 ไบต์ก็ราว 60 กิกะไบต์ ขณะที่ลิมิตคือ 32 เมกะไบต์

งานต้องโตตามจำนวนกำแพงแทนจำนวนช่อง ตอนถัดไปจึงทิ้งช่องไปเลย แล้วหาผืนจากกำแพงตรง ๆ

เฉลย ตอนที่ 4 · เดินรอบผืนของผังจริง

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

ผังที่เส้นไม่ตัดกันแบบนี้คือกราฟระนาบ (planar graph อ่านว่า แพลนนาร์ กราฟ คือกราฟที่วาดบนกระดาษได้โดยไม่มีเส้นไหนตัดกัน) ผืนที่เส้นล้อมไว้เรียกว่าหน้า (face)

ผังที่กำแพงต่อกันเป็นก้อนเดียวอย่างตัวอย่างนี้ หน้าตรงกับผืน A, B, C, D พอดี ผังที่มีหลายก้อนต่างออกไปนิดหนึ่ง ซึ่งเป็นเรื่องของตอนที่ 5

เครื่องมือที่ใช้หาหน้าคือครึ่งเส้น (half-edge) มองกำแพงหนึ่งเส้นเป็นลูกศรสองอันวิ่งสวนกัน อันละฝั่ง ลูกศรแต่ละอันเป็นของหน้าที่อยู่ทางซ้ายมือของมัน

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

ทิศในบทนี้ใช้แบบเดียวกับโจทย์ คือ x เพิ่มไปทางขวา (ตะวันออก) และ y เพิ่มขึ้นข้างบน (เหนือ) การเดินรอบหน้าหนึ่งหน้าทำแบบนี้

  1. เริ่มที่ครึ่งเส้นที่ยังไม่มีเจ้าของ แล้วเดินไปตามมันจนถึงจุดปลาย
  2. ที่จุดปลาย ลองเลี้ยวซ้ายก่อน ถ้ามีกำแพงทางนั้นก็ไปทางนั้น
  3. ไม่มี ก็ลองไปตรง ไม่มีอีก ก็ลองเลี้ยวขวา
  4. ไม่มีสักทาง (ปลายตัน) ก็ย้อนกลับตามกำแพงเดิมอีกฝั่ง

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

เดินรอบด้านในของห้อง C ในตัวอย่าง
ก้าวจุดหันไปทางที่จุดปลายxu·yv − xv·yu
1 5 ไป 6 ตะวันออก ไปตรง 6 − 12 = −6
2 6 ไป 7 ตะวันออก เลี้ยวซ้าย 12 − 18 = −6
3 7 ไป 10 เหนือ เลี้ยวซ้าย 30 − 18 = 12
4 10 ไป 9 ตะวันตก ไปตรง 30 − 20 = 10
5 9 ไป 8 ตะวันตก เลี้ยวซ้าย 20 − 10 = 10
6 8 ไป 5 ใต้ เลี้ยวซ้าย 6 − 10 = −4
ก้าวสุดท้ายพากลับถึงครึ่งเส้นแรก ผลรวมคอลัมน์ขวาคือ 16 เป็นบวก เท่ากับสองเท่าของพื้นที่ห้อง C (8 ช่อง ตรวจตอนบิลด์)

ห้องกับขอบนอก ต่างกันที่ทิศของการเดิน

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

ห้องสี่เหลี่ยม 3 × 3 ของชุดที่ 1 เดินด้านในได้ 18 = 2 × 9 ขอบนอกของห้องเดียวกันคือวงเดิมที่เดินกลับทิศ ได้ −18 กำแพงเส้นเดียวที่ไม่ล้อมอะไรถูกเดินไปแล้วกลับ สองพจน์หักล้างกันเหลือ 0

ผลรวมเป็นบวกแปลว่าห้อง ศูนย์หรือติดลบแปลว่าขอบนอกของก้อน (ขอบนอกของตัวอย่างได้ −98) พิกัดถึงล้าน แต่ละพจน์ถึงราว 10¹² เกินขอบของ int ซึ่งอยู่ราว 2.1 × 10⁹ โค้ดเลยเก็บผลรวมเป็น long long (จำนวนเต็ม 64 บิต)

พอรู้หน้าของครึ่งเส้นทุกอัน ก็สร้างกราฟคู่ (dual graph) ได้ คือให้หน้าเป็นปม และกำแพงแต่ละเส้นเป็นเส้นเชื่อมสองหน้าที่มันคั่น

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

ชั่วโมง 0ชั่วโมง 1ชั่วโมง 2 1 2 3 4 5611 12 137 8 9 10 1415 16 17 ABCD
กราฟคู่ของตัวอย่าง ปมคือผืน วางเป็นคอลัมน์ตามชั่วโมงที่น้ำถึง เลขบนเส้นคือหมายเลขกำแพง เส้นแดงพัง เส้นเขียวรอด ห่วงบน B คือกำแพงที่มี B ทั้งสองฝั่ง

ผังที่มีหลายก้อนยังเหลือเรื่องเดียวต้องเคลียร์ คือก้อนที่ลอยอยู่ในห้องของอีกก้อน

เฉลย ตอนที่ 5 · ก้อนกำแพงที่ลอยอยู่ในห้อง

  • ก้อน คือกลุ่มกำแพงที่ต่อถึงกันทางปลาย
  • ขอบนอกของก้อน คือรอบที่เดินแล้วได้ผลรวมไม่เกินศูนย์ (ตอนที่ 4) ก้อนหนึ่งมีขอบนอกหนึ่งรอบ
  • ถ้าก้อนหนึ่งลอยอยู่ข้างในห้องของอีกก้อน เรียกห้องนั้นว่าห้องที่ห่อ
ABCD 1234567891011
ก้อนนอกคือกำแพง 1 ถึง 4 ก้อนในคือกำแพง 5 ถึง 11 ลอยอยู่ในห้อง B ซึ่งเป็นห้องที่ห่อ ผืน C กับ D คือห้องของก้อนใน

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

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

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

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

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

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

ก้อนใน · ชั่วโมงสองฝั่งของกำแพง
กำแพงผังจริงคิดก้อนในแยกผล
5 1 กับ 2 0 กับ 1 พัง ทั้งสองแบบ
6 1 กับ 2 0 กับ 1 พัง ทั้งสองแบบ
7 2 กับ 1 1 กับ 0 พัง ทั้งสองแบบ
8 2 กับ 1 1 กับ 0 พัง ทั้งสองแบบ
9 2 กับ 1 1 กับ 0 พัง ทั้งสองแบบ
10 1 กับ 2 0 กับ 1 พัง ทั้งสองแบบ
11 2 กับ 2 1 กับ 1 รอด ทั้งสองแบบ
ชั่วโมงในผังจริงมากกว่าตอนคิดแยก 1 ทุกช่อง เท่ากับชั่วโมงของห้อง B กำแพง 11 รอดทั้งสองแบบ และกำแพงของก้อนนอกได้ชั่วโมงเท่าเดิมไม่ว่าจะมีก้อนในหรือไม่ (ตรวจตอนบิลด์)

เหตุผลข้างบนคือสิ่งที่หักล้างคำเตือนของรุ่นแรก ส่วนการเทียบ 2,000 ผังในตอนที่ 6 เป็นหลักฐานเสริม โค้ดเต็มเลยเหลือแค่นี้ หน้าไหนผลรวมไม่เกินศูนย์ ยุบรวมเป็นปมเดียวคือทะเล

หน่วยความจำบนผังตาราง 99,856 จุด
โค้ดเมกะไบต์
รุ่นเดิม (ยิงรังสี เซกเมนต์ทรีตามพิกัดดิบ)39.4
รุ่นเดิม แต่เซกเมนต์ทรีตามลำดับที่ของ x25.0
โค้ดเต็มของหน้านี้14.6
เซกเมนต์ทรีของรุ่นแรกจอง 4 × (x สูงสุด + 2) ช่อง ช่องละ 4 ไบต์ x สูงสุดของผังนี้คือ 945,000 ต้นไม้อย่างเดียวจึงกิน 15.1 เมกะไบต์ เปิดตามลำดับที่ของ x (316 ค่า) เหลือ 5.1 กิโลไบต์ ตัวเลขรวมลดลง 14.4 เมกะไบต์ ใกล้กับขนาดต้นไม้ที่หายไป ส่วนที่ลดลงอีกมาจากการตัดขั้นยิงรังสีกับตัวรวมกลุ่มทิ้ง และการเก็บกราฟคู่เป็นอาร์เรย์ต่อกัน

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

รวมตอนที่ 4 กับ 5 เข้าด้วยกัน ได้โค้ดข้างล่าง

ดูโค้ดเต็ม
flood.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> X(n+1),Y(n+1);
    for(int i=1;i<=n;i++) scanf("%d %d",&X[i],&Y[i]);
    int w; scanf("%d",&w);
    // กำแพง e แตกเป็นครึ่งเส้นสองอัน 2e (a ไป b) กับ 2e+1 (b ไป a)
    // ทิศ 0=ตะวันออก 1=เหนือ 2=ตะวันตก 3=ใต้  ทิศย้อนกลับของ d คือ d^2
    vector<array<int,4>> out(n+1,{-1,-1,-1,-1});   // ครึ่งเส้นที่ออกจากจุดนี้ไปทางทิศนั้น
    vector<int> heTo(2*w),heDir(2*w);
    for(int e=0;e<w;e++){
        int a,b; scanf("%d %d",&a,&b);
        int d = (Y[a]==Y[b]) ? (X[b]>X[a] ? 0 : 2) : (Y[b]>Y[a] ? 1 : 3);
        heTo[2*e]=b;   heDir[2*e]=d;     out[a][d]=2*e;
        heTo[2*e+1]=a; heDir[2*e+1]=d^2; out[b][d^2]=2*e+1;
    }
    // เดินรอบแต่ละหน้า โดยให้หน้านั้นอยู่ทางซ้ายมือเสมอ
    // ถึงจุดปลายแล้ว ลองเลี้ยวซ้ายก่อน ไม่ได้ก็ตรงไป ไม่ได้ก็เลี้ยวขวา สุดท้ายค่อยย้อนกลับ
    vector<int> heFace(2*w,-1);
    vector<char> isRoom;                       // หน้านี้คือด้านในของห้อง (เดินทวนเข็ม) หรือไม่
    for(int s=0;s<2*w;s++){
        if(heFace[s]>=0) continue;
        int fid=(int)isRoom.size(); ll area2=0; int h=s;
        do{
            heFace[h]=fid;
            int u=heTo[h^1], v=heTo[h];
            area2 += (ll)X[u]*Y[v] - (ll)X[v]*Y[u];   // สูตรเชือกผูกรองเท้า ได้สองเท่าของพื้นที่ มีเครื่องหมาย
            int back=heDir[h]^2, nxt=-1;
            for(int k=3;k>=0;k--){ int dd=(back+k)%4; if(out[v][dd]>=0){ nxt=out[v][dd]; break; } }
            h=nxt;
        } while(h!=s);
        isRoom.push_back(area2>0);
    }
    // หน้าที่ไม่ใช่ห้อง คือขอบนอกของก้อนกำแพงแต่ละก้อน ยุบรวมเป็นปมเดียวคือทะเล (ปม 0)
    // ก้อนที่ลอยอยู่ในห้องก็ยุบเข้าทะเลได้ ชั่วโมงของทั้งก้อนจะเลื่อนลงเท่ากันหมด
    // ผลต่างสองข้างของกำแพงทุกเส้นจึงไม่เปลี่ยน
    int F=(int)isRoom.size();
    vector<int> node(F); int V=1;
    for(int f=0;f<F;f++) node[f] = isRoom[f] ? V++ : 0;
    // กราฟคู่: ปมคือหน้า เส้นเชื่อมคือกำแพง เก็บแบบอาร์เรย์ต่อกันเพื่อประหยัดหน่วยความจำ
    vector<int> deg(V+1,0), adj(2*w);
    for(int e=0;e<w;e++){ deg[node[heFace[2*e]]+1]++; deg[node[heFace[2*e+1]]+1]++; }
    for(int v=0;v<V;v++) deg[v+1]+=deg[v];
    vector<int> pos(deg.begin(),deg.end()-1);
    for(int e=0;e<w;e++){ int a=node[heFace[2*e]], b=node[heFace[2*e+1]]; adj[pos[a]++]=b; adj[pos[b]++]=a; }
    // BFS จากทะเล dist[v] คือชั่วโมงที่น้ำท่วมถึงหน้านั้น
    vector<int> dist(V,-1), q; q.reserve(V);
    dist[0]=0; q.push_back(0);
    for(size_t i=0;i<q.size();i++){
        int u=q[i];
        for(int k=deg[u];k<deg[u+1];k++){ int v=adj[k]; if(dist[v]<0){ dist[v]=dist[u]+1; q.push_back(v); } }
    }
    // กำแพงรอดเมื่อสองข้างถูกน้ำถึงในชั่วโมงเดียวกัน
    vector<int> ans;
    for(int e=0;e<w;e++) if(dist[node[heFace[2*e]]]==dist[node[heFace[2*e+1]]]) ans.push_back(e+1);
    printf("%d\n",(int)ans.size());
    for(int e:ans) printf("%d\n",e);
}
แผนที่ของโค้ดเต็ม
ส่วนในโค้ดอธิบายอยู่ที่
แตกกำแพง e เป็นครึ่งเส้น 2e กับ 2e+1ตอนที่ 4 (ครึ่งเส้น)
เดินรอบแต่ละหน้า ลองเลี้ยวซ้ายก่อนตอนที่ 4 (ลำดับการเลี้ยว)
area2 สูตรเชือกผูกรองเท้า แยกห้องกับขอบนอกตอนที่ 4 (ห้องกับขอบนอก)
ยุบขอบนอกทุกก้อนเป็นทะเล ปม 0ตอนที่ 5
กราฟคู่ แล้ว BFS จากทะเลตอนที่ 4 กับ 5 (ชั่วโมงอาจเลื่อนทั้งก้อน แต่ผลรอดหรือพังเท่าเดิม)
เทียบ dist สองฝั่งของกำแพงตอนที่ 1 (กฎที่ได้)
ตัวตรวจ: ปล่อยน้ำตามโจทย์ทีละชั่วโมงจริง ๆ

ตัวตรวจนี้ไม่ใช้กฎชั่วโมงเท่ากันเลย มันทำตามโจทย์ตรง ๆ บนตาราง น้ำไหล หากำแพงที่มีน้ำข้างเดียว ล้มทั้งหมดพร้อมกัน แล้วทำซ้ำ ใช้ได้กับพิกัดเล็กเท่านั้น

brute_flood.cpp
// ตัวตรวจ: ปล่อยน้ำตามโจทย์ทีละชั่วโมงจริง ๆ บนตาราง (พิกัดเล็กเท่านั้น)
// ไม่ใช้ข้อสรุปเรื่องชั่วโมงเท่ากันเลย ใช้แค่กติกาในโจทย์ตรง ๆ
#include <bits/stdc++.h>
using namespace std;
int main(){
    int n; if(scanf("%d",&n)!=1) return 0;
    vector<int> X(n+1),Y(n+1); int mx=0;
    for(int i=1;i<=n;i++){ scanf("%d %d",&X[i],&Y[i]); X[i]++; Y[i]++; mx=max(mx,max(X[i],Y[i])); }
    int w; scanf("%d",&w);
    int G=mx+2;
    auto cid=[&](int i,int j){ return i*G+j; };
    // wh[i][j] = กำแพงที่คั่นช่อง (i,j-1) กับ (i,j)  wv[i][j] = กำแพงที่คั่นช่อง (i-1,j) กับ (i,j)  (-1 คือไม่มี)
    vector<int> wh(G*G,-1), wv(G*G,-1);
    for(int e=1;e<=w;e++){
        int a,b; scanf("%d %d",&a,&b);
        if(Y[a]==Y[b]){ for(int i=min(X[a],X[b]);i<max(X[a],X[b]);i++) wh[cid(i,Y[a])]=e; }
        else { for(int j=min(Y[a],Y[b]);j<max(Y[a],Y[b]);j++) wv[cid(X[a],j)]=e; }
    }
    vector<char> down(w+1,0);
    while(true){
        // น้ำไหลจากทะเลผ่านทุกขอบที่ไม่มีกำแพงยืนอยู่
        vector<char> wet(G*G,0); vector<int> st={cid(0,0)}; wet[cid(0,0)]=1;
        auto open=[&](int e){ return e<0 || down[e]; };
        while(!st.empty()){
            int u=st.back(); st.pop_back(); int i=u/G,j=u%G;
            auto go=[&](int ni,int nj,int e){ if(ni<0||nj<0||ni>=G||nj>=G) return; int v=cid(ni,nj); if(!wet[v]&&open(e)){ wet[v]=1; st.push_back(v);} };
            if(j+1<G) go(i,j+1,wh[cid(i,j+1)]);
            go(i,j-1,j>0?wh[cid(i,j)]:-1);
            if(i+1<G) go(i+1,j,wv[cid(i+1,j)]);
            go(i-1,j,i>0?wv[cid(i,j)]:-1);
        }
        // กำแพงที่ยังยืนและมีน้ำข้างเดียว พังพร้อมกันทั้งหมดในชั่วโมงนี้
        vector<int> fall;
        for(int i=0;i<G;i++) for(int j=0;j<G;j++){
            if(j>0){ int e=wh[cid(i,j)]; if(e>0&&!down[e]&&wet[cid(i,j-1)]!=wet[cid(i,j)]) fall.push_back(e); }
            if(i>0){ int e=wv[cid(i,j)]; if(e>0&&!down[e]&&wet[cid(i-1,j)]!=wet[cid(i,j)]) fall.push_back(e); }
        }
        if(fall.empty()) break;
        for(int e:fall) down[e]=1;
    }
    vector<int> ans; for(int e=1;e<=w;e++) if(!down[e]) ans.push_back(e);
    printf("%d\n",(int)ans.size());
    for(int e:ans) printf("%d\n",e);
}

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

  • ตอนบิลด์ หน้านี้เทียบกฎชั่วโมงเท่ากันกับการปล่อยน้ำจริงบนตัวอย่าง ทั้งสามชุดของเกม และผังก้อนลอยในตอนที่ 5
  • สุ่มผังบนจุดตารางไม่เกิน 8 × 8 จุด 2,000 ผัง (653 ผังมีก้อนลอยในห้อง) เทียบโค้ดตอนที่ 3 และโค้ดเต็มกับตัวตรวจ แล้วยืดพิกัดของผังเดียวกันไปถึงหลักล้านเทียบอีกรอบ ตรงทุกผัง
  • บนผังตาราง 99,856 จุด โค้ดเต็มตอบตรงกับโค้ดตอนที่ 3

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

เวลาและหน่วยความจำของโค้ดเต็ม
ผังจุดกำแพงวินาทีเมกะไบต์
ตาราง 316 × 316 จุด99,856199,0800.04 ถึง 0.0514.6
ห้องซ้อนกัน 25,000 ชั้น100,000100,0000.03 ถึง 0.049.6
วัดบนเครื่องผม (WSL, g++ -O2) ด้วยโค้ดที่ดึงออกมาจากหน้านี้ แต่ละผังรันสามรอบ ลิมิตคือ 2 วินาทีและ 32 เมกะไบต์

ทดสอบตัวเอง

กำแพงที่มีผืนเดียวกันทั้งสองฝั่ง (เช่นกำแพง 6 ในตัวอย่าง) มีโอกาสพังไหม

ไม่มี ผืนเดียวกันเปียกพร้อมกันทั้งผืน ไม่มีจังหวะที่ฝั่งหนึ่งเปียกอีกฝั่งแห้ง

ถ้าเลื่อนทุกจุดของตัวอย่างไปทางขวาอีก 1,000 หน่วย คำตอบเปลี่ยนไหม แล้วตารางของตอนที่ 2 จะใหญ่แค่ไหน

คำตอบเท่าเดิม เพราะลำดับของจุดเหมือนเดิม โค้ดตอนที่ 2 ใช้ตารางจัตุรัสตามพิกัดที่มากที่สุดของทั้งสองแกน เลยโตเป็น 1,011 × 1,011 ช่อง ถ้าทำตารางผืนผ้าจะเหลือ 1,011 × 11 ส่วนตอนที่ 3 บีบกลับมาเหลือ 8 × 8 เท่าเดิม

ทำไมโค้ดเต็มใช้คิวธรรมดา แต่ตอนที่ 2 ต้องใช้คิวสองหัว

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

สรุปและท่าที่ติดมือกลับไป

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

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

แหล่งที่มา

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