programming.in.th · ข้อ 2042

โชคชะตา: นับวิธีระบายเส้น ให้ทุกเส้นทางที่กำหนดมีจุดตัดอย่างน้อยหนึ่งจุด

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

★★★★★ treedpsegment treecounting อ่าน 16 นาที เผยแพร่ 11 กันยายน 2026 อัปเดต 16 ตุลาคม 2026

โจทย์ · ระบายเส้นบนต้นไม้ให้ทุกคู่ที่สั่งไว้มีเส้นสำคัญคั่น

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

คู่ (u, v) ที่ u เป็นบรรพบุรุษ (ancestor อ่านว่า "แอนเซสเตอร์" แปลว่าผู้ที่มาก่อน) ของ v คือจุดที่อยู่เหนือ v บนทางขึ้นไปหาราก วิถีจาก u ลงไป v ก็คือประสบการณ์หนึ่งช่วงของชีวิต

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

คำถามคือระบายได้ทั้งหมดกี่แบบ คู่ซ้ำกันได้ และคำตอบใหญ่มากจนต้องตอบเป็นเศษจากการหารด้วย 998,244,353

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

EXAMPLE
InputOutput
5
1 2
2 3
3 4
3 5
2
1 3
2 5
10

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

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

เอาต์พุตคือจำนวนแบบของการระบาย หารด้วย 998,244,353 แล้วเอาเศษ ไม่ใช่จำนวนเส้นสำคัญที่น้อยที่สุด (ซึ่งในตัวอย่างนี้คือ 1 เส้น) ต้นไม้นี้มี 4 เส้น จึงมีการระบายทั้งหมด 16 แบบก่อนเอาเงื่อนไขมากรอง

วิถีของคู่ (1, 3) คือเส้น 1-2 กับ 2-3 ส่วนคู่ (2, 5) คือเส้น 2-3 กับ 3-5 เส้น 2-3 อยู่บนวิถีของทั้งสองคู่ จึงแบ่งคำตอบเป็นสองกอง

รวม 8 + 2 = 10 ตรงกับเอาต์พุต

ต้นไม้ของตัวอย่างที่หนึ่ง การระบายทั้ง 16 แบบ เรียงเป็น ก ข ค ง ก ข ค ง 1 2 3 4 5 0 0 0 0 ตก 1 0 0 0 ตก 0 1 0 0 ผ่าน 1 1 0 0 ผ่าน 0 0 1 0 ตก 1 0 1 0 ตก 0 1 1 0 ผ่าน 1 1 1 0 ผ่าน 0 0 0 1 ตก 1 0 0 1 ผ่าน 0 1 0 1 ผ่าน 1 1 0 1 ผ่าน 0 0 1 1 ตก 1 0 1 1 ผ่าน 0 1 1 1 ผ่าน 1 1 1 1 ผ่าน
ซ้ายคือต้นไม้ของตัวอย่าง เส้นชื่อ ก ข ค ง ตามลำดับในอินพุต ขวาคือการระบายทั้ง 16 แบบ เขียว 10 แบบผ่าน แดง 6 แบบตก แบบที่ตกทุกแบบมีเส้น ข เป็น 0

ใบ้

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

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

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

ถ้ามีข้อค้างหลายข้อพร้อมกัน ต้องจำทุกข้อจริงหรือเปล่า

ลองเอง · ระบายเส้นเอง แล้วดูว่ามีกี่แบบ

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

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

แตะที่ป้ายบนเส้นเพื่อสลับระหว่าง 0 กับ 1

พอหาแบบที่ผ่านได้แล้ว การ์ดจะบอกว่าต้นไม้นั้นมีทั้งหมดกี่แบบ

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

เกมที่สอง: ถ้ามีข้อค้างหลายข้อพร้อมกัน ขอบเส้นไหนปิดได้ครบทุกข้อ

โซ่ 6 จุด v อยู่ลึกสุด มีข้อบังคับค้าง 3 ข้อ ลองระบายขอบเป็น 1 ทีละเส้น แล้วดูว่าปิดได้กี่ข้อ

ลึก 1ลึก 2ลึก 3ลึก 4ลึก 5ลึก 6 v u ลึก 2u ลึก 3u ลึก 4

ยังไม่ได้ระบายขอบไหน ทั้ง 3 ข้อยังค้าง

ลองแล้ว 0 จาก 5 ขอบ

ลองให้ครบทั้งสองเกมก่อนนะครับ โดยเฉพาะต้นที่สองกับต้นที่สามในเกมแรก และผลของเกมที่สอง เพราะเฉลยข้างล่างจะพูดถึงมัน

เฉลย ตอนที่ 1 · เริ่มจากวิธีที่ช้าแต่ถูก แล้ววัดว่ายังขาดอีกเท่าไร

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

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

วิธีที่ 1

ไล่ทุกการระบาย

2N − 1 แบบ × เดินทุกคู่

