programming.in.th · ข้อ 2008
ข้อที่คุ้มที่สุดสำหรับฝึกมองปัญหาสองมุมพร้อมกัน มุมแรกเป็นดีพีบิตมาสก์ที่ได้ 40 คะแนน มุมที่สองเห็นว่ามันคือการเดินบนต้นไม้ แล้วคำตอบยุบเหลือสูตรปิดบรรทัดเดียว ที่นี่มีทั้งสองมุมพร้อมโค้ดที่พิสูจน์แล้วว่าให้ผลตรงกัน
เครื่องพิมพ์แบบโบราณเรียงคำด้วยการวางแผ่นโลหะตัวอักษรต่อกันเป็นแถว แล้วกดลงกระดาษทีเดียว มันทำได้แค่สามอย่าง คือเติมตัวอักษรหนึ่งตัวต่อท้าย ลบตัวสุดท้ายทิ้ง และ กดพิมพ์คำที่เรียงค้างอยู่
เริ่มต้นเครื่องว่างเปล่า เราต้องพิมพ์คำที่กำหนดมาให้ครบทุกคำ จะพิมพ์คำไหนก่อนหลังก็ได้ และพอพิมพ์ครบแล้วจะปล่อยตัวอักษรค้างไว้ในเครื่องเลยก็ได้ ไม่ต้องเก็บกวาด งานของเราคือหาว่าอย่างน้อยต้องกดกี่ครั้ง แล้วแสดงลำดับคำสั่งออกมาหนึ่งแบบ
ประเด็นที่ทำให้ข้อนี้สนุกคือ การลบตัวสุดท้ายทิ้งมีราคาเท่ากับการเติมตัวใหม่ ทุกตัวอักษรที่เราเติมลงไป ถ้าไม่ได้ค้างอยู่ตอนจบ ก็ต้องเสียอีกหนึ่งครั้งเพื่อลบมันออก
อินพุต / ขอบเขต / เอาต์พุต
N จากนั้น N บรรทัด บรรทัดละหนึ่งคำ
N ≤ 25,000 แต่ละคำใช้ a-z ยาว 1 ถึง
20 ตัว และไม่มีคำซ้ำกัน เวลา 1 วินาที หน่วยความจำ 64 เมกะไบต์
M จากนั้น M บรรทัด
บรรทัดละหนึ่งคำสั่ง โดยตัวอักษรพิมพ์เล็กคือเติม - คือลบ และ P คือกดพิมพ์
N ไม่เกิน 18
| Input | Output |
|---|---|
| 3 the poem | 20 t h e P - - - p o e m P - - - r i n t P |
คำสั่งชุดนี้เป็นคนละชุดกับในโจทย์ แต่ยาวเท่ากันคือ 20 ครั้ง ซึ่งโจทย์บอกเองว่าตอบแบบไหนก็ได้
ของที่เราเลือกเองได้คือลำดับการพิมพ์ ตัวอย่างในโจทย์มี 3 คำ จึงมีลำดับให้เลือก 6 แบบ ไล่ดูให้ครบได้ในกระดาษแผ่นเดียว แต่โจทย์จริงให้คำมาได้ถึง 25,000 คำ จำนวนลำดับคือผลคูณของ 1 คูณ 2 คูณ 3 ไปเรื่อย ๆ จนถึง 25,000 ซึ่งยาวเกินกว่าจะเขียนลงกระดาษ
ชุดทดสอบย่อยที่ให้ 40 คะแนนวางกับดักไว้ตรงนี้พอดี พอเห็นว่า N ไม่เกิน 18
ท่าที่โผล่มาในหัวทันทีคือดีพีบนหน้ากากบิตของเซตคำที่พิมพ์ไปแล้ว ซึ่งคิดเป็นราว 4,718,592 ครั้ง
สบายมากในหนึ่งวินาที ปัญหาคือมันตันสนิทตั้งแต่ N เป็น 19 และไม่มีส่วนไหนของมัน
ต่อยอดไปหาเฉลยเต็มได้
จุดที่ปลดล็อกอยู่ในราคาของการย้ายจากคำหนึ่งไปอีกคำหนึ่ง ราคานั้นขึ้นกับสองคำที่ติดกันเท่านั้น คำที่เหลือจะเรียงกันยังไงก็ไม่เกี่ยว ลำดับทั้งลำดับจึงไม่ใช่ของที่ต้องนั่งเลือก และเมื่อย้อนกลับไปอ่านประโยคที่ว่าตัวอักษรซึ่งค้างอยู่ตอนจบไม่ต้องเสียค่าลบ ก็เหลือของที่ต้องตัดสินใจจริง ๆ อยู่อย่างเดียว คือคำไหนที่ปล่อยค้างไว้ในเครื่อง
ใบ้
สถานะของเครื่องพิมพ์คือสตริงหนึ่งตัว และคำสั่งสองแบบแรกคือการเดินไปข้างหน้าหนึ่งตัวอักษร กับการถอยกลับหนึ่งตัวอักษร ลองวาดสตริงที่เป็นไปได้ทั้งหมดเป็นแผนภาพดู แล้วถามตัวเองว่าการพิมพ์ครบทุกคำ แปลว่าเราต้องเดินไปแตะปมไหนบ้าง
เมื่อเห็นรูปนั้นแล้ว คำถามถัดไปคือ ถ้าทุกก้าวลงต้องมีก้าวขึ้นคู่กันเสมอ แล้วจะมีก้าวไหนบ้างที่ไม่ต้องจ่ายขากลับ
สองคำที่ใช้ส่วนหัวร่วมกัน ลองดูว่าประหยัดตรงไหน
หน้าจอตอนนี้
(ว่าง)
เติมตัวอักษรจนได้คำที่ต้องการ แล้วกดพิมพ์ ทำจนครบทุกคำในรายการ
ใช้ไปแล้ว 0 คำสั่ง · น้อยที่สุดคือ 0 คำสั่ง
คำสั่งมีสามอย่างคือเติมตัวอักษร ลบตัวท้าย และกดพิมพ์ ทุกอย่างนับเป็นหนึ่งคำสั่งเท่ากันหมด
ถ้าลองจัดลำดับคำใหม่แล้วจำนวนคำสั่งเปลี่ยน แปลว่าลำดับมีผล คำถามคือคำไหนควรถูกพิมพ์เป็นคำสุดท้าย
ที่มาของแนวคิดนี้
สิ่งแรกที่ผมสังเกตคือคำสั่งของเครื่องนี้ไม่ใช่คำสั่งอิสระสามแบบ มันเป็นการเดิน เติมตัวอักษรคือก้าวลง ลบคือถอยขึ้น และเครื่องจำสถานะล่าสุดไว้ให้เสมอ พอสิ่งที่ทำได้มีแค่ลงหนึ่งขั้นกับขึ้นหนึ่งขั้น โครงสร้างที่รองรับพอดีก็มีอยู่แบบเดียวคือต้นไม้ของส่วนหัวร่วม ไทรจึงไม่ได้มาจากการนึกว่า "โจทย์สตริงต้องใช้ไทร" แต่มาจากการอ่านชุดคำสั่งแล้วเห็นว่ามันคือการเดินบนต้นไม้
ที่ทำให้กล้าเชื่อว่ามุมนี้ถูก คือมันไปตรงกับอีกมุมที่คิดทีหลังพอดี ต้นทุนย้ายจากคำ a ไปคำ b
เท่ากับ |a| + |b| − 2 × LCP ซึ่งก็คือระยะทางบนไทรเป๊ะ ๆ
และปัญหาเดินเยี่ยมให้ครบบนต้นไม้มีสูตรปิด คือสองเท่าของจำนวนเส้น ลบความลึกของจุดที่จบ
สองมุมที่ตั้งต้นคนละทางแต่ลงเอยที่เลขเดียวกัน คือหลักฐานที่หนักกว่าการนั่งพิสูจน์มุมเดียวซ้ำ ๆ
บทเรียนที่ยกไปข้ออื่นได้คือ ก่อนเลือกโครงสร้างข้อมูล ให้ไล่ดูก่อนว่าสิ่งที่โจทย์อนุญาตให้ทำได้ มีหน้าตาเป็นอะไร แล้วเลือกโครงสร้างที่การกระทำเหล่านั้นคือการเดินหนึ่งก้าวพอดี
เอาคำทั้งหมดมาเรียงเป็นต้นไม้ตามส่วนหัวที่ใช้ร่วมกัน ต้นไม้แบบนี้เรียกว่า ไทร (trie)
ปมรากคือสตริงว่าง ปมลูกของ p คือ p ที่ต่อท้ายด้วยหนึ่งตัวอักษร
สถานะของเครื่องพิมพ์คือปมที่เรายืนอยู่ การเติมตัวอักษรคือเดินลงหนึ่งเส้น การลบคือถอยขึ้นหนึ่งเส้น
และการกดพิมพ์ทำได้เมื่อยืนบนปมที่เป็นคำในรายการ
แกะคำศัพท์
trie อ่านว่า "ไทร" หรือบางคนอ่าน "ทราย" ก็มี ชื่อนี้ตัดมาจากกลางคำว่า retrieval ที่แปลว่าการค้นคืน คนตั้งชื่อคือ Edward Fredkin เขาอยากให้อ่านว่า "ทรี" เหมือน tree เพราะมันคือต้นไม้ แต่คนใช้กันจริงกลับอ่าน "ไทร" เพื่อไม่ให้ชนกับคำว่า tree เวลาพูด
ทีนี้นับต้นทุน ต้นไม้นี้มีเส้นเชื่อมทั้งหมด 11 เส้น เท่ากับจำนวนคำนำหน้าที่ไม่ใช่สตริงว่าง ทุกเส้นต้องถูกเดินลงอย่างน้อยหนึ่งครั้ง ไม่งั้นคำที่อยู่ใต้เส้นนั้นจะไม่มีวันถูกพิมพ์ และเมื่อเดินลงไปแล้ว ถ้ายังมีงานเหลืออยู่นอกกิ่งนั้น ก็ต้องถอยกลับขึ้นมาอีกหนึ่งครั้ง
เส้นที่ไม่ต้องจ่ายขากลับคือเส้นที่ค้างอยู่ในเครื่องตอนจบ ซึ่งเป็นเส้นทางจากรากลงไปหาปมสุดท้ายที่เรายืน และเส้นทางนั้นยาวได้มากที่สุดเท่ากับความลึกของปมที่ลึกที่สุด จำนวนคำสั่งจึงเป็น
| รายการ | ที่มา | จำนวนครั้ง |
|---|---|---|
| เดินลง | ทุกเส้นเชื่อมของไทร | 11 |
| เดินขึ้น | ทุกเส้น ยกเว้นเส้นที่ค้างไว้ตอนจบ | 6 |
| กดพิมพ์ | คำละหนึ่งครั้ง | 3 |
| รวม | 2 × 11 − 5 + 3 | 20 |
print ยาว 5 ตัว
เหลืออีกเรื่องเดียวคือลำดับการเดิน ต้องเดินยังไงให้กิ่งของคำยาวที่สุดเป็นกิ่งสุดท้ายที่เข้าจริง ๆ คำตอบง่ายมาก คือตอนไล่ลูกของปมแต่ละปม ให้เก็บลูกที่อยู่บนเส้นทางไปหาคำยาวที่สุดไว้ท้ายสุด แล้วพอลงไปในกิ่งนั้น ก็ไม่ต้องใส่คำสั่งถอยกลับ
void dfs(int u) {
if (T[u].word) out.push_back('P'); // ปมนี้เป็นคำ ก็กดพิมพ์เสียตรงนี้
int keepChild = -1;
for (int c = 0; c < 26; c++) {
int v = T[u].ch[c];
if (v < 0) continue;
if (T[v].keep) { keepChild = c; continue; } // กิ่งของคำยาวสุด เก็บไว้ทีหลัง
out.push_back('a' + c);
dfs(v);
out.push_back('-'); // กิ่งธรรมดา ลงไปแล้วต้องถอยกลับขึ้นมา
}
if (keepChild >= 0) { // ลงกิ่งสุดท้ายแล้วค้างไว้เลย ไม่ต้องถอย
out.push_back('a' + keepChild);
dfs(T[u].ch[keepChild]);
}
}
ลองเปลี่ยนไปค้าง the ที่ยาว 3 ตัวแทน ทุกอย่างเหมือนเดิมหมด ต่างแค่ตอนจบเราหยุดอยู่ตื้นกว่า
| ค้างคำไหนไว้ | ความลึก | จำนวนคำสั่ง |
|---|---|---|
print (ยาวที่สุด) | 5 | 20 |
the | 3 | 22 |
ที่มาของแนวคิดนี้
ลำดับจริงคือผมคิดท่าไทรได้ก่อน แล้วค่อยเขียนมุมนี้ทีหลัง และสิ่งที่ได้กลับมาไม่ได้มีแค่คะแนนชุดทดสอบย่อย แต่ได้ตัวตรวจสอบที่คิดคนละแบบมาด้วย ตอนนั้นเฉลยไทรตอบตัวอย่างในโจทย์ถูกแล้ว แต่ตัวอย่างในโจทย์เขียนขึ้นมาเพื่ออธิบายโจทย์ ไม่ได้เขียนมาเพื่อทำให้โค้ดพัง ผมจึงอยากได้อีกโปรแกรมที่คิดคนละแบบ แล้วเอามาเทียบกัน
ผลคือสุ่มชุดคำเล็ก ๆ 400 ชุด สองมุมให้จำนวนคำสั่งตรงกันทุกชุด แต่ผมไม่หยุดแค่นั้น เพราะโปรแกรมที่นับจำนวนคำสั่งถูก แต่พิมพ์ผิดลำดับ จะรอดการเทียบตัวเลขไปได้สบาย ผมเลยเขียนตัวตรวจที่เดินตามคำสั่งที่โปรแกรมสั่งออกมาจริง ๆ ทีละตัว แล้วเช็กว่าเซตของคำที่ถูกพิมพ์ตรงกับอินพุตไหม อันนี้รัน 800 ชุด
บทเรียนที่ยกไปข้ออื่นได้คือ ถ้าโจทย์ให้ตอบเป็นชุดคำสั่ง ตัวตรวจสอบต้องรันคำสั่งนั้นจริง ไม่ใช่แค่เทียบความยาวของมัน
ถ้ายังไม่เห็นไทร ยังมีอีกมุมที่พาไปถึง 40 คะแนนได้ และเป็นมุมที่ฝึกวิธีคิดได้ดี ลืมเรื่องต้นไม้ไปก่อน แล้วมองว่าเรากำลังไล่พิมพ์คำทีละคำ โดยเลือกลำดับเอง
ต้นทุนของการย้ายจากคำ a ไปคำ b คือถอยลบจนเหลือส่วนหัวที่ทั้งคู่ใช้ร่วมกัน
แล้วเติมส่วนที่เหลือของ b เข้าไป นั่นคือ
cost(a, b) = |a| + |b| − 2 × ความยาวส่วนหัวที่เหมือนกัน
พอนิยามแบบนี้ ปัญหาก็กลายเป็นหาลำดับเยี่ยมคำทุกคำที่ผลรวมต้นทุนน้อยที่สุด โดยเริ่มจากเครื่องเปล่า ซึ่งก็คือดีพีบิตมาสก์แบบที่ใช้กับปัญหาพนักงานขายเดินทาง สถานะคือ (เซตของคำที่พิมพ์ไปแล้ว, คำล่าสุดที่อยู่ในเครื่อง)
จำนวนสถานะคือ 2^18 × 18 ราว 4,718,592 สถานะ ซึ่งไหวสบายในหนึ่งวินาที
แต่พอ N ขึ้นไปถึง 25,000 มันตายทันที
ที่น่าสนใจคือสองมุมนี้ให้คำตอบตรงกันเสมอ ผมสุ่มคำชุดเล็ก 400 ชุดมาเทียบแล้วไม่มีชุดไหนต่างกันเลย เพราะจริง ๆ แล้วต้นทุนที่นิยามข้างบนคือระยะทางบนไทรนั่นเอง และเส้นทางเยี่ยมทุกปมที่สั้นที่สุด บนต้นไม้ก็มีสูตรปิดคือ 2 เท่าของจำนวนเส้น ลบความลึกของจุดจบ พอดีกับที่นับไว้ข้างบน
บทเรียนที่เอาไปใช้ต่อได้คือ เวลาเจอปัญหา "เลือกลำดับให้ถูกที่สุด" ให้ลองเขียนมันเป็นดีพีบิตมาสก์ก่อน เพื่อให้ได้คำตอบที่เชื่อถือได้สำหรับอินพุตเล็ก แล้วค่อยหาโครงสร้างที่ทำให้ตัดมันเหลือเชิงเส้น ระหว่างทางเราจะได้ตัวตรวจสอบมาฟรีหนึ่งตัว
กดถัดไปเพื่อไล่คำสั่งทีละครั้ง
| ครั้งที่ | คำสั่ง | ในเครื่อง | พิมพ์ออกมา |
|---|---|---|---|
| 1 | t | t | |
| 2 | h | th | |
| 3 | e | the | |
| 4 | P | the | the |
| 5 | - | th | |
| 6 | - | t | |
| 7 | - | (ว่าง) | |
| 8 | p | p | |
| 9 | o | po | |
| 10 | e | poe | |
| 11 | m | poem | |
| 12 | P | poem | poem |
| 13 | - | poe | |
| 14 | - | po | |
| 15 | - | p | |
| 16 | r | pr | |
| 17 | i | pri | |
| 18 | n | prin | |
| 19 | t | print | |
| 20 | P | print | print |
สังเกตท้ายตาราง เครื่องจบด้วย print ค้างอยู่ ไม่มีคำสั่งลบตามหลังอีกเลย
นั่นคือ 5 ครั้งที่เราประหยัดได้
| ทาง | ขอบเขตที่มันไหว | งานคร่าว ๆ |
|---|---|---|
| มองเป็นปัญหาเดินเยี่ยมให้ครบ | N ไม่เกิน 18 | 84,934,656 ครั้ง |
| เดินบนไทร | N ถึง 25,000 | 500,000 ครั้ง |
ไทรใหญ่สุดได้ราว 500,000 ปม แต่ละปมเก็บลูก 26 ช่อง ก็คือราว 50 เมกะไบต์ ซึ่งยังอยู่ในลิมิต 64 เมกะไบต์ จริง ๆ คำที่ใช้ร่วมกันเยอะจะทำให้ปมน้อยกว่านี้มาก
จุดที่ต้องระวังคือการพิมพ์ผลลัพธ์ คำสั่งมีได้เป็นล้านบรรทัด ถ้าเรียก
printf ทีละบรรทัดจะช้าเกินไป ในโค้ดนี้จึงต่อเป็นสตริงเดียวแล้วเทออกทีเดียว
ตอนตรวจก่อนส่ง ผมไม่ได้เทียบแค่จำนวนครั้ง แต่เขียนตัวจำลองที่เดินตามคำสั่งจริง แล้วเช็กว่าเซตของคำที่ถูกพิมพ์ออกมาตรงกับอินพุตเป๊ะ และจำนวนครั้งเท่ากับสูตร 2 × จำนวนเส้น ลบความยาวคำที่ยาวที่สุด บวกจำนวนคำ ทดสอบไป 800 ชุด ผ่านหมด การเช็กแค่ตัวเลขอย่างเดียวไม่พอ เพราะโปรแกรมที่นับถูกแต่พิมพ์ลำดับผิดจะรอดสายตาไปได้
เวลาที่วัดได้บนเครื่องผม อินพุต 25,000 คำ คำละ 15 ถึง 20 ตัวอักษร ใช้เวลา 76 มิลลิวินาที จากลิมิตหนึ่งวินาที
การตัดสินใจว่าจะเก็บกิ่งไหนไว้ทีหลังเป็นท่าโลภ (greedy) คือเราเลือกด้วยกฎข้อเดียวว่า เก็บกิ่งที่มีคำที่ลึกที่สุดไว้ท้ายสุด แล้วไม่ย้อนกลับมาทบทวน สิ่งที่ทำให้กล้าเชื่อกฎนี้คือ หัวข้อ "ถ้าเลือกกิ่งค้างผิด จะแพงขึ้นเท่าไร" ข้างบน ซึ่งคิดราคาของการเลือกผิดออกมาเป็นตัวเลข
รูปนี้ยกไปใช้ได้กับท่าโลภทุกข้อ คืออย่าเชื่อกฎเพราะตัวอย่างผ่าน ให้คิดราคาของการทำตรงข้าม ถ้าทำตรงข้ามแล้วแย่ลงเสมอ กฎนั้นถูก ถ้ามีกรณีที่ไม่แย่ลง กฎนั้นยังไม่พร้อมใช้ มีคำอธิบายเรื่องท่าโลภอยู่ในโจทย์โมบาย
เมื่อสถานะของเครื่องเป็นสตริงที่แก้ได้แค่ท้าย ทุกคำสั่งก็คือหนึ่งก้าวบนไทร ต้นทุนจึงถูกล็อกไว้แล้ว ยกเว้นตัวเดียวที่เราเลือกได้ คือกิ่งที่ยอมค้างไว้ตอนจบ และตัวที่เลือกได้ก็ควรเป็นกิ่งที่ลึกที่สุด
ในหน้านี้