programming.in.th · ข้อ 2007

กลิฟมายัน: หน้าต่างที่ขยับทีละหนึ่ง ควรเสียค่าตรวจแค่สองครั้ง

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

★★☆☆☆ two pointersstringad hoc อ่าน 8 นาที 7 กันยายน 2026

โจทย์ · คำเดียวกันที่เขียนสลับที่ได้ตามใจ

ภาษามายันเขียนด้วยกลุ่มสัญลักษณ์ที่เรียกว่า กลิฟ (glyph) หนึ่งคำเกิดจากเอากลิฟหลายตัวมาวางเรียงกัน ปัญหาคือคนเขียนสมัยนั้นวางกลิฟตามใจชอบ ไม่มีกฎว่าตัวไหนต้องมาก่อน

แกะคำศัพท์

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

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

พูดอีกแบบ ให้ตัดหน้าต่างยาว g ตัวออกจาก S ทุกตำแหน่งที่ตัดได้ แล้วนับว่ามีกี่หน้าต่างที่มีกลิฟแต่ละชนิดจำนวนเท่ากันเป๊ะกับใน W ตัวพิมพ์เล็กกับตัวพิมพ์ใหญ่ถือเป็นคนละกลิฟ

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

EXAMPLE
InputOutput
4 11
cAda
AbrAcadAbRa
2
ทุกหน้าต่างยาว 4 ตัวในตัวอย่าง
เริ่มที่หน้าต่างเรียงตัวอักษรแล้วผล
1 AbrA AAbr
2 brAc Abcr
3 rAca Aacr
4 Acad Aacd ใช่
5 cadA Aacd ใช่
6 adAb Aabd
7 dAbR ARbd
8 AbRa ARab
คำที่ตามหาเรียงตัวอักษรแล้วได้ Aacd หน้าต่างไหนเรียงแล้วตรงกันก็นับเป็นหนึ่งครั้ง ในตัวอย่างนี้เจอ 2 ครั้ง

ใบ้

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

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

ข้อความ A 0 b 1 r 2 A 3 c 4 a 5 d 6 A 7 b 8 R 9 a 10 0 · AbrA 1 · brAc 2 · rAca 3 · Acad ตรง 4 · cadA ตรง 5 · adAb 6 · dAbR 7 · AbRa
ข้อความ AbrAcadAbRa กับคำที่ต้องหา cAda ซึ่งยาว 4 ตัว หน้าต่างกว้าง 4 ตัวเลื่อนไปได้ 8 ตำแหน่ง แถบแต่ละแถบข้างล่างคือหนึ่งตำแหน่ง แถบเขียวคือตำแหน่งที่ตัวอักษรในหน้าต่างสลับที่แล้วตรงกับ cAda พอดี มีอยู่ 2 ตำแหน่ง สังเกตว่าสองตำแหน่งที่ติดกันต่างกันแค่ตัวอักษรหัวกับท้าย จึงไม่ต้องนับใหม่ทั้งบาน (วาดประกอบโดยผู้เขียน)

ลองเอง · ลากหน้าต่างหาคำที่สลับที่แล้วตรงกัน

อุ่นเครื่องด้วยแผ่นจารึกเดียวกับที่โจทย์ยกมา

คำที่ตามหา cAda ยาว 4 กลิฟ
กลิฟ
ต้องการ
ในหน้าต่าง

เลื่อนหน้าต่างไปเรื่อย ๆ เจอหน้าต่างที่ใช้ได้เมื่อไรก็กดเก็บ

เก็บได้ 0 จาก 2 หน้าต่างที่ใช้ได้

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

เฉลย · เก็บจำนวนชนิดที่พอดีแล้วไว้ตัวเดียว

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

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

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

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

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

หน้าต่างหนึ่งอันใช้ได้เมื่อกลิฟทุกชนิดมีจำนวนเท่ากับที่ต้องการเป๊ะ เก็บตารางนับสองใบคือ need ที่นับจาก W ครั้งเดียวตอนต้น กับ have ที่นับจากหน้าต่างปัจจุบัน แล้วเลื่อนหน้าต่างทีละหนึ่ง ปรับ have แค่สองช่อง

ปัญหาที่เหลือคือคำถามว่าเท่ากันทุกช่องหรือยัง ถ้าไล่เทียบทั้ง 52 ช่องทุกก้าว ก็ยังเป็น 156 ล้าน ครั้ง ซึ่งพอไหวแต่เปลืองโดยไม่จำเป็น ทางที่คมกว่าคือเก็บตัวนับเพิ่มอีกตัวเดียวชื่อ matched แปลว่าตอนนี้มีกลิฟกี่ชนิดที่จำนวนพอดีแล้ว

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

หัวใจของทั้งข้อ
int c = id(S[r]);
have[c]++;
if (have[c] == need[c]) matched++;              // ชนิดนี้เพิ่งพอดี
else if (have[c] == need[c] + 1) matched--;     // เพิ่งเกิน เลยหลุดจากคำว่าพอดี

if (r >= g) {                                    // ตัวที่หลุดออกทางซ้าย
    int d = id(S[r - g]);
    if (have[d] == need[d]) matched--;
    else if (have[d] == need[d] + 1) matched++;
    have[d]--;
}
if (r >= g - 1 && matched == distinct) ans++;    // ทุกชนิดพอดีพร้อมกัน

