programming.in.th · ข้อ 2041

นักชิม: หนึ่งวันคือการคูณเมทริกซ์หนึ่งครั้ง

ต้องออกจากเมือง 1 แล้วกลับมาให้พอดี T วัน โดย T ถึงพันล้าน ถนนยาวไม่เกิน 5 วัน จึงหั่นเมืองเป็นสำเนาให้ทุกถนนยาวหนึ่งวันเท่ากัน แล้วเดินทีละวันกลายเป็นคูณเมทริกซ์บนพีชคณิต max-plus ยกกำลังแบบทวิภาคข้ามช่วงที่ไม่มีเทศกาล

★★★★★ dpbinary liftingstate space อ่าน 14 นาที 11 กันยายน 2026

โจทย์ · นักชิมที่ต้องกลับถึงบ้านให้พอดีวัน

อาณาจักรเอลฟ์มี N เมือง เมืองที่ i ทำอาหารที่ให้ความพึงพอใจ C[i] หน่วย ถนนในอาณาจักรเป็นทางเดียวทั้งหมด M เส้น ถนนเส้นหนึ่งพาจากเมือง u ไปเมือง v และใช้เวลา w วัน ออกจาก u วันที่ d ก็ไปถึง v วันที่ d + w

นักชิม W เริ่มที่เมือง 1 ในวันที่ 0 และต้องกลับมารายงานตัวที่เมือง 1 ในวันที่ T พอดี ห้ามหยุดพักระหว่างทาง พอถึงเมืองไหนเขาชิมทันทีแล้วออกเดินทางต่อในวันนั้นเลย กลับมาเมืองเดิมกี่รอบก็ได้ความพึงพอใจเท่าเดิมทุกรอบ นอกจากนี้ยังมีเทศกาลอาหาร k งาน งานที่ i จัดวันที่ t ที่เมือง x ถ้าเขาอยู่ที่เมืองนั้นพอดีวันนั้นก็ได้เพิ่มอีก y หน่วย เราต้องบอกว่าความพึงพอใจรวมที่มากที่สุดเป็นเท่าไร

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

EXAMPLE
InputOutput
3 4 11 0
1 3 4
1 2 1
2 1 3
2 3 2
3 1 4
13
4 8 16 3
3 1 2 4
1 2 1
1 3 1
1 3 2
3 4 3
2 3 2
3 2 1
4 2 1
4 1 5
3 3 5
1 2 5
5 4 20
39

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

ชุดแรกบรรทัดบนสุดอ่านว่ามี 3 เมือง ถนน 4 เส้น แข่งกัน 11 วัน และไม่มีเทศกาลเลย บรรทัดถัดมาคือความพึงพอใจของเมือง 1 ถึงเมือง 3 เรียงตามลำดับ คือ 1, 3, 4 ส่วนอีก 4 บรรทัดคือถนน เลขสามตัวในบรรทัดคือต้นทาง ปลายทาง และจำนวนวัน ระวังว่าถนนเป็นทางเดียว บรรทัด 2 1 3 แปลว่าไปจากเมือง 2 กลับเมือง 1 ได้ใน 3 วัน แต่ไม่ได้แปลว่าเดินสวนทางได้

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

แผนที่โจทย์ยกมาคือ 1 → 2 → 1 → 2 → 3 → 1 เดินแล้วได้แบบนี้: วันที่ 0 อยู่เมือง 1 ได้ 1 จากนั้นวันที่ 1 อยู่เมือง 2 ได้ 3 จากนั้นวันที่ 4 อยู่เมือง 1 ได้ 1 จากนั้นวันที่ 5 อยู่เมือง 2 ได้ 3 จากนั้นวันที่ 7 อยู่เมือง 3 ได้ 4 จากนั้นวันที่ 11 อยู่เมือง 1 ได้ 1 รวมทั้งหมด 1 + 3 + 1 + 3 + 4 + 1 เท่ากับ 13 หน่วย สังเกตว่าเขาเหยียบเมือง 1 สามครั้งและได้ 1 หน่วยทุกครั้ง เพราะโจทย์บอกว่าชิมซ้ำก็ได้เท่าเดิม ส่วนชุดที่สองมีเทศกาล 3 งาน จัดวันที่ 3, 1, 5 ตามลำดับที่อินพุตให้มา ซึ่งไม่ได้เรียงตามวัน แผนที่ชนะคือ 1 → 3 → 4 → 2 → 3 → 4 → 1 และมันไปตรงงานเดียวคือวันที่ 5 ที่เมือง 4 ได้เพิ่ม 20 ส่วนอีก 2 งาน เขายอมทิ้งเพราะแวะไม่คุ้ม รวมแล้วได้ 39 หน่วย

