ปูพื้นฐาน

กราฟที่โจทย์ไม่ได้ให้มา: แปลงคำว่า "อย่างน้อยกี่ท่า" เป็นโค้ดชุดเดิมทุกครั้ง

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

บทปูพื้นฐาน ★★☆☆☆ bfsstate spaceพื้นฐาน อ่าน 14 นาที 6 กันยายน 2026

อาการ · โจทย์ที่ถามว่า "อย่างน้อยกี่ท่า" แต่ไม่มีกราฟมาให้

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

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

สองคำที่ต้องนิยามให้ได้ก่อนเขียนโค้ด

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

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

เมื่อทุกท่าราคาเท่ากันคือหนึ่งท่า การหาทางที่สั้นที่สุดจึงเป็นงานของการค้นตามความกว้าง (breadth-first search หรือ BFS) ซึ่งกวาดสถานะออกไปเป็นชั้น ๆ ชั้นที่ d คือสถานะทุกอันที่ไปถึงได้ ด้วย d ท่าพอดี ไม่มากไม่น้อยกว่านั้น

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

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

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

ทำไมต้องเป็นการค้นตามความกว้าง

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

โครงที่ใช้ซ้ำได้ทุกข้อ

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

โครง BFS
// โครงของการค้นตามความกว้าง ใช้ซ้ำได้ทุกข้อ เปลี่ยนแค่สามจุดที่มีคอมเมนต์กำกับ
vector<int> dist(STATES, -1);          // (1) จำนวนสถานะทั้งหมด
queue<int> q;

dist[START] = 0;                       // (2) สถานะเริ่มต้น
q.push(START);

while (!q.empty()) {
    int v = q.front(); q.pop();
    if (isGoal(v)) { answer = dist[v]; break; }

    for (int u : neighbours(v)) {      // (3) หนึ่งท่าที่ทำได้จากสถานะ v
        if (dist[u] != -1) continue;   // เคยไปถึงแล้วด้วยระยะที่สั้นกว่าหรือเท่ากัน
        dist[u] = dist[v] + 1;
        q.push(u);
    }
}

จุดที่เหลือให้คิดจริง ๆ มีข้อเดียว คือจะเข้ารหัสสถานะเป็นเลขตัวเดียวยังไง เพราะเราอยากใช้อาเรย์ธรรมดาเป็นตัวจำว่าไปถึงหรือยัง ไม่ใช่ตารางแฮชที่ช้ากว่าหลายเท่า ในโจทย์เหยือกน้ำ สถานะ (a, b) อัดเป็น a × (Y + 1) + b ได้ตรง ๆ เหมือนการอ่านเลขสองหลักในฐาน Y + 1

ขนาดของกราฟสถานะในโจทย์ต่าง ๆ
โจทย์สถานะคืออะไรมีกี่สถานะ
กริด 1000 × 1000 ตำแหน่ง (แถว, คอลัมน์) 1,000,000
เหยือกน้ำ 3 กับ 5 ลิตร ปริมาณน้ำในเหยือกทั้งสองใบ 24
ล็อกรหัสสี่หลัก ตัวเลขสี่หลักที่วงล้อชี้อยู่ 10,000
ปริศนา 15 ตำแหน่งของเบี้ยทั้ง 16 ช่อง 10,461,394,944,000
สามแถวบนสร้างอาเรย์เก็บระยะได้สบาย ๆ ส่วนแถวล่างสร้างไม่ได้ในจักรวาลนี้ ตรงนั้นคือจุดที่ต้อง เปลี่ยนคำถามแทนที่จะเปลี่ยนอัลกอริทึม ซึ่งเป็นเรื่องของข้อ ปริศนา 15 ทั้งข้อ

คิดก่อนอ่านต่อ

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


ลองเอง · ตวงน้ำให้ได้พอดี

เหยือกสามกับห้าลิตร ขอสี่ลิตรพอดี

ต้องการ 4 ลิตรพอดี ในเหยือกใบใดใบหนึ่ง

0 / 0
0 / 0

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

ใช้ไปแล้ว 0 ท่า

ทุกท่าราคาเท่ากันหมด ซึ่งเป็นเงื่อนไขที่ทำให้การค้นตามความกว้างใช้ได้

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

โจทย์ฝึก · ไล่จากง่ายไปยาก

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

ฝึกข้อ 1 ★☆☆☆☆ · เดินออกจากเขาวงกต

โจทย์กำหนด

