programming.in.th · ข้อ 2032
ข้อที่หนักที่สุดในคลังนี้ และเป็นข้อที่คุ้มที่สุดถ้าอยากเห็นการรื้อเฉลยจากลูกบาศก์ลงมาเหลือ N log N ทีละชั้น เริ่มจากตัดข้อมูลที่ไม่มีผลทิ้ง แล้วเปลี่ยนการจำลองเป็นสูตรปิด และปิดท้ายด้วยเหตุผลว่าทำไมไม่ต้องลองจุดเริ่มต้นครบทุกจุด
สนามยิงธนูมีเป้าเรียงเป็นเส้นตรง N เป้า หมายเลข 1 ถึง N จากซ้ายไปขวา
มีนักยิง 2N คน ยืนประจำเป้าละสองคน แล้วแข่งกันเป็นรอบ ๆ ทุกรอบทุกเป้าจะได้ผู้ชนะหนึ่งคนและผู้แพ้หนึ่งคน
จากนั้นทุกคนย้ายที่พร้อมกันตามกติกาสามข้อ
N ขยับไปเป้าที่อยู่ทางซ้ายมือหนึ่งเป้าN และผู้ชนะที่เป้า 1 ยืนอยู่ที่เดิมN
นักยิงทุกคนมีหมายเลขทักษะ 1 ถึง 2N ไม่ซ้ำกัน โดยเลขน้อยกว่าคือเก่งกว่า และคนเก่งกว่าชนะเสมอ
ไม่มีเซอร์ไพรส์ ไม่มีดวง การแข่งจะดำเนินไป R รอบ
คุณมาถึงสนามเป็นคนสุดท้าย อีก 2N - 1 คนยืนเรียงแถวรอไว้เรียบร้อยแล้ว สิ่งที่คุณทำได้อย่างเดียวคือ
แทรกตัวเองเข้าไปในแถว ณ จุดใดจุดหนึ่ง แล้วจากนั้นสองคนซ้ายสุดของแถวไปเข้าเป้า 1 สองคนถัดไปเข้าเป้า 2
ไล่ไปจนครบ เป้าหมายของคุณคือจบการแข่งขันที่เป้าหมายเลขน้อยที่สุดเท่าที่จะเป็นไปได้
และถ้ามีหลายทางที่ทำได้เท่ากัน ให้เลือกทางที่เริ่มต้นที่เป้าหมายเลขมากที่สุด
อินพุต / ขอบเขต / เอาต์พุต
N และ R จากนั้นอีก 2N บรรทัด
บรรทัดแรกของชุดนี้คือหมายเลขทักษะของตัวเรา ที่เหลืออีก 2N - 1 บรรทัด
คือหมายเลขทักษะของคนอื่นเรียงตามที่เขายืนอยู่ในแถวจากซ้ายไปขวา
1 ≤ N ≤ 200,000 และ 2N ≤ R ≤ 1,000,000,000
หมายเลขทักษะเป็น 1 ถึง 2N ไม่ซ้ำกัน เวลา 2 วินาที หน่วยความจำ 64 เมกะไบต์
N ≤ 5,000 และในนั้นมี 20 คะแนน
สำหรับเทสที่ N ≤ 200 | Input | Output |
|---|---|
| 4 8 7 4 2 6 5 8 1 3 | 3 |
| 4 9 2 1 5 8 3 4 7 6 | 2 |
หมายเลขทักษะเขียนบรรทัดละคน ตัวแรกคือเราเสมอ
ท่าแรกที่ทุกคนคิดถึงคือจำลองมันตรง ๆ ลองแทรกตัวเองทุกจุด แล้วเดินการแข่งให้ครบ R รอบ
ปัญหาคือตัวเลขในโจทย์ไม่ยอมให้ทำ จุดที่แทรกได้มี N จุด แต่ละจุดต้องเดิน R รอบ
และหนึ่งรอบต้องจัดการทั้ง N เป้า คูณกันแล้วได้ราว N × R × N ซึ่งที่ขอบเขตบนคือ
ตัวเลขที่ยาวเกินกว่าจะเขียนเป็นชื่อหน่วยได้
ที่แสบกว่าตัวเลขคือค่า R ที่ใหญ่ถึง 1,000,000,000 มันบอกใบ้อยู่กลาย ๆ ว่า
การเดินให้ครบทุกรอบไม่ใช่ทางที่โจทย์ตั้งใจ และเงื่อนไข R ≥ 2N ที่โจทย์แถมมาให้ก็แปลกดี
ทำไมโจทย์ถึงต้องรับประกันว่ารอบมีมากพอ ทั้งที่ปกติข้อจำกัดมักบอกว่าอะไรมีน้อย
ใบ้
ลองถามว่า เวลาเราขึ้นไปยืนที่เป้า สิ่งที่ตัดสินชะตาของเรารอบนั้นคืออะไรกันแน่ เราต้องรู้ไหมว่าคู่แข่งชื่ออะไร
เก่งเป็นอันดับที่เท่าไร หรือรู้แค่อย่างเดียวก็พอ แล้วถ้ารู้แค่อย่างเดียวพอ
คนอีก 2N - 1 คนบนสนามยังต่างกันอยู่กี่แบบ?
ตัวอย่างแรกของโจทย์ สี่เป้า แปดคน
กดจุดยืนเพื่อดูว่าซ้อมจนจบแล้วเราไปอยู่เป้าไหน แล้วเลือกจุดที่ดีที่สุด
ลองไปแล้ว 0 จุด · ดีที่สุดที่เจอคือเป้า ยังไม่มี
เป้าหมายคือจบให้ได้เป้าที่เลขน้อยที่สุด ถ้ามีหลายจุดที่จบเท่ากัน โจทย์ขอจุดยืนที่เลขมากที่สุด
ลองไล่ให้ครบทุกจุดแล้วดูลำดับของผลลัพธ์ ถ้ามันขึ้นแล้วลง แปลว่าท่าที่อาศัยความเรียงอย่างการค้นแบบไบนารีใช้ไม่ได้
ที่มาของแนวคิดนี้
สิ่งแรกที่ผมทำคือเขียนตัวจำลองคนจริงครบ 2N คน แล้วยอมรับว่ามันช้าเกินไป จากนั้นถามคำถามเดียวคือ หมายเลขทักษะของคนอื่นมีผลอะไรกับชะตากรรมของเราบ้าง นอกจากมันมากหรือน้อยกว่าของเรา พอตอบว่าไม่มีเลย ข้อมูลที่ต้องเก็บก็หายไปเกือบหมดในบรรทัดเดียว
คำถามแบบนี้ใช้ได้กับโจทย์อีกหลายข้อ ลองไล่ดูว่าอินพุตส่วนไหนที่ถ้าเปลี่ยนแล้วคำตอบไม่เปลี่ยน ส่วนนั้นคือส่วนที่ทิ้งได้
คำตอบของใบ้สั้นมาก เราแพ้ทุกคนที่หมายเลขน้อยกว่าเรา และชนะทุกคนที่หมายเลขมากกว่าเรา ตลอดกาล ทุกรอบ
ทุกเป้า ดังนั้นสำหรับเรา คนอื่นบนสนามมีแค่สองประเภท ติดป้าย 1 ให้คนที่เราไม่ชนะ
และ 0 ให้คนที่เราชนะ
คำถามคือแล้วป้ายพวกนี้ขยับยังไงเมื่อเวลาผ่านไป ซึ่งตอบได้จากกติกาโดยไม่ต้องรู้หมายเลขจริงของใครเลย เพราะที่เป้าหนึ่ง ๆ ผู้ชนะคือคนที่เก่งกว่า และคนที่เก่งกว่าย่อมมีป้ายไม่ต่ำกว่าคู่ของตัวเอง ไล่ทั้งสามกรณีแล้วได้กฎเดียว
1 เจอป้าย 1 ผู้ชนะเป็น 1 ผู้แพ้เป็น 11 เจอป้าย 0 ผู้ชนะเป็น 1 ผู้แพ้เป็น 00 เจอป้าย 0 ผู้ชนะเป็น 0 ผู้แพ้เป็น 0
สรุปเป็นประโยคเดียวว่า ทุกเป้าส่งป้ายที่สูงกว่าออกไป และเก็บป้ายที่ต่ำกว่าไว้ เท่านั้นเอง
พอเป็นแบบนี้ เราไม่ต้องเก็บว่าใครยืนตรงไหนอีกแล้ว เก็บแค่ตัวเลขเดียวต่อหนึ่งเป้าคือ
เป้านั้นมีคนป้าย 1 อยู่กี่คน ซึ่งเป็น 0, 1 หรือ 2 เรียกมันว่า c[i]
แปลงกฎ "ส่งตัวสูงออก เก็บตัวต่ำไว้" ให้เป็นสมการของ c ได้ตรง ๆ โดยจำไว้ว่าเป้า 1
กับเป้า N ผิดฝาผิดตัวกว่าเพื่อน เพราะที่เป้า 1 ผู้ชนะอยู่ต่อและผู้แพ้ถูกเหวี่ยงไปเป้า N
ก่อนอ่านสามบรรทัดนี้ ต้องรู้ก่อนว่าทุกบรรทัดพูดเรื่องเดียวกัน คือเป้าหนึ่งเป้าจะมีป้าย 1 กี่คนในรอบถัดไป และคำตอบประกอบจากสองส่วนเสมอ คือคนที่เป้านี้เก็บไว้เอง บวกคนที่รับมาจากเป้าทางขวา วงเล็บเหลี่ยมในสมการอ่านว่า "ถ้าเงื่อนไขนี้จริงให้นับหนึ่ง ถ้าไม่จริงให้นับศูนย์" สามบรรทัดจึงต่างกันแค่ว่า ใครคือคนที่ถูกเก็บไว้ และใครคือคนที่ถูกส่งต่อ
N - 1 ซึ่งเป็นกรณีปกติ
เป้านี้เก็บผู้แพ้ของตัวเองไว้ ซึ่งเป็นป้าย 1 ก็ต่อเมื่อเดิมมีป้าย 1
อยู่สองคน (ถ้ามีคนเดียว คนนั้นชนะแล้วเดินออกไป) แล้วรับผู้ชนะ
จากเป้าทางขวามาอีกหนึ่งคน ซึ่งเป็นป้าย 1 ก็ต่อเมื่อเป้านั้นมีป้าย 1 อย่างน้อยหนึ่งคน
1 ก็ต่อเมื่อมีป้าย 1 อย่างน้อยหนึ่งคน
ไม่ต้องถึงสอง นี่คือเหตุผลที่เป้า 1 กลายเป็นตะแกรงที่ดูดคนเก่งไปจอด และเป็นบรรทัดเดียวที่ทำให้
สูตรการไหลในตอนที่ 6 ต้องแบ่งสองเฟส
N ซึ่งไม่มีเป้าทางขวาให้รับของ มันรับ
ผู้แพ้ของเป้า 1 ที่ถูกเหวี่ยงข้ามมาแทน จึงนับเมื่อเป้า 1 มีป้าย 1 ครบสองคน
คือตอนที่ผู้แพ้ของเป้า 1 เป็นป้าย 1 ด้วย
ผลที่ได้จากสามบรรทัดนี้คือกระดานทั้งกระดานเดินต่อได้โดยไม่ต้องรู้ว่าใครเป็นใคร
รู้แค่จำนวนป้าย 1 ต่อเป้าก็พอ ซึ่งเป็นการบีบข้อมูลที่ทำให้ทุกอย่างหลังจากนี้เป็นไปได้
ตอนที่แล้วเราติดป้ายให้คนอื่น ทีนี้ตัวเราเองล่ะ เราชนะทุกคนที่ป้าย 0 จึงต้องนับตัวเองเป็นป้าย 1
แต่เราก็แพ้ทุกคนที่ป้าย 1 ด้วย พูดอีกแบบคือ เราคือป้าย 1 ที่อ่อนที่สุดในสนาม
ข้อสรุปที่ตามมาสวยมาก ถ้าเรายืนอยู่ที่เป้า i แล้ว c[i] = 2 แปลว่าที่เป้านี้มีป้าย 1
สองคน ซึ่งคนหนึ่งคือเราเอง อีกคนจึงเป็นป้าย 1 ที่ไม่ใช่เรา และเราแพ้เขาแน่นอน ส่วนถ้า c[i] = 1
ป้าย 1 ตัวเดียวนั้นคือเรา คู่แข่งจึงเป็นป้าย 0 และเราชนะแน่นอน
แปลว่าเราไม่ต้องรู้อะไรเกี่ยวกับคู่แข่งเลยนอกจากเลข c ของช่องที่เรายืนอยู่
ชะตาของเราแต่ละรอบจึงเขียนได้สามบรรทัด
bool oppStrong = (c[pos] == 2);
if (pos == 1) pos = oppStrong ? N : 1; // แพ้ที่เป้า 1 คือถูกส่งไปท้ายแถว
else if (!oppStrong) pos--; // ชนะที่เป้าอื่นคือขยับไปทางซ้าย
สังเกตว่าที่เป้า 1 การ "ชนะ" ไม่ได้พาเราไปไหน เพราะผู้ชนะที่เป้า 1 ยืนอยู่ที่เดิม ส่วนการ "แพ้" ที่เป้า 1
คือหายนะเต็ม ๆ เพราะโดนส่งไปเป้า N ไกลสุด แล้วต้องไต่กลับมาใหม่
กลับไปที่ข้อสังเกตค้างคาว่าทำไมโจทย์ต้องบอกว่ารอบมีมากพอ คำตอบคือเพราะระบบนี้เข้าสู่วงรอบ
ลองคิดถึงคนที่เก่งที่สุดในสนาม เขาชนะทุกครั้ง จึงไหลไปทางซ้ายทุกรอบจนถึงเป้า 1 แล้วก็ปักหลักอยู่ตรงนั้นตลอดไป
คนที่เก่งรองลงมาไต่มาถึงเป้า 1 แล้วแพ้ ถูกส่งไปเป้า N ไต่กลับมาใหม่ แพ้อีก วนแบบนี้ด้วยคาบ N
รอบเป๊ะ ๆ
พอทุกคนเข้าที่แล้ว หน้าตาของสนามทั้งสนามก็ซ้ำเดิมทุก N รอบ ช่วงตั้งตัวก่อนเข้าวงรอบกินเวลาไม่เกิน
2N รอบ ซึ่งตรงกับเงื่อนไข R ≥ 2N ที่โจทย์แถมมาให้พอดี โจทย์กำลังบอกเราว่า
"รับประกันว่าเลยช่วงตั้งตัวไปแล้ว" การเดินจริงจึงไม่ต้องครบ R รอบ แค่เดินให้พ้นช่วงตั้งตัว
แล้วเก็บเศษของคาบไว้
ผมเผื่อไว้ที่ 3N แทนที่จะเป็น 2N ตามที่โจทย์รับประกัน เพราะการเผื่ออีกหนึ่งคาบ
ราคาถูกมาก แต่กันความผิดพลาดตรงขอบพอดีได้หมด ผลคือเดินจริงไม่เกิน 4N รอบ ไม่ว่า R
จะใหญ่แค่ไหน
เอาตัวอย่างที่สองมาเดินจริง หมายเลขทักษะของเราคือ 2 จากทั้งหมด 8 คน จึงมีป้าย
1 อยู่แค่ 2 คน (นับตัวเราเองด้วย) และเราเลือกเริ่มที่เป้า 2
กดถัดไปเพื่อเดินกระดานทีละรอบ
| รอบ | c[1] | c[2] | c[3] | c[4] | เรายืนที่ | คู่แข่ง | ผล |
|---|---|---|---|---|---|---|---|
| 1 | 1 | [1] | 0 | 0 | เป้า 2 | ป้าย 0 เราชนะ | ชนะ ขยับไปเป้า 1 |
| 2 | [2] | 0 | 0 | 0 | เป้า 1 | ป้าย 1 เก่งกว่าเรา | แพ้ ถูกส่งไปเป้า 4 |
| 3 | 1 | 0 | 0 | [1] | เป้า 4 | ป้าย 0 เราชนะ | ชนะ ขยับไปเป้า 3 |
| 4 | 1 | 0 | [1] | 0 | เป้า 3 | ป้าย 0 เราชนะ | ชนะ ขยับไปเป้า 2 |
| 5 | 1 | [1] | 0 | 0 | เป้า 2 | ป้าย 0 เราชนะ | ชนะ ขยับไปเป้า 1 |
| 6 | [2] | 0 | 0 | 0 | เป้า 1 | ป้าย 1 เก่งกว่าเรา | แพ้ ถูกส่งไปเป้า 4 |
| 7 | 1 | 0 | 0 | [1] | เป้า 4 | ป้าย 0 เราชนะ | ชนะ ขยับไปเป้า 3 |
| 8 | 1 | 0 | [1] | 0 | เป้า 3 | ป้าย 0 เราชนะ | ชนะ ขยับไปเป้า 2 |
| 9 | 1 | [1] | 0 | 0 | เป้า 2 | ป้าย 0 เราชนะ | ชนะ ขยับไปเป้า 1 |
ช่องที่ใส่วงเล็บเหลี่ยมคือเป้าที่เรายืนอยู่ในรอบนั้น อ่านคอลัมน์ c ไล่ลงมาจะเห็นเลข 1
ไหลไปทางซ้ายรอบละหนึ่งช่อง พอสองตัวมาเจอกันที่เป้า 1 ช่องนั้นขึ้นเป็น 2 แล้วตัวที่อ่อนกว่า
ก็ถูกเหวี่ยงไปโผล่ที่เป้า 4 รอบถัดมา ทั้งหมดนี้มี 7 รอบที่เราเจอป้าย 0
แล้วเดินไปทางซ้ายได้ และ 2 รอบที่เจอป้าย 1 ซึ่งเป็นรอบที่โดนส่งกลับไปท้ายแถว
จบลงที่เป้า 1
ทำแบบเดียวกันกับทุกจุดที่แทรกได้ แล้วเทียบกัน
| เริ่มที่เป้า | 1 | 2 | 3 | 4 | ตอบ |
|---|---|---|---|---|---|
| ตัวอย่างที่ 1 จบที่เป้า | 4 | 2 | 2 | 4 | 3 |
| ตัวอย่างที่ 2 จบที่เป้า | 4 | 1 | 2 | 3 | 2 |
ตัวอย่างแรกจบที่เป้า 2 ได้จากสองจุดเริ่มต้น กติกาข้อรองจึงเข้ามาตัดสิน ให้เลือกจุดเริ่มที่มากกว่า
ได้คำตอบ 3 ส่วนตัวอย่างที่สองมีจุดเดียวที่จบถึงเป้า 1
แถวบนของตารางนี้เตือนอะไรบางอย่างด้วย ค่าที่ได้คือ 4 2 2 4 ซึ่งขึ้นแล้วลง
ไม่ได้เรียงจากน้อยไปมาก แปลว่าเดาไม่ได้ว่าเริ่มขวาขึ้นแล้วจะจบขวาขึ้นตาม จะข้ามการลองบางจุด
ด้วยการค้นหาแบบทวิภาคไม่ได้ตรง ๆ
โค้ดเดินตามสามชั้นที่เล่ามา สร้างกระดาน c ของจุดเริ่มต้นหนึ่งจุด เดินไม่เกิน 4N รอบ
โดยแต่ละรอบขยับตำแหน่งเราด้วยเลขตัวเดียว แล้วอัปเดตกระดานทั้งแถว
งานต่อหนึ่งจุดเริ่มต้นคือ 4N รอบ คูณ N เป้า ทั้งข้อจึงเป็น O(N³)
ซึ่งฟังดูน่ากลัว แต่ค่าคงที่ต่ำมากเพราะงานในวงในคือการบวกเลข 0 กับ 1 เท่านั้น
| N | เวลา (มิลลิวินาที) | เทียบกับลิมิต 2 วินาที |
|---|---|---|
| 200 | 34 | เหลือเวลา 1,966 มิลลิวินาที |
| 500 | 216 | เหลือเวลา 1,784 มิลลิวินาที |
| 800 | 828 | เหลือเวลา 1,172 มิลลิวินาที |
| 1,200 | 2,273 | เกินลิมิต |
N ≤ 200 ซึ่งจบใน 34 มิลลิวินาที เหลือเวลาเกือบเต็ม
โค้ดชุดนี้ยังไปได้ถึงราว N = 800 ก่อนจะชนลิมิต
โค้ดชุดนี้ได้กี่คะแนน
โค้ดรอบแรกกิน subtest 20 คะแนน (N ≤ 200) เต็มสบาย แต่ไปได้ถึงราว N = 800
ก่อนชนลิมิต ยังไม่ถึง subtest 60 คะแนนที่ N ≤ 5,000 เพราะ N³
ที่ห้าพันคือแสนสองหมื่นห้าพันล้าน
สิ่งที่แพงคือการเดินกระดานใหม่ทั้งแถว ทุกรอบ ทุกจุดเริ่มต้น หัวข้อถัดไปจะรื้อทั้งสามชั้นนี้ทิ้ง
แล้วเหลือ O(N log N) ซึ่งจบ N สองแสนใน 340 มิลลิวินาที
ที่มาของแนวคิดนี้
ผมไม่ได้พิสูจน์อะไรก่อนเลย ผมสั่งพิมพ์กระดานออกมาดูเฉย ๆ หลังผ่านไป 2N รอบ แล้วดูอีก N รอบว่า ใครเปลี่ยนเป้าบ้าง ใครไม่เปลี่ยนเลย พอเห็นว่าคนที่วนคือลำดับ 2 ถึง N+1 พอดีเป๊ะ ผมถึงกลับไปหาเหตุผลจากกติกา ไม่ใช่ทางกลับกัน
ก่อนเชื่อ ผมสุ่มมาตรวจกับ N ตั้งแต่ 2 ถึง 8 อย่างละ 150 ชุด ไม่มีชุดไหนที่ชุดคนวนไม่ใช่ 2 ถึง N+1 นี่คือจังหวะที่การสังเกตก่อน แล้วค่อยพิสูจน์ประหยัดเวลาไปมาก เพราะถ้าเริ่มจากพยายามพิสูจน์ ผมคงยังไม่รู้ด้วยซ้ำว่าจะพิสูจน์อะไร
ก่อนจะรื้อโค้ด ต้องรู้ก่อนว่าปลายทางหน้าตาเป็นยังไง ผมเดินกระดานจริงไปจนพ้นช่วงตั้งตัว แล้วดูว่าใครขยับใครไม่ขยับ คำตอบเหมือนกันทุกอินพุตที่ลอง และมันสวยกว่าที่คิด
พอเข้าสภาวะคงตัว ลำดับ 1 จอดที่เป้า 1 ถาวร ลำดับ 2 ถึง N+1 กลายเป็นขบวนที่วนซ้ายรอบละหนึ่งช่อง ส่วนที่เหลือคือลำดับ N+2 ถึง 2N ค้างนิ่งอยู่เป้าละคน
| ตัวอย่าง | คนที่วนไม่หยุด | คนที่ค้างอยู่กับที่ | ตรงกับ 2 ถึง N+1 ไหม |
|---|---|---|---|
| ที่ 1 (N = 4) | 2, 3, 4, 5 | 1, 6, 7, 8 | ตรง |
| ที่ 2 (N = 4) | 2, 3, 4, 5 | 1, 6, 7, 8 | ตรง |
สองแถวนี้มาจากการเดินกระดานจริงจนพ้นช่วงตั้งตัว แล้วดูว่าใครเปลี่ยนเป้าบ้างในอีก N รอบถัดมา
ผลตรวจอัตโนมัติในหน้านี้ตอนนี้คือ ตรงตามข้ออ้างทั้งสองชุด เหตุผลอ่านออกได้จากกติกา ที่เป้า 2 ถึง N ผู้ชนะเดินต่อ ผู้แพ้ค้าง ขบวนจึงกวาดคนแข็งขึ้นมาเรื่อย ๆ ส่วนที่เป้า 1 กติกากลับด้าน ผู้ชนะอยู่ต่อ มันจึงเป็นตะแกรงที่ดูดคนเก่งที่สุดออกจากขบวนไปจอดถาวร เหลือให้วนพอดี N คน คือลำดับ 2 ถึง N+1
ผลที่ใช้ได้ทันทีมีสองข้อ หนึ่ง ถ้าเราเป็นลำดับ 2 ถึง N+1 สุดท้ายเราจะขยับทุกรอบ ตำแหน่งจึงเป็นแค่เลขคณิต
สอง ระบบวนคาบ N หลังผ่านไปไม่เกิน N รอบ ดังนั้น T = 2N + ((R − 2N) mod N) ก็พอ
ไม่ต้องเผื่อถึง 3N เหมือนโค้ดรอบแรก
สองทางที่ผมลองก่อน แล้วทิ้ง
ทางแรกคือบีบกระดานเป็นบิต เพราะการเดินกระดานหนึ่งรอบเขียนเป็น
E' = S | (E >> 1) กับ S' = S & (E >> 1) ได้ตรง ๆ
เร็วขึ้น 64 เท่า ฟังดูน่าจะพอ แต่พอคูณเลขออกมาคือ 3,125 คำต่อรอบ คูณสี่แสนรอบ คูณอีกสิบแปดครั้ง
ยังเป็นหมื่นล้าน ตกไป
ทางที่สองคือตารางกระโดด เก็บว่าเดิน 1, 2, 4, 8 รอบไปอยู่ไหน ซึ่งเป็นท่ามาตรฐานของ
"เดินไกลมาก" แต่มันกินหน่วยความจำ N log R คือราวหกสิบเท่าของอาเรย์ ชนลิมิต 64 เมกะไบต์แน่ ๆ ตกไปอีก
พอสองทางที่คุ้นเคยตกทั้งคู่ คำถามจึงเปลี่ยนจาก "จะเดินกระดานให้เร็วขึ้นยังไง" เป็น "จริง ๆ แล้วเราต้องรู้อะไรกันแน่" คำตอบคือค่าเดียว คือช่องที่เรายืน และนั่นคือจุดที่ผมเลิกสนใจกระดานแล้วหันไปหาปริมาณอื่นที่คำนวณล่วงหน้าได้
ค่าที่เราต้องรู้ในแต่ละรอบมีค่าเดียว คือเป้าที่เรายืนอยู่มีคนแข็งกว่าเราไหม การเดินกระดานทั้งแถวเพื่อตอบคำถามช่องเดียวคือการทำงานเกิน ทางออกคือเปลี่ยนตัวแปรที่ติดตาม
ให้ n_i(t) คือจำนวนคนแข็งกว่าเราที่เป้า i ตอนรอบที่ t และ
F_i(t) คือจำนวนคนแข็งที่ออกจากเป้า i ไปแล้วทั้งหมด สองตัวนี้ผูกกันตรง ๆ
เพราะคนที่เป้า i คือคนตั้งต้น บวกคนที่ไหลเข้ามา ลบคนที่ไหลออกไป
// F_i(t) = จำนวนโทเคนที่ออกจากเป้า i ครบ t รอบ
// ความจริงข้อเดียวที่ต้องเชื่อ: เป้าที่มีโทเคนจะปล่อยออกไปหนึ่งตัวต่อรอบเสมอ
// n_i(t) = n_i(0) + F_{i+1}(t) - F_i(t) จำนวนโทเคนที่เป้า i ตอนนี้
// F_i(t+1) = F_i(t) + [ n_i(t) >= 1 ]
// เขียนใหม่เป็นรูป min-plus ได้ เพราะ n_i(t) >= 0 เสมอ
// F_i(t+1) = min( F_i(t) + 1 , F_{i+1}(t) + n_i(0) )
// คลี่ออกมาแล้วเหลือแค่การเลือกว่าจะเดิน "ทแยง" กี่ก้าว
// F_i(t) = min over j in [0, t] ของ (t - j) + ( n_i(0) + ... + n_{i+j-1}(0) )
บรรทัดสุดท้ายคือหัวใจ ปัญหาที่ดูเหมือนต้องจำลองทีละรอบ กลายเป็นสูตรปิดที่เลือกแค่ว่า
จะเดินทแยงกี่ก้าว และผลรวมของ n(0) ตลอดทาง ก็คือผลรวมของช่วงบนอาเรย์ตั้งต้น
// ให้ P[y] = ผลรวมของ n(0) ตั้งแต่เป้าลำดับที่ 1 ถึง y-1 (คลี่วงออกเป็นเส้นตรง)
// และ G[y] = P[y] - y สูตรข้างบนจะยุบเหลือ
// F_i(t) = t - P[i] + i + min( G[i], G[i+1], ..., G[i+t] )
// นั่นคือ "ค่าน้อยสุดของช่วง" บนอาเรย์เดียว
//
// และเพราะเราเดินไปทางซ้ายรอบละไม่เกินหนึ่งช่อง หน้าต่าง [i, i+t] จึงมีแต่ "ขยาย"
// ไม่เคยหด เก็บค่าน้อยสุดสะสมไว้ตัวเดียวก็พอ ไม่ต้องมีโครงสร้างข้อมูลอะไรเลย
ll nwL = pos, nwR = pos + (t + 1);
while (winL > nwL) { winL--; mn = min(mn, G[winL]); }
while (winR < nwR) { winR++; mn = min(mn, G[winR]); } จุดที่ทำให้มันถูกและเร็วพร้อมกันคือหน้าต่างมีแต่ขยาย เพราะทุกรอบเราขยับซ้ายได้ไม่เกินหนึ่งช่อง ขอบซ้ายของหน้าต่างจึงเลื่อนออกทีละหนึ่ง ขอบขวาก็เลื่อนออกทีละหนึ่ง ไม่มีจังหวะไหนที่ต้องเอาของออก เก็บค่าน้อยสุดสะสมไว้ตัวเดียวก็พอ ไม่ต้องมีเซกเมนต์ทรีหรือตารางกระโดดเลย
ที่มา และสามรอบที่ยังผิด
ผมไม่ได้มองออกตั้งแต่แรกว่าเป้า 1 จะพัง ผมเขียนสูตรแล้วเอาไปเทียบกับการเดินกระดานจริง ทุกช่องทุกรอบ แล้วดูว่าผิดกี่จุด รอบแรกผิดพันกว่าจุด และทุกจุดที่ผิดมีค่าติดลบ นั่นคือเบาะแสว่ามีคนออกจากเป้า 1 ไปแล้วทั้งที่ยังไม่มีใครไปถึง
จากตรงนั้นผมเดาทางแก้ไปสองรอบ ทั้งสองรอบดีขึ้นแต่ยังผิด และรอบที่สามผมเลิกเดา กลับไปคัดเฉพาะเคสที่เป้า 1 มีคนแข็งอยู่ตั้งแต่ต้น มาทดสอบอย่างเดียว ผลคือตรงหมด ตรงนั้นเองที่รู้ว่าปัญหาไม่ได้อยู่ที่สูตร แต่อยู่ที่เงื่อนไขที่สูตรต้องการ เหลือแค่ทำให้เงื่อนไขนั้นเป็นจริง
| ทำอะไร | จุดที่ผิด | จากทั้งหมด |
|---|---|---|
| ใช้สูตร min-plus ตรง ๆ ทั้งวง | 1,306 | 11,156 |
| ตัดค่าติดลบทิ้งด้วย max(0, ...) | 774 | 14,394 |
| ใช้ค่ามากสุดสะสมตามเวลาแทน | 278 | 14,394 |
| ทดสอบเฉพาะกรณีที่เป้า 1 มีคนแข็งอยู่แล้ว | 0 | 18,264 |
| แยกสองเฟส (เฉลยจริง) | 0 | 36,510 |
สูตร min-plus ข้างบนตั้งอยู่บนข้อเท็จจริงว่า "เป้าที่มีโทเคนจะปล่อยออกหนึ่งตัวต่อรอบ" ซึ่งไม่จริงที่เป้า 1 เพราะที่นั่นผู้ชนะอยู่ต่อ เป้า 1 จึงปล่อยคนแข็งออกไปก็ต่อเมื่อมีคนแข็งสองคน
ตรงนี้แก้ได้ด้วยการลดค่าของเป้า 1 ลงหนึ่ง คือใช้ c₁ = n₁(0) − 1 แทน แล้วสูตรก็ตรงเป๊ะ
ผมสุ่มกระดานเล็ก ๆ มาเทียบทุกช่องทุกรอบรวมหนึ่งหมื่นแปดพันจุด ไม่มีจุดไหนต่างกันเลย
แต่มีเงื่อนไขข้อเดียว คือเป้า 1 ต้องมีคนแข็งอยู่แล้ว
ระวัง
ถ้าเป้า 1 ยังว่างจากคนแข็ง สูตรจะให้ค่าติดลบ ซึ่งแปลว่ามีคนออกจากเป้า 1 ไปแล้วทั้งที่ยังไม่มีใครไปถึงเลย และนี่ไม่ใช่เคสหายาก มันคือเคสที่เราไปนั่งรอที่เป้า 1 ก่อนคนเก่งจะมาถึง ซึ่งเป็นตัวถ่วงหลักของทั้งข้อ
ทางแก้ตรงไปตรงมา แบ่งเวลาเป็นสองเฟส เฟสแรกคือช่วงที่ยังไม่มีคนแข็งไปถึงเป้า 1
ช่วงนั้นไม่มีใครวนกลับไปท้ายแถวเลย ระบบจึงเป็นเส้นตรงล้วน สูตรเดิมใช้ได้โดยให้เส้นทางไปจบที่เป้า 1
เฟสแรกยาวเท่าไรก็รู้ทันที คือ i₀ − 1 เมื่อ i₀ คือเป้าซ้ายสุดที่มีคนแข็งอยู่
เพราะคนแข็งตัวนั้นไม่มีอะไรขวางข้างหน้าเลย
พอจบเฟสแรก เป้า 1 มีคนแข็งแน่นอน ก็คำนวณสภาพกระดาน ณ วินาทีนั้นในเวลาเชิงเส้น แล้วตั้งต้นเฟสสองด้วยสูตรวง ซึ่งคราวนี้ถูกต้องตลอด
ที่มา และจุดที่ผมมองข้ามหลักฐานของตัวเอง
ข้ออ้างว่า u(k) ไม่ลด ผมได้มาจากสัญชาตญาณก่อน คือคนที่ออกตัวทีหลังไม่น่าจะแซงคนที่ออกตัวก่อนได้
แล้วผมเขียนสคริปต์สุ่มสี่ร้อยชุดมาตรวจ ซึ่งตรวจสองอย่างพร้อมกัน
คือความไม่ลด กับความกว้างของช่วง เพราะตอนนั้นผมเชื่อว่าช่วงกว้างไม่เกิน N
ผลคือความไม่ลดผ่านหมด แต่มีสี่ชุดที่ช่วงกว้างเท่ากับ N พอดี ซึ่งสคริปต์ปริ๊นต์ออกมาว่า BAD ผมดูแล้วคิดว่าไม่เป็นไร เพราะคำตอบยังถูก แล้วก็เดินหน้าต่อ
มันกลับมาเล่นงานตอนเขียนโค้ดจริง ผมสมมติว่าข้ามเส้นแบ่งได้ครั้งเดียว
แล้วตัวสุ่มเทียบจับได้ที่ N = 5 ชุดหนึ่งซึ่งมีสองบล็อกให้เป้าสุดท้ายเท่ากัน
โค้ดเลือกบล็อกแรกเพราะเจอก่อน ทั้งที่โจทย์ขอเป้าเริ่มต้นที่มากที่สุด ตอบ 3 แทนที่จะเป็น 5
บทเรียนไม่ได้อยู่ที่สูตร แต่อยู่ที่ตอนที่ตัวตรวจสอบบอกว่ามีอะไรผิดปกติ อย่าเพิ่งตัดสินว่าไม่สำคัญ หลักฐานมันอยู่ตรงหน้าผมตั้งแต่วันแรก ผมแค่อ่านผ่าน
เหลืออีกชั้นเดียว คือยังต้องลอง k ครบทั้ง N ค่า
ให้ u(k) = k − จำนวนก้าวซ้ายทั้งหมด ซึ่งเป็นตำแหน่งแบบ "คลี่วงออกเป็นเส้นตรง"
เป้าสุดท้ายคือ u(k) หารเอาเศษด้วย N
u(k) เป็นฟังก์ชันไม่ลดของ k ถ้าเริ่มขวากว่า ก็ไม่มีทางไปจบซ้ายกว่าเมื่อคลี่วงออกแล้ว
อ่านเป็นภาษาคนคือ คนที่ออกตัวหลังแซงคนที่ออกตัวก่อนไม่ได้ ซึ่งเป็นเรื่องปกติของระบบที่ทุกคน
เดินทางเดียวและติดกันเองได้ ผมสุ่มมาตรวจสี่ร้อยชุด ไม่มีชุดไหนที่ u ลดลง
แต่ช่วงของ u กว้างเกิน N ได้ จึงข้ามเส้นแบ่งของการหารเอาเศษได้หลายครั้ง
ตรงนี้ผมพลาดรอบแรก เขียนไปว่าข้ามได้ครั้งเดียว แล้วตัวสุ่มเทียบจับได้ที่เคส N = 5
ซึ่งมีสองบล็อกที่ให้เป้าสุดท้ายเท่ากัน แต่โค้ดเลือกบล็อกแรกเพราะเจอก่อน ทั้งที่โจทย์ขอเป้าเริ่มต้นที่มากที่สุด
// u(k) = k - (จำนวนก้าวซ้ายทั้งหมด) เป็นฟังก์ชันไม่ลดของ k
// เป้าสุดท้ายคือ f(k) = ((u(k) - 1) mod N) + 1
// ในบล็อกเดียวกัน (u ที่หารด้วย N แล้วได้ผลหารเท่ากัน) f เพิ่มตาม u
// ผู้เข้าชิงจึงมีแค่ค่า u ที่เล็กที่สุดของแต่ละบล็อก ซึ่งมีไม่กี่ตัว
vector<ll> cand;
cand.push_back(u1);
for (ll v = (ll)N * (fdiv(u1 - 1, N) + 1) + 1; v <= uN; v += N)
cand.push_back(U(firstGE(v))); // ค้นหาแบบไบนารีได้เพราะ u ไม่ลด
ในหนึ่งบล็อก f เพิ่มตาม u ผู้เข้าชิงจึงมีแค่ค่า u ที่เล็กที่สุดของแต่ละบล็อก
และจำนวนบล็อกไม่เกินห้า เพราะ u กว้างไม่เกิน N + T รวมแล้วเราประเมิน
u(k) แค่ราว log N ครั้ง ครั้งละ O(N)
| ลักษณะอินพุต | เวลา (มิลลิวินาที) | เทียบกับลิมิต 2 วินาที |
|---|---|---|
| อินพุตสุ่ม | 340 | เหลือเวลา 1,660 มิลลิวินาที |
| เรียงจากเก่งไปอ่อน (เราคือลำดับ 1) | 143 | เหลือเวลา 1,857 มิลลิวินาที |
| เรียงจากอ่อนไปเก่ง | 338 | เหลือเวลา 1,662 มิลลิวินาที |
| เราอ่อนที่สุด คนแข็งกองท้ายแถว | 334 | เหลือเวลา 1,666 มิลลิวินาที |
| เราลำดับ 2 คนเก่งสุดอยู่ท้ายแถว | 336 | เหลือเวลา 1,664 มิลลิวินาที |
การตรวจก่อนเชื่อทำสี่ชั้น ชั้นแรกเทียบสูตรการไหลกับการเดินกระดานจริงทุกช่องทุกรอบ สามหมื่นหกพันจุด
ชั้นที่สองเทียบคำตอบทั้งข้อกับตัวจำลองคนจริงที่ N ไม่เกิน 8 จำนวนสี่พันชุด
ชั้นที่สามเทียบที่ N ระหว่าง 9 ถึง 40 อีกสี่ร้อยชุด บวกชุดที่จงใจจัดแถวให้โหด
(เรียงขึ้น เรียงลง คนแข็งกองท้าย เราเป็นลำดับสุดขั้ว) อีกสี่ร้อยชุด
ชั้นที่สี่เทียบที่ N ระหว่าง 60 ถึง 220 อีกหกสิบชุด ตรงกันทั้งหมด
สองบักที่ตัวตรวจสอบจับได้ระหว่างทาง คือสูตรที่เป้า 1 ให้ค่าติดลบตอนเป้า 1 ยังว่าง กับการเลือกบล็อกผิดตอนเสมอกัน ทั้งคู่เป็นบักที่ตอบตัวอย่างในโจทย์ถูกทั้งสองชุด
ข้อนี้ยาวเพราะมันซ้อนสามชั้น แต่ละชั้นเป็นท่าที่ยกไปใช้กับข้ออื่นได้เอง และคุ้มที่จะจำแยกจากตัวโจทย์
และท่าที่ควรติดมือที่สุดคือท่าที่ตอนที่ 7 สอน คือตอนที่ตัวตรวจสอบบอกว่ามีอะไรผิดปกติ อย่าเพิ่งตัดสินว่าไม่สำคัญ ผมมีหลักฐานว่าช่วงกว้างเท่ากับ N อยู่ตรงหน้าตั้งแต่วันแรก แล้วอ่านผ่านไป มันกลับมาเล่นงานตอนเขียนโค้ดจริง เจอสัญญาณแปลก ๆ จากตัวตรวจครั้งหน้า ให้หยุดอ่านมันให้จบก่อนเดินหน้า
หมายเลขทักษะของคนอื่นไม่มีความหมายกับเราเลยนอกจากมันมากหรือน้อยกว่าของเรา สนามจึงยุบเหลือเลข 0 ถึง 2 ต่อหนึ่งเป้า จากนั้นเลิกเดินกระดานแล้วหันไปนับการไหลแทน ซึ่งมีสูตรปิดที่ยุบเหลือค่าน้อยสุดของช่วง และเพราะจบขวากว่าไม่ได้ถ้าออกตัวซ้ายกว่า จุดเริ่มต้นทั้ง N จุดจึงเหลือให้ลองแค่ราว log N จุด
ในหน้านี้