ลองทุกแบบแล้วเช็กทุกคู่ตามนิยามตรง ๆ หน้านี้ใช้มันเป็นตัวตรวจคำตอบด้วย

ได้ชุด N, M ≤ 10 (16%)

วิธีที่ 2

dp ตารางเต็ม

ผลรวมความลึกของทุกจุด

แต่ละจุดถือตารางยาวเท่าความลึกของมัน งานรวมจึงเท่ากับผลรวมความลึก ซึ่งบนเส้นตรงเป็นราว N² ÷ 2 (ตอนที่ 2 และ 3 เล่าสถานะกับสูตร)

ได้ชุด N ≤ 2,000 และชุดไบนารีสมบูรณ์

วิธีเต็ม

เซกเมนต์ทรีควบรวม

ราว N log N + M

สูตรเดียวกับวิธีที่ 2 แต่เก็บตารางในเซกเมนต์ทรี (ต้นไม้ที่แต่ละปมเก็บผลรวมของช่วง) แบบที่ช่องว่างไม่กินเวลา เล่าในตอนที่ 4

ได้ครบทุกชุด

วิธีที่ 1 ตายเร็วมาก ต้นไม้ 30 จุดก็มีการระบายเกินห้าร้อยล้านแบบแล้ว ส่วนวิธีที่ 2 ตายเฉพาะต้นไม้ลึก เพราะจุดที่ลึก d ถือตารางยาว d ช่อง

10610810101012 ตารางเต็มเซกเมนต์ทรีควบรวม 1.3×1011 เกินงบ 625 เท่า1×107 ทัน งบ 2 วินาที
จำนวนก้าวบนต้นไม้เส้นตรง 500,000 จุด สเกลแบบ log (ขีดละร้อยเท่า) เส้นทองคืองบ 2 วินาที โดยคิดหยาบ ๆ ว่าเครื่องทำได้ราว 10⁸ ก้าวต่อวินาที วิธีที่ 1 ไม่ได้วาดเพราะ 2 ยกกำลัง 499,999 ยาวเกินกราฟไปไกลมาก

ตารางเต็มบนเส้นตรงแค่ 100,000 จุด วัดได้จริง 60.23 วินาที ทั้งที่ขอบเขตจริงยาวกว่านั้นห้าเท่า และเวลาโตเป็นกำลังสอง

สิ่งที่ทั้งวิธีที่ 2 และวิธีเต็มใช้ร่วมกันคือสถานะของ dp ซึ่งเป็นแก่นของข้อนี้ เริ่มจากตรงนั้นก่อน

เฉลย ตอนที่ 2 · จำแค่ข้อบังคับที่ค้างอยู่ลึกที่สุด

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

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

ผมรู้ตั้งแต่อ่านโจทย์จบว่านี่เป็น dp บนต้นไม้ สิ่งที่ยังไม่รู้คือสถานะต้องเป็นอะไร

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

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

นึกภาพ

ข้อค้างแต่ละข้อเหมือนพัสดุที่ต้องส่งก่อนเส้นตายของตัวเอง ระหว่างไต่ขึ้นหาราก เราเจอเส้นตายของ u ที่ลึกที่สุดก่อนเสมอ

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

สถานะที่ใช้ทั้งหน้า

ให้ความลึกของรากเป็น 1 แล้ว dp[v][j] = จำนวนวิธีระบายเส้นในกิ่งของ v ที่

  • ทุกข้อบังคับที่ทั้ง u และปลายล่างอยู่ในกิ่งนี้ ถูกปิดแล้ว
  • ในบรรดาข้อค้าง ข้อที่ u ลึกที่สุดอยู่ที่ความลึก j พอดี

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

ระดับค้างอยู่ในช่วง 0 ถึง ความลึกของ v ลบหนึ่ง เพราะ u อยู่เหนือ v เสมอ

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

โค้ดทำสิ่งนี้ด้วยบรรทัดเดียว คือเก็บ att[v] (ย่อจาก attach แปลว่าแขวน) เป็นค่ามากสุดของความลึกของ u ทุกตัวที่จบที่ v คู่ซ้ำจึงไม่ต้องนับสองรอบไปด้วยเลย

เฉลย ตอนที่ 3 · สองทางเลือกที่ขอบ กับการรวมสองกิ่งเข้าด้วยกัน

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

เรื่องแรก · ส่งตารางผ่านขอบขึ้นไปหาพ่อ

