programming.in.th · ข้อ 2006

สี่เหลี่ยมพิฆาต: หาโครงสร้างซ้ำในเงื่อนไข แล้วต้นทุนยุบสองพันเท่า

สองท่าที่หยิบไปใช้ต่อได้ตลอด คือการมองหาว่าเงื่อนไขก้อนใหญ่มีตัวมันเองซ่อนอยู่ข้างในไหม และการเอาแฮชสตริงมาทำให้การตรวจหนึ่งชั้นเหลือเวลาคงที่ ผลคือ 82,358,940,040 ครั้งยุบเหลือ 35,820,200 ครั้ง

★★★☆☆ hashingdpstring อ่าน 8 นาที 6 กันยายน 2026

โจทย์ · หน่วยความจำที่อ่านกลับหัวแล้วเหมือนเดิม

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

สิ่งที่มินตราตามล่าอยู่เขาเรียกมันว่า สี่เหลี่ยมพิฆาต (killer square) นั่นคือตารางย่อยรูปจัตุรัส ที่มีช่องมากกว่าหนึ่งช่อง และเมื่อหมุนมันไป 180 องศาแล้วยังได้ภาพเดิมเป๊ะทุกช่อง งานของเราคือหาว่าจัตุรัสแบบนี้ ที่ใหญ่ที่สุดในตารางมีด้านยาวเท่าไร

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

ก่อนหมุน 1 A 0 B 1 C 1 D 1 . 1 D 1 C 0 B 1 A ป้ายจุดคือช่องที่ทับตัวเอง หมุน 180 หลังหมุน 1 A 0 B 1 C 1 D 1 . 1 D 1 C 0 B 1 A ตรงกับภาพซ้ายทุกช่อง
ช่องที่ติดป้ายตัวเดียวกันคือช่องที่หมุนแล้วไปทับกัน จึงต้องมีค่าเท่ากัน ส่วนช่องกลางทับตัวเองจึงเป็นอะไรก็ได้ (วาดประกอบโดยผู้เขียน)

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

EXAMPLE
InputOutput
3 6
101010
111001
101001
3
4 5
10010
01010
10101
01001
3
3 3
101
111
100
-1

ตารางตัวอย่างแรกมีสี่เหลี่ยมพิฆาตอยู่ 3 อัน ด้านยาว 3, 2, 2 คำตอบจึงเป็น 3

ตัวอย่างที่ 1 · สี่เหลี่ยมพิฆาตทุกอัน
ด้านยาวมุมบนซ้ายตำแหน่งในตาราง
3 แถว 1 คอลัมน์ 1 101... 111... 101...
2 แถว 1 คอลัมน์ 5 ....10 ....01 ......
2 แถว 2 คอลัมน์ 4 ...... ...00. ...00.
ช่องที่เป็นจุดคือช่องที่ไม่ได้อยู่ในจัตุรัสอันนั้น เขียนแบบนี้เพื่อให้เห็นว่ามันวางทับอยู่ตรงไหนของตารางเดิม

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

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

ตารางขนาด 300 คูณ 300 มีจัตุรัสให้พิจารณา 8.96 ล้าน อัน ตัวเลขนี้ยังพอไหว ปัญหาอยู่ที่ต้นทุนของการตรวจหนึ่งอัน เพราะจัตุรัสด้านยาว k มี k × k ช่อง รวมทั้งตารางแล้วต้องเทียบช่องต่อช่องราว 82.4 พันล้าน ครั้ง ในเวลาหนึ่งวินาที

ตาราง 300 × 300 · นับเป็นจำนวนครั้งที่แตะข้อมูล
วิธีต้นทุนต่อหนึ่งจัตุรัสรวมทั้งตาราง
ตรวจทุกจัตุรัสแบบซื่อ ๆ k × k ครั้งต่อหนึ่งจัตุรัส 82,358,940,040
ปอกทีละวง เทียบขอบด้วยแฮช 4 ครั้งต่อหนึ่งจัตุรัส 35,820,200
สองแถวนี้นับหน่วยเดียวกันคือจำนวนครั้งที่โปรแกรมแตะข้อมูลหนึ่งชิ้น จำนวนจัตุรัสที่ต้องพิจารณาเท่ากันทั้งคู่ ต่างกันแค่ราคาต่อหนึ่งอันเท่านั้น

ใบ้

