programming.in.th · ข้อ 2043
นับคู่เหตุการณ์ที่ขึ้นทั้งสองแกนภายในกรอบ กุญแจคือคู่ที่นับมีเงื่อนไขติดตัวอยู่แล้วสองข้อ ขอบทั้งสี่จึงยุบเหลือปลายละข้อ แล้วหั่นแกนดัชนีเป็นบล็อกจนคู่แตกเป็นหกหมวด หมวดข้ามบล็อกแก้ด้วยการกวาดค่าจากน้อยไปมาก ปิดท้ายด้วยการไล่เวลาจาก 14.75 วินาทีเหลือ 3.66 โดยไม่แตะตรรกะเลยสักบรรทัด
L ได้รับคำถามจากคนฉลาด ๆ ว่าถ้าปริภูมิเวลาเขียนลงระนาบสองมิติได้ เหตุการณ์หนึ่งก็เป็นจุดหนึ่งจุด และยุคสมัยหนึ่งก็เป็นสี่เหลี่ยมผืนผ้าหนึ่งรูป
เหตุการณ์มี N เหตุการณ์ เหตุการณ์ที่ i อยู่ที่จุด (i, Pi) โดย P เป็นการเรียงสับเปลี่ยนของ 1 ถึง N ดังนั้นไม่มีสองเหตุการณ์ไหนอยู่แถวเดียวกันหรือหลักเดียวกัน คู่เหตุการณ์ i กับ j นับเป็น โศกนาฏกรรม เมื่อ i มาก่อน j และค่าของ i น้อยกว่าค่าของ j คือจุดหนึ่งอยู่ล่างซ้ายของอีกจุดพอดี แต่ละยุคสมัยถามว่าในกรอบของยุคนั้นมีโศกนาฏกรรมกี่คู่
อินพุต / ขอบเขต / เอาต์พุต
N M บรรทัดที่สองคือ P ทั้ง N ตัว แล้วอีก M บรรทัดคือกรอบของแต่ละยุค เขียนเรียงเป็น r1 r2 c1 c2N ≤ 100,000, M ≤ 200,000, เวลา 4 วินาที หน่วยความจำ 1024 เมกะไบต์| Input | Output |
|---|---|
| 9 9 9 8 7 6 2 4 5 3 1 4 9 3 6 2 9 1 8 3 8 2 4 3 9 2 7 2 8 1 6 1 9 1 9 1 3 5 7 2 3 3 3 6 6 6 6 | 1 4 2 4 4 4 0 0 0 |
อ่านตัวอย่างนี้ยังไง
บรรทัดแรกคือจำนวนเหตุการณ์กับจำนวนยุค บรรทัดที่สองคือค่าของแต่ละเหตุการณ์เรียงตามเวลา เหตุการณ์ที่ 5 มีค่า 2 จึงเป็นจุด (5, 2) อีก 9 บรรทัดคือกรอบของแต่ละยุค เรียงเป็นช่วงดัชนีก่อน แล้วค่อยช่วงค่า เอาต์พุตมีบรรทัดละหนึ่งจำนวน คือจำนวนคู่ของเหตุการณ์ในกรอบนั้น ไม่ใช่จำนวนเหตุการณ์ในกรอบ
ยุคที่ 1 กรอบคือ 4 9 3 6 หมายถึงดัชนี 4 ถึง 9 และค่า 3 ถึง 6
ในกรอบนี้มีเหตุการณ์อยู่ 4 เหตุการณ์ คือ (4, 6), (6, 4), (7, 5), (8, 3)
แต่คำตอบคือ 1 เพราะคู่เดียวที่ขึ้นทั้งสองแกนคือ (6, 7) นั่นคือ P6 = 4 น้อยกว่า P7 = 5
คู่อื่นในกรอบค่าลดลงหมด ยุคที่ 2 ตอบ 4 จากคู่ (5, 6), (5, 7), (5, 8), (6, 7) ส่วนสามยุคท้ายตอบ 0
เพราะกรอบแคบจนเหลือเหตุการณ์ไม่ถึงสองอัน
ตัวอย่างชุดนี้ชี้ขาดการอ่านโจทย์ได้ด้วย ถ้าเผลออ่านว่านับคู่ที่ค่าลดลง ยุคที่ 1 จะได้ 5 และถ้าเผลออ่านลำดับตัวเลขในกรอบเป็นดัชนีสลับกับค่า ยุคที่ 1 จะได้ 0 ทั้งคู่ไม่ใช่ 1
ใบ้
ถ้ากรอบเดียวก็ไล่ทุกคู่ในกรอบจบ แต่มี 200,000 กรอบ และแต่ละกรอบอาจกิน 100,000 เหตุการณ์ คูณกันแล้วเกินงบไปมหาศาล
ลองสังเกตอย่างหนึ่งก่อน คู่ที่เรานับมีเงื่อนไขติดตัวอยู่แล้วสองข้อคือ i มาก่อน j และค่าของ i น้อยกว่าค่าของ j แล้วกรอบมีขอบสี่ด้าน ขอบทั้งสี่ด้านนั้นบังคับปลายทั้งสองข้างจริง ๆ หรือเปล่า
กรอบกินทั้งกระดาน อุ่นเครื่องก่อน
กดจุดสองจุดเพื่อเลือกหนึ่งคู่
กรอบทองคือยุคที่ถาม วงที่ขอบทองคือเหตุการณ์ที่อยู่ในกรอบ กดสองจุดเพื่อเสนอหนึ่งคู่
ลองเล่นดูก่อนนะครับ พอเห็นว่าคู่แบบไหนถึงนับได้แล้ว ค่อยไปดูเฉลย
ที่มาของแนวคิดนี้
ผมไม่ได้เริ่มจากการนึกท่าออก ผมเริ่มจากการนับว่าเรามีงบเท่าไร คำถาม 200,000 ข้อ ถ้าข้อหนึ่งยอมให้ใช้เวลาเท่ากับรากที่สองของ 100,000 คือราว 316 ก้าว รวมแล้วราว 63 ล้านก้าว ซึ่งพอดีกับ 4 วินาที แปลว่าเป้าหมายคือหาวิธีตอบข้อละรากที่สองของ N ให้ได้ ไม่ใช่ข้อละ log
พอรู้เป้าแล้วก็ย้อนกลับมาดูว่าอะไรขวางอยู่ สิ่งที่ขวางคือกรอบมีขอบสี่ด้าน และคู่หนึ่งคู่มีปลายสองข้าง ฟังดูเหมือนต้องดูแลเงื่อนไขแปดอย่าง ผมเลยลองเขียนเงื่อนไขทั้งหมดออกมาเรียงกันแล้วตัดที่ซ้ำทิ้ง ซึ่งเป็นขั้นที่เปลี่ยนทุกอย่าง
คู่ที่เรานับมี i มาก่อน j อยู่แล้ว ดังนั้นถ้า i อยู่ในช่วง [r1, r2] และ j ก็อยู่ในช่วงเดียวกัน เงื่อนไข j ≥ r1 เป็นจริงฟรี ๆ เพราะ j มากกว่า i อยู่แล้ว และ i ≤ r2 ก็เป็นจริงฟรีเช่นกัน เหลือของจริงแค่สองข้อ เรื่องค่าก็เหมือนกันเป๊ะ เพราะคู่ที่นับมีค่าของ i น้อยกว่าค่าของ j อยู่แล้ว
สี่เงื่อนไข ปลายละข้อ
ดัชนีอยู่ในกรอบทั้งคู่ ยุบเหลือ i ≥ r1 กับ j ≤ r2
ค่าอยู่ในกรอบทั้งคู่ ยุบเหลือ Pi ≥ c1 กับ Pj ≤ c2
ขอบซ้ายกับขอบล่างของกรอบบังคับเฉพาะปลายล่างของคู่ ส่วนขอบขวากับขอบบนบังคับเฉพาะปลายบน ไม่มีขอบไหนบังคับปลายทั้งสองพร้อมกัน ซึ่งทำให้ทุกอย่างหลังจากนี้แยกคิดทีละปลายได้
หั่นแกนดัชนีเป็นบล็อกละ B ตำแหน่ง ช่วง [r1, r2] ของคำถามหนึ่งจะแตกเป็นสามท่อนคือ เศษซ้าย บล็อกเต็มตรงกลาง และเศษขวา เศษสองฝั่งยาวไม่เกิน B ตำแหน่ง ส่วนตรงกลางเป็นบล็อกเต็มทั้งก้อน คู่หนึ่งคู่มีปลายสองข้าง จึงตกอยู่ในหกหมวด
| ปลายล่างอยู่ | ปลายบนอยู่ | จัดการยังไง |
|---|---|---|
| เศษซ้าย | เศษซ้าย | รวบสามหมวดนี้เป็นรอบเดียว ดึงชิ้นเศษออกมาแบบเรียงตามค่าอยู่แล้ว แล้วนับด้วย BIT |
| เศษขวา | เศษขวา | รวมอยู่ในรอบเดียวกันข้างบน |
| เศษซ้าย | เศษขวา | รวมอยู่ในรอบเดียวกันข้างบน |
| เศษซ้าย | บล็อกเต็ม | ต่อชิ้นเศษหนึ่งชิ้น ถามตารางพรีฟิกซ์ครั้งเดียวจบ |
| บล็อกเต็ม | เศษขวา | เหมือนบรรทัดบน แค่สลับข้าง |
| บล็อกเต็ม | บล็อกเต็ม | แยกเป็นบล็อกเดียวกันกับคนละบล็อก อันหลังคือตอนที่ 3 |
หมวดที่ปลายข้างหนึ่งเป็นชิ้นเศษและอีกข้างอยู่ในบล็อกเต็ม ตอบได้ทันทีถ้ามีตาราง
F[ค่า][บล็อก] ที่บอกว่าบล็อก 0 ถึงบล็อกนั้นมีกี่ตำแหน่งที่ค่าไม่เกินค่าที่ระบุ
เพราะช่วงบล็อกเต็มมีขอบตรงกับขอบบล็อกพอดี การนับจึงเป็นการลบกันสองครั้ง
หมวดที่ปลายทั้งสองอยู่ในบล็อกเต็มบล็อกเดียวกัน ก็เตรียมล่วงหน้าได้ เพราะแต่ละบล็อกมีสมาชิกไม่เกิน B ตัว จึงเก็บตารางสองมิติขนาด B คูณ B ต่อบล็อกไว้เลยว่า ถ้าอันดับของปลายล่างไม่เกิน u และอันดับของปลายบนไม่เกิน v จะมีกี่คู่ รวมทั้งคลังใช้พื้นที่ N คูณ B ซึ่งยังอยู่ในงบ
เหลือหมวดเดียวคือปลายทั้งสองอยู่ในบล็อกเต็มคนละบล็อก หมวดนี้เป็นฟังก์ชันของสี่ตัวแปร คือบล็อกแรก บล็อกสุดท้าย ขอบค่าล่าง และขอบค่าบน ตารางสี่มิติเป็นไปไม่ได้ ต้องเปลี่ยนมุม
มุมที่ใช้ได้คือกวาดค่าจากน้อยไปมาก ใส่เหตุการณ์ทีละอันตามลำดับค่า ของที่ใส่ไปแล้วมีค่าน้อยกว่าของที่ใส่ทีหลังเสมอ เงื่อนไขค่าของ i น้อยกว่าค่าของ j จึงเป็นจริงฟรี ๆ เหลือแค่เรื่องตำแหน่ง ตอนใส่เหตุการณ์ที่อยู่บล็อก bx มันจับคู่กับของที่ใส่แล้วในบล็อกที่เล็กกว่า bx ทุกตัว เก็บสองอย่างไว้
colTotal[bx] คือจำนวนคู่ที่ปลายบนอยู่บล็อก bx และปลายล่างอยู่บล็อกใดก็ได้ที่เล็กกว่าE[b1][bx] คือจำนวนคู่ที่ปลายบนอยู่บล็อก bx และปลายล่างอยู่บล็อกที่เล็กกว่า b1
คำถามที่ช่วงบล็อกเต็มเป็น b1 ถึง b2 จึงตอบด้วยการบวก colTotal[bx] − E[b1][bx] ไล่ตั้งแต่ bx = b1 ถึง b2
ซึ่งเป็นการอ่านแถวเดียวของ E ติดกันรวดเดียว
เหลือเรื่องเดียวคือขอบค่าล่าง เพราะการกวาดให้ผลของ "ค่าไม่เกิน c2" ไม่ใช่ "ค่าอยู่ระหว่าง c1 ถึง c2" ตรงนี้ใช้เอกลักษณ์ที่ตรงไปตรงมา ถ้า A คือของที่ค่าไม่เกิน c1 − 1 และ S คือของที่ค่าอยู่ใน [c1, c2]
สูตรที่ยุบขอบค่าสองด้านให้เหลือพรีฟิกซ์
คู่ที่อยู่ใน S ทั้งคู่ = (คู่ที่ค่าไม่เกิน c2 ทั้งคู่) − (คู่ที่ค่าไม่เกิน c1 − 1 ทั้งคู่) − Cross(A, S)
สองพจน์แรกคืออ่านค่าจากการกวาดที่เวลา c2 และที่เวลา c1 − 1 ส่วน Cross(A, S) คิดง่ายกว่าที่คิด เพราะค่าทุกตัวใน A น้อยกว่าค่าทุกตัวใน S ดังนั้นในคู่ข้าม ปลายล่างต้องเป็นฝั่ง A เสมอ ไม่มีทางสลับ Cross จึงเป็นแค่ผลรวมของจำนวนสมาชิก A ในบล็อกก่อนหน้า คูณจำนวนสมาชิก S ในบล็อกปัจจุบัน ไล่ทีละบล็อก ซึ่งอ่านจากตาราง F ได้ทั้งหมด
เรื่องจริงที่เกิดขึ้นตามลำดับ
ตัวแรกที่ให้คำตอบถูกทุกเคสใช้เวลา 14.75 วินาที จากลิมิต 4 วินาที คือช้ากว่าที่ต้องการเกือบสี่เท่า ตอนนั้นผมคิดว่าคงต้องรื้ออัลกอริทึม แต่สุดท้าย ไม่ได้แก้ตรรกะสักบรรทัด ที่แก้คือเรื่องการวางข้อมูลในหน่วยความจำล้วน ๆ
จุดที่ใหญ่ที่สุดคือตาราง F ผมเก็บเป็น F[บล็อก][ค่า] ตามที่นึกออกก่อน
แต่ลูปร้อนทุกอันในโปรแกรมวิ่งไล่ตามบล็อกโดยตรึงค่าไว้ การอ่านสองครั้งติดกันจึงห่างกัน 391 กิโลไบต์
และพลาดแคชทุกครั้ง พอสลับเป็น F[ค่า][บล็อก] ลูปเดียวกันก็อ่านติดกันรวดเดียว เวลาหายไป 4.26 วินาที
จากการสลับแกนอย่างเดียว
| แก้อะไร | วินาที |
|---|---|
| ตัวแรกที่ให้คำตอบถูก | 14.75 |
| ตัด binary search ต่อบล็อกต่อคำถาม ใช้ตาราง F แทน | 8.14 |
| สลับแกนตาราง F จาก F[บล็อก][ค่า] เป็น F[ค่า][บล็อก] | 3.88 |
| สลับแกนตาราง E กับตาราง f แล้วจูนขนาดบล็อกใหม่ | 3.66 |
บทเรียนที่พ่วงมาและผมไม่ได้คาดไว้เลยคือ ขนาดบล็อกที่ดีที่สุดย้ายที่หลังแก้แคช ก่อนแก้ บล็อกใหญ่เร็วกว่า คือ B = 240 ชนะ B = 120 พอแก้เสร็จกลับกลายเป็นตรงข้าม B = 139 เร็วที่สุด และ B = 80 ช้ากว่าราวหกสิบเปอร์เซ็นต์ เพราะตารางสองมิติของแต่ละบล็อก ต้องเล็กพอที่จะค้างอยู่ในแคชได้ ค่าคงที่ที่จูนไว้ตอนโค้ดยังวางข้อมูลผิดแกน จึงใช้ต่อไม่ได้เลย
ที่เหลือเป็นของเล็ก การอ่านอินพุตราวเก้าแสนจำนวนด้วย scanf กินไปราวหนึ่งในเจ็ดวินาที
เปลี่ยนเป็นอ่านทั้งไฟล์ทีเดียวแล้วแกะเองก็ได้คืนมา
ตรวจแล้วด้วยตัวอย่างของโจทย์ (รวมถึงรายการคู่ที่โจทย์ไล่ออกมาให้ในยุคที่ 1 และ 2) แล้วสุ่มอีก 980 เคส ที่ N เป็น 8, 20, 45, 120, 400, 1200, 4000 และลำดับสี่รูปแบบ (สุ่ม, กลับหลัง, เรียงแล้ว, ครึ่งเรียง) เทียบกับโค้ด subtask ทุกเคส และเทียบกับตัวตรวจเมื่อ N ไม่เกิน 120 ผิดศูนย์เคส
| รูปแบบคำถาม | วินาที | เมกะไบต์ |
|---|---|---|
| กรอบสุ่มทั้งสองแกน | 3.66 | 365 |
| กรอบเต็มระนาบทุกคำถาม | 1.7 | 359 |
| กรอบยาวเกือบเต็มแกนดัชนี | 3.59 | 363 |
| กรอบสั้น | 0.77 | 363 |
คำว่าตรวจแล้วในหน้านี้หมายถึงตรวจกับตัวตรวจของเราเองและตัวอย่างในโจทย์ ยังไม่ได้ส่งเข้าเครื่องตรวจของ programming.in.th และเวลาที่วัดเป็นของเครื่องที่ใช้เขียน ไม่ใช่ของเครื่องตรวจ ตัวเลข 92 เปอร์เซ็นต์จึงเป็นตัวเลขที่ควรระวัง
ก่อนออกแบบโครงสร้างข้อมูล ให้เขียนเงื่อนไขทั้งหมดของสิ่งที่กำลังนับออกมาเรียงกันก่อน แล้วตัดข้อที่เป็นจริงอยู่แล้วทิ้ง ข้อนี้ดูเหมือนมีแปดเงื่อนไข แต่พอตัดแล้วเหลือสี่ และสี่ข้อนั้นแยกกันคนละปลายพอดี ซึ่งเป็นเหตุผลเดียวที่ทำให้ทุกอย่างหลังจากนั้นเป็นไปได้
และเมื่อโปรแกรมช้ากว่าที่คาดสองถึงสี่เท่า ให้สงสัยการวางข้อมูลก่อนสงสัยอัลกอริทึม ลูปที่วิ่งไปตามแกนหนึ่งแต่ตารางเก็บเรียงตามอีกแกนหนึ่ง คือความต่างระดับหลายเท่าตัว โดยที่จำนวนคำสั่งเท่าเดิมทุกประการ
ขอบสี่ด้านของกรอบบังคับปลายคู่ละข้างพอดี หั่นแกนดัชนีเป็นบล็อกแล้วแตกคู่เป็นหกหมวด เหลือหมวดข้ามบล็อกที่แก้ด้วยการกวาดค่าจากน้อยไปมาก แล้วยุบขอบค่าสองด้านด้วยเอกลักษณ์ Cross
ในหน้านี้