programming.in.th · ข้อ 2006
สองท่าที่หยิบไปใช้ต่อได้ตลอด คือการมองหาว่าเงื่อนไขก้อนใหญ่มีตัวมันเองซ่อนอยู่ข้างในไหม และการเอาแฮชสตริงมาทำให้การตรวจหนึ่งชั้นเหลือเวลาคงที่ ผลคือ 82,358,940,040 ครั้งยุบเหลือ 35,820,200 ครั้ง
มินตรานั่งไล่บักโปรแกรมอยู่ แล้วเริ่มสงสัยว่าที่มันพังอยู่ทุกวันนี้อาจไม่เกี่ยวกับโค้ดเลย แต่เกี่ยวกับรูปร่างของข้อมูลในหน่วยความจำ
หน่วยความจำที่โปรแกรมใช้อยู่เป็นตารางขนาด R แถว C คอลัมน์ แต่ละช่องเป็น 0 หรือ 1 เท่านั้น
สิ่งที่มินตราตามล่าอยู่เขาเรียกมันว่า สี่เหลี่ยมพิฆาต (killer square) นั่นคือตารางย่อยรูปจัตุรัส ที่มีช่องมากกว่าหนึ่งช่อง และเมื่อหมุนมันไป 180 องศาแล้วยังได้ภาพเดิมเป๊ะทุกช่อง งานของเราคือหาว่าจัตุรัสแบบนี้ ที่ใหญ่ที่สุดในตารางมีด้านยาวเท่าไร
หมุน 180 องศาคือหมุนกลับหัวกลับหางในทีเดียว ช่องมุมบนซ้ายจะไปแทนที่มุมล่างขวา ช่องบนขวาไปแทนล่างซ้าย พูดอีกแบบคือทุกช่องต้องจับคู่กับช่องที่อยู่ตรงข้ามมันผ่านจุดกึ่งกลางของจัตุรัส แล้วสองช่องนั้นต้องมีค่าเท่ากัน
อินพุต / ขอบเขต / เอาต์พุต
R และ C จากนั้นอีก R บรรทัด แต่ละบรรทัดมี
C ตัวอักษรที่เป็น 0 หรือ 1 ติดกันโดยไม่มีช่องว่างคั่น
R, C ≤ 300 เวลา 1 วินาที หน่วยความจำ 32 เมกะไบต์-1 | Input | Output |
|---|---|
| 3 6 101010 111001 101001 | 3 |
| 4 5 10010 01010 10101 01001 | 3 |
| 3 3 101 111 100 | -1 |
ตารางตัวอย่างแรกมีสี่เหลี่ยมพิฆาตอยู่ 3 อัน ด้านยาว 3, 2, 2 คำตอบจึงเป็น 3
| ด้านยาว | มุมบนซ้าย | ตำแหน่งในตาราง |
|---|---|---|
| 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 พันล้าน ครั้ง ในเวลาหนึ่งวินาที
| วิธี | ต้นทุนต่อหนึ่งจัตุรัส | รวมทั้งตาราง |
|---|---|---|
| ตรวจทุกจัตุรัสแบบซื่อ ๆ | k × k ครั้งต่อหนึ่งจัตุรัส | 82,358,940,040 |
| ปอกทีละวง เทียบขอบด้วยแฮช | 4 ครั้งต่อหนึ่งจัตุรัส | 35,820,200 |
ใบ้
ลองวาดจัตุรัสด้าน 5 แล้วระบายวงนอกสุดของมันให้เป็นสีหนึ่ง วงถัดเข้ามาเป็นอีกสีหนึ่ง ไล่เข้าไปจนถึงช่องกลาง ทีนี้ลองหมุนกระดาษ 180 องศา แล้วถามว่าวงนอกสุดหมุนไปทับวงไหน และวงที่สองหมุนไปทับวงไหน ถ้าเรารู้อยู่แล้วว่าจัตุรัสด้าน 3 ที่อยู่ตรงกลางผ่านแล้ว เหลืออะไรอีกที่ต้องตรวจ?
ตารางเดียวกับตัวอย่างแรกของโจทย์
คลิกช่องเพื่อวางมุมบนซ้าย แล้วปรับด้านยาวด้วยปุ่มบวกลบ จากนั้นกดตรวจ
ใหญ่ที่สุดที่หาเจอแล้ว 0
จัตุรัสพิฆาตคือจัตุรัสที่หมุน 180 องศาแล้วได้ภาพเดิมทุกช่อง
ลองสังเกตดูว่าตอนกรอบเป็นสีเขียว มุมทั้งสี่มีความสัมพันธ์อะไรกัน แล้วขอบทั้งสี่ด้านล่ะ
ที่มาของแนวคิดนี้
ท่าไล่ให้ครบตายที่ต้นทุนของการตรวจหนึ่งอัน ไม่ใช่ที่จำนวนอันที่ต้องตรวจ ตรงนี้สำคัญ เพราะมันบอกว่าสิ่งที่ต้องทำให้ถูกลงคือการตรวจหนึ่งครั้ง ไม่ใช่การลดจำนวนผู้เข้าแข่งขัน และวิธีที่ผมใช้ตอนไม่รู้จะเริ่มตรงไหนคือ เขียนเงื่อนไขออกมาเป็นสมการ แล้วนั่งดูว่าดัชนีมันเดินยังไง
สิ่งที่โผล่ออกมาคือดัชนีสองข้างของเครื่องหมายเท่ากับบวกกันได้ค่าคงที่เสมอ ค่านั้นไม่ขึ้นกับช่องที่กำลังดูอยู่เลย
แปลว่าการหมุนไม่เคยพาช่องข้ามวง ช่องที่ห่างขอบหนึ่งช่องหมุนไปแล้วก็ยังห่างขอบหนึ่งช่อง
พอรู้ข้อนี้ เงื่อนไขทั้งก้อนก็แตกออกเป็นวง ๆ ที่ไม่ยุ่งกันเอง และคำถามของจัตุรัสด้าน 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 มีช่องเดียวที่จับคู่กับตัวเอง)
การปอกวงช่วยให้ไม่ต้องตรวจไส้ในซ้ำ แต่ตัววงนอกเองยังมีช่องอยู่ราว 4k ช่อง ถ้ายังเดินทีละช่องก็ยังช้าอยู่ดี
ตรงนี้แหละที่ต้องมองว่าวงนอกจริง ๆ แล้วประกอบด้วยอะไร
หมุน 180 องศาแล้ว ขอบบนไปทับขอบล่างในลำดับกลับด้าน และ ขอบซ้ายไปทับขอบขวาในลำดับกลับด้าน เท่านั้นเอง เอาจัตุรัสด้าน 3 จากตัวอย่างแรกมาแกะให้เห็นชัด ๆ
| สิ่งที่เทียบ | ฝั่งหนึ่ง | อีกฝั่ง อ่านกลับด้าน | ผล |
|---|---|---|---|
| ขอบบน กับ ขอบล่าง | 101 | 101 | ตรงกัน |
| ขอบซ้าย กับ ขอบขวา | 111 | 111 | ตรงกัน |
| ไส้ในด้าน 1 | 1 | 1 | ผ่านฟรี |
ทีนี้เหลือคำถามเดียว จะเทียบ "สตริงช่วงหนึ่ง" กับ "สตริงอีกช่วงหนึ่งที่กลับด้านแล้ว" ให้จบในเวลาคงที่ได้ยังไง
คำตอบคือแฮชนำหน้า (prefix hash) มองแต่ละแถวเป็นตัวเลขฐาน B แล้วเก็บค่าสะสมไว้ล่วงหน้า
ค่าของช่วงใด ๆ ก็หักลบกันออกมาได้ในครั้งเดียว ส่วน "อ่านกลับด้าน" ก็แค่สร้างชุดที่สองจากแถวที่กลับด้านไว้ตั้งแต่แรก
แล้วเทียบกับช่วงที่สะท้อนตำแหน่งกันไว้
สรุปคือหนึ่งจัตุรัสเสียค่าตรวจแค่สี่ครั้ง คือดูไส้ใน หนึ่งครั้ง เทียบแถว หนึ่งครั้ง เทียบคอลัมน์ หนึ่งครั้ง และเขียนผลลงตาราง หนึ่งครั้ง ต้นทุนทั้งตารางจึงยุบจาก 82.4 พันล้าน ครั้งเหลือ 35.82 ล้าน ครั้ง
ตารางคำตอบไล่จากด้านสั้นไปด้านยาว โดยเก็บไว้แค่สองชั้นล่าสุด เพราะสูตรอ้างถึงชั้น 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 ซึ่งคูณกันแล้วพับด้วยการเลื่อนบิตได้เลย
ไม่ต้องหารจริง
เวลาที่วัดได้บนเครื่องผม ตารางเต็มขอบเขต 300 คูณ 300 ที่เป็นเลข 0 ล้วน (กรณีหนักที่สุด
เพราะไม่มีจัตุรัสไหนถูกตัดทิ้งกลางทางเลย คำตอบคือ 300 เต็ม) ใช้เวลา 85 มิลลิวินาที
ส่วนตารางสุ่ม 0 กับ 1 ครึ่งต่อครึ่งจบใน 37 มิลลิวินาที จากลิมิตหนึ่งวินาที
ตัวอย่างที่สามคือตาราง 101
111
100 ซึ่งตอบ -1 หมายความว่าไม่มีแม้แต่จัตุรัสด้าน 2 สักอันเดียว
ซึ่งฟังดูแปลก เพราะจัตุรัสด้าน 2 มีเงื่อนไขเบามาก คือแค่คู่ทแยงมุมทั้งสองคู่ต้องมีค่าเท่ากันเท่านั้น
(หมุน 180 องศาในจัตุรัสด้าน 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: หาความยาวพาลินโดรมที่ยาวที่สุด และรัศมีของทุกจุดศูนย์กลาง
// ไม่ใช้แฮชเลย จึงไม่มีโอกาสชนกัน
// อินพุต: สตริงหนึ่งบรรทัด เอาต์พุต: ความยาวพาลินโดรมที่ยาวที่สุด
#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;
} เกณฑ์เลือกระหว่างสองทางในข้อนี้ ถ้าคำถามเป็นเรื่องพาลินโดรมล้วน ๆ ให้ใช้ Manacher เพราะไม่มีความเสี่ยง แต่โจทย์นี้ไม่ได้ถามพาลินโดรมของสตริงเดียว มันถามเรื่องตารางสองมิติที่หมุน 180 องศาแล้วเหมือนเดิม ซึ่งเทียบสองช่วงใดก็ได้ในตารางที่เปลี่ยนไปเรื่อย ๆ นั่นคือสิ่งที่แฮชทำได้และ Manacher ทำไม่ได้ตรง ๆ จึงเลือกแฮชเป็นท่าหลัก แล้วจ่ายค่าความเสี่ยงด้วยการเลือกมอดุลัสให้ดี
การหมุน 180 องศาไม่เคยพาช่องข้ามวง จัตุรัสด้าน k จึงเท่ากับจัตุรัสด้าน k - 2 ที่อยู่ข้างในบวกวงนอกหนึ่งวง
และวงนอกหนึ่งวงคือการเทียบสตริงสองคู่ ซึ่งแฮชนำหน้าตอบให้ได้ในเวลาคงที่
ในหน้านี้