programming.in.th · ข้อ 2026

ปริศนา 15: บทเรียนเรื่องการอ่านโจทย์ให้เจอว่ามันขอน้อยกว่าที่คิด

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

★★★☆☆ bfsconstructive อ่าน 10 นาที 6 กันยายน 2026

โจทย์ · เครื่องขนย้ายมวลสารที่ขอให้แก้ปริศนาก่อน

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

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

ที่ต้องอ่านให้ดีคือความหมายของตัวอักษรที่เราต้องตอบ คำตอบเป็นสตริงของอักษรสี่ตัวคือ N, E, W, S และแต่ละตัวบอกตำแหน่งของหมากที่จะถูกเลื่อนมาแทนช่องว่าง ไม่ใช่ทิศที่หมากเคลื่อนที่ พูดอีกแบบคือมันคือทิศที่ช่องว่างขยับไป

ก่อนเดิน 3 N ช่องว่าง 11 S 7 W 5 E W หลังเดิน 3 7 11 ช่องว่าง 5
ตัวอักษรบอกว่าหยิบหมากจากด้านไหนของช่องว่าง หมากตัวนั้นจะเลื่อนเข้ามาแทน ส่วนช่องว่างก็ย้ายไปอยู่ที่เดิมของหมากตัวนั้น (วาดประกอบโดยผู้เขียน)

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

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

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

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

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

ปัญหาอยู่ที่จำนวนสถานะ กระดาน 4×4 มีของ 16 ชิ้นสลับที่กันได้ 16! แบบ และครึ่งหนึ่งของนั้นแก้ไม่ได้เลย เหลือ 10,461,394,944,000 สถานะที่ไปถึงกันได้จริง มากกว่าสิบล้านล้าน ต่อให้เก็บสถานะละหนึ่งบิต ก็ยังกินพื้นที่เกินหน่วยความจำ 32 เมกะไบต์ไปหลายแสนเท่า และเวลาที่ให้มาคือ 0.1 วินาที

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

ใบ้

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

ลองเอง · เล่นให้จบด้วยมือ

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

0 ตา

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

ลองเล่นดูก่อนนะครับ พอมีคำตอบในใจแล้ว ค่อยไปดูเฉลย

เฉลย ตอนที่ 1 · โจทย์ไม่เคยขอทางที่สั้นที่สุด

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

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

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

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

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

เกณฑ์คะแนนบอกไว้ตรง ๆ ว่าได้เต็มเมื่อใช้ตาน้อยกว่า 5,000 ตา ไม่มีคะแนนพิเศษสำหรับคำตอบที่สั้นกว่านั้น คำตอบยาว 4,999 ตากับคำตอบยาว 50 ตาได้คะแนนเท่ากันเป๊ะ ตัวเลข 5,000 คือใบอนุญาตให้เราเดินอ้อมได้เยอะมาก

ทีนี้ลองคิดว่าตอนคุณเล่นด้วยมือ คุณทำยังไง เกือบทุกคนทำเหมือนกันคือไล่เก็บทีละตัว จัด 1 เข้ามุมซ้ายบนก่อน แล้วก็ 2, 3, 4 ให้ครบแถวบน จากนั้นห้ามแตะแถวบนอีกเลย ไปทำแถวถัดไปในพื้นที่ที่เหลือ วิธีนี้ไม่มีทางให้คำตอบที่สั้นที่สุด แต่มันจบเสมอ

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

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

เฉลย ตอนที่ 2 · ทำไมสองตัวท้ายแถวต้องวางพร้อมกัน

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

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

ทางแก้คือเลิกวางสองตัวท้ายทีละตัว แล้วถามคำถามใหญ่ขึ้นหนึ่งขั้น: จะพา 3 กับ 4 ไปเข้าที่พร้อมกันได้ยังไง สถานะของคำถามนี้คือ (ช่องของ 3, ช่องของ 4, ช่องว่าง) ซึ่งมีไม่เกิน 16 × 16 × 16 = 4096 แบบ ยังเล็กจนค้นแบบกว้างได้สบาย และคราวนี้ไม่มีซอกตัน เพราะเราไม่ได้ล็อกตัวแรกทิ้งไว้ก่อน

โน้ต: นี่คือจุดที่ผมพลาดตอนเขียนครั้งแรก

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

เฉลย ตอนที่ 3 · สองแถวล่างไม่ต้องประดิษฐ์อะไรอีก