หน้าต่างใช้ได้เมื่อ matched เท่ากับจำนวนชนิดที่ปรากฏใน W ไม่ใช่ 52 เพราะชนิดที่ W ไม่ได้ใช้เลยมี need เป็นศูนย์ และมันก็ถูกนับว่าพอดีตั้งแต่ยังไม่เจอตัวไหนเลย

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

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

คำที่ตามหาคือ cAda ซึ่งใช้กลิฟ 4 ชนิด ดังนั้นหน้าต่างจะใช้ได้เมื่อ matched ขึ้นไปแตะ 4 พอดี

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

เดินตัวนับตลอดแผ่นจารึก
ก้าวที่เข้าออก matchedหน้าต่างผล
1 A ยังไม่มี 1 ยังไม่เต็ม
2 b ยังไม่มี 0 ยังไม่เต็ม
3 r ยังไม่มี -1 ยังไม่เต็ม
4 A ยังไม่มี -2 AbrA
5 c A 0 brAc
6 a b 2 rAca
7 d r 4 Acad นับ
8 A A 4 cadA นับ
9 b c 2 adAb
10 R a 0 dAbR
11 a d 0 AbRa

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

สามแนวคิด ราคาต่างกันคนละโลก

อินพุตใหญ่สุด · นับเป็นจำนวนครั้งที่แตะข้อมูลหนึ่งชิ้น
แนวคิดต้นทุนต่อหนึ่งก้าวรวมทั้งอินพุต
นับใหม่ทุกหน้าต่างg ครั้ง9,000,000,000
เลื่อนหน้าต่าง แล้วเทียบตารางนับทั้งใบ2 + 52 ครั้ง162,000,000
เลื่อนหน้าต่าง แล้วดูแลตัวนับชนิดที่พอดี2 ครั้ง6,000,000
สามแถวนี้นับหน่วยเดียวกัน แนวคิดที่สองผ่านได้อยู่แล้วในลิมิตสามวินาที แนวคิดที่สามไม่ได้เปลี่ยนคลาสความซับซ้อน แต่มันตัดค่าคงที่ทิ้งไป 26 เท่า และเป็นท่าที่ยกไปใช้ต่อได้กับโจทย์หน้าต่างเลื่อนข้ออื่น

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

ถ้าอยากเห็นท่าหน้าต่างเลื่อนแบบเต็ม ๆ ตั้งแต่ต้น รวมถึงเวอร์ชันที่ความยาวหน้าต่างไม่คงที่ ไปอ่านบทปูพื้นฐาน สองตัวชี้กับหน้าต่างเลื่อน ก่อนได้

โค้ด C++

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

ดูโค้ดเต็ม
writing.cpp
#include <bits/stdc++.h>
using namespace std;
static inline int idx(char c){ return c>='a'&&c<='z' ? c-'a' : c-'A'+26; }
int main(){
    int g; long long n;
    if(scanf("%d %lld",&g,&n)!=2) return 0;
    static char W[3005]; scanf("%s",W);
    string S; { S.resize(n+5); scanf("%s",&S[0]); S.resize(strlen(S.c_str())); }
    int need[52]={0}, have[52]={0};
    for(int i=0;i<g;i++) need[idx(W[i])]++;
    int distinct=0; for(int i=0;i<52;i++) if(need[i]) distinct++;
    int matched=0; long long ans=0; int L=(int)S.size();
    for(int r=0;r<L;r++){
        int c=idx(S[r]);
        have[c]++; if(have[c]==need[c]) matched++; else if(have[c]==need[c]+1) matched--;
        if(r>=g){ int d=idx(S[r-g]); if(have[d]==need[d]) matched--; else if(have[d]==need[d]+1) matched++; have[d]--; }
        if(r>=g-1 && matched==distinct) ans++;
    }
    printf("%lld\n",ans);
}
ของแถม: ตัวตรวจสอบที่ผมใช้ก่อนส่ง

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

brute.cpp
// ตัวตรวจสอบแบบซื่อ ๆ นับใหม่ทุกหน้าต่าง ใช้ได้แค่อินพุตเล็ก ๆ ตอนสุ่มเทียบก่อนส่ง
#include <bits/stdc++.h>
using namespace std;
static int id(char c){ return c>='a'&&c<='z' ? c-'a' : c-'A'+26; }
int main(){
    int g; long long n; scanf("%d %lld",&g,&n);
    static char W[3005], S[3000006];
    scanf("%s %s",W,S);
    int need[52]={0};
    for(int i=0;i<g;i++) need[id(W[i])]++;
    int L=(int)strlen(S), ans=0;
    for(int i=0;i+g<=L;i++){
        int c[52]={0};
        for(int j=0;j<g;j++) c[id(S[i+j])]++;
        bool ok=true;
        for(int t=0;t<52;t++) if(c[t]!=need[t]){ ok=false; break; }
        if(ok) ans++;
    }
    printf("%d\n",ans);
}

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

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

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

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

แหล่งที่มา

  1. โจทย์ Writing บน programming.in.th ข้อ 2007 programming.in.th/tasks/2007 (สืบค้น 6 กันยายน 2026)
  2. ต้นทางของโจทย์คือ International Olympiad in Informatics 2006 ที่ประเทศเม็กซิโก