ปูพื้นฐาน

เร่งดีพีบนช่วงแบบคะนูธ: จุดตัดที่ดีที่สุดเดินไปทางเดียว จึงไม่ต้องส่องซ้ำ

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

บทปูพื้นฐาน ★★★☆☆ dpinterval dpพื้นฐาน อ่าน 22 นาที 10 กันยายน 2026

ปัญหาที่บทนี้แก้

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

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

ไม้ยาว 10 หน่วย 4 5 7 8 0 10 ตาที่ 1 จ่าย 10 ตาที่ 2 จ่าย 6 ตาที่ 3 จ่าย 3 ตาที่ 4 จ่าย 3 รวม 22
ไม้ยาว 10 หน่วย มีรอยกา 4 รอย ตารางข้างล่างคือลำดับการตัดที่ถูกที่สุดลำดับหนึ่ง แต่ละแถวบอกว่ารอบนั้นตัดที่ไหน ตอนนั้นชิ้นที่ถือยาวเท่าไร และจ่ายเท่าไร

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

ตรงนี้ C(i, j) คือค่าใช้จ่ายที่ต้องจ่ายเมื่อผ่าช่วง [i..j] หนึ่งครั้ง ซึ่งในโจทย์ตัดไม้คือความยาวรวมของช่วงนั้น ส่วน k คือรอยที่เลือกผ่า


สูตรที่เขียนได้ กับตัวเลขที่ทำให้ตกรอบ

สูตรข้างบนมีช่องให้เติม n² ช่อง แต่ละช่องไล่ k ได้ถึง n จุด รวมเป็น O(n³) ซึ่งไหวถ้า n อยู่ระดับไม่กี่ร้อย พอโจทย์ให้ n เป็นหลักพัน มันก็ตกเวลาไปเฉย ๆ

วัดหัวต่อหัว · g++ 11.4 ตัวเดียวกัน คอมไพล์ด้วย -O2
n ไล่จุดตัดทุกจุด ใส่หน้าต่างของ Knuth ต่างกัน
1,000 0.384 วินาที 0.018 วินาที 21 เท่า
3,000 32.750 วินาที 0.454 วินาที 72 เท่า
สองแถวนี้ไม่ใช่การประมาณ ผมคอมไพล์ทั้งสองตัวแล้วรันอินพุตชุดเดียวกันจริง ทั้งคู่ตอบเลขเดียวกันทุกครั้ง ต่างกันแค่ขอบเขตของลูปในสุด และช่องว่างนี้ถ่างขึ้นเรื่อย ๆ ตาม n

ชื่อของท่าที่จะแก้เรื่องนี้

ท่านี้ชื่อ Knuth optimization ตั้งตามชื่อ Donald Knuth อ่านว่า "โดนัลด์ คะนูธ" นักวิทยาการคอมพิวเตอร์ที่เขียนชุดหนังสือ The Art of Computer Programming เขาใช้ท่านี้ครั้งแรกในปี 1971 กับโจทย์สร้างต้นไม้ค้นหาที่ดีที่สุด ซึ่งเป็นโจทย์ฝึกข้อสุดท้ายของบทนี้พอดี ส่วนคำว่า optimization อ่านว่า "ออปทิไมเซชัน" แปลว่า "การทำให้ดีที่สุด" ในที่นี้หมายถึงการเร่งความเร็ว ไม่ได้แปลว่าคำตอบดีขึ้น เพราะคำตอบเท่าเดิมเป๊ะ

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


ลองเอง · เลือกลำดับตัดไม้

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

ไม้ยาว 100 มีรอยกาสามรอยที่แบ่งเท่า ๆ กัน ชุดนี้ตัดยังไงก็ได้เท่ากันหลายทาง ใช้ชินกับกติกาก่อน

ไม้ยาว 100 หน่วย มีรอยกา 3 รอย ต้องตัดให้ครบทุกรอย

คลิกที่รอยกาสีทองเพื่อตัดตรงนั้น

ยังไม่ได้ตัดเลย จ่ายไป 0

ตัวเลขบนรอยที่ตัดแล้วคือลำดับที่ตัด ตัวเลขใต้ชิ้นคือความยาวของชิ้นนั้นตอนนี้

ชุดที่สามมีรอยกาสามรอยกระจุกอยู่ทางซ้าย ถ้าไล่ตัดเรียงจากรอยซ้ายสุดไปขวาสุด จะได้ 118 ขณะที่คำตอบจริงคือ 70 เพราะทุกครั้งที่ตัดรอยทางซ้าย ชิ้นที่ถืออยู่ยังยาวเกือบเต็มไม้

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


ของชิ้นเดียวที่เปลี่ยนทุกอย่าง คือจุดตัดที่ดีที่สุด

ตั้งชื่อให้มันก่อน เรียกว่า opt(i, j)

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

ทีนี้ลองพิมพ์ opt ของทุกช่องออกมาดู นี่คือของจริงจากไม้ยาว 10 ข้างบน แถวคือขอบซ้ายของช่วง คอลัมน์คือขอบขวา

OPT ของทุกช่วง · ไม้ยาว 10
i \ j 12345
1 11111
2 ·2223
3 ··333
4 ···44
5 ····5
อ่านจากซ้ายไปขวาในแถวเดียวกัน ตัวเลขไม่เคยลดลง และอ่านจากบนลงล่างในคอลัมน์เดียวกัน ตัวเลขก็ไม่เคยลดลงเหมือนกัน สองข้อสังเกตนี้คือทั้งหมดของท่านี้