ให้กริด R คูณ C ที่ # คือกำแพง . คือช่องว่าง S คือจุดเริ่ม และ T คือจุดหมาย เดินได้สี่ทิศ ถามว่าต้องเดินอย่างน้อยกี่ก้าว ถ้าไปไม่ถึงให้ตอบ -1 โดย R, C ≤ 1 000

EXAMPLE
InputOutput
5 5
S..#.
.#..#
.#.#.
...#T
.#...
9

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

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

เดินได้สี่ทิศแปลว่าห้ามเดินทแยง ชุดนี้เดินจาก S ไป T ได้สั้นสุด 9 ก้าว โดยผ่าน 10 ช่องรวมช่องเริ่มกับช่องจบ

ระยะจากจุดเริ่มถึงทุกช่องที่ไปถึงได้ 0 S 1 2 # · 1 # 3 4 # 2 # 4 # 10 3 4 5 # 9 T 4 # 6 7 8
เลขในช่องคือระยะจากจุดเริ่มถึงช่องนั้น ช่องสีเขียวคือเส้นทางที่สั้นที่สุดเส้นหนึ่ง ช่องที่ไม่มีเลขคือช่องที่เดินไปไม่ถึง ซึ่งเป็นเหตุผลที่ค่าตั้งต้นของตารางระยะควรเป็นค่าที่แปลว่ายังไปไม่ถึง

คำใบ้

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

ที่มาของท่านี้

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

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

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

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

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

ฝึกข้อ 1 · กริด
#include <bits/stdc++.h>
using namespace std;

int main() {
    int R, C;
    scanf("%d %d", &R, &C);
    vector<string> g(R);
    for (auto& s : g) { char b[1024]; scanf("%s", b); s = b; }

    int sr = 0, sc = 0, tr = 0, tc = 0;
    for (int i = 0; i < R; i++)
        for (int j = 0; j < C; j++) {
            if (g[i][j] == 'S') { sr = i; sc = j; }
            if (g[i][j] == 'T') { tr = i; tc = j; }
        }

    vector<vector<int>> dist(R, vector<int>(C, -1));
    queue<pair<int, int>> q;
    dist[sr][sc] = 0;
    q.push({sr, sc});

    const int dx[4] = {-1, 1, 0, 0}, dy[4] = {0, 0, -1, 1};
    while (!q.empty()) {
        auto [x, y] = q.front(); q.pop();
        for (int k = 0; k < 4; k++) {
            int nx = x + dx[k], ny = y + dy[k];
            if (nx < 0 || ny < 0 || nx >= R || ny >= C) continue;
            if (g[nx][ny] == '#') continue;
            if (dist[nx][ny] != -1) continue;
            dist[nx][ny] = dist[x][y] + 1;
            q.push({nx, ny});
        }
    }
    printf("%d\n", dist[tr][tc]);   // ไปไม่ถึงจะยังเป็น -1 อยู่พอดี
    return 0;
}

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


ฝึกข้อ 2 ★★☆☆☆ · เหยือกน้ำสองใบ

โจทย์กำหนด

ให้เหยือกความจุ X กับ Y ลิตร เริ่มต้นว่างทั้งคู่ ทำได้หกท่าคือเติมใบใดใบหนึ่งให้เต็ม เททิ้งใบใดใบหนึ่ง และเทใบหนึ่งใส่อีกใบจนใบรับเต็มหรือใบเทหมด ถามว่าอย่างน้อยกี่ท่าถึงจะมีเหยือกใบใดใบหนึ่ง เก็บน้ำได้ Z ลิตรพอดี ทำไม่ได้ให้ตอบ -1

EXAMPLE
InputOutput
5 3 46

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

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

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

เริ่มที่ว่างทั้งคู่ แล้วเดินไปทีละท่า 0 , 0 5 , 0 เติมใบใหญ่ให้เต็ม 2 , 3 เทใบใหญ่ใส่ใบเล็ก 2 , 0 เททิ้งใบเล็ก 0 , 2 เทใบใหญ่ใส่ใบเล็ก 5 , 2 เติมใบใหญ่ให้เต็ม 4 , 3 เทใบใหญ่ใส่ใบเล็ก 6 ท่า ซึ่งคือเอาต์พุต จากสภาพที่เป็นไปได้ทั้งหมด 24 แบบ
ลำดับสภาพของเหยือกสองใบตามเส้นทางที่สั้นที่สุด ตัวเลขในกล่องคือน้ำในใบใหญ่กับใบเล็ก ข้อความใต้ลูกศรคือท่าที่ทำ

คำใบ้

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

