ปูพื้นฐาน

DP ที่สถานะคือความจำล่าสุด: เก็บอดีตให้น้อยที่สุดเท่าที่อนาคตยังต้องใช้

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

บทปูพื้นฐาน ★★☆☆☆ dpstate compressionพื้นฐาน อ่าน 17 นาที 6 กันยายน 2026

อาการ · ตัวเลขเดียวต่อหนึ่งคำนำหน้า เก็บได้ไม่พอ

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

ท่ามาตรฐานของ DP คือให้ dp[i] เป็นคะแนนดีที่สุดเมื่อดูของ i ชิ้นแรก แล้วเดินไปข้างหน้าทีละชิ้น ลองใช้กับแถวสั้น ๆ แค่สามชิ้นคือ 5 5 100 ดู

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

แต่คำตอบจริงของแถวนี้คือ 105 ซึ่งได้จากการเลือกชิ้นที่ 1 กับ 3 นั่นคือยอมทิ้งชิ้นที่สอง ทั้งที่มันทำให้คะแนนของคำนำหน้าลดลงจาก 10 เหลือ 5

ตรงนี้คือหัวใจของทั้งบท

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

ลองเอง · เลือกของให้ได้แต้มมากที่สุด

ของสี่ชิ้น พอให้เห็นกติกาว่าห้ามติดกันสามชิ้น

กติกาข้อเดียว ห้ามเลือกของสามชิ้นที่ติดกัน

กดเลือกของที่จะเก็บ ห้ามเลือกสามชิ้นที่ติดกัน

แต้มตอนนี้ 0 · เลือกติดกันยาวสุด 0 ชิ้น

ตัวเลขทุกตัวเป็นบวก ดังนั้นการไม่เลือกเลยไม่เคยเป็นทางที่ดี

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

ทางออก · จดสภาพลงไปในสถานะด้วย

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

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

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

สองข้อสอบที่ของชิ้นหนึ่งต้องผ่าน ถึงจะเรียกว่าสถานะได้

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

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

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

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

เดินตารางให้ดูหนึ่งแถว

เอาแถว 5 5 100 1 8 9 มาเดินจริง แต่ละแถวของตารางคือหลังตัดสินใจของชิ้นนั้นเสร็จแล้ว ช่องที่เป็นจุดคือสถานะที่ยังไปไม่ถึง

กดถัดไปเพื่อดูตารางโตขึ้นทีละแถว

แถว 5 5 100 1 8 9 · คำตอบคือ 122
หลังชิ้นที่ ค้าง 0 ชิ้น ค้าง 1 ชิ้น ค้าง 2 ชิ้น
ก่อนเริ่ม 0··
1. ค่า 5 05·
2. ค่า 5 5510
3. ค่า 100 10105105
4. ค่า 1 10511106
5. ค่า 8 10611319
6. ค่า 9 113115122

แผนที่ดีที่สุดคือเก็บชิ้นที่ 1, 3, 5, 6 รวม 122 คะแนน สังเกตแถวที่เขียนว่า 2. ค่า 5 สองช่องซ้ายยังเก็บ 5 ไว้ ทั้งที่ช่องขวาสุดของแถวเดียวกันมี 10 ซึ่งมากกว่า ถ้าตารางมีคอลัมน์เดียว เลข 10 จะกลืนที่เหลือไปหมดตั้งแต่ตรงนั้น แล้วของชิ้นที่ 3 ที่มีค่า 100 ก็จะเก็บไม่ได้ ทั้งที่แถวถัดมาบอกชัดว่าเลข 5 คือตัวที่พาไปถึง 105

ด่านที่สาม · สถานะมีได้กี่แบบ

สองข้อสอบข้างบนบอกว่าอะไรใช้เป็นสถานะได้ แต่ไม่ได้บอกว่ามันคุ้ม เพราะของที่ผ่านสองข้อนั้นเสมอมีอยู่อย่างหนึ่ง คือ "จำอดีตทั้งหมดไว้เลย" ซึ่งพอไหมก็พอ ปิดตัวเองไหมก็ปิด แต่มันมีได้ถึง 2^n แบบ ซึ่งที่ n = 100,000 คือจำนวนที่เขียนลงกระดาษยังไม่ไหว

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

