programming.in.th · ข้อ 2026
คำว่าดีที่สุดซึ่งเราเติมเข้าไปเองคือกับดัก กระดานมีหน้าตาได้สิบล้านล้านแบบ แต่โจทย์ขอแค่ต่ำกว่า 5,000 ตา ท่าที่ได้กลับไปคือการล็อกของที่เข้าที่แล้ว เพื่อหั่นการค้นก้อนเดียวเป็นเจ็ดก้อนที่ก้อนใหญ่สุดมี 40,320 สถานะ
คุณเพิ่งเจออัญมณีในโบราณสถาน แล้วก็เพิ่งนึกได้ว่าไม่ได้วางแผนทางออกไว้เลย เทพเจ้าที่เฝ้าที่นี่รู้ตัวแล้วว่ามีคนบุกเข้ามา และกำลังถล่มสถานที่ลงช้า ๆ โชคดีที่มีเครื่องขนย้ายมวลสารอยู่ตรงนั้น โชคร้ายที่มันจะทำงานก็ต่อเมื่อคุณแก้ ปริศนา 15 (Fifteen Puzzle อ่านว่า "ฟิฟทีน พัซเซิล") ได้ก่อน
ปริศนา 15 คือกระดาน 4×4 ที่มีหมากกำกับเลข 1 ถึง 15 วางอยู่ เหลือหนึ่งช่องว่างไว้ให้ขยับ หมากที่อยู่ติดช่องว่างเลื่อนเข้ามาแทนที่ได้ทีละตัว เป้าหมายคือเรียงให้กลายเป็น 1 ถึง 15 ตามลำดับ โดยช่องว่างไปจบที่มุมขวาล่าง
ที่ต้องอ่านให้ดีคือความหมายของตัวอักษรที่เราต้องตอบ คำตอบเป็นสตริงของอักษรสี่ตัวคือ
N, E, W, S และแต่ละตัวบอกตำแหน่งของหมากที่จะถูกเลื่อนมาแทนช่องว่าง
ไม่ใช่ทิศที่หมากเคลื่อนที่ พูดอีกแบบคือมันคือทิศที่ช่องว่างขยับไป
อินพุต / ขอบเขต / เอาต์พุต
N, E, W, S ที่พากระดานไปถึงสถานะสิ้นสุดP)
ถ้าคำตอบพากระดานไปไม่ถึง มีอักษรอื่นปน หรือเลื่อนออกนอกกระดาน จะได้ 0 คะแนนทันที
| Input | Output |
|---|---|
| 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 ได้จริง แต่มันกินเวลาเป็นวินาทีถึงนาทีในกระดานที่โหด และเราให้มันไม่ได้เลยแม้แต่หนึ่งในสิบวินาที
ใบ้
กลับไปอ่านเกณฑ์คะแนนอีกรอบ โจทย์ขออะไรกันแน่ แล้วลองถามต่อว่า ถ้าคุณตกลงกับตัวเองว่าหมากที่เข้าที่แล้วจะไม่ขยับอีก ปัญหาที่เหลือหลังวางหมากไปหนึ่งตัว ยังใหญ่เท่าเดิมอยู่ไหม
กดที่หมากตัวที่อยู่ติดช่องว่างเพื่อเลื่อนมันเข้ามา ตัวอักษรของตาที่เดินจะถูกจดไว้ให้ข้างล่าง ลองสังเกตตัวเองว่าคุณแก้มันด้วยวิธีอะไร แล้วจำความรู้สึกนั้นไว้ เพราะเฉลยจะทำเหมือนคุณเป๊ะ
ลองเล่นดูก่อนนะครับ พอมีคำตอบในใจแล้ว ค่อยไปดูเฉลย
ที่มาของแนวคิดนี้
ผมเสียเวลาไปกับข้อนี้อยู่พักหนึ่งเพราะพยายามตอบคำถามที่โจทย์ไม่ได้ถาม พอเห็นปริศนา 15 สมองก็วิ่งไปที่ "หาทางที่สั้นที่สุด" ทันที ซึ่งเป็นโจทย์ที่ดังและยากจริง และผมก็ไล่ราคาของมันจนสุด ทั้งจำนวนสถานะที่เกินหน่วยความจำไปหลายแสนเท่า และเวลาที่ให้มาแค่ 0.1 วินาที
สิ่งที่พลิกเรื่องไม่ใช่ไอเดียใหม่ แต่คือการกลับไปอ่านเกณฑ์คะแนนอีกรอบ มันเขียนไว้ตรง ๆ ว่าได้เต็มเมื่อใช้ตาน้อยกว่าเพดานที่กำหนด และไม่มีคะแนนพิเศษให้คำตอบที่สั้นกว่านั้นเลย คำตอบที่ยาวเกือบชนเพดานกับคำตอบที่สั้นที่สุดได้คะแนนเท่ากันเป๊ะ
พอยอมทิ้งคำว่า "สั้นที่สุด" ข้อนี้ก็เปลี่ยนจากโจทย์ค้นหาที่ใหญ่ที่สุดข้อหนึ่ง เป็นโจทย์ที่เลียนแบบวิธีที่คนเล่นจริงใช้มือทำ คือไล่เก็บทีละตัวแล้วล็อกไว้ และเพดานตาที่โจทย์ให้มาก็คือใบอนุญาตให้เดินอ้อมได้ตามสบาย
บทเรียนที่ยกไปข้ออื่นได้คือ ก่อนตัดสินว่าโจทย์ยากแค่ไหน ให้อ่านเกณฑ์การให้คะแนนก่อนอ่านตัวปัญหา โจทย์ที่ขอ "คำตอบที่ใช้ได้" กับโจทย์ที่ขอ "คำตอบที่ดีที่สุด" ต่างกันคนละชั้นความยาก และความต่างนั้นมักซ่อนอยู่ในประโยคเดียวที่คนอ่านข้าม
เกณฑ์คะแนนบอกไว้ตรง ๆ ว่าได้เต็มเมื่อใช้ตาน้อยกว่า 5,000 ตา ไม่มีคะแนนพิเศษสำหรับคำตอบที่สั้นกว่านั้น คำตอบยาว 4,999 ตากับคำตอบยาว 50 ตาได้คะแนนเท่ากันเป๊ะ ตัวเลข 5,000 คือใบอนุญาตให้เราเดินอ้อมได้เยอะมาก
ทีนี้ลองคิดว่าตอนคุณเล่นด้วยมือ คุณทำยังไง เกือบทุกคนทำเหมือนกันคือไล่เก็บทีละตัว จัด 1 เข้ามุมซ้ายบนก่อน แล้วก็ 2, 3, 4 ให้ครบแถวบน จากนั้นห้ามแตะแถวบนอีกเลย ไปทำแถวถัดไปในพื้นที่ที่เหลือ วิธีนี้ไม่มีทางให้คำตอบที่สั้นที่สุด แต่มันจบเสมอ
สิ่งที่การล็อกทำให้เกิดขึ้นคือปัญหาหดลงทุกครั้งที่วางหมากได้หนึ่งตัว พอ 1 เข้าที่และถูกล็อก ตอนหา 2 เราไม่ต้องสนใจอีกแล้วว่าหมากอีกสิบสามตัวเรียงกันยังไง เพราะสิ่งเดียวที่มีผลกับการพา 2 ไปเข้าที่คือ 2 อยู่ช่องไหน กับ ช่องว่างอยู่ช่องไหน เท่านั้น
นับสถานะใหม่: ช่องของหมากมี 16 แบบ ช่องว่างมี 16 แบบ รวมแล้วไม่เกิน 256 สถานะ ค้นแบบกว้างบนนี้จบในพริบตา และได้ทางที่สั้นที่สุดของเฟสนั้นด้วย
ถ้าไล่วางทีละตัวไปเรื่อย ๆ จะเจอกำแพงที่หมากตัวที่สามของแถว สมมติวาง 1 กับ 2 เข้าที่แล้วล็อกไว้ จากนั้นวาง 3 ลงช่องของมันแล้วล็อกอีกตัว ช่องสุดท้ายของแถวคือมุมขวาบน ซึ่งตอนนี้มีทางเข้าทางเดียว คือจากข้างล่าง และหมาก 4 ที่ต้องไปอยู่ตรงนั้น ต้องเข้าไปพร้อมกับที่ช่องว่างต้องออกมา ซึ่งทำไม่ได้พร้อมกัน
ทางแก้คือเลิกวางสองตัวท้ายทีละตัว แล้วถามคำถามใหญ่ขึ้นหนึ่งขั้น: จะพา 3 กับ 4 ไปเข้าที่พร้อมกันได้ยังไง
สถานะของคำถามนี้คือ (ช่องของ 3, ช่องของ 4, ช่องว่าง) ซึ่งมีไม่เกิน 16 × 16 × 16 = 4096 แบบ
ยังเล็กจนค้นแบบกว้างได้สบาย และคราวนี้ไม่มีซอกตัน เพราะเราไม่ได้ล็อกตัวแรกทิ้งไว้ก่อน
โน้ต: นี่คือจุดที่ผมพลาดตอนเขียนครั้งแรก
ผมเขียนแบบวางทีละตัวก่อน แล้วโปรแกรมวนไม่จบในบางกระดาน อาการคือหมากตัวสุดท้ายกับช่องว่างสลับที่กันไปมาในซอกนั้นไม่หยุด การเปลี่ยนมาค้นสองตัวพร้อมกันทำให้ปัญหาหายไปเอง โดยไม่ต้องจำท่าไม้ตายอะไรเลย
พอวางแถวบนสองแถวเสร็จ สิ่งที่เหลือคือกระดานย่อยขนาด 2×4 มีหมากเจ็ดตัวกับช่องว่างหนึ่งช่อง
จำนวนวิธีเรียงของแปดอย่างในแปดช่องคือ 8! = 40,320 แบบ
เลขนี้เล็กจนไม่ต้องคิดท่าอะไรเพิ่มอีกแล้ว ค้นแบบกว้างจากสถานะปัจจุบันไปยังสถานะสิ้นสุดตรง ๆ ทีเดียวจบ และได้ทางที่สั้นที่สุดของกระดานย่อยนี้แถมมาด้วย ตอนผมไล่ค้นทั้งกราฟดู มีสถานะที่ไปถึงกันได้จริง 20,160 แบบ พอดีครึ่งหนึ่งของ 40,320 ซึ่งตรงกับที่โจทย์บอกว่าครึ่งหนึ่งของการเรียงแก้ไม่ได้
กระดานข้างล่างคือกระดานเดโมที่โปรแกรมแก้จบใน 34 ตา ช่องสีเขียวคือช่องที่ถูกล็อกแล้ว กดถัดไปเพื่อเดินทีละตา จะเห็นพื้นที่ทำงานหดลงเรื่อย ๆ จนเหลือแค่สองแถวล่าง
แถบทางขวาคือเฟสทั้งเจ็ด แถบที่สว่างคือเฟสที่กำลังทำอยู่ ตัวเลขข้างหลังคือจำนวนตาที่เฟสนั้นใช้กับกระดานใบนี้
ทั้งสามเฟสใช้โครงเดียวกันคือค้นแบบกว้างแล้วย้อนรอยเส้นทาง ต่างกันแค่ว่า "สถานะ" หมายถึงอะไร
ตัวช่วยที่ทำให้ทุกอย่างสั้นคือฟังก์ชัน nb ที่คืน -1 เมื่อช่องปลายทางตกขอบหรือถูกล็อกไว้
การล็อกจึงไม่ต้องมีโค้ดพิเศษที่ไหนอีกเลย มันคือกำแพงที่ทุกเฟสมองเห็นเหมือนกันหมด
#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 เมกะไบต์
ตอบได้แบบไม่ต้องเดา เพราะแต่ละเฟสค้นแบบกว้าง มันจึงให้ทางที่สั้นที่สุดของเฟสนั้นเสมอ ถ้าเราหาว่าสถานะที่ไกลที่สุดของแต่ละเฟสอยู่ห่างเป้าหมายกี่ตา แล้วเอามาบวกกัน จะได้ขอบบนที่กระดานไหนก็ทะลุไม่ได้ วิธีหาคือ BFS ถอยหลังจากเป้าหมายของเฟสนั้นให้ทั่วกราฟ แล้วดูระยะที่มากที่สุด
| เฟส | สถานะที่ค้น | ไกลสุด (ตา) |
|---|---|---|
| วางหมาก 1 | 256 | 21 |
| วางหมาก 2 | 256 | 17 |
| วางหมาก 3 กับ 4 | 4096 | 32 |
| วางหมาก 5 | 256 | 17 |
| วางหมาก 6 | 256 | 13 |
| วางหมาก 7 กับ 8 | 4096 | 29 |
| สองแถวล่าง | 40320 | 36 |
| รวม | · | 165 |
ตัวเลข 36 ของเฟสสุดท้ายมีของแถมอยู่ นั่นคือระยะที่ไกลที่สุดของปริศนา 2×4 ทั้งใบ หรือที่เรียกกันว่า เลขของพระเจ้า (God's number) ของกระดานขนาดนั้น เราได้มันมาฟรี ๆ จากการค้นทั้งกราฟอยู่แล้ว
โจทย์ข้อนี้ใจดี มันรับประกันว่ากระดานที่ให้มาแก้ได้เสมอ แต่ปริศนาเลื่อนแผ่นในโลกจริง ไม่ได้แก้ได้ทุกกระดาน ถ้าเราหยิบแผ่นสองแผ่นมาสลับที่กันบนกระดานที่แก้ได้ กระดานที่ได้จะกลายเป็นกระดานที่แก้ไม่ได้เลย ไม่ว่าจะเลื่อนอีกกี่ล้านครั้ง
ที่น่าสนใจคือเรารู้ได้ว่ากระดานหนึ่งแก้ได้หรือไม่ โดยไม่ต้องลองเลื่อนแม้ครั้งเดียว ด้วยการมองหาสิ่งที่การเลื่อนหนึ่งครั้งเปลี่ยนมันไม่ได้ ของอย่างนั้นเรียกว่า ค่าไม่แปรผัน (invariant) และเป็นเครื่องมือหลัก ของโจทย์ทุกข้อที่ถามว่า "ทำไปเรื่อย ๆ จะไปถึงสภาพนั้นได้ไหม"
เอาตัวเลขบนกระดานมาเรียงต่อกันเป็นแถวเดียว แล้วนับจำนวนคู่ที่ผิดลำดับ
(คู่ที่ตัวเลขมากอยู่ก่อนตัวเลขน้อย) โดยไม่นับช่องว่าง เรียกค่านี้ว่า inv
ทีนี้ดูว่าการเลื่อนหนึ่งครั้งทำอะไรกับ inv
inv จึงคงเดิม
c ตำแหน่ง
เมื่อ c คือจำนวนคอลัมน์ ตัวเลขตัวนั้นกระโดดข้ามตัวอื่น c − 1 ตัว
คู่ที่ผิดลำดับจึงเปลี่ยนไปเป็นจำนวนคู่คี่เดียวกับ c − 1
ผลที่ได้ต่างกันสองกรณี ถ้า c เป็นเลขคี่ แล้ว c − 1 เป็นเลขคู่
การเลื่อนทุกแบบจึงไม่เปลี่ยนคู่คี่ของ inv เลย
คู่คี่ของ inv จึงเป็นค่าไม่แปรผัน ตรง ๆ
ส่วนถ้า c เป็นเลขคู่ การเลื่อนขึ้นลงจะพลิกคู่คี่ของ inv
ทุกครั้ง แต่มันก็ขยับแถวของช่องว่างไปหนึ่งแถวทุกครั้งด้วย
ของที่ไม่แปรผันจึงเป็นคู่คี่ของผลบวก ระหว่าง inv
กับระยะแถวของช่องว่างถึงแถวล่างสุด
เมื่อรู้ค่าไม่แปรผันแล้ว การตัดสินก็เหลือบรรทัดเดียว เทียบค่าไม่แปรผันของกระดานที่ได้มา กับของกระดานเป้าหมาย ถ้าไม่เท่ากันคือแก้ไม่ได้ ไม่ต้องค้นหาอะไรเลย
// ค่าไม่แปรผันเรื่องคู่คี่ ตัดสินว่าปริศนาเลื่อนแผ่นแก้ได้หรือไม่ โดยไม่ต้องลองเลย
// กระดาน 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;
} รูปทั่วไปของท่านี้ยกไปใช้ได้กว้างมาก พอเจอโจทย์ที่ถามว่า "จากสภาพนี้ไปถึงสภาพนั้นได้ไหม" ให้เลิกมองหาเส้นทาง แล้วไปมองหาปริมาณที่ทุกท่าที่อนุญาตเปลี่ยนมันไม่ได้ ถ้าหาเจอ และค่าของสองสภาพไม่เท่ากัน คำตอบคือไปไม่ได้ จบเลยโดยไม่ต้องค้นหา ท่าเดียวกันนี้โผล่ในโจทย์เครื่องเคลื่อนย้าย และเป็นหัวใจของโจทย์ตัดถนน
วิธีของข้อนี้มีชื่อเรียกว่าการสร้างคำตอบ (constructive) คือแทนที่จะไปค้นหาว่าคำตอบอยู่ที่ไหน เราออกแบบขั้นตอนที่ผลิตคำตอบออกมาตรง ๆ แล้วพิสูจน์ว่าขั้นตอนนั้นไม่มีทางตัน ตัวโจทย์ไม่ได้ขอทางที่สั้นที่สุด จึงเปิดช่องให้เราเลือกทางที่อธิบายได้ง่ายแทนทางที่ดีที่สุด
ทบทวนพื้นฐาน · การสร้างคำตอบ
โจทย์ค้นหาจะถามว่า "คำตอบที่ดีที่สุดคืออะไร" แล้วเราตอบด้วยการไล่ดูความเป็นไปได้ ส่วนโจทย์สร้างคำตอบ จะถามแค่ว่า "ขอคำตอบที่ใช้ได้มาหนึ่งอัน" ซึ่งเปลี่ยนงานไปคนละแบบ จากการค้นหาเป็นการออกแบบวิธีทำ สัญญาณที่บอกว่าเจอโจทย์ประเภทนี้คือคำว่า "ไม่เกิน" ในขอบเขตของจำนวนก้าว หรือประโยคว่ารับคำตอบใดก็ได้ พอเห็นสัญญาณนั้นให้เลิกมองหาค่าที่ดีที่สุดทันที แล้วไปหาขั้นตอนที่ลดปัญหาให้เล็กลงทีละก้าวอย่างการันตี เช่นในข้อนี้คือ วางของให้เข้าที่ทีละแถวแล้วล็อกแถวนั้นไว้ ไม่แตะอีก
ท่าคู่กันที่ต้องรู้คือคู่คี่ (parity) ซึ่งบอกได้ว่ากระดานที่ได้มานั้นแก้ได้หรือแก้ไม่ได้เลย ก่อนจะเสียเวลาสร้างคำตอบให้มัน มีคำอธิบายเรื่องคู่คี่อยู่ในโจทย์เครื่องเคลื่อนย้าย
สิบล้านล้านสถานะยุบเหลือเจ็ดรอบที่รอบใหญ่สุดมี 40,320 สถานะ เพราะเราเลิกขอคำตอบที่สั้นที่สุด แล้วยอมล็อกของที่เข้าที่แล้วทิ้งไว้ ข้อนี้เกณฑ์คะแนนคือส่วนหนึ่งของโจทย์ ไม่ใช่เชิงอรรถท้ายหน้า
ในหน้านี้