พอวางแถวบนสองแถวเสร็จ สิ่งที่เหลือคือกระดานย่อยขนาด 2×4 มีหมากเจ็ดตัวกับช่องว่างหนึ่งช่อง จำนวนวิธีเรียงของแปดอย่างในแปดช่องคือ 8! = 40,320 แบบ

เลขนี้เล็กจนไม่ต้องคิดท่าอะไรเพิ่มอีกแล้ว ค้นแบบกว้างจากสถานะปัจจุบันไปยังสถานะสิ้นสุดตรง ๆ ทีเดียวจบ และได้ทางที่สั้นที่สุดของกระดานย่อยนี้แถมมาด้วย ตอนผมไล่ค้นทั้งกราฟดู มีสถานะที่ไปถึงกันได้จริง 20,160 แบบ พอดีครึ่งหนึ่งของ 40,320 ซึ่งตรงกับที่โจทย์บอกว่าครึ่งหนึ่งของการเรียงแก้ไม่ได้

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

เดินให้ดูหนึ่งกระดาน

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

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

โค้ด C++

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

fifteen-puzzle.cpp
#include <bits/stdc++.h>
using namespace std;

int g[16];              // g[i] = หมายเลขหมากในช่อง i (0 = ช่องว่าง)
bool lockd[16];         // ช่องที่เข้าที่แล้ว ห้ามแตะอีก
int bl;                 // ช่องว่างอยู่ที่ไหน
string ans;

const int DR[4] = {-1, 0, 1, 0};
const int DC[4] = {0, 1, 0, -1};
const char LET[4] = {'N', 'E', 'S', 'W'};   // ทิศที่ "ช่องว่าง" ขยับไป

// ช่องปลายทางเมื่อช่องว่างที่ p ขยับไปทาง d (คืน -1 ถ้าตกขอบหรือชนช่องที่ล็อกไว้)
int nb(int p, int d) {
    int r = p / 4 + DR[d], c = p % 4 + DC[d];
    if (r < 0 || r > 3 || c < 0 || c > 3) return -1;
    int q = r * 4 + c;
    return lockd[q] ? -1 : q;
}

void apply1(int d) {
    int q = nb(bl, d);
    g[bl] = g[q];
    g[q] = 0;
    bl = q;
    ans += LET[d];
}

// วางหมากตัวเดียว: สถานะคือ (ช่องของหมาก, ช่องว่าง) มีไม่เกิน 16 x 16 แบบ
void placeOne(int v, int goal) {
    int start = (int)(find(g, g + 16, v) - g) * 16 + bl;
    vector<int> from(256, -2);
    vector<char> dir(256, -1);
    from[start] = -1;
    queue<int> q;
    q.push(start);
    int end = -1;
    while (!q.empty()) {
        int s = q.front(); q.pop();
        int tp = s >> 4, bp = s & 15;
        if (tp == goal) { end = s; break; }
        for (int d = 0; d < 4; d++) {
            int np = nb(bp, d);
            if (np < 0) continue;
            int ntp = (np == tp) ? bp : tp;      // ช่องว่างเดินทับหมาก = หมากถูกดันมาที่เดิมของช่องว่าง
            int ns = ntp * 16 + np;
            if (from[ns] != -2) continue;
            from[ns] = s; dir[ns] = (char)d;
            q.push(ns);
        }
    }
    vector<int> path;
    for (int s = end; from[s] != -1; s = from[s]) path.push_back(dir[s]);
    reverse(path.begin(), path.end());
    for (int d : path) apply1(d);
    lockd[goal] = true;
}

// วางหมากสองตัวท้ายแถวพร้อมกัน: สถานะคือ (ช่อง A, ช่อง B, ช่องว่าง) มีไม่เกิน 16^3 แบบ
void placePair(int va, int vb, int ga, int gb) {
    int pa = (int)(find(g, g + 16, va) - g), pb = (int)(find(g, g + 16, vb) - g);
    int start = (pa * 16 + pb) * 16 + bl;
    vector<int> from(4096, -2);
    vector<char> dir(4096, -1);
    from[start] = -1;
    queue<int> q;
    q.push(start);
    int end = -1;
    while (!q.empty()) {
        int s = q.front(); q.pop();
        int ap = s >> 8, bp = (s >> 4) & 15, bk = s & 15;
        if (ap == ga && bp == gb) { end = s; break; }
        for (int d = 0; d < 4; d++) {
            int np = nb(bk, d);
            if (np < 0) continue;
            int nap = (np == ap) ? bk : ap;
            int nbp = (np == bp) ? bk : bp;
            int ns = (nap * 16 + nbp) * 16 + np;
            if (from[ns] != -2) continue;
            from[ns] = s; dir[ns] = (char)d;
            q.push(ns);
        }
    }
    vector<int> path;
    for (int s = end; from[s] != -1; s = from[s]) path.push_back(dir[s]);
    reverse(path.begin(), path.end());
    for (int d : path) apply1(d);
    lockd[ga] = lockd[gb] = true;
}