ขอบที่ต่อจุด c กับพ่อ ให้ D เป็นความลึกของพ่อ ตารางที่ c ส่งขึ้นไปคือ g มีช่อง 0 ถึง D − 1 และ S คือผลรวมทั้งตารางของลูก

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

  • ช่อง 0 รับ S มาจากการระบายขอบนี้เป็น 1 ข้อค้างทุกข้อที่หอบขึ้นมา ถูกปิดตรงนี้พร้อมกันหมด ไม่ว่าก่อนหน้านี้จะค้างอยู่ระดับไหน ตารางทั้งตารางจึงยุบมาลงช่องเดียว
  • ช่อง 0 รับ dp[c][0] เพิ่มอีกทาง จากการระบายเป็น 0 ตอนที่ไม่มีอะไรค้างอยู่แล้ว ไม่มีอะไรให้หอบ มันก็ยังไม่มีอะไรให้หอบต่อ
  • ช่อง j ตั้งแต่ 1 ถึง D - 1 รับ dp[c][j] ตรง ๆ จากการระบายเป็น 0 ทั้งที่ยังค้างอยู่ที่ระดับ j เส้นตายไม่เลื่อน เพราะมันผูกกับความลึกของ u แค่เสียโอกาสไปหนึ่งเส้น
  • ช่อง dp[c][D] ไม่ไหลไปไหนเลยในทางระบาย 0 เพราะ D คือความลึกของพ่อ แปลว่าข้อค้างนั้นมี u เป็นพ่อของ c พอดี ขอบนี้คือเส้นสุดท้ายบนวิถีของมัน ระบาย 0 คือยอมแพ้ (ส่วนทางระบาย 1 นับมันไปแล้วใน S)
ข้อบังคับที่ u อยู่ลึก 2 ลึก 1ลึก 2ลึก 3ลึก 4ลึก 5 ข้อบังคับยังค้าง u v ระบาย 1 > ระดับค้าง 0 ระบาย 0 > ระดับค้าง 2 ข้อบังคับที่ u อยู่ลึก 4 ลึก 1ลึก 2ลึก 3ลึก 4ลึก 5 ข้อบังคับยังค้าง u v ระบาย 1 > ระดับค้าง 0 ระบาย 0 > ตาย
โซ่ 5 ระดับ v อยู่ล่างสุด เส้นหนาคือขอบเหนือ v ซ้าย: u ลึก 2 ระบาย 0 แล้วยังค้างต่อได้ ขวา: u ลึก 4 คือพ่อของ v พอดี ระบาย 0 แล้วสายนี้ตาย

เรื่องที่สอง · รวมหลายกิ่งเข้าด้วยกัน

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

ให้ A กับ B เป็นสองตาราง preA, preB เป็นผลรวมสะสม และถือว่า preA[−1] = 0 จุดที่ยังไม่มีลูกเริ่มจากตาราง [1, 0, 0, …] ซึ่งรวมกับอะไรก็ได้ของเดิม

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

เรื่องที่สาม · แขวนข้อบังคับที่จบที่จุดนี้

ทำหลังรวมลูกครบแล้ว ให้ b = att[v] ทุกช่องที่ j น้อยกว่า b ถูกกวาดมาเทลงช่อง b เพราะเงื่อนไขใหม่เข้มกว่าของเดิม ช่องที่ j ไม่น้อยกว่า b ไม่ต้องแตะ เพราะของเดิมเข้มกว่าอยู่แล้ว

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

ลองทายก่อน: แถวของจุด 3 คือ [0, 2, 2] แล้วแถวของจุด 2 คืออะไร

จุด 2 ลึก 2 ตารางจึงมีสองช่อง ส่งจุด 3 ผ่านขอบ 2-3: ผลรวมทั้งแถวคือ 4 ช่อง 0 ได้ 0 + 4 ช่อง 1 รับ 2 ตรง ๆ ส่วนช่อง 2 (2) หายเพราะ u ของมันคือจุด 2 เอง จุด 2 ไม่มีคู่จบที่ตัวเอง แถวจึงเป็น [4, 2]

ตาราง dp ของตัวอย่างที่หนึ่ง จากใบขึ้นไปหาราก

12345
จุด ลึก att j = 0j = 1j = 2j = 3
5 4 2 0010
4 4 · 1000
3 3 1 022·
2 2 · 42··
1 1 · 10···

"·" คือช่องที่ไม่มีอยู่ เพราะระดับค้างต้องตื้นกว่าตัวจุดเอง

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

อ่านคำตอบจากแถวของจุด 2

ทางระบายขอบ 1-2 เป็น 1 = ผลรวมทั้งแถว = 6
ทางระบายเป็น 0 ที่ไม่มีอะไรค้าง = ช่อง 0 = 4
รวม 6 + 4 = 10 ตรงกับเอาต์พุต

เฉลย ตอนที่ 4 · ตารางเต็มตายที่ต้นไม้ลึก เซกเมนต์ทรีที่ควบรวมได้ช่วยตรงนี้

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

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

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

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

ทำไมฝั่งว่างจบในขั้นเดียว

ถ้า B ว่างทั้งช่วง [l, r] แปลว่า B[j] = 0 และ preB[j] เท่ากับผลรวมของ B ก่อน l ทุกช่อง สูตรรวมเหลือ C[j] = A[j] × ค่าคงที่