1 วัน 3 วัน 2 วัน 4 วัน 1 1 2 3 3 4 วันที่ อยู่เมือง ได้เพิ่ม รวม 0 เมือง 1 +1 1 1 เมือง 2 +3 4 4 เมือง 1 +1 5 5 เมือง 2 +3 8 7 เมือง 3 +4 12 11 เมือง 1 +1 13
อาณาจักรของตัวอย่างแรก ตัวเลขขาวในวงคือหมายเลขเมือง ตัวเลขทองคือความพึงพอใจของเมืองนั้น ลูกศรคือถนนทางเดียวพร้อมจำนวนวันที่ใช้ ทางขวาคือบัญชีของแผน 1 → 2 → 1 → 2 → 3 → 1 ทีละครั้งที่เหยียบเมือง จนได้ 13 หน่วยในวันที่ 11

ใบ้

ถ้า T เล็ก ข้อนี้คือตารางธรรมดา ช่องหนึ่งช่องคือ “วันที่ d ยืนอยู่เมือง v แล้วได้มากที่สุดเท่าไร” ไล่จากวันที่ 0 ไปข้างหน้าจนถึงวันที่ T แล้วอ่านคำตอบที่ช่องของเมือง 1 ปัญหาคือ T ใหญ่ได้ถึง 1,000,000,000 ตารางนั้นจึงมีแถวเป็นพันล้านแถว

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

ลองเอง · พาเขาเที่ยวเองสี่อาณาจักร

อาณาจักรเดียวกับตัวอย่างแรก ลองเดินให้กลับถึงเมือง 1 พอดีวันที่ 11 ก่อน แล้วค่อยไล่หายอดที่มากที่สุด

 

1 วัน 3 วัน 2 วัน 4 วัน 1 1 2 3 3 4

ชุดนี้ไม่มีเทศกาล

 

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

 

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

ลองให้ครบทั้งสี่ชุดนะครับ โดยเฉพาะชุดสุดท้าย ถ้าลองแล้วรู้สึกว่ามันแปลก ๆ ความรู้สึกนั้นถูกแล้ว

เฉลย ตอนที่ 1 · หั่นเมืองจนถนนทุกเส้นยาวหนึ่งวันเท่ากัน

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

ผมรู้จักท่ายกกำลังเมทริกซ์อยู่ก่อนแล้ว สิ่งที่ต้องตัดสินใจจริงในข้อนี้จึงไม่ใช่ว่าจะใช้ท่าไหน แต่คือจะทำยังไงให้ “หนึ่งวัน” เป็นก้าวแบบเดียวกันทุกครั้ง ตัวเลขที่ผลักคือ T ถึง 1,000,000,000 ตารางไล่ทีละวันซึ่งเป็นสิ่งแรกที่ผมเขียน ต้องผ่อนถนนทุกเส้นในทุกวัน คูณออกมาได้ 501,000,000,000 ครั้ง และตารางเองก็กินหน่วยความจำระดับหลายแสนเมกะไบต์ ไม่ใช่เรื่องปรับจูนให้เร็วขึ้นอีกนิด แต่ต้องเปลี่ยนหน่วยของการคิด

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

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

ท่าคือหั่นเมืองหนึ่งเมืองออกเป็นสำเนา เมืองละ W ตัว เมื่อ W คือความยาวถนนที่มากที่สุด สำเนาที่ j ของเมือง v เขียนเป็น (v, j) แปลว่า “อีก j วันข้างหน้าฉันจะเหยียบเมือง v” สำเนา (v, 0) จึงแปลว่ากำลังยืนอยู่ที่เมือง v จริง ๆ ในวันนี้

เมื่อมองแบบนี้ การเดินทางทั้งอาณาจักรเหลือกฎแค่สองข้อ และทั้งสองข้อใช้เวลาหนึ่งวันเท่ากัน ข้อแรก ถนนจากเมือง u ไปเมือง v ที่ยาว w วัน กลายเป็นการก้าวจาก (u, 0) ไป (v, w - 1) คือตัดสินใจออกเดินทางวันนี้ แล้วเหลืออีก w - 1 วันจะถึง ข้อสอง สำเนา (v, j) เมื่อ j มากกว่า 0 ก้าวไป (v, j - 1) ได้อย่างเดียว คืออยู่ระหว่างทางแล้วเวลาผ่านไปหนึ่งวัน

กฎสองข้อของหนึ่งวัน
// สร้างเมทริกซ์ของหนึ่งวัน จากถนนกับโซ่สำเนาเมือง
// id(v, j) = หมายเลขสถานะของสำเนาที่ j ของเมือง v
for (auto [u, v, w] : edges)
    A[id(u, 0)][id(v, w - 1)] = max(A[id(u, 0)][id(v, w - 1)], C[v]);  // ออกเดินทาง จ่ายรางวัลตรงนี้
for (int v = 0; v < n; v++)
    for (int j = 1; j < W; j++)
        A[id(v, j)][id(v, j - 1)] = 0;                                  // อยู่ระหว่างทาง ผ่านไปหนึ่งวัน

รางวัล C[v] ถูกบวกที่การก้าวข้อแรก คือตอนออกเดินทาง ไม่ใช่ตอนไปถึง ส่วนการก้าวข้อสองได้ 0 จำนวนสถานะทั้งหมดคือ N คูณ W ซึ่งที่ขอบเขตใหญ่สุดคือ 50 คูณ 5 เท่ากับ 250 ตัว และตอนนี้หนึ่งวันคือการก้าวหนึ่งครั้งบนกราฟที่มีแค่ 250 จุด เหมือนกันทุกวัน

(2,0) วันที่ 5 (3,1) วันที่ 6 +4 ถนน 2→3 (3,0) วันที่ 7 (1,3) วันที่ 8 +1 ถนน 3→1 (1,2) วันที่ 9 (1,1) วันที่ 10 (1,0) วันที่ 11
ขาสุดท้ายของตัวอย่างแรก กางเป็นการก้าววันละครั้ง กล่องเขียวคือสำเนา j = 0 ซึ่งแปลว่าเหยียบเมืองนั้นจริงในวันนั้น กล่องเทาคือยังอยู่ระหว่างทาง ลูกศรทุกเส้นยาวหนึ่งวันเท่ากัน ถนน 3→1 ที่ยาว 4 วันจึงกลายเป็นการก้าวสี่ครั้ง โดยรางวัล +1 ของเมือง 1 ถูกจ่ายตั้งแต่ก้าวแรกของทั้งสี่ก้าว

เฉลย ตอนที่ 2 · หนึ่งวันคือการคูณเมทริกซ์หนึ่งครั้ง

พอหนึ่งวันเป็นก้าวเดียวกันเสมอ เราเขียนกฎของหนึ่งวันลงเป็นตารางตัวเลขสี่เหลี่ยมที่เรียกว่าเมทริกซ์ (matrix อ่านว่า “เมทริกซ์” คำนี้มาจากภาษาละติน mātrix ที่แปลว่าแม่พันธุ์หรือครรภ์ คือที่ที่อะไรบางอย่างงอกออกมา ก่อนจะถูกยืมมาใช้เรียกตารางตัวเลขในคณิตศาสตร์) ได้ตารางขนาด 250 คูณ 250 ช่องแถว p หลัก q เก็บว่าก้าวจากสถานะ p ไป q ในหนึ่งวันแล้วได้กี่หน่วย ถ้าก้าวไม่ได้ก็ใส่ค่าลบมหาศาลไว้แทนคำว่าไปไม่ได้

ทีนี้คำถามว่า “เดิน a + b วันแล้วได้มากที่สุดเท่าไร” ตอบได้จากคำตอบของ a วัน กับ b วัน โดยไล่ดูว่าจุดพักตรงกลางอยู่ที่สถานะไหน แล้วเอาสองท่อนมาบวกกัน เลือกอันที่มากที่สุด รูปแบบนี้หน้าตาเหมือนการคูณเมทริกซ์เป๊ะ ๆ ต่างกันแค่เปลี่ยนเครื่องหมาย: ที่เดิมต้องบวกให้เปลี่ยนเป็นเอามากสุด และที่เดิมต้องคูณให้เปลี่ยนเป็นบวก ระบบตัวเลขที่สลับเครื่องหมายแบบนี้เรียกว่าพีชคณิตมากสุดบวก (max-plus algebra อ่านว่า “แม็กซ์-พลัส” บางตำราเรียกพีชคณิตเขตร้อน เพราะคนที่ศึกษาเรื่องนี้ยุคแรกเป็นชาวบราซิล)

คูณเมทริกซ์แบบมากสุดบวก
// หนึ่งวันคือการคูณหนึ่งครั้ง แต่บวกกลายเป็นมากสุด และคูณกลายเป็นบวก
// C[i][j] = ค่ามากที่สุดของ A[i][k] + B[k][j] เมื่อไล่ k ทุกตัว
for (int i = 0; i < S; i++)
    for (int k = 0; k < S; k++) {
        if (A[i][k] <= HALF) continue;          // ไปจากสถานะ i ถึง k ไม่ได้ ข้าม
        for (int j = 0; j < S; j++)
            C[i][j] = max(C[i][j], A[i][k] + B[k][j]);
    }

ของดีของการเขียนหนึ่งวันเป็นเมทริกซ์คือมันยกกำลังได้ เมทริกซ์ของ 2 วันคือเมทริกซ์ของ 1 วันคูณตัวเอง ของ 4 วันคือของ 2 วันคูณตัวเอง ไล่ไปเรื่อย ๆ เก็บไว้เป็นตารางกระโดดสองยกกำลัง ซึ่งเป็นท่าเดียวกับที่ ข้อศูนย์ประชุมใช้กระโดดข้ามงานทีละก้อน ที่ T ถึง 1,000,000,000 เราต้องการกำลังถึง 30 ชั้น คือยกกำลังสองซ้ำ 29 ครั้ง แต่ละครั้งเป็นการคูณเมทริกซ์ 250 คูณ 250 หนึ่งที ราคา 15,625,000 ครั้ง ชั้นทั้งหมดกินหน่วยความจำ 15 เมกะไบต์ ซึ่งเทียบกับลิมิต 512 แล้วเหลือเฟือ

พอมีตารางชั้นแล้ว การเดินหน้าไป d วันก็แค่ดูว่า d เขียนเป็นเลขฐานสองแล้วมีบิตไหนติดบ้าง แล้วเอาเวกเตอร์คำตอบปัจจุบันคูณกับชั้นนั้น ๆ ตามลำดับ ตรงนี้เป็นการคูณเวกเตอร์กับเมทริกซ์ ซึ่งราคาแค่ 62,500 ครั้ง ถูกกว่าการคูณเมทริกซ์เต็ม ๆ อยู่ 250 เท่า

COST
ทางที่ T = 52,501ที่ T = 1,000,000,000
ไล่ทีละวัน ทุกวันผ่อนถนนทุกเส้น26,303,001501,000,000,000
หั่นเมืองแล้วคูณเมทริกซ์ ยกกำลังทวิภาค435,375,000830,000,000
สองแถวนับด้วยหน่วยเดียวกัน คือจำนวนครั้งที่ต้องบวกเลขหนึ่งครั้งแล้วเทียบว่ามากกว่าค่าเดิมไหม คิดที่ขอบเขตใหญ่สุดของโจทย์คือถนน 501 เส้น เมือง 50 เมือง และเทศกาล 200 งาน สังเกตว่าที่ T เท่ากับ 52,501 ทางไล่ทีละวันถูกกว่าเกือบยี่สิบเท่า นั่นคือเหตุผลที่ชุดทดสอบชุดนั้นมีอยู่ แต่พอ T โตเป็นพันล้าน ทางแรกโตตามไปตรง ๆ ส่วนทางที่สองโตตามจำนวนบิตของ T ซึ่งเพิ่มจาก 16 เป็น 30 เท่านั้น

เฉลย ตอนที่ 3 · เทศกาลหั่นเส้นเวลาออกเป็นช่วง

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

แต่เทศกาลมีไม่เกิน 200 งาน มันจึงหั่นเส้นเวลาออกเป็นช่วงได้ไม่เกิน 201 ช่วงเท่านั้น วิธีทำคือเรียงเทศกาลตามวัน (อินพุตไม่ได้เรียงมาให้ อย่างตัวอย่างที่สองให้มาเป็นวันที่ 3, 1, 5) แล้วเดินหน้าทีละช่วงด้วยตารางกระโดด พอถึงวันที่มีงาน ก็เอา y บวกเข้าช่องของสถานะ (x, 0) ตรง ๆ ซึ่งแปลว่ายืนอยู่เมือง x พอดีวันนั้น แล้วเดินหน้าต่อ

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

0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 เมือง 2 +5 เมือง 3 +5 เมือง 4 +20 1 วัน 2 วัน 2 วัน 11 วัน = 8 + 2 + 1
เส้นเวลาของตัวอย่างที่สอง งาน 3 งานหั่น 16 วันออกเป็น 4 ช่วง แต่ละช่วงข้ามด้วยตารางกระโดดตามบิตของความยาวช่วง ช่วงสุดท้ายยาว 11 วัน จึงกระโดด 3 ครั้งคือ 8 แล้ว 2 แล้ว 1 ที่ T เป็นพันล้าน ช่วงหนึ่งช่วงก็ยังใช้การกระโดดไม่เกิน 30 ครั้งเท่านั้น

เฉลย ตอนที่ 4 · จ่ายรางวัลตั้งแต่ออกเดินทาง ทำไมถึงไม่นับเกิน

จุดที่ดูน่าสงสัยที่สุดของท่านี้คือ เราบวก C[v] ตอนก้าวจาก (u, 0) ไป (v, w - 1) ซึ่งคือวันที่เขาออกเดินทาง ไม่ใช่วันที่ไปถึง ถ้า T มาถึงก่อนที่เขาจะเดินทางถึงเมือง v เท่ากับเราแจกความพึงพอใจของมื้อที่เขายังไม่ได้กินไปแล้ว

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

พูดอีกแบบคือการยืนอยู่ที่ (1, 0) ในวันที่ T เป็นหลักฐานในตัวมันเองว่า ทุกการเดินทางที่เคยตัดสินใจไว้ได้ลงจอดครบแล้ว เพราะถ้ามีสักเส้นที่ยังค้าง เขาจะยังติดอยู่ในสำเนา (v, j) บางตัว ไม่ใช่ที่ (1, 0) การจ่ายล่วงหน้าจึงเป็นแค่เรื่องของจังหวะการบันทึกบัญชี ไม่ใช่การแจกของฟรี

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

โค้ด

ดูโค้ดเต็มขอบเขต
dl_full.cpp
// NOI 2020 "Delicacy" (programming.in.th 2041) - full constraints.
// (max,+) matrix exponentiation over a time-expanded graph.
//
// Every edge weight is 1..5, so split each city v into W copies (v,j),
// j = 0..W-1, where W = max edge weight.  State (v,j) means "I will land on
// city v in j more days".  (v,0) means "I am standing on v right now".
// One day = one application of the matrix A:
//     (u,0) --edge u->v of weight w--> (v, w-1)   gaining C[v]
//     (v,j) --> (v,j-1)  (j >= 1)                 gaining 0
// The reward C[v] is paid the moment the trip is committed; that is fine
// because a walk is only ever read off at a (x,0) state, where no trip is
// pending.
//
// Start vector: (1,0) = C[1], everything else unreachable.
// Advance day by day with binary lifting over A^(2^b); at each festival day t
// hop to day t, add y to the entry of state (x,0), continue.  Answer is the
// entry of (1,0) after exactly T days.
#include "dl_case.h"
#include <algorithm>
#include <vector>
#include <cstdio>

namespace dlfull {

using ll = long long;
static const ll NEG = -1000000000000000000LL;  // "unreachable"
static const ll HALF = -500000000000000000LL;  // anything <= HALF is unreachable
                                               // (every real value is >= 0)

struct MaxPlus {
    int S = 0;

    // C = A (max,+) B
    void mul(const std::vector<ll> &A, const std::vector<ll> &B, std::vector<ll> &C) const {
        for (int i = 0; i < S; i++) {
            ll *c = &C[(size_t)i * S];
            for (int j = 0; j < S; j++) c[j] = NEG;
            const ll *a = &A[(size_t)i * S];
            for (int k = 0; k < S; k++) {
                ll av = a[k];
                if (av <= HALF) continue;
                const ll *b = &B[(size_t)k * S];
                for (int j = 0; j < S; j++) {
                    ll t = av + b[j];
                    if (t > c[j]) c[j] = t;
                }
            }
            // Re-normalise: finite + NEG is still astronomically negative but
            // not exactly NEG; snapping it back stops it creeping upward over
            // the ~35 products we chain together.
            for (int j = 0; j < S; j++)
                if (c[j] <= HALF) c[j] = NEG;
        }
    }

    // out = v (max,+) B   (v is a row vector)
    void vmul(const std::vector<ll> &v, const std::vector<ll> &B, std::vector<ll> &out) const {
        for (int j = 0; j < S; j++) out[j] = NEG;
        for (int i = 0; i < S; i++) {
            ll av = v[i];
            if (av <= HALF) continue;
            const ll *b = &B[(size_t)i * S];
            for (int j = 0; j < S; j++) {
                ll t = av + b[j];
                if (t > out[j]) out[j] = t;
            }
        }
        for (int j = 0; j < S; j++)
            if (out[j] <= HALF) out[j] = NEG;
    }
};

ll solve(const Case &cs) {
    const int n = cs.n;
    const ll T = cs.T;

    int W = 1;
    for (const auto &e : cs.E) W = std::max(W, e[2]);

    MaxPlus mp;
    mp.S = n * W;
    const int S = mp.S;
    auto id = [&](int v, int j) { return v * W + j; };

    // A = one-day transition matrix.
    std::vector<ll> A((size_t)S * S, NEG);
    for (const auto &e : cs.E) {
        int u = e[0], v = e[1], w = e[2];
        ll &cell = A[(size_t)id(u, 0) * S + id(v, w - 1)];
        cell = std::max(cell, cs.C[v]);
    }
    for (int v = 0; v < n; v++)
        for (int j = 1; j < W; j++) A[(size_t)id(v, j) * S + id(v, j - 1)] = 0;

    // Binary powers of A up to the highest bit of T.
    int B = 0;
    while ((1LL << (B + 1)) <= T) B++;
    std::vector<std::vector<ll>> pw;
    pw.push_back(A);
    for (int b = 1; b <= B; b++) {
        std::vector<ll> nxt((size_t)S * S);
        mp.mul(pw[b - 1], pw[b - 1], nxt);
        pw.push_back(std::move(nxt));
    }

    std::vector<ll> vec((size_t)S, NEG), tmp((size_t)S);
    vec[id(0, 0)] = cs.C[0];  // day 0: standing on city 1, already eaten there

    auto advance = [&](ll d) {
        for (int b = 0; d > 0; b++, d >>= 1) {
            if (d & 1) {
                mp.vmul(vec, pw[b], tmp);
                vec.swap(tmp);
            }
        }
    };

    std::vector<std::array<ll, 3>> F = cs.F;
    std::sort(F.begin(), F.end(),
              [](const std::array<ll, 3> &a, const std::array<ll, 3> &b) { return a[0] < b[0]; });

    ll now = 0;
    for (size_t i = 0; i < F.size(); i++) {
        ll t = F[i][0];
        if (t > T || t < now) continue;  // defensive; the statement gives 1 <= t <= T
        advance(t - now);
        now = t;
        // Apply every festival held on day t.  Two festivals on the same day in
        // the same city stack; in different cities they simply land on
        // different states, which is exactly right.
        size_t j = i;
        while (j < F.size() && F[j][0] == t) {
            int x = (int)F[j][1];
            ll &cell = vec[id(x, 0)];
            if (cell > HALF) cell += F[j][2];
            j++;
        }
        i = j - 1;
    }
    advance(T - now);

    ll ans = vec[id(0, 0)];
    return ans <= HALF ? -1 : ans;
}

}  // namespace dlfull

#ifndef DL_NO_MAIN
int main() {
    Case cs = readCase();
    printf("%lld\n", dlfull::solve(cs));
    return 0;
}
#endif

ค่ามากที่สุดที่เป็นไปได้คือความพึงพอใจ 52,501 ต่อวัน คูณ 1,000,000,000 วัน บวกเทศกาลอีก 200 งาน งานละถึง 1,000,000,000 รวมแล้วเกินขอบเขตของ int ไปไกลมาก ทุกอย่างจึงเป็น long long ส่วนค่าที่แทนคำว่าไปไม่ได้ต้องเป็นลบมากพอที่บวกกันหลายทอดแล้วยังไม่มีทางแซงค่าจริงได้ และต้องดึงกลับมาที่ค่าเดิมทุกครั้งหลังคูณ ไม่งั้นมันจะค่อย ๆ ไต่ขึ้นทีละนิดตลอด 35 ทอดที่เราคูณต่อกัน

ดูโค้ดของชุดที่ T ไม่เกิน 52,501
dl_sub.cpp
// NOI 2020 "Delicacy" - subtask solver for T <= 52501 (also used as an oracle).
// Plain forward DP over days.  dp[d][v] = best satisfaction for being on city v
// on day d, already counting C[v] and any festival bonus of day d.
// Every edge costs at least 1 day, so day d is complete before it is expanded.
#include "dl_case.h"
#include <algorithm>
#include <vector>
#include <cstdio>

namespace dlsub {

using ll = long long;
static const ll NONE = -1;  // unreachable (real values are always >= 0)

ll solve(const Case &cs) {
    const int n = cs.n;
    const ll T = cs.T;

    // adjacency by source city
    std::vector<std::vector<std::pair<int, int>>> adj(n);  // (dest, weight)
    for (const auto &e : cs.E) adj[e[0]].push_back({e[1], e[2]});

    // festivals bucketed by day
    std::vector<std::vector<std::pair<int, ll>>> fest((size_t)T + 1);  // (city, bonus)
    for (const auto &f : cs.F) {
        if (f[0] >= 0 && f[0] <= T) fest[(size_t)f[0]].push_back({(int)f[1], f[2]});
    }

    std::vector<std::vector<ll>> dp((size_t)T + 1, std::vector<ll>(n, NONE));
    dp[0][0] = cs.C[0];

    for (ll d = 0; d <= T; d++) {
        for (const auto &fb : fest[(size_t)d]) {
            if (dp[(size_t)d][fb.first] != NONE) dp[(size_t)d][fb.first] += fb.second;
        }
        if (d == T) break;
        for (int v = 0; v < n; v++) {
            ll cur = dp[(size_t)d][v];
            if (cur == NONE) continue;
            for (const auto &pr : adj[v]) {
                ll nd = d + pr.second;
                if (nd > T) continue;
                ll cand = cur + cs.C[pr.first];
                if (cand > dp[(size_t)nd][pr.first]) dp[(size_t)nd][pr.first] = cand;
            }
        }
    }
    return dp[(size_t)T][0];  // NONE == -1, which is exactly the required output
}

}  // namespace dlsub

#ifndef DL_NO_MAIN
int main() {
    Case cs = readCase();
    printf("%lld\n", dlsub::solve(cs));
    return 0;
}
#endif

นี่คือตารางไล่ทีละวันตรง ๆ ซึ่งเป็นท่าแรกที่ควรเขียน ที่ T ไม่เกิน 52,501 และถนนไม่เกิน 50 เส้น มันทำงาน 2,625,050 ครั้ง และตารางกิน 21 เมกะไบต์ ซึ่งอยู่ในลิมิตสบาย ตามเงื่อนไขของชุดทดสอบ โค้ดนี้ครอบชุด 20% ที่ T ไม่เกิน 5 และชุด 20% ที่ T ไม่เกิน 52,501 รวมเป็น 40% ส่วนชุดที่เหลือปลด T ไว้ถึงพันล้าน ตารางนี้จึงจองหน่วยความจำไม่ไหวตั้งแต่แรก ผมใช้มันเป็นตัวตรวจของชุดใหญ่ด้วย เพราะมันคิดคนละทางกับเมทริกซ์แต่รับอินพุตชุดเดียวกัน

ดูตัวไล่ทุกเส้นทางที่ใช้เทียบ
dl_brute.cpp
// NOI 2020 "Delicacy" - brute force for tiny cases.
// Literally enumerates every walk out of city 1: at each city pick an outgoing
// road, recurse, stop when day == T.  No DP table, no memoisation, no matrix -
// it shares nothing with the other two solvers except the input struct.
#include "dl_case.h"
#include <algorithm>
#include <vector>
#include <cstdio>

namespace dlbrute {

using ll = long long;

struct Runner {
    const Case *cs = nullptr;
    std::vector<std::vector<std::pair<int, int>>> adj;
    ll best = -1;

    ll bonusAt(ll day, int city) const {
        ll s = 0;
        for (const auto &f : cs->F)
            if (f[0] == day && (int)f[1] == city) s += f[2];
        return s;
    }

    void rec(ll day, int city, ll sum) {
        if (day == cs->T) {
            if (city == 0 && sum > best) best = sum;
            return;
        }
        for (const auto &pr : adj[city]) {
            ll nd = day + pr.second;
            if (nd > cs->T) continue;
            rec(nd, pr.first, sum + cs->C[pr.first] + bonusAt(nd, pr.first));
        }
    }
};

ll solve(const Case &cs) {
    Runner r;
    r.cs = &cs;
    r.adj.assign(cs.n, {});
    for (const auto &e : cs.E) r.adj[e[0]].push_back({e[1], e[2]});
    r.rec(0, 0, cs.C[0] + r.bonusAt(0, 0));
    return r.best;
}

}  // namespace dlbrute

#ifndef DL_NO_MAIN
int main() {
    Case cs = readCase();
    printf("%lld\n", dlbrute::solve(cs));
    return 0;
}
#endif

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

ดูส่วนอ่านอินพุตที่ทั้งสามโปรแกรมใช้ร่วมกัน
dl_case.h
// Shared *data* declaration only (no algorithm code) so that the three
// independent solvers can be linked into one stress-test binary verbatim.
#ifndef DL_CASE_H
#define DL_CASE_H
#include <vector>
#include <array>
#include <cstdio>

struct Case {
    int n = 0, m = 0, k = 0;
    long long T = 0;
    std::vector<long long> C;                // C[i], cities 0-indexed
    std::vector<std::array<int, 3>> E;       // {u, v, w}, cities 0-indexed
    std::vector<std::array<long long, 3>> F; // {t, x, y}, x 0-indexed
};

inline Case readCase() {
    Case c;
    long long T = 0;
    if (scanf("%d %d %lld %d", &c.n, &c.m, &T, &c.k) != 4) return c;
    c.T = T;
    c.C.assign(c.n, 0);
    for (int i = 0; i < c.n; i++) {
        if (scanf("%lld", &c.C[i]) != 1) return c;
    }
    c.E.assign(c.m, {0, 0, 0});
    for (int i = 0; i < c.m; i++) {
        int u = 0, v = 0, w = 0;
        if (scanf("%d %d %d", &u, &v, &w) != 3) return c;
        c.E[i] = {u - 1, v - 1, w};
    }
    c.F.assign(c.k, {0, 0, 0});
    for (int i = 0; i < c.k; i++) {
        long long t = 0, x = 0, y = 0;
        if (scanf("%lld %lld %lld", &t, &x, &y) != 3) return c;
        c.F[i] = {t, x - 1, y};
    }
    return c;
}
#endif

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

คอมไพล์ทั้งสามโปรแกรมแล้วรันกับตัวอย่างสองชุดในโจทย์ ได้ 13 กับ 39 ตรงกันทั้งสามตัว ตัวเฉลยผ่านการสุ่มทดสอบ 57,000 รอบโดยไม่มีรอบไหนไม่ตรง และผมรันซ้ำด้วยเมล็ดสุ่มของตัวเองอีก 6,000 รอบ ก็ไม่ต่างกันสักรอบ ในจำนวนนั้น 3,493 รอบเทียบกับตัวไล่ทุกเส้นทาง นอกจากนี้ยังมีอีก 600 รอบที่เทียบกับสูตรปิด บนอาณาจักรที่บังคับให้เดินได้ทางเดียว โดยดัน T ถึงพันล้านจนได้ใช้บิตกระโดดครบทั้ง 30 บิต อีก 60 รอบที่ขนาดเต็มคือ N = 50, M = 501, k = 200 เทียบกับตารางไล่ทีละวัน และเคสขอบอีกแปดเคสที่คำนวณด้วยมือ เช่น เมืองเดียวที่มีถนนวนกลับตัวเองยาว 1 วันแล้ว T เป็นพันล้าน T คี่กับถนนวนกลับตัวเองยาว 2 วันซึ่งต้องตอบ -1 เทศกาลที่จัดตรงวันที่ T พอดี และเมืองที่ไม่มีถนนออกเลย ทั้งหมดตรงกันหมด เวลาที่แย่ที่สุดที่วัดได้คือ 0.55 วินาที จากลิมิต 2 วินาที บนเคสที่จงใจสร้างให้หนักที่สุด (N = 50, M = 501, T = 1,000,000,000, k = 200 และจัดวันเทศกาลให้ห่างกัน 2 ยกกำลัง 22 ลบหนึ่งวัน เพื่อให้ต้องคูณเวกเตอร์กับเมทริกซ์มากที่สุด) หน่วยความจำสูงสุด 18 เมกะไบต์จาก 512 ทั้งหมดนี้เทียบกับตัวตรวจของเราเองกับตัวอย่างสองชุดในโจทย์ ไม่ใช่ข้อมูลทดสอบจริงของผู้จัด

ท่าที่ติดมือกลับไป

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

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

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

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

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

แหล่งที่มา

  1. โจทย์ นักชิม (Delicacy) บน programming.in.th ข้อ 2041 programming.in.th/tasks/2041 (สืบค้น 11 กันยายน 2026)
  2. ต้นฉบับจาก The 37th CCF National Olympiad in Informatics (NOI 2020) ตามที่ระบุไว้ท้ายโจทย์
  3. อาณาจักรสามชุดหลังในกล่อง “ลองเอง” ผมแต่งขึ้นเอง เพราะตัวอย่างในโจทย์ไม่มีชุดที่ตอบ -1 และไม่มีชุดที่ลงโทษการเดินแบบตะกละ คำตอบของทั้งสี่ชุดคิดด้วยการไล่ทุกเส้นทางตอนบิลด์ ถ้าไม่ตรงกับที่หน้านี้อ้าง หน้านี้จะไม่ถูกสร้าง