programming.in.th · ข้อ 2007
ข้ออุ่นเครื่องที่สอนนิสัยซึ่งใช้ได้ตลอดชีวิต คือถามว่าอะไรเปลี่ยนไปบ้างระหว่างสองสถานะที่ติดกัน แล้วจ่ายแค่ส่วนที่เปลี่ยน แถมด้วยกับดักหนึ่งบรรทัดที่ทำให้โปรแกรมไม่เจอคำตอบเลยสักครั้ง
ภาษามายันเขียนด้วยกลุ่มสัญลักษณ์ที่เรียกว่า กลิฟ (glyph) หนึ่งคำเกิดจากเอากลิฟหลายตัวมาวางเรียงกัน ปัญหาคือคนเขียนสมัยนั้นวางกลิฟตามใจชอบ ไม่มีกฎว่าตัวไหนต้องมาก่อน
แกะคำศัพท์
glyph อ่านว่า "กลิฟ" แปลว่า เครื่องหมายที่สลักไว้ มาจากคำกรีก glyphē ที่แปลว่ารอยแกะสลัก ส่วน permutation อ่านว่า "เพอร์มิวเทชัน" แปลว่า การเรียงสับเปลี่ยน คือเอาของชุดเดิมมาสลับที่กันใหม่โดยไม่เพิ่มไม่ลดสักชิ้น
นักโบราณคดีจึงรู้แค่ว่าคำที่ตามหา W ประกอบด้วยกลิฟชุดไหนบ้าง แต่ไม่รู้ว่าบนแผ่นจารึกมันถูกเรียงยังไง
งานของเราคืออ่านแผ่นจารึก S แล้วนับว่ามีกี่ตำแหน่งที่กลิฟ g ตัวติดกันเป็นการเรียงสับเปลี่ยนของ
W
พูดอีกแบบ ให้ตัดหน้าต่างยาว g ตัวออกจาก S ทุกตำแหน่งที่ตัดได้
แล้วนับว่ามีกี่หน้าต่างที่มีกลิฟแต่ละชนิดจำนวนเท่ากันเป๊ะกับใน W
ตัวพิมพ์เล็กกับตัวพิมพ์ใหญ่ถือเป็นคนละกลิฟ
อินพุต / ขอบเขต / เอาต์พุต
g และ |S| บรรทัดที่สองคือคำ W
ยาว g ตัว บรรทัดที่สามคือแผ่นจารึก S g ≤ 3,000, |S| ≤ 3,000,000 อักขระเป็น
a-z และ A-Z เท่านั้น เวลา 3 วินาที หน่วยความจำ 32 เมกะไบต์
W ปรากฏได้| Input | Output |
|---|---|
| 4 11 cAda AbrAcadAbRa | 2 |
| เริ่มที่ | หน้าต่าง | เรียงตัวอักษรแล้ว | ผล |
|---|---|---|---|
| 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 พันล้าน ครั้ง ในเวลาสามวินาที
แต่หน้าต่างสองอันที่อยู่ติดกันต่างกันแค่สองตัว คือตัวที่โผล่เข้าทางขวา กับตัวที่หลุดออกทางซ้าย ที่เหลือเหมือนกันหมด แล้วทำไมเรายังต้องนับใหม่ทั้งหน้าต่างทุกครั้งด้วย
อุ่นเครื่องด้วยแผ่นจารึกเดียวกับที่โจทย์ยกมา
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 |
สังเกตว่าแนวคิดแรกไม่ได้แพงเพราะ |S| ใหญ่ แต่แพงเพราะมันเอา g
มาคูณกับความยาวของแผ่นจารึก ทั้งที่ g ตัวนั้นเป็นตัวเดิมที่เพิ่งนับไปเมื่อก้าวที่แล้ว
ถ้าอยากเห็นท่าหน้าต่างเลื่อนแบบเต็ม ๆ ตั้งแต่ต้น รวมถึงเวอร์ชันที่ความยาวหน้าต่างไม่คงที่ ไปอ่านบทปูพื้นฐาน สองตัวชี้กับหน้าต่างเลื่อน ก่อนได้
แผ่นจารึกยาวสามล้านตัว จึงอ่านเข้ามาเป็นสตริงเดียวทีเดียว และเดินผ่านมันรอบเดียวจบ
ตารางนับใช้ int ขนาด 52 ช่อง สองใบ กินหน่วยความจำไม่ถึงครึ่งกิโลไบต์
เวลาที่วัดได้บนเครื่องผม อินพุตเต็มขอบเขตคือแผ่นจารึกสามล้านตัวกับคำยาวสามพันตัว ใช้เวลา 96 มิลลิวินาที จากลิมิตสามวินาที
เวลาจะยกท่านี้ไปข้ออื่น มีคำถามเดียวที่ต้องตอบก่อน คือของที่เราเก็บไว้นั้น ซ่อมจากส่วนต่างได้ไหม เมื่อรู้แค่ว่าใครเข้าและใครออก จำนวนนับกับผลรวมซ่อมได้ เพราะตัวที่ออกไปหักออกตรง ๆ ได้ ส่วนค่ามากสุดของหน้าต่างซ่อมไม่ได้ เพราะวันที่ตัวออกคือตัวที่มากที่สุดพอดี ไม่มีใครรู้ว่าตัวรองคือตัวไหนโดยไม่กวาดหน้าต่างใหม่ ถ้าเจอกรณีหลัง ให้เลิกดัดวิธีนับแล้วไปเปลี่ยนเครื่องมือเลย ท่าที่ใช้แทนอยู่ในหัวข้อสุดท้ายของบทปูพื้นฐาน สองตัวชี้กับหน้าต่างเลื่อน
หน้าต่างที่ขยับทีละหนึ่งเปลี่ยนข้อมูลแค่สองช่อง ถ้าคำตอบของหน้าต่างสรุปได้ด้วยตัวเลขที่อัปเดตจากสองช่องนั้นได้ ก็ไม่มีเหตุผลให้กวาดใหม่ทั้งหน้าต่าง และที่นี่ตัวเลขนั้นคือจำนวนชนิดที่ตอนนี้มีครบพอดีแล้ว
ในหน้านี้