ปูพื้นฐาน

DP บนช่วง: จุดที่ต้องเลิกถามว่าทำอะไรที่ตำแหน่งนี้ แล้วหันไปถามทั้งช่วง

วิธีอ่านโจทย์ให้เจอสัญญาณว่า dp[i] ที่ใช้ได้มาตลอดกำลังจะพัง แล้วพาเปลี่ยน state จากจุดเป็นช่วง พร้อมเงื่อนไข "ตรงกลางต้องเกลี้ยง" ที่ทำให้ของคนละมุมมาเจอกันได้ พร้อมโจทย์ฝึก 3 ข้อที่ไล่ระดับกัน

บทปูพื้นฐาน ★★☆☆☆ dpinterval dpพื้นฐาน อ่าน 22 นาที 27 สิงหาคม 2026

อาการ · โจทย์ที่ทำให้ dp[i] พังทั้งใบ

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

โจทย์ประจำบท · เก็บจานบนโต๊ะยาว

บนโต๊ะมีจาน n ใบวางเรียงกันเป็นแถว ใบที่ i มีขนาด a[i] นิ้ว คุณหยิบจาน สองใบที่อยู่ติดกัน ซ้อนกันแล้วยกเข้าตู้ได้ ถ้าขนาดต่างกันไม่เกิน 1 นิ้ว พอยกออกไป จานที่อยู่สองข้างของช่องว่างจะเลื่อนมาชิดกัน แล้วนับเป็นคู่ติดกันได้ทันที ทำซ้ำกี่ครั้งก็ได้ คำถามคือสุดท้ายเหลือจานบนโต๊ะน้อยที่สุดกี่ใบ

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

แต่คำตอบจริงคือ เหลือ 1 ใบ ทางที่ถูกคืออย่าเพิ่งแตะคู่แรก ยก 2 กับ 3 ตรงกลางออกก่อน โต๊ะจะเหลือ 1 1 3 ซึ่งทำให้จานใบที่ 1 กับใบที่ 4 ที่เดิมไม่เคยอยู่ติดกันเลย มาชนกันพอดี ยกออกอีกคู่ เหลือแค่ใบ 3 ใบเดียว

ทางโลภ · เก็บคู่ซ้ายสุดก่อน 1 2 3 1 3 3 1 3 ตัน เหลือ 3 ใบ ทางที่ถูก · ข้ามคู่แรก ไปเก็บตรงกลางก่อน 1 2 3 1 3 1 1 3 ยกได้อีกคู่ เหลือ 1 ใบ สองใบนี้เดิมอยู่คนละฝั่งของโต๊ะ ไม่เคยติดกันมาก่อน
ท่าโลภเก็บคู่ซ้ายสุดก่อน แล้วจานที่เหลือกลายเป็นเศษที่จับคู่กันไม่ได้ ส่วนทางที่ถูกยอมข้ามคู่แรกไป เพื่อเปิดทางให้จานคนละมุมได้มาเจอกัน การตัดสินใจที่ตำแหน่งหนึ่งจึงไปเปลี่ยนว่าใครติดกับใครที่อีกฝั่งของแถว อดีตกับอนาคตจึงคุยกันข้ามหัวเรา ซึ่งเป็นสิ่งที่ dp[i] แบบไล่ซ้ายไปขวาไม่มีที่ให้เก็บ (วาดประกอบโดยผู้เขียน)

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

แกะคำศัพท์

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


