programming.in.th

หยิบหนังสือ: เมื่อสูตรตอบไม่ได้ ให้เปลี่ยนคำถาม อย่าเปลี่ยนสูตร

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

★★★☆☆ dpstack อ่าน 7 นาที 26 สิงหาคม 2026

โจทย์ · หยิบหนังสือ

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

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

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

โจทย์กำหนด · อินพุต / ขอบเขต / เอาต์พุต

EXAMPLE
InputOutput
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 เล่มหลุดออกไป กองมันปิดช่องเอง เล่มที่เมื่อกี้อยู่คนละมุมของกองเลื่อนมาชนกันได้ กลายเป็นสามเล่มติดกันชุดใหม่ที่ตอนเริ่มไม่มีอยู่จริง

หยิบหนึ่งครั้ง แล้วกองปิดช่องเอง กองตอนเริ่ม เล่ม 11 เล่ม 22 เล่ม 33 เล่ม 44 เล่ม 55 เล่ม 66 เล่ม 77 หยิบ 3 เล่มติดกัน 2 + 3 − 4 = +1 สามเล่มที่หยิบ 1 บวก2 บวก3 ลบ4 5 6 7 กองปิดช่อง กองหลังหยิบ เล่ม 11 เล่ม 55 เล่ม 66 เล่ม 77 เล่ม 1 กับ 5 มาติดกันแล้ว (ตอนเริ่มห่างกันสามเล่ม)
หยิบเล่มที่ 2, 3, 4 ออก ได้ 2 + 3 − 4 = 1 แต้ม ผลข้างเคียงที่สำคัญกว่าแต้มคือ เล่ม 1 กับเล่ม 5 ที่ไม่เคยอยู่ติดกัน กลายเป็นเพื่อนบ้านกันทันที (วาดประกอบโดยผู้เขียน)

คำใบ้

ลองนึกถึงตัวอย่างชุดที่ 1 (เลข 1 ถึง 7 ตอบ 5) ถ้าหยิบสามเล่มล่างสุด 5, 6, 7 จะได้ 4 แต้ม ซึ่งเป็นการหยิบครั้งเดียวที่ให้แต้มสูงสุดในกองนี้ แต่คำตอบของโจทย์คือ 5 แปลว่ามีทางที่ดีกว่าการรีบคว้าก้อนใหญ่ คำถามที่ควรค้างอยู่ในหัวตอนนี้คือ การหยิบครั้งแรกที่ดูแล้วไม่คุ้ม มันไปเปิดทางให้ครั้งต่อไปยังไง