// สองแถวล่างที่เหลือ: แปดช่อง เรียงได้ 8! = 40320 แบบ ค้นทีเดียวจบ
int fct[9];

int rankPerm(const int a[8]) {
    int r = 0;
    for (int i = 0; i < 8; i++) {
        int less = 0;
        for (int j = i + 1; j < 8; j++) if (a[j] < a[i]) less++;
        r += less * fct[7 - i];
    }
    return r;
}

void unrankPerm(int r, int a[8]) {
    int pool[8] = {0, 1, 2, 3, 4, 5, 6, 7}, n = 8;
    for (int i = 0; i < 8; i++) {
        int k = r / fct[7 - i];
        r %= fct[7 - i];
        a[i] = pool[k];
        for (int j = k; j < n - 1; j++) pool[j] = pool[j + 1];
        n--;
    }
}

void finishBottom() {
    fct[0] = 1;
    for (int i = 1; i <= 8; i++) fct[i] = fct[i - 1] * i;

    int cur[8], goal[8];
    for (int i = 0; i < 8; i++) {
        cur[i] = g[8 + i] == 0 ? 7 : g[8 + i] - 9;   // ช่องว่างเป็นเลข 7 หมาก 9..15 เป็น 0..6
        goal[i] = i;
    }
    int start = rankPerm(cur), target = rankPerm(goal);

    vector<int> from(40320, -2);
    vector<char> dir(40320, -1);
    from[start] = -1;
    queue<int> q;
    q.push(start);
    while (!q.empty()) {
        int s = q.front(); q.pop();
        if (s == target) break;
        int a[8];
        unrankPerm(s, a);
        int z = 0;
        while (a[z] != 7) z++;
        for (int d = 0; d < 4; d++) {
            int nr = z / 4 + DR[d], nc = z % 4 + DC[d];
            if (nr < 0 || nr > 1 || nc < 0 || nc > 3) continue;   // ขยับได้เฉพาะในกระดานย่อยสองแถว
            int nz = nr * 4 + nc;
            int b[8];
            memcpy(b, a, sizeof b);
            swap(b[z], b[nz]);
            int ns = rankPerm(b);
            if (from[ns] != -2) continue;
            from[ns] = s; dir[ns] = (char)d;
            q.push(ns);
        }
    }
    vector<int> path;
    for (int s = target; from[s] != -1; s = from[s]) path.push_back(dir[s]);
    reverse(path.begin(), path.end());
    for (int d : path) apply1(d);
}

int main() {
    for (int i = 0; i < 16; i++) {
        if (!(cin >> g[i])) return 0;
        if (g[i] == 0) bl = i;
    }

    for (int r = 0; r < 2; r++) {
        placeOne(4 * r + 1, r * 4 + 0);
        placeOne(4 * r + 2, r * 4 + 1);
        placePair(4 * r + 3, 4 * r + 4, r * 4 + 2, r * 4 + 3);
    }
    finishBottom();

    cout << ans << '\n';
    return 0;
}

เวลาที่ใช้จริงคือ 4.6 มิลลิวินาที ในกระดานที่หนักที่สุดที่ผมสุ่มเจอ จากลิมิต 100 มิลลิวินาที (วัดด้วย steady_clock คร่อมตั้งแต่เฟสแรกถึงเฟสสุดท้าย ไม่รวมเวลาเปิดโปรแกรม) หน่วยความจำที่ใช้มากที่สุดคือสองอาเรย์ของเฟสสุดท้าย ขนาด 40,320 ช่อง คือราวสองแสนไบต์ จากลิมิต 32 เมกะไบต์

แล้วมันเกิน 5,000 ตาได้ไหม

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

ตาไกลสุดของแต่ละเฟส
เฟสสถานะที่ค้นไกลสุด (ตา)
วางหมาก 1 256 21
วางหมาก 2 256 17
วางหมาก 3 กับ 4 4096 32
วางหมาก 5 256 17
วางหมาก 6 256 13
วางหมาก 7 กับ 8 4096 29
สองแถวล่าง 40320 36
รวม · 165
ไม่ว่ากระดานเริ่มต้นจะโหดแค่ไหน โปรแกรมนี้ใช้ไม่เกิน 165 ตา ห่างจากเพดาน 5,000 ตาอยู่สามสิบเท่า