เขียนสองข้อสังเกตนั้นเป็นบรรทัดเดียวได้แบบนี้

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

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

ตาราง opt · แถว i คือขอบซ้าย คอลัมน์ j คือขอบขวา j=1 j=2 j=3 j=4 j=5 i=1 i=2 i=3 i=4 i=5 1 1 1 1 1 2 2 2 3 3 3 3 4 4 5 ขอบซ้าย ขอบขวา จุดตัดที่ต้องส่องของช่อง [1..4] k=1 k=2 k=3 เขียว 2 ช่อง จากทั้งหมด 3 ช่อง
ช่องเป้าหมายคือ [1..4] ถ้าไม่รู้อะไรเลยต้องส่องจุดตัด 3 จุด แต่เพื่อนบ้านซ้ายกับล่างบอกขอบมาแล้ว จึงเหลือ 2 จุด แถบข้างล่างคือช่วงที่ต้องส่องจริง

ทำไมแคบลงนิดเดียวแล้วถึงยุบได้ทั้งอันดับ

ตรงนี้เป็นสะพานที่สองที่ตำรามักข้าม เพราะดูจากภาพข้างบน มันแคบลงแค่ไม่กี่ช่อง แล้วจะยุบจาก O(n³) เป็น O(n²) ได้ยังไง

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

ช่วงยาว 5 ช่อง ทุกช่องในเส้นทแยงเดียวกัน k=1 k=2 k=3 k=4 k=5 k=6 k=7 k=8 k=9 [1..5] 2 [2..6] 3 [3..7] 1 [4..8] 2 [5..9] 1 รวมงานจริง 9 ครั้ง จากที่ถ้าไล่เต็มจะเป็น 20 ครั้ง
เส้นทแยงของช่วงยาว 5 ช่อง บนไม้อีกต้นที่มี 9 ช่อง แถบเขียวคือจุดตัดที่ต้องส่องจริงของแต่ละช่อง สังเกตว่าขอบซ้ายกับขอบขวาของแถบไม่เคยถอยกลับ ผลรวมความยาวของทุกแถบจึงมีเพดานเท่าความกว้างของทั้งเส้นบวกจำนวนแถบ

พอขอบทั้งสองข้างเดินทางเดียว ผลรวมความยาวของแถบทั้งเส้นก็มีเพดานง่าย ๆ คือ ระยะที่ขอบขวาเดินได้ทั้งหมด บวกจำนวนแถบ เพราะแถบหนึ่งกินอย่างน้อยหนึ่งช่องเสมอ ในภาพข้างบนคือ 5 บวก 5 เท่ากับ 10 ซึ่งมากกว่างานจริง 9 ครั้งอยู่ ทั้งเส้นทแยงจึงเป็นงาน O(n) ไม่ใช่ O(n²) และมีเส้นทแยงทั้งหมด n เส้น รวมเป็น O(n²)

วิธีนับแบบนี้มีชื่อ และเคยเจอมาแล้ว

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


เดินให้ดูทีละขั้น

นี่คือการเติมตารางจริงของไม้ยาว 10 ต้นเดิม เดินไล่ตามความยาวช่วงจากสั้นไปยาว ช่องเขียวคือช่องที่กำลังเติม ช่องเหลืองคือเพื่อนบ้านสองช่องที่บอกขอบมาให้

เติมตาราง dp ไล่ตามความยาวช่วง

DP · ไม้ยาว 10
i \ j 12345
1 0 · · · ·
2 0 · · ·
3 0 · ·
4 0 ·
5 0

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


เงื่อนไขที่ต้องจริงก่อน ถึงจะใช้ท่านี้ได้

ข้ออ้างเรื่อง opt ไม่ถูกต้องฟรี ๆ มันต้องการเงื่อนไขสองข้อกับฟังก์ชันค่าใช้จ่าย C ทั้งสองข้อพูดถึงช่วงสี่ตำแหน่งที่เรียงกันคือ a ถึง b ถึง c ถึง d

ข้อแรกคือ ค่าใช้จ่ายต้องโตตามช่วง ช่วงที่อยู่ข้างในต้องไม่แพงกว่าช่วงที่ครอบมันอยู่

ข้อสองชื่อ อสมการสี่เหลี่ยม (quadrangle inequality อ่านว่า "ควอดแรงเกิล อินอิควอลิตี" แปลตรงตัวว่า "อสมการสี่เหลี่ยม" ตั้งชื่อตามการที่มันพูดถึงสี่จุดพร้อมกัน)

อสมการข้อสองนี้อ่านยากถ้าดูแค่ตัวอักษร ลองอ่านเป็นภาษาคน ฝั่งซ้ายคือช่วงคู่หนึ่งที่ เหลื่อมกัน คือ [a..c] กับ [b..d] ส่วนฝั่งขวาคือช่วงคู่หนึ่งที่ ซ้อนกัน คือ [a..d] ที่ครอบ [b..c] ไว้ทั้งอัน ทั้งสองคู่กินตำแหน่งรวมกันเท่ากันเป๊ะ อสมการจึงบอกว่า แบบซ้อนกันแพงกว่าหรือเท่ากับแบบเหลื่อมกันเสมอ พูดอีกทางคือค่าใช้จ่ายลงโทษการมีช่วงยาว ๆ มากกว่าลงโทษการมีช่วงกลาง ๆ สองช่วง