ลองวาดจัตุรัสด้าน 5 แล้วระบายวงนอกสุดของมันให้เป็นสีหนึ่ง วงถัดเข้ามาเป็นอีกสีหนึ่ง ไล่เข้าไปจนถึงช่องกลาง ทีนี้ลองหมุนกระดาษ 180 องศา แล้วถามว่าวงนอกสุดหมุนไปทับวงไหน และวงที่สองหมุนไปทับวงไหน ถ้าเรารู้อยู่แล้วว่าจัตุรัสด้าน 3 ที่อยู่ตรงกลางผ่านแล้ว เหลืออะไรอีกที่ต้องตรวจ?

ลองเอง · หาจัตุรัสพิฆาตที่ใหญ่ที่สุด

ตารางเดียวกับตัวอย่างแรกของโจทย์

ด้านยาว 2

คลิกช่องเพื่อวางมุมบนซ้าย แล้วปรับด้านยาวด้วยปุ่มบวกลบ จากนั้นกดตรวจ

ใหญ่ที่สุดที่หาเจอแล้ว 0

จัตุรัสพิฆาตคือจัตุรัสที่หมุน 180 องศาแล้วได้ภาพเดิมทุกช่อง

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

เฉลย ตอนที่ 1 · หัวหอมที่ปอกทีละวง

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

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

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

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

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

เงื่อนไขของโจทย์เขียนเป็นสมการได้บรรทัดเดียว จัตุรัสมุมบนซ้ายที่ (r, c) ด้านยาว k เป็นสี่เหลี่ยมพิฆาต ก็ต่อเมื่อ

สังเกตว่าดัชนีสองข้างของเครื่องหมายเท่ากับบวกกันได้ค่าคงที่เสมอ แถวคือ 2r + k - 1 คอลัมน์คือ 2c + k - 1 ทั้งสองค่านี้ไม่ขึ้นกับ i หรือ j เลย นั่นแปลว่าการหมุน 180 องศา สลับตำแหน่งภายในวงเดียวกันเท่านั้น ช่องที่อยู่ห่างจากขอบจัตุรัสหนึ่งช่อง หมุนไปแล้วก็ยังห่างจากขอบหนึ่งช่องอยู่ดี

ผลที่ตามมาคือเงื่อนไขทั้งก้อนแยกเป็นชั้น ๆ อิสระจากกันได้ เหมือนหัวหอมที่ปอกทีละวง วงนอกสุดตรวจกันเอง วงถัดเข้าไปตรวจกันเอง และเมื่อปอกวงนอกออกหนึ่งชั้น สิ่งที่เหลือคือจัตุรัสด้าน k - 2 ที่มุมบนซ้ายเลื่อนมาที่ (r + 1, c + 1) ซึ่งเป็นคำถามหน้าตาเดียวกันเป๊ะกับคำถามเดิม

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

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

เฉลย ตอนที่ 2 · วงนอกหนึ่งวงเทียบกันได้ในสี่ครั้ง

การปอกวงช่วยให้ไม่ต้องตรวจไส้ในซ้ำ แต่ตัววงนอกเองยังมีช่องอยู่ราว 4k ช่อง ถ้ายังเดินทีละช่องก็ยังช้าอยู่ดี ตรงนี้แหละที่ต้องมองว่าวงนอกจริง ๆ แล้วประกอบด้วยอะไร

หมุน 180 องศาแล้ว ขอบบนไปทับขอบล่างในลำดับกลับด้าน และ ขอบซ้ายไปทับขอบขวาในลำดับกลับด้าน เท่านั้นเอง เอาจัตุรัสด้าน 3 จากตัวอย่างแรกมาแกะให้เห็นชัด ๆ

จัตุรัสด้าน 3 ที่แถว 1 คอลัมน์ 1
สิ่งที่เทียบฝั่งหนึ่งอีกฝั่ง อ่านกลับด้านผล
ขอบบน กับ ขอบล่าง 101 101 ตรงกัน
ขอบซ้าย กับ ขอบขวา 111 111 ตรงกัน
ไส้ในด้าน 1 1 1 ผ่านฟรี
มุมทั้งสี่ถูกนับซ้ำทั้งในแถวและในคอลัมน์ ซึ่งไม่เป็นไร เพราะการตรวจซ้ำไม่ทำให้คำตอบผิด มันแค่เสียแรงเพิ่มนิดเดียว