สถานะคือ (a, b) โดย 0 ≤ a ≤ X และ 0 ≤ b ≤ Y มีทั้งหมด (X + 1)(Y + 1) อัน อัดเป็นเลขตัวเดียวด้วย a × (Y + 1) + b แล้วแตกกลับด้วยการหาร กับหารเอาเศษ ท่าที่ยากที่สุดคือการเท ซึ่งย้ายน้ำได้เท่ากับค่าที่น้อยกว่าระหว่างน้ำที่มีในใบเท กับที่ว่างที่เหลือในใบรับ

ฝึกข้อ 2 · เหยือกน้ำ
#include <bits/stdc++.h>
using namespace std;

int main() {
    int X, Y, Z;
    scanf("%d %d %d", &X, &Y, &Z);

    // สถานะคือ (a, b) ปริมาณน้ำในใบเล็กและใบใหญ่ อัดเป็นเลขตัวเดียว a*(Y+1)+b
    int S = (X + 1) * (Y + 1);
    vector<int> dist(S, -1);
    auto id = [&](int a, int b) { return a * (Y + 1) + b; };

    queue<int> q;
    dist[id(0, 0)] = 0;
    q.push(id(0, 0));

    int ans = -1;
    while (!q.empty()) {
        int cur = q.front(); q.pop();
        int a = cur / (Y + 1), b = cur % (Y + 1);
        if (a == Z || b == Z) { ans = dist[cur]; break; }

        int t  = min(a, Y - b);        // เทใบเล็กใส่ใบใหญ่ ได้เท่าที่ใบใหญ่ยังว่าง
        int t2 = min(b, X - a);        // เทใบใหญ่ใส่ใบเล็ก
        int nx[6][2] = {{X, b}, {a, Y}, {0, b}, {a, 0}, {a - t, b + t}, {a + t2, b - t2}};

        for (auto& p : nx) {
            int j = id(p[0], p[1]);
            if (dist[j] == -1) { dist[j] = dist[cur] + 1; q.push(j); }
        }
    }
    printf("%d\n", ans);
    return 0;
}

สุ่มเทียบ 400 เทส กับตัวตรวจแบบผ่อนคลายค่าเช่นเดิม รวมกรณี Z = 0 ซึ่งตอบ 0 ตั้งแต่ยังไม่ทำอะไร และกรณี Z ที่ใหญ่กว่าเหยือกทั้งสองใบซึ่งตอบ -1 ตรงกันทั้งหมด


ฝึกข้อ 3 ★★★☆☆ · ล็อกรหัสที่มีรหัสต้องห้าม

โจทย์กำหนด

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

EXAMPLE
InputOutput
5
0201 0101 0102 1212 2002
0202
6

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

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

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

คำว่า "ห้ามอยู่ในสภาพนั้นแม้แต่ระหว่างทาง" คือหัวใจ รหัสต้องห้ามไม่ได้ห้ามแค่เป็นคำตอบ มันห้ามเหยียบผ่านด้วย

เส้นทางที่สั้นที่สุด 6 ครั้ง 0000 1000 1100 1200 1201 1202 0202 รหัสต้องห้าม เดินเข้าไปไม่ได้แม้แต่ผ่าน 0201 0101 0102 1212 2002
เส้นทางที่สั้นที่สุดของตัวอย่างนี้ ตัวเลขในกล่องคือสภาพของล็อกในแต่ละก้าว กล่องสีทองด้านล่างคือรหัสต้องห้าม ซึ่งเป็นสภาพที่เดินเข้าไปไม่ได้เลย

คำใบ้

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

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

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

ฝึกข้อ 3 · ล็อกรหัส
#include <bits/stdc++.h>
using namespace std;

int main() {
    char tgt[16];
    int m;
    scanf("%s %d", tgt, &m);

    vector<char> bad(10000, 0);
    for (int i = 0; i < m; i++) { char s[16]; scanf("%s", s); bad[atoi(s)] = 1; }
    int target = atoi(tgt);

    if (bad[0]) { printf("-1\n"); return 0; }   // ออกจากจุดเริ่มไม่ได้เลย

    vector<int> dist(10000, -1);
    queue<int> q;
    dist[0] = 0;
    q.push(0);

    while (!q.empty()) {
        int v = q.front(); q.pop();
        if (v == target) break;

        int p[4] = {1000, 100, 10, 1};
        for (int k = 0; k < 4; k++) {
            int digit = (v / p[k]) % 10;
            for (int d : {1, 9}) {                 // หมุนขึ้นหนึ่ง กับหมุนลงหนึ่ง (คือขึ้นเก้าแบบวน)
                int nd = (digit + d) % 10;
                int u = v + (nd - digit) * p[k];
                if (bad[u] || dist[u] != -1) continue;
                dist[u] = dist[v] + 1;
                q.push(u);
            }
        }
    }
    printf("%d\n", dist[target]);
    return 0;
}