ตัวอย่างจากบทนี้และจากคลังโจทย์
ที่ไหนจำอะไรค่าต่อช่องกว้างมีกี่แบบ
ตัวอย่างประจำบท เลือกติดกันมาแล้วกี่ชิ้น 3 1 3
ฝึกข้อ 2 สองช่องล่าสุดสีเหมือนกันไหม 2 1 2
ฝึกข้อ 3 ตัวอักษรสองตัวล่าสุด 3 2 9
ข้อ Miners คู่ล่าสุดของสองเหมือง 4 4 256
ถ้าเผลอจำทั้งอดีต ตัดสินใจไปแล้วยังไงบ้างทุกชิ้น 2 n 2^n
สี่แถวบนมีจำนวนสถานะที่ไม่โตตามความยาวอินพุตเลย ต่อให้แถวยาวเป็นแสนชิ้น ตารางก็ยังกว้างเท่าเดิม งานทั้งหมดจึงเป็นความยาวแถวคูณจำนวนสถานะ ซึ่งเป็นเส้นตรง

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

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

เขียนเป็นโค้ด

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

rolling.cpp
#include <bits/stdc++.h>
using namespace std;

int main() {
    int n;
    scanf("%d", &n);
    vector<long long> a(n);
    for (auto& x : a) scanf("%lld", &x);

    const long long NEG = LLONG_MIN / 4;          // เผื่อที่ให้บวกค่าเข้าไปโดยไม่ล้น
    vector<long long> cur(3, NEG), nxt(3);
    cur[0] = 0;                                   // ยังไม่เลือกอะไร ค้างศูนย์ชิ้น

    for (int i = 0; i < n; i++) {
        fill(nxt.begin(), nxt.end(), NEG);        // ต้องล้างก่อนทุกรอบ เพราะเราเขียนแบบผลัก
        for (int j = 0; j < 3; j++) {
            if (cur[j] == NEG) continue;          // สถานะนี้ยังไปไม่ถึง
            nxt[0] = max(nxt[0], cur[j]);                     // ข้ามชิ้นนี้ ช่วงที่เลือกติดกันขาด
            if (j < 2) nxt[j + 1] = max(nxt[j + 1], cur[j] + a[i]);  // เก็บชิ้นนี้ ช่วงยาวขึ้นหนึ่ง
        }
        cur = nxt;
    }

    printf("%lld\n", *max_element(cur.begin(), cur.end()));
    return 0;
}

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

อย่างที่สองคือค่า NEG ที่ใช้แทน "สถานะนี้ยังไปไม่ถึง" ซึ่งต้องแยกจากคะแนน 0 ให้ขาด เพราะ 0 เป็นคะแนนที่ถูกต้องได้ (ตอนยังไม่เลือกอะไรเลย) และต้องหารสี่ทิ้งไว้ด้วย เพราะเราจะเอาค่าไปบวกกับ a[i] ถ้าใช้ LLONG_MIN ตรง ๆ แล้วบวกเลขบวกเข้าไป มันจะล้นกลับไปเป็นค่าบวกมหาศาลแล้วชนะทุกอย่างในตาราง

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

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

brute.cpp
// ตัวตรวจสอบแบบซื่อ ๆ ลองทุกชุดที่เลือกได้ด้วยบิตมาสก์ ใช้ได้แค่ n ราว ๆ 20
// เอาไว้สุ่มเทียบกับโค้ดจริงก่อนส่ง ไม่ใช่โค้ดที่ส่งเข้าระบบตัดสิน
#include <bits/stdc++.h>
using namespace std;

int main() {
    int n;
    scanf("%d", &n);
    vector<long long> a(n);
    for (auto& x : a) scanf("%lld", &x);

    long long best = LLONG_MIN;
    for (int m = 0; m < (1 << n); m++) {
        int run = 0;
        bool ok = true;
        long long s = 0;
        for (int i = 0; i < n; i++) {
            if (m >> i & 1) {
                run++; s += a[i];
                if (run >= 3) { ok = false; break; }
            } else run = 0;
        }
        if (ok) best = max(best, s);
    }
    printf("%lld\n", best);
}

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

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

ฝึกข้อ 1 ★☆☆☆☆ · ห้ามเว้นสามชิ้นติดกัน

โจทย์กำหนด

ของเรียงมาเป็นแถว n ≤ 200 000 ชิ้น ชิ้นที่ i มีราคา c[i] ซึ่งเป็นบวก คุณต้องซื้อบางชิ้น โดยมีกติกาว่าห้ามเว้นสามชิ้นติดกัน คือในทุกช่วงยาวสามชิ้นต้องมีของที่ซื้ออย่างน้อยหนึ่งชิ้น ให้จ่ายน้อยที่สุด