ทีนี้เหลือคำถามเดียว จะเทียบ "สตริงช่วงหนึ่ง" กับ "สตริงอีกช่วงหนึ่งที่กลับด้านแล้ว" ให้จบในเวลาคงที่ได้ยังไง คำตอบคือแฮชนำหน้า (prefix hash) มองแต่ละแถวเป็นตัวเลขฐาน B แล้วเก็บค่าสะสมไว้ล่วงหน้า ค่าของช่วงใด ๆ ก็หักลบกันออกมาได้ในครั้งเดียว ส่วน "อ่านกลับด้าน" ก็แค่สร้างชุดที่สองจากแถวที่กลับด้านไว้ตั้งแต่แรก แล้วเทียบกับช่วงที่สะท้อนตำแหน่งกันไว้

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

โค้ด C++

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

หัวใจของทั้งข้อ
if (!two[r + 1][c + 1]) continue;             // ไส้ในด้าน k-2 ต้องผ่านก่อน
if (!rowFit(r, r + k - 1, c, k)) continue;    // ขอบบนคู่ขอบล่าง
if (!colFit(c, c + k - 1, r, k)) continue;    // ขอบซ้ายคู่ขอบขวา
cur[r][c] = 1;

จุดที่ต้องระวังคือช่วงกลับด้าน ถ้าช่วงที่สนใจในสตริงปกติคือ [c, c + k - 1] ช่วงเดียวกันในสตริงที่กลับด้านแล้ว จะอยู่ที่ [C - c - k, C - c - 1] เขียนผิดที่นี่แล้วโปรแกรมยังรันได้ปกติ ตอบตัวอย่างถูกบ้างผิดบ้าง เป็นบักที่หาเจอยากมากถ้าไม่มีตัวตรวจสอบสุ่มเทียบให้

ฐาน B ของแฮชสุ่มใหม่ทุกครั้งที่รัน ไม่ได้ตั้งค่าคงที่ไว้ เพราะข้อที่มีคนแต่งอินพุตมาชนแฮชได้ การใช้ฐานตายตัวคือการเปิดประตูทิ้งไว้ ส่วนมอดุลัสใช้ 2^61 - 1 ซึ่งคูณกันแล้วพับด้วยการเลื่อนบิตได้เลย ไม่ต้องหารจริง

ดูโค้ดเต็ม
killer.cpp
#include <bits/stdc++.h>
using namespace std;
typedef unsigned long long ull;

// แฮชโมดูโล 2^61 - 1 คูณกันแล้วพับด้วยบิต ไม่ต้องหารเลย
const ull MOD = (1ULL << 61) - 1;
static inline ull add(ull a, ull b) { a += b; if (a >= MOD) a -= MOD; return a; }
static inline ull sub(ull a, ull b) { return add(a, MOD - b); }
static inline ull mul(ull a, ull b) {
    __uint128_t c = (__uint128_t)a * b;
    return add((ull)(c & MOD), (ull)(c >> 61));
}

int R, C;
ull B;
vector<ull> pw;
vector<vector<ull>> hRow, hRowRev, hCol, hColRev;

// แฮชนำหน้าของทุกบรรทัดในชุดที่ให้มา
static void build(vector<vector<ull>>& h, const vector<string>& s) {
    int n = s.size(), m = s[0].size();
    h.assign(n, vector<ull>(m + 1, 0));
    for (int i = 0; i < n; i++)
        for (int j = 0; j < m; j++)
            h[i][j + 1] = add(mul(h[i][j], B), (ull)(s[i][j] - '0' + 1));
}
static inline ull cut(const vector<vector<ull>>& h, int i, int l, int len) {
    return sub(h[i][l + len], mul(h[i][l], pw[len]));
}