ท่าที่ผมใช้จริงเวลาแข่ง

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

ดูบทพิสูจน์ว่าทำไมสองเงื่อนไขนั้นทำให้ opt ไม่ถอยหลัง

ส่วนนี้ไม่ต้องอ่านก็เขียนโค้ดได้ แต่ถ้าอยากรู้ว่าข้ออ้างมาจากไหน มันเดินสองก้าว ก้าวแรกยกสมบัติจาก C ขึ้นไปให้ dp ก้าวที่สองใช้สมบัติของ dp มาบีบ opt

ก้าวที่ 1 · dp เองก็เข้าอสมการสี่เหลี่ยมด้วย

ข้อความที่ต้องพิสูจน์คือ ถ้า C เข้าทั้งสองเงื่อนไข แล้วสำหรับ a ถึง b ถึง c ถึง d ที่เรียงกัน

พิสูจน์ด้วยอุปนัยเชิงเข้ม (strong induction อ่านว่า "สตรอง อินดักชัน" แปลว่า "การอุปนัยที่ยืมผลของทุกกรณีที่เล็กกว่า ไม่ใช่แค่กรณีก่อนหน้าอันเดียว") บนความยาวช่วง แล้วแยกเป็นสองกรณี

กรณี b = c ให้ z เป็นจุดตัดที่ดีที่สุดของช่วง [a..d] ถ้า z น้อยกว่า b ก็ใช้ได้ว่า dp(a,b) ไม่แพ้การตัดที่ z คือ dp(a,b) ≤ dp(a,z) + dp(z+1,b) + C(a,b) เอามาประกบกับสมมติฐานอุปนัยที่บอกว่า dp(z+1,b) + dp(b,d) ≤ dp(z+1,d) และเงื่อนไขข้อแรกที่บอกว่า C(a,b) ≤ C(a,d) ก็ได้ผลที่ต้องการ ส่วนกรณี z ≥ b ทำแบบเดียวกันโดยกลับข้าง

กรณี b น้อยกว่า c รูปเหมือนกัน แต่ต้องแตกเป็นหลายกรณีย่อยตามว่า opt(b,c) อยู่ก่อนหรือหลัง opt(a,d) แล้วใช้อสมการสี่เหลี่ยมของ C กับสมมติฐานอุปนัยบนช่วงดัชนีที่เหมาะกับกรณีนั้น ตรงนี้ผมยกมาเป็นโครง ไม่ได้กางทุกกรณีย่อย ถ้าอยากได้ครบไปดูที่ต้นทางหรือที่บทความของ Yao ในรายการอ้างอิงท้ายหน้า

ก้าวที่ 2 · จาก dp ไปหา opt

ตั้งชื่อค่าที่ได้เมื่อบังคับให้ตัดที่ k ว่า dp_k(i, j)

เอาอสมการของก้าวที่ 1 มาใช้กับสี่จุด p+1 ถึง q+1 ถึง j-1 ถึง j โดยที่ p ไม่เกิน q จะได้

ย้ายข้างแล้วบวก dp(i,p) - dp(i,q) เข้าไปทั้งสองฝั่ง ส่วน C(i, ·) ตัดกันเองเพราะเหมือนกันทั้งสองฝั่งอยู่แล้ว ผลคือ

อ่านบรรทัดนี้เป็นภาษาคน ให้ p เป็นจุดตัดที่อยู่ซ้ายกว่า q ถ้าที่ช่วงสั้น [i..j-1] ตัวขวา q ชนะ p อยู่แล้ว (ฝั่งซ้ายเป็นบวก) ฝั่งขวาก็ต้องเป็นบวกด้วย แปลว่าที่ช่วงยาว [i..j] ตัวขวา q ก็ยังชนะอยู่เหมือนเดิม

พูดกลับกันคือ จุดตัดที่แพ้ไปแล้วตอนช่วงสั้น ไม่มีวันกลับมาชนะตอนช่วงยาว จุดที่ชนะจึงเลื่อนไปทางขวาหรืออยู่ที่เดิมเท่านั้น ซึ่งก็คือ opt(i, j-1) ≤ opt(i, j) ส่วนอีกครึ่งคือ opt(i, j) ≤ opt(i+1, j) พิสูจน์ด้วยรูปเดียวกันเป๊ะ เปลี่ยนจากขยับขอบขวาเป็นขยับขอบซ้ายแทน

ก้าวที่ 3 · ทำไมรวมทั้งตารางแล้วเป็น O(n²)

ในเนื้อหาข้างบนผมนับทีละเส้นทแยง เพราะมันเห็นภาพกว่า ส่วนต้นทางนับทั้งตารางทีเดียว ด้วยผลรวมที่หักล้างกันเอง คืองานทั้งหมดมีเพดานเท่ากับ

พจน์ส่วนใหญ่หักล้างกันหมด เหลือแค่พจน์บวกที่ j เป็นตัวขวาสุด กับพจน์ลบที่ i เป็นตัวซ้ายสุด ซึ่งรวมกันแล้วเป็น O(n²) สองวิธีนับให้ผลเท่ากัน ต่างกันแค่ว่าจะจับกลุ่มพจน์ยังไง