ลองเอง · กองหนังสือจำลอง

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

    แต้มสะสม0

    ดีที่สุดที่ทำได้0

    หยิบไปแล้ว0

    คลิกเล่มบนสุดของสามเล่มที่อยากหยิบ

    กอง A คือกองที่หลอกคนเล่นได้เจ็บที่สุด กอง B กับ C คือตัวอย่างชุดที่ 1 และ 2 ของโจทย์ ส่วนกอง D เป็นกองที่ผมเตรียมไว้ให้ลองมืออีกกอง แล้วเดี๋ยวมันจะกลับมาให้เห็นอีกครั้งในเฉลย

    ลองเล่นให้หนำใจก่อนนะครับ พอมีคำตอบในใจแล้วค่อยเปิดเฉลย


    เฉลย ตอนที่ 1 · ทำไมการรีบคว้าก้อนใหญ่ถึงแพ้

    กอง 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

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

    เฉลย ตอนที่ 2 · เปลี่ยนคำถามจาก "หยิบตรงไหน" เป็น "ใครได้บวก ใครได้ลบ"

    สองทางที่ผมลองก่อน แล้วทิ้ง

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

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

    จุดที่ทำให้พลิก คือเลข 16 เมกะไบต์นั่นเอง โจทย์ที่ n แค่ 2,000 ไม่มีเหตุผลจะตั้งลิมิตหน่วยความจำต่ำขนาดนั้น นอกจากจะกำลังบอกว่าตารางสองมิติที่ยาวด้านละ n ไม่ใช่คำตอบ พอยอมรับข้อนั้น คำถามก็เปลี่ยนจาก "จะจำช่วงยังไงให้ประหยัด" เป็น "มีวิธีมองอื่นที่ไม่ต้องจำช่วงเลยไหม"

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

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

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

    มองเป็นเครื่องหมาย แทนที่จะมองเป็นลำดับการหยิบ กอง A: 9, 2, 9, 2, 9, 1 (ซ้ายคือบนสุดของกอง) + + + − + − 9 2 9 2 9 1 เล่ม 1 เล่ม 2 เล่ม 3 เล่ม 4 เล่ม 5 เล่ม 6 หยิบครั้งที่ 1 · 2 + 9 − 2 = 9 หยิบครั้งที่ 2 · 9 + 9 − 1 = 17 รวม 26 แต้ม
    กอง A กับคำตอบ 26 แต้ม สังเกตว่าเส้นของการหยิบสองครั้งซ้อนกันสนิท ไขว้กันไม่ได้เลย เพราะกว่าเล่ม 1 จะได้เจอเล่ม 5 เล่มที่ขวางอยู่ต้องออกไปก่อนแล้ว (วาดประกอบโดยผู้เขียน)

    เฉลย ตอนที่ 3 · กฎของเครื่องหมายที่ทำได้จริง

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

    แกะคำศัพท์

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

    เดินจากเล่มบนสุดลงล่างทีละเล่ม แล้วตัดสินใจว่าเล่มนี้จะเป็นอะไร

    • ให้เป็นบวก: ผลักมันเข้าสแต็ก มันกลายเป็น "บวกที่ยังรอคู่" เพิ่มอีกหนึ่งเล่ม
    • ให้เป็นลบ: ทำได้ก็ต่อเมื่อในสแต็กมีบวกรออยู่อย่างน้อย 2 เล่ม มันจะกินสองเล่มบนสุดออกไป ปิดจ๊อบเป็นการหยิบหนึ่งครั้ง
    • ไม่หยิบเลย: ทำได้ก็ต่อเมื่อสแต็กว่าง ถ้ายังมีบวกค้างอยู่ แปลว่ามีเล่มข้างบนที่รอจับคู่ข้ามหัวเล่มนี้ลงมา ซึ่งจะข้ามได้ก็ต่อเมื่อเล่มนี้ถูกหยิบออกไปแล้ว
    • ตอนจบ: สแต็กต้องว่าง ถ้ามีบวกค้างแปลว่ามีเล่มที่เราตั้งใจให้บวก แต่ไม่มีการหยิบครั้งไหนมารับมันจริง
    เดินทีละเล่ม แล้วดูแค่ "ตอนนี้มีบวกค้างกี่เล่ม" เริ่ม ค้าง 0 แต้ม 0 เล่ม 9 · บวก 9 ค้าง 1 แต้ม 0 เล่ม 2 · บวก 9 2 ค้าง 2 แต้ม 0 เล่ม 9 · บวก 9 2 9 ค้าง 3 แต้ม 0 เล่ม 2 · ลบ กินสองเล่มบน 9 2 กับ 9 ออก ค้าง 1 แต้ม 9 เล่ม 9 · บวก 9 9 ค้าง 2 แต้ม 9 เล่ม 1 · ลบ ออกหมด ค้าง 0 แต้ม 26
    เดินทีละเล่มพร้อมสแต็กในมือ ความสูงของสแต็กคือจำนวนบวกที่ยังรอคู่ ทั้งเรื่องเหลือแค่ตัวเลขตัวเดียวนี้ ซึ่งคือสิ่งที่ทำให้ข้อนี้ยุบลงเป็นตารางเล็ก ๆ ได้ (วาดประกอบโดยผู้เขียน)
    ทำไมกฎสแต็กถึงตรงกับการหยิบจริงเป๊ะ ๆ (สำหรับคนอยากได้เหตุผลเต็ม)

    ทางไป (การหยิบจริงต้องเข้ากฎนี้): ตอนที่หยิบสามเล่ม p, q, r ออกพร้อมกัน ทั้งสามต้องติดกัน ณ ตอนนั้น แปลว่าทุกเล่มที่เคยขวางระหว่าง p กับ r ถูกหยิบออกไปครบแล้ว กลุ่มการหยิบจึงซ้อนกันเป็นชั้น ๆ ไขว้กันไม่ได้ ซึ่งพอไล่จากบนลงล่างก็คือกฎสแต็กพอดี

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

    เฉลย ตอนที่ 4 · เหลือแค่ตารางเดียว

    พอเรื่องทั้งหมดเหลือแค่ "เดินจากบนลงล่าง แล้วจำว่ามีบวกค้างกี่เล่ม" สิ่งที่ต้องจำก็มีแค่สองตัว คือดูมาถึงเล่มที่เท่าไหร่ กับค้างอยู่กี่เล่ม ที่เหลือเป็น 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 ส่วนคอลัมน์อื่นไม่มีท่าข้าม

    RECURRENCE · dp[i][p] มาจากไหนได้บ้าง
    ให้เล่มที่ 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 คือไปไม่ถึง กดปุ่มเดินทีละขั้นได้ ตารางจะค่อย ๆ เติมทีละช่อง พร้อมบอกว่าเลขนั้นมาจากช่องไหนและทำไมถึงเลือกค่านี้

    DP TABLE · กอง 7 3 1 2 9 9 7
    แถว p = 0p = 1p = 2p = 3p = 4p = 5p = 6p = 7
    i = 0 (ยังไม่เริ่ม) 0·······
    i = 1 · a[1] = 7 07······
    i = 2 · a[2] = 3 0310·····
    i = 3 · a[3] = 1 91411····
    i = 4 · a[4] = 2 9113613···
    i = 5 · a[5] = 9 91820121522··
    i = 6 · a[6] = 9 11182729212431·
    i = 7 · a[7] = 7 2022253436283138

    สีเหลืองคือช่องที่กำลังเติม สีฟ้าคือช่องต้นทางที่มันหยิบค่ามาใช้ ขั้นสุดท้ายจะไฮไลต์เส้นทางที่ให้คำตอบทั้งเส้น

    ตารางนี้คือ dp[i][p] ทั้งใบ และมันเดินตามสมการแบบดึงในตาราง RECURRENCE ข้างบนตรง ๆ คือไล่ทีละช่องปลายทาง แล้วถามว่าช่องนั้นรับค่ามาจากช่องไหนในแถวก่อนหน้า จึงเห็นช่องต้นทางสีฟ้าอยู่ในแถวบนเสมอ

    แล้วโค้ดจริงเห็นอะไร · cur กับ nxt ทีละขั้น

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

    สองแถวที่เครื่องถือไว้ · กอง 7 3 1 2 9 9 7
    แถว p = 0p = 1p = 2p = 3p = 4p = 5p = 6p = 7
    cur ········
    nxt ········

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

    ของแถม: เขียนกลับด้านเป็นท่า "ผลัก" ก็ได้คำตอบเดียวกัน

    ทั้งตารางและโค้ดข้างบนเขียนสมการแบบดึง (pull) คือยืนที่ช่องปลายทางแล้วถามว่า "ฉันรับค่ามาจากช่องไหนได้บ้าง" อีกท่าที่เจอบ่อยพอกันคือผลัก (push) คือยืนที่ช่องต้นทางแล้วถามว่า "ค่าของฉันส่งต่อไปช่องไหนได้บ้าง" สองท่านี้ให้คำตอบเท่ากันเสมอ เพราะมันคือลูกศรเส้นเดียวกัน แค่อ่านจากคนละปลาย

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

    อ่านแบบดึง (ท่าที่ใช้ในบทความ) อ่านแบบผลัก (ท่ากลับด้าน) dp[i] q-1 0 q+2 dp[i+1] q ยืนที่ช่อง q แล้วไล่ลูกศรที่ชี้เข้ามา dp[i+1][q] = max(...) เขียนช่องปลายทางครั้งเดียวจบ cur p nxt p-2 0 p+1 ยืนที่ช่อง p แล้วไล่ลูกศรที่ออกไป nxt[?] = max(nxt[?], cur[p] ...) ช่องปลายทางถูกเขียนหลายครั้ง จึงต้องใช้ max
    ลูกศรสามเส้นเดิม อ่านจากปลายทาง (ดึง) หรืออ่านจากต้นทาง (ผลัก) ก็ได้ลูกศรชุดเดียวกัน ต่างกันแค่ว่าเราวนที่ช่องไหนเป็นตัวตั้ง (วาดประกอบโดยผู้เขียน)

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

    PULL vs PUSH · ลูกศรเส้นเดียวกัน
    ลูกศรเส้นที่อ่านแบบดึง (ปลายทางชื่อ q)อ่านแบบผลัก (ต้นทางชื่อ p)ชื่อผูกกันด้วย
    เล่มนี้เป็นบวก dp[i+1][q] ← dp[i][q−1] + a nxt[p+1] ← cur[p] + a q = p + 1
    เล่มนี้เป็นลบ dp[i+1][q] ← dp[i][q+2] − a nxt[p−2] ← cur[p] − a q = p − 2
    ไม่หยิบเล่มนี้ dp[i+1][0] ← dp[i][0] nxt[0] ← cur[0] q = p = 0
    แทน q = p + 1 ลงในบรรทัดแรกของฝั่งดึง จะได้ dp[i+1][p+1] ← dp[i][p] + a ซึ่งคือบรรทัดของฝั่งผลักเป๊ะ ๆ อีกสองบรรทัดก็แทนแบบเดียวกัน
    ท่ากลับด้าน ให้คำตอบเดียวกัน
    // ท่ากลับด้าน ยืนที่ช่องต้นทาง cur[p] แล้วส่งค่าออกไปสามช่อง
    // ต้อง fill(nxt, NEG) ก่อนเข้าลูปนี้ เพราะช่องปลายทางถูกเขียนหลายครั้ง
    long long v = cur[p];
    if (v == NEG) continue;
    if (p == 0)   nxt[0]     = max(nxt[0],     v);        // ไม่หยิบเล่มนี้เลย
    nxt[p + 1]               = max(nxt[p + 1], v + a[i]); // ให้เป็นบวก
    if (p >= 2)   nxt[p - 2] = max(nxt[p - 2], v - a[i]); // ให้เป็นลบ

    แล้วทำไมบทความนี้เลือกฝั่งดึง

    เหตุผลข้อแรกคือตาราง RECURRENCE กับโค้ดจะเป็นประโยคเดียวกัน อ่านสลับกันได้โดยไม่ต้องแปลในหัว เหตุผลข้อสองคือฝั่งดึงเขียนช่องปลายทางครั้งเดียวจบ จึงไม่ต้องห่อด้วย max กับค่าเดิมของตัวเอง และไม่ต้องมีจังหวะล้างแถว ราคาที่จ่ายคือต้องเช็คขอบตอนอ่าน ว่า q−1 ไม่ติดลบและ q+2 ไม่ล้นอาร์เรย์ ส่วนฝั่งผลักกลับกัน คืออ่านสบายแต่เขียนหลายครั้ง จึงต้องล้างแถวก่อนและห่อด้วย max ทุกบรรทัด

    เส้นทางที่ได้คือ 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 ให้ทุกช่อง โปรแกรมจะเชื่อว่าตัวเองยืนอยู่ในสถานะที่ไปไม่ได้จริง แล้วตอบเกินความจริง

    โค้ด C++

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

    ดูโค้ดเฉลย C++ (เวลา O(n²) หน่วยความจำ O(n))
    solution.cpp
    #include <bits/stdc++.h>
    using namespace std;
    
    int main() {
        int n;
        if (scanf("%d", &n) != 1) return 0;
        vector<int> a(n);
        for (int i = 0; i < n; i++) scanf("%d", &a[i]);
    
        // cur[p] = แต้มมากสุด เมื่อดูหนังสือไปแล้ว i เล่ม และยังมี "บวกค้าง" อยู่ p เล่ม
        // NEG = สถานะที่ไปไม่ถึง (ใช้ 0 ไม่ได้ เพราะ 0 เป็นแต้มที่ถูกต้องได้)
        const long long NEG = LLONG_MIN / 4;
        vector<long long> cur(n + 1, NEG), nxt(n + 1, NEG);
        cur[0] = 0;
    
        for (int i = 0; i < n; i++) {
            // ยืนที่ช่องปลายทาง nxt[q] แล้วถามว่ามันรับค่ามาจากช่องไหนของ cur ได้บ้าง
            for (int q = 0; q <= n; 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;   // เขียนทับทุกช่องของแถว จึงไม่ต้องล้างแถวก่อน
            }
            swap(cur, nxt);
        }
    
        printf("%lld\n", cur[0]);            // จบแบบไม่มีบวกค้าง = ทุกครั้งที่หยิบสมบูรณ์
        return 0;
    }

    swap(cur, nxt) ท้ายลูปคือหัวใจของการประหยัดหน่วยความจำ เราไม่เคยเก็บตารางทั้งใบ เก็บแค่แถวที่กำลังใช้ เพราะแถวใหม่ขึ้นกับแถวก่อนหน้าเท่านั้น และ swap ของ vector คือการสลับตัวชี้ ไม่ได้ก็อปข้อมูล จึงเร็วคงที่

    ดูโค้ดตัวตรวจสอบแบบซื่อ ๆ (ใช้สุ่มเทียบก่อนส่ง)
    brute.cpp
    // ตัวตรวจสอบแบบซื่อ ๆ ลองหยิบทุกวิธีที่เป็นไปได้ ใช้ได้แค่กองเล็ก ๆ
    // เอาไว้สุ่มเทียบกับโค้ดจริงก่อนส่ง ไม่ใช่โค้ดที่ส่งเข้าระบบตัดสิน
    #include <bits/stdc++.h>
    using namespace std;
    
    long long best(vector<int> a) {
        long long r = 0;                     // 0 = เลือกที่จะหยุดหยิบตรงนี้
        for (size_t i = 0; i + 2 < a.size(); i++) {
            vector<int> b = a;
            long long gain = (long long)b[i] + b[i + 1] - b[i + 2];
            b.erase(b.begin() + i, b.begin() + i + 3);
            r = max(r, gain + best(b));
        }
        return r;
    }

    ท่านี้เป็นนิสัยที่คุ้มที่สุดของการเขียนโปรแกรมแข่ง เขียนตัวช้าที่มั่นใจว่าถูก แล้วสุ่มกองเล็ก ๆ (เช่น 1 ถึง 10 เล่ม เลข 0 ถึง 15) ยิงเทียบกับโค้ดจริงสักสองสามร้อยรอบ ความมั่นใจที่ได้ต่างจาก "อ่านโค้ดแล้วรู้สึกว่าถูก" คนละเรื่อง เฉลยข้างบนผ่านทั้งตัวอย่างสามชุดของโจทย์ และการสุ่มเทียบ 400 รอบ


    ทางที่คนคิดถึงก่อน · DP บนช่วง และเหตุผลของลิมิต 16 เมกะไบต์

    ตอนแรกผมคิดว่าเลข 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 เป็นลบ

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

    การหยิบหนึ่งครั้งซอยช่วงออกเป็นสามท่อนย่อย i + ของแทรกท่อนที่ 1 x + ของแทรกท่อนที่ 2 y − ของแทรกท่อนที่ 3 f[i+1][x-1] f[x+1][y-1] f[y+1][j] แต้มของช่วงนี้ = a[i] + a[x] − a[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] จึงมีค่าเสมอ ไม่มีช่องไหนตอบว่าทำไม่ได้ และคำตอบของโจทย์คือ g[n] ที่อ่านว่า "ดูหนังสือครบทุกเล่มแล้ว" ไม่ได้อ่านว่า "กวาดหนังสือออกครบทุกเล่มแล้ว"

    0 7 1 3 2 1 3 2 4 9 5 9 6 7 f[0][2] = 9 f[4][6] = 11 คาไว้
    กอง D จากเกมข้างบน กับคำตอบ 20 แต้ม สองช่วงสีเขียวคือกิ่งที่สองทำงานสองครั้ง 9 แต้ม กับ 11 แต้ม ส่วนเล่มสีเทาคือกิ่งแรกทำงานหนึ่งครั้ง ปล่อยมันคาไว้เพราะความยาวที่เหลือหารสามไม่ลงตัว สังเกตว่าเล่มที่ถูกทิ้งไม่จำเป็นต้องอยู่ริม มันคาอยู่กลางแถวได้ (วาดประกอบโดยผู้เขียน)

    ชั้นนี้เพิ่มงานมาอีก n² ครั้ง ซึ่งไม่ใช่ตัวปัญหา ตัวปัญหาคือ f กับ t ที่อยู่ข้างล่าง

    บิลค่าใช้จ่าย ตอนที่ทำให้รู้ว่าลิมิตคือคำใบ้

    เอาขอบเขตจริงของโจทย์มาแทน แล้วเทียบกับเฉลยที่เดินตารางเดียวแบบข้างบน

    COST · ที่ n = 2,000
    วิธีงานที่ต้องทำหน่วยความจำ
    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 ไบต์
    ทั้งสองแถวในตารางนี้นับด้วยหน่วยเดียวกันคือ ช่องละ 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 บนช่วง (ทางที่ไปไม่รอด แต่ถูกต้อง)
    interval.cpp
    // DP บนช่วง: ทางที่คนคิดถึงก่อน ถูกต้องแต่แพงเกินลิมิตของโจทย์ข้อนี้
    // f[i][j] = แต้มสูงสุดถ้า "เก็บกวาดช่วง [i..j] ออกให้หมดทั้งช่วง"
    // t[i][y] = ตัวช่วย: เลือกบวกสองเล่มคือ i กับ x แล้วเคลียร์ของแทรกจนพร้อมจะเจอ y
    #include <bits/stdc++.h>
    using namespace std;
    
    int main() {
        int n; scanf("%d", &n);
        vector<int> a(n);
        for (int i = 0; i < n; i++) scanf("%d", &a[i]);
    
        const long long NEG = LLONG_MIN / 4;
        vector<vector<long long>> f(n + 1, vector<long long>(n + 1, NEG));
        vector<vector<long long>> t(n + 1, vector<long long>(n + 1, NEG));
        // ช่วงว่างถือว่าเคลียร์แล้ว ได้ 0 แต้ม
        auto F = [&](int i, int j) -> long long {
            if (i > j) return 0;
            if (i < 0 || j >= n) return NEG;
            return f[i][j];
        };
    
        for (int len = 3; len <= n; len++) {
            for (int i = 0; i + len - 1 < n; i++) {
                int j = i + len - 1;
                // เติม t[i][y] ที่ยังไม่เคยคิด (y = j) ค่าที่ y < j คิดไปแล้วในรอบก่อน
                for (int y = i + 2; y <= j; y++) {
                    if (t[i][y] != NEG) continue;
                    long long best = NEG;
                    for (int x = i + 1; x < y; x++) {
                        long long l = F(i + 1, x - 1), m = F(x + 1, y - 1);
                        if (l == NEG || m == NEG) continue;
                        best = max(best, (long long)a[i] + a[x] + l + m);
                    }
                    t[i][y] = best;
                }
                long long best = NEG;
                for (int y = i + 2; y <= j; y++) {
                    long long rest = F(y + 1, j);
                    if (t[i][y] == NEG || rest == NEG) continue;
                    best = max(best, t[i][y] - a[y] + rest);
                }
                f[i][j] = best;
            }
        }
    
        // หยุดหยิบกลางทางได้ จึงต้องต่ออีกชั้นว่าจะเอาช่วงไหนมาต่อกันบ้าง
        vector<long long> g(n + 1, 0);
        for (int k = 1; k <= n; k++) {
            g[k] = g[k - 1];
            for (int i = 0; i < k; i++) {
                long long v = F(i, k - 1);
                if (v != NEG) g[k] = max(g[k], g[i] + v);
            }
        }
        printf("%lld\n", g[n]);
        return 0;
    }

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

    บทเรียนที่ได้ · state ที่ดีคือ state ที่กล้าลืม

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

    การกล้าลืมตำแหน่งนั้นถูกต้องเพราะโครงสร้างที่พิสูจน์ไว้ในเฉลยตอนที่ 3 การจับคู่ของการหยิบต้องซ้อนกันเป็นชั้น ไขว้กันไม่ได้ เมื่อไขว้ไม่ได้ ลบตัวถัดไปก็ต้องไปปิดจ๊อบกับบวกสองตัวล่าสุดเสมอ เราจึงไม่ต้องจำชื่อของมัน จำแค่จำนวนก็พอ มิติของตารางเลยยุบจากสองตำแหน่งเหลือตำแหน่งเดียวบวกตัวนับหนึ่งตัว และหน่วยความจำยุบจาก 32,000,000 ไบต์เหลือ 32,016 ไบต์ (หน่วยเดียวกัน คือช่องละ 8 ไบต์)

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

    เคล็ดลับที่ใช้ได้กับข้ออื่นด้วย

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

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

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

    แหล่งที่มา

    โจทย์ต้นฉบับเป็นภาษาไทย ผมเรียบเรียงคำอธิบายใหม่ด้วยคำตัวเอง ส่วนวิธีคิดและโค้ดเป็นของผมเอง ตรวจกับตัวอย่างของโจทย์และการสุ่มเทียบแล้ว

    1. โจทย์ "หยิบหนังสือ" จากคลังโจทย์ programming.in.th ผู้แต่งโจทย์ วรภัทร จรางกูล (สืบค้น 26 สิงหาคม 2026)
    2. โจทย์ต้นทางระบุว่าดัดแปลงจากข้อสอบพี่ช่วยน้อง โรงเรียนมหิดลวิทยานุสรณ์ พ.ศ. 2552
    3. ที่มาของชื่อ dynamic programming จากคำบอกเล่าของ Richard Bellman ในอัตชีวประวัติ Eye of the Hurricane: An Autobiography (1984)