ค่าคงที่นี้รู้ได้ระหว่างเดิน เพราะการควบรวมพกผลรวมสะสมของทั้งสองฝั่งไปจากซ้ายไปขวา (รวมช่อง 0 ที่อยู่นอกต้นไม้ด้วย) ก่อนลงลูกขวาก็บวกผลรวมของลูกซ้ายเข้าไป

นั่นคือการคูณทั้งช่วงด้วยเลขตัวเดียว ซึ่งป้ายคูณทำได้ที่ปมเดียว ฝั่ง A ว่างก็เหมือนกัน แค่สลับบทบาท

ต้น A ต้น B B ว่างตรงนี้ คูณค่าคงที่ จบ A ว่างตรงนี้ คูณค่าคงที่ จบ ไม่มีปม
การควบรวมเดินลงเฉพาะตรงที่ทั้งสองต้นมีปม (เขียว) ตรงที่ต้นหนึ่งว่าง (เทา) ปมของอีกต้นถูกแปะป้ายคูณแล้วจบทันที

อีกสองงานของตอนที่ 3 ทำบนต้นไม้แบบนี้

  • ส่งผ่านขอบ: ตัดช่อง D ทิ้ง (ลบหนึ่งช่อง) แล้วบวกผลรวมทั้งต้นเข้าช่อง 0 ใช้ราว log N
  • แขวน: ตัดช่วง 1 ถึง b − 1 ทิ้งพร้อมเก็บผลรวม แล้วบวกผลรวมนั้นเข้าช่อง b ใช้ราว log N

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

ปมใหม่เกิดเฉพาะตอนแขวน ครั้งละไม่เกินราว log N ปม และไม่เกินหนึ่งครั้งต่อจุด รวมแล้วเป็นราว O(N log N + M) โดย M มาจากการอ่านคู่ ซึ่งยุบเหลือ att ต่อจุดตั้งแต่อ่าน

รายละเอียดที่โค้ดเต็มทำต่างจากสูตรมีสองที่

  • ช่อง j = 0 เป็นตัวแปรธรรมดาชื่อ s[v] ไม่อยู่ในต้นไม้ เพราะทางระบาย 1 เทของลงช่องนี้ทุกขอบ ถ้าอยู่ในต้นไม้ทุกขอบจะต้องสร้างปมใหม่
  • ปมที่ทิ้งแล้วเข้ากองว่าง (freeList) แล้วเอามาใช้ซ้ำ

โค้ด

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

แผนที่โค้ด
ช่วงในโค้ดทำอะไรอ่านคู่กับ
อ่านอินพุตอ่านทั้งก้อนแล้วแกะตัวเลขเอง อินพุตใหญ่ราว 13 เมกะไบต์ตารางเวลา
เซกเมนต์ทรีที่ควบรวมได้บวกช่องเดียว ตัดช่วงทิ้ง และควบรวมสองต้นพร้อมป้ายคูณตอนที่ 4
ปักรากที่ 1 หาความลึกกับลำดับใช้ BFS (ค้นตามแนวกว้าง) เพราะต้นไม้ลึกได้ห้าแสน การเรียกตัวเองซ้ำจะทำสแต็กล้น ลำดับย้อนกลับคือจากใบขึ้นรากตอนที่ 3
ข้อบังคับ: เก็บแค่ u ที่ลึกที่สุดต่อจุดปลายatt[v] = ค่ามากสุด คู่ซ้ำหายไปเองตรงนี้ตอนที่ 2
ไล่จากใบขึ้นหารากแขวน ส่งผ่านขอบ แล้วรวมเข้าตารางของพ่อตอนที่ 3
ดูโค้ดเต็มขอบเขต (เซกเมนต์ทรีควบรวม)
destiny_full.cpp
// NOI 2020 "Destiny" / โชคชะตา (programming.in.th 2042)
// เต็มขอบเขต: N, M <= 500,000  เวลา O((N + M) log N)
//
// dp[v][j] = จำนวนวิธีระบายเส้นในกิ่งของ v ที่ข้อบังคับในกิ่งปิดครบ
//            และข้อที่ยังค้าง ข้อที่ u ลึกที่สุดอยู่ที่ความลึก j (j = 0 คือไม่มีอะไรค้าง)
// ช่อง j = 0 เก็บเป็นตัวแปร s[v] ส่วนช่อง j >= 1 เก็บในเซกเมนต์ทรีที่ควบรวมได้ (แกนคือความลึก)
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const ll MOD = 998244353;

// ---------- อ่านอินพุต ----------
static char buf[1 << 25];
int bufLen = 0, bufPos = 0;
inline int readChar() {
    if (bufPos == bufLen) { bufLen = (int)fread(buf, 1, sizeof(buf), stdin); bufPos = 0; if (bufLen <= 0) return -1; }
    return buf[bufPos++];
}
inline int readInt() {
    int c = readChar();
    while (c != -1 && (c < '0' || c > '9')) c = readChar();
    int x = 0;
    while (c >= '0' && c <= '9') { x = x * 10 + (c - '0'); c = readChar(); }
    return x;
}