ถ้าฝืนใช้กับค่าใช้จ่ายที่ไม่เข้าเงื่อนไข จะพังยังไง

ตรงนี้สำคัญกว่าที่คิด เพราะเวลาข้ออ้างไม่จริง โปรแกรมไม่พังและไม่ฟ้องอะไรเลย มันแค่ตอบค่าที่มากกว่าคำตอบจริงอย่างเงียบ ๆ เฉพาะบางอินพุต

ตัวค้านที่เล็กที่สุดที่ผมหาเจอมีแค่ 4 ช่อง และค่าใช้จ่ายเป็นศูนย์ทุกช่องยกเว้นสองช่อง คือ C(1,2) = 1 กับ C(2,4) = 1 ที่เหลือเป็นศูนย์หมด ไล่จุดตัดทุกจุดได้คำตอบ 0 แต่ใส่หน้าต่างของ Knuth แล้วได้ 1

OPT ของตัวค้าน
i \ j 1234
1 1113
2 ·222
3 ··33
4 ···4
ช่องที่หักท่านี้คือ [1..4] ซึ่งเพื่อนบ้านซ้ายให้ขอบ 1 เพื่อนบ้านล่างให้ขอบ 2 แต่จุดตัดที่ดีที่สุดจริงคือ 3 ซึ่งหลุดออกนอกหน้าต่างไป โปรแกรมจึงไม่มีวันมองเห็นมัน

ค่าใช้จ่ายชุดนี้พังทั้งสองเงื่อนไข อสมการสี่เหลี่ยมพังที่ a=1 b=2 c=2 d=3 และเงื่อนไขโตตามช่วงพังที่ a=1 b=1 c=2 d=3 ส่วนค่าใช้จ่ายแบบผลรวมของช่วง ผมไล่ตรวจทุกสี่จุดในไม้ตัวอย่างแล้ว ผ่านทั้งสองข้อ

เรื่องที่เกิดขึ้นจริงตอนหาตัวค้านตัวนี้

รอบแรกผมปล่อยให้ตัวสุ่มใส่ค่าลงช่องแนวทแยง C(i, i) ได้ด้วย มันหาตัวค้านเจอเร็วมาก แต่พอไปดูว่าอสมการพังที่คู่ไหน คำตอบคือคู่ที่ใช้ C(2, 2) ซึ่งเป็นช่องที่ดีพีไม่เคยอ่านเลย เพราะช่วงยาวหนึ่งช่องไม่มีอะไรให้ตัด

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


แม่แบบที่ลอกไปใช้ได้ทุกข้อ

เทียบกับดีพีบนช่วงธรรมดา โค้ดนี้ต่างกันแค่สามบรรทัด คือตาราง opt กับ lo และ hi

knuth.cpp
// แม่แบบ Knuth optimization สำหรับรูป dp[i][j] = min(dp[i][k] + dp[k+1][j]) + C(i, j)
// เปลี่ยนแค่ฟังก์ชัน C กับการอ่านอินพุต ที่เหลือลอกทั้งก้อน
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

int n;
ll C(int i, int j);              // ค่าใช้จ่ายของช่วง i..j

const ll INF = (ll)4e18;

ll solve() {
    vector<vector<ll>> dp(n + 2, vector<ll>(n + 2, 0));
    vector<vector<int>> opt(n + 2, vector<int>(n + 2, 0));

    // ช่วงยาวหนึ่งช่องไม่มีอะไรให้ตัด และเป็นที่ที่หน้าต่างเริ่มนับ
    for (int i = 1; i <= n; i++) { dp[i][i] = 0; opt[i][i] = i; }

    for (int len = 2; len <= n; len++) {
        for (int i = 1; i + len - 1 <= n; i++) {
            int j = i + len - 1;
            dp[i][j] = INF;
            // ขอบซ้ายมาจากช่องซ้ายมือ ขอบขวามาจากช่องข้างล่าง ทั้งคู่เติมไปแล้ว
            int lo = max(opt[i][j - 1], i);
            int hi = min(opt[i + 1][j], j - 1);
            for (int k = lo; k <= hi; k++) {
                ll cand = dp[i][k] + dp[k + 1][j] + C(i, j);
                if (cand < dp[i][j]) { dp[i][j] = cand; opt[i][j] = k; }
            }
        }
    }
    return n ? dp[1][n] : 0;
}

สามจุดที่ต้องระวังในแม่แบบนี้


โจทย์ฝึก · ไล่จากง่ายไปยาก

สามข้อนี้หยิบมาจากรายการอ้างอิงท้ายหน้า Knuth optimization ของ cp-algorithms เรียงให้ข้อแรกใช้ท่านี้ก็ได้ไม่ใช้ก็ได้ ข้อสองบังคับให้ใช้ และข้อสามเปลี่ยนหน้าตาของค่าใช้จ่าย

ฝึกข้อ 1 · ตัดไม้ (UVA 10003 Cutting Sticks)

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

อินพุต / ขอบเขต / เอาต์พุต

EXAMPLE
InputOutput
100
3
25 50 75
200

อ่านตัวอย่างนี้ยังไง