ลองเอง · เก็บโต๊ะให้เหลือน้อยที่สุด

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

    เหลือบนโต๊ะ0

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

    ยกไปแล้ว0

    กดจานใบหนึ่งเพื่อเลือก

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

    ลองให้หนำใจก่อนนะครับ พอเริ่มรู้สึกว่า "ต้องมองไปข้างหน้าหลายตา" แล้วค่อยอ่านต่อ


    ทางออก · เปลี่ยน state จากจุดเป็นช่วง

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

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

    แกะคำศัพท์

    interval อ่านว่า "อินเทอร์วัล" แปลว่า "ช่วง" มาจากภาษาละติน inter ที่แปลว่า "ระหว่าง" บวก vallum ที่แปลว่า "กำแพงค่าย" รวมกันแล้วแปลตรงตัวว่า "พื้นที่ระหว่างกำแพงสองด้าน" ซึ่งตรงกับที่เราจะใช้มันพอดี คือของที่อยู่ระหว่างเสาสองต้นชื่อ i กับ j เทคนิคทั้งบทนี้จึงเรียกกันว่า DP บนช่วง หรือ interval DP

    คำถามเดียวที่ต้องตอบให้ได้: จานซ้ายสุดจะไปคู่กับใคร

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

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

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

    เขียนสองทางนั้นเป็นสมการได้แบบนี้ โดยที่ k ไล่ทุกตำแหน่งทางขวาที่เข้าเงื่อนไข

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

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

    RECURRENCE · keep[i][j] มาจากไหนได้บ้าง
    ชะตาของจานใบที่ iค่าที่ได้ใช้ท่านี้ได้เมื่อ
    ไม่ถูกยก ค้างบนโต๊ะจนจบ keep[i+1][j] ใช้ได้เสมอ
    ถูกยกคู่กับใบที่ k (k−i+1) + keep[k+1][j] |a[i] − a[k]| ≤ 1 และช่วง [i+1..k−1] ยกออกได้เกลี้ยง
    ที่บรรทัดล่างได้ k−i+1 ใบ ไม่ใช่แค่ 2 ใบ เพราะมันนับรวมของตรงกลางที่ถูกยกออกไปจนเกลี้ยงแล้วด้วย ตัวเลขนี้จึงเท่ากับความยาวของช่วง [i..k] พอดี

    เคล็ดลับ: "เกลี้ยงไหม" ไม่ต้องมีตารางของตัวเอง

    หลายคนสร้างตารางที่สองชื่อ full[i][j] เก็บว่าช่วงนั้นยกออกได้หมดไหม ซึ่งก็ถูก แต่ในโจทย์ที่เป้าหมายคือยกออกให้ได้มากที่สุด เราได้ของชิ้นนั้นมาฟรีอยู่แล้ว เพราะ "ยกออกได้เกลี้ยง" ก็คือ keep[i][j] เท่ากับความยาวของช่วงพอดี เก็บตารางเดียวจึงพอ ระวังไว้อย่างเดียวคือถ้าวันไหนเป้าหมายเปลี่ยนเป็นแต้มสูงสุด แทนที่จะเป็นจำนวนชิ้น ทางลัดนี้จะใช้ไม่ได้ทันที เพราะทางที่ให้แต้มสูงสุดไม่จำเป็นต้องเป็นทางที่ยกออกได้เยอะสุด (เดี๋ยวเจอของจริงในโจทย์ฝึกข้อ 3)

    ทำไมต้องวนตามความยาว ไม่ใช่วนตามตำแหน่ง

    สังเกตว่าช่องหนึ่ง ๆ ไปขอค่าจากช่อง [i+1..j], [i+1..k−1] และ [k+1..j] ซึ่งสั้นกว่าตัวมันเองเสมอ ถ้าเราวนตามตำแหน่ง i จากซ้ายไปขวาแบบที่ชินมือ เราจะไปขอค่าจากช่องที่ยังว่างอยู่ ทางที่ถูกจึงเป็นการไล่ตามความยาวช่วง ทำช่วงยาว 2 ให้ครบทุกตำแหน่งก่อน แล้วค่อยขยับไปยาว 3 ยาว 4 ไปเรื่อย ๆ ตอนถึงช่วงยาว L ทุกช่วงที่สั้นกว่า L เสร็จหมดแล้วแน่นอน

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

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


    เดินตารางให้ดูจริง ทีละช่อง

    เอาแถว 1 2 3 1 3 เมื่อกี้มาเติมตารางจริง แถวของตารางคือ i (ปลายซ้ายของช่วง) คอลัมน์คือ j (ปลายขวา) ช่องที่ i มากกว่า j ไม่มีความหมาย เลยเว้นว่างไว้ กดเดินทีละขั้นได้ ตารางจะค่อย ๆ เต็มจากเส้นทแยงมุมออกไปทางขวาบน พร้อมบอกว่าช่องนั้นเลือกทางไหนและทำไม

    KEEP TABLE · แถวจาน 1 2 3 1 3
    ช่วง j = 0ขนาด 1j = 1ขนาด 2j = 2ขนาด 3j = 3ขนาด 1j = 4ขนาด 3
    i = 0 · ขนาด 1
    i = 1 · ขนาด 2
    i = 2 · ขนาด 3
    i = 3 · ขนาด 1
    i = 4 · ขนาด 3

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

    จุดที่น่าดูที่สุดคือตอนเติมช่อง keep[0][3] ซึ่งเป็นช่วง 1 2 3 1 ที่นั่นจานใบแรกเลือกจับคู่กับใบที่ 4 ได้ เพราะช่วงตรงกลาง [1..2] มีค่าในตารางเป็น 2 ซึ่งเท่ากับความยาวของมันพอดี แปลว่าเกลี้ยง นี่คือช่องที่เก็บ "ความรู้" ว่าตรงกลางหายหมดแล้ว ซึ่งเป็นความรู้ที่ dp[i] ไม่มีที่เก็บ


    เขียนเป็นโค้ด

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

    เก็บจานบนโต๊ะ · DP บนช่วง
    #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]);
    
        // keep[i][j] = ยกออกได้มากที่สุดกี่ใบ ถ้ามองเฉพาะช่วง [i..j]
        vector<vector<int>> keep(n + 2, vector<int>(n + 2, 0));
        auto K = [&](int i, int j) { return i > j ? 0 : keep[i][j]; };
        // ช่วงที่ยกออกได้ "ครบทุกใบ" คือช่วงที่ค่าในตารางเท่ากับความยาวของมันพอดี
        auto cleared = [&](int i, int j) { return i > j || keep[i][j] == j - i + 1; };
    
        // วนตามความยาวช่วง ไม่ใช่ตามตำแหน่ง ช่วงสั้นต้องเสร็จก่อนช่วงยาวเสมอ
        for (int len = 2; len <= n; len++) {
            for (int i = 0; i + len - 1 < n; i++) {
                int j = i + len - 1;
                int best = K(i + 1, j);                    // ทิ้ง a[i] ไว้บนโต๊ะ
                for (int k = i + 1; k <= j; k++) {
                    if (abs(a[i] - a[k]) > 1) continue;    // ขนาดไม่เข้าคู่
                    if (!cleared(i + 1, k - 1)) continue;  // ของที่ขวางต้องหายเกลี้ยงก่อน
                    best = max(best, (k - i + 1) + K(k + 1, j));
                }
                keep[i][j] = best;
            }
        }
    
        printf("%d\n", n - K(0, n - 1));   // เหลือบนโต๊ะกี่ใบ
        return 0;
    }

    นับงานดู ตารางมีช่องที่ใช้จริง n(n+1)/2 ช่อง แต่ละช่องไล่ k ได้ถึง n ครั้ง รวมเป็น O(n³) เวลา และ O(n²) หน่วยความจำ ที่ n = 300 คือราว 27,000,000 ครั้ง ซึ่งสบายมาก แต่ที่ n = 2,000 จะพุ่งเป็น 8,000,000,000 ครั้ง และตารางกินช่องถึง 2,001,000 ช่อง ตรงนี้แหละที่ทำให้ DP บนช่วงมีเพดานของมัน ถ้าโจทย์ให้ n ระดับหลักพันขึ้นไป แปลว่าเขาไม่ได้ตั้งใจให้ใช้ท่านี้ ต้องมองหามุมอื่นที่ยุบมิติทิ้งได้ ซึ่งเป็นเรื่องของบทถัดไป

    ตัวตรวจแบบซื่อ ๆ และนิสัยที่ควรติดตัว

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

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

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


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

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

    ฝึกข้อ 1 ★☆☆☆☆ · แถวที่ลบคู่ฝาแฝด

    โจทย์กำหนด

    แถวของเลข n ตัว ยกสองตัวที่ติดกันและมีค่าเท่ากันเป๊ะออกได้ ช่องปิดเหมือนเดิม ทำซ้ำได้เรื่อย ๆ ถามว่าเหลือน้อยที่สุดกี่ตัว โดยที่ n ใหญ่ได้ถึง 200 000

    EXAMPLE
    InputOutput
    8
    4 1 1 2 3 3 2 5
    2

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

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

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

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

    คำใบ้

    n ระดับสองแสนตัดทาง O(n³) และ O(n²) ทิ้งไปหมดแล้ว แปลว่าโจทย์กำลังบอกว่าข้อนี้ไม่ต้องใช้ตารางเลย ลองถามตัวเองว่ากติกา "เท่ากันเป๊ะ" ต่างจาก "ต่างกันไม่เกิน 1" ตรงไหน

    ที่มาของท่านี้ · เคสสี่ตัวที่ทำให้ผมเลิกดื้อ

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

    มันคายออกมาเป็นแถวสี่ตัวคือ 1 2 3 1 ลองเดินด้วยมือดูครับ ยกคู่ 2 3 ออกก่อน เพราะต่างกัน 1 พอช่องปิด เลข 1 สองตัวที่เดิมอยู่คนละหัวคนละท้ายก็มาติดกัน แล้วยกออกได้อีกคู่ เหลือศูนย์ ส่วน dp[i] ตอบ 2

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

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

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

    พอลำดับไม่มีผล ก็กวาดครั้งเดียวจบด้วยกองซ้อน (stack) เจอตัวใหม่ที่เท่ากับตัวบนสุดของกองเมื่อไร ก็หายไปทั้งคู่ ที่เหลือค้างในกองตอนจบคือคำตอบ ทำงาน O(n)

    ฝึกข้อ 1 · กองซ้อน
    // ฝึกข้อ 1: ลบคู่ที่ติดกันและเท่ากัน เหลือน้อยที่สุดกี่ตัว
    #include <bits/stdc++.h>
    using namespace std;
    
    int main() {
        int n;
        if (scanf("%d", &n) != 1) return 0;
        vector<int> st;
        st.reserve(n);
        for (int i = 0; i < n; i++) {
            int x;
            scanf("%d", &x);
            if (!st.empty() && st.back() == x) st.pop_back();   // เจอฝาแฝด หายไปทั้งคู่
            else st.push_back(x);
        }
        printf("%d\n", (int)st.size());
        return 0;
    }

    ระวัง

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


    ฝึกข้อ 2 ★★☆☆☆ · เติมวงเล็บให้ครบ

    โจทย์กำหนด

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

    EXAMPLE
    InputOutput
    ())(2

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

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

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

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

    คำใบ้

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

    ให้ f[i][j] คือจำนวนตัวที่ต้องเติมน้อยที่สุด เพื่อให้ช่วง [i..j] ถูกกติกา ช่วงยาวหนึ่งตัวต้องเติมคู่ให้มันเสมอ จึงเป็น 1 ส่วนช่วงที่ยาวกว่านั้นมีสองทาง

    • ถ้าหัวเป็น ( และท้ายเป็น ) ให้สองตัวนี้จับคู่กันเลย เหลือปัญหาตรงกลาง f[i+1][j−1]
    • หรือผ่าช่วงเป็นสองท่อนที่จุด k แล้วบวกกัน f[i][k] + f[k+1][j]

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

    ฝึกข้อ 2 · ท่ามาตรฐาน
    // ฝึกข้อ 2 ท่าที่ 1: DP บนช่วง O(n^3)
    // f[i][j] = ต้องเติมวงเล็บอย่างน้อยกี่ตัว ช่วง [i..j] ถึงจะถูกกติกา
    #include <bits/stdc++.h>
    using namespace std;
    
    int main() {
        char buf[512];
        if (scanf("%s", buf) != 1) return 0;
        string s = buf;
        int n = (int)s.size();
        vector<vector<int>> f(n + 1, vector<int>(n + 1, 0));
    
        for (int len = 1; len <= n; len++) {
            for (int i = 0; i + len - 1 < n; i++) {
                int j = i + len - 1;
                if (len == 1) { f[i][j] = 1; continue; }   // ตัวเดียวโดด ๆ ต้องเติมคู่ให้เสมอ
                int best = INT_MAX;
                // หัวกับท้ายจับคู่กันได้พอดี ที่เหลือคือปัญหาย่อยตรงกลาง
                if (s[i] == '(' && s[j] == ')') best = min(best, i + 1 <= j - 1 ? f[i + 1][j - 1] : 0);
                // ไม่งั้นก็ผ่าเป็นสองท่อนที่ไม่ยุ่งกัน
                for (int k = i; k < j; k++) best = min(best, f[i][k] + f[k + 1][j]);
                f[i][j] = best;
            }
        }
        printf("%d\n", n == 0 ? 0 : f[0][n - 1]);
        return 0;
    }

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

    ฝึกข้อ 2 · ท่าตัวนับ
    // ฝึกข้อ 2 ท่าที่ 2: ตัวนับตัวเดียว O(n)
    // open = วงเล็บเปิดที่ยังไม่มีใครมาปิด, add = วงเล็บปิดที่หาเจ้าของไม่ได้
    #include <bits/stdc++.h>
    using namespace std;
    
    int main() {
        char buf[512];
        if (scanf("%s", buf) != 1) return 0;
        int open = 0, add = 0;
        for (char *p = buf; *p; p++) {
            if (*p == '(') open++;
            else if (open > 0) open--;      // ไปปิดตัวที่ค้างอยู่
            else add++;                     // ไม่มีใครให้ปิด ต้องเติมตัวเปิดข้างหน้า
        }
        printf("%d\n", add + open);
        return 0;
    }

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


    ฝึกข้อ 3 ★★★☆☆ · เปลี่ยนสิ่งที่วัด

    โจทย์กำหนด

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

    EXAMPLE
    InputOutput
    6
    9 4 5 5 3 9
    35

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

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

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

    ยกได้เฉพาะคู่ที่ติดกันและขนาดต่างกันไม่เกิน 1 9 4 5 5 3 9 ได้ 10 สะสม 10 9 4 3 9 ได้ 7 สะสม 17 9 9 ได้ 18 สะสม 35 รวม 35 แต้ม
    ลำดับการยกที่ให้แต้มมากที่สุดของตัวอย่างนี้ คู่สีทองคือคู่ที่ยกในรอบนั้น ตัวเลขทางขวาคือแต้มที่ได้จากรอบนั้นและแต้มสะสม

    คำใบ้

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

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

    ทางแก้คือแยกเป็นสองตารางตรง ๆ full[i][j] เก็บว่าเกลี้ยงได้ไหมแบบไม่สนแต้ม และ val[i][j] เก็บแต้มสูงสุด ตัวแรกใช้ตัดสินว่าคู่ไหนจับกันได้ ตัวหลังใช้สะสมคำตอบ

    ฝึกข้อ 3 · สองตาราง
    // ฝึกข้อ 3: กติกาเดิม แต่แต้มคือผลรวมขนาดจานที่ยกออก อยากได้แต้มมากที่สุด
    // ต้องมีสองตาราง full บอกว่าช่วงนั้นยกออกได้เกลี้ยงไหม (ไม่เกี่ยวกับแต้ม)
    // ส่วน val เก็บแต้มสูงสุด ถ้าเอาตารางแต้มมาตัดสินเรื่องเกลี้ยงจะผิด
    // เพราะทางที่แต้มสูงสุดไม่จำเป็นต้องเป็นทางที่ยกออกได้เยอะสุด
    #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]);
    
        vector<vector<char>> full(n + 2, vector<char>(n + 2, 0));
        vector<vector<long long>> val(n + 2, vector<long long>(n + 2, 0));
        auto isFull = [&](int i, int j) { return i > j ? true : (bool)full[i][j]; };
        auto V = [&](int i, int j) { return i > j ? 0LL : val[i][j]; };
    
        for (int len = 2; len <= n; len++)
            for (int i = 0; i + len - 1 < n; i++) {
                int j = i + len - 1;
                for (int k = i + 1; k <= j; k++)
                    if (abs(a[i] - a[k]) <= 1 && isFull(i + 1, k - 1) && isFull(k + 1, j)) {
                        full[i][j] = 1;
                        break;
                    }
            }
    
        for (int len = 2; len <= n; len++)
            for (int i = 0; i + len - 1 < n; i++) {
                int j = i + len - 1;
                long long best = V(i + 1, j);
                for (int k = i + 1; k <= j; k++) {
                    if (abs(a[i] - a[k]) > 1) continue;
                    if (!isFull(i + 1, k - 1)) continue;
                    best = max(best, a[i] + a[k] + V(i + 1, k - 1) + V(k + 1, j));
                }
                val[i][j] = best;
            }
    
        printf("%lld\n", V(0, n - 1));
        return 0;
    }

    ระวัง

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


    ยุบอีกชั้น · Knuth optimization

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

    สำหรับ DP บนช่วงรูปหนึ่งที่พบบ่อยมาก มีท่าที่ยุบ O(n³) ลงเป็น O(n²) โดยไม่เปลี่ยนสมการเลย เปลี่ยนแค่ขอบเขตของลูปในสุด ท่านั้นชื่อ Knuth optimization (ตามชื่อ Donald Knuth)

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

    ข้ออ้างที่ทำให้ตัดงานได้

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

    opt[i][j−1] ≤ opt[i][j] ≤ opt[i+1][j]

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

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

    ระวัง ท่านี้ใช้ไม่ได้กับ DP บนช่วงทุกแบบ

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

    วัดหัวต่อหัว · ที่ n = 2,000 ด้วย g++ ตัวเดียวกัน
    แนวคิดอันดับงานคร่าว ๆเวลาที่วัดได้
    DP บนช่วง ไล่จุดตัดทุกจุด O(n³) 1,333,333,333 4.80 วินาที
    เติม Knuth optimization O(n²) 4,000,000 0.08 วินาที
    สองแถวนี้ไม่ใช่การประมาณ ผมคอมไพล์ทั้งสองตัวด้วย g++ แล้วรันอินพุตชุดเดียวกันจริง ต่างกัน 60 เท่า ที่ n เพียงสองพัน และช่องว่างนี้ถ่างขึ้นตาม n หน่วยความจำของฝั่ง Knuth มากกว่าราวหนึ่งเท่าครึ่ง เพราะต้องเก็บตาราง opt เพิ่มอีกใบ
    knuth.cpp
    // Knuth optimization: รวมกองหินให้เหลือกองเดียว ค่าใช้จ่ายน้อยที่สุด
    // dp[i][j] = ค่าน้อยสุดที่รวมช่วง i..j และ opt[i][j] = จุดตัดที่ดีที่สุดของช่วงนั้น
    // ข้ออ้างที่ทำให้เร็วขึ้น: opt[i][j-1] <= opt[i][j] <= opt[i+1][j]
    // จึงไล่จุดตัดแค่ช่วงแคบ ๆ แทนที่จะไล่ทั้งช่วง ทำให้ O(n^3) ยุบเป็น O(n^2)
    #include <bits/stdc++.h>
    using namespace std;
    
    int main() {
        int n;
        if (scanf("%d", &n) != 1) return 0;
        vector<long long> a(n + 1, 0), pre(n + 1, 0);
        for (int i = 1; i <= n; i++) { scanf("%lld", &a[i]); pre[i] = pre[i - 1] + a[i]; }
        auto sum = [&](int i, int j) { return pre[j] - pre[i - 1]; };
    
        const long long INF = (long long)4e18;
        vector<vector<long long>> dp(n + 2, vector<long long>(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 = opt[i][j - 1];
                int hi = (i + 1 <= j) ? opt[i + 1][j] : j - 1;
                lo = max(lo, i);
                hi = min(hi, j - 1);
                for (int k = lo; k <= hi; k++) {
                    long long cand = dp[i][k] + dp[k + 1][j] + sum(i, j);
                    if (cand < dp[i][j]) { dp[i][j] = cand; opt[i][j] = k; }
                }
            }
        }
        printf("%lld\n", n == 0 ? 0 : dp[1][n]);
        return 0;
    }
    ตัวตรวจที่ทำให้กล้าเชื่อโค้ดข้างบน

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

    brute_interval_dp.cpp
    // ตัวตรวจอิสระของ Knuth: DP บนช่วงแบบไล่จุดตัดทุกจุด ไม่ใช้ข้ออ้างเรื่อง opt เลย
    // นี่คือ O(n^3) ตรง ๆ ซึ่งเป็นสิ่งที่ Knuth พยายามหลบ จึงเป็นการตรวจที่อิสระจริง
    #include <bits/stdc++.h>
    using namespace std;
    
    int main() {
        int n;
        if (scanf("%d", &n) != 1) return 0;
        vector<long long> a(n + 1, 0), pre(n + 1, 0);
        for (int i = 1; i <= n; i++) { scanf("%lld", &a[i]); pre[i] = pre[i - 1] + a[i]; }
        auto sum = [&](int i, int j) { return pre[j] - pre[i - 1]; };
        const long long INF = (long long)4e18;
        vector<vector<long long>> dp(n + 2, vector<long long>(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] + sum(i, j));
            }
        printf("%lld\n", n == 0 ? 0 : dp[1][n]);
        return 0;
    }

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

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

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

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