int main() {
    scanf("%d %d", &R, &C);
    vector<string> row(R);
    for (int i = 0; i < R; i++) { char buf[512]; scanf("%s", buf); row[i] = buf; }

    mt19937_64 rng(chrono::steady_clock::now().time_since_epoch().count());
    B = rng() % (MOD - 300) + 256;                  // ฐานสุ่ม กันคนแต่งอินพุตมาชนแฮช
    pw.assign(max(R, C) + 1, 1);
    for (size_t i = 1; i < pw.size(); i++) pw[i] = mul(pw[i - 1], B);

    // เตรียมสี่ชุด: แถวปกติ แถวกลับด้าน คอลัมน์ปกติ คอลัมน์กลับด้าน
    vector<string> rowRev(R), col(C, string(R, '0')), colRev(C);
    for (int i = 0; i < R; i++) { rowRev[i] = row[i]; reverse(rowRev[i].begin(), rowRev[i].end()); }
    for (int j = 0; j < C; j++) {
        for (int i = 0; i < R; i++) col[j][i] = row[i][j];
        colRev[j] = col[j];
        reverse(colRev[j].begin(), colRev[j].end());
    }
    build(hRow, row); build(hRowRev, rowRev); build(hCol, col); build(hColRev, colRev);

    // ขอบบนช่วง [c, c+k-1] ต้องเท่ากับขอบล่างช่วงเดียวกัน "อ่านกลับด้าน"
    // ช่วงกลับด้านของ [c, c+k-1] ในสตริงที่กลับแล้ว คือ [C-c-k, C-c-1]
    auto rowFit = [&](int u, int v, int c, int k) {
        return cut(hRow, u, c, k) == cut(hRowRev, v, C - c - k, k);
    };
    auto colFit = [&](int a, int b, int r, int k) {
        return cut(hCol, a, r, k) == cut(hColRev, b, R - r - k, k);
    };

    int K = min(R, C);
    // สองชั้นที่ต้องเก็บคือ k-1 กับ k-2 เท่านั้น จัตุรัสด้าน 0 กับ 1 สมมาตรเสมอ
    vector<vector<char>> two(R, vector<char>(C, 1));
    vector<vector<char>> one(R, vector<char>(C, 1));
    int ans = -1;
    for (int k = 2; k <= K; k++) {
        vector<vector<char>> cur(R, vector<char>(C, 0));
        for (int r = 0; r + k <= R; r++)
            for (int c = 0; c + k <= C; c++) {
                if (!two[r + 1][c + 1]) continue;             // ไส้ในด้าน k-2 ต้องผ่านก่อน
                if (!rowFit(r, r + k - 1, c, k)) continue;    // ขอบบนคู่ขอบล่าง
                if (!colFit(c, c + k - 1, r, k)) continue;    // ขอบซ้ายคู่ขอบขวา
                cur[r][c] = 1;
                ans = k;
            }
        two = one;
        one = cur;
    }
    printf("%d\n", ans);
    return 0;
}
ของแถม: ตัวตรวจสอบที่ผมใช้ก่อนส่ง

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

brute.cpp
// ตัวตรวจสอบแบบซื่อ ๆ ไล่ทุกจัตุรัสแล้วเทียบช่องต่อช่อง ใช้ได้แค่ตารางเล็ก ๆ
// เอาไว้สุ่มเทียบกับโค้ดจริงก่อนส่ง ไม่ใช่โค้ดที่ส่งเข้าระบบตัดสิน
#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[512]; scanf("%s", b); s = b; }

    int ans = -1;
    for (int k = 2; k <= min(R, C); k++)
        for (int r = 0; r + k <= R; r++)
            for (int c = 0; c + k <= C; c++) {
                bool ok = true;
                for (int i = 0; i < k && ok; i++)
                    for (int j = 0; j < k && ok; j++)
                        if (g[r + i][c + j] != g[r + k - 1 - i][c + k - 1 - j]) ok = false;
                if (ok) ans = max(ans, k);
            }
    printf("%d\n", ans);
}

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

แกะตัวอย่างที่ตอบ -1

ตัวอย่างที่สามคือตาราง 101 111 100 ซึ่งตอบ -1 หมายความว่าไม่มีแม้แต่จัตุรัสด้าน 2 สักอันเดียว ซึ่งฟังดูแปลก เพราะจัตุรัสด้าน 2 มีเงื่อนไขเบามาก คือแค่คู่ทแยงมุมทั้งสองคู่ต้องมีค่าเท่ากันเท่านั้น (หมุน 180 องศาในจัตุรัสด้าน 2 คือสลับมุมบนซ้ายกับล่างขวา และสลับบนขวากับล่างซ้าย)

กดถัดไปเพื่อตรวจจัตุรัสด้านสองทีละอัน

ตัวอย่างที่ 3 · จัตุรัสด้าน 2 ทั้งสี่อัน
มุมบนซ้ายทแยงหลักทแยงรองผล
แถว 1 คอลัมน์ 1 1 กับ 1 0 กับ 1 ตก
แถว 1 คอลัมน์ 2 0 กับ 1 1 กับ 1 ตก
แถว 2 คอลัมน์ 1 1 กับ 0 1 กับ 1 ตก
แถว 2 คอลัมน์ 2 1 กับ 0 1 กับ 0 ตก

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

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