บรรทัดแรกคือความยาวไม้ทั้งท่อน คือ 100 หน่วย บรรทัดที่สองบอกว่ามีรอยกา 3 รอย บรรทัดที่สามคือตำแหน่งของรอยกาแต่ละรอย นับจากปลายซ้ายของไม้ ซึ่งคือ 25 และ 50 และ 75 เอาต์พุตเป็นเงินรวม ไม่ใช่จำนวนครั้งที่ตัดและไม่ใช่ความยาวชิ้นไหน ตรงนี้คือจุดที่คนอ่านผิดบ่อยที่สุด

ไม้ต้นนี้ถูกแบ่งเท่า ๆ กันพอดี การตัดครั้งแรกจึงต้องจ่ายเต็ม 100 เสมอไม่ว่าจะตัดรอยไหน หลังจากนั้นเหลือสองชิ้นที่ต้องตัดอีกชิ้นละครั้ง เฉพาะครั้งแรกมีจุดตัดที่ให้ค่าต่ำสุดอยู่ 1 แบบ ผลรวมจึงเป็น 100 บวก 50 บวก 50 เท่ากับ 200

ไม้ยาว 100 รอยกาที่ 25, 50, 75 25 50 75 ตาที่ 1 จ่าย 100 ตาที่ 2 จ่าย 50 ตาที่ 3 จ่าย 50 รวม 200
ลำดับการตัดที่ถูกที่สุดลำดับหนึ่งของตัวอย่างข้อที่ 1 ครั้งแรกจ่ายเต็มความยาวไม้ ครั้งที่สองกับสามจ่ายแค่ครึ่งเดียวเพราะไม้ถูกแยกไปแล้ว

ใบ้

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

เฉลยฝึกข้อ 1

ที่มาของแนวคิดนี้

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

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

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

แปลงรอยกาเป็นช่องว่าง ไม้ที่มี n รอยกาจะมี n+1 ช่อง แล้วนิยาม dp[i][j] ว่าเป็นค่าน้อยสุดที่ตัดช่วงช่อง [i..j] ให้แหลกหมด ค่าใช้จ่ายของการผ่าช่วงนี้หนึ่งครั้งคือความยาวรวมของช่วง ซึ่งเท่ากันไม่ว่าจะผ่าตรงไหน จึงดึงออกมานอกวงเล็บได้ตามสูตรที่เขียนไว้ข้างบน

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

โค้ดฝึกข้อ 1

ดูโค้ด C++
sticks.cpp
// Cutting Sticks: ไม้ยาว L มีรอยกา n รอย ตัดหนึ่งครั้งเสียเท่าความยาวชิ้นที่กำลังตัด
// อินพุต: L แล้ว n แล้วตำแหน่งรอยกา n ตัว จบไฟล์เมื่อ L = 0
// เอาต์พุต: The minimum cutting is <ค่าน้อยสุด>.
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

const ll INF = (ll)4e18;

int main() {
    ll L;
    while (scanf("%lld", &L) == 1 && L != 0) {
        int c; scanf("%d", &c);
        vector<ll> mark(c + 2);
        mark[0] = 0;
        for (int t = 1; t <= c; t++) scanf("%lld", &mark[t]);
        mark[c + 1] = L;

        // ช่องว่างระหว่างรอยกาที่ติดกัน หนึ่งช่องคือของหนึ่งชิ้นที่ห้ามตัดอีก
        int n = c + 1;
        vector<ll> pre(n + 1, 0);
        for (int i = 1; i <= n; i++) pre[i] = pre[i - 1] + (mark[i] - mark[i - 1]);
        auto C = [&](int i, int j) { return pre[j] - pre[i - 1]; };

        vector<vector<ll>> dp(n + 2, vector<ll>(n + 2, 0));
        vector<vector<int>> opt(n + 2, vector<int>(n + 2, 0));
        for (int i = 1; i <= n; i++) { dp[i][i] = 0; opt[i][i] = i; }

        for (int len = 2; len <= n; len++)
            for (int i = 1; i + len - 1 <= n; i++) {
                int j = i + len - 1;
                dp[i][j] = INF;
                int lo = max(opt[i][j - 1], i);
                int hi = min(opt[i + 1][j], j - 1);
                for (int k = lo; k <= hi; k++) {
                    ll cand = dp[i][k] + dp[k + 1][j] + C(i, j);
                    if (cand < dp[i][j]) { dp[i][j] = cand; opt[i][j] = k; }
                }
            }

        printf("The minimum cutting is %lld.\n", dp[1][n]);
    }
    return 0;
}

ตรวจกับไบนารีที่คอมไพล์จากโค้ดก้อนนี้เอง ไม่ใช่จากไฟล์ที่เขียนไว้ก่อนแล้วคัดลอกมา คือดึงโค้ดออกจากหน้าที่บิลด์เสร็จแล้ว คอมไพล์ด้วย g++ 11.4 แล้วป้อนไม้สุ่มขนาด 1 ถึง 9 ช่อง 4,000 รอบ เทียบกับตัวไล่จุดตัดทุกจุด ตรงกันทุกรอบ จากนั้นป้อนไม้สุ่มขนาด 1 ถึง 6 ช่องอีก 1,200 รอบ เทียบกับตัวไล่ ลำดับการตัดทุกแบบ ซึ่งเป็นตัวตรวจที่ไม่เชื่อแม้แต่ว่าโจทย์นี้เป็นดีพีบนช่วง ตรงกันทุกรอบเช่นกัน


ฝึกข้อ 2 · หักสตริง (SPOJ BRKSTRNG Breaking String)

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

อินพุต / ขอบเขต / เอาต์พุต