สุ่มเทียบ 200 เทส กับตัวตรวจแบบผ่อนคลายค่าซึ่งกวาดทั้ง 10 000 สถานะซ้ำจนนิ่ง โดยจงใจให้รหัสต้องห้าม บางเทสไปตกที่ 0000 พอดี ตรงกันทั้งหมด

ระวัง

การหมุนลงหนึ่งขั้นเขียนเป็น (digit - 1) % 10 ไม่ได้ เพราะภาษา C++ ให้เศษที่ติดลบ เมื่อตัวตั้งติดลบ ตอน digit เป็น 0 จะได้ -1 แล้วดัชนีหลุดขอบอาเรย์ ท่าที่ปลอดภัยคือบวก 9 แทนการลบ 1 ซึ่งให้ผลเดียวกันในโลกที่วนครบสิบ


เส้นแบ่งที่ต้องรู้ · BFS ธรรมดาพังเมื่อไร แล้วต้องใช้อะไรแทน

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

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

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

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

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

int main() {
    int n, m, start;
    if (scanf("%d %d %d", &n, &m, &start) != 3) return 0;
    vector<vector<pair<int, int>>> g(n);
    for (int i = 0; i < m; i++) {
        int u, v, w;
        scanf("%d %d %d", &u, &v, &w);
        g[u].push_back({v, w});
        g[v].push_back({u, w});
    }
    const int INF = INT_MAX;
    vector<int> dist(n, INF);
    deque<int> dq;
    dist[start] = 0;
    dq.push_back(start);
    while (!dq.empty()) {
        int u = dq.front();
        dq.pop_front();
        for (auto [v, w] : g[u]) {
            // ถ้าเส้นนี้ไม่เพิ่มระยะเลย ปมปลายทางต้องได้คิวเท่ากับปมนี้ จึงต้องอยู่หัวแถว
            if (dist[u] + w < dist[v]) {
                dist[v] = dist[u] + w;
                if (w == 0) dq.push_front(v);
                else dq.push_back(v);
            }
        }
    }
    for (int i = 0; i < n; i++) printf("%d%c", dist[i] == INF ? -1 : dist[i], i + 1 == n ? '\n' : ' ');
    return 0;
}
ตัวตรวจที่ทำให้กล้าเชื่อโค้ดข้างบน

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

brute_bellman.cpp
// ตัวตรวจอิสระของ 0-1 BFS: Bellman-Ford แบบผ่อนคลายซ้ำจนไม่มีอะไรเปลี่ยน
// ไม่รู้จักแถวสองหัว ไม่รู้จักลำดับคิว จึงไม่ได้ทดสอบข้ออ้างเรื่องหัวแถวท้ายแถวเลย
#include <bits/stdc++.h>
using namespace std;

int main() {
    int n, m, start;
    if (scanf("%d %d %d", &n, &m, &start) != 3) return 0;
    vector<array<int, 3>> e(m);
    for (int i = 0; i < m; i++) scanf("%d %d %d", &e[i][0], &e[i][1], &e[i][2]);
    const int INF = INT_MAX;
    vector<int> dist(n, INF);
    dist[start] = 0;
    for (int it = 0; it < n; it++) {
        bool changed = false;
        for (auto& x : e) {
            int u = x[0], v = x[1], w = x[2];
            if (dist[u] != INF && dist[u] + w < dist[v]) { dist[v] = dist[u] + w; changed = true; }
            if (dist[v] != INF && dist[v] + w < dist[u]) { dist[u] = dist[v] + w; changed = true; }
        }
        if (!changed) break;
    }
    for (int i = 0; i < n; i++) printf("%d%c", dist[i] == INF ? -1 : dist[i], i + 1 == n ? '\n' : ' ');
    return 0;
}

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

เกณฑ์เลือกจึงเรียงได้เป็นบันได ราคาเท่ากันหมดให้ใช้ BFS ราคาเป็น 0 กับ 1 ให้ใช้ 0-1 BFS ราคาเป็นจำนวนเต็มเล็ก ๆ หลายค่าให้ใช้คิวหลายชั้น และราคาเป็นอะไรก็ได้ให้ใช้ Dijkstra สิ่งที่ต้องจำคืออย่าใช้ BFS กับกราฟที่ราคาไม่เท่ากัน เพราะมันไม่เตือนอะไรเลย

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

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