กลับหมวดโจทย์
บันทึกศาสตร์แห่งเทพ ปูพื้นฐาน
เร่งดีพีด้วยเปลือกนูน: เมื่อทางเลือกทุกทางเป็นเส้นตรง ก็เหลือแค่เส้นที่ต่ำที่สุด ดีพีที่ i กับ j พันกันอยู่ในวงเล็บเดียว ยุบจาก O(n²) เหลือ O(n) ได้ ถ้ากระจายวงเล็บนั้นออกมาแล้วเห็นว่าทางเลือกทุกทางคือเส้นตรง บทนี้ไล่ครบสามระดับ คือสแต็กกับตัวชี้ ค้นหาแบบไบนารีบนเปลือก และต้นไม้ลีเชา พร้อมกฎสองข้อที่ตัดสินว่าใช้อันไหน เกมหาเส้นที่ทิ้งได้ ตัวอย่างค้านที่เล็กที่สุดของการใช้ผิดระดับ และโจทย์ฝึกสี่ข้อไล่จากง่ายไปยาก
บทปูพื้นฐาน ★★★★☆ dp geometry segment tree พื้นฐาน อ่าน 26 นาที 10 กันยายน 2026
ปัญหาที่บทนี้แก้
มหาวิทยาลัยจะสร้างทางเดินมีหลังคา บนทางเดินมีจุดที่ต้องมีหลังคาคลุมให้ได้ อยู่ 10 จุด จุดอื่นคลุมหรือไม่คลุมก็ไม่มีใครสน ผู้รับเหมาคิดราคาแปลก ๆ คือหลังคาหนึ่งผืน
ที่พาดจากตำแหน่ง x ถึงตำแหน่ง y ราคา 5000 บวกกับ
ระยะพาดยกกำลังสอง ผืนที่คลุมจุดเดียวก็ยังต้องจ่ายค่าคงที่ 5000 อยู่
ราคาแบบนี้บีบจากสองข้างพอดี ผืนใหญ่ผืนเดียวคลุมทุกจุดจ่าย 1,041,324
เพราะระยะพาดถูกยกกำลังสอง ส่วนการแยกเป็นจุดละผืนจ่าย 50,000
เพราะค่าคงที่โดนคิดซ้ำ 10 รอบ คำตอบที่ถูกที่สุดอยู่ตรงกลางระหว่างสองทางนั้น คือ 30,726 ด้วยหลังคา 5 ผืน
ทางเดิน · จุดที่ต้องคลุมคือขีดสีทอง 1 23 45 67 101 124 560 789 990 1019
ผืนที่ 1
พาด 66 จ่าย 9356
ผืนที่ 2
พาด 23 จ่าย 5529
ผืนที่ 3
พาด 0 จ่าย 5000
ผืนที่ 4
พาด 0 จ่าย 5000
ผืนที่ 5
พาด 29 จ่าย 5841
รวม 30726
ขยาย จุดที่ต้องคลุมทั้ง 10 จุด กับหลังคา 5 ผืนที่ถูกที่สุด แถบเทาคือช่วงที่แต่ละผืนพาด ผืนที่กว้างที่สุดพาด 66 หน่วยจึงจ่ายถึง 9356 ขณะที่อีก 2 ผืนคลุมจุดเดียวจ่ายแค่ค่าคงที่ จุดที่ต้องคลุมทั้ง 10 จุด กับหลังคา 5 ผืนที่ถูกที่สุด แถบเทาคือช่วงที่แต่ละผืนพาด ผืนที่กว้างที่สุดพาด 66 หน่วยจึงจ่ายถึง 9356 ขณะที่อีก 2 ผืนคลุมจุดเดียวจ่ายแค่ค่าคงที่
เลื่อนล้อเมาส์เพื่อซูม คลิกลากเพื่อเลื่อนภาพ (บนมือถือใช้สองนิ้วขยาย นิ้วเดียวลากเลื่อน)
เขียนสูตรได้ไม่ยาก มองว่าหลังคาผืนสุดท้ายคลุมจุดที่ j+1 ถึงจุดที่ i
แล้วที่เหลือเป็นโจทย์เดิมที่เล็กลง
d p [ i ] = 0 ≤ j < i m i n ( d p [ j ] + c + ( x [ i ] − x [ j + 1 ] ) 2 )
สูตรนี้มีช่องให้เติม n ช่อง แต่ละช่องไล่ j ได้ถึง n ค่า
รวมเป็น O(n²) ซึ่งไหวถ้า n อยู่ระดับหมื่น
แต่โจทย์ต้นทางให้ n ถึงหนึ่งล้าน
วัดหัวต่อหัว · g++ 11.4 ตัวเดียวกัน คอมไพล์ด้วย -O2 n ไล่ทางเลือกทุกทาง ถามเปลือกนูน เร็วขึ้น 20,000 0.15 s 0.004 s 37 เท่า 100,000 4.18 s 0.014 s 299 เท่า 200,000 14.75 s 0.024 s 615 เท่า 1,000,000 ไม่ได้วัด 0.076 s ไม่ได้วัด
สองแถวนี้ไม่ใช่การประมาณ ผมคอมไพล์ทั้งสองตัวแล้วรันอินพุตชุดเดียวกันจริง จุดสุ่มระหว่าง 1 ถึงหนึ่งพันล้าน
ทั้งคู่ตอบเลขเดียวกันทุกครั้ง ค่าของฝั่งเปลือกนูนเฉลี่ยจากการรันห้าครั้ง เพราะครั้งเดียวสั้นเกินกว่านาฬิกาจะละเอียดพอ
แถวสุดท้ายผมไม่ได้รันฝั่งไล่ทุกทาง เพราะเวลาที่ต้องใช้ยาวเกินกว่าจะนั่งรอ และตัวเลขที่ไม่ได้วัดผมไม่เขียนลงตาราง
ชื่อของท่าที่จะแก้เรื่องนี้
ท่านี้ชื่อ convex hull trick (ย่อว่า CHT ) อ่านว่า "คอนเวกซ์ ฮัลล์ ทริก" คำว่า convex อ่านว่า "คอนเวกซ์" แปลว่า "นูนออก" เป็นคำที่ใช้เรียกรูปที่ไม่มีรอยบุบเข้าไปข้างใน ส่วน hull อ่านว่า "ฮัลล์" แปลตรงตัวว่า "เปลือก" หรือ "ท้องเรือ"
รวมกันเป็น "เปลือกนูน" คือรูปนูนที่เล็กที่สุดที่ห่อของทั้งกองไว้ได้
ในบทนี้เราจะไม่ใช้เปลือกนูนทั้งรูป ใช้แค่ขอบล่างของมัน ซึ่งเรียกว่า lower envelope อ่านว่า "โลเวอร์ เอนเวอโลป" แปลว่า "ซองล่าง" หรือขอบล่างสุดที่ห่อของทั้งกองไว้
ท่านี้เป็นสมาชิกคนที่สามของตระกูล "เร่งดีพีด้วยการตัดทางเลือกที่ไม่มีสิทธิ์ชนะทิ้ง" ในคลังนี้ อีกสองคนคือ
แบ่งครึ่งเร่งดีพี กับ
คะนูธ จุดต่างอยู่ที่รูปของทางเลือก
สองท่านั้นอ้างว่าจุดตัดที่ดีที่สุดเดินไปทางเดียว ส่วนท่านี้อ้างว่า
ทางเลือกทุกทางเป็นเส้นตรงบนระนาบเดียวกัน พออ้างได้แบบนั้น
เครื่องมือเรขาคณิตก็เข้ามาช่วยตัดทางเลือกทิ้งได้ทันที
รูปที่ต้องมองให้ออก คือทางเลือกทุกทางเป็นเส้นตรง
กลับไปดูสูตรข้างบนอีกที ก้อนที่แพงคือ (x[i] - x[j+1])² ซึ่งมี i กับ j พันกันอยู่ในวงเล็บเดียว นั่นคือสาเหตุที่ต้องไล่ทุก j
ถ้า กระจายกำลังสองออกมา ปมนั้นจะคลายเอง
d p [ i ] = x [ i ] 2 + c + j m i n ( ความชัน ( − 2 x [ j + 1 ]) ⋅ x [ i ] + จุดตัดแกน ( d p [ j ] + x [ j + 1 ] 2 ) )
หลังกระจาย ก้อนที่อยู่นอก min ขึ้นกับ i ล้วน คิดทีเดียวจบ
ส่วนก้อนที่อยู่ใน min มีรูปที่สำคัญมาก คือ ตัวเลขที่ขึ้นกับ j เท่านั้น
คูณกับตัวเลขที่ขึ้นกับ i เท่านั้น บวกตัวเลขที่ขึ้นกับ j เท่านั้น
d p [ i ] = ( ก้อนที่ขึ้นกับ i ล้วน ) + j m i n ( m j ⋅ x i + b j )
รูปนี้คุณเห็นมาตั้งแต่มัธยม มันคือ y = mx + b นั่นเอง ความชันคือ m_j จุดตัดแกนตั้งคือ b_j ทั้งสองค่าคิดจบตอนที่เรารู้ dp[j] แล้ว
ส่วน x_i คือตำแหน่งที่เราไปถาม
คำถามเปลี่ยนไปแล้ว และนี่คือสิ่งเดียวที่บทนี้ทำ
จากเดิม "ไล่ดู j ทุกตัวว่าตัวไหนให้ค่าน้อยสุด" กลายเป็น "มีเส้นตรงกองหนึ่งวางอยู่บนกระดาษ ที่ตำแหน่ง x นี้ เส้นไหนอยู่ต่ำที่สุด"
คำถามสองอันนี้เป็นคำถามเดียวกันเป๊ะ ต่างกันแค่คำถามที่สองมีรูปให้ดู
และรูปนั้นมีสมบัติที่ทำให้ทิ้งทางเลือกได้เป็นกอง
วาดออกมาดูจริง นี่คือเส้นตรงห้าเส้น เส้นเขียวหนาคือขอบล่าง ซึ่งก็คือคำตอบของทุก x พร้อมกันทั้งแถบ
เส้นตรง 5 เส้น กับขอบล่างของมัน เส้นประคือเส้นที่ไม่เคยเป็นเส้นที่ต่ำที่สุดที่ตำแหน่งไหนเลย จุดหักของขอบล่างมี 3 จุด และแต่ละเส้นที่อยู่บนขอบล่างเป็นเจ้าของช่วง x ที่ติดกันเป็นพืดเดียว ไม่ใช่หลายท่อน เส้นตรง 5 เส้น กับขอบล่างของมัน เส้นประคือเส้นที่ไม่เคยเป็นเส้นที่ต่ำที่สุดที่ตำแหน่งไหนเลย จุดหักของขอบล่างมี 3 จุด และแต่ละเส้นที่อยู่บนขอบล่างเป็นเจ้าของช่วง x ที่ติดกันเป็นพืดเดียว ไม่ใช่หลายท่อน
เลื่อนล้อเมาส์เพื่อซูม คลิกลากเพื่อเลื่อนภาพ (บนมือถือใช้สองนิ้วขยาย นิ้วเดียวลากเลื่อน)
จากรูปนี้มีข้อสังเกตสองข้อที่เป็นทั้งหมดของท่านี้
เส้นที่อยู่บนขอบล่าง เป็นเจ้าของช่วง x ที่ติดกันเป็นพืดเดียว ไม่มีเส้นไหนชนะเป็นช่วง ๆ
แล้วหายไปแล้วกลับมาชนะอีก เพราะเส้นตรงสองเส้นตัดกันได้ที่เดียว
เรียงตามความชัน คือเรียงตามตำแหน่งบนขอบล่างพอดี เส้นที่ชันมาก (ในกรณีหาค่าน้อยสุด
คือชันเป็นบวกมาก) เป็นเจ้าของฝั่งซ้าย เส้นที่ชันน้อยเป็นเจ้าของฝั่งขวา
ผลที่ตามมาคือ เส้นที่ไม่ได้อยู่บนขอบล่าง ทิ้งได้ทันทีและตลอดไป
ในรูปข้างบนมีเส้นแบบนั้น 1 เส้น เก็บไว้ก็เปลืองเวลาเปล่า ๆ
เพราะไม่ว่าจะถามที่ x ไหน มันก็ไม่เคยเป็นคำตอบ
ลองเอง · เส้นไหนไม่มีวันชนะ
ก่อนไปดูวิธีทิ้งเส้นด้วยเครื่อง ลองทิ้งด้วยมือก่อน กติกาคือกดชิปของเส้นที่คุณคิดว่า
ไม่มีวันเป็นเส้นที่ต่ำที่สุด ไม่ว่าจะถามที่ x ไหนก็ตาม กดครบแล้วกดตรวจ
ระวังว่าแกน x ยืดไปทั้งสองข้างไม่มีที่สิ้นสุด เส้นที่ดูสูงในกรอบอาจไปชนะไกล ๆ นอกกรอบได้
ชุดที่ 1 · อุ่นเครื่อง ชุดที่ 2 · ชันเท่ากัน ชุดที่ 3 · เส้นที่ถูกบีบจากสองข้าง ชุดที่ 4 · ชุดที่ไม่มีอะไรให้ทิ้ง
สี่เส้น มีเส้นหนึ่งที่ดูออกได้ตั้งแต่ยังไม่ต้องคิด ใช้ชินกับกติกาก่อน
เริ่มใหม่ ชุดนี้ทุกเส้นใช้ได้ ตรวจคำตอบ
กดชิปของเส้นที่คิดว่าไม่มีวันชนะ แล้วกดตรวจคำตอบ
ยังไม่ได้เลือกเส้นไหน
ชิปที่ถูกเลือกจะจางลง หมายถึงเส้นที่คุณกำลังบอกว่าทิ้งได้
พอมีคำตอบในใจแล้ว มาดูกันว่าเครื่องรู้ได้ยังไงว่าเส้นไหนทิ้งได้ โดยไม่ต้องวาดรูปเลย
เครื่องรู้ได้ยังไงว่าเส้นไหนทิ้งได้
ตัวตัดสินคือจุดตัด สมมติว่ามีเส้นสามเส้นเรียงตามความชันจากมากไปน้อย เรียกว่า a, b, c คำถามคือเส้นกลาง b ยังมีที่ทางของตัวเองไหม
เส้น b จะได้ครองช่วงของตัวเอง ก็ต้องมีช่วงที่มันชนะทั้ง a และ c
เส้น b ชนะ a ตั้งแต่จุดที่ a กับ b ตัดกันไปทางขวา
และ b ชนะ c จนถึงจุดที่ b กับ c ตัดกัน
ถ้าจุดตัดสองจุดนั้นสลับข้างกัน ช่วงของ b ก็หายไปเลย
y=0 x
x=14
x=5
เพื่อนบ้านตัดกันเองที่ x=9.5
เส้นเขียวคือเพื่อนบ้านสองข้าง · เส้นประคือเส้นที่ถูกบีบ
จุดตัดสลับข้างกัน เส้นประจึงไม่เหลือช่วงของตัวเอง
ขยาย เส้นกลางที่ถูกบีบจนไม่เหลือช่วงของตัวเอง เส้นประคือเส้นที่ทิ้งได้ มันตัดเพื่อนบ้านที่ชันกว่าที่ x = 14 และตัดเพื่อนบ้านที่ชันน้อยกว่าที่ x = 5 ซึ่งอยู่สลับข้างกัน วงกลมสีฟ้าคือที่ที่เพื่อนบ้านสองข้างตัดกันเอง ตรงตำแหน่งนั้นเพื่อนบ้านอยู่ที่ y = -7.5 ส่วนเส้นประอยู่ที่ y = 1.5 ซึ่งสูงกว่า เส้นกลางที่ถูกบีบจนไม่เหลือช่วงของตัวเอง เส้นประคือเส้นที่ทิ้งได้ มันตัดเพื่อนบ้านที่ชันกว่าที่ x = 14 และตัดเพื่อนบ้านที่ชันน้อยกว่าที่ x = 5 ซึ่งอยู่สลับข้างกัน วงกลมสีฟ้าคือที่ที่เพื่อนบ้านสองข้างตัดกันเอง ตรงตำแหน่งนั้นเพื่อนบ้านอยู่ที่ y = -7.5 ส่วนเส้นประอยู่ที่ y = 1.5 ซึ่งสูงกว่า
เลื่อนล้อเมาส์เพื่อซูม คลิกลากเพื่อเลื่อนภาพ (บนมือถือใช้สองนิ้วขยาย นิ้วเดียวลากเลื่อน)
เขียนเงื่อนไข "จุดตัดสลับข้างกัน" เป็นสูตรได้ตรง ๆ คือจุดตัดของ a กับ c
อยู่ซ้ายกว่าหรือตรงกับจุดตัดของ a กับ b
m a − m c b c − b a ≤ m a − m b b b − b a
แต่สูตรนี้เขียนลงโค้ดตรง ๆ ไม่ได้ เพราะมันเป็นการหาร ซึ่งพาทศนิยมเข้ามา
ท่าที่ปลอดภัยคือคูณไขว้ ตัวหารทั้งสองข้างเป็นบวกแน่ เพราะ a ชันกว่าทั้งสองตัว
จึงคูณไขว้ได้โดยไม่ต้องกลับเครื่องหมาย
( b c − b a ) ( m a − m b ) ≤ ( b b − b a ) ( m a − m c )
การหารในสูตรนี้เป็นกับดักที่ตอบผิดแบบเงียบ ๆ
บทสอนหลายที่เขียนฟังก์ชันหาจุดตัดด้วย long double ซึ่งใช้ได้ในหลายโจทย์
แต่พอค่าโตขึ้น ความละเอียดจะหลุด แล้วเปลือกจะเก็บเส้นที่ควรทิ้งหรือทิ้งเส้นที่ควรเก็บ
โดยไม่มีข้อความเตือนอะไรเลย ถ้าเขียนแบบคูณไขว้ตั้งแต่แรก ปัญหานี้หายไปทั้งชั้น
เหลือแค่เรื่องเดียวคือตัวคูณล้นชนิดข้อมูล ซึ่งแก้ด้วย __int128
และเป็นเรื่องที่ผมจะไล่ให้ดูด้วยตัวเลขจริงในหัวข้อกับดักท้ายบท
เดินให้ดูทีละขั้น
นี่คือการเติม dp ของทางเดินตัวอย่างข้างบนจริง ๆ แถวหนึ่งคือจุดที่ต้องคลุมหนึ่งจุด
ก่อนถามทุกครั้ง ต้องใส่เส้นของทางเลือกที่เพิ่งพร้อมใช้เข้าไปก่อน
สังเกตสองคอลัมน์ขวาสุด คือจำนวนเส้นที่ยังเหลือในสแต็ก กับความชันของเส้นที่ชนะรอบนั้น
เติม dp ของทางเดินมีหลังคา ทีละจุด
เดินครบทั้งตารางแล้วได้ 30726 ตรงกับที่ไล่ทางเลือกทุกทาง จำนวนครั้งที่ต้องใส่เส้นคือ 10 ครั้ง เท่ากับจำนวนจุดพอดี ขณะที่การไล่ทุกทางเลือกต้องส่อง 55 ครั้ง
ทำไมรวมทั้งลูปแล้วเป็น O(n) ไม่ใช่ O(n²)
ดูเผิน ๆ ทั้ง add และ query มีลูป while อยู่ข้างใน
ซึ่งรอบเดียวอาจวนหลายครั้ง แต่ของที่ลูปพวกนั้นกินไม่เคยกลับมา
เส้นที่ถูกทิ้งไม่มีวันถูกใส่กลับ และตัวชี้เดินไปข้างหน้าทางเดียวไม่มีวันถอย
เส้นถูกใส่ทั้งหมด n เส้น จึงถูกทิ้งได้มากที่สุด n ครั้งรวมทั้งโปรแกรม
และตัวชี้เดินได้มากที่สุด n ช่องรวมทั้งโปรแกรม
วิธีนับแบบนี้เรียกว่า amortized analysis อ่านว่า "อะมอร์ไทซ์ อะแนไลซิส" แปลว่า "การวิเคราะห์แบบเฉลี่ยทบ"
เป็นตัวนับเดียวกับที่บทหน้าต่างเลื่อน ใช้ คือขอบที่เดินทางเดียวตลอด
ให้นับรวมทั้งลูปได้เลย แทนที่จะคูณจำนวนรอบด้วยงานที่แย่ที่สุดของหนึ่งรอบ
สามระดับของท่านี้ กับกฎที่ตัดสินว่าใช้อันไหน
ตรงนี้เป็นหัวใจของการหยิบท่านี้ไปใช้จริง เพราะเครื่องมือที่ใช้ไม่ได้ขึ้นกับโจทย์
มันขึ้นกับคำถามสองข้อ เท่านั้น
ความชันของเส้นที่ใส่ เรียงอยู่แล้วไหม (เรียงขึ้นหรือลงก็ได้ ขอแค่เรียง) ตำแหน่งที่ถาม เรียงอยู่แล้วไหม
ตอบสองข้อนี้ให้ได้ก่อนพิมพ์โค้ดทุกครั้ง แล้วเลือกจากตารางนี้
เลือกเครื่องมือจากคำตอบสองข้อ ความชัน ตำแหน่งที่ถาม เครื่องมือ ราคาต่อครั้ง เรียง เรียง สแต็ก กับตัวชี้ (ระดับ 1) O(1) เฉลี่ยทบ เรียง ไม่เรียง สแต็ก กับค้นหาแบบไบนารี (ระดับ 2) O(log n) ไม่เรียง ไม่เรียง ต้นไม้ลีเชา (ระดับ 3) O(log C)
ช่อง C ในแถวสุดท้ายคือความกว้างของช่วงตำแหน่งที่จะถาม ไม่ใช่จำนวนเส้น
นั่นคือข้อแลกเปลี่ยนของระดับ 3 มันไม่สนว่ามีเส้นกี่เส้น แต่ไปสนว่าแกน x กว้างแค่ไหน
กรณี "ความชันไม่เรียง แต่ตำแหน่งที่ถามเรียง" ก็มีอยู่ ซึ่งใช้ต้นไม้ลีเชาได้เหมือนกัน
จึงยุบรวมไว้ในแถวเดียวกัน
ระดับ 1 · ชันเรียง ถามเรียง
นี่คือระดับที่บทนี้เดินให้ดูมาทั้งหมด และเป็นระดับที่เจอบ่อยที่สุดในโจทย์แข่ง
เพราะดีพีมักไล่ i จากน้อยไปมาก แล้วทั้งความชันและตำแหน่งที่ถามก็มักเป็น
ฟังก์ชันของ i ที่ขึ้นหรือลงทางเดียว โค้ดอยู่ในหัวข้อแม่แบบข้างล่าง
ระดับ 2 · ชันเรียง แต่ถามไม่เรียง
การใส่เส้นไม่ต้องแก้อะไรเลย เพราะการใส่พึ่งแค่ว่าความชันเรียง ที่ต้องเปลี่ยนคือการถาม
เลิกใช้ตัวชี้ แล้วใช้ข้อสังเกตข้อแรกจากรูปเปลือกล่างแทน คือ
เส้นที่ i เป็นเจ้าของช่วง x ตั้งแต่จุดตัดกับเส้นก่อนหน้า ถึงจุดตัดกับเส้นถัดไป
ช่วงพวกนี้เรียงกันไม่คาบเกี่ยว จึงค้นหาแบบไบนารีได้ตรง ๆ
ดูโค้ดระดับ 2 พร้อมตัวทดสอบในตัว
#include < bits/stdc++.h >
using namespace std;
typedef long long ll;
/* ---- เปลือกล่างแบบ "ชันเรียง แต่คำถามไม่เรียง" ----
ใส่เส้นยังเหมือนเดิมเป๊ะ เพราะการใส่พึ่งแค่ว่าความชันเรียงลดลง
ที่เปลี่ยนคือการถาม เลิกใช้ตัวชี้ แล้วค้นหาแบบไบนารีบนช่วงของแต่ละเส้นแทน */
struct Line { ll m, b; };
static inline ll lineAt ( const Line & L , ll x ) { return L.m * x + L.b; }
struct Hull {
vector < Line > st;
static bool useless ( const Line & a , const Line & b , const Line & c ) {
return (__int128)(c.b - a.b) * (a.m - b.m) <= (__int128)(b.b - a.b) * (a.m - c.m);
}
void add ( Line L ) {
if ( ! st. empty () && st. back ().m == L.m) {
if (st. back ().b <= L.b) return ;
st. pop_back ();
}
while (st. size () >= 2 && useless (st[st. size () - 2 ], st. back (), L)) st. pop_back ();
st. push_back (L);
}
/* เส้นที่ i ชนะตั้งแต่จุดตัดกับเส้นที่ i-1 ไปจนถึงจุดตัดกับเส้นที่ i+1
จึงหาเส้นแรกที่ "จุดตัดกับเส้นถัดไป" อยู่ทางขวาของ x ที่ถาม */
ll query ( ll x ) const {
int lo = 0 , hi = ( int )st. size () - 1 ;
while (lo < hi) {
int mid = lo + (hi - lo) / 2 ;
if ( lineAt (st[mid + 1 ], x) <= lineAt (st[mid], x)) lo = mid + 1 ;
else hi = mid;
}
return lineAt (st[lo], x);
}
};
int main () {
/* ตัวทดสอบในตัว: สุ่มเส้นแล้วเรียงความชันจากมากไปน้อย
ถามที่ตำแหน่งแบบสุ่มไม่เรียง เทียบกับการไล่ทุกเส้น */
mt19937 rng ( 20260910 );
for ( int round = 1 ; round <= 3000 ; round ++ ) {
int n = 1 + ( int )( rng () % 8 );
vector < Line > all;
for ( int t = 0 ; t < n; t ++ ) all. push_back ({(ll)( rng () % 21 ) - 10 , (ll)( rng () % 41 ) - 20 });
sort (all. begin (), all. end (), []( const Line & p , const Line & q ) {
return p.m != q.m ? p.m > q.m : p.b < q.b;
});
Hull h;
for ( const Line & L : all) h. add (L);
for ( int q = 0 ; q < 12 ; q ++ ) {
ll x = (ll)( rng () % 41 ) - 20 ;
ll got = h. query (x);
ll want = LLONG_MAX;
for ( const Line & L : all) want = min (want, lineAt (L, x));
if (got != want) {
printf ( " ROUND %d x= %lld got= %lld want= %lld \n " , round, x, got, want);
return 1 ;
}
}
}
printf ( " ok \n " );
return 0 ;
}
ไฟล์นี้มีตัวทดสอบอยู่ในตัว สุ่มเส้นแล้วเรียงความชันจากมากไปน้อย
จากนั้นถามที่ตำแหน่งสุ่มแบบไม่เรียง เทียบกับการไล่ทุกเส้น รันแล้วพิมพ์ ok
แปลว่าตรงกันทุกรอบ ผมรันจริงและได้ ok
ระดับ 3 · ไม่เรียงทั้งคู่ ต้องยกต้นไม้ลีเชามาใช้
พอความชันมาแบบไม่เรียง สแต็กใช้ไม่ได้อีก เพราะกติกาของการทิ้งเส้นสร้างขึ้นบนสมมติฐานว่า
เส้นใหม่ชันน้อยที่สุดในกอง เครื่องมือที่ใช้แทนคือ Li Chao tree อ่านว่า "ลี เชา ทรี" ตั้งชื่อตาม Li Chao ผู้เผยแพร่ท่านี้
ตัวมันคือเซกเมนต์ทรี ที่สร้างบนแกน x
ไม่ใช่บนอาเรย์ของข้อมูล และแต่ละปมเก็บเส้นเดียว
กติกาการใส่เส้นสั้นมาก ที่ปมหนึ่งซึ่งดูแลช่วง [l, r] ให้เทียบเส้นเก่ากับเส้นใหม่
ที่จุดกลางของช่วง ตัวที่ต่ำกว่าอยู่ที่ปมนี้ อีกตัวจะต่ำกว่าได้แค่ครึ่งเดียวของช่วง
(เพราะเส้นตรงสองเส้นตัดกันได้ที่เดียว) จึงส่งลงไปครึ่งนั้นครึ่งเดียว
ความลึกจึงเป็น log ของความกว้างแกน x
การถามก็สั้นเท่ากัน คือเดินจากรากลงไปถึงใบของตำแหน่งที่ถาม แล้วเอาค่าน้อยสุดของทุกปมที่เดินผ่าน
สิ่งที่กติกาการใส่รับประกันคือ เส้นที่ต่ำที่สุดของตำแหน่งนั้น ต้องนอนอยู่ปมใดปมหนึ่งบนเส้นทางนี้แน่
[0,7]
1x+1
[0,3]
3x+2
[0,1]
ว่าง
[0,0]
ว่าง
[1,1]
ว่าง
[2,3]
ว่าง
[2,2]
ว่าง
[3,3]
ว่าง
[4,7]
0x+7
[4,5]
ว่าง
[4,4]
ว่าง
[5,5]
ว่าง
[6,7]
-2x+20
[6,6]
ว่าง
[7,7]
ว่าง
ถามที่ x = 5 เดินผ่าน 4 ปม ได้ค่าน้อยสุด 6
ขยาย ต้นไม้ลีเชาที่สร้างบนแกน x ช่วง 0 ถึง 7 หลังใส่เส้น 4 เส้น กล่องเขียวคือเส้นทางจากรากลงไปถึงใบของตำแหน่ง x = 5 ค่าน้อยสุดที่เจอบนเส้นทางนี้คือ 6 ซึ่งตรงกับการไล่ทุกเส้นที่ตำแหน่งเดียวกัน กล่องที่ไม่มีเส้นแปลว่าปมนั้นยังว่าง ต้นไม้ลีเชาที่สร้างบนแกน x ช่วง 0 ถึง 7 หลังใส่เส้น 4 เส้น กล่องเขียวคือเส้นทางจากรากลงไปถึงใบของตำแหน่ง x = 5 ค่าน้อยสุดที่เจอบนเส้นทางนี้คือ 6 ซึ่งตรงกับการไล่ทุกเส้นที่ตำแหน่งเดียวกัน กล่องที่ไม่มีเส้นแปลว่าปมนั้นยังว่าง
เลื่อนล้อเมาส์เพื่อซูม คลิกลากเพื่อเลื่อนภาพ (บนมือถือใช้สองนิ้วขยาย นิ้วเดียวลากเลื่อน)
ดูโค้ดต้นไม้ลีเชา พร้อมตัวทดสอบในตัว
#include < bits/stdc++.h >
using namespace std;
typedef long long ll;
const ll INF = (ll) 4 e 18 ;
/* ---- ต้นไม้ลีเชา: ใส่เส้นได้ทุกลำดับ ถามได้ทุกตำแหน่ง ----
หนึ่งปมเก็บเส้นเดียว และรับประกันว่าเดินจากรากลงไปถึงใบของตำแหน่งที่ถาม
จะต้องผ่านเส้นที่ต่ำที่สุดของตำแหน่งนั้นอย่างน้อยหนึ่งครั้ง */
struct Node { int lc, rc; ll m, b; };
vector < Node > nd ( 1 , { 0 , 0 , 0 , INF }); // ปมที่ 0 คือ "ว่าง" เส้นสูงชนิดไม่มีวันชนะ
static inline ll at ( int t , ll x ) { return nd[t].m * x + nd[t].b; }
int makeNode ( ll m , ll b ) {
nd. push_back ({ 0 , 0 , m, b});
return ( int )nd. size () - 1 ;
}
/* ตัดสินที่จุดกลางว่าเส้นไหนต่ำกว่า เก็บตัวที่ต่ำกว่าไว้ที่ปมนี้
อีกตัวจะต่ำกว่าได้แค่ครึ่งเดียว จึงส่งลงไปครึ่งนั้น ทำให้ลึกแค่ log ของช่วง
คืนค่าเป็นหมายเลขปม ไม่รับพารามิเตอร์เป็น int& เพราะ makeNode ทำ push_back
ซึ่งอาจย้ายตำแหน่งของ vector ทั้งก้อน แล้วอ้างอิงที่ถืออยู่จะชี้ไปหน่วยความจำเก่า */
int addLine ( int t , int l , int r , ll m , ll b ) {
if ( ! t) return makeNode (m, b);
int mid = l + (r - l) / 2 ;
bool onLeft = m * l + b < at (t, l);
bool onMid = m * mid + b < at (t, mid);
if (onMid) { swap (nd[t].m, m); swap (nd[t].b, b); }
if (l == r) return t;
if (onLeft != onMid) {
int c = addLine (nd[t].lc, l, mid, m, b);
nd[t].lc = c;
} else {
int c = addLine (nd[t].rc, mid + 1 , r, m, b);
nd[t].rc = c;
}
return t;
}
ll queryTree ( int t , int l , int r , ll x ) {
if ( ! t) return INF;
ll res = at (t, x);
if (l == r) return res;
int mid = l + (r - l) / 2 ;
if (x <= mid) return min (res, queryTree (nd[t].lc, l, mid, x));
return min (res, queryTree (nd[t].rc, mid + 1 , r, x));
}
int main () {
/* ตัวทดสอบในตัว: ใส่เส้นแบบสุ่มทั้งลำดับและค่า แล้วถามทุกตำแหน่งในช่วง
เทียบกับการไล่ทุกเส้น */
const int LO = - 60 , HI = 60 ;
mt19937 rng ( 20260911 );
for ( int round = 1 ; round <= 400 ; round ++ ) {
nd. assign ( 1 , { 0 , 0 , 0 , INF});
int root = 0 ;
vector < pair < ll, ll >> all;
int n = 1 + ( int )( rng () % 10 );
for ( int t = 0 ; t < n; t ++ ) {
ll m = (ll)( rng () % 21 ) - 10 , b = (ll)( rng () % 41 ) - 20 ;
all. push_back ({m, b});
root = addLine (root, LO, HI, m, b);
}
for ( int x = LO; x <= HI; x ++ ) {
ll got = queryTree (root, LO, HI, x);
ll want = INF;
for ( auto & L : all) want = min (want, L.first * x + L.second);
if (got != want) {
printf ( " ROUND %d x= %d got= %lld want= %lld \n " , round, x, got, want);
return 1 ;
}
}
}
printf ( " ok \n " );
return 0 ;
}
ไฟล์นี้ก็มีตัวทดสอบในตัว ใส่เส้นแบบสุ่มทั้งลำดับและค่า แล้วถามทุกตำแหน่งจำนวนเต็มในช่วง
เทียบกับการไล่ทุกเส้น รันแล้วได้ ok
จุดที่ต้องอ่านให้ดีในโค้ดนี้คือ addLine คืนหมายเลขปมกลับมา
ไม่ได้รับพารามิเตอร์เป็นอ้างอิง เหตุผลอยู่ในเรื่องที่ผมเล่าไว้ในฝึกข้อ 4
เพราะเวอร์ชันแรกที่ผมเขียนใช้อ้างอิง แล้วมันผิดในแบบที่หาสาเหตุยากที่สุดในบทนี้
ถ้าฝืนใช้ผิดระดับ จะพังยังไง
ท่านี้มีคุณสมบัติที่น่ากลัวเหมือนท่าเร่งดีพีอื่น ๆ คือเวลาใช้ผิด มันไม่ฟ้อง
ไม่มีข้อผิดพลาดตอนรัน ไม่มีลูปไม่รู้จบ มีแค่ตัวเลขที่มากกว่าคำตอบจริงในบางอินพุต
สองตัวอย่างข้างล่างนี้ผมหามาจากการไล่ทุกกรณีเล็ก ๆ ตามลำดับจากเล็กไปใหญ่
ตัวแรกที่เจอจึงเป็นตัวที่เล็กที่สุดจริง ไม่ใช่ตัวที่สุ่มมาได้
ค้านที่ 1 · ตำแหน่งที่ถามไม่เรียง แต่ยังใช้ตัวชี้
ตัวชี้เดินไปข้างหน้าทางเดียว เพราะเราสัญญาไว้ว่าตำแหน่งที่ถามจะเรียงขึ้นเรื่อย ๆ
ถ้าผิดสัญญา ตัวชี้ก็ถอยกลับไปดูเส้นที่มันเดินผ่านมาแล้วไม่ได้
ตัวค้านที่เล็กที่สุด · เส้น 2 เส้น ถาม 2 ครั้ง ของที่ใส่และที่ถาม ตัวชี้ตอบ ความจริง
เส้น y = 2x - 1 และ y = 1x - 1 แล้วถามที่ x = 0 ตามด้วย -1 -2 -3
คำถามแรกที่ x = 0 ทำให้ตัวชี้เดินไปหาเส้นที่ชันน้อยกว่า
คำถามที่สองที่ x = -1 คำตอบย้อนกลับไปอยู่ที่เส้นเดิม แต่ตัวชี้ถอยไม่ได้
ผลคือตอบ -2 ทั้งที่ความจริงคือ -3
สองเส้นสองคำถามเป็นขนาดที่เล็กที่สุดที่ทำให้เกิดเรื่องนี้ได้
ค้านที่ 2 · ความชันไม่เรียง แต่ยังใช้สแต็ก
เงื่อนไขทิ้งเส้นสร้างบนสมมติฐานว่าเส้นใหม่ชันน้อยที่สุดในกอง ถ้าเส้นใหม่ชันมากกว่าเส้นที่อยู่บนสุด
ตัวหารในสูตรจุดตัดจะกลับเครื่องหมาย แล้วเงื่อนไขก็ตัดสินกลับด้าน ผลคือทิ้งเส้นที่ยังมีประโยชน์
ตัวค้านที่เล็กที่สุด · ใส่เส้นผิดลำดับ ลำดับที่ใส่ เส้นที่หายไปจากสแต็ก สแต็กตอบ ความจริง -2x - 2 แล้ว -1x - 2 แล้ว 0x + 0 -1x - 2 0 -1
ถามที่ x = -1 สแต็กตอบ 0 ส่วนความจริงคือ -1
ตรงนี้ผมไม่ได้ใช้ตัวชี้เลย ใช้การไล่ดูทุกเส้นที่ยังอยู่ในสแต็ก
เพื่อให้ชัดว่าความผิดพลาดอยู่ที่ตอนใส่ ไม่ใช่ตอนถาม
สิ่งที่ต้องทำก่อนส่ง ทุกครั้งที่ใช้ท่านี้
เขียนออกมาเป็นคำว่าความชันของคุณคือฟังก์ชันอะไรของ i แล้วถามว่ามันขึ้นหรือลงทางเดียวจริงไหม
ตัวอย่างในบทนี้ความชันคือ -2x[j+1] ซึ่งลดลงเพราะ x เรียงขึ้น
ถ้าตอบข้อนี้ไม่ได้ อย่าใช้ระดับ 1
ถามข้อเดียวกันกับตำแหน่งที่ถาม ในบทนี้คือ x[i] ซึ่งเรียงขึ้น
ถ้าตำแหน่งที่ถามไม่เรียง ให้ลบตัวชี้ทิ้งแล้วใช้การค้นหาแบบไบนารี ซึ่งเป็นการแก้สามบรรทัด
สุ่มเทียบกับการไล่ทางเลือกทุกทาง ก่อนส่ง โค้ดตัวตรวจอยู่ท้ายหน้านี้
นี่เป็นข้อที่ข้ามไม่ได้จริง ๆ เพราะสองข้อบนเป็นการอ่านโค้ดของตัวเอง ซึ่งเชื่อไม่ได้
แม่แบบที่ลอกไปใช้ได้ทุกข้อ
โครงนี้ใช้ได้กับทุกข้อในบทนี้ ที่ต่างกันคือสามบรรทัด คือความชันของเส้น
จุดตัดแกนของเส้น และตำแหน่งที่ถาม ส่วนตัวเปลือกไม่ต้องแตะเลย
#include < bits/stdc++.h >
using namespace std;
typedef long long ll;
/* ============================================================
แม่แบบ CHT ระดับ 1 · ลอกไปใช้ได้เลย
ใช้ได้เมื่อครบสองข้อ
1. ความชันของเส้นที่ใส่ เรียงจากมากไปน้อย
2. ตำแหน่งที่ถาม เรียงจากน้อยไปมาก
หาค่า "น้อยสุด" ถ้าต้องการค่ามากสุด ให้กลับเครื่องหมายทั้งชันและจุดตัด
แล้วกลับเครื่องหมายคำตอบอีกที
============================================================ */
struct Line { ll m, b; };
static inline ll lineAt ( const Line & L , ll x ) { return L.m * x + L.b; }
struct Hull {
vector < Line > st; // เส้นที่ยังมีสิทธิ์ชนะ เรียงตามความชันจากมากไปน้อย
int ptr = 0 ; // ตัวชี้ว่ารอบก่อนเส้นไหนชนะ เดินไปข้างหน้าทางเดียว
/* เส้นกลาง b ทิ้งได้ ถ้าจุดตัดของ a กับเส้นใหม่ c ไม่ได้อยู่ขวากว่าจุดตัดของ a กับ b
เขียนเป็นการคูณไขว้ ไม่ใช้ทศนิยม และใช้ __int128 เพราะผลคูณล้น long long ได้จริง */
static bool useless ( const Line & a , const Line & b , const Line & c ) {
return (__int128)(c.b - a.b) * (a.m - b.m) <= (__int128)(b.b - a.b) * (a.m - c.m);
}
/* ตัวชี้อาจชี้เลยปลายสแต็กหลังมีการทิ้งเส้น ต้องหนีบกลับทุกครั้ง
เส้นที่ถูกทิ้งอยู่ท้ายสแต็กเสมอ การหนีบมาที่ตัวสุดท้ายจึงไม่ทำให้พลาดคำตอบ */
void clampPtr () {
if (ptr >= ( int )st. size ()) ptr = ( int )st. size () - 1 ;
if (ptr < 0 ) ptr = 0 ;
}
void add ( Line L ) {
if ( ! st. empty () && st. back ().m == L.m) { // ชันเท่ากันไม่มีวันตัดกัน เก็บตัวที่ต่ำกว่า
if (st. back ().b <= L.b) return ;
st. pop_back ();
}
while (st. size () >= 2 && useless (st[st. size () - 2 ], st. back (), L)) st. pop_back ();
st. push_back (L);
clampPtr ();
}
ll query ( ll x ) {
clampPtr ();
while (ptr + 1 < ( int )st. size () && lineAt (st[ptr + 1 ], x) <= lineAt (st[ptr], x)) ptr ++ ;
return lineAt (st[ptr], x);
}
};
/* ตัวอย่างการต่อสาย: dp[i] = ก้อนที่ขึ้นกับ i ล้วน + min over j<i ของ ( m(j)*x(i) + b(j) )
กติกาเดียวที่ต้องระวังคือลำดับ ใส่เส้นของ j ให้ครบทุกตัวที่ใช้ได้ ก่อนจะถามที่ i */
ll demo ( int n , const vector < ll > & slope , const vector < ll > & pos , const vector < ll > & own ) {
vector < ll > dp (n + 1 , 0 );
Hull h;
for ( int i = 1 ; i <= n; i ++ ) {
int j = i - 1 ; // รอบนี้เส้นของ j เพิ่งพร้อมใช้พอดี
h. add ({slope[j], dp[j]});
dp[i] = h. query (pos[i - 1 ]) + own[i - 1 ];
}
return dp[n];
}
int main () {
/* กันไว้ให้ไฟล์นี้คอมไพล์ได้เดี่ยว ๆ ไม่ได้เป็นส่วนของเฉลยข้อไหน */
int n = 3 ;
vector < ll > slope = { 0 , - 2 , - 4 }, pos = { 1 , 2 , 3 }, own = { 5 , 5 , 5 };
printf ( " %lld \n " , demo (n, slope, pos, own));
return 0 ;
} สี่จุดที่ต้องระวังในแม่แบบนี้
ลำดับต้องเป็น "ใส่ก่อน ถามหลัง" ในรอบเดียวกัน เส้นของทางเลือก j
ใส่ได้เมื่อ dp[j] รู้ค่าแล้วเท่านั้น ถ้าใส่เร็วไป จุดตัดแกนจะเป็นศูนย์ที่ยังไม่มีความหมาย
และคำตอบจะน้อยกว่าจริง ซึ่งจับยากกว่าคำตอบที่มากกว่าจริง
ต้องหนีบตัวชี้ทุกครั้งหลังทิ้งเส้น เพราะเส้นที่ถูกทิ้งอยู่ท้ายสแต็ก
ตัวชี้อาจชี้เลยปลายไปแล้ว ถ้าไม่หนีบ โปรแกรมจะอ่านนอกขอบเขต ซึ่งบางเครื่องไม่พังทันที
แต่ตอบเลขมั่ว
ต้องมีสาขาสำหรับเส้นที่ชันเท่ากัน เส้นสองเส้นที่ชันเท่ากันไม่มีวันตัดกัน
สูตรจุดตัดจึงหารด้วยศูนย์ เก็บตัวที่จุดตัดแกนต่ำกว่าไว้ตัวเดียวก็จบ
ต้องใช้ __int128 ในการคูณไขว้ ไม่ใช่ long long
ข้อนี้มีตัวเลขจริงให้ดูในฝึกข้อ 1
โจทย์ฝึก · ไล่จากง่ายไปยาก
สี่ข้อนี้หยิบมาจากรายการโจทย์ท้ายบทความต้นทางบน Codeforces เรียงให้เห็นครบทุกมุมของท่านี้
ข้อแรกใช้ระดับ 1 ตรง ๆ ข้อสองยากที่การมองให้ออกว่ามันเป็นเส้นตรง ข้อสามเป็นการหา
ค่ามากสุด ซึ่งต้องพลิกเครื่องมือ และข้อสี่บังคับให้ใช้ระดับ 3
เพราะทั้งความชันและตำแหน่งที่ถามเรียงไม่ได้เลย
ฝึกข้อ 1 · ทางเดินมีหลังคา (Kattis coveredwalkway)
เรื่องเดียวกับที่บทนี้เดินให้ดูมาทั้งบท เปลี่ยนแค่ขอบเขต ซึ่งเป็นจุดที่ทำให้ต้องใช้ท่านี้จริง ๆ
ไม่ใช่ใช้เพราะสวย
อินพุต / ขอบเขต / เอาต์พุต
อินพุต: บรรทัดแรกมีจำนวนจุด n กับค่าคงที่ c
แล้วอีก n บรรทัดคือตำแหน่งของจุดที่ต้องคลุม เรียงจากน้อยไปมากมาให้แล้ว
ขอบเขต: n ถึงหนึ่งล้าน c ถึงหนึ่งพันล้าน
ตำแหน่งอยู่ระหว่าง 1 ถึงหนึ่งพันล้าน และตำแหน่งซ้ำกันได้ เอาต์พุต: ค่าใช้จ่ายรวมที่น้อยที่สุด รับประกันว่าลงในจำนวนเต็ม 64 บิต EXAMPLE Input Output 10 50001 23 45 67 101 124 560 789 990 1019 30726
อ่านตัวอย่างนี้ยังไง
บรรทัดแรกบอกว่ามีจุดที่ต้องคลุม 10 จุด และค่าคงที่ต่อหนึ่งผืนคือ 5000 อีก 10 บรรทัดคือตำแหน่งของจุดพวกนั้น
เอาต์พุตเป็นเงินรวม ไม่ใช่จำนวนหลังคา นี่เป็นจุดที่คนอ่านผิดบ่อยที่สุด
เพราะโจทย์เล่าเรื่องหลังคาแต่ถามเรื่องเงิน
คำตอบ 30726 มาจากหลังคา 5 ผืน คือ 5000 + 66² = 9356 บวก 5000 + 23² = 5529 บวก 5000 + 0² = 5000 บวก 5000 + 0² = 5000 บวก 5000 + 29² = 5841 สังเกตว่าผืนที่คลุมจุดเดียวยังจ่ายค่าคงที่ 5000 อยู่ นั่นคือเหตุผลที่ไม่คุ้มจะแยกทุกจุด
และผืนที่พาด 66 หน่วยจ่ายถึง 9356 นั่นคือเหตุผลที่ไม่คุ้มจะรวมทุกจุด
เงินรวม 30726 · ทอง = ค่าคงที่ · เขียว = ระยะพาดยกกำลังสอง
ผืนที่ 1
พาด 66 · 9356
ผืนที่ 2
พาด 23 · 5529
ผืนที่ 3
พาด 0 · 5000
ผืนที่ 4
พาด 0 · 5000
ผืนที่ 5
พาด 29 · 5841
ขยาย ค่าใช้จ่ายของคำตอบ แยกตามหลังคาแต่ละผืน ความกว้างของแถบคือสัดส่วนของเงินที่ผืนนั้นกิน ส่วนสีทองในแถบคือค่าคงที่ 5000 ที่ทุกผืนต้องจ่ายเท่ากัน ส่วนสีเขียวคือค่าระยะพาดยกกำลังสอง ผืนที่คลุมจุดเดียวจึงเป็นทองล้วน ค่าใช้จ่ายของคำตอบ แยกตามหลังคาแต่ละผืน ความกว้างของแถบคือสัดส่วนของเงินที่ผืนนั้นกิน ส่วนสีทองในแถบคือค่าคงที่ 5000 ที่ทุกผืนต้องจ่ายเท่ากัน ส่วนสีเขียวคือค่าระยะพาดยกกำลังสอง ผืนที่คลุมจุดเดียวจึงเป็นทองล้วน
เลื่อนล้อเมาส์เพื่อซูม คลิกลากเพื่อเลื่อนภาพ (บนมือถือใช้สองนิ้วขยาย นิ้วเดียวลากเลื่อน)
ใบ้
เขียนสูตร O(n²) ออกมาก่อน แล้วดูก้อน (x[i] - x[j+1])² ให้ดี
กระจายกำลังสองออกมา แล้วแยกว่าก้อนไหนขึ้นกับ i เท่านั้น
ก้อนไหนขึ้นกับ j เท่านั้น และก้อนไหนมีทั้งคู่คูณกันอยู่
ถ้าแยกได้แล้ว ตอบสองคำถามนี้ คือความชันของคุณเรียงไหม และตำแหน่งที่ถามเรียงไหม
เฉลยฝึกข้อ 1 แตะเพื่อดูเฉลย ที่มาของแนวคิดนี้
สิ่งที่ทำให้ผมมองไปทางเส้นตรงตั้งแต่แรก ไม่ใช่การจำรูป แต่เป็นเลข n ถึงหนึ่งล้าน
เทียบกับสูตรที่มีสองลูป ผมคูณดูแล้วได้ห้าแสนล้านรอบ ซึ่งไม่ต้องคิดต่อ
และมันบอกอีกอย่างที่สำคัญกว่า คือคำตอบต้องใช้เวลาระดับ n
หรือ n log n เท่านั้น ไม่มีทางเลือกอื่น
พอรู้เพดานนั้น คำถามก็เปลี่ยนเป็น "ต้องทิ้งอะไรถึงจะเหลือแค่นั้น" และของที่ต้องทิ้งมีอย่างเดียว
คือลูป j ผมจึงกลับไปดูว่าอะไรบังคับให้ต้องไล่ j ทุกตัว
คำตอบคือวงเล็บยกกำลังสองที่มี i กับ j อยู่ด้วยกัน
การกระจายกำลังสองไม่ใช่กลเม็ด มันคือการพยายามแยก i กับ j ออกจากกัน ตรง ๆ
และเมื่อแยกได้ รูปที่เหลือคือเส้นตรง ซึ่งเป็นรูปที่มีเครื่องมือรออยู่แล้ว
บทเรียนที่หยิบไปใช้ต่อได้คือ เวลาเห็นก้อนที่ i กับ j พันกัน
ให้ลองกระจายก่อนเสมอ ถ้าหลังกระจายแล้วเหลือรูป "ของที่ขึ้นกับ j คูณของที่ขึ้นกับ i"
แค่นั้นก็พอที่จะยกท่านี้มาใช้ได้ ไม่ต้องมีอย่างอื่นอีก
นิยาม dp[i] ว่าเป็นค่าน้อยสุดที่คลุมจุดที่ 1 ถึง i ให้ครบ
แล้วมองว่าหลังคาผืนสุดท้ายคลุมจุดที่ j+1 ถึง i
ผืนนั้นราคา c บวก (x[i] - x[j+1])²
กระจายกำลังสองแล้วจะได้ว่าก้อนที่ขึ้นกับ j คือ (-2·x[j+1])·x[i] + (dp[j] + x[j+1]²) ซึ่งเป็นเส้นตรงที่ตำแหน่ง x[i]
โดยความชันคือ -2·x[j+1] และจุดตัดแกนคือ dp[j] + x[j+1]²
ตอบสองคำถาม ความชัน -2·x[j+1] ลดลง เพราะ x
เรียงขึ้นและมีเครื่องหมายลบคูณอยู่ ตำแหน่งที่ถาม x[i] เพิ่มขึ้น
ครบสองข้อ จึงใช้ระดับ 1 ได้ตรง ๆ เหลืองาน O(n)
มีจุดที่ต้องระวังหนึ่งจุดที่โจทย์นี้มีแต่โจทย์อื่นในบทนี้ไม่มี คือ
ตำแหน่งซ้ำกันได้ ตำแหน่งซ้ำแปลว่าความชันเท่ากัน ซึ่งเป็นกรณีที่เส้นสองเส้น
ไม่มีวันตัดกัน ถ้าไม่มีสาขาจัดการ สูตรจุดตัดจะหารด้วยศูนย์
โค้ดฝึกข้อ 1 แตะเพื่อดูเฉลย ดูโค้ด C++
#include < bits/stdc++.h >
using namespace std;
typedef long long ll;
/* ---- เปลือกล่างแบบเดินทางเดียว ใช้ได้เมื่อความชัน "ลดลง" และคำถาม "เพิ่มขึ้น" ---- */
struct Line { long long m, b; };
static inline long long lineAt ( const Line & L , long long x ) { return L.m * x + L.b; }
struct Hull {
vector < Line > st;
int ptr = 0 ;
/* เส้นกลาง b ไม่จำเป็น ถ้าจุดตัดของ a กับเส้นใหม่ c อยู่ซ้ายกว่าหรือตรงกับจุดตัดของ a กับ b
เขียนเป็นการคูณไขว้ ไม่ใช้ทศนิยม จะได้ไม่มีปัญหาความละเอียด */
static bool useless ( const Line & a , const Line & b , const Line & c ) {
return (__int128)(c.b - a.b) * (a.m - b.m) <= (__int128)(b.b - a.b) * (a.m - c.m);
}
void clampPtr () { if (ptr >= ( int )st. size ()) ptr = ( int )st. size () - 1 ; if (ptr < 0 ) ptr = 0 ; }
void add ( Line L ) {
if ( ! st. empty () && st. back ().m == L.m) { // ชันเท่ากัน เก็บเส้นที่ต่ำกว่า
if (st. back ().b <= L.b) return ;
st. pop_back ();
}
while (st. size () >= 2 && useless (st[st. size () - 2 ], st. back (), L)) st. pop_back ();
st. push_back (L);
clampPtr ();
}
long long query ( long long x ) {
clampPtr ();
while (ptr + 1 < ( int )st. size () && lineAt (st[ptr + 1 ], x) <= lineAt (st[ptr], x)) ptr ++ ;
return lineAt (st[ptr], x);
}
};
int main () {
ios :: sync_with_stdio ( false );
cin. tie ( nullptr );
ll n, c;
if ( ! (cin >> n >> c)) return 0 ;
vector < ll > x (n + 1 );
for (ll i = 1 ; i <= n; i ++ ) cin >> x[i];
/* dp[i] = ค่าน้อยสุดที่คลุมจุดที่ 1..i ให้ครบ
ถ้าหลังคาผืนสุดท้ายคลุมจุด j+1..i ราคาผืนนั้นคือ c + (x[i] - x[j+1])^2
กระจายกำลังสองออกมา จะได้ dp[j] + x[j+1]^2 - 2*x[j+1]*x[i] + x[i]^2 + c
ก้อนที่ขึ้นกับ j คือ (-2*x[j+1]) * x[i] + (dp[j] + x[j+1]^2) ซึ่งเป็นเส้นตรงที่ x[i] */
vector < ll > dp (n + 1 , 0 );
Hull h;
for (ll i = 1 ; i <= n; i ++ ) {
ll j = i - 1 ; // เส้นของ j เพิ่งพร้อมใช้พอดี
h. add ({ - 2 * x[j + 1 ], dp[j] + x[j + 1 ] * x[j + 1 ]});
dp[i] = h. query (x[i]) + x[i] * x[i] + c;
}
cout << dp[n] << " \n " ;
return 0 ;
}
ตรวจกับไบนารีที่คอมไพล์จากโค้ดก้อนนี้เอง ไม่ใช่จากไฟล์ที่เขียนไว้ก่อนแล้วคัดลอกมา
คือดึงโค้ดออกจากหน้าที่บิลด์เสร็จแล้ว คอมไพล์ด้วย g++ 11.4 แล้วเทียบกับตัวไล่ทางเลือกทุกทาง 8,000 รอบ แบ่งเป็นเลขเล็กที่จุดซ้ำกันบ่อย 4,000 รอบ
กับเลขใหญ่ระดับขอบเขตจริงที่จุดกองอยู่ปลายสองข้าง 4,000 รอบ ตรงกันทุกรอบ
และมันตอบตัวอย่างของโจทย์ได้ 30726 ตรงตามเฉลย
เรื่องของบรรทัดเดียวที่ผมเปลี่ยน แล้วโปรแกรมตอบผิดโดยไม่ฟ้อง
ผมเขียน __int128 ในการคูณไขว้ตั้งแต่ครั้งแรก เพราะกะขนาดตัวเลขไว้ก่อนแล้ว
ไม่ได้เจอบักตัวนี้เอง แต่ผมอยากรู้ว่ามันสำคัญจริงหรือแค่ระวังเกินไป
จึงคอมไพล์เวอร์ชันที่เปลี่ยนเป็น long long ขึ้นมา แล้วไปหาอินพุตที่ทำให้มันพัง
ผลคือมันผ่านตัวอย่างของโจทย์ คือตอบ 30726 เหมือนกันเป๊ะ
และผ่านการสุ่มด้วยเลขเล็กหลายพันรอบด้วย มันเริ่มพังเฉพาะตอนที่จุดกองอยู่ปลายสองข้าง
ซึ่งเป็นรูปที่ทำให้ผลต่างของจุดตัดแกนใหญ่ที่สุด
// เงื่อนไขทิ้งเส้น เวอร์ชันที่ผลคูณล้น
static bool useless ( const Line & a , const Line & b , const Line & c ) {
// บรรทัดนี้ผิด: ผลต่างของจุดตัดแกนโตได้ถึงระดับ 2e18 และผลต่างของความชันถึง 4e9
// คูณกันแล้วเป็น 8e27 ซึ่งล้น long long (เพดานราว 9.2e18) แบบเงียบ ๆ
return ( long long )(c.b - a.b) * (a.m - b.m) <= ( long long )(b.b - a.b) * (a.m - c.m);
}
// อินพุตที่เล็กที่สุดเท่าที่ทำให้พัง มีจุดแค่สามจุด
// 3 1000000
// 1
// 999999900
// 1000000000
// เวอร์ชันล้นตอบ 3000000 คือ "คลุมจุดละผืน" สามผืน
// คำตอบจริงคือ 2010000 คือผืนแรกคลุมจุดที่ 1 อย่างเดียวจ่าย 1000000
// ผืนที่สองคลุมสองจุดท้ายซึ่งห่างกัน 100 จ่าย 1000000 + 100*100 = 1010000
//
// เส้นตรงต้องมีสามเส้นขึ้นไปเงื่อนไขนี้จึงถูกเรียกใช้ สามจุดจึงเป็นตัวค้านที่เล็กที่สุดที่มีได้
บทเรียนคือขนาดของตัวเลขในเงื่อนไขทิ้งเส้น ไม่ได้เท่ากับขนาดของคำตอบ
คำตอบของโจทย์นี้รับประกันว่าลงจำนวนเต็ม 64 บิต ซึ่งจริง แต่ตัวคูณที่ใช้ตัดสินว่าจะทิ้งเส้นไหน
เป็นผลคูณของสองผลต่าง ซึ่งใหญ่กว่าคำตอบเป็นสิบเท่าของเลขยกกำลัง
และเวลาล้น มันไม่ตอบผิดทุกครั้ง มันตอบผิดเป็นบางครั้ง ซึ่งแย่กว่า
ฝึกข้อ 2 · โค่นต้นไม้ (CF 319C Kalila and Dimna in the Logging Industry)
หมาจิ้งจอกสองตัวรับงานโค่นต้นไม้ n ต้น ต้นที่ i สูง a[i]
เลื่อยไฟฟ้าฟันหนึ่งครั้งลดความสูงลงหนึ่งหน่วย และต้องชาร์จใหม่ทุกครั้งที่ฟัน
ราคาชาร์จขึ้นกับต้นที่ล้มสนิทไปแล้ว ถ้าต้นที่ล้มสนิทแล้วซึ่งหมายเลขมากที่สุดคือ i
ราคาชาร์จหนึ่งครั้งคือ b[i] ต้องโค่นให้ล้มสนิททุกต้น ด้วยเงินน้อยที่สุด
อินพุต / ขอบเขต / เอาต์พุต
อินพุต: n แล้วอาเรย์ a แล้วอาเรย์ b ขอบเขต: n ถึงหนึ่งแสน ค่าใน a และ b
ถึงหนึ่งพันล้าน โจทย์รับประกันว่า a เรียงเพิ่ม b เรียงลด a[1] = 1 และ b[n] = 0 เอาต์พุต: เงินน้อยที่สุด EXAMPLE Input Output 6 1 2 3 10 20 30 6 5 4 3 2 0 138
อ่านตัวอย่างนี้ยังไง
บรรทัดที่สองคือความสูง ของต้นไม้ ซึ่งเท่ากับจำนวนครั้งที่ต้องฟัน
ต้นนั้น บรรทัดที่สามคือราคาต่อหนึ่งฟัน ที่จะได้ถ้าต้นนั้นล้มสนิทแล้ว
เอาต์พุตเป็นเงินรวม ไม่ใช่จำนวนครั้งที่ฟัน
กุญแจอยู่ที่เงื่อนไขสองข้อท้ายโจทย์ a[1] = 1 แปลว่าต้นแรกฟันครั้งเดียวก็ล้ม
และเลื่อยชาร์จมาให้แล้วตอนเริ่ม จึงโค่นต้นแรกฟรี
ส่วน b[n] = 0 แปลว่าพอต้นสุดท้ายล้ม การชาร์จก็ฟรีตลอดไป
งานที่เหลือทั้งหมดจึงฟรี คำตอบคือค่าใช้จ่ายในการไปให้ถึงต้นสุดท้าย เท่านั้น
ต้นที่เราไม่แวะโค่นก่อน ก็ไปโค่นทีหลังตอนฟรีได้
ในตัวอย่างนี้ ลำดับที่ถูกที่สุดคือแวะโค่นต้น 1 แล้วต้น 3 แล้วต้น 6 รวม 138 ส่วนกฎที่คนคิดออกก่อนเสมอ คือโค่นเรียงทุกต้นไม่ข้ามใคร จ่ายถึง 187 ซึ่งแพงกว่า 49
สูง = จำนวนครั้งที่ต้องฟัน · เลขใต้แถบ = ราคาชาร์จถ้าต้นนี้ล้ม
1
ต้น 1
b=6 2
ต้น 2
b=5 3
ต้น 3
b=4 10
ต้น 4
b=3 20
ต้น 5
b=2 30
ต้น 6
b=0
ขยาย ต้นไม้ทั้ง 6 ต้นในตัวอย่าง ความสูงของแถบคือจำนวนครั้งที่ต้องฟัน ตัวเลขใต้แถบคือราคาชาร์จที่จะได้ถ้าต้นนั้นล้ม แถบเขียวคือต้นที่คำตอบเลือกแวะโค่น ซึ่งมี 3 ต้นจากทั้งหมด 6 ต้น ต้นไม้ทั้ง 6 ต้นในตัวอย่าง ความสูงของแถบคือจำนวนครั้งที่ต้องฟัน ตัวเลขใต้แถบคือราคาชาร์จที่จะได้ถ้าต้นนั้นล้ม แถบเขียวคือต้นที่คำตอบเลือกแวะโค่น ซึ่งมี 3 ต้นจากทั้งหมด 6 ต้น
เลื่อนล้อเมาส์เพื่อซูม คลิกลากเพื่อเลื่อนภาพ (บนมือถือใช้สองนิ้วขยาย นิ้วเดียวลากเลื่อน)
เงินที่จ่ายในแต่ละช่วง · ลำดับที่ถูกที่สุด แวะโค่นต้น ตอนนั้นชาร์จราคา ต้องฟัน จ่าย 3 6 3 18 6 4 30 120 รวม · · 138
ต้นที่ไม่ได้อยู่ในตารางนี้ ไม่ได้แปลว่าไม่ต้องโค่น มันถูกโค่นทีหลังตอนที่การชาร์จฟรีแล้ว
เพราะโจทย์ให้ b ของต้นสุดท้ายเป็นศูนย์ จึงไม่คิดเงินอีก
ใบ้
อย่าคิดเป็น "จะฟันต้นไหนกี่ครั้ง" ให้คิดเป็น "จะแวะโค่นต้นไหนให้ล้มสนิทบ้าง
ก่อนจะไปถึงต้นสุดท้าย" ถ้าตอนนี้ต้นที่ล้มแล้วซึ่งใหญ่สุดคือ j
แล้วเราเลือกไปโค่นต้น i ต่อ ราคาของก้าวนั้นคืออะไร
เขียนออกมาเป็นสูตรแล้วดูว่ามันหน้าตาเหมือนอะไรที่เพิ่งอ่านมา
เฉลยฝึกข้อ 2 แตะเพื่อดูเฉลย สองทางที่ผมลองก่อน แล้วทิ้ง
ทางแรกที่ผมลองคือโลภ คือแวะโค่นต้นที่ให้ราคาชาร์จถูกที่สุดต่อหน่วยความสูงก่อน
มันแพ้ทันทีในตัวอย่างที่สองของโจทย์ ผมเลยลองอีกทางคือ "โค่นเรียงทุกต้นไม่ข้ามใคร"
ซึ่งเป็นกฎที่ดูปลอดภัยที่สุด ทางนั้นได้ 187 เทียบกับคำตอบจริง 138
ตัวเลขสองตัวนี้บอกอะไรที่สำคัญ คือการข้ามต้นเป็นเรื่องที่คุ้ม
และคุ้มมากพอที่จะไม่ใช่รายละเอียดปลีกย่อย
พอรู้ว่าต้องเลือกว่าจะข้ามต้นไหน ก็เห็นว่านี่คือดีพี ไม่ใช่โลภ
และสิ่งที่ต้องจำจากอดีตมีค่าเดียว คือต้นที่ล้มสนิทแล้วซึ่งใหญ่ที่สุดคือต้นไหน
เพราะมันเป็นตัวเดียวที่กำหนดราคาชาร์จของทุกฟันต่อจากนี้ ตรงนี้เองที่สูตรออกมาเป็น
dp[j] + b[j]·a[i] และผมเห็นรูปเส้นตรงทันที เพราะมันคือ
ค่าคงที่คูณตัวแปรบวกค่าคงที่ โดยไม่ต้องกระจายอะไรเลย
ซึ่งต่างจากข้อแรกที่ต้องกระจายกำลังสองก่อน
บทเรียนที่หยิบไปใช้ต่อได้คือ ให้เอากฎโลภที่คิดออกง่ายที่สุดไปคำนวณกับตัวอย่างจริงก่อน
ไม่ใช่เพื่อหวังว่ามันจะถูก แต่เพื่ออ่านว่ามันแพ้ด้วยส่วนต่างเท่าไร
ส่วนต่างนั้นบอกว่าสิ่งที่มันมองข้ามคืออะไร และนั่นคือสิ่งที่ต้องกลายเป็นสถานะของดีพี
นิยาม dp[i] ว่าเป็นเงินน้อยสุดที่จะทำให้ต้นที่ i ล้มสนิท
โดยที่ต้นที่ i เป็นต้นที่ใหญ่ที่สุดที่ล้มแล้ว ได้ dp[1] = 0
เพราะโจทย์ให้ a[1] = 1 และเลื่อยชาร์จมาแล้ว
ถ้าก่อนจะโค่นต้น i ต้นที่ใหญ่สุดที่ล้มแล้วคือ j
ทุกฟันจากนี้ราคา b[j] และต้องฟัน a[i] ครั้ง จึงได้
dp[i] = min over j<i ของ dp[j] + b[j]·a[i] คำตอบคือ dp[n]
เพราะพอต้นที่ n ล้ม ที่เหลือฟรีหมด
ตอบสองคำถาม ความชันคือ b[j] ซึ่งโจทย์รับประกันว่าเรียงลด
ตำแหน่งที่ถามคือ a[i] ซึ่งโจทย์รับประกันว่าเรียงเพิ่ม
ครบสองข้อ ใช้ระดับ 1 ได้ตรง ๆ เหลืองาน O(n)
เงื่อนไขสองข้อที่ดูเหมือนของแถมในโจทย์ จึงเป็นของที่ทำให้ข้อนี้ง่ายลงทั้งข้อ
โค้ดฝึกข้อ 2 แตะเพื่อดูเฉลย ดูโค้ด C++
#include < bits/stdc++.h >
using namespace std;
typedef long long ll;
/* ---- เปลือกล่างแบบเดินทางเดียว ใช้ได้เมื่อความชัน "ลดลง" และคำถาม "เพิ่มขึ้น" ---- */
struct Line { long long m, b; };
static inline long long lineAt ( const Line & L , long long x ) { return L.m * x + L.b; }
struct Hull {
vector < Line > st;
int ptr = 0 ;
/* เส้นกลาง b ไม่จำเป็น ถ้าจุดตัดของ a กับเส้นใหม่ c อยู่ซ้ายกว่าหรือตรงกับจุดตัดของ a กับ b
เขียนเป็นการคูณไขว้ ไม่ใช้ทศนิยม จะได้ไม่มีปัญหาความละเอียด */
static bool useless ( const Line & a , const Line & b , const Line & c ) {
return (__int128)(c.b - a.b) * (a.m - b.m) <= (__int128)(b.b - a.b) * (a.m - c.m);
}
void clampPtr () { if (ptr >= ( int )st. size ()) ptr = ( int )st. size () - 1 ; if (ptr < 0 ) ptr = 0 ; }
void add ( Line L ) {
if ( ! st. empty () && st. back ().m == L.m) { // ชันเท่ากัน เก็บเส้นที่ต่ำกว่า
if (st. back ().b <= L.b) return ;
st. pop_back ();
}
while (st. size () >= 2 && useless (st[st. size () - 2 ], st. back (), L)) st. pop_back ();
st. push_back (L);
clampPtr ();
}
long long query ( long long x ) {
clampPtr ();
while (ptr + 1 < ( int )st. size () && lineAt (st[ptr + 1 ], x) <= lineAt (st[ptr], x)) ptr ++ ;
return lineAt (st[ptr], x);
}
};
int main () {
ios :: sync_with_stdio ( false );
cin. tie ( nullptr );
int n;
if ( ! (cin >> n)) return 0 ;
vector < ll > a (n + 1 ), b (n + 1 );
for ( int i = 1 ; i <= n; i ++ ) cin >> a[i];
for ( int i = 1 ; i <= n; i ++ ) cin >> b[i];
/* dp[i] = ค่าน้อยสุดที่จะโค่นต้นที่ i ให้ล้มสนิท
ตอนโค่นต้นที่ i ต้นที่ล้มสนิทแล้วซึ่งหมายเลขมากสุดคือ j อยู่ก่อน i
จึงจ่ายค่าชาร์จ b[j] ต่อหนึ่งฟัน และต้องฟัน a[i] ครั้ง
dp[i] = min over j<i ของ dp[j] + b[j] * a[i] ซึ่งคือเส้นตรงชัน b[j] ที่ตำแหน่ง a[i] */
vector < ll > dp (n + 1 , 0 );
Hull h;
h. add ({b[ 1 ], dp[ 1 ]}); // dp[1] = 0 เพราะ a[1] = 1 และเลื่อยชาร์จมาแล้ว
for ( int i = 2 ; i <= n; i ++ ) {
dp[i] = h. query (a[i]);
h. add ({b[i], dp[i]});
}
cout << dp[n] << " \n " ;
return 0 ;
}
เปลือกในไฟล์นี้เป็นก้อนเดียวกับฝึกข้อ 1 ไม่ได้แก้แม้แต่ตัวอักษรเดียว
ต่างกันแค่สามบรรทัดที่บอกว่าความชันคืออะไร จุดตัดแกนคืออะไร และถามที่ไหน
ตรวจกับตัวไล่ทางเลือกทุกทาง 1,500 รอบ ด้วยอินพุตสุ่มที่เคารพเงื่อนไขของโจทย์
คือ a เรียงเพิ่มโดย a[1] = 1 และ b เรียงลดโดย b[n] = 0 ตรงกันทุกรอบ และมันตอบตัวอย่างทั้งสองชุดของโจทย์ได้ถูก
คือ 25 กับ 138
ฝึกข้อ 3 · สี่เหลี่ยมของนัท (CF 1083E The Fair Nut and Rectangles)
มีสี่เหลี่ยม n รูป รูปที่ i มีมุมหนึ่งอยู่ที่จุดกำเนิดและมุมตรงข้ามอยู่ที่ (x[i], y[i]) พร้อมค่าปรับ a[i] ให้เลือกรูปมาบางส่วน
แล้วคิดคะแนนเป็นพื้นที่ของรูปที่รวมกันแล้ว ลบผลรวมค่าปรับของรูปที่เลือก
ให้คะแนนมากที่สุด โจทย์รับประกันว่าไม่มีรูปไหนซ้อนอยู่ในรูปอื่น
อินพุต / ขอบเขต / เอาต์พุต
อินพุต: n แล้ว n บรรทัด แต่ละบรรทัดมี x, y, a ขอบเขต: n ถึงหนึ่งล้าน x และ y
ถึงหนึ่งพันล้าน และ a อยู่ระหว่าง 0 ถึง x·y
ไม่มีรูปไหนซ้อนอยู่ในรูปอื่น
เอาต์พุต: คะแนนมากที่สุด EXAMPLE Input Output 34 4 8 1 5 0 5 2 10 9
อ่านตัวอย่างนี้ยังไง
แต่ละบรรทัดคือความกว้าง ความสูง และค่าปรับ ของรูปหนึ่งรูป
เอาต์พุตเป็นคะแนน ซึ่งอาจเกิดจากการเลือกรูปไม่ครบ และ
เลือกศูนย์รูปก็ได้ ซึ่งได้คะแนน 0 คำตอบจึงไม่ต่ำกว่า 0 เสมอ
ประโยค "ไม่มีรูปไหนซ้อนอยู่ในรูปอื่น" เป็นของที่ต้องแปลก่อน มันหมายความว่าถ้าเรียงตาม x จากน้อยไปมาก แล้ว y จะลดลงเองโดยอัตโนมัติ
ถ้ามีรูปที่ทั้งกว้างกว่าและสูงกว่าอีกรูป รูปเล็กก็จะซ้อนอยู่ข้างในพอดี ซึ่งโจทย์ห้ามไว้
หลังเรียงแล้ว ยูเนียนของรูปจึงเป็นขั้นบันไดที่ลดลงทางขวา เสมอ
ในตัวอย่างนี้ เรียงตาม x ได้ (1, 5) แล้ว (4, 4) แล้ว (5, 2) คำตอบเลือก 2 รูป คือรูปที่ 1 และ 2 ตามลำดับที่เรียงแล้ว
พื้นที่รวมได้ 17 จากแถบ (1 - 0) × 5 = 5 บวก (4 - 1) × 4 = 12 แล้วลบค่าปรับรวม 8 เหลือ 9
ยูเนียนของรูปที่เลือก x y 5 1 5 12 4 4
รูป (1,5) เพิ่ม 5 ค่าปรับ 0
รูป (4,4) เพิ่ม 12 ค่าปรับ 8
พื้นที่ 17 ลบค่าปรับ 8
คะแนน 9
ขยาย ยูเนียนของรูปที่คำตอบเลือก เป็นขั้นบันไดที่ลดลงทางขวา แถบเขียวคือส่วนที่แต่ละรูปเพิ่มพื้นที่ให้จริง ซึ่งเท่ากับความกว้างส่วนที่เกินจากรูปก่อนหน้า คูณความสูงของตัวเอง พื้นที่รวม 17 ลบค่าปรับ 8 เหลือ 9 ยูเนียนของรูปที่คำตอบเลือก เป็นขั้นบันไดที่ลดลงทางขวา แถบเขียวคือส่วนที่แต่ละรูปเพิ่มพื้นที่ให้จริง ซึ่งเท่ากับความกว้างส่วนที่เกินจากรูปก่อนหน้า คูณความสูงของตัวเอง พื้นที่รวม 17 ลบค่าปรับ 8 เหลือ 9
เลื่อนล้อเมาส์เพื่อซูม คลิกลากเพื่อเลื่อนภาพ (บนมือถือใช้สองนิ้วขยาย นิ้วเดียวลากเลื่อน)
ใบ้
เรียงตาม x ก่อน แล้วนิยามดีพีด้วยคำถามว่า "รูปที่อยู่ขวาสุดที่เราเลือก
คือรูปไหน" พอตั้งคำถามแบบนี้ พื้นที่ที่รูปขวาสุดเพิ่มให้จะคิดได้ด้วยเลขสองตัวเท่านั้น
คือ x ของตัวเอง กับ x ของรูปที่เลือกก่อนหน้ามันติดกัน
เขียนออกมาเป็นสูตรแล้วจะเห็นว่าก้อนใน max เป็นเส้นตรงอีกแล้ว
ทีนี้ระวังสองอย่าง คือมันเป็น max ไม่ใช่ min
และตำแหน่งที่ถามเรียงลด ไม่ได้เรียงเพิ่ม
เฉลยฝึกข้อ 3 แตะเพื่อดูเฉลย ที่มาของแนวคิดนี้
ข้อนี้ผมติดอยู่ที่คำว่า "พื้นที่ยูเนียน" เพราะยูเนียนของรูปหลายรูปฟังดูเหมือนต้องคิดทั้งกองพร้อมกัน
ซึ่งเป็นสิ่งที่ดีพีทำไม่ได้ ตัวที่ปลดล็อกคือประโยค "ไม่มีรูปไหนซ้อนอยู่ในรูปอื่น"
ตอนแรกผมอ่านผ่าน คิดว่าเป็นเงื่อนไขกันเคสประหลาด แต่พอลองวาดสามรูปในตัวอย่างจริงบนกระดาษ
ผมเห็นว่ามันบังคับให้ยูเนียนเป็นขั้นบันไดที่ลดลงทางขวาเท่านั้น
พอเป็นขั้นบันได พื้นที่ก็แยกเป็นแถบแนวตั้งที่ไม่ทับกัน และแถบของรูปหนึ่งขึ้นกับ
รูปที่เลือกก่อนหน้ามันตัวเดียว ไม่ได้ขึ้นกับทั้งกอง นั่นคือจุดที่มันกลายเป็นดีพี
และสูตร f[i] = x[i]·y[i] - a[i] + max(f[j] - x[j]·y[i]) ก็ออกมาเอง
เรื่องที่เหลือคือ max กับตำแหน่งที่ถามเรียงลด ซึ่งดูเหมือนต้องเขียนเปลือกอีกชุด
ผมเกือบเขียนแล้ว แต่หยุดคิดก่อนว่าเปลือกที่มีอยู่ทำอะไรได้ คำตอบคือมันหาค่าน้อยสุด
เมื่อชันเรียงลดและถามเรียงเพิ่ม ซึ่งเป็นภาพสะท้อน ของสิ่งที่ข้อนี้ต้องการพอดี
การกลับเครื่องหมายชันและจุดตัดคือการสะท้อนรูปข้ามแกน x และการถามที่ -y[i]
คือการสะท้อนข้ามแกน y เมื่อสะท้อนสองครั้ง ปัญหาก็ตกลงมาอยู่ในรูปที่เครื่องมือเดิมรับได้
บทเรียนที่หยิบไปใช้ต่อได้คือ ก่อนเขียนโครงสร้างข้อมูลชุดที่สอง ให้ถามว่าปัญหาใหม่เป็น
ภาพสะท้อนของอันเดิมไหม เพราะโค้ดที่ไม่ได้เขียนคือโค้ดที่ไม่มีบัก
เรียงรูปตาม x จากน้อยไปมาก แล้ว y จะเรียงลดเอง
นิยาม f[i] ว่าเป็นคะแนนดีที่สุดเมื่อรูปที่ i เป็นรูปขวาสุดที่เลือก
ถ้ารูปที่เลือกก่อนหน้าติดกันคือรูป j พื้นที่ที่รูป i เพิ่มให้คือแถบกว้าง x[i] - x[j] สูง y[i] จึงได้ว่า f[i] = x[i]·y[i] - a[i] + max over j ของ ( f[j] - x[j]·y[i] )
โดยที่ j = 0 หมายถึงไม่เลือกรูปไหนก่อนหน้าเลย ซึ่งเป็นเส้นชัน 0 จุดตัดแกน 0
ก้อนใน max เป็นเส้นชัน -x[j] ที่ตำแหน่ง y[i]
ความชัน -x[j] เรียงลด (เพราะ x เรียงเพิ่ม) แต่ตำแหน่งที่ถาม y[i] ก็เรียงลดด้วย และเราหาค่ามากสุด ทั้งสองอย่างสวนทางกับเครื่องมือที่มี
ท่าแก้คือสะท้อนรูปสองครั้ง ใส่เส้นด้วยชัน -x[j]
และจุดตัดแกน -f[j] แล้วถามที่ -y[i] จากนั้นกลับเครื่องหมายคำตอบ
ผลลัพธ์เท่ากันเป๊ะ แต่ตอนนี้ความชันเรียงลด ตำแหน่งที่ถามเรียงเพิ่ม และเป็นการหาค่าน้อยสุด
ซึ่งคือรูปที่เปลือกเดิมรับได้ทั้งหมด
สุดท้าย คำตอบคือค่ามากสุดของ f[i] ทุกตัว ไม่ใช่ f[n]
เพราะรูปขวาสุดที่เลือกไม่จำเป็นต้องเป็นรูปที่ x มากที่สุด
โค้ดฝึกข้อ 3 แตะเพื่อดูเฉลย ดูโค้ด C++
#include < bits/stdc++.h >
using namespace std;
typedef long long ll;
/* ---- เปลือกล่างแบบเดินทางเดียว ใช้ได้เมื่อความชัน "ลดลง" และคำถาม "เพิ่มขึ้น" ---- */
struct Line { long long m, b; };
static inline long long lineAt ( const Line & L , long long x ) { return L.m * x + L.b; }
struct Hull {
vector < Line > st;
int ptr = 0 ;
/* เส้นกลาง b ไม่จำเป็น ถ้าจุดตัดของ a กับเส้นใหม่ c อยู่ซ้ายกว่าหรือตรงกับจุดตัดของ a กับ b
เขียนเป็นการคูณไขว้ ไม่ใช้ทศนิยม จะได้ไม่มีปัญหาความละเอียด */
static bool useless ( const Line & a , const Line & b , const Line & c ) {
return (__int128)(c.b - a.b) * (a.m - b.m) <= (__int128)(b.b - a.b) * (a.m - c.m);
}
void clampPtr () { if (ptr >= ( int )st. size ()) ptr = ( int )st. size () - 1 ; if (ptr < 0 ) ptr = 0 ; }
void add ( Line L ) {
if ( ! st. empty () && st. back ().m == L.m) { // ชันเท่ากัน เก็บเส้นที่ต่ำกว่า
if (st. back ().b <= L.b) return ;
st. pop_back ();
}
while (st. size () >= 2 && useless (st[st. size () - 2 ], st. back (), L)) st. pop_back ();
st. push_back (L);
clampPtr ();
}
long long query ( long long x ) {
clampPtr ();
while (ptr + 1 < ( int )st. size () && lineAt (st[ptr + 1 ], x) <= lineAt (st[ptr], x)) ptr ++ ;
return lineAt (st[ptr], x);
}
};
struct Rect { ll x, y, a; };
int main () {
ios :: sync_with_stdio ( false );
cin. tie ( nullptr );
int n;
if ( ! (cin >> n)) return 0 ;
vector < Rect > r (n);
for ( auto & t : r) cin >> t.x >> t.y >> t.a;
sort (r. begin (), r. end (), []( const Rect & p , const Rect & q ) { return p.x < q.x; });
/* โจทย์รับประกันว่าไม่มีรูปไหนซ้อนอยู่ในรูปอื่น เรียงตาม x เพิ่มขึ้นแล้ว y จะลดลงเอง
f[i] = ค่าดีที่สุดเมื่อรูปที่ i เป็นรูปขวาสุดที่เลือก
พื้นที่ที่รูปที่ i เพิ่มให้คือ x[i]*y[i] ลบส่วนที่รูปก่อนหน้าคลุมไว้แล้วคือ x[j]*y[i]
f[i] = x[i]*y[i] - a[i] + max over j<i ของ ( f[j] - x[j]*y[i] )
ก้อนใน max เป็นเส้นตรงชัน -x[j] ที่ตำแหน่ง y[i] ซึ่งเป็น "ค่ามากสุด" และความชันลดลง
พลิกเครื่องมือเดิมมาใช้ด้วยการกลับเครื่องหมายทั้งชันและจุดตัด (สะท้อนรูปข้ามแกน x)
แล้วถามที่ -y[i] ซึ่งเรียงเพิ่มขึ้นพอดี ค่ามากสุดจึงเท่ากับลบของค่าน้อยสุด */
Hull h;
h. add ({ 0 , 0 }); // ไม่เลือกรูปไหนก่อนหน้าเลย f = 0
ll ans = 0 ;
for ( int i = 0 ; i < n; i ++ ) {
ll f = r[i].x * r[i].y - r[i].a - h. query ( - r[i].y);
ans = max (ans, f);
h. add ({ - r[i].x, - f});
}
cout << ans << " \n " ;
return 0 ;
}
เปลือกยังเป็นก้อนเดิมอีกครั้ง สิ่งที่เพิ่มมาคือเครื่องหมายลบสามตัว
คือที่ความชัน ที่จุดตัดแกน และที่ตำแหน่งที่ถาม
ดูตัวตรวจที่ไม่เชื่อแม้แต่ว่าข้อนี้เป็นดีพี
/* ตัวตรวจของฝึกข้อ 3: ไล่ทุกสับเซตของรูป แล้วคิดพื้นที่ยูเนียนจากนิยามตรง ๆ
ไม่ใช้ดีพี ไม่ใช้ข้ออ้างเรื่อง "รูปขวาสุด" และไม่ใช้เรื่องไม่มีรูปซ้อนกันเลย */
#include < bits/stdc++.h >
using namespace std;
typedef long long ll;
struct R { ll x, y, a; };
int main () {
int n;
if ( ! (cin >> n)) return 0 ;
vector < R > r (n);
for ( auto & t : r) cin >> t.x >> t.y >> t.a;
ll best = 0 ; // ไม่เลือกรูปไหนเลยได้ 0
for ( int mask = 1 ; mask < ( 1 << n); mask ++ ) {
vector < R > s;
for ( int i = 0 ; i < n; i ++ ) if (mask >> i & 1 ) s. push_back (r[i]);
vector < ll > xs;
for ( auto & t : s) xs. push_back (t.x);
sort (xs. begin (), xs. end ());
xs. erase ( unique (xs. begin (), xs. end ()), xs. end ());
ll area = 0 , prev = 0 ;
for (ll cut : xs) { // แถบแนวตั้ง (prev, cut]
ll top = 0 ;
for ( auto & t : s) if (t.x >= cut) top = max (top, t.y);
area += (cut - prev) * top;
prev = cut;
}
ll cost = 0 ;
for ( auto & t : s) cost += t.a;
best = max (best, area - cost);
}
cout << best << " \n " ;
return 0 ;
}
ตัวนี้ไล่ทุกสับเซตของรูป แล้วคิดพื้นที่ยูเนียนจากนิยามตรง ๆ
ด้วยการสแกนแถบแนวตั้ง มันไม่รู้จักดีพี ไม่รู้จักคำว่า "รูปขวาสุด"
และไม่ได้ใช้เงื่อนไขเรื่องไม่มีรูปซ้อนกันเลย ซึ่งเป็นเหตุผลเดียวที่มันเชื่อถือได้
ตรวจ 1,500 รอบ ด้วยรูปสุ่มไม่เกินแปดรูปที่เคารพเงื่อนไขไม่มีรูปซ้อนกัน
ตรงกันทุกรอบ และตอบตัวอย่างทั้งสองชุดของโจทย์ได้ถูก คือ 9 กับ 10
ฝึกข้อ 4 · กระโดดลงใบ (CF 932F Escape Through Leaf)
มีต้นไม้ n ปมที่มีรากอยู่ที่ปมที่ 1 ปมที่ i มีค่าประจำตัวสองค่าคือ a[i] และ b[i] จากปมหนึ่งกระโดดไปปมไหนก็ได้ที่อยู่ในต้นย่อยของมัน
(แต่กระโดดมาที่ตัวเองไม่ได้) ราคาของการกระโดดจาก u ไป v คือ a[u]·b[v] ให้หาว่าแต่ละปมกระโดดลงไปถึงใบใดก็ได้ ด้วยราคารวมน้อยที่สุดเท่าไร
รากไม่ถือเป็นใบ ไม่ว่ามันจะมีกิ่งเดียวก็ตาม
อินพุต / ขอบเขต / เอาต์พุต
อินพุต: n แล้วอาเรย์ a แล้วอาเรย์ b
แล้วเส้นเชื่อม n-1 เส้น
ขอบเขต: n ถึงหนึ่งแสน ค่าใน a และ b
อยู่ระหว่างลบหนึ่งแสนถึงหนึ่งแสน ติดลบได้ และไม่มีการรับประกันว่าเรียง เอาต์พุต: n จำนวน ค่าที่ i คือคำตอบของปมที่ i EXAMPLE Input Output 4 5 -10 5 7 -8 -80 -3 -10 2 1 2 4 1 3 -300 100 0 0
อ่านตัวอย่างนี้ยังไง
บรรทัดที่สองคือ a ของทุกปมเรียงตามหมายเลขปม บรรทัดที่สามคือ b
แล้วอีก 3 บรรทัดคือเส้นเชื่อม เอาต์พุตเป็นราคารวมของแต่ละปม
ทั้ง 4 ปม ไม่ใช่ค่าเดียว และไม่ใช่จำนวนครั้งที่กระโดด
ปมที่เป็นใบตอบ 0 เพราะมันถึงใบอยู่แล้วโดยไม่ต้องกระโดด
เส้นเชื่อมในตัวอย่างนี้ทำให้ปมที่ 1 เป็นรากที่มีลูกคือปมที่ 2 และปมที่ 3
และปมที่ 2 มีลูกคือปมที่ 4 ปมที่ 3 กับปมที่ 4 จึงเป็นใบ ตอบ 0 ทั้งคู่
ปมที่ 2 มีทางเลือกเดียวคือกระโดดไปปมที่ 4 ราคา a[2]·b[4] = -10 × -10 = 100
ปมที่ 1 น่าสนใจที่สุด มันกระโดดตรงไปใบได้เลย แต่ไม่คุ้ม
ทางที่ถูกที่สุดคือแวะที่ปมที่ 2 ก่อน คือ 1 ไป 2 ราคา 5 × -80 = -400 แล้ว 2 ไป 4 ราคา -10 × -10 = 100 รวม -300 จุดที่ทำให้มันคุ้มคือ b ของปมที่ 2 ติดลบมาก
การกระโดดไปที่นั่นจึงได้เงินคืน แล้วค่อยจ่ายต่อจากนั้น
ปม 1
a=5 b=-8
ตอบ -300
ปม 2
a=-10 b=-80
ตอบ 100
ปม 3
a=5 b=-3
ตอบ 0
ปม 4
a=7 b=-10
ตอบ 0
กรอบทองคือใบ · เส้นเขียวหนาคือทางกระโดดของราก รวม -300
ขยาย ต้นไม้ในตัวอย่าง ตัวเลขในกล่องคือ a และ b ของปมนั้น ตัวเลขข้างกล่องคือคำตอบของปมนั้น ลูกศรเขียวคือเส้นทางกระโดดที่ถูกที่สุดของราก ซึ่งแวะปมกลางก่อนเพราะ b ของปมนั้นติดลบมาก รวม -300 ต้นไม้ในตัวอย่าง ตัวเลขในกล่องคือ a และ b ของปมนั้น ตัวเลขข้างกล่องคือคำตอบของปมนั้น ลูกศรเขียวคือเส้นทางกระโดดที่ถูกที่สุดของราก ซึ่งแวะปมกลางก่อนเพราะ b ของปมนั้นติดลบมาก รวม -300
เลื่อนล้อเมาส์เพื่อซูม คลิกลากเพื่อเลื่อนภาพ (บนมือถือใช้สองนิ้วขยาย นิ้วเดียวลากเลื่อน)
ใบ้
เขียนสูตรออกมาก่อน dp[u] = min over v ในต้นย่อยของ u ของ a[u]·b[v] + dp[v]
แล้วดูว่าอะไรเป็นความชัน อะไรเป็นตำแหน่งที่ถาม พอตอบได้แล้วให้กลับไปอ่านขอบเขตอีกที
แล้วถามสองคำถามประจำ ครั้งนี้คำตอบจะเป็น "ไม่" ทั้งสองข้อ
คำถามถัดไปจึงเป็นว่า เครื่องมือที่ไม่สนลำดับเลย จะเอามาต่อกับต้นไม้ได้ยังไง
เมื่อแต่ละปมต้องรู้เส้นของทุกปมในต้นย่อยของตัวเอง
เฉลยฝึกข้อ 4 แตะเพื่อดูเฉลย เรื่องจริงที่เกิดขึ้นตามลำดับ
สองคำถามประจำตอบง่ายมากในข้อนี้ ความชันคือ b[v] ตำแหน่งที่ถามคือ a[u] ทั้งคู่โจทย์ให้มาแบบสุ่ม ติดลบได้ และไม่รับประกันอะไรเลย
จึงเป็นระดับ 3 ตั้งแต่บรรทัดแรกที่อ่านขอบเขตจบ ไม่มีอะไรให้ลังเล
ของที่ต้องคิดจริง ๆ คือการต่อลีเชากับต้นไม้ ปมหนึ่งต้องถามเส้นของทุกปมในต้นย่อยของตัวเอง
ซึ่งคือการรวมต้นไม้ของลูกทุกตัวเข้าด้วยกัน ผมเขียน mergeTree
แบบเดียวกับที่รวมเซกเมนต์ทรีสองต้น คือรวมลูกซ้ายกับลูกซ้าย ลูกขวากับลูกขวา
แล้วยัดเส้นของปมฝั่งที่ถูกกลืนลงไปในต้นที่เหลือ
รันตัวอย่างที่หนึ่ง ผ่าน ได้ 10 50 0 ตรงเฉลย รันตัวอย่างที่สอง
ไม่ผ่าน ได้ -50 100 0 0 ขณะที่เฉลยคือ -300 100 0 0
ค่าที่ผิดคือปมที่ 1 เพียงตัวเดียว และมันผิดไปในทางที่แย่กว่าคำตอบจริง
ซึ่งแปลว่าโปรแกรมมองไม่เห็นทางเลือกบางทาง ไม่ใช่คิดราคาผิด
ผมไล่ดูว่าทางที่หายไปคือทางไหน คำตอบคือทางที่ผ่านปมที่ 2 ซึ่งแปลว่า
เส้นของปมที่ 2 หายไปจากต้นไม้ที่ถูกรวมขึ้นมา ตรงนั้นเองที่ผมเห็นว่า
ปัญหาไม่ได้อยู่ที่ตรรกะของลีเชาเลย มันอยู่ที่ C++ คือผมส่ง nd[t].lc
เข้าไปเป็นอ้างอิง แล้วข้างในนั้นมีการ push_back
ซึ่งย้าย vector ทั้งก้อนไปที่ใหม่ อ้างอิงที่ถืออยู่จึงชี้ไปหน่วยความจำเก่า
การเขียนค่ากลับหายไปเงียบ ๆ
บทเรียนที่หยิบไปใช้ต่อได้คือ เวลาโครงสร้างข้อมูลแบบจองปมเองเก็บอยู่ใน vector
ห้ามถืออ้างอิงหรือพอยน์เตอร์เข้าไปในนั้นข้ามการ push_back เด็ดขาด
ท่าที่ปลอดภัยคือให้ฟังก์ชันคืนหมายเลขปมกลับมา แล้วเขียนค่าลงหลังการเรียกจบ
และข้อนี้โชคดีที่ตัวอย่างของโจทย์จับได้ ถ้าตัวอย่างเป็นต้นไม้เล็กกว่านี้
หรือ vector ยังไม่ต้องขยาย มันจะผ่านตัวอย่างแล้วไปตกเทสจริง
นิยาม dp[u] ว่าเป็นราคารวมน้อยสุดที่จะกระโดดจาก u ลงไปถึงใบใดก็ได้
ในต้นย่อยของ u ถ้า u เป็นใบก็ได้ 0
การกระโดดครั้งแรกไปที่ v ราคา a[u]·b[v] แล้วจ่ายต่ออีก dp[v] ก้อนนี้เป็นเส้นชัน b[v] จุดตัดแกน dp[v]
ที่ตำแหน่ง a[u]
โครงของโค้ดจึงเป็น เดินต้นไม้จากล่างขึ้นบน ที่ปม u ให้
รวมต้นไม้ลีเชาของลูกทุกตัว เข้าเป็นต้นเดียวก่อน แล้ว
ถามที่ a[u] ได้ dp[u] ออกมา แล้วจึง
ใส่เส้นของตัวเอง ลงในต้นนั้น เพื่อให้พ่อของมันใช้ต่อได้
ลำดับสามอย่างนี้สลับกันไม่ได้ ถ้าใส่เส้นของตัวเองก่อนถาม ปมจะกระโดดมาที่ตัวเองได้ ซึ่งโจทย์ห้าม
สองเรื่องที่ต้องระวังเพิ่ม เรื่องแรกคือต้นไม้ลึกได้ถึงแสนชั้น
การเดินต้นไม้ด้วยฟังก์ชันเรียกตัวเองจะล้นสแต็กของระบบ ต้องใช้สแต็กของตัวเองแบบที่
บทดีพีบนต้นไม้ ทำ ส่วนการเรียกซ้ำภายในลีเชาไม่เป็นไร
เพราะมันลึกแค่ log ของความกว้างแกน x เรื่องที่สองคือช่วงของแกน x
ต้องคลุมค่า a ที่ติดลบ ได้ด้วย
โค้ดฝึกข้อ 4 แตะเพื่อดูเฉลย ดูโค้ด C++
#include < bits/stdc++.h >
using namespace std;
typedef long long ll;
const ll INF = (ll) 4 e 18 ;
const int XLO = - 100000 , XHI = 100000 ; // ช่วงของ a[i] ตามขอบเขตโจทย์
/* ---- ต้นไม้ลีเชา เก็บเส้นตรงหนึ่งเส้นต่อหนึ่งปม เดินรากถึงใบแล้วเจอเส้นที่ดีที่สุดแน่ ---- */
struct Node { int lc, rc; ll m, b; };
vector < Node > nd ( 1 , { 0 , 0 , 0 , INF }); // ปมที่ 0 คือ "ว่าง" เส้นสูงชนิดไม่มีวันชนะ
static inline ll at ( int t , ll x ) { return nd[t].m * x + nd[t].b; }
int makeNode ( ll m , ll b ) {
nd. push_back ({ 0 , 0 , m, b});
return ( int )nd. size () - 1 ;
}
/* ใส่เส้นใหม่ ตัดสินที่จุดกลางว่าเส้นไหนต่ำกว่า เก็บตัวที่ต่ำกว่าไว้ที่ปมนี้
อีกตัวจะต่ำกว่าได้แค่ครึ่งเดียว จึงส่งลงไปครึ่งนั้น ทำให้ลึกแค่ log ของช่วง
คืนค่าเป็นหมายเลขปม ไม่รับพารามิเตอร์เป็น int& เพราะ makeNode ทำ push_back
ซึ่งอาจย้ายตำแหน่งของ vector ทั้งก้อน แล้วอ้างอิงที่ถืออยู่จะชี้ไปที่หน่วยความจำเก่า */
int addLine ( int t , int l , int r , ll m , ll b ) {
if ( ! t) return makeNode (m, b);
int mid = l + (r - l) / 2 ;
bool onLeft = m * l + b < at (t, l);
bool onMid = m * mid + b < at (t, mid);
if (onMid) { swap (nd[t].m, m); swap (nd[t].b, b); }
if (l == r) return t;
if (onLeft != onMid) {
int c = addLine (nd[t].lc, l, mid, m, b);
nd[t].lc = c;
} else {
int c = addLine (nd[t].rc, mid + 1 , r, m, b);
nd[t].rc = c;
}
return t;
}
/* รวมสองต้นเข้าด้วยกัน รวมลูกก่อน แล้วค่อยยัดเส้นของปมฝั่งที่ถูกกลืนลงไปในต้นที่เหลือ */
int mergeTree ( int a , int b , int l , int r ) {
if ( ! a) return b;
if ( ! b) return a;
int mid = l + (r - l) / 2 ;
if (l < r) {
int cl = mergeTree (nd[a].lc, nd[b].lc, l, mid);
nd[a].lc = cl;
int cr = mergeTree (nd[a].rc, nd[b].rc, mid + 1 , r);
nd[a].rc = cr;
}
return addLine (a, l, r, nd[b].m, nd[b].b);
}
ll queryTree ( int t , int l , int r , ll x ) {
if ( ! t) return INF;
ll res = at (t, x);
if (l == r) return res;
int mid = l + (r - l) / 2 ;
if (x <= mid) return min (res, queryTree (nd[t].lc, l, mid, x));
return min (res, queryTree (nd[t].rc, mid + 1 , r, x));
}
int main () {
ios :: sync_with_stdio ( false );
cin. tie ( nullptr );
int n;
if ( ! (cin >> n)) return 0 ;
vector < ll > a (n + 1 ), b (n + 1 );
for ( int i = 1 ; i <= n; i ++ ) cin >> a[i];
for ( int i = 1 ; i <= n; i ++ ) cin >> b[i];
vector < vector <int>> g (n + 1 );
for ( int e = 0 ; e < n - 1 ; e ++ ) {
int u, v;
cin >> u >> v;
g[u]. push_back (v);
g[v]. push_back (u);
}
/* ไล่ต้นไม้ด้วยสแต็กของตัวเอง ไม่ใช้การเรียกซ้ำ เพราะต้นไม้ลึกได้ถึงแสนชั้น */
vector <int> par (n + 1 , 0 ), order;
order. reserve (n);
{
vector <int> stk{ 1 };
par[ 1 ] = - 1 ;
while ( ! stk. empty ()) {
int u = stk. back ();
stk. pop_back ();
order. push_back (u);
for ( int v : g[u]) if (v != par[u]) { par[v] = u; stk. push_back (v); }
}
}
/* dp[u] = ค่าน้อยสุดที่จะกระโดดจาก u ลงไปถึงใบใดก็ได้ในต้นย่อยของ u
กระโดดครั้งแรกไปที่ v ราคา a[u]*b[v] แล้วจ่ายต่ออีก dp[v]
ก้อนนี้เป็นเส้นตรงชัน b[v] จุดตัด dp[v] ที่ตำแหน่ง a[u]
ความชัน b[v] มาแบบไม่เรียง และตำแหน่งถาม a[u] ก็ไม่เรียง จึงใช้ต้นไม้ลีเชา */
vector < ll > dp (n + 1 , 0 );
vector <int> root (n + 1 , 0 );
for ( int t = ( int )order. size () - 1 ; t >= 0 ; t -- ) {
int u = order[t];
for ( int v : g[u]) if (v != par[u]) root[u] = mergeTree (root[u], root[v], XLO, XHI);
dp[u] = root[u] ? queryTree (root[u], XLO, XHI, a[u]) : 0 ; // ไม่มีลูกคือเป็นใบ จ่าย 0
root[u] = addLine (root[u], XLO, XHI, b[u], dp[u]);
}
for ( int i = 1 ; i <= n; i ++ ) cout << dp[i] << " \n " [i == n];
return 0 ;
}
ตรวจกับตัวไล่ทุกปมในต้นย่อยตรง ๆ 2,600 รอบ แบ่งเป็นต้นไม้สุ่มเล็ก
ไม่เกินสิบปม 2,000 รอบ กับต้นไม้ไม่เกินหกสิบปมที่บังคับทรงเป็นทางยาว
เป็นดาว และเป็นแบบสุ่ม อีก 600 รอบ ตรงกันทุกรอบ
วัดเวลาด้วยต้นไม้ที่เป็นทางยาวหนึ่งแสนปม ซึ่งเป็นทรงที่ทำให้
การเรียกตัวเองล้มแน่ ๆ ได้ 0.06 วินาที และใช้หน่วยความจำราว 12 เมกะไบต์
ดูตัวตรวจที่ไม่รู้จักต้นไม้ลีเชาเลย
/* ตัวตรวจของฝึกข้อ 4: ไล่ทุกปมในต้นย่อยตรง ๆ ไม่มีต้นไม้ลีเชา ไม่มีเปลือกนูน */
#include < bits/stdc++.h >
using namespace std;
typedef long long ll;
const ll INF = (ll) 4 e 18 ;
int n; vector < vector <int>> g; vector <int> par, order_;
int main (){
if ( ! (cin >> n)) return 0 ;
vector < ll > a (n + 1 ), b (n + 1 );
for ( int i = 1 ;i <= n;i ++ )cin >> a[i];
for ( int i = 1 ;i <= n;i ++ )cin >> b[i];
g. assign (n + 1 ,{});
for ( int e = 0 ;e < n - 1 ;e ++ ){ int u,v;cin >> u >> v;g[u]. push_back (v);g[v]. push_back (u);}
par. assign (n + 1 , 0 );
vector <int> stk{ 1 };par[ 1 ] =- 1 ;
while ( ! stk. empty ()){ int u = stk. back ();stk. pop_back ();order_. push_back (u);
for ( int v:g[u]) if (v != par[u]){par[v] = u;stk. push_back (v);}}
// ปมในต้นย่อยของแต่ละ u
vector < vector <int>> sub (n + 1 );
for ( int t = ( int )order_. size () - 1 ;t >= 0 ;t -- ){ int u = order_[t];sub[u]. push_back (u);
for ( int v:g[u]) if (v != par[u]) for ( int w:sub[v])sub[u]. push_back (w);}
vector < ll > dp (n + 1 , 0 );
for ( int t = ( int )order_. size () - 1 ;t >= 0 ;t -- ){ int u = order_[t];ll best = INF;
for ( int v:sub[u]) if (v != u)best = min (best,a[u] * b[v] + dp[v]);
dp[u] = (best == INF ? 0 : best);}
for ( int i = 1 ;i <= n;i ++ )cout << dp[i] << " \n " [i == n];
}
ตัวนี้เก็บรายชื่อปมทุกตัวในต้นย่อยของแต่ละปมไว้ตรง ๆ แล้วไล่ดูทุกตัว
ช้าเป็น O(n²) แต่มันไม่ใช้เปลือกนูน ไม่ใช้ลีเชา
และไม่เชื่อข้ออ้างอะไรของบทนี้เลย ซึ่งเป็นเหตุผลเดียวที่มันเชื่อถือได้
ดูบรรทัดที่ผิด กับอินพุตที่จับมันได้
// ต้นไม้ลีเชา เวอร์ชันแรกที่ผมเขียน ซึ่งผิด
// ปมเก็บใน vector<Node> nd แล้วรับพารามิเตอร์เป็นอ้างอิงไปยังช่องลูก
void addLine ( int & t , int l , int r , ll m , ll b ) {
// makeNode ข้างล่างทำ nd.push_back() ซึ่งถ้า vector เต็มจะจองบล็อกใหม่แล้วย้ายข้อมูลทั้งก้อน
// ตอนนั้น t ที่เป็นอ้างอิงไปยัง nd[...].lc ของบล็อกเก่า กลายเป็นอ้างอิงค้าง
if ( ! t) { t = makeNode (m, b); return ; }
int mid = l + (r - l) / 2 ;
bool onLeft = m * l + b < at (t, l);
bool onMid = m * mid + b < at (t, mid);
if (onMid) { swap (nd[t].m, m); swap (nd[t].b, b); }
if (l == r) return ;
// สองบรรทัดนี้คือจุดตาย ส่ง nd[t].lc เข้าไปเป็นอ้างอิง
// แล้วข้างในนั้นมีการ push_back ซึ่งย้าย nd ทั้งก้อน
// การเขียนค่ากลับจึงไปลงที่หน่วยความจำที่ถูกปล่อยแล้ว เส้นที่ใส่หายไปเงียบ ๆ
if (onLeft != onMid) addLine (nd[t].lc, l, mid, m, b);
else addLine (nd[t].rc, mid + 1 , r, m, b);
}
// อาการ: ตัวอย่างที่หนึ่งของโจทย์ผ่าน (10 50 0) แต่ตัวอย่างที่สองตอบ
// -50 100 0 0
// ขณะที่คำตอบคือ
// -300 100 0 0
// ปมที่ 1 ควรกระโดดไปปมที่ 2 ด้วยราคา 5 * (-80) = -400 แล้วต่อไปปมที่ 4 อีก 100 รวม -300
// แต่เส้นของปมที่ 2 หายไปจากต้นไม้ที่ถูกรวมขึ้นมา จึงเหลือทางที่แย่กว่าคือกระโดดตรงไปปมที่ 4 ได้ -50
//
// ทางแก้: ให้ addLine คืนหมายเลขปมกลับมา แล้วเขียนค่าลง nd[t].lc หลังการเรียกจบ
// ตอนนั้น nd ถูกมองใหม่แล้ว จึงไม่มีอ้างอิงค้างข้ามการ push_back อีก
ผมคอมไพล์เวอร์ชันนี้จริงเพื่อยืนยันเรื่องที่เล่า มันผ่านตัวอย่างที่หนึ่งของโจทย์
และตกตัวอย่างที่สอง นี่เป็นหนึ่งในไม่กี่ครั้งที่ตัวอย่างของโจทย์จับบักได้เอง
และเป็นเหตุผลที่ควรรันตัวอย่างทุกชุดที่โจทย์ให้มา ไม่ใช่ชุดแรกชุดเดียว
ตัวตรวจที่ควรเขียนคู่กันทุกครั้ง
ท่านี้เป็นท่าที่ตัวตรวจคุ้มค่าที่สุดในคลังนี้ร่วมกับคะนูธ
เพราะทุกอย่างที่ผิดได้ในบทนี้ผิดแบบไม่มีข้อความเตือน ทั้งการเดาเรื่องลำดับผิด
ทั้งการล้นของตัวคูณ และทั้งการวางลำดับ "ใส่ก่อน ถามหลัง" สลับกัน
อาการที่ออกมาเหมือนกันหมด คือตัวเลขที่ถูกในตัวอย่างของโจทย์ แต่ผิดในบางอินพุตที่ใหญ่กว่า
ดูโค้ดตัวตรวจ รวมสองชั้นในไบนารีเดียว
/* ตัวตรวจของทางเดินมีหลังคา รวมสองอย่างไว้ในไบนารีเดียว
ชั้นที่หนึ่ง: ดีพีที่ไล่ทางเลือกทุกทาง ซึ่งไม่รู้จักคำว่าเปลือกนูนเลย
ชั้นที่สอง: เปลือกล่างที่บทนี้สอน
ถ้าสองชั้นตอบไม่ตรงกันแม้แต่รอบเดียว โปรแกรมจะพิมพ์อินพุตที่พังออกมาให้เลย
ทำเป็นไบนารีเดียวที่วนเคสในตัว ไม่ใช่สองไบนารีกับสคริปต์เชลล์
เพราะการเปิดโปรเซสใหม่ต่อหนึ่งเคสแพงกว่าการคิดเคสนั้นหลายสิบเท่า */
#include < bits/stdc++.h >
using namespace std;
typedef long long ll;
struct Line { ll m, b; };
static inline ll lineAt ( const Line & L , ll x ) { return L.m * x + L.b; }
struct Hull {
vector < Line > st;
int ptr = 0 ;
static bool useless ( const Line & a , const Line & b , const Line & c ) {
return (__int128)(c.b - a.b) * (a.m - b.m) <= (__int128)(b.b - a.b) * (a.m - c.m);
}
void clampPtr () {
if (ptr >= ( int )st. size ()) ptr = ( int )st. size () - 1 ;
if (ptr < 0 ) ptr = 0 ;
}
void add ( Line L ) {
if ( ! st. empty () && st. back ().m == L.m) {
if (st. back ().b <= L.b) return ;
st. pop_back ();
}
while (st. size () >= 2 && useless (st[st. size () - 2 ], st. back (), L)) st. pop_back ();
st. push_back (L);
clampPtr ();
}
ll query ( ll x ) {
clampPtr ();
while (ptr + 1 < ( int )st. size () && lineAt (st[ptr + 1 ], x) <= lineAt (st[ptr], x)) ptr ++ ;
return lineAt (st[ptr], x);
}
};
ll byHull ( const vector < ll > & x , ll c ) {
int n = ( int )x. size ();
vector < ll > dp (n + 1 , 0 );
Hull h;
for ( int i = 1 ; i <= n; i ++ ) {
int j = i - 1 ;
h. add ({ - 2 * x[j], dp[j] + x[j] * x[j]});
dp[i] = h. query (x[i - 1 ]) + x[i - 1 ] * x[i - 1 ] + c;
}
return dp[n];
}
ll byEveryChoice ( const vector < ll > & x , ll c ) {
int n = ( int )x. size ();
vector < ll > dp (n + 1 , LLONG_MAX);
dp[ 0 ] = 0 ;
for ( int i = 1 ; i <= n; i ++ )
for ( int j = 0 ; j < i; j ++ ) {
ll d = x[i - 1 ] - x[j];
ll v = dp[j] + c + d * d;
if (v < dp[i]) dp[i] = v;
}
return dp[n];
}
int main () {
mt19937_64 rng ( 20260910 );
int rounds = 0 ;
/* กลุ่มที่หนึ่ง: เลขเล็ก จุดซ้ำกันได้บ่อย เพื่อจับกรณีชันเท่ากัน */
for ( int it = 0 ; it < 4000 ; it ++ ) {
int n = 1 + ( int )( rng () % 9 );
ll c = 1 + (ll)( rng () % 40 );
vector < ll > x (n);
for (ll & v : x) v = 1 + (ll)( rng () % 30 );
sort (x. begin (), x. end ());
ll a = byHull (x, c), b = byEveryChoice (x, c);
rounds ++ ;
if (a != b) {
printf ( " DIFF n= %d c= %lld hull= %lld truth= %lld \n x = " , n, c, a, b);
for (ll v : x) printf ( " %lld " , v);
printf ( " \n " );
return 1 ;
}
}
/* กลุ่มที่สอง: เลขใหญ่ระดับขอบเขตจริง เพื่อจับการล้นของตัวคูณในเงื่อนไขทิ้งเส้น
จุดจะถูกดันไปกองที่ปลายสองข้าง เพราะนั่นคือรูปที่ทำให้ผลต่างของจุดตัดแกนใหญ่ที่สุด */
for ( int it = 0 ; it < 4000 ; it ++ ) {
int n = 3 + ( int )( rng () % 8 );
ll c = 1 + (ll)( rng () % 1000000000 LL );
vector < ll > x (n);
for (ll & v : x) {
if ( rng () % 2 ) v = 1 + (ll)( rng () % 300 );
else v = 1000000000 LL - (ll)( rng () % 300 );
}
sort (x. begin (), x. end ());
ll a = byHull (x, c), b = byEveryChoice (x, c);
rounds ++ ;
if (a != b) {
printf ( " DIFF n= %d c= %lld hull= %lld truth= %lld \n x = " , n, c, a, b);
for (ll v : x) printf ( " %lld " , v);
printf ( " \n " );
return 1 ;
}
}
printf ( " ตรงกันทุกรอบ %d รอบ \n " , rounds);
return 0 ;
}
ชั้นแรกคือดีพีที่ไล่ทางเลือกทุกทาง ซึ่งไม่รู้จักคำว่าเปลือกนูนเลย ชั้นที่สองคือท่าที่บทนี้สอน
จุดที่ตั้งใจออกแบบคือกลุ่มทดสอบสองกลุ่ม กลุ่มแรกใช้เลขเล็กและปล่อยให้จุดซ้ำกันบ่อย
เพื่อจับกรณีความชันเท่ากัน กลุ่มที่สองใช้เลขระดับขอบเขตจริงและดันจุดไปกองที่ปลายสองข้าง
เพื่อจับการล้นของตัวคูณ
การแยกสองกลุ่มไม่ใช่เรื่องตกแต่ง ผมทดสอบแล้วว่ากลุ่มเลขเล็กเพียงกลุ่มเดียวจับการล้นไม่ได้
ผมเอาโค้ดตัวนี้ไปเปลี่ยน __int128 เป็น long long แล้วรัน
มันฟ้องภายในกลุ่มที่สองพร้อมพิมพ์อินพุตที่พังออกมาให้ ซึ่งเป็นการยืนยันว่าตัวตรวจตัวนี้
จับบักที่บทนี้เตือนไว้ได้จริง ไม่ใช่แค่รันผ่านสวย ๆ
และเหตุผลที่รวมสองชั้นไว้ในไบนารีเดียวแทนที่จะใช้สองไบนารีกับสคริปต์เชลล์ คือการเปิด
โปรเซสใหม่ต่อหนึ่งเคสแพงกว่าการคิดเคสนั้นหลายสิบเท่า แปดพันรอบในไบนารีเดียวจบในพริบตา
สรุปบรรทัดเดียว
ถ้าดีพีของคุณมีรูป dp[i] = (ก้อนที่ขึ้นกับ i ล้วน) + min หรือ max ของ ( m(j)·x(i) + b(j) )
แปลว่าทางเลือกทุกทางเป็นเส้นตรงบนระนาบเดียวกัน แล้วเส้นที่ไม่ได้อยู่บนขอบล่างก็ทิ้งได้ทันทีและตลอดไป
ที่เหลือคือตอบสองคำถามว่าความชันเรียงไหมและตำแหน่งที่ถามเรียงไหม
แล้วหยิบเครื่องมือตามตารางสามระดับ
ท่านี้ไม่ได้ทำให้การคิดแต่ละช่องเร็วขึ้น มันเปลี่ยนคำถามจาก "ไล่ดูทางเลือกทุกทาง"
เป็น "ที่ตำแหน่งนี้ เส้นไหนอยู่ต่ำที่สุด" ซึ่งเป็นคำถามที่เรขาคณิตตอบได้โดยไม่ต้องไล่
ที่มาและอ่านต่อ
meooow, "[Tutorial] Convex Hull Trick" ต้นทางหลักของบทนี้
ทั้งการแบ่งสามระดับ เงื่อนไขทิ้งเส้น ข้อสังเกตเรื่องความละเอียดของ long double
และรายการโจทย์ท้ายบทความที่โจทย์ฝึกทั้งสี่ข้อมาจากที่นั่น codeforces.com/blog/entry/63823
cp-algorithms, "Convex hull trick and Li Chao tree" ที่มาของกติกาต้นไม้ลีเชาที่ใช้ในระดับ 3 cp-algorithms.com
โจทย์ฝึก: Kattis coveredwalkway (2012 University of Chicago Invitational Programming Contest),
Codeforces 319C Kalila and Dimna in the Logging Industry, Codeforces 1083E The Fair Nut and Rectangles,
Codeforces 932F Escape Through Leaf
ในคลังนี้: เซกเมนต์ทรี คือบทที่ควรอ่านก่อนถ้าจะเข้าระดับ 3 และ ดีพีบนต้นไม้ คือที่มาของโครงเดินต้นไม้แบบไม่เรียกตัวเองที่ฝึกข้อ 4 ใช้
ท่าพี่น้องที่ตัดทางเลือกทิ้งด้วยเหตุผลอื่น: แบ่งครึ่งเร่งดีพี กับ คะนูธ ทั้งคู่อ้างว่าจุดตัดที่ดีที่สุดเดินไปทางเดียว
ต่างจากบทนี้ที่อ้างว่าทางเลือกเป็นเส้นตรง
ข้อก่อนหน้า เร่งดีพีบนช่วงแบบคะนูธ: จุดตัดที่ดีที่สุดเดินไปทางเดียว จึงไม่ต้องส่องซ้ำ ข้อถัดไป ดิสจอยต์เซต: รู้ในพริบตาว่าสองคนอยู่ก๊วนเดียวกันไหม แม้ก๊วนจะรวมกันไปแล้วเป็นแสนครั้ง สารบัญ