EXAMPLE
InputOutput
30 4
3 10 17 25
70

อ่านตัวอย่างนี้ยังไง

บรรทัดแรกคือความยาวสายทั้งเส้น 30 หน่วย ตามด้วยจำนวนตำแหน่งที่ต้องหัก 4 ตำแหน่ง บรรทัดที่สองคือตำแหน่งเหล่านั้น คือ 3, 10, 17, 25 เอาต์พุตเป็นค่าใช้จ่ายรวม ไม่ใช่จำนวนครั้งที่หัก ซึ่งรู้อยู่แล้วว่าเท่ากับจำนวนตำแหน่งพอดี

ตัวอย่างนี้เลือกมาให้ตำแหน่งไม่สม่ำเสมอ ถ้าไล่หักเรียงจากซ้ายไปขวาจะได้ 90 ส่วนลำดับที่ถูกที่สุดได้ 70 ต่างกัน 20 หน่วย ครั้งแรกมีตำแหน่งที่ให้ค่าต่ำสุดอยู่ 1 แบบ ถ้าลำดับที่คุณคิดต่างจากในภาพแต่รวมได้เท่ากัน ก็ถูกเหมือนกัน โจทย์ถามแค่ตัวเลข

สายยาว 30 ตำแหน่งที่ต้องหักคือ 3, 10, 17, 25 3 10 17 25 ตาที่ 1 จ่าย 30 ตาที่ 2 จ่าย 17 ตาที่ 3 จ่าย 10 ตาที่ 4 จ่าย 13 รวม 70
ลำดับการหักที่ถูกที่สุดของตัวอย่างข้อที่ 2 แถบเทาคือชิ้นที่ถืออยู่ในตานั้น เส้นเขียวคือตำแหน่งที่หัก สังเกตว่าตาแรกเลือกตำแหน่งที่แบ่งสายออกใกล้ครึ่ง เพื่อให้ตาถัดไปถือชิ้นสั้นลง

ใบ้

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

เฉลยฝึกข้อ 2

ที่มาของแนวคิดนี้

ข้อนี้ผมไม่ได้คิดอะไรใหม่เลย ผมยกโค้ดข้อที่แล้วมาแล้ววัดเวลาก่อน เพราะขอบเขตเป็นสิ่งเดียวที่เปลี่ยน ที่ n เท่ากับหนึ่งพัน การไล่จุดตัดทุกจุดใช้ 0.384 วินาที ต่อหนึ่งชุดทดสอบ ตัวเลขนี้เองที่ตัดสินทุกอย่าง เพราะโจทย์บอกว่ามีหลายชุดในไฟล์เดียว สิบชุดก็เกินสี่วินาทีแล้ว

พอใส่หน้าต่างของ Knuth ลงไป ตัวเลขเดียวกันนั้นเหลือ 0.018 วินาที และผมยังลองที่ n สามพันเพื่อดูว่าช่องว่างถ่างขึ้นจริงไหม ได้ 32.750 วินาทีเทียบกับ 0.454 วินาที คือต่างกัน 72 เท่า ซึ่งตรงกับที่ทฤษฎีบอก

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

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

โค้ดฝึกข้อ 2

ดูโค้ด C++
brkstrng.cpp
// Breaking String: เรื่องเดียวกับตัดไม้ แต่ n ถึงหนึ่งพันและมีหลายชุดทดสอบ
// จุดเดียวที่ต่างจากข้อแรกคือขอบเขต ซึ่งเป็นตัวบังคับว่าต้องใส่หน้าต่างของ Knuth
// อินพุต: L m แล้วตำแหน่งที่ต้องหัก m ตัว  เอาต์พุต: ค่าน้อยสุดของแต่ละชุด
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

const ll INF = (ll)4e18;

int main() {
    ll L; int m;
    while (scanf("%lld %d", &L, &m) == 2) {
        vector<ll> mark(m + 2);
        mark[0] = 0;
        for (int t = 1; t <= m; t++) scanf("%lld", &mark[t]);
        mark[m + 1] = L;

        int n = m + 1;
        vector<ll> pre(n + 1, 0);
        for (int i = 1; i <= n; i++) pre[i] = pre[i - 1] + (mark[i] - mark[i - 1]);
        auto C = [&](int i, int j) { return pre[j] - pre[i - 1]; };

        vector<vector<ll>> dp(n + 2, vector<ll>(n + 2, 0));
        vector<vector<int>> opt(n + 2, vector<int>(n + 2, 0));
        for (int i = 1; i <= n; i++) { dp[i][i] = 0; opt[i][i] = i; }

        for (int len = 2; len <= n; len++)
            for (int i = 1; i + len - 1 <= n; i++) {
                int j = i + len - 1;
                dp[i][j] = INF;
                int lo = max(opt[i][j - 1], i);
                int hi = min(opt[i + 1][j], j - 1);
                for (int k = lo; k <= hi; k++) {
                    ll cand = dp[i][k] + dp[k + 1][j] + C(i, j);
                    if (cand < dp[i][j]) { dp[i][j] = cand; opt[i][j] = k; }
                }
            }

        printf("%lld\n", n ? dp[1][n] : 0);
    }
    return 0;
}

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


ฝึกข้อ 3 · ต้นไม้ค้นหาที่ดีที่สุด (UVA 10304 Optimal Binary Search Tree)

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

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