EXAMPLE
InputOutput
8
7 2 9 4 1 8 3 6
6

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

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

กติกา "ห้ามเว้นสามชิ้นติดกัน" อ่านง่ายที่สุดโดยไล่หน้าต่างยาวสามชิ้นทุกหน้าต่าง ทุกหน้าต่างต้องมีของที่ซื้ออย่างน้อยหนึ่งชิ้น ชุดนี้จ่ายน้อยที่สุดได้ 6 โดยซื้อชิ้นที่ 2, 5, 7

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

ราคาของแต่ละชิ้น ช่องเขียวคือชิ้นที่ซื้อ 7 1 2 2 9 3 4 4 1 5 8 6 3 7 6 8 จ่ายรวม 6 ซึ่งคือเอาต์พุต ส่วนท่าโลภซื้อทุกชิ้นที่สามจ่าย 17
ช่องสีเขียวคือชิ้นที่ซื้อ แถบใต้ภาพคือหน้าต่างยาวสามชิ้นทุกหน้าต่าง ซึ่งทุกอันต้องมีสีเขียวอย่างน้อยหนึ่งช่อง

คำใบ้

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

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

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

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

และเพราะการยุบแบบนี้ฟังดูดีเกินจริง ผมไม่เชื่อมันจากการให้เหตุผลอย่างเดียว ผมเขียนตัวไล่ทาสีทุกวิธีจริง ๆ แล้วเทียบกับสูตรสองสถานะ ทุกค่าของ n ตั้งแต่ 1 ถึง 8 คู่กับทุกค่าของ k ตั้งแต่ 1 ถึง 4 ตรงกันหมดทุกช่อง รวมกรณีขอบอย่าง k = 1 ที่ทาได้แบบเดียวจนกระทั่ง n ถึงสาม แล้วคำตอบกลายเป็นศูนย์

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

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

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

ฝึกข้อ 1 · กลับด้านของโจทย์ประจำบท
#include <bits/stdc++.h>
using namespace std;

int main() {
    int n;
    scanf("%d", &n);
    vector<long long> c(n);
    for (auto& x : c) scanf("%lld", &x);

    const long long INF = LLONG_MAX / 4;
    vector<long long> cur(3, INF), nxt(3);
    cur[0] = 0;                                   // ยังไม่เว้นติดกันเลย

    for (int i = 0; i < n; i++) {
        fill(nxt.begin(), nxt.end(), INF);
        for (int j = 0; j < 3; j++) {
            if (cur[j] == INF) continue;
            nxt[0] = min(nxt[0], cur[j] + c[i]);           // เลือก ช่วงที่เว้นขาดทันที
            if (j < 2) nxt[j + 1] = min(nxt[j + 1], cur[j]);  // เว้น ช่วงยาวขึ้นหนึ่ง
        }
        cur = nxt;
    }

    printf("%lld\n", *min_element(cur.begin(), cur.end()));
    return 0;
}

สุ่มเทียบกับตัวลองทุกชุด 500 แถว ตรงกันทั้งหมด


ฝึกข้อ 2 ★★☆☆☆ · ทาสีที่ห้ามซ้ำสามช่องติดกัน

โจทย์กำหนด

มีช่องเรียงกัน n ช่อง ทาสีได้ k สี ห้ามให้สามช่องที่ติดกันเป็นสีเดียวกัน นับจำนวนวิธีทาทั้งหมด เอาเศษจากการหารด้วย 10^9 + 7 โดย n ≤ 10^18 และ k ≤ 10^9

EXAMPLE
InputOutput
4 366

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

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

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

ทั้งหมด 81 แบบ ผ่านกติกา 66 แบบ RRRR RRRG RRRB RRGR RRGG RRGB RRBR RRBG RRBB RGRR RGRG RGRB RGGR RGGG RGGB RGBR RGBG RGBB RBRR RBRG RBRB RBGR RBGG RBGB RBBR RBBG RBBB GRRR GRRG GRRB GRGR GRGG GRGB GRBR GRBG GRBB GGRR GGRG GGRB GGGR GGGG GGGB GGBR GGBG GGBB GBRR GBRG GBRB GBGR GBGG GBGB GBBR GBBG GBBB BRRR BRRG BRRB BRGR BRGG BRGB BRBR BRBG BRBB BGRR BGRG BGRB BGGR BGGG BGGB BGBR BGBG BGBB BBRR BBRG BBRB BBGR BBGG BBGB BBBR BBBG BBBB
ทุกแบบที่ทาได้เมื่อมีสี่ช่องสามสี แบบสีเขียวคือแบบที่ถูกกติกา แบบสีจางคือแบบที่มีสามช่องติดกันเป็นสีเดียว ซึ่งถูกตัดทิ้ง

คำใบ้

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

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

  • พอไหม พอ ถ้าสองช่องล่าสุดสีต่างกัน ช่องถัดไปทาสีเดียวกับช่องล่าสุดได้ (1 ทาง) หรือทาสีอื่นก็ได้ (k − 1 ทาง) ถ้าสองช่องล่าสุดสีเหมือนกัน ช่องถัดไปต้องทาสีอื่นเท่านั้น (k − 1 ทาง) ทั้งหมดนี้ไม่ต้องรู้เลยว่าสีนั้นชื่ออะไร
  • ปิดตัวเองไหม ปิด เพราะ "ทาสีเดียวกับช่องล่าสุด" พาไปสถานะเหมือนกันเสมอ และ "ทาสีอื่น" พาไปสถานะต่างกันเสมอ

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

ฝึกข้อ 2 · สถานะคือความสัมพันธ์ ไม่ใช่ค่า
#include <bits/stdc++.h>
using namespace std;
const long long MOD = 1000000007;

int main() {
    long long n, k;
    scanf("%lld %lld", &n, &k);
    long long k1 = (k - 1) % MOD;

    // สถานะไม่ใช่ "ช่องล่าสุดเป็นสีอะไร" ซึ่งมี k แบบ
    // แต่เป็น "สองช่องล่าสุดสีเหมือนกันไหม" ซึ่งมีสองแบบ ไม่ว่า k จะใหญ่แค่ไหน
    long long diff = k % MOD, same = 0;           // ช่องแรกนับรวมไว้ฝั่ง diff
    for (long long i = 2; i <= n; i++) {
        long long nd = (diff + same) % MOD * k1 % MOD;   // ทาสีต่างจากช่องก่อนหน้า
        long long ns = diff;                             // ทาซ้ำได้ ก็ต่อเมื่อคู่ก่อนหน้าไม่ซ้ำ
        diff = nd;
        same = ns;
    }

    printf("%lld\n", (diff + same) % MOD);
    return 0;
}

สุ่มเทียบกับตัวไล่ทุกการทาสี 200 เทส ที่ n ≤ 8 และ k ≤ 4 ตรงกันทั้งหมด รวมกรณี k = 1 ซึ่งตอบ 0 ทันทีที่ n ≥ 3

ระวัง

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


ฝึกข้อ 3 ★★★☆☆ · เมื่อต้องจำค่าจริง ๆ

โจทย์กำหนด

นับสตริงยาว n ที่สร้างจากตัวอักษร a, b, c โดยห้ามมีตัวอักษรเดียวกันสามตัวติดกัน และห้ามมีคำว่า abc โผล่เป็นสามตัวติดกัน เอาเศษจากการหารด้วย 10^9 + 7

EXAMPLE
InputOutput
460

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

อินพุตคือความยาวอย่างเดียว เอาต์พุตคือจำนวนสตริงที่ถูกกติกาทั้งสองข้อพร้อมกัน สตริงยาว 4 สร้างได้ 81 แบบ ถ้าตัดเฉพาะแบบที่มีตัวเดียวกันสามตัวติดกัน จะเหลือ 66 แบบ แล้วในนั้นยังมีอีก 6 แบบที่มีคำว่า abc โผล่อยู่ ต้องตัดทิ้งอีกชั้น เหลือ 60 แบบ

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

รอดกติกาข้อแรก 66 แบบ แต่มี 6 แบบที่มีคำว่า abc aabc abca abcb abcc babc cabc
สตริงที่ถูกตัดเพราะกติกา abc โดยเฉพาะ ทุกตัวในกลุ่มนี้ไม่มีตัวซ้ำสามตัวติดกันเลย จึงรอดกติกาข้อแรก แล้วมาตายที่กติกาข้อสอง ซึ่งเป็นเหตุผลที่ต้องจำตัวอักษรจริง

คำใบ้

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

ท่าของข้อ 2 ตกข้อสอบข้อหนึ่ง ทันที กติกา "ห้ามมี abc" ถามถึงค่าจริง ๆ ของสองตัวล่าสุด ไม่ใช่แค่ความสัมพันธ์ ถ้าเรารู้แค่ว่าสองตัวล่าสุด "ต่างกัน" เราตอบไม่ได้ว่าเติม c ต่อท้ายได้หรือเปล่า เพราะมันขึ้นกับว่าสองตัวนั้นคือ ab หรือ ba หรือ ac

สถานะจึงต้องเป็นคู่ตัวอักษรสองตัวล่าสุดจริง ๆ ซึ่งมี 3 คูณ 3 เท่ากับ 9 แบบ ยังเล็กมาก อัดเป็นเลขตัวเดียวด้วย p × 3 + q โดยให้ q เป็นตัวล่าสุดเสมอ เวลาเติมตัวใหม่ สถานะปลายทางคือ q × 3 + r ซึ่งอ่านออกทันทีว่า "ตัวเก่าหลุดออกไปหนึ่งตัว ตัวใหม่เข้ามาหนึ่งตัว"

ฝึกข้อ 3 · จำคู่ตัวอักษรล่าสุด
#include <bits/stdc++.h>
using namespace std;
const long long MOD = 1000000007;

int main() {
    long long n;
    scanf("%lld", &n);
    if (n == 1) { printf("3\n"); return 0; }      // ยังไม่มีคู่ให้จำ ตอบตรง ๆ

    // สถานะคือคู่ตัวอักษรสองตัวล่าสุด อัดเป็น p*3+q โดย q คือตัวล่าสุด
    vector<long long> cur(9, 0), nxt(9);
    for (int p = 0; p < 3; p++)
        for (int q = 0; q < 3; q++) cur[p * 3 + q] = 1;   // ความยาว 2 ทุกคู่ยังไม่ผิดกติกา

    for (long long i = 3; i <= n; i++) {
        fill(nxt.begin(), nxt.end(), 0);
        for (int p = 0; p < 3; p++)
            for (int q = 0; q < 3; q++) {
                long long v = cur[p * 3 + q];
                if (!v) continue;
                for (int r = 0; r < 3; r++) {
                    if (p == q && q == r) continue;            // สามตัวติดกันเหมือนกัน
                    if (p == 0 && q == 1 && r == 2) continue;  // abc
                    nxt[q * 3 + r] = (nxt[q * 3 + r] + v) % MOD;
                }
            }
        cur = nxt;
    }

    long long s = 0;
    for (int i = 0; i < 9; i++) s = (s + cur[i]) % MOD;
    printf("%lld\n", s);
    return 0;
}

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


ก้าวถัดไปของการบีบสถานะ · เมื่อสิ่งที่ต้องจำไม่ใช่ตัวเลขตัวเดียว

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

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

ท่าที่รับมือคือเก็บ n บิตนั้นเป็นจำนวนเต็มหนึ่งตัว โดยให้บิตที่ i แทนแถวที่ i จำนวนเต็มตัวนั้นเรียกว่าหน้ากากบิต (bitmask) และ DP ที่ใช้มันเป็นสถานะเรียกว่า DP บนหน้ากากบิต รูปที่เดินทีละช่อง ไม่ใช่ทีละคอลัมน์เต็ม เรียกเฉพาะลงไปอีกว่า broken profile

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

ส่วนด่านที่สามของบทนี้ คือสถานะมีได้กี่แบบ กลายเป็นเรื่องคอขวดตรงนี้พอดี หน้ากาก n บิตมีได้ 2ⁿ แบบ ต้นทุนจึงเป็น O(m · n · 2ⁿ) ซึ่งใช้ได้เฉพาะเมื่อ ด้านหนึ่งของตารางแคบ โค้ดข้างล่างจึงสลับให้ n เป็นด้านที่แคบกว่าเสมอก่อนเริ่ม เพราะนั่นคือความต่างระหว่างทำได้กับทำไม่ได้

broken_profile.cpp
// DP บนหน้ากากบิต (broken profile): นับวิธีปูโดมิโน 1x2 ให้เต็มตาราง n แถว m คอลัมน์
// สถานะคือ "หน้ากากของช่องที่ยื่นล้ำมาจากคอลัมน์ก่อนหน้า" ซึ่งเป็นการบีบสถานะแบบเดียวกับบทนี้
// แต่สิ่งที่บีบไม่ใช่ตัวเลขเดียว มันคือแถวหนึ่งแถวที่เก็บเป็นบิต
// อินพุต: n m   เอาต์พุต: จำนวนวิธี หารเอาเศษด้วย 1e9+7
#include <bits/stdc++.h>
using namespace std;
const long long MOD = 1000000007LL;

int main() {
    int n, m;
    if (scanf("%d %d", &n, &m) != 2) return 0;
    if (n > m) swap(n, m);                 // ให้ n เป็นด้านที่แคบกว่า หน้ากากจึงเล็กที่สุด
    int full = 1 << n;
    vector<long long> cur(full, 0), nxt(full, 0);
    cur[0] = 1;
    for (int col = 0; col < m; col++) {
        // เดินทีละคอลัมน์ ภายในคอลัมน์เดินทีละแถว
        for (int row = 0; row < n; row++) {
            fill(nxt.begin(), nxt.end(), 0);
            for (int mask = 0; mask < full; mask++) {
                long long v = cur[mask];
                if (v == 0) continue;
                if (mask >> row & 1) {
                    // ช่องนี้ถูกโดมิโนแนวนอนจากคอลัมน์ก่อนกินไปแล้ว ปิดบิตทิ้ง
                    nxt[mask ^ (1 << row)] = (nxt[mask ^ (1 << row)] + v) % MOD;
                } else {
                    // วางแนวนอน ยื่นไปคอลัมน์ถัดไป
                    nxt[mask | (1 << row)] = (nxt[mask | (1 << row)] + v) % MOD;
                    // วางแนวตั้ง กินช่องนี้กับช่องล่างในคอลัมน์เดียวกัน
                    if (row + 1 < n && !(mask >> (row + 1) & 1))
                        nxt[mask | (1 << (row + 1))] = (nxt[mask | (1 << (row + 1))] + v) % MOD;
                }
            }
            cur.swap(nxt);
        }
    }
    printf("%lld\n", cur[0] % MOD);
    return 0;
}
ตัวตรวจที่ทำให้กล้าเชื่อโค้ดข้างบน

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

brute_domino.cpp
// ตัวตรวจอิสระของ broken profile: ไล่วางโดมิโนทุกวิธีด้วยการเรียกซ้ำบนช่องว่างแรกที่เจอ
// ไม่มีหน้ากากบิต ไม่มีการบีบสถานะ จึงไม่ได้ทดสอบข้ออ้างของท่านั้นเลย
#include <bits/stdc++.h>
using namespace std;
const long long MOD = 1000000007LL;

int n, m;
vector<vector<int>> used;
long long ways = 0;

void rec() {
    int r = -1, c = -1;
    for (int i = 0; i < n && r == -1; i++)
        for (int j = 0; j < m; j++)
            if (!used[i][j]) { r = i; c = j; break; }
    if (r == -1) { ways = (ways + 1) % MOD; return; }
    // วางแนวนอน
    if (c + 1 < m && !used[r][c + 1]) {
        used[r][c] = used[r][c + 1] = 1;
        rec();
        used[r][c] = used[r][c + 1] = 0;
    }
    // วางแนวตั้ง
    if (r + 1 < n && !used[r + 1][c]) {
        used[r][c] = used[r + 1][c] = 1;
        rec();
        used[r][c] = used[r + 1][c] = 0;
    }
}

int main() {
    if (scanf("%d %d", &n, &m) != 2) return 0;
    used.assign(n, vector<int>(m, 0));
    rec();
    printf("%lld\n", ways % MOD);
    return 0;
}

สุ่มตารางเล็ก ๆ ตั้งแต่ 1 คูณ 1 ถึง 5 คูณ 7 แล้วเทียบจำนวนวิธี 400 รอบ ตรงกันทุกรอบ ชุดสุ่มรวมกรณีที่ช่องทั้งหมดเป็นจำนวนคี่ ซึ่งคำตอบต้องเป็นศูนย์ (ปูไม่ได้เลย) และกรณีแถวเดียวที่ปูได้ทางเดียว ที่ตาราง 5 คูณ 1000 โค้ดข้างบนใช้เวลาต่ำกว่า 0.01 วินาที ขณะที่ตัวตรวจแตะขนาดนั้นไม่ได้เลย

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

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

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

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