programming.in.th · ข้อ 2028
อีกครึ่งคือต้องได้ชุดที่หมายเลขเล็กที่สุดตามพจนานุกรม บทนี้เปลี่ยนคำถามระดับทั้งชุด ให้เป็นคำถามระดับช่องว่างเดียวที่งานนั้นตกอยู่ แล้วตอบมันด้วยตารางกระโดดสองยกกำลัง
ศูนย์ประชุมมีห้องเดียว บริษัท N รายยื่นขอเช่า แต่ละรายบอกวันเริ่มกับวันจบของงานตัวเอง
และจะเช่าก็ต่อเมื่อได้ห้องคนเดียวตลอดช่วงนั้น ฝ่ายการตลาดอยากปล่อยห้องให้ได้จำนวนบริษัทมากที่สุด
ชุดที่ได้จำนวนมากที่สุดมักมีหลายชุด ฝ่ายการตลาดเลยเพิ่มกติกา: เขียนหมายเลขบริษัทของแต่ละชุดเรียงจากน้อยไปมาก แล้วเลือกชุดที่เล็กที่สุดตามลำดับพจนานุกรม (lexicographic order)
ลำดับพจนานุกรมคือเทียบตัวแรกก่อน ถ้าเท่ากันค่อยเทียบตัวที่สอง แบบเดียวกับการเรียงคำในพจนานุกรม
อินพุต / ขอบเขต / เอาต์พุต
N จากนั้นอีก N บรรทัด
บรรทัดที่ i คือวันเริ่มกับวันจบของบริษัทที่ i (นับรวมทั้งสองวัน)
N ≤ 200,000 วันเริ่มไม่น้อยกว่า 1 และวันจบไม่เกิน
1,000,000,000 เวลา 1.5 วินาที หน่วยความจำ 64 MB
N ไม่เกิน 3,000| Input | Output |
|---|---|
| 4 4 9 9 11 13 19 10 17 | 2 1 3 |
ชุดที่ใหญ่ที่สุดของตัวอย่างนี้มีสามชุด คือ (1, 3), (2, 3) และ (1, 4) เรียงตามพจนานุกรมแล้ว (1, 3) มาก่อน (1, 4) และ (1, 4) มาก่อน (2, 3) คำตอบคือ (1, 3)
ใบ้
โจทย์มีสองชั้น ชั้นแรกคือจำนวนต้องมากที่สุด ชั้นที่สองคือหมายเลขต้องเล็กที่สุด ชั้นแรกเป็นโจทย์คลาสสิกที่มีวิธีตอบสั้น ๆ อยู่แล้ว
สำหรับชั้นที่สอง ลองไล่บริษัทที่ 1, 2, 3 ไปเรื่อย ๆ แล้วรับตัวที่รับได้ คำถามที่ต้องตอบคือ "รับได้" แปลว่าอะไร และตรวจมันได้เร็วแค่ไหน
ชุดนี้มีหลายวิธีที่ได้จำนวนเท่ากัน คำถามคือชุดไหนที่หมายเลขเรียงแล้วเล็กที่สุด
กดที่แท่งเพื่อรับบริษัทนั้น แท่งที่ทับกันรับพร้อมกันไม่ได้
รับไว้ 0 บริษัท
เลือกครบแล้วกดตรวจคำตอบ หน้านี้จะบอกว่าจำนวนยังขาด หรือยังมีชุดที่หมายเลขเล็กกว่า
ชุดที่สองมีตัวล่อ (บริษัทที่ดูน่ารับเพราะหมายเลขเล็กหรือเริ่มเร็ว แต่รับแล้วเสียจำนวน) คือบริษัทที่เริ่มวันแรกสุดแต่ยาวจนกินที่ของอีกสองราย รับมันแล้วจำนวนรวมลดลงทันที ทั้งที่หมายเลขมันเล็กที่สุด
วิธีที่ถูกแน่นอนแต่ช้า จะเป็นตัวตรวจของทุกวิธีที่ตามมา
ลองทุกเซตย่อย
ทุก ๆ บริษัทที่เพิ่มขึ้นหนึ่งราย จำนวนชุดเพิ่มเป็นสองเท่า วัดจริงที่ N = 20 ใช้ 0.26 วินาที ที่ N = 22 ใช้ 1.37 วินาที ใกล้ลิมิต 1.5 วินาทีแล้ว
| ส่วน | N ไม่เกิน | ลองทุกเซตย่อยผ่านไหม |
|---|---|---|
| 50% แรก | 3,000 | ไม่ผ่าน |
| อีก 50% | 200,000 | ไม่ผ่าน |
การหาจำนวนมากที่สุดคือโจทย์จัดตารางช่วงเวลา (interval scheduling) ซึ่งมีวิธีตอบที่รู้กันมานาน เรียกว่าวิธีโลภ (greedy อ่านว่า "กรีดดี้" แปลว่าตะกละ) คือทุกครั้งหยิบตัวที่ดูดีที่สุดตรงหน้า แล้วไม่ย้อนกลับมาแก้
กฎการหยิบ
ยืนอยู่ที่วัน p หยิบงานที่เริ่มไม่ก่อน p และจบเร็วที่สุด
ถ้างานนั้นจบวัน e ก็ย้ายไปยืนที่วัน e + 1 แล้วทำซ้ำ
ตั้งแต่ตอนนี้จนจบบทใช้ชุดตัวอย่างชุดเดียว (ชุดเดียวกับแท็บที่สามของเกม) มี 8 บริษัท ช่วงวันที่ 1 ถึง 14: #1 (7 ถึง 10), #2 (9 ถึง 10), #3 (11 ถึง 12), #4 (1 ถึง 3), #5 (2 ถึง 4), #6 (2 ถึง 2), #7 (8 ถึง 8), #8 (12 ถึง 12)
เริ่มยืนที่วันที่ 1 แล้วทำตามกฎ
ถ้ามีหลายงานจบวันเดียวกัน จะหยิบตัวไหนก็ได้จำนวนเท่ากัน เพราะก้าวต่อไปยืนวันเดียวกัน หน้านี้หยิบตัวหมายเลขน้อยกว่าเสมอ
ทำไมจบเร็วก่อนถึงได้จำนวนมากที่สุด
เอาชุดที่ดีที่สุดชุดไหนก็ได้มาหนึ่งชุด แล้วสลับงานแรกของมันเป็นงานที่จบเร็วที่สุด งานที่สองของชุดเดิมเริ่มหลังงานแรกเดิมจบ และงานใหม่จบไม่ช้ากว่างานแรกเดิม งานที่สองก็ยังเริ่มหลังงานใหม่จบ
จำนวนไม่ลด ทำแบบเดียวกันกับงานที่สอง ที่สาม ไปเรื่อย ๆ ชุดที่ดีที่สุดก็กลายเป็นชุดของวิธีโลภทีละตัว
วิธีนี้ตอบได้แค่จำนวน ชุดที่มันหยิบมาเรียงแล้วคือ 2, 3, 6, 7 แต่คำตอบของชุดนี้คือ 2, 3, 4, 7 ซึ่งเล็กกว่าตามพจนานุกรม
นิยาม cnt(l, r)
เริ่มยืนที่วัน l แล้วหยิบตามกฎข้างบน โดยนับเฉพาะงานที่จบไม่เกินวัน r
ได้กี่งานคือ cnt(l, r) พูดง่าย ๆ คือช่วงวัน l ถึง r วางงานที่ไม่ทับกันได้มากสุดกี่งาน
เช่น cnt(1, 14) = 4 ตามที่เพิ่งเดินไป ถ้า l > r ให้ค่าเป็น 0
ที่มาของแนวคิดนี้
วิธีที่อยากทำที่สุดคือไล่บริษัทตามหมายเลข แล้วถามทีละตัวว่า "ถ้ารับตัวนี้ จำนวนรวมยังเท่าเดิมไหม"
คำถามนี้ถูกต้อง แต่ถ้าตอบด้วยการนับใหม่ทั้งชุดทุกครั้ง ราคาคือ N คูณ N
ช่องว่างที่จะเล่าต่อไปไม่ได้ทำให้ถูกลงด้วยตัวเอง (ตอนที่ 4 จะเห็นว่ามันยังราว 2 × N × N) สิ่งที่มันให้คือคำถามเหลือแค่นับสองช่วง แล้วการนับช่วงหนึ่งทำให้เร็วได้ด้วยตารางกระโดดในตอนที่ 5
เริ่มจากเหตุผลว่าทำไมไล่ตามหมายเลขแล้วได้ชุดที่เล็กที่สุด
ตัวที่รับไปแล้วยึดวันของมันไว้ วันที่เหลือแตกเป็นท่อนที่ไม่ติดกัน เรียกแต่ละท่อนว่าช่องว่าง (gap)
นึกภาพ
ถนนริมทางที่มีรถจอดอยู่แล้วบางคัน ที่ว่างระหว่างรถสองคันเป็นช่องแยกกัน รถคันใหม่ต้องลงในช่องเดียวทั้งคัน และการจอดในช่องหนึ่งไม่กินที่ของช่องอื่น
สิ่งที่เป็นจริงตลอดการไล่
จำนวนตัวที่รับไว้ + ผลรวมของ cnt ของทุกช่องว่าง = จำนวนมากที่สุดของทั้งโจทย์
ในชุดตัวอย่าง ตัวแรกที่ถูกรับคือ #2 (9 ถึง 10) หลังรับแล้วเหลือช่องว่าง 1 ถึง 8 (วางได้ 2) กับ 11 ถึง 14 (วางได้ 1) รวม 1 + 2 + 1 = 4 เท่ากับจำนวนมากที่สุด
เรียกจำนวนที่ช่องว่างหนึ่งวางได้ว่า c และเก็บไว้คู่กับช่องว่างตั้งแต่ตอนสร้าง
บริษัทใหม่ที่อยู่ในช่องว่าง [gl, gr] แตะได้แค่ช่องนี้ ถ้ารับมัน ช่องนี้แตกเป็นฝั่งซ้ายกับฝั่งขวาของมัน
เงื่อนไขรับ
cnt(gl, s - 1) + 1 + cnt(e + 1, gr) == c โดย s, e
คือวันเริ่มกับวันจบของบริษัทนี้
ซ้ายบวกตัวมันเองบวกขวาจะมากกว่า c ไม่ได้ เพราะ c คือค่ามากที่สุดของช่องนี้อยู่แล้ว
ถ้าเท่ากัน สิ่งที่เป็นจริงตลอดยังจริงอยู่ ถ้าน้อยกว่า รับแล้วทั้งโจทย์เสียจำนวน
ย้อนไปตอนเริ่ม ยังไม่มีใครถูกรับ ช่องว่างยังเป็นวันที่ 1 ถึง 14 ทั้งท่อน ภาพข้างล่างเทียบบริษัทสองรายแรก
แทนตัวเลขลงในเงื่อนไข
cnt(1, 6) + 1 + cnt(11, 14)
= 1 + 1 + 1 = 3 น้อยกว่า 4 ไม่รับ
cnt(1, 8) + 1 + cnt(11, 14)
= 2 + 1 + 1 = 4 เท่ากับ 4 รับ
ทุกค่า cnt ในสองบรรทัดนี้ตรงกับการลองทุกเซตย่อยของช่วงนั้น (หน้านี้ตรวจตอนสร้างหน้า)
มีอีกสองกรณีที่ไม่ต้องนับ
cnt(1, 14) ของตอนที่ 2
ตารางข้างล่างเดินชุดตัวอย่างทีละบริษัท กดถัดไปเพื่อเปิดทีละแถว ทุกบรรทัดใช้รูปแบบเดียวกับที่แทนตัวเลขไปเมื่อกี้
ไล่ทีละบริษัทบนชุด 8 ราย ช่วงวันที่ 1 ถึง 14 วางได้มากที่สุด 4 งาน
| บริษัท | ขอวันที่ | ช่องว่าง | เดิมได้ | ซ้าย + 1 + ขวา | ผล |
|---|---|---|---|---|---|
| #1 | 7 ถึง 10 | 1 ถึง 14 | 4 | 1 + 1 + 1 = 3 | ไม่รับ |
| #2 | 9 ถึง 10 | 1 ถึง 14 | 4 | 2 + 1 + 1 = 4 | รับ |
| #3 | 11 ถึง 12 | 11 ถึง 14 | 1 | 0 + 1 + 0 = 1 | รับ |
| #4 | 1 ถึง 3 | 1 ถึง 8 | 2 | 0 + 1 + 1 = 2 | รับ |
| #5 | 2 ถึง 4 | ทับ #4 | · | · | ไม่รับ |
| #6 | 2 ถึง 2 | ทับ #4 | · | · | ไม่รับ |
| #7 | 8 ถึง 8 | 4 ถึง 8 | 1 | 0 + 1 + 0 = 1 | รับ |
| #8 | 12 ถึง 12 | · | · | · | หยุด |
ชุดที่รับคือ 2, 3, 4, 7 ตรงกับการลองทุกเซตย่อย
วิธีนับ cnt(l, r) ที่ตรงที่สุดคือเรียงงานตามวันจบไว้ครั้งเดียว
แล้วทุกครั้งที่ถามก็เดินวิธีโลภไปตามรายการนั้น
c อ่านจากช่องว่างได้เลยN งานวิธีที่ 1
ราว 2N ชุด
ถูกแน่นอน ไปได้ถึง N ราว 22
ไม่ได้คะแนน ใช้เป็นตัวตรวจ
วิธีที่ 2
ไม่เกิน 2 × N × N ก้าว
เงื่อนไขของตอนที่ 3 กับ cnt ที่เดินวิธีโลภตรง ๆ
50% (N ไม่เกิน 3,000)
วิธีเต็ม
ราว 126 ก้าวต่อบริษัท
เงื่อนไขเดิมทุกบรรทัด เปลี่ยนแค่วิธีนับ (ตอนที่ 5)
100%
วัดจริงที่ N = 200,000 วิธีที่ 2 ใช้ 23 วินาที ช้ากว่าลิมิตราว 15 เท่า น้อยกว่าที่กราฟบอก เพราะกราฟนับกรณีที่ทุกการนับไล่ครบทุกงาน และลูปนี้ง่ายจนเครื่องทำได้เกิน 10⁸ ก้าวต่อวินาที
ตัวที่ต้องแก้คือการนับหนึ่งครั้ง เป้าคือให้ใช้ราว log₂ N ก้าวแทน N ก้าว (log₂ N คือจำนวนครั้งที่หาร N ด้วย 2 จนเหลือ 1 ที่ N = 200,000 ได้ราว 18)
วิธีโลภของตอนที่ 2 ไม่ได้เลือกอะไรเลย ยืนที่วัน p แล้วก้าวต่อไปถูกกำหนดตายตัว
เขียนเป็นฟังก์ชันได้
jump(p)
jump(p) = วันจบของงานที่เริ่มไม่ก่อน p และจบเร็วที่สุด แล้วบวก 1
ส่วน cnt(l, r) คือจำนวนครั้งที่ก้าวจากวัน l ได้ โดยยังยืนไม่เลยวัน r + 1
นึกภาพ
รถไฟสายหนึ่งที่สถานีถัดไปของทุกสถานีตายตัว มีขบวนธรรมดาไปสถานีถัดไป และขบวนด่วนที่ข้ามไปทีละ 2 สถานี 4 สถานี 8 สถานี
อยากรู้ว่าไปได้กี่สถานีก่อนเลยปลายทาง ให้ลองขบวนที่ไกลที่สุดก่อน ถ้าเลยปลายทางก็ไม่ขึ้น ถ้าไม่เลยก็ขึ้น แล้วลองขบวนที่สั้นลงครึ่งหนึ่งต่อจากที่ยืนอยู่
ตารางที่เก็บว่าจากแต่ละจุด ก้าว 1, 2, 4, 8, … ครั้งแล้วไปถึงไหน เรียกว่าตารางกระโดด (binary lifting อ่านว่า "ไบนารี ลิฟติ้ง" แปลตรง ๆ ว่ายกทีละกำลังสอง)
ช่อง 2 ก้าวได้จากการก้าว 1 ก้าวสองรอบ ช่อง 4 ก้าวได้จากช่อง 2 ก้าวสองรอบ ไล่ขึ้นไปเรื่อย ๆ
บีบพิกัด (coordinate compression)
วันมีถึงพันล้าน แต่จุดที่การนับไปยืนได้มีแค่วันที่ 1 กับวันจบ + 1 ของทุกงาน เพราะการนับเริ่มที่ขอบซ้ายของช่องว่างหรือวันจบ + 1 ของใครสักคน และทุกก้าวก็ลงที่วันจบ + 1
โค้ดเก็บตารางแค่ที่จุดเหล่านี้ บวกจุดปิดท้ายอีกหนึ่งจุดที่วันสุดท้าย + 1 ซึ่งไม่มีงานเริ่มได้ รวมไม่เกิน N + 2 จุด (+2 คือวันที่ 1 กับจุดปิดท้าย)
ในชุดตัวอย่าง วันสุดท้ายคือ 14 (โจทย์จริงคือพันล้าน) จุดปิดท้ายคือวัน 15 แถวของมันเป็น · ทั้งแถว
| ยืนที่วัน | ก้าวแรกหยิบ | 1 ก้าว | 2 ก้าว | 4 ก้าว |
|---|---|---|---|---|
| 1 | #6 (2 ถึง 2) | 3 | 9 | 13 |
| 3 | #7 (8 ถึง 8) | 9 | 11 | · |
| 4 | #7 (8 ถึง 8) | 9 | 11 | · |
| 5 | #7 (8 ถึง 8) | 9 | 11 | · |
| 9 | #2 (9 ถึง 10) | 11 | 13 | · |
| 11 | #3 (11 ถึง 12) เสมอ #8 | 13 | · | · |
| 13 | · | · | · | · |
| 15 | · | · | · | · |
ทำไมลองจากก้าวใหญ่ลงก้าวเล็กแล้วได้คำตอบพอดี ก้าวยิ่งมากยิ่งไปไกล ไม่มีทางถอยหลัง ถ้า 4 ก้าวเลยขอบ คำตอบต้องน้อยกว่า 4 ถ้า 2 ก้าวไม่เลย คำตอบอย่างน้อย 2
ทุกชั้นตัดสินหลักหนึ่งหลักของคำตอบในเลขฐานสอง ชั้นที่ก้าวคือหลักที่เป็น 1
ลองนับซ้ายของบริษัท 2 จากตอนที่ 3 คือ cnt(1, 8) ห้ามยืนเลยวัน 9
นับ cnt(1, 8) ด้วยการกระโดด
ได้ 2 = 0×4 + 1×2 + 0×1 ตรงกับค่าซ้ายในตอนที่ 3
โค้ดจริงต้องรองรับคำตอบถึง 200,000 ซึ่งน้อยกว่า 218 = 262,144 เขียนเป็นเลขฐานสองได้ไม่เกิน 18 หลัก ตาราง 18 ชั้นก็พอ
ส่วนการหาว่าจุดไหนอยู่ตรงวันไหน ใช้การค้นหาแบบแบ่งครึ่ง (binary search) คือหาตำแหน่งในรายการที่เรียงแล้ว โดยตัดครึ่งทีละรอบ รายการยาว 200,002 ใช้ราว 18 รอบ
นับก้าวของโค้ดเต็มที่ N = 200,000
รวม 36,000,036 ก้าว ราว 3.6×107 ต่ำกว่างบ
โค้ดเต็มคือโค้ดวิธีที่ 2 ทุกบรรทัด เปลี่ยนแค่ cnt เป็นการกระโดด แล้วเพิ่มการเตรียมตาราง
| ส่วนของโค้ด | อธิบายใน |
|---|---|
| นับ cnt(l, r) | ตอนที่ 5 การนับด้วยการกระโดด |
| บีบพิกัด | ตอนที่ 5 จุดที่การนับไปยืนได้ |
| ก้าวเดียว | ตอนที่ 2 กฎการหยิบ และตอนที่ 5 ฟังก์ชัน jump |
| ตารางกระโดด | ตอนที่ 5 ช่อง 2 ก้าวได้จาก 1 ก้าวสองรอบ |
| ไล่ตามหมายเลข | ตอนที่ 3 เงื่อนไขรับ |
ตัวเลขเวลาและหน่วยความจำในส่วนนี้วัดด้วยวิธีเดียวกันทั้งหมด (บอกไว้ท้ายส่วน) และ MB ในหน้านี้หมายถึง 1,048,576 ไบต์
โค้ดรุ่นแรกใช้หน่วยความจำเกือบเต็มลิมิต
โค้ดรุ่นแรกใช้เวลามากสุด 0.92 วินาที และหน่วยความจำสูงสุด 59.0 MB จากลิมิต 64 MB เหลือที่ว่างแค่ 5.0 MB
ตัวกินที่คือตารางกระโดด รุ่นแรกเก็บพิกัดทั้งวันเริ่ม วันจบ และวันจบ + 1 รวม 3N + 2 จุด ตาราง 18 ชั้นหนัก 41.2 MB พอเหลือ N + 2 จุด ตารางเหลือ 13.7 MB ประหยัด 27.5 MB
บนชุดเดียวกัน (งานสั้นกระจายห่าง) หน่วยความจำลดจาก 59.0 เหลือ 31.9 MB ลดไป 27.1 MB ใกล้กับที่ตารางเล็กลง
ค่าสูงสุดของโค้ดใหม่ 33.9 MB มาจากอีกชุดหนึ่งที่งานแทบไม่ทับกัน บริษัทถูกรับเกือบทุกราย ช่องว่างที่ต้องเก็บมีเกือบเท่าจำนวนบริษัท
แก้อีกสามจุดพร้อมกัน โดยไม่เปลี่ยนวิธีคิด
c ของมันไว้ ต่อบริษัทนับสองครั้งแทนสามครั้งเวลามากสุดลดเหลือ 0.59 วินาที ไม่ได้แยกวัดว่าแต่ละจุดช่วยเวลาเท่าไร
ตรวจอะไรไปบ้าง
วิธีวัด: WSL Ubuntu, g++ -O2 และ /usr/bin/time บนหกชุดที่ N = 200,000 รันชุดละห้ารอบ ใช้รอบที่เร็วที่สุดของแต่ละชุด แล้วรายงานชุดที่แย่ที่สุด
ตัวเลขเหล่านี้เป็นผลทดสอบบนชุดที่เลือกมา ยังไม่ได้พิสูจน์ว่าทุกอินพุตทันเวลา
สองชั้นของข้อนี้ตอบด้วยวิธีโลภกับการรับตามหมายเลขเมื่อช่องว่างไม่เสียจำนวน และทันเวลาได้เพราะนับช่วงด้วยตารางกระโดด
ส่วนที่ยกไปใช้ข้ออื่นได้คือ เวลาต้องถามซ้ำ ๆ ว่ารับตัวนี้แล้วคำตอบรวมเปลี่ยนไหม ให้ดูก่อนว่าของที่รับไปแล้วแบ่งปัญหาออกเป็นก้อนอิสระได้หรือเปล่า
ในหน้านี้