อินพุต / ขอบเขต / เอาต์พุต

EXAMPLE
InputOutput
5
9 8 5 6 7
74

อ่านตัวอย่างนี้ยังไง

บรรทัดแรกบอกว่ามีของ 5 อย่าง บรรทัดที่สองคือความถี่ของแต่ละอย่าง ตามลำดับตัวอักษร คือ เกลือ 9, ข้าว 8, งา 5, น้ำตาล 6, พริก 7 ลำดับนี้ห้ามสลับ เพราะมันคือกติกาของต้นไม้ค้นหา เอาต์พุตเป็น จำนวนครั้งที่ต้องถามรวม ไม่ใช่ความสูงของต้นไม้และไม่ใช่ชื่อของราก

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

ต้นไม้ในภาพมีรากคือ ข้าว ซึ่งความถี่ 8 อยู่ชั้นที่ 1 จึงคิดเป็น 8 ครั้ง เอาทุกปมมาบวกกันแบบนี้ได้ 74

ข้าว 8 x 1 = 8 เกลือ 9 x 2 = 18 น้ำตาล 6 x 2 = 12 งา 5 x 3 = 15 พริก 7 x 3 = 21 รวม 74
ต้นไม้ค้นหาที่ดีที่สุดของตัวอย่างนี้ ตัวเลขบนกล่องคือความถี่ ตัวเลขข้างกล่องคือชั้น ผลรวมของ ความถี่ คูณ ชั้น เท่ากับ 74

ใบ้

ถามตัวเองว่า ใครเป็นราก ของช่วง [i..j] พอเลือกรากได้ ของที่อยู่ก่อนหน้ามัน กลายเป็นต้นไม้ย่อยฝั่งซ้าย ของที่อยู่หลังกลายเป็นฝั่งขวา และทุกปมในสองฝั่งนั้น ลึกลงไปอีกหนึ่งชั้นพร้อมกันหมด คำถามคือ การลึกลงหนึ่งชั้นพร้อมกันหมด ทำให้ค่ารวมเพิ่มขึ้นเท่าไร แล้วตัวเลขนั้นขึ้นกับว่าเลือกรากตัวไหนหรือเปล่า

เฉลยฝึกข้อ 3

ที่มาของแนวคิดนี้

ข้อนี้เป็นข้อที่ Knuth ใช้ท่านี้ครั้งแรกจริง ๆ ผมจึงเดาไว้ตั้งแต่ต้นว่าต้องเข้าเงื่อนไข แต่สิ่งที่ต้องคิดจริงคือหน้าตาของค่าใช้จ่าย ซึ่งไม่เหมือนสองข้อแรก

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

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

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

นิยาม g[i][j] ว่าเป็นค่าน้อยสุดของช่วง [i..j] เมื่อรากของช่วงนั้น นับเป็นชั้นที่ 1 จะได้สูตรนี้

ตรงนี้ S(i, j) คือผลรวมความถี่ของช่วง ต่างจากแม่แบบสองอย่างคือ k เป็นรากซึ่งถูกดึงออกจากทั้งสองข้าง จึงเป็น g[i][k-1] กับ g[k+1][j] และ k เดินได้ถึง j ไม่ใช่ j-1 เพราะรากเป็นตัวขวาสุดของช่วงก็ได้ ขอบขวาของหน้าต่างจึงต้องหนีบด้วย j ไม่ใช่ j-1 ซึ่งเป็นบรรทัดเดียวที่ต่างจากแม่แบบ

โค้ดฝึกข้อ 3

ดูโค้ด C++
obst.cpp
// Optimal Binary Search Tree: ของ n อย่างเรียงตามชื่ออยู่แล้ว แต่ละอย่างมีความถี่ f[i]
// จัดเป็นต้นไม้ค้นหา ให้ผลรวมของ ความถี่ คูณ ชั้นที่มันอยู่ น้อยที่สุด (รากคือชั้นที่ 1)
// รูปนี้ต่างจากแม่แบบนิดเดียว คือ k เป็น "ราก" ซึ่งถูกดึงออกจากทั้งสองข้าง
// จึงเป็น g[i][k-1] กับ g[k+1][j] และ k เดินได้ถึง j ไม่ใช่ j-1
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

const ll INF = (ll)4e18;

int main() {
    int n;
    while (scanf("%d", &n) == 1 && n > 0) {
        vector<ll> f(n + 1, 0), pre(n + 1, 0);
        for (int i = 1; i <= n; i++) { scanf("%lld", &f[i]); pre[i] = pre[i - 1] + f[i]; }
        auto S = [&](int i, int j) { return i > j ? 0LL : pre[j] - pre[i - 1]; };

        vector<vector<ll>> g(n + 3, vector<ll>(n + 3, 0));
        vector<vector<int>> opt(n + 3, vector<int>(n + 3, 0));
        for (int i = 1; i <= n; i++) { g[i][i] = f[i]; opt[i][i] = i; }

        for (int len = 2; len <= n; len++)
            for (int i = 1; i + len - 1 <= n; i++) {
                int j = i + len - 1;
                g[i][j] = INF;
                int lo = max(opt[i][j - 1], i);
                int hi = min(opt[i + 1][j], j);      // ถึง j เพราะรากเป็นตัวขวาสุดได้
                for (int k = lo; k <= hi; k++) {
                    ll cand = (k > i ? g[i][k - 1] : 0) + (k < j ? g[k + 1][j] : 0) + S(i, j);
                    if (cand < g[i][j]) { g[i][j] = cand; opt[i][j] = k; }
                }
            }

        printf("%lld\n", n ? g[1][n] : 0);
    }
    return 0;
}

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