// ---------- เซกเมนต์ทรีที่ควบรวมได้ ----------
// ปมเก็บผลรวมของช่วง กับป้ายคูณที่ยังไม่ส่งลงลูก (lazy tag)
// ปมที่ถูกทิ้งตอนควบรวมเข้ากองว่าง freeList แล้วเอากลับมาใช้ใหม่
vector<int> lc, rc;
vector<ll> sm, mul;
vector<int> freeList;
int newNode() {
    int x;
    if (!freeList.empty()) { x = freeList.back(); freeList.pop_back(); }
    else { x = (int)lc.size(); lc.push_back(0); rc.push_back(0); sm.push_back(0); mul.push_back(1); }
    lc[x] = rc[x] = 0; sm[x] = 0; mul[x] = 1;
    return x;
}
void freeNode(int x) { freeList.push_back(x); }
inline void applyMul(int x, ll k) { if (x) { sm[x] = sm[x] * k % MOD; mul[x] = mul[x] * k % MOD; } }
inline void push(int x) {
    if (mul[x] != 1) { applyMul(lc[x], mul[x]); applyMul(rc[x], mul[x]); mul[x] = 1; }
}
inline void pull(int x) { sm[x] = ((lc[x] ? sm[lc[x]] : 0) + (rc[x] ? sm[rc[x]] : 0)) % MOD; }

// บวก val ที่ช่อง pos
int addAt(int x, int l, int r, int pos, ll val) {
    if (!x) x = newNode();
    if (l == r) { sm[x] = (sm[x] + val) % MOD; return x; }
    push(x);
    int m = (l + r) / 2;
    if (pos <= m) lc[x] = addAt(lc[x], l, m, pos, val);
    else rc[x] = addAt(rc[x], m + 1, r, pos, val);
    pull(x);
    return x;
}
// ลบช่วง [ql, qr] ทิ้ง คืนผลรวมของสิ่งที่ลบผ่าน taken
int cutRange(int x, int l, int r, int ql, int qr, ll &taken) {
    if (!x || qr < l || r < ql) return x;
    if (ql <= l && r <= qr) {
        // ทิ้งทั้งต้นย่อย คืนแค่ปมบนสุดเข้ากองว่าง ปมลูกหลานไม่มีใครชี้ถึงแล้วจึงไม่เป็นปัญหา
        taken = (taken + sm[x]) % MOD;
        freeNode(x);
        return 0;
    }
    push(x);
    int m = (l + r) / 2;
    lc[x] = cutRange(lc[x], l, m, ql, qr, taken);
    rc[x] = cutRange(rc[x], m + 1, r, ql, qr, taken);
    if (!lc[x] && !rc[x]) { freeNode(x); return 0; }
    pull(x);
    return x;
}
// รวมสองตาราง C[j] = A[j] * preB[j] + B[j] * preA[j-1]
// pa = ผลรวมของ A ทุกช่องก่อน l, pb = ผลรวมของ B ทุกช่องก่อน l (รวมช่อง 0 ที่อยู่นอกต้นไม้)
int mergeTree(int a, int b, int l, int r, ll pa, ll pb) {
    if (!a && !b) return 0;
    if (!b) { applyMul(a, pb); return a; }          // B ว่างทั้งช่วง preB คงที่เท่ากับ pb
    if (!a) { applyMul(b, pa); return b; }          // A ว่างทั้งช่วง preA คงที่เท่ากับ pa
    if (l == r) {
        ll A = sm[a], B = sm[b];
        sm[a] = (A * ((pb + B) % MOD) + B * pa) % MOD;
        freeNode(b);
        return a;
    }
    push(a); push(b);
    int m = (l + r) / 2;
    ll aL = lc[a] ? sm[lc[a]] : 0, bL = lc[b] ? sm[lc[b]] : 0;
    lc[a] = mergeTree(lc[a], lc[b], l, m, pa, pb);
    rc[a] = mergeTree(rc[a], rc[b], m + 1, r, (pa + aL) % MOD, (pb + bL) % MOD);
    freeNode(b);
    pull(a);
    return a;
}

