programming.in.th
ท่าเปลี่ยนคำถามที่เห็นชัดที่สุดในคลังนี้ จาก "หยิบตรงไหน" เป็น "ใครได้บวก ใครได้ลบ" แล้วกองที่ดูพัวพันกันทั้งกองก็ยุบเป็นตารางที่เดินหน้าเดียวจบ ต่อยอดจากบท DP บนช่วง และเป็นทางเข้าของท่าอัดสถานะ
คุณเข้าแถวสายจนโดนอาจารย์สั่งให้ไปจัดหนังสือในห้องสมุด หนังสือเรียนหนา ๆ กองใหญ่ จัดไปสักพักก็เบื่อ พอดีถึงเวลาพักเที่ยงและอาจารย์ห้องสมุดไม่อยู่ คุณเลยคิดเกมขึ้นมาเล่นคนเดียวกับกองหนังสือตรงหน้า
กองหนึ่งมีหนังสือ n เล่มวางซ้อนกันอยู่ ทุกเล่มมีเลขเขียนไว้บนสัน
กติกาคือ หยิบออกได้ทีละ 3 เล่มที่อยู่ติดกันเท่านั้น และแต้มที่ได้จากการหยิบหนึ่งครั้งคือ
เอาเลขของสองเล่มบนบวกกัน แล้วหักด้วยเลขของเล่มล่างสุดในสามเล่มนั้น
หยิบกี่ครั้งก็ได้ ไม่จำเป็นต้องหยิบจนหมดกอง ถ้าหยิบต่อแล้วเสียแต้มก็หยุดได้
คำถามคือ ถ้าเลือกได้อย่างอิสระว่าจะหยิบตรงไหนก่อนหลัง แต้มรวมมากที่สุดที่เป็นไปได้คือเท่าไหร่
โจทย์กำหนด · อินพุต / ขอบเขต / เอาต์พุต
N จำนวนหนังสือในกอง จากนั้นอีก N บรรทัด บรรทัดละหนึ่งจำนวน คือเลขบนสันหนังสือ ไล่จากบนลงล่าง1 ≤ N ≤ 2 000 เลขบนสันเป็นจำนวนเต็มไม่ติดลบ ไม่เกิน 1 000 000| Input | Output |
|---|---|
| 7 1 2 3 4 5 6 7 | 5 |
| 9 5 5 5 5 5 5 5 5 100 | 10 |
| 15 28 40 39 88 37 46 18 25 39 50 67 40 19 74 23 | 385 |
ชุดที่ 2 อ่านง่ายที่สุด มีเลข 5 อยู่แปดเล่ม แล้วมีเลข 100 นอนรออยู่ล่างสุด
ถ้าโจทย์บอกว่า "หยิบได้ทีละ 3 เล่มติดกัน แล้วจบ" ข้อนี้จบใน 3 บรรทัด ไล่ดูทุกตำแหน่งแล้วเก็บค่าที่ดีที่สุด ตัวที่ทำให้มันมีชีวิตขึ้นมาคือคำว่า หยิบได้หลายครั้ง เพราะพอหนังสือ 3 เล่มหลุดออกไป กองมันปิดช่องเอง เล่มที่เมื่อกี้อยู่คนละมุมของกองเลื่อนมาชนกันได้ กลายเป็นสามเล่มติดกันชุดใหม่ที่ตอนเริ่มไม่มีอยู่จริง
คำใบ้
ลองนึกถึงตัวอย่างชุดที่ 1 (เลข 1 ถึง 7 ตอบ 5) ถ้าหยิบสามเล่มล่างสุด 5, 6, 7 จะได้ 4 แต้ม ซึ่งเป็นการหยิบครั้งเดียวที่ให้แต้มสูงสุดในกองนี้
แต่คำตอบของโจทย์คือ 5 แปลว่ามีทางที่ดีกว่าการรีบคว้าก้อนใหญ่
คำถามที่ควรค้างอยู่ในหัวตอนนี้คือ การหยิบครั้งแรกที่ดูแล้วไม่คุ้ม มันไปเปิดทางให้ครั้งต่อไปยังไง
ก่อนไปดูเฉลย ลองเล่นกองข้างล่างนี้ก่อนครับ คลิกที่หนังสือเล่มบนสุดของสามเล่มที่อยากหยิบ ระบบจะหยิบเล่มนั้นกับอีกสองเล่มใต้มันออกไป แล้วปิดช่องกองให้เอง ตัวเลข "ดีที่สุดที่ทำได้" คือคำตอบจริงของกองนั้น ลองไล่ให้ถึงดูครับ
แต้มสะสม0
ดีที่สุดที่ทำได้0
หยิบไปแล้ว0
คลิกเล่มบนสุดของสามเล่มที่อยากหยิบ
ลองเล่นให้หนำใจก่อนนะครับ พอมีคำตอบในใจแล้วค่อยเปิดเฉลย
กอง A ในเกมคือ 9, 2, 9, 2, 9, 1 ถ้าเดินแบบ โลภ (greedy) คือมองหาการหยิบที่ได้แต้มเยอะที่สุดตอนนี้แล้วคว้าเลย
คุณจะเจอ 2 + 9 − 1 = 10 ที่สามเล่มล่าง คว้าไปก่อน เหลือ 9, 2, 9 ซึ่งหยิบได้อีกแค่ 9 + 2 − 9 = 2 รวมเป็น 12 แต้ม
แต่คำตอบจริงคือ 26 แต้ม ทางที่ถูกคือหยิบตรงกลางก่อน (2, 9, 2 ได้ 9 แต้ม) เพื่อให้เลข 9 สองตัวที่อยู่หัวกองกับเกือบท้ายกอง
ซึ่งไม่เคยอยู่ติดกันเลย ได้มาเจอกัน แล้วหยิบทีเดียว 9 + 9 − 1 = 17 รวม 26
บทเรียนของข้อนี้อยู่ตรงนี้ครับ การหยิบไม่ได้มีหน้าที่แค่เก็บแต้ม มันมีหน้าที่ จับคู่ ด้วย เลขใหญ่ทุกตัวอยากได้เครื่องหมายบวก ส่วนเลขเล็กเหมาะกับการเป็นตัวที่โดนลบ การหยิบผิดจังหวะคือการเอาเลขใหญ่ไปนอนรวมกับเลขใหญ่ด้วยกัน แล้วบังคับให้ตัวหนึ่งกลายเป็นตัวลบ
สองทางที่ผมลองก่อน แล้วทิ้ง
ทางแรกคือท่าโลภ ซึ่งตายตั้งแต่กอง A ในเกมข้างบน มันได้ 12 ขณะที่ของจริงคือ 26 และมันตายด้วยเหตุผลที่มีประโยชน์ คือมันมองการหยิบเป็นการเก็บแต้ม ทั้งที่การหยิบยังจัดที่ให้เลขที่เหลือด้วย ตรงนี้ยังไม่ใช่คำตอบ แต่มันบอกแล้วว่าสิ่งที่ต้องคุมคือความสัมพันธ์ระหว่างเล่ม ไม่ใช่ลำดับการหยิบ
ทางที่สองคือ DP บนช่วง ซึ่งเป็นท่าประจำตระกูลของโจทย์แนว "ลบแล้วช่องปิด" และผมเดินมันจนสุดจริง ๆ มันถูกต้อง แต่ตารางใบเดียวก็กินหน่วยความจำเกินลิมิต 16 เมกะไบต์ไปเท่าตัว และถ้าบีบชนิดข้อมูลลงจนสุด มันก็ชนเพดานพอดีเป๊ะโดยไม่เหลือที่ให้ตารางใบที่สองเลย รายละเอียดทั้งหมดผมแยกไปเล่าไว้ในหัวข้อท้ายบทความ
จุดที่ทำให้พลิก คือเลข 16 เมกะไบต์นั่นเอง โจทย์ที่ n แค่ 2,000 ไม่มีเหตุผลจะตั้งลิมิตหน่วยความจำต่ำขนาดนั้น
นอกจากจะกำลังบอกว่าตารางสองมิติที่ยาวด้านละ n ไม่ใช่คำตอบ พอยอมรับข้อนั้น
คำถามก็เปลี่ยนจาก "จะจำช่วงยังไงให้ประหยัด" เป็น "มีวิธีมองอื่นที่ไม่ต้องจำช่วงเลยไหม"
บทเรียนที่ยกไปข้ออื่นได้คือ ลิมิตหน่วยความจำที่ต่ำผิดปกติเป็นคำใบ้ระดับเดียวกับขอบเขตที่เล็กผิดปกติ มันกำลังตัดตระกูลของเฉลยทิ้งให้เราหนึ่งตระกูล ก่อนที่เราจะเสียเวลาเขียนมันจนจบ
ตราบใดที่ยังคิดเป็น "ลำดับของการหยิบ" เราจะติดอยู่กับกองที่เปลี่ยนรูปทุกครั้ง ซึ่งจำสถานะไม่ไหวแน่นอน ท่ามาตรฐานคือเลิกสนใจว่าหยิบก่อนหลังยังไง แล้วมองที่ผลลัพธ์ตอนจบแทน
เมื่อทุกอย่างจบลง หนังสือแต่ละเล่มมีชะตากรรมได้แค่ 3 แบบ คือ ได้เครื่องหมายบวก (เป็นหนึ่งในสองเล่มบนของครั้งที่หยิบมัน) ได้เครื่องหมายลบ (เป็นเล่มล่างสุดของครั้งนั้น) หรือ ไม่ถูกหยิบเลย แต้มรวมก็คือผลบวกของทุกเล่มตามเครื่องหมายของมัน เท่านั้นเอง งานที่เหลือคือหาว่า รูปแบบเครื่องหมายแบบไหนที่ทำได้จริง
ไม่ใช่ทุกรูปแบบเครื่องหมายที่เกิดขึ้นได้ เช่นเล่มบนสุดของกองจะเป็นตัวลบไม่ได้เลย เพราะตัวลบต้องมีเพื่อนบวกอยู่เหนือมันสองเล่ม กฎที่แท้จริงจะเห็นชัดที่สุดเมื่อไล่จากบนกองลงล่างทีละเล่ม พร้อมถือ กองซ้อน (stack) ไว้ในมือ
แกะคำศัพท์
stack อ่านว่า "สแต็ก" แปลตรง ๆ ว่า "กองที่ซ้อนทับกันขึ้นไป" คำเดียวกับกองฟางที่ซ้อนเป็นชั้น ๆ ในทางโปรแกรม มันคือที่เก็บของซึ่งชิ้นที่ใส่ทีหลังจะถูกหยิบออกก่อน เหมือนกองจานที่วางซ้อนกัน คุณหยิบใบบนสุดเสมอ ข้อนี้สนุกตรงที่โจทย์เป็นกองหนังสือของจริงอยู่แล้ว วิธีคิดเลยยืมของจริงมาใช้ได้ตรง ๆ
เดินจากเล่มบนสุดลงล่างทีละเล่ม แล้วตัดสินใจว่าเล่มนี้จะเป็นอะไร
พอเรื่องทั้งหมดเหลือแค่ "เดินจากบนลงล่าง แล้วจำว่ามีบวกค้างกี่เล่ม" สิ่งที่ต้องจำก็มีแค่สองตัว คือดูมาถึงเล่มที่เท่าไหร่ กับค้างอยู่กี่เล่ม ที่เหลือเป็น DP ตรง ๆ
แกะคำศัพท์
DP ย่อจาก dynamic programming อ่านว่า "ไดนามิก โปรแกรมมิง" ไทยแปลกันว่า "กำหนดการพลวัต" ใจความคือ ตอบคำถามย่อยแล้วเก็บไว้ในตาราง เอาคำตอบเก่ามาต่อยอด จะได้ไม่คำนวณซ้ำ คำว่า dynamic ในชื่อนี้ไม่ได้มีความหมายทางเทคนิคอะไร Richard Bellman คนตั้งชื่อเล่าเองว่าเลือกคำนี้เพราะฟังดูน่าประทับใจ และเจ้านายที่คุมงบตอนนั้นเกลียดคำว่า "วิจัยคณิตศาสตร์" พอดี
ให้ dp[i][p] คือแต้มมากที่สุดเมื่อพิจารณาหนังสือ i เล่มแรกไปแล้ว และเหลือบวกค้างอยู่ p เล่ม
จุดเริ่มคือ dp[0][0] = 0 คือยังไม่ดูเล่มไหนเลย ยังไม่ค้างอะไรเลย แต้มเป็นศูนย์
ช่องอื่น ๆ ในแถวแรกคือสถานะที่ไปไม่ถึง
ทีนี้พอขยับไปดูเล่มที่ i เล่มนี้มีได้สามหน้าที่ตามกฎเครื่องหมายเมื่อกี้ แต่ละหน้าที่ก็คือคำตอบของคำถามว่า
ช่องนี้รับค่ามาจากช่องไหนในแถวก่อนหน้า
ก่อนอ่านสมการ ขอปักหมุดคำเดียว p ในช่องที่เรากำลังเติม คือจำนวนบวกค้าง "หลัง" ตัดสินใจเรื่องเล่มที่ i ไปแล้ว
ส่วนดัชนีตัวหลังของช่องต้นทาง คือจำนวนบวกค้าง "ก่อน" ตัดสินใจ ทั้งสามบรรทัดจึงเป็นคำถามเดียวกันหมด
คือถ้าอยากให้จบที่ค้าง p เล่ม ก่อนหน้านี้ต้องค้างอยู่กี่เล่ม
dp[i−1][p−1] + a[i] เล่มนี้ถูกผลักเข้าสแต็ก บวกค้างจึงเพิ่มขึ้นหนึ่งเล่ม
ถ้าปลายทางต้องค้าง p เล่ม ก่อนหน้านี้ก็ต้องค้างอยู่ p−1 เล่ม ส่วนแต้มบวก a[i] เข้าไปตรง ๆ
ทางนี้ใช้กับช่อง p = 0 ไม่ได้ เพราะเล่มที่เพิ่งเป็นบวกก็นับเป็นบวกค้างหนึ่งเล่มด้วยตัวมันเอง
หลังตัดสินใจจึงต้องค้างอย่างน้อยหนึ่งเล่มเสมอ
dp[i−1][p+2] − a[i] เล่มนี้ไปปิดจ๊อบ กินบวกค้างที่รออยู่ไปสองเล่ม
ถ้าปลายทางต้องค้าง p เล่ม ก่อนหน้านี้ต้องค้างอยู่ p+2 เล่ม ส่วนแต้มถูกหักด้วย a[i]
ทางนี้ใช้ได้ก็ต่อเมื่อแถวก่อนหน้าไปถึงช่อง p+2 ได้จริง
dp[i−1][0] แต้มไม่ขยับ และเพราะกฎเครื่องหมายอนุญาตให้ข้ามได้เฉพาะตอนที่ไม่มีบวกค้างเลย
ทั้งก่อนและหลังจึงเป็นศูนย์เท่ากัน ทางนี้โผล่ได้ที่ช่อง p = 0 ช่องเดียว
ผลของสามข้อนี้คือแต่ละช่องมีทางเข้าไม่เท่ากัน ที่คอลัมน์ p = 0 เหลือแค่สองทาง
คือข้ามเล่มนี้ไป กับให้มันเป็นลบโดยรับค่ามาจากช่อง p = 2 ส่วนคอลัมน์อื่นไม่มีท่าข้าม
| ให้เล่มที่ i ทำหน้าที่ | ค่าที่ได้ | ใช้ท่านี้ได้เมื่อ |
|---|---|---|
| เป็นบวก เปิดคู่ใหม่ ค้างเพิ่มอีกหนึ่ง | dp[i−1][p−1] + a[i] | p ≥ 1 ไม่งั้นช่องต้นทางจะเป็น dp[i−1][−1] ซึ่งไม่มีอยู่ในตาราง |
| เป็นลบ ปิดจ๊อบ กินบวกค้างไปสอง | dp[i−1][p+2] − a[i] | แถวก่อนหน้าต้องค้างอยู่ p+2 ได้จริง |
| ไม่ถูกหยิบเลย ข้ามไปดื้อ ๆ | dp[i−1][0] | p = 0 เท่านั้น |
dp[i][p] คือค่าที่มากที่สุดในสามทางนี้ ทางไหนเงื่อนไขไม่ผ่านก็ตัดทิ้ง ถ้าไม่เหลือทางเลยแปลว่าช่องนั้นไปไม่ถึง
คำตอบของทั้งข้อคือ dp[n][0] ที่ต้องเป็น p = 0 เพราะบวกค้างหนึ่งเล่มคือสัญญาว่าเดี๋ยวจะมีลบมาปิดให้ครบสาม
ถ้าจบกองแล้วยังค้างอยู่ แปลว่าเราบวกเลขของหนังสือที่ความจริงไม่เคยถูกหยิบออกมาเลย แต้มนั้นใช้ไม่ได้
สถานะทั้งหมดมี n × n ช่อง แต่ละช่องทำงานคงที่ จึงเป็น O(n²) ที่ n = 2 000 คือราวสี่ล้านครั้ง
เครื่องผมวิ่งจบใน 26 มิลลิวินาที จากลิมิต 1 วินาที
อีกอย่างที่ต่างจากตารางในหัวคือโค้ดไม่ได้เก็บตารางทั้งใบ เก็บแค่สองแถวที่กำลังใช้ cur[q] คือ dp[i][q] แถวที่ยืนอยู่
และ nxt[q] คือ dp[i+1][q] แถวถัดไป พอทำแถวถัดไปเสร็จก็ swap ให้มันกลายเป็นแถวปัจจุบัน
// cur[q] คือ dp[i][q] แถวที่กำลังยืนอยู่ ส่วน nxt[q] คือ dp[i+1][q] แถวถัดไป
// ยืนที่ช่องปลายทาง nxt[q] แล้วเก็บค่าที่ดีที่สุดจากสามทางที่ชี้เข้ามา
long long best = NEG;
if (q == 0 && cur[0] != NEG) best = max(best, cur[0]); // ไม่หยิบเล่มนี้เลย
if (q >= 1 && cur[q - 1] != NEG) best = max(best, cur[q - 1] + a[i]); // ให้เป็นบวก
if (q + 2 <= n && cur[q + 2] != NEG) best = max(best, cur[q + 2] - a[i]); // ให้เป็นลบ
nxt[q] = best; โน้ต: ไม่มีจังหวะล้างแถว
ลูปเขียน nxt[q] = best ทับลงไปตั้งแต่ q = 0 จนถึง q = n ครบทั้งแถว
ตัวเลขที่ค้างอยู่ในแถวนั้นคือของแถวเมื่อสองรอบก่อน มันถูกทับจนหมดเองก่อนจะมีคนอ่าน จึงไม่ต้องมี fill คอยล้างแถวก่อนเริ่ม
เอากองเล็ก ๆ เจ็ดเล่มคือ 7 3 1 2 9 9 7 มาเติมตารางจริง กองนี้เลือกมาเพราะทางที่ดีที่สุดของมันคือหยิบสองครั้งแล้วเหลือทิ้งไว้หนึ่งเล่ม จะได้เห็นว่าท่า "ไม่หยิบ" หน้าตาเป็นยังไงในตาราง แถวคือ i (ดูมาแล้วกี่เล่ม)
คอลัมน์คือ p (ค้างอยู่กี่เล่ม) จุด · คือช่องที่ยังเป็น NEG คือไปไม่ถึง
กดปุ่มเดินทีละขั้นได้ ตารางจะค่อย ๆ เติมทีละช่อง พร้อมบอกว่าเลขนั้นมาจากช่องไหนและทำไมถึงเลือกค่านี้
| แถว | p = 0 | p = 1 | p = 2 | p = 3 | p = 4 | p = 5 | p = 6 | p = 7 |
|---|---|---|---|---|---|---|---|---|
| i = 0 (ยังไม่เริ่ม) | 0 | · | · | · | · | · | · | · |
| i = 1 · a[1] = 7 | 0 | 7 | · | · | · | · | · | · |
| i = 2 · a[2] = 3 | 0 | 3 | 10 | · | · | · | · | · |
| i = 3 · a[3] = 1 | 9 | 1 | 4 | 11 | · | · | · | · |
| i = 4 · a[4] = 2 | 9 | 11 | 3 | 6 | 13 | · | · | · |
| i = 5 · a[5] = 9 | 9 | 18 | 20 | 12 | 15 | 22 | · | · |
| i = 6 · a[6] = 9 | 11 | 18 | 27 | 29 | 21 | 24 | 31 | · |
| i = 7 · a[7] = 7 | 20 | 22 | 25 | 34 | 36 | 28 | 31 | 38 |
สีเหลืองคือช่องที่กำลังเติม สีฟ้าคือช่องต้นทางที่มันหยิบค่ามาใช้ ขั้นสุดท้ายจะไฮไลต์เส้นทางที่ให้คำตอบทั้งเส้น
ตารางนี้คือ dp[i][p] ทั้งใบ และมันเดินตามสมการแบบดึงในตาราง RECURRENCE ข้างบนตรง ๆ
คือไล่ทีละช่องปลายทาง แล้วถามว่าช่องนั้นรับค่ามาจากช่องไหนในแถวก่อนหน้า จึงเห็นช่องต้นทางสีฟ้าอยู่ในแถวบนเสมอ
ตัวเล่นข้างล่างคือกองเดียวกัน แต่แสดงเฉพาะสิ่งที่เครื่องถือไว้จริง ๆ คือสองแถว และเดินตามสมการเดียวกับตารางข้างบน หนึ่งรอบของหนังสือหนึ่งเล่มมีสองจังหวะ: ไล่เติมทุกช่องของ nxt จากซ้ายไปขวา แล้ว swap ลองเทียบกับตารางเต็มใบข้างบนดูได้ ค่าที่ปรากฏจะตรงกันแถวต่อแถว
| แถว | p = 0 | p = 1 | p = 2 | p = 3 | p = 4 | p = 5 | p = 6 | p = 7 |
|---|---|---|---|---|---|---|---|---|
| cur | · | · | · | · | · | · | · | · |
| nxt | · | · | · | · | · | · | · | · |
สีทองคือช่องของ nxt ที่กำลังเติม สีเขียวคือช่องของ cur ที่ถูกเลือกมาเป็นต้นทาง สีจางคือต้นทางที่เป็นไปได้แต่แพ้อีกทางหนึ่ง
เส้นทางที่ได้คือ 0 → 7 → 10 → 9 → 9 → 18 → 27 → 20 อ่านเป็นภาษาคนได้ว่า หยิบเล่ม 1-2-3 ครั้งหนึ่ง (7 + 3 − 1 = 9 แต้ม) แล้วข้ามเล่มที่ 4 ทิ้งไว้ในกอง จากนั้นหยิบเล่ม 5-6-7 อีกครั้ง (9 + 9 − 7 = 11 แต้ม) รวม 20 แต้ม
จังหวะข้ามคือขั้นที่น่าดูที่สุด ตอนเติมช่อง dp[4][0] มีสองทางให้เลือก
ทางแรกคือให้เล่มที่ 4 (เลข 2) เป็นลบไปปิดจ๊อบกับ dp[3][2] = 4 ได้ 2 แต้ม
อีกทางคือไม่หยิบมันเลย แล้วยกค่า dp[3][0] = 9 มาทั้งดุ้น ทางหลังมากกว่า ค่าในช่องจึงไม่ขยับสองแถวติด
นั่นแหละคือหน้าตาของ "เลือกที่จะไม่หยิบ" ในตาราง DP คือแถวเดินหน้าไปแต่แต้มยืนอยู่กับที่ และหนังสือเล่มนั้นก็ค้างอยู่ในกองตลอดกาล
อีกจุดที่ควรสังเกตคือทุกครั้งที่เส้นทางแวะกลับมาที่ p = 0 แปลว่าการหยิบชุดนั้นปิดจ๊อบครบสามเล่มพอดี หนี้หมด เริ่มรอบใหม่ได้
ส่วนค่าที่ค้างอยู่คอลัมน์อื่นในแถวสุดท้ายอย่าง dp[7][3] = 34 ดูเยอะกว่าคำตอบเยอะ
แต่มันคือแต้มของคนที่บวกไว้สามเล่มแล้วไม่มีลบมาปิด หนังสือกองนั้นยังไม่ถูกหยิบออกมาจริง จึงอ่านเป็นคำตอบไม่ได้ เราจึงมองแค่ช่อง cur[0] ตอนจบ
จุดที่พลาดกันบ่อยคือค่าเริ่มต้น ช่องที่ยังไปไม่ถึงต้องใส่ค่าติดลบมหาศาล ห้ามใส่ 0 เพราะ 0 เป็นแต้มที่ถูกต้องได้ (แปลว่าไม่หยิบอะไรเลย) ถ้าใส่ 0 ให้ทุกช่อง โปรแกรมจะเชื่อว่าตัวเองยืนอยู่ในสถานะที่ไปไม่ได้จริง แล้วตอบเกินความจริง
เขียนแบบที่ใช้ส่งแข่งจริง คืออ่านอินพุตด้วย scanf ใช้อาร์เรย์สองแถวสลับกัน (แถวปัจจุบันกับแถวถัดไป)
และตั้งชื่อตัวแปรให้ตรงกับสิ่งที่มันเป็นจริง ๆ จะได้กลับมาอ่านรู้เรื่องตอนดีบั๊กในนาทีที่ 45
ตอนแรกผมคิดว่าเลข 16 เมกะไบต์เป็นแค่ค่าเริ่มต้นเก่า ๆ ของระบบ แต่พอลองเดินทางที่คนส่วนใหญ่จะเดินก็เข้าใจว่ามันเป็นคำใบ้ หัวข้อนี้ไม่ใช่เฉลยของข้อนี้ แต่ผมอยากเล่าให้ครบ เพราะมันแสดงให้เห็นชัดว่าปัญหาเดียวกันมองเป็น DP ได้หลายแบบ และการเลือกว่าจะให้ตาราง "จำอะไร" คือสิ่งที่ตัดสินว่าโปรแกรมจะรอดหรือตาย
ปัญหาแนว "ลบของออกจากแถวแล้วแถวปิดช่อง" มีท่าไม้ตายประจำตระกูลอยู่ท่าหนึ่งคือ DP บนช่วง (interval DP) อ่านว่า "อินเทอร์วัล ดีพี" แปลว่า กำหนดการพลวัตบนช่วง คือให้สถานะเป็นช่วงต่อเนื่องหนึ่งช่วงของแถว แล้วไล่แก้จากช่วงสั้นไปหาช่วงยาว
เหตุผลที่คนคิดถึงท่านี้ก่อนคือมันตอบเรื่อง "กองปิดช่องเอง" ได้ตรง ๆ ถ้าเรารู้ว่าช่วง [x+1..y−1] ถูกกวาดออกไปหมดแล้ว
เราก็รู้ทันทีว่าเล่ม x กับเล่ม y จะมาอยู่ติดกัน โดยไม่ต้องสนใจว่าข้างในมันหยิบกันยังไง นิยามที่ได้คือ
สถานะของ DP บนช่วง
f[i][j] คือแต้มมากที่สุด ถ้าบังคับว่าหนังสือในช่วงเล่ม i ถึงเล่ม j
ต้องถูกเก็บกวาดออกไปจนหมดทั้งช่วง ไม่เหลือคาไว้สักเล่ม (ช่วงที่ความยาวหารสามไม่ลงตัวจึงเป็นไปไม่ได้เสมอ)
และช่วงว่างถือว่าเคลียร์แล้ว ได้ 0 แต้ม
ทีนี้มาหาสมการ ในช่วง [i..j] ที่ต้องกวาดให้เกลี้ยง เล่ม i ซึ่งเป็นเล่มบนสุดต้องหลุดออกไปกับการหยิบสักครั้งหนึ่ง
การหยิบครั้งนั้นประกอบด้วยเล่ม i เล่ม x และเล่ม y โดยที่ i < x < y
เล่ม i กับ x เป็นบวก เล่ม y เป็นลบ
กติกา "ต้องติดกันตอนหยิบ" บังคับโครงสร้างที่เหลือทั้งหมดให้เราเลย ของที่แทรกอยู่ระหว่างสามเล่มนี้ต้องหายไปก่อน ช่วงจึงถูกซอยเป็นสามท่อนย่อยที่ต้องเคลียร์เกลี้ยงเหมือนกัน และแต่ละท่อนก็เป็นปัญหาเดิมที่เล็กลง
เขียนเป็นสมการได้ตรง ๆ แบบนี้
ลองนับงานดู สถานะมี n² ช่อง และการหาค่าของแต่ละช่องต้องไล่คู่ (x, y) ซึ่งมีอยู่ n² คู่
รวมเป็น O(n⁴) ซึ่งแตะไม่ได้เลยที่ n เท่ากับ 2,000
ท่าที่ช่วยได้คือแยกการเลือก x ออกจากการเลือก y เพราะสองตัวนี้ไม่ได้พันกัน สร้างตารางช่วยอีกใบชื่อ t[i][y]
แปลว่า "ให้เล่ม i กับเล่ม x สักตัวเป็นบวก แล้วเคลียร์ของแทรกจนพร้อมจะไปเจอเล่ม y ได้แต้มสูงสุดเท่าไหร่"
พอมี t แล้ว ตัวจริงก็เหลือไล่แค่ตัวเดียวคือ y
ทั้งสองสมการมี n² ช่อง ช่องละงาน n ครั้ง จึงเหลือ O(n³) ดีขึ้นมากแต่ยังไม่พอ
f ตอบได้แค่กรณี "กวาดช่วงนี้เกลี้ยง" แต่โจทย์อนุญาตให้เหลือหนังสือคาไว้ (อย่างกอง D ในเกมข้างบนที่ต้องยอมทิ้งไว้หนึ่งเล่ม)
จึงต้องมี DP อีกชั้นมาเลือกว่าจะกวาดช่วงไหนบ้าง โดยช่วงที่เลือกห้ามซ้อนกัน ให้ g[k] คือแต้มสูงสุดเมื่อดูหนังสือ k เล่มแรก
ก่อนอ่านสมการนี้ ต้องรู้ก่อนว่าสองตารางนับของไม่เหมือนกัน g นับเป็นจำนวนเล่ม
ส่วน f นับเป็นตำแหน่งเล่ม ที่เริ่มจาก 0 พอ g[i] แปลว่าจัดการเล่มแรก i เล่มเสร็จแล้ว
เล่มถัดไปที่ยังไม่ถูกแตะจึงอยู่ที่ตำแหน่ง i พอดี เลข i ตัวเดียวเลยทำสองหน้าที่พร้อมกัน
คือเป็นทั้งจำนวนเล่มที่จบไปแล้ว และตำแหน่งเริ่มของช่วงใหม่ ส่วนปลายช่วงคือเล่มที่ k ซึ่งอยู่ตำแหน่ง k-1
ช่วงที่กำลังจะกวาดจึงเขียนว่า f[i][k-1]
g[k-1] คือปล่อยเล่มที่ k คาไว้บนชั้น ไม่เอามาคิดแต้มเลย
แต้มจึงเท่ากับของเล่มแรก k-1 เล่มเป๊ะ ๆ กิ่งนี้ใช้ได้ทุกกรณีโดยไม่มีเงื่อนไข
และมันเป็นกิ่งเดียวที่ทำให้ "หยุดกลางทาง" เกิดขึ้นได้ ถ้าตัดกิ่งนี้ออก ทุกเล่มจะถูกบังคับให้ไปอยู่ในช่วงที่กวาดเกลี้ยงช่วงใดช่วงหนึ่ง
ซึ่งเข้มกว่าที่โจทย์ขอไว้
g[i] + f[i][k-1] คือปิดท้ายด้วยการกวาดช่วง [i .. k-1] ให้เกลี้ยง
เล่มแรก i เล่มคิดจบไปแล้วใน g[i] ที่บวกเข้ามาจึงเป็นแต้มของช่วงใหม่ล้วน ๆ ซึ่ง f ตอบไว้ให้แล้ว
ช่วงใหม่จ่อกับของเก่าพอดี ไม่มีเล่มไหนถูกนับสองครั้งและไม่มีเล่มไหนหลุดหาย
กิ่งนี้มีอยู่เฉพาะช่วงที่กวาดเกลี้ยงได้จริง ช่วงที่ทำไม่ได้ f จะคืนค่าว่าเป็นไปไม่ได้ แล้วกิ่งนี้ก็หายไปเอง
(โค้ดในเฉลยข้างล่างเช็คตรงนี้ด้วย v != NEG)
เพราะกิ่งแรกไม่มีเงื่อนไข g[k] จึงมีค่าเสมอ ไม่มีช่องไหนตอบว่าทำไม่ได้
และคำตอบของโจทย์คือ g[n] ที่อ่านว่า "ดูหนังสือครบทุกเล่มแล้ว" ไม่ได้อ่านว่า "กวาดหนังสือออกครบทุกเล่มแล้ว"
ชั้นนี้เพิ่มงานมาอีก n² ครั้ง ซึ่งไม่ใช่ตัวปัญหา ตัวปัญหาคือ f กับ t ที่อยู่ข้างล่าง
เอาขอบเขตจริงของโจทย์มาแทน แล้วเทียบกับเฉลยที่เดินตารางเดียวแบบข้างบน
| วิธี | งานที่ต้องทำ | หน่วยความจำ |
|---|---|---|
DP บนช่วง (f กับ t) | 8,000,000,000 ครั้ง O(n³) | 64,000,000 ไบต์ สองตาราง O(n²) ช่องละ 8 ไบต์ |
| DP เชิงเส้น (เฉลยของข้อนี้) | 4,000,000 ครั้ง O(n²) | 32,016 ไบต์ สองแถว O(n) ช่องละ 8 ไบต์ |
long long ที่โค้ดทั้งสองฝั่งในบทความนี้ใช้จริง
ตาราง f ใบเดียวจึงเป็น 4,000,000 ช่อง เท่ากับ 32,000,000 ไบต์ ซึ่งเกินลิมิต 16 เมกะไบต์ไปเท่าตัวตั้งแต่ยังไม่นับ t
และต่อให้ใจดีกับมันที่สุดคือบีบทั้งสองตารางลงเป็น int 4 ไบต์ (แต้มสูงสุดที่โจทย์นี้เป็นไปได้อยู่ราว 1.3 พันล้าน ยังไม่ล้น int)
f ใบเดียวก็ยังกิน 16,000,000 ไบต์ คือชนเพดาน 16 เมกะไบต์พอดีเป๊ะโดยไม่เหลือที่ให้ t เลย
ส่วนฝั่งเวลา 8,000,000,000 ครั้งใน 1 วินาทีก็เกินกำลังเครื่องอยู่ดี
เทียบสองวิธีตรงจุดเดียวก็เห็นความต่างชัด DP บนช่วงจำว่าคู่หูอยู่ตำแหน่งไหน มันเก็บทั้ง i และ j ไว้เพื่อจะได้รู้ว่าใครจะมาชนกับใคร
ส่วนDP เชิงเส้นจำแค่ว่ามีบวกค้างอยู่กี่เล่ม ไม่สนใจเลยว่าบวกพวกนั้นนอนอยู่ตรงไหนของกอง
การกล้าลืมตำแหน่งนั้นถูกต้องเพราะโครงสร้างที่พิสูจน์ไว้ในเฉลยตอนที่ 3 การจับคู่ของการหยิบต้องซ้อนกันเป็นชั้น ไขว้กันไม่ได้ เมื่อไขว้ไม่ได้ ลบตัวถัดไปก็ต้องไปปิดจ๊อบกับบวกสองตัวล่าสุดเสมอ เราจึงไม่ต้องจำชื่อของมัน จำแค่จำนวนก็พอ มิติของตารางเลยยุบจากสองตำแหน่งเหลือตำแหน่งเดียวบวกตัวนับหนึ่งตัว และหน่วยความจำยุบจาก 32,000,000 ไบต์เหลือ 32,016 ไบต์ (หน่วยเดียวกัน คือช่องละ 8 ไบต์)
เวลาติดโจทย์ DP แล้วรู้สึกว่าตารางใหญ่เกินไป คำถามที่คุ้มที่สุดคือ "ข้อมูลที่ผมเก็บอยู่ตอนนี้ มีอะไรที่ผมไม่เคยเอามาใช้ตัดสินใจเลยบ้าง" อะไรที่ตอบไม่ได้ว่าเก็บไว้ทำอะไร มักจะโยนทิ้งได้ และการโยนทิ้งครั้งเดียวมักลดทั้งเวลาและหน่วยความจำไปพร้อมกัน
เคล็ดลับที่ใช้ได้กับข้ออื่นด้วย
ลิมิตหน่วยความจำแปลก ๆ ในโจทย์แข่งมักเป็นคำใบ้ ไม่ใช่ความหยาบคายของผู้ออกโจทย์ ลองเอาขอบเขตของ n มายกกำลังสองคูณขนาดตัวแปรดูก่อนเลย
ถ้ามันชนลิมิตพอดี แปลว่าผู้ออกโจทย์ตั้งใจกันวิธีที่ใช้ตารางสองมิติออกไป
เลิกถามว่า "หยิบตรงไหนก่อน" แล้วถามว่า "เล่มไหนได้บวก เล่มไหนได้ลบ" ทั้งข้อจะยุบเหลือกฎเดียว คือตัวลบต้องมีบวกค้างรออยู่สองเล่ม และสิ่งที่ต้องจำก็เหลือแค่ตัวเลขตัวเดียวว่าตอนนี้ค้างอยู่กี่เล่ม
โจทย์ต้นฉบับเป็นภาษาไทย ผมเรียบเรียงคำอธิบายใหม่ด้วยคำตัวเอง ส่วนวิธีคิดและโค้ดเป็นของผมเอง ตรวจกับตัวอย่างของโจทย์และการสุ่มเทียบแล้ว
ในหน้านี้