ตัวตรวจที่ควรเขียนคู่กันทุกครั้ง

นี่คือกรณีที่ตัวตรวจสำคัญที่สุดเท่าที่มีในคลังนี้ เพราะข้ออ้างของ Knuth เป็นข้ออ้างเชิงทฤษฎีที่ ถ้าไม่จริง โค้ดจะไม่ฟ้องอะไรเลย ไม่มี segmentation fault ไม่มีลูปไม่รู้จบ มีแต่ตัวเลขที่ใหญ่กว่าคำตอบจริงนิดหน่อยในบางอินพุต

ดูโค้ดตัวตรวจสองชั้น
checker.cpp
// ตัวตรวจอิสระ: ไล่จุดตัดทุกจุดจริง ไม่ใช้ข้ออ้างเรื่อง opt เลยแม้แต่นิดเดียว
// นี่คือ O(n^3) ซึ่งเป็นสิ่งที่ Knuth พยายามหลบ จึงเป็นการตรวจที่อิสระจริง
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const ll INF = (ll)4e18;

int n; vector<ll> pre;
ll C(int i, int j) { return pre[j] - pre[i - 1]; }

ll cubic() {
    vector<vector<ll>> dp(n + 2, vector<ll>(n + 2, 0));
    for (int len = 2; len <= n; len++)
        for (int i = 1; i + len - 1 <= n; i++) {
            int j = i + len - 1;
            dp[i][j] = INF;
            for (int k = i; k < j; k++)
                dp[i][j] = min(dp[i][j], dp[i][k] + dp[k + 1][j] + C(i, j));
        }
    return n ? dp[1][n] : 0;
}

// ตัวตรวจชั้นที่สอง: ไล่ "ลำดับการตัด" ทุกแบบ ไม่ใช้ดีพีเลย
// ชั้นนี้สำคัญกว่า เพราะมันไม่ได้เชื่อแม้แต่ว่าโจทย์นี้เป็นดีพีบนช่วง
map<vector<pair<int,int>>, ll> memo;
ll byOrder(vector<pair<int,int>> st) {
    vector<pair<int,int>> live;
    for (auto &p : st) if (p.second > p.first) live.push_back(p);
    if (live.empty()) return 0;
    sort(live.begin(), live.end());
    auto it = memo.find(live);
    if (it != memo.end()) return it->second;
    ll best = INF;
    for (size_t idx = 0; idx < live.size(); idx++) {
        auto [l, r] = live[idx];
        ll price = C(l, r);
        for (int c = l; c < r; c++) {
            vector<pair<int,int>> ns;
            for (size_t t = 0; t < live.size(); t++) if (t != idx) ns.push_back(live[t]);
            ns.push_back({l, c}); ns.push_back({c + 1, r});
            best = min(best, price + byOrder(ns));
        }
    }
    memo[live] = best;
    return best;
}

int main() {
    mt19937 rng(12345);
    for (int t = 0; t < 4000; t++) {
        n = 1 + rng() % 9;
        pre.assign(n + 1, 0);
        for (int i = 1; i <= n; i++) pre[i] = pre[i - 1] + (1 + rng() % 12);
        ll a = cubic();
        if (n <= 6) { memo.clear(); if (byOrder({{1, n}}) != a) { puts("ORDER MISMATCH"); return 1; } }
        // เอา a ไปเทียบกับผลของโค้ด Knuth ที่กำลังจะส่ง
    }
    puts("OK");
    return 0;
}

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

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

ถ้าดีพีบนช่วงของคุณมีรูป dp[i][j] = min(dp[i][k] + dp[k+1][j]) + C(i, j) และ C เป็นผลรวมของค่าที่ไม่ติดลบในช่วง ให้เก็บตาราง opt เพิ่มอีกใบ แล้วเปลี่ยนลูปในสุดจาก i ถึง j-1 เป็น opt[i][j-1] ถึง opt[i+1][j] เท่านี้ O(n³) ก็ยุบเป็น O(n²) โดยคำตอบเท่าเดิม

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

ที่มาและอ่านต่อ

  1. cp-algorithms, "Knuth's Optimization" ต้นทางของบทนี้ ทั้งเงื่อนไขสองข้อ อสมการเรื่อง opt และรายการโจทย์ฝึกท้ายหน้า cp-algorithms.com
  2. F. F. Yao, "Efficient Dynamic Programming Using Quadrangle Inequalities" (1980) งานที่พิสูจน์ว่าอสมการสี่เหลี่ยมทำให้ opt ขยับทางเดียว ซึ่งเป็นฐานของทั้งบท
  3. D. E. Knuth, "Optimum binary search trees" (1971) ที่มาของชื่อท่า และของโจทย์ฝึกข้อที่ 3
  4. UVA 10003 Cutting Sticks, SPOJ BRKSTRNG Breaking String, UVA 10304 Optimal Binary Search Tree
  5. ในคลังนี้: DP บนช่วง คือบทที่ควรอ่านก่อน และ แบ่งครึ่งเร่งดีพี คือท่าพี่น้องที่ใช้กับดีพีแบบมีชั้น