int main() {
    int n = readInt();
    vector<vector<int>> adj(n + 1);
    for (int i = 0; i < n - 1; i++) { int x = readInt(), y = readInt(); adj[x].push_back(y); adj[y].push_back(x); }

    // ---------- ปักรากที่ 1 หาความลึกกับลำดับ ----------
    // ใช้ BFS เพราะต้นไม้ลึกได้ถึงห้าแสน การเรียกตัวเองซ้ำจะทำสแต็กล้น
    vector<int> par(n + 1, 0), dep(n + 1, 0), order;
    order.reserve(n);
    dep[1] = 1; order.push_back(1);
    for (size_t i = 0; i < order.size(); i++) {
        int v = order[i];
        for (int w : adj[v]) if (w != par[v]) { par[w] = v; dep[w] = dep[v] + 1; order.push_back(w); }
    }
    int maxDep = 1;
    for (int v = 1; v <= n; v++) maxDep = max(maxDep, dep[v]);

    // ---------- ข้อบังคับ: เก็บแค่ u ที่ลึกที่สุดต่อจุดปลาย ----------
    int m = readInt();
    vector<int> att(n + 1, 0);
    for (int i = 0; i < m; i++) { int u = readInt(), v = readInt(); att[v] = max(att[v], dep[u]); }

    // ---------- ไล่จากใบขึ้นหาราก ----------
    lc.reserve(1 << 22); rc.reserve(1 << 22); sm.reserve(1 << 22); mul.reserve(1 << 22);
    newNode();                               // ปม 0 คือ "ว่าง" ห้ามใช้
    vector<ll> s(n + 1, 1);                  // ช่อง j = 0 ของแต่ละจุด เริ่มที่ 1 (ยังไม่มีเส้นให้ระบาย)
    vector<int> root(n + 1, 0);              // ต้นไม้ของช่อง j >= 1
    const int L = 1, R = max(1, maxDep);
    for (int i = n - 1; i >= 0; i--) {
        int v = order[i];
        // แขวนข้อบังคับที่จบที่ v: ทุกช่องที่ j < b ถูกกวาดมาเทลงช่อง b
        int b = att[v];
        if (b >= 1) {
            ll acc = s[v];
            s[v] = 0;
            if (b - 1 >= 1) root[v] = cutRange(root[v], L, R, 1, b - 1, acc);
            root[v] = addAt(root[v], L, R, b, acc);
        }
        if (v == 1) break;
        // ส่งขึ้นไปให้พ่อ p ผ่านเส้น (p, v) ให้ D = ความลึกของพ่อ
        int p = par[v], D = dep[p];
        ll dead = 0;
        root[v] = cutRange(root[v], L, R, D, D, dead);    // ช่อง D: ระบาย 0 แล้วตาย (u คือพ่อพอดี)
        ll S = (s[v] + (root[v] ? sm[root[v]] : 0) + dead) % MOD;  // ทางระบาย 1 รับทั้งตารางเดิม
        ll g0 = (s[v] + S) % MOD;                          // ช่อง 0: ระบาย 0 ตอนไม่มีอะไรค้าง + ระบาย 1
        // รวมเข้ากับตารางของพ่อ (A = พ่อ, B = g)
        ll A0 = s[p];
        root[p] = mergeTree(root[p], root[v], L, R, A0, g0);
        s[p] = A0 * g0 % MOD;
        root[v] = 0;
    }
    printf("%lld\n", s[1] % MOD);
    return 0;
}
ดูโค้ดตารางเต็ม (ชุดย่อยที่ N เล็ก หรือต้นไม้ตื้น)
destiny_slow.cpp
// NOI 2020 "Destiny" / โชคชะตา (programming.in.th 2042) - ตารางเต็ม
// สูตรของเฉลยตอนที่ 3 เขียนตรง ๆ ตารางของจุดที่ลึก d ยาว d ช่อง
// เวลาเป็นสัดส่วนกับผลรวมความลึกของทุกจุด: ชนะชุด N <= 2,000 และชุดต้นไม้ไบนารีสมบูรณ์ (ลึกราว log N)
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const ll MOD = 998244353;