ทางที่ไม่มีโอกาสชนเลย · Manacher

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

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

มันมีสองท่าที่ต้องรู้ ท่าแรกคือการแทรกตัวคั่น พาลินโดรมมีสองแบบ คือความยาวคี่ที่มีตัวกลางหนึ่งตัว และความยาวคู่ที่ศูนย์กลางอยู่ระหว่างตัวอักษร การเขียนโค้ดแยกสองกรณีคือแหล่งบั๊กชั้นดี ท่าแก้คือแทรกตัวคั่นระหว่างทุกตัวอักษรและที่หัวท้าย ทุกพาลินโดรมในสตริงใหม่จะมีความยาวคี่เสมอ เหลือกรณีเดียว

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

manacher.cpp
// Manacher: หาความยาวพาลินโดรมที่ยาวที่สุด และรัศมีของทุกจุดศูนย์กลาง
// ไม่ใช้แฮชเลย จึงไม่มีโอกาสชนกัน
// อินพุต: สตริงหนึ่งบรรทัด   เอาต์พุต: ความยาวพาลินโดรมที่ยาวที่สุด
#include <bits/stdc++.h>
using namespace std;

int main() {
    string s;
    if (!(cin >> s)) return 0;
    // แทรกตัวคั่นเพื่อรวมกรณีความยาวคู่กับคี่เป็นกรณีเดียว
    string t = "#";
    for (char c : s) { t += c; t += '#'; }
    int n = (int)t.size();
    vector<int> rad(n, 0);
    int l = 0, r = -1;                  // ช่วงพาลินโดรมที่ขวาสุดที่รู้แล้ว
    for (int i = 0; i < n; i++) {
        int k = (i > r) ? 1 : min(rad[l + r - i], r - i + 1);   // ใช้ภาพสะท้อนของของเก่า
        while (i - k >= 0 && i + k < n && t[i - k] == t[i + k]) k++;
        rad[i] = k--;
        if (i + k > r) { l = i - k; r = i + k; }
    }
    int best = 0;
    for (int i = 0; i < n; i++) best = max(best, rad[i] - 1);
    printf("%d\n", best);
    return 0;
}
ตัวตรวจที่ทำให้กล้าเชื่อโค้ดข้างบน

บรรทัดที่พลาดง่ายที่สุดคือค่าตั้งต้น min(rad[l + r - i], r - i + 1) ถ้าลืมตัดด้วย r - i + 1 โค้ดจะยังตอบถูกในหลายกรณี และผิดเฉพาะตอนที่คู่สะท้อน ยาวเกินขอบของช่วงที่รู้แล้ว ตัวตรวจจึงลองทุกช่วงแล้วเช็คว่าเป็นพาลินโดรมไหมตรง ๆ โดยไม่รู้จักภาพสะท้อนเลย

brute_palindrome.cpp
// ตัวตรวจอิสระของ Manacher: ลองทุกช่วงแล้วเช็คว่าเป็นพาลินโดรมไหมตรง ๆ
// ไม่รู้จักการใช้ภาพสะท้อนของของเก่าเลย
#include <bits/stdc++.h>
using namespace std;

int main() {
    string s;
    if (!(cin >> s)) return 0;
    int n = (int)s.size(), best = 0;
    for (int i = 0; i < n; i++)
        for (int j = i; j < n; j++) {
            bool ok = true;
            for (int a = i, b = j; a < b && ok; a++, b--) if (s[a] != s[b]) ok = false;
            if (ok) best = max(best, j - i + 1);
        }
    printf("%d\n", best);
    return 0;
}

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

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

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

การหมุน 180 องศาไม่เคยพาช่องข้ามวง จัตุรัสด้าน k จึงเท่ากับจัตุรัสด้าน k - 2 ที่อยู่ข้างในบวกวงนอกหนึ่งวง และวงนอกหนึ่งวงคือการเทียบสตริงสองคู่ ซึ่งแฮชนำหน้าตอบให้ได้ในเวลาคงที่

แหล่งที่มา

  1. โจทย์ Killer บน programming.in.th ข้อ 2006 programming.in.th/tasks/2006 (สืบค้น 6 กันยายน 2026)
  2. ต้นทางของโจทย์คือ Croatian Open Competition in Informatics คอนเทสต์ที่ 1 ฤดูกาล 2006/2007 แข่งวันที่ 28 ตุลาคม 2006