ตัวเลข 36 ของเฟสสุดท้ายมีของแถมอยู่ นั่นคือระยะที่ไกลที่สุดของปริศนา 2×4 ทั้งใบ หรือที่เรียกกันว่า เลขของพระเจ้า (God's number) ของกระดานขนาดนั้น เราได้มันมาฟรี ๆ จากการค้นทั้งกราฟอยู่แล้ว

ดูโค้ดที่ใช้หาขอบบน
bound.cpp (บางส่วน)
// หาขอบบนของจำนวนตา: BFS ถอยหลังจากเป้าหมายของแต่ละเฟส
// แล้วดูว่าสถานะที่ไกลที่สุดในเฟสนั้นห่างกี่ตา เอาทุกเฟสมาบวกกัน
int eccOne(int goal) {              // เฟสวางหมากตัวเดียว
    vector<int> dist(256, -1);
    queue<int> q;
    for (int b = 0; b < 16; b++) {  // ปลายทางคือ "หมากถึงที่แล้ว" ช่องว่างอยู่ไหนก็ได้
        if (lockd[b] || b == goal) continue;
        dist[goal * 16 + b] = 0;
        q.push(goal * 16 + b);
    }
    int mx = 0;
    while (!q.empty()) {
        int s = q.front(); q.pop();
        mx = max(mx, dist[s]);
        int tp = s >> 4, bp = s & 15;
        for (int d = 0; d < 4; d++) {
            int np = nb(bp, d);
            if (np < 0) continue;
            int ns = ((np == tp) ? bp : tp) * 16 + np;
            if (dist[ns] != -1) continue;
            dist[ns] = dist[s] + 1;
            q.push(ns);
        }
    }
    return mx;                      // ไกลสุดของเฟสนี้
}

เฟสที่วางสองตัวพร้อมกันกับเฟสสองแถวล่างใช้โครงเดียวกัน ต่างแค่การเข้ารหัสสถานะ

ของแถม: ตัวตรวจที่ผมใช้ก่อนส่ง

ผมสุ่มกระดานด้วยการเดินถอยจากสถานะสิ้นสุดสามร้อยตา (กระดานที่ได้จึงแก้ได้แน่นอน) แล้วเอาคำตอบของโปรแกรมมาเดินจริง ทดสอบไป 31,000 กระดาน ผ่านทั้งหมด ยาวสุดที่เจอคือ 135 ตา เฉลี่ย 85 ตา ซึ่งอยู่ใต้ขอบบน 165 ที่คำนวณไว้

check.js
// ตัวตรวจ: รับกระดานกับสตริงคำตอบ แล้วเดินตามจริงว่าจบที่สถานะสิ้นสุดไหม
// ใช้คู่กับตัวสุ่มกระดาน (สุ่มโดยเดินถอยจากสถานะสิ้นสุด กระดานที่ได้จึงแก้ได้เสมอ)
const D = { N: [-1, 0], E: [0, 1], S: [1, 0], W: [0, -1] };

function check(board, moves) {
  const g = board.slice();
  let b = g.indexOf(0);
  for (const ch of moves) {
    if (!(ch in D)) return `มีอักษรที่ไม่ใช่ N E W S: ${ch}`;
    const r = ((b / 4) | 0) + D[ch][0];
    const c = (b % 4) + D[ch][1];
    if (r < 0 || r > 3 || c < 0 || c > 3) return 'เลื่อนออกนอกกระดาน';
    const k = r * 4 + c;
    g[b] = g[k];
    g[k] = 0;
    b = k;
  }
  for (let i = 0; i < 15; i++) if (g[i] !== i + 1) return 'ยังไม่ถึงสถานะสิ้นสุด';
  return g[15] === 0 ? null : 'ยังไม่ถึงสถานะสิ้นสุด';
}

คำถามที่โจทย์นี้ไม่ได้ถาม แต่ต้องรู้ · กระดานนี้แก้ได้จริงหรือไม่

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

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

หาค่าไม่แปรผันของกระดานนี้

เอาตัวเลขบนกระดานมาเรียงต่อกันเป็นแถวเดียว แล้วนับจำนวนคู่ที่ผิดลำดับ (คู่ที่ตัวเลขมากอยู่ก่อนตัวเลขน้อย) โดยไม่นับช่องว่าง เรียกค่านี้ว่า inv

ทีนี้ดูว่าการเลื่อนหนึ่งครั้งทำอะไรกับ inv

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

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

solvable.cpp
// ค่าไม่แปรผันเรื่องคู่คี่ ตัดสินว่าปริศนาเลื่อนแผ่นแก้ได้หรือไม่ โดยไม่ต้องลองเลย
// กระดาน r คูณ c ตัวเลข 1..r*c-1 และ 0 คือช่องว่าง
// กฎ: คู่คี่ของ (จำนวนคู่สลับที่ผิดลำดับ + ระยะแถวของช่องว่างถึงแถวล่างสุด) ต้องคงที่
//     สำหรับกระดานที่ c เป็นเลขคี่ ใช้แค่คู่คี่ของจำนวนคู่ที่ผิดลำดับ
// อินพุต: r c แล้วกระดาน r แถว   เอาต์พุต: 1 ถ้าแก้ได้ 0 ถ้าแก้ไม่ได้
#include <bits/stdc++.h>
using namespace std;

int main() {
    int r, c;
    if (scanf("%d %d", &r, &c) != 2) return 0;
    int n = r * c;
    vector<int> b(n);
    int blankRow = 0;
    for (int i = 0; i < n; i++) {
        scanf("%d", &b[i]);
        if (b[i] == 0) blankRow = i / c;
    }
    // นับคู่ที่ผิดลำดับ โดยไม่นับช่องว่าง
    int inv = 0;
    for (int i = 0; i < n; i++) {
        if (b[i] == 0) continue;
        for (int j = i + 1; j < n; j++) {
            if (b[j] == 0) continue;
            if (b[i] > b[j]) inv++;
        }
    }
    bool ok;
    if (c % 2 == 1) {
        // คอลัมน์เป็นเลขคี่ การเลื่อนซ้ายขวาไม่เปลี่ยนคู่คี่ และการเลื่อนขึ้นลงเปลี่ยนเป็นจำนวนคู่
        ok = (inv % 2 == 0);
    } else {
        // คอลัมน์เป็นเลขคู่ ต้องรวมระยะแถวของช่องว่างเข้าไปด้วย
        int dist = (r - 1) - blankRow;
        ok = ((inv + dist) % 2 == 0);
    }
    printf("%d\n", ok ? 1 : 0);
    return 0;
}
ตัวตรวจที่ทำให้กล้าเชื่อข้ออ้างข้างบน

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

brute_solvable.cpp
// ตัวตรวจอิสระของค่าไม่แปรผัน: BFS บนกระดานทุกสภาพที่ไปถึงได้ แล้วดูว่าเจอกระดานเป้าหมายไหม
// ไม่รู้จักคำว่าคู่คี่หรือจำนวนคู่ที่ผิดลำดับเลย ใช้ได้เฉพาะกระดานเล็ก
#include <bits/stdc++.h>
using namespace std;

int main() {
    int r, c;
    if (scanf("%d %d", &r, &c) != 2) return 0;
    int n = r * c;
    string start;
    for (int i = 0; i < n; i++) {
        int v;
        scanf("%d", &v);
        start += (char)('0' + v);
    }
    string goal;
    for (int i = 1; i < n; i++) goal += (char)('0' + i);
    goal += '0';

    unordered_set<string> seen;
    seen.insert(start);
    vector<string> q{start};
    bool found = (start == goal);
    for (size_t h = 0; h < q.size() && !found; h++) {
        string cur = q[h];
        int p = (int)cur.find('0');
        int pr = p / c, pc = p % c;
        const int dr[4] = {-1, 1, 0, 0}, dc[4] = {0, 0, -1, 1};
        for (int d = 0; d < 4; d++) {
            int nr = pr + dr[d], nc = pc + dc[d];
            if (nr < 0 || nr >= r || nc < 0 || nc >= c) continue;
            string nx = cur;
            swap(nx[p], nx[nr * c + nc]);
            if (seen.insert(nx).second) {
                if (nx == goal) { found = true; break; }
                q.push_back(nx);
            }
        }
    }
    printf("%d\n", found ? 1 : 0);
    return 0;
}

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

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

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

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

ทบทวนพื้นฐาน · การสร้างคำตอบ

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

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

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

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

แหล่งที่มา

  1. โจทย์ ปริศนา 15 บน programming.in.th ข้อ 2026 โจทย์โดย อาภาพงศ์ จันทร์ทอง programming.in.th/tasks/2026 (สืบค้น 6 กันยายน 2026)
  2. ต้นทางของโจทย์คือ TOI.CPP:03-2009 ของมูลนิธิโอลิมปิกวิชาการฯ thailandoi.org/toi.c/03-2009