int main() {
    int n;
    if (scanf("%d", &n) != 1) return 0;
    vector<vector<int>> adj(n + 1);
    for (int i = 0; i < n - 1; i++) { int x, y; if (scanf("%d %d", &x, &y) != 2) return 0; adj[x].push_back(y); adj[y].push_back(x); }
    vector<int> par(n + 1, 0), dep(n + 1, 0), order;
    dep[1] = 1; order.push_back(1);
    for (size_t i = 0; i < order.size(); i++) {
        int v = order[i];
        for (int w : adj[v]) if (w != par[v]) { par[w] = v; dep[w] = dep[v] + 1; order.push_back(w); }
    }
    int m;
    if (scanf("%d", &m) != 1) return 0;
    vector<int> att(n + 1, 0);
    for (int i = 0; i < m; i++) { int u, v; if (scanf("%d %d", &u, &v) != 2) return 0; att[v] = max(att[v], dep[u]); }

    // cur[v] คือตารางของ v ระหว่างที่ลูกทยอยรวมเข้ามา ยาว dep[v] ช่อง (j = 0 ถึง dep[v] - 1)
    // สร้างตอนต้องใช้ครั้งแรก และทิ้งทันทีที่ส่งขึ้นไปหาพ่อแล้ว ถ้าสร้างล่วงหน้าทุกจุด
    // ต้นไม้เส้นตรงแสนจุดจะกินห้าพันล้านช่องพร้อมกัน
    vector<vector<ll>> cur(n + 1);
    auto ensure = [&](int v) { if (cur[v].empty()) { cur[v].assign(dep[v], 0); cur[v][0] = 1; } };
    for (int i = n - 1; i >= 0; i--) {
        int v = order[i];
        ensure(v);
        vector<ll> &d = cur[v];
        // แขวนข้อบังคับที่จบที่ v
        int b = att[v];
        if (b >= 1) {
            ll acc = 0;
            for (int j = 0; j < b; j++) { acc = (acc + d[j]) % MOD; d[j] = 0; }
            d[b] = (d[b] + acc) % MOD;
        }
        if (v == 1) break;
        // ขอบ (p, v): ช่อง 0 รับ dp[0] + S, ช่อง 1..D-1 รับตรง ๆ, ช่อง D ตาย
        int p = par[v], D = dep[p];
        ll S = 0;
        for (ll x : d) S = (S + x) % MOD;
        vector<ll> g(D, 0);
        g[0] = (d[0] + S) % MOD;
        for (int j = 1; j < D; j++) g[j] = d[j];
        // รวมแบบเอาค่ามากสุด: C[j] = A[j] * preB[j] + B[j] * preA[j-1]
        ensure(p);
        vector<ll> &A = cur[p];
        ll pa = 0, pb = 0;
        for (int j = 0; j < D; j++) {
            ll pbj = (pb + g[j]) % MOD;
            ll c = (A[j] * pbj + pa * g[j]) % MOD;
            pa = (pa + A[j]) % MOD;
            pb = pbj;
            A[j] = c;
        }
        vector<ll>().swap(d);
    }
    ll ans = 0;
    for (ll x : cur[1]) ans = (ans + x) % MOD;
    printf("%lld\n", ans);
    return 0;
}

โค้ดนี้คือสูตรในเฉลยตอนที่ 3 แบบเขียนตรง ๆ ตารางถูกสร้างตอนต้องใช้และทิ้งทันทีที่ส่งขึ้นไปหาพ่อ

มันชนะชุดที่ N เล็ก และชุดไบนารีสมบูรณ์ (วัดที่ N = M = 100,000 ได้ 0.19 วินาที) แต่พอต้นไม้ลึกก็ตายทันที ดูแถวเส้นตรงในตารางเวลา

ดูตัวตรวจที่ไล่ทุกการระบาย
destiny_brute.cpp
// NOI 2020 "Destiny" / โชคชะตา (programming.in.th 2042) - ตัวตรวจ
// ไล่ทุกการระบาย 2^(N-1) แบบ แล้วเดินวิถีของทุกคู่ตามนิยามในโจทย์ตรง ๆ
// ไม่ใช้ความลึก ไม่มีสถานะ ไม่ยุบข้อบังคับ ใช้ได้กับชุด N <= 10 และเป็นตัวเทียบตอน stress test
#include <bits/stdc++.h>
using namespace std;

int main() {
    int n;
    if (scanf("%d", &n) != 1) return 0;
    vector<vector<pair<int, int>>> adj(n + 1);
    for (int i = 0; i < n - 1; i++) {
        int x, y;
        if (scanf("%d %d", &x, &y) != 2) return 0;
        adj[x].push_back({y, i});
        adj[y].push_back({x, i});
    }
    // พ่อและเลขเส้นที่ต่อขึ้นไปหาพ่อ
    vector<int> par(n + 1, 0), pe(n + 1, -1), st = {1};
    vector<bool> seen(n + 1, false);
    seen[1] = true;
    while (!st.empty()) {
        int v = st.back(); st.pop_back();
        for (auto [w, e] : adj[v]) if (!seen[w]) { seen[w] = true; par[w] = v; pe[w] = e; st.push_back(w); }
    }
    int m;
    if (scanf("%d", &m) != 1) return 0;
    vector<long long> need(m, 0);   // มาสก์ของเส้นบนวิถีของแต่ละคู่
    for (int i = 0; i < m; i++) {
        int u, v;
        if (scanf("%d %d", &u, &v) != 2) return 0;
        for (int x = v; x != u; x = par[x]) need[i] |= 1LL << pe[x];
    }
    long long cnt = 0;
    for (long long mask = 0; mask < (1LL << (n - 1)); mask++) {
        bool ok = true;
        for (int i = 0; i < m && ok; i++) if (!(mask & need[i])) ok = false;
        if (ok) cnt++;
    }
    printf("%lld\n", cnt % 998244353);
    return 0;
}

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

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

เวลาที่วัดได้จริง
ชุดทดสอบโปรแกรมเวลาหน่วยความจำสูงสุด
ต้นไม้สุ่ม โค้ดเต็ม 0.61 วินาที 63 เมกะไบต์
เส้นตรง (ลึก 500,000) โค้ดเต็ม 0.86 วินาที 116 เมกะไบต์
ดาว (ลึก 2) โค้ดเต็ม 0.34 วินาที 53 เมกะไบต์
ไบนารีสมบูรณ์ โค้ดเต็ม 0.60 วินาที 70 เมกะไบต์
ไม้กวาด (ก้านยาวครึ่งหนึ่ง) โค้ดเต็ม 0.88 วินาที 90 เมกะไบต์
พุ่มลึก (พ่อห่างขึ้นไป 1 ถึง 5) โค้ดเต็ม 1.10 วินาที 109 เมกะไบต์
เส้นตรง N = M = 100,000 ตารางเต็ม 60.23 วินาที 14 เมกะไบต์
เส้นตรง N = M = 100,000 โค้ดเต็ม 0.13 วินาที 23 เมกะไบต์
ไบนารีสมบูรณ์ N = M = 100,000 ตารางเต็ม 0.19 วินาที 25 เมกะไบต์
หกแถวบนคือ N เท่ากับ M เท่ากับ 500,000 สามแถวล่างเป็น 100,000 จุด เพื่อเทียบสองโปรแกรมกันตรง ๆ วัดบน WSL Ubuntu 22.04 ด้วย g++ 11.4.0 แบบ -O2 เอาค่าที่แย่ที่สุดจากสามรอบ เวลานี้รวมเวลาอ่านอินพุตขนาด 12 ถึง 13 เมกะไบต์ด้วย
  • ทั้งสามโปรแกรมตอบตัวอย่างที่หนึ่งได้ 10 และตัวอย่างที่สองได้ 960
  • สุ่มต้นไม้ 1,500 ต้น ขนาดไม่เกิน 12 จุด หกรูปทรง (สุ่ม เส้นตรง ดาว ไบนารีสมบูรณ์ ไม้กวาด พุ่มลึก) โค้ดเต็มและตารางเต็มตรงกับตัวไล่ทุกแบบทุกต้น
  • อีก 300 ต้น ขนาดไม่เกิน 3,049 จุด โค้ดเต็มตรงกับตารางเต็มทุกต้น
  • ตัวสร้างอินพุตใส่คู่ซ้ำราวหนึ่งในสิบ และให้ u ลึกเกาะกลุ่มบ้าง จะได้มีจุดที่รับข้อบังคับหลายความลึก

ทั้งหมดนี้ตรวจกับตัวตรวจของผมเองกับตัวอย่างในโจทย์ ยังไม่ได้ส่งเข้าเครื่องตรวจจริง

เช็กความเข้าใจ

สามข้อนี้ตอบได้จากสิ่งที่อ่านมา ลองคิดก่อนแล้วค่อยเปิดคำตอบ

ข้อ 1. ถ้าคู่เดิมโผล่ซ้ำสิบครั้ง โค้ดต้องทำอะไรเพิ่มไหม

ดูคำตอบข้อ 1

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

ข้อ 2. ตอนส่งตารางจากจุด c ขึ้นไปหาพ่อ ทำไมช่อง D (ความลึกของพ่อ) ถึงหายไปในทางระบาย 0

ดูคำตอบข้อ 2

ระดับค้าง D แปลว่าข้อค้างที่เข้มที่สุดมี u เป็นพ่อของ c พอดี ขอบระหว่าง c กับพ่อจึงเป็นเส้นสุดท้ายบนวิถีของมัน ระบาย 0 คือปล่อยโอกาสสุดท้ายทิ้ง สายนั้นจึงไม่มีทางถูกต้อง (ทางระบาย 1 ยังนับมันไว้ใน S)

ข้อ 3. ทำไมตารางเต็มเร็วบนต้นไม้ไบนารีสมบูรณ์ แต่ช้ามากบนเส้นตรง ทั้งที่ N เท่ากัน

ดูคำตอบข้อ 3

งานของตารางเต็มเท่ากับผลรวมความลึกของทุกจุด ไบนารีสมบูรณ์ลึกแค่ราว log N ผลรวมจึงราว N log N ส่วนเส้นตรงลึกถึง N ผลรวมเป็นราว N² ÷ 2 ที่ 100,000 จุดจึงต่างกัน 0.19 วินาที กับ 60.23 วินาที

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

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

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

แหล่งที่มา

  1. โจทย์ โชคชะตา (Destiny) บน programming.in.th ข้อ 2042 programming.in.th/tasks/2042 (สืบค้น 11 กันยายน 2026)
  2. ต้นฉบับจาก The 37th CCF National Olympiad in Informatics (NOI 2020) ตามที่ระบุไว้ท้ายโจทย์ภาษาไทย
  3. ตัวเลขเวลาและหน่วยความจำในหน้านี้ผมวัดเองบน WSL Ubuntu 22.04 ด้วย g++ 11.4.0 แบบ -O2 ไม่ใช่ผลจากระบบตรวจของโจทย์