programming.in.th · ข้อ 2011

สวนหลวงสมดุล: เงื่อนไขที่พูดถึงทุกช่วง ยุบเหลือรางกว้างสองหน่วย

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

★★★☆☆ dpcountingstate compression อ่าน 11 นาที 7 กันยายน 2026

โจทย์ · สวนที่ต้องสมดุลทุกช่วง

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

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

เอาสวนสมดุลทุกแบบของความยาวหนึ่ง ๆ มาเรียงตามพจนานุกรม (L มาก่อน P) แล้วให้เลข 1, 2, 3 ไปเรื่อย ๆ งานของเราคือรับสวนสมดุลมาหนึ่งแบบ แล้วตอบว่ามันเป็นแบบที่เท่าไร โดยตอบเป็นเศษเหลือจากการหารด้วย M

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

EXAMPLE
InputOutput
5
7
PLPPL
5
12
10000
LPLLPLPPLPLL
39

ความยาว 5 มีสวนสมดุลทั้งหมด 14 แบบ เรียงตามพจนานุกรมได้ LLPLP, LLPPL, LPLLP, LPLPL, LPLPP, LPPLL, LPPLP, PLLPL, PLLPP, PLPLL, PLPLP, PLPPL, PPLLP, PPLPL ตัว PLPPL อยู่อันดับที่ 12 และ 12 หารเอาเศษด้วย 7 ได้ 5

ใบ้

"ทุกช่วงติดกัน" ฟังดูเหมือนต้องตรวจ N² ช่วง แต่ลองแทนบัวด้วย +1 กกด้วย −1 แล้วเขียนผลรวมสะสมออกมาดู จำนวนบัวลบจำนวนกกของช่วง (i, j] ก็คือผลต่างของผลรวมสะสมสองจุด

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

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

ลองเอง · ปลูกสวนให้ได้อันดับที่ขอ

สวนสั้น ๆ ห้าต้น ลองไล่ให้ชินกับรางก่อน

ปลูกสวนยาว 5 ต้น ให้เป็นสวนสมดุลอันดับที่ 6 จากทั้งหมด 14 แบบ โดยเรียงตามพจนานุกรม บัว (L) มาก่อน กก (P)

กดปลูกทีละต้น เส้นจะขยับขึ้นเมื่อปลูกบัว และลงเมื่อปลูกกก

สวนตอนนี้ ยังว่าง · ความกว้างของราง 0

รางกว้างเกินสองเมื่อไร สวนนั้นไม่สมดุลทันที และไม่ว่าจะปลูกต่อยังไงก็แก้ไม่ได้แล้ว

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

เฉลย · เงื่อนไขทั้งข้อยุบเหลือรางกว้างสองหน่วย

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

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

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

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

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

ให้ S[k] คือผลรวมสะสมของ k ต้นแรก จำนวนบัวลบจำนวนกกในช่วง (i, j] ก็คือ S[j] − S[i] ตรง ๆ เงื่อนไขว่าทุกช่วงต้องต่างกันไม่เกินสอง จึงแปลว่า |S[j] − S[i]| ≤ 2 สำหรับทุกคู่ ซึ่งพูดสั้น ๆ ได้ว่า

ค่ามากที่สุดของผลรวมสะสม ลบค่าน้อยที่สุด ต้องไม่เกินสอง

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

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

ตารางสถานะทั้ง 6 แบบ
สถานะ (a, b)ความหมาย ปลูกบัว ไปไหนปลูกกก ไปไหน
(0, 0) สูงจากพื้นราง 0 รางกว้าง 0 (1, 1) (0, 1)
(0, 1) สูงจากพื้นราง 0 รางกว้าง 1 (1, 1) (0, 2)
(1, 1) สูงจากพื้นราง 1 รางกว้าง 1 (2, 2) (0, 1)
(0, 2) สูงจากพื้นราง 0 รางกว้าง 2 (1, 2) ตัน
(1, 2) สูงจากพื้นราง 1 รางกว้าง 2 (2, 2) (0, 2)
(2, 2) สูงจากพื้นราง 2 รางกว้าง 2 ตัน (1, 2)
คำว่าตันแปลว่าเดินแล้วรางกว้างเกินสอง สวนแบบนั้นไม่สมดุลจึงไม่นับ ตารางนี้สร้างจากฟังก์ชันเดินสถานะจริง ไม่ได้กรอกมือ
หัวใจของทั้งข้อ
// สถานะคือ (a, b) โดย a = ผลรวมสะสม ลบ ค่าน้อยสุดที่เคยเจอ
//                    b = ค่ามากสุดที่เคยเจอ ลบ ค่าน้อยสุดที่เคยเจอ
// เงื่อนไขสมดุลคือ b <= 2 เสมอ จึงมีสถานะที่ใช้ได้แค่หกแบบ
static inline int stepState(int s, int c) {
    int a = SA[s], b = SB[s];
    if (c == 0) {                        // ปลูกต้นบัว ผลรวมสะสมบวกหนึ่ง
        int na = a + 1, nb = b;
        if (na > nb) nb = na;            // ดันเพดานขึ้น
        if (nb > 2) return -1;           // ช่วงกว้างเกินสอง สวนนี้ไม่สมดุลแล้ว
        return SID[na][nb];
    }
    int na = a - 1, nb = b;              // ปลูกต้นกก ผลรวมสะสมลบหนึ่ง
    if (na < 0) { na = 0; nb = b + 1; }  // พื้นต่ำลง ระยะห่างจากพื้นถึงเพดานจึงกว้างขึ้น
    if (nb > 2) return -1;
    return SID[na][nb];
}

จากการนับไปสู่การหาอันดับ

พอนับได้แล้ว การหาอันดับก็เป็นท่ามาตรฐาน คำนวณ g[i][t] ไว้ก่อนว่า ถ้ายืนที่ตำแหน่ง i ด้วยสถานะ t จะเติมส่วนที่เหลือได้กี่แบบ คิดถอยหลังจากท้ายมาหน้าในเวลาเชิงเส้น

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

ไล่หาอันดับของตัวอย่างทีละต้น

ความยาว 5 มีสวนสมดุล 14 แบบ ตรงกับที่ไล่นับตรง ๆ ได้ 14 แบบพอดี

หาอันดับของ PLPPL
ต้นที่โจทย์ปลูกสถานะก่อนปลูก ถ้าปลูกบัวแทน จะมีกี่แบบอันดับสะสม
1 P (0, 0) 7 8
2 L (0, 1) 8
3 P (1, 1) 2 10
4 P (0, 1) 2 12
5 L (0, 2) 12
แถวที่โจทย์ปลูกบัวไม่บวกอะไรเลย เพราะบัวเป็นตัวเล็กสุดอยู่แล้ว ไม่มีสวนไหนมาก่อนได้ อันดับสุดท้ายคือ 12 ตรงกับที่ไล่เรียงตรง ๆ
จำนวนสวนสมดุลของแต่ละความยาว
123456789101112
24610142230466294126190
แถวบนคือความยาว แถวล่างคือจำนวนสวนสมดุล มันโตแบบเลขชี้กำลัง ตรงนี้คือเหตุผลที่ข้อนี้ห้ามไล่นับ และเป็นเหตุผลที่โจทย์ต้องให้หารเอาเศษ ไม่งั้นคำตอบเก็บในตัวแปรไหนก็ไม่พอ

อีกมุมหนึ่ง: ไม่ต้องบีบสถานะก็ได้ 40 คะแนน

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

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

ดูโค้ดของชุดทดสอบย่อย
garden_sub.cpp
// เฉลยชุดทดสอบย่อย N ไม่เกิน 40 (40 คะแนน)
// ยังไม่ต้องมองออกว่าสถานะบีบเหลือหกแบบ แค่จำสถานะดิบ ๆ ไว้ใน map ก็พอ
// เพราะเงื่อนไขสมดุลบังคับให้ค่ามากสุดลบค่าน้อยสุดไม่เกินสอง สถานะที่ไปถึงได้จึงมีไม่กี่อัน
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
int N; ll MOD;
map<array<int,4>, ll> memo;                         // (ตำแหน่ง, ผลรวมสะสม, ค่าน้อยสุด, ค่ามากสุด)

ll ways(int i, int s, int mn, int mx) {
    if (mx - mn > 2) return 0;
    if (i == N) return 1;
    array<int,4> key{i, s, mn, mx};
    auto it = memo.find(key);
    if (it != memo.end()) return it->second;
    ll v = 0;
    for (int d = 1; d >= -1; d -= 2) {               // +1 คือปลูกบัว, -1 คือปลูกกก
        int ns = s + d;
        v += ways(i + 1, ns, min(mn, ns), max(mx, ns));
    }
    return memo[key] = v;
}

int main() {
    scanf("%d %lld", &N, &MOD);
    char buf[64]; scanf("%s", buf); string g = buf;
    ll rank_ = 1;
    int s = 0, mn = 0, mx = 0;
    for (int i = 0; i < N; i++) {
        if (g[i] == 'P') {                           // ต้นบัวมาก่อนต้นกกตามพจนานุกรม
            int ns = s + 1;
            rank_ += ways(i + 1, ns, min(mn, ns), max(mx, ns));
        }
        s += (g[i] == 'L' ? 1 : -1);
        mn = min(mn, s); mx = max(mx, s);
    }
    printf("%lld\n", rank_ % MOD);
}

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

บิลค่าใช้จ่าย · เทียบสองทางด้วยหน่วยเดียวกัน
ทางขอบเขตที่มันไหวงานคร่าว ๆ
จำสถานะดิบไว้ใน map N ไม่เกิน 40 สถานะที่ไปถึงได้ นับด้วยการรันจริง
บีบสถานะเหลือ 5 แบบ N ถึง 1,000,000 5,000,000 ครั้ง
แถวบนไม่มีเลขตายตัว เพราะจำนวนสถานะที่ไปถึงได้ขึ้นกับอินพุต ต้องรันจึงจะรู้ ซึ่งเป็นลักษณะของทางที่จำสถานะดิบทุกครั้ง แถวล่างรู้ล่วงหน้าเป๊ะ ๆ ว่าแต่ละตำแหน่งมีสถานะได้เท่าไร การรู้จำนวนสถานะล่วงหน้านี่เองที่ทำให้กล้ายก N ขึ้นเป็นล้าน

โค้ด C++

ตาราง g มีขนาด (N + 1) × 6 ซึ่งที่ N เต็มขอบเขตคือ 6,000,006 ช่อง เก็บเป็น long long ก็ราว 46 เมกะไบต์ ยังอยู่ในลิมิต 64 เมกะไบต์ ถ้าอยากประหยัดกว่านี้เก็บเป็น int ได้ เพราะทุกค่าถูกหารเอาเศษด้วย M ที่ไม่เกินสิบล้านอยู่แล้ว

ดูโค้ดเต็ม
garden.cpp
#include <bits/stdc++.h>
using namespace std;
// state = (a,b): a = S - minS, b = maxS - minS, 0<=a<=b<=2  -> 6 states
static int SID[3][3]; static int SA[6],SB[6],NS;
static inline int stepState(int s,int c){ // c=0 -> 'L'(+1), c=1 -> 'P'(-1); -1 = invalid
    int a=SA[s],b=SB[s];
    if(c==0){ int na=a+1,nb=b; if(na>nb) nb=na; if(nb>2) return -1; return SID[na][nb]; }
    int na=a-1,nb=b; if(na<0){ na=0; nb=b+1; } if(nb>2) return -1; return SID[na][nb];
}
int main(){
    long long N,M; if(scanf("%lld %lld",&N,&M)!=2) return 0;
    static char buf[1000006]; scanf("%s",buf); string s=buf;
    NS=0; for(int b=0;b<=2;b++) for(int a=0;a<=b;a++){ SID[a][b]=NS; SA[NS]=a; SB[NS]=b; NS++; }
    int n=(int)N;
    vector<array<long long,6>> g(n+1);
    for(int t=0;t<6;t++) g[n][t]=1%M;
    for(int i=n-1;i>=0;i--) for(int t=0;t<6;t++){
        long long v=0;
        for(int c=0;c<2;c++){ int nx=stepState(t,c); if(nx>=0) v+=g[i+1][nx]; }
        g[i][t]=v%M;
    }
    long long rank_=1%M; int cur=SID[0][0];
    for(int i=0;i<n;i++){
        if(s[i]=='P'){ int nx=stepState(cur,0); if(nx>=0) rank_=(rank_+g[i+1][nx])%M; }
        cur=stepState(cur,s[i]=='L'?0:1);
        if(cur<0){ printf("-1\n"); return 0; }
    }
    printf("%lld\n",rank_%M);
}

เวลาที่วัดได้บนเครื่องผม สวนยาว 1,000,000 ต้นที่สุ่มให้สมดุล ใช้เวลา 88 มิลลิวินาที จากลิมิต 1.5 วินาที

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

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

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

แหล่งที่มา

  1. โจทย์ Linear Garden บน programming.in.th ข้อ 2011 programming.in.th/tasks/2011 (สืบค้น 6 กันยายน 2026)
  2. ต้นทางของโจทย์คือ 20th International Olympiad in Informatics ที่กรุงไคโร ประเทศอียิปต์ วันแข่งที่ 2