programming.in.th · ข้อ 2032

ยิงธนู: เลิกจำลองกระดาน แล้วเปลี่ยนสนามทั้งสนามให้เป็นค่าน้อยสุดของช่วง

ข้อที่หนักที่สุดในคลังนี้ และเป็นข้อที่คุ้มที่สุดถ้าอยากเห็นการรื้อเฉลยจากลูกบาศก์ลงมาเหลือ N log N ทีละชั้น เริ่มจากตัดข้อมูลที่ไม่มีผลทิ้ง แล้วเปลี่ยนการจำลองเป็นสูตรปิด และปิดท้ายด้วยเหตุผลว่าทำไมไม่ต้องลองจุดเริ่มต้นครบทุกจุด

★★★★★ simulationinvariantbinary searchad hoc อ่าน 17 นาที 6 กันยายน 2026

โจทย์ · แทรกแถวเข้าไปในทัวร์นาเมนต์ที่หมุนไม่หยุด

สนามยิงธนูมีเป้าเรียงเป็นเส้นตรง N เป้า หมายเลข 1 ถึง N จากซ้ายไปขวา มีนักยิง 2N คน ยืนประจำเป้าละสองคน แล้วแข่งกันเป็นรอบ ๆ ทุกรอบทุกเป้าจะได้ผู้ชนะหนึ่งคนและผู้แพ้หนึ่งคน จากนั้นทุกคนย้ายที่พร้อมกันตามกติกาสามข้อ

นักยิงทุกคนมีหมายเลขทักษะ 1 ถึง 2N ไม่ซ้ำกัน โดยเลขน้อยกว่าคือเก่งกว่า และคนเก่งกว่าชนะเสมอ ไม่มีเซอร์ไพรส์ ไม่มีดวง การแข่งจะดำเนินไป R รอบ

คุณมาถึงสนามเป็นคนสุดท้าย อีก 2N - 1 คนยืนเรียงแถวรอไว้เรียบร้อยแล้ว สิ่งที่คุณทำได้อย่างเดียวคือ แทรกตัวเองเข้าไปในแถว ณ จุดใดจุดหนึ่ง แล้วจากนั้นสองคนซ้ายสุดของแถวไปเข้าเป้า 1 สองคนถัดไปเข้าเป้า 2 ไล่ไปจนครบ เป้าหมายของคุณคือจบการแข่งขันที่เป้าหมายเลขน้อยที่สุดเท่าที่จะเป็นไปได้ และถ้ามีหลายทางที่ทำได้เท่ากัน ให้เลือกทางที่เริ่มต้นที่เป้าหมายเลขมากที่สุด

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

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

EXAMPLE
InputOutput
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 จุด · ดีที่สุดที่เจอคือเป้า ยังไม่มี

เป้าหมายคือจบให้ได้เป้าที่เลขน้อยที่สุด ถ้ามีหลายจุดที่จบเท่ากัน โจทย์ขอจุดยืนที่เลขมากที่สุด

ลองไล่ให้ครบทุกจุดแล้วดูลำดับของผลลัพธ์ ถ้ามันขึ้นแล้วลง แปลว่าท่าที่อาศัยความเรียงอย่างการค้นแบบไบนารีใช้ไม่ได้

เฉลย ตอนที่ 1 · โลกทั้งใบเหลือคนสองประเภท

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

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

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

คำตอบของใบ้สั้นมาก เราแพ้ทุกคนที่หมายเลขน้อยกว่าเรา และชนะทุกคนที่หมายเลขมากกว่าเรา ตลอดกาล ทุกรอบ ทุกเป้า ดังนั้นสำหรับเรา คนอื่นบนสนามมีแค่สองประเภท ติดป้าย 1 ให้คนที่เราไม่ชนะ และ 0 ให้คนที่เราชนะ

คำถามคือแล้วป้ายพวกนี้ขยับยังไงเมื่อเวลาผ่านไป ซึ่งตอบได้จากกติกาโดยไม่ต้องรู้หมายเลขจริงของใครเลย เพราะที่เป้าหนึ่ง ๆ ผู้ชนะคือคนที่เก่งกว่า และคนที่เก่งกว่าย่อมมีป้ายไม่ต่ำกว่าคู่ของตัวเอง ไล่ทั้งสามกรณีแล้วได้กฎเดียว

  • ป้าย 1 เจอป้าย 1 ผู้ชนะเป็น 1 ผู้แพ้เป็น 1
  • ป้าย 1 เจอป้าย 0 ผู้ชนะเป็น 1 ผู้แพ้เป็น 0
  • ป้าย 0 เจอป้าย 0 ผู้ชนะเป็น 0 ผู้แพ้เป็น 0

สรุปเป็นประโยคเดียวว่า ทุกเป้าส่งป้ายที่สูงกว่าออกไป และเก็บป้ายที่ต่ำกว่าไว้ เท่านั้นเอง พอเป็นแบบนี้ เราไม่ต้องเก็บว่าใครยืนตรงไหนอีกแล้ว เก็บแค่ตัวเลขเดียวต่อหนึ่งเป้าคือ เป้านั้นมีคนป้าย 1 อยู่กี่คน ซึ่งเป็น 0, 1 หรือ 2 เรียกมันว่า c[i]

เป้าที่มี 1 1 ออกไปทางซ้าย 1 อยู่ต่อ 1 เป้าที่มี 1 0 ออกไปทางซ้าย 1 อยู่ต่อ 0 เป้าที่มี 0 0 ออกไปทางซ้าย 0 อยู่ต่อ 0
ทุกเป้าส่งป้ายที่สูงกว่าออกไปทางซ้าย และเก็บป้ายที่ต่ำกว่าไว้ สนามทั้งสนามจึงเล่าได้ด้วยตัวเลข 0 ถึง 2 ต่อหนึ่งเป้า (วาดประกอบโดยผู้เขียน)

แปลงกฎ "ส่งตัวสูงออก เก็บตัวต่ำไว้" ให้เป็นสมการของ c ได้ตรง ๆ โดยจำไว้ว่าเป้า 1 กับเป้า N ผิดฝาผิดตัวกว่าเพื่อน เพราะที่เป้า 1 ผู้ชนะอยู่ต่อและผู้แพ้ถูกเหวี่ยงไปเป้า N

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

  • บรรทัดกลาง ใช้กับเป้า 2 ถึง N - 1 ซึ่งเป็นกรณีปกติ เป้านี้เก็บผู้แพ้ของตัวเองไว้ ซึ่งเป็นป้าย 1 ก็ต่อเมื่อเดิมมีป้าย 1 อยู่สองคน (ถ้ามีคนเดียว คนนั้นชนะแล้วเดินออกไป) แล้วรับผู้ชนะ จากเป้าทางขวามาอีกหนึ่งคน ซึ่งเป็นป้าย 1 ก็ต่อเมื่อเป้านั้นมีป้าย 1 อย่างน้อยหนึ่งคน
  • บรรทัดบน คือเป้า 1 ต่างจากปกติเพราะที่นี่ผู้ชนะอยู่ต่อ ของที่เก็บไว้จึงเป็นผู้ชนะ ซึ่งเป็นป้าย 1 ก็ต่อเมื่อมีป้าย 1 อย่างน้อยหนึ่งคน ไม่ต้องถึงสอง นี่คือเหตุผลที่เป้า 1 กลายเป็นตะแกรงที่ดูดคนเก่งไปจอด และเป็นบรรทัดเดียวที่ทำให้ สูตรการไหลในตอนที่ 6 ต้องแบ่งสองเฟส
  • บรรทัดล่าง คือเป้า N ซึ่งไม่มีเป้าทางขวาให้รับของ มันรับ ผู้แพ้ของเป้า 1 ที่ถูกเหวี่ยงข้ามมาแทน จึงนับเมื่อเป้า 1 มีป้าย 1 ครบสองคน คือตอนที่ผู้แพ้ของเป้า 1 เป็นป้าย 1 ด้วย

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

เฉลย ตอนที่ 2 · เราคือคนอ่อนที่สุดในกลุ่มของตัวเอง

ตอนที่แล้วเราติดป้ายให้คนอื่น ทีนี้ตัวเราเองล่ะ เราชนะทุกคนที่ป้าย 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 ไกลสุด แล้วต้องไต่กลับมาใหม่

เฉลย ตอนที่ 3 · ทำไมโจทย์ถึงรับประกันว่า R ≥ 2N

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

พอทุกคนเข้าที่แล้ว หน้าตาของสนามทั้งสนามก็ซ้ำเดิมทุก N รอบ ช่วงตั้งตัวก่อนเข้าวงรอบกินเวลาไม่เกิน 2N รอบ ซึ่งตรงกับเงื่อนไข R ≥ 2N ที่โจทย์แถมมาให้พอดี โจทย์กำลังบอกเราว่า "รับประกันว่าเลยช่วงตั้งตัวไปแล้ว" การเดินจริงจึงไม่ต้องครบ R รอบ แค่เดินให้พ้นช่วงตั้งตัว แล้วเก็บเศษของคาบไว้

ผมเผื่อไว้ที่ 3N แทนที่จะเป็น 2N ตามที่โจทย์รับประกัน เพราะการเผื่ออีกหนึ่งคาบ ราคาถูกมาก แต่กันความผิดพลาดตรงขอบพอดีได้หมด ผลคือเดินจริงไม่เกิน 4N รอบ ไม่ว่า R จะใหญ่แค่ไหน

เดินกระดานให้ดูหนึ่งชุด

เอาตัวอย่างที่สองมาเดินจริง หมายเลขทักษะของเราคือ 2 จากทั้งหมด 8 คน จึงมีป้าย 1 อยู่แค่ 2 คน (นับตัวเราเองด้วย) และเราเลือกเริ่มที่เป้า 2

กดถัดไปเพื่อเดินกระดานทีละรอบ

ตัวอย่างที่ 2 · เริ่มที่เป้า 2
รอบ c[1]c[2]c[3]c[4] เรายืนที่ คู่แข่ง ผล
1 1[1]00 เป้า 2 ป้าย 0 เราชนะ ชนะ ขยับไปเป้า 1
2 [2]000 เป้า 1 ป้าย 1 เก่งกว่าเรา แพ้ ถูกส่งไปเป้า 4
3 100[1] เป้า 4 ป้าย 0 เราชนะ ชนะ ขยับไปเป้า 3
4 10[1]0 เป้า 3 ป้าย 0 เราชนะ ชนะ ขยับไปเป้า 2
5 1[1]00 เป้า 2 ป้าย 0 เราชนะ ชนะ ขยับไปเป้า 1
6 [2]000 เป้า 1 ป้าย 1 เก่งกว่าเรา แพ้ ถูกส่งไปเป้า 4
7 100[1] เป้า 4 ป้าย 0 เราชนะ ชนะ ขยับไปเป้า 3
8 10[1]0 เป้า 3 ป้าย 0 เราชนะ ชนะ ขยับไปเป้า 2
9 1[1]00 เป้า 2 ป้าย 0 เราชนะ ชนะ ขยับไปเป้า 1

ช่องที่ใส่วงเล็บเหลี่ยมคือเป้าที่เรายืนอยู่ในรอบนั้น อ่านคอลัมน์ c ไล่ลงมาจะเห็นเลข 1 ไหลไปทางซ้ายรอบละหนึ่งช่อง พอสองตัวมาเจอกันที่เป้า 1 ช่องนั้นขึ้นเป็น 2 แล้วตัวที่อ่อนกว่า ก็ถูกเหวี่ยงไปโผล่ที่เป้า 4 รอบถัดมา ทั้งหมดนี้มี 7 รอบที่เราเจอป้าย 0 แล้วเดินไปทางซ้ายได้ และ 2 รอบที่เจอป้าย 1 ซึ่งเป็นรอบที่โดนส่งกลับไปท้ายแถว จบลงที่เป้า 1

ทำแบบเดียวกันกับทุกจุดที่แทรกได้ แล้วเทียบกัน

เป้าที่ไปจบ เทียบทุกจุดที่แทรกได้
เริ่มที่เป้า 1234 ตอบ
ตัวอย่างที่ 1 จบที่เป้า 4224 3
ตัวอย่างที่ 2 จบที่เป้า 4123 2
ตัวอย่างแรกจบที่เป้า 2 ได้จากสองจุดเริ่มต้น กติกาข้อรองจึงเข้ามาตัดสิน ให้เลือกจุดเริ่มที่มากกว่า ได้คำตอบ 3 ส่วนตัวอย่างที่สองมีจุดเดียวที่จบถึงเป้า 1

แถวบนของตารางนี้เตือนอะไรบางอย่างด้วย ค่าที่ได้คือ 4 2 2 4 ซึ่งขึ้นแล้วลง ไม่ได้เรียงจากน้อยไปมาก แปลว่าเดาไม่ได้ว่าเริ่มขวาขึ้นแล้วจะจบขวาขึ้นตาม จะข้ามการลองบางจุด ด้วยการค้นหาแบบทวิภาคไม่ได้ตรง ๆ

โค้ด C++ รอบแรก · ท่าตรงไปตรงมาที่ได้ 20 คะแนน

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

งานต่อหนึ่งจุดเริ่มต้นคือ 4N รอบ คูณ N เป้า ทั้งข้อจึงเป็น O(N³) ซึ่งฟังดูน่ากลัว แต่ค่าคงที่ต่ำมากเพราะงานในวงในคือการบวกเลข 0 กับ 1 เท่านั้น

เวลาที่วัดได้บนเครื่องผม · R เต็มขอบเขต
Nเวลา (มิลลิวินาที)เทียบกับลิมิต 2 วินาที
200 34 เหลือเวลา 1,966 มิลลิวินาที
500 216 เหลือเวลา 1,784 มิลลิวินาที
800 828 เหลือเวลา 1,172 มิลลิวินาที
1,200 2,273 เกินลิมิต
เทสของ subtest 20 คะแนนคือ N ≤ 200 ซึ่งจบใน 34 มิลลิวินาที เหลือเวลาเกือบเต็ม โค้ดชุดนี้ยังไปได้ถึงราว N = 800 ก่อนจะชนลิมิต

โค้ดชุดนี้ได้กี่คะแนน

โค้ดรอบแรกกิน subtest 20 คะแนน (N ≤ 200) เต็มสบาย แต่ไปได้ถึงราว N = 800 ก่อนชนลิมิต ยังไม่ถึง subtest 60 คะแนนที่ N ≤ 5,000 เพราะ N³ ที่ห้าพันคือแสนสองหมื่นห้าพันล้าน

สิ่งที่แพงคือการเดินกระดานใหม่ทั้งแถว ทุกรอบ ทุกจุดเริ่มต้น หัวข้อถัดไปจะรื้อทั้งสามชั้นนี้ทิ้ง แล้วเหลือ O(N log N) ซึ่งจบ N สองแสนใน 340 มิลลิวินาที

ดูโค้ดเต็ม
archery.cpp
#include <bits/stdc++.h>
using namespace std;

int main() {
    long long N, R;
    scanf("%lld %lld", &N, &R);
    vector<int> s(2 * N);
    for (int i = 0; i < 2 * N; i++) scanf("%d", &s[i]);
    int me = s[0];
    vector<int> o(s.begin() + 1, s.end());     // คนอื่น 2N-1 คน เรียงจากซ้ายไปขวา

    // หลัง 2N รอบระบบวนคาบ N ส่วนที่เกินจึงตัดทิ้งได้ เดินจริงไม่เกิน 4N รอบ
    long long T = (R <= 3 * N) ? R : 3 * N + ((R - 3 * N) % N);

    vector<int> c(N + 2), nc(N + 2);
    long long bestFinal = LLONG_MAX, bestStart = -1;

    for (long long k = 1; k <= N; k++) {
        // แทรกตัวเองที่เป้า k แล้วคนอื่นจับคู่กันตามลำดับเดิม
        for (long long i = 1; i <= N; i++) {
            int a, b;
            if (i < k)       { a = o[2 * i - 2]; b = o[2 * i - 1]; }
            else if (i == k) { a = o[2 * i - 2]; b = me; }
            else             { a = o[2 * i - 3]; b = o[2 * i - 2]; }
            c[i] = (a <= me) + (b <= me);      // นับคนที่เราไม่ชนะ (รวมตัวเราเอง)
        }

        long long pos = k;
        for (long long q = 0; q < T; q++) {
            // เราคือคนที่อ่อนที่สุดในกลุ่ม "ไม่แพ้ให้เรา" คู่แข่งจึงแข็งกว่าเรา
            // ก็ต่อเมื่อช่องนี้มีคนกลุ่มนั้นอยู่สองคน คือเรากับอีกคนหนึ่ง
            bool oppStrong = (c[pos] == 2);
            if (pos == 1) pos = oppStrong ? N : 1;   // แพ้ที่เป้า 1 คือถูกส่งไปท้ายแถว
            else if (!oppStrong) pos--;              // ชนะที่เป้าอื่นคือขยับไปทางซ้าย

            for (long long i = 1; i <= N; i++) {
                if (i == 1)      nc[1] = (c[1] >= 1) + (N >= 2 ? (c[2] >= 1) : 0);
                else if (i < N)  nc[i] = (c[i] == 2) + (c[i + 1] >= 1);
                else             nc[N] = (c[N] == 2) + (c[1] == 2);
            }
            if (N == 1) nc[1] = c[1];
            swap(c, nc);
        }

        if (pos < bestFinal) { bestFinal = pos; bestStart = k; }
        else if (pos == bestFinal) bestStart = k;    // เสมอกันเลือกเป้าเริ่มต้นที่มากที่สุด
    }

    printf("%lld\n", bestStart);
    return 0;
}
ของแถม: ตัวตรวจสอบที่ผมใช้ก่อนส่ง

ตัวย่อในหน้านี้กล้าทิ้งหมายเลขทักษะของคนอื่นทั้งหมด ซึ่งเป็นการอ้างที่ต้องพิสูจน์ด้วยของจริง ผมจึงเขียนตัวจำลองคนจริงทุกคนครบทุกรอบข้างล่างนี้ แล้วสุ่มเทียบ 1,200 เทส แบ่งเป็นสองชุด ชุดแรก N ≤ 7 กับ R ใกล้ ๆ 2N เพื่อกดดันช่วงตั้งตัว ชุดที่สอง N ≤ 9 กับ R ที่ใหญ่กว่า 2N ไปหลายร้อยรอบ เพื่อกดดันการตัดคาบทิ้ง ตรงกันทั้งหมด

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

brute.cpp
// ตัวตรวจสอบแบบซื่อ ๆ จำลองคนจริงทุกคนครบ R รอบ ใช้ได้แค่ N เล็ก ๆ กับ R ไม่กี่ร้อย
// เอาไว้สุ่มเทียบกับโค้ดจริงก่อนส่ง ไม่ใช่โค้ดที่ส่งเข้าระบบตัดสิน
#include <bits/stdc++.h>
using namespace std;

int N; long long R;
int me;
vector<int> o;

int simulate(int k, long long rounds) {
    vector<int> line;
    for (int i = 0; i < 2 * k - 2; i++) line.push_back(o[i]);
    line.push_back(me);
    for (int i = 2 * k - 2; i < (int)o.size(); i++) line.push_back(o[i]);

    vector<array<int, 2>> t(N + 1);
    for (int i = 1; i <= N; i++) { t[i][0] = line[2 * i - 2]; t[i][1] = line[2 * i - 1]; }

    for (long long q = 0; q < rounds; q++) {
        vector<int> win(N + 1), los(N + 1);
        for (int i = 1; i <= N; i++) {
            win[i] = min(t[i][0], t[i][1]);       // เลขลำดับน้อยกว่าคือเก่งกว่า
            los[i] = max(t[i][0], t[i][1]);
        }
        vector<array<int, 2>> nt(N + 1);
        for (int i = 1; i <= N; i++) {
            if (i == 1)      nt[i] = {win[1], N >= 2 ? win[2] : los[1]};
            else if (i < N)  nt[i] = {los[i], win[i + 1]};
            else             nt[i] = {los[N], los[1]};
        }
        if (N == 1) nt[1] = t[1];
        t = nt;
    }
    for (int i = 1; i <= N; i++) if (t[i][0] == me || t[i][1] == me) return i;
    return -1;
}

int main() {
    scanf("%d %lld", &N, &R);
    scanf("%d", &me);
    o.resize(2 * N - 1);
    for (int i = 0; i < 2 * N - 1; i++) scanf("%d", &o[i]);

    int best = INT_MAX, bestK = -1;
    for (int k = 1; k <= N; k++) {
        int f = simulate(k, R);
        if (f < best) { best = f; bestK = k; }
        else if (f == best) bestK = k;
    }
    printf("%d\n", bestK);
}

เฉลย ตอนที่ 4 · สภาวะคงตัวมีรูปที่ตายตัวเสมอ

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

ผมไม่ได้พิสูจน์อะไรก่อนเลย ผมสั่งพิมพ์กระดานออกมาดูเฉย ๆ หลังผ่านไป 2N รอบ แล้วดูอีก N รอบว่า ใครเปลี่ยนเป้าบ้าง ใครไม่เปลี่ยนเลย พอเห็นว่าคนที่วนคือลำดับ 2 ถึง N+1 พอดีเป๊ะ ผมถึงกลับไปหาเหตุผลจากกติกา ไม่ใช่ทางกลับกัน

ก่อนเชื่อ ผมสุ่มมาตรวจกับ N ตั้งแต่ 2 ถึง 8 อย่างละ 150 ชุด ไม่มีชุดไหนที่ชุดคนวนไม่ใช่ 2 ถึง N+1 นี่คือจังหวะที่การสังเกตก่อน แล้วค่อยพิสูจน์ประหยัดเวลาไปมาก เพราะถ้าเริ่มจากพยายามพิสูจน์ ผมคงยังไม่รู้ด้วยซ้ำว่าจะพิสูจน์อะไร

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

พอเข้าสภาวะคงตัว ลำดับ 1 จอดที่เป้า 1 ถาวร ลำดับ 2 ถึง N+1 กลายเป็นขบวนที่วนซ้ายรอบละหนึ่งช่อง ส่วนที่เหลือคือลำดับ N+2 ถึง 2N ค้างนิ่งอยู่เป้าละคน

เขียว จอดถาวร ทอง ขบวนที่วนไม่หยุด เทา ค้างนิ่ง ผู้ชนะไหลไปทางซ้าย ยกเว้นที่เป้า 1 ที่ผู้ชนะอยู่ต่อ เป้า 1 ลำดับ 1 ลำดับ 3 เป้า 2 ลำดับ 4 ลำดับ 6 เป้า 3 ลำดับ 5 ลำดับ 8 เป้า 4 ลำดับ 2 ลำดับ 7 เส้นแดง ผู้แพ้ที่เป้า 1 ถูกเหวี่ยงไปท้ายแถว ขบวนจึงวนครบรอบ
สภาพกระดานจริงของตัวอย่างที่ 1 หลังเดินไปจนพ้นช่วงตั้งตัว วาดจากการเดินกระดานจริง 8 รอบ ไม่ได้จัดวางด้วยมือ ช่องเขียวที่เป้า 1 คือลำดับ 1 ซึ่งจอดถาวรเพราะที่เป้า 1 ผู้ชนะอยู่ต่อ ช่องทองคือขบวนที่วนไปทางซ้ายรอบละหนึ่งเป้า มี 4 คนพอดี คือลำดับ 2, 3, 4, 5 ส่วนช่องเทาคือคนที่ค้างนิ่งเป้าละคน คือลำดับ 6, 7, 8 ผลตรวจอัตโนมัติว่าคนที่จอดที่เป้า 1 เป็นลำดับ 1 จริงคือ ตรง (วาดประกอบโดยผู้เขียน)
ตรวจข้ออ้างกับตัวอย่างทั้งสองชุด
ตัวอย่างคนที่วนไม่หยุดคนที่ค้างอยู่กับที่ตรงกับ 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 เหมือนโค้ดรอบแรก

เฉลย ตอนที่ 5 · เลิกเดินกระดาน เปลี่ยนไปนับ "การไหล"

สองทางที่ผมลองก่อน แล้วทิ้ง

ทางแรกคือบีบกระดานเป็นบิต เพราะการเดินกระดานหนึ่งรอบเขียนเป็น 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]); }
F ที่เป้า 1 เมื่อผ่านไป 4 รอบ คือค่าน้อยสุดของทุกทางเลือกข้างล่าง n(0) 2 เป้า 1 2 เป้า 2 1 เป้า 3 2 เป้า 4 2 เป้า 1 j = 0 4 + 0 = 4 j = 1 3 + 2 = 5 j = 2 2 + 4 = 6 j = 3 1 + 5 = 6 j = 4 0 + 7 = 7 เขียวคือทางที่ชนะ เดินทแยง 0 ก้าว ได้ F เท่ากับ 4 ช่องที่ถูกรวมเป็นช่วงติดกันเสมอ ผลรวมจึงเป็นผลรวมของช่วง
สูตร min over j อ่านเป็นรูปได้แบบนี้ ใช้ตัวอย่างที่ 1 ที่จุดเริ่ม 3 ซึ่งให้ n(0) เป็น 2, 2, 1, 2 แถวบนคือจำนวนคนแข็งตั้งต้นของแต่ละเป้า แถวล่างแต่ละแถวคือทางเลือกหนึ่งค่าของ j นั่นคือเดินทแยงไป j ก้าวแล้วเดินตรงอีก 4 ลบ j ก้าว ค่าของแต่ละทางเลือกคิดจาก n(0) จริง และทางที่ชนะคือ j เท่ากับ 0 ซึ่งให้ F เท่ากับ 4 สิ่งที่ต้องเห็นคือช่องทแยงที่ถูกรวมเป็นช่วงติดกันเสมอ ผลรวมของมันจึงเป็นผลรวมของช่วงบนอาเรย์เดียว ซึ่งเป็นเหตุผลที่สูตรยุบเหลือการถามค่าน้อยสุดของช่วงได้ (วาดประกอบโดยผู้เขียน)

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

เฉลย ตอนที่ 6 · เป้า 1 กติกากลับด้าน สูตรจึงต้องแบ่งสองเฟส

ที่มา และสามรอบที่ยังผิด

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

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

ห้ารอบของการไล่หาสูตรที่เป้า 1
ทำอะไรจุดที่ผิดจากทั้งหมด
ใช้สูตร min-plus ตรง ๆ ทั้งวง 1,306 11,156
ตัดค่าติดลบทิ้งด้วย max(0, ...) 774 14,394
ใช้ค่ามากสุดสะสมตามเวลาแทน 278 14,394
ทดสอบเฉพาะกรณีที่เป้า 1 มีคนแข็งอยู่แล้ว 0 18,264
แยกสองเฟส (เฉลยจริง) 0 36,510
สองแถวกลางคือการเดาทางแก้ ซึ่งลดจำนวนจุดที่ผิดลงได้แต่ไม่เคยถึงศูนย์ แถวที่สี่ไม่ใช่ทางแก้ มันคือการทดลองเพื่อระบุสาเหตุ และมันคือแถวที่ทำให้เห็นทางออก

สูตรพังที่เป้า 1 เพราะกติกาที่นั่นกลับด้าน

สูตร min-plus ข้างบนตั้งอยู่บนข้อเท็จจริงว่า "เป้าที่มีโทเคนจะปล่อยออกหนึ่งตัวต่อรอบ" ซึ่งไม่จริงที่เป้า 1 เพราะที่นั่นผู้ชนะอยู่ต่อ เป้า 1 จึงปล่อยคนแข็งออกไปก็ต่อเมื่อมีคนแข็งสองคน

ตรงนี้แก้ได้ด้วยการลดค่าของเป้า 1 ลงหนึ่ง คือใช้ c₁ = n₁(0) − 1 แทน แล้วสูตรก็ตรงเป๊ะ ผมสุ่มกระดานเล็ก ๆ มาเทียบทุกช่องทุกรอบรวมหนึ่งหมื่นแปดพันจุด ไม่มีจุดไหนต่างกันเลย แต่มีเงื่อนไขข้อเดียว คือเป้า 1 ต้องมีคนแข็งอยู่แล้ว

ระวัง

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

ทางแก้ แบ่งเวลาเป็นสองเฟส

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

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

พอจบเฟสแรก เป้า 1 มีคนแข็งแน่นอน ก็คำนวณสภาพกระดาน ณ วินาทีนั้นในเวลาเชิงเส้น แล้วตั้งต้นเฟสสองด้วยสูตรวง ซึ่งคราวนี้ถูกต้องตลอด

เฉลย ตอนที่ 7 · ไม่ต้องลองทุกจุดเริ่มต้น

ที่มา และจุดที่ผมมองข้ามหลักฐานของตัวเอง

ข้ออ้างว่า 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)

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

โค้ดเต็มขอบเขต

เวลาที่วัดได้ · N = 200,000 และ R = 1,000,000,000
ลักษณะอินพุตเวลา (มิลลิวินาที)เทียบกับลิมิต 2 วินาที
อินพุตสุ่ม 340 เหลือเวลา 1,660 มิลลิวินาที
เรียงจากเก่งไปอ่อน (เราคือลำดับ 1) 143 เหลือเวลา 1,857 มิลลิวินาที
เรียงจากอ่อนไปเก่ง 338 เหลือเวลา 1,662 มิลลิวินาที
เราอ่อนที่สุด คนแข็งกองท้ายแถว 334 เหลือเวลา 1,666 มิลลิวินาที
เราลำดับ 2 คนเก่งสุดอยู่ท้ายแถว 336 เหลือเวลา 1,664 มิลลิวินาที
แถวที่เร็วที่สุดคือกรณีที่เราเป็นลำดับ 1 ซึ่งตอบได้ทันทีโดยไม่ต้องคำนวณอะไรเลย ที่เหลือเดินครบทุกขั้น และยังเหลือเวลามากกว่าห้าเท่าของที่ใช้
ดูโค้ดเต็ม
archery_full.cpp
// Archery (IOI 2009) เฉลยเต็มขอบเขต N <= 200,000
//
// แนวคิดสามชั้น
//   1) ย่อโลก: คนอื่นสำคัญแค่ "แข็งกว่าเรา" หรือไม่  นับเป็นโทเคนต่อเป้า n[i] in {0,1,2}
//   2) แทนที่จะจำลองกระดานทุกรอบ ให้คิดเป็น "การไหล" F_i(t) = จำนวนโทเคนที่ออกจากเป้า i ครบ t รอบ
//      ซึ่งมีสูตรปิดแบบ min-plus:  F_i(t) = min over j ของ (t - j) + (ผลรวม n ของเป้า i..i+j-1)
//      สูตรนี้ยุบเหลือการถามค่าน้อยสุดของช่วงบนอาเรย์เดียว ตอบได้ในเวลาคงที่
//   3) เป้า 1 มีกติกากลับด้าน (ผู้ชนะอยู่ต่อ) สูตรจึงใช้ได้ต่อเมื่อเป้า 1 มีโทเคนแล้ว
//      ก่อนหน้านั้นเป็นช่วงตั้งตัวที่ไม่มีใครวนกลับ คิดแยกเป็นเฟสแรก
// สุดท้าย u(k) = k - (จำนวนก้าวซ้าย) เป็นฟังก์ชันไม่ลด จึงค้นหาคำตอบแบบไบนารีได้
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

int N;
ll R, T;
int me;
vector<int> b;                 // b[j] = 1 ถ้าคนที่ j ในแถว (ไม่รวมเรา) แข็งกว่าเรา

// ---- บัฟเฟอร์ใช้ซ้ำทุกครั้งที่ประเมิน u(k) ----
vector<int> n0, nt1;
vector<int> P1, G1, P2, G2;
vector<ll> f1at;               // F1(i, t1) ของทุกเป้า

static inline int stationOf(ll y) {          // y เป็นดัชนีที่คลี่วงออกแล้ว
    ll r = y % N; if (r <= 0) r += N; return (int)r;
}

/** คืน u(k) = k - จำนวนก้าวซ้ายทั้งหมด และถ้า outPos ไม่เป็น null ก็คืนเป้าสุดท้ายด้วย */
ll evalU(int k, int* outPos) {
    for (int i = 1; i <= N; i++) {
        if (i < k)       n0[i] = b[2 * i - 2] + b[2 * i - 1];
        else if (i == k) n0[i] = b[2 * k - 2];
        else             n0[i] = b[2 * i - 3] + b[2 * i - 2];
    }
    int i0 = 0;
    for (int i = 1; i <= N; i++) if (n0[i] >= 1) { i0 = i; break; }
    ll t1 = (n0[1] >= 1) ? 0 : i0 - 1;       // เฟสแรกจบตอนโทเคนตัวแรกถึงเป้า 1

    // ---------- เฟส 1 : เป้า 1 ยังไม่ปล่อยใคร เส้นทาง min-plus จึงจบที่เป้า 1 ----------
    ll L1 = 2LL * N + 4;
    P1.assign(L1 + 2, 0);
    for (ll y = 2; y <= L1 + 1; y++) P1[y] = P1[y - 1] + ((y - 1 <= N) ? n0[y - 1] : 0);
    G1.assign(L1 + 2, 0);
    for (ll y = 0; y <= L1 + 1; y++) G1[y] = P1[y] - (int)y;

    ll pos = k, steps = 0;
    // เฟสแรกยาวไม่เกิน N รอบ และเราไม่มีทางวนข้ามเป้า 1 ในเฟสนี้ จึงเดินตรง ๆ ได้
    {
        ll winL = k, winR = k;               // หน้าต่างของ F1(pos, t)
        ll mnCur = G1[k];
        ll sL = k + 1, sR = k + 1;           // หน้าต่างของ F1(pos+1, t)
        ll mnSrc = G1[min(sL, L1)];
        for (ll t = 0; t < min(t1, T); t++) {
            ll fp = (pos == 1) ? 0 : (t - P1[pos] + pos + mnCur);
            ll src = pos + 1;
            ll fs = (stationOf(src) == 1) ? 0 : (t - P1[src] + src + mnSrc);
            bool blocked = (n0[pos] + fs - fp) >= 1;
            bool moved = (pos == 1) ? blocked : !blocked;
            if (moved) { pos--; steps++; }   // ในเฟสแรกเป้า 1 ไม่มีโทเคน เราจึงไม่มีทางถูกเตะ
            // ขยายหน้าต่างทั้งสอง (ขยายอย่างเดียว ไม่เคยหด)
            ll nwL = pos, nwR = pos + (t + 1);
            while (winL > nwL) { winL--; mnCur = min<ll>(mnCur, G1[winL]); }
            while (winR < nwR) { winR++; if (winR <= L1) mnCur = min<ll>(mnCur, G1[winR]); }
            ll nsL = pos + 1, nsR = pos + 1 + (t + 1);
            while (sL > nsL) { sL--; mnSrc = min<ll>(mnSrc, G1[sL]); }
            while (sR < nsR) { sR++; if (sR <= L1) mnSrc = min<ll>(mnSrc, G1[sR]); }
        }
    }

    if (T <= t1) { if (outPos) *outPos = (int)pos; return k - steps; }

    // ---------- สภาพกระดานตอนจบเฟสแรก ----------
    // F1(i, t1) ของทุกเป้า หาได้ด้วยหน้าต่างเลื่อนความกว้างคงที่
    f1at.assign(N + 3, 0);
    {
        deque<ll> dq;
        ll hi0 = min<ll>(1 + t1, L1);
        for (ll x = 1; x <= hi0; x++) { while (!dq.empty() && G1[dq.back()] >= G1[x]) dq.pop_back(); dq.push_back(x); }
        for (ll i = 1; i <= N + 1; i++) {
            ll hi = min(i + t1, L1);
            while (!dq.empty() && dq.front() < i) dq.pop_front();
            while (!dq.empty() && dq.back() > hi) dq.pop_back();
            for (ll x = max(i, hi0 + 1); x <= hi; x++) { while (!dq.empty() && G1[dq.back()] >= G1[x]) dq.pop_back(); dq.push_back(x); }
            hi0 = max(hi0, hi);
            f1at[i] = (stationOf(i) == 1) ? 0 : (t1 - P1[i] + i + G1[dq.front()]);
        }
    }
    nt1.assign(N + 2, 0);
    for (int i = 1; i <= N; i++) {
        int src = (i < N) ? i + 1 : 1;
        ll fs = (src == 1) ? 0 : f1at[src];
        nt1[i] = (int)(n0[i] + fs - f1at[i]);
    }

    // ---------- เฟส 2 : เป้า 1 มีโทเคนแล้ว สูตร min-plus แบบวนใช้ได้ทั้งหมด ----------
    ll OFF = T + 5;                          // เลื่อนดัชนีให้ไม่ติดลบ
    ll L2 = OFF + N + T + 5;
    P2.assign(L2 + 2, 0);
    for (ll y = 1; y <= L2 + 1; y++) {
        int s = stationOf(y - 1 - OFF);
        P2[y] = P2[y - 1] + nt1[s] - (s == 1 ? 1 : 0);
    }
    G2.assign(L2 + 2, 0);
    for (ll y = 0; y <= L2 + 1; y++) G2[y] = P2[y] - (int)y;

    ll y = pos + OFF;                        // ดัชนีที่คลี่แล้วของตำแหน่งเรา
    ll wL = y, wR = y, mnCur = G2[y];
    ll sL = y + 1, sR = y + 1, mnSrc = G2[y + 1];
    for (ll t = 0; t < T - t1; t++) {
        ll fp = t - P2[wL] + wL + mnCur;
        ll fs = t - P2[sL] + sL + mnSrc;
        int st = stationOf(y - OFF);
        bool blocked = ((ll)nt1[st] + fs - fp) >= 1;
        bool moved = (st == 1) ? blocked : !blocked;
        if (moved) { y--; steps++; }
        ll nwL = y, nwR = y + (t + 1);
        while (wL > nwL) { wL--; mnCur = min<ll>(mnCur, G2[wL]); }
        while (wR < nwR) { wR++; mnCur = min<ll>(mnCur, G2[wR]); }
        ll nsL = y + 1, nsR = y + 1 + (t + 1);
        while (sL > nsL) { sL--; mnSrc = min<ll>(mnSrc, G2[sL]); }
        while (sR < nsR) { sR++; mnSrc = min<ll>(mnSrc, G2[sR]); }
    }
    if (outPos) *outPos = stationOf(y - OFF);
    return k - steps;
}

int main() {
    scanf("%d %lld", &N, &R);
    vector<int> s(2 * N);
    for (int i = 0; i < 2 * N; i++) scanf("%d", &s[i]);
    me = s[0];
    if (me == 1) { printf("%d\n", N); return 0; }   // เก่งที่สุด อยู่เป้า 1 แน่นอน จึงเลือกเป้าเริ่มมากสุด

    b.assign(2 * N - 1, 0);
    for (int i = 1; i < 2 * N; i++) b[i - 1] = (s[i] < me) ? 1 : 0;
    T = (R <= 2LL * N) ? R : 2LL * N + ((R - 2LL * N) % N);

    n0.assign(N + 2, 0);
    auto U = [&](int k) { return evalU(k, nullptr); };

    ll u1 = U(1), uN = U(N);
    auto fOf = [&](ll u) { ll r = (u - 1) % N; if (r < 0) r += N; return r + 1; };

    // k ที่ใหญ่ที่สุดที่ u(k) <= v  (u ไม่ลด จึงค้นหาแบบไบนารีได้)
    auto lastLE = [&](ll v) {
        int lo = 1, hi = N, res = 1;
        while (lo <= hi) { int md = (lo + hi) / 2; if (U(md) <= v) { res = md; lo = md + 1; } else hi = md - 1; }
        return res;
    };
    // k ที่เล็กที่สุดที่ u(k) >= v
    auto firstGE = [&](ll v) {
        int lo = 1, hi = N, res = -1;
        while (lo <= hi) { int md = (lo + hi) / 2; if (U(md) >= v) { res = md; hi = md - 1; } else lo = md + 1; }
        return res;
    };

    auto fdiv = [&](ll a, ll m) { ll d = a / m; if ((a % m) && ((a < 0) != (m < 0))) d--; return d; };

    // u ไม่ลด แต่ช่วงของมันกว้างได้เกิน N จึงข้ามเส้นแบ่งบล็อกได้หลายครั้ง
    // ผู้เข้าชิงคือค่า u ที่เล็กที่สุดของแต่ละบล็อก เพราะภายในบล็อกเดียวกัน f เพิ่มตาม u
    vector<ll> cand;
    cand.push_back(u1);
    for (ll v = (ll)N * (fdiv(u1 - 1, N) + 1) + 1; v <= uN; v += N) {
        int kw = firstGE(v);
        if (kw > 0) cand.push_back(U(kw));
    }
    ll bestF = LLONG_MAX, bestU = 0;
    for (ll u : cand) {
        ll f = fOf(u);
        if (f < bestF || (f == bestF && u > bestU)) { bestF = f; bestU = u; }
    }
    int ans = lastLE(bestU);
    printf("%d\n", ans);
    return 0;
}
ดูบักตอนเสมอกัน กับเคสที่เล็กที่สุดที่จับมันได้

ทั้งไฟล์เหมือนโค้ดข้างบนทุกบรรทัด ต่างกันแค่ท่อนสุดท้ายที่เลือกผู้ชนะจากผู้เข้าชิง

archery_wrong.cpp · เฉพาะท่อนเลือกผู้ชนะ
// ผู้เข้าชิงคือค่า u ที่เล็กที่สุดของแต่ละบล็อก สองบล็อกให้เป้าสุดท้ายเท่ากันได้
ll bestF = LLONG_MAX, bestU = 0;
for (ll u : cand) {
    ll f = fOf(u);
    if (f < bestF) { bestF = f; bestU = u; }   // บรรทัดที่ผิด เสมอกันแล้วเก็บตัวที่เจอก่อน
}
int ans = lastLE(bestU);                       // ค่า u ที่เจอก่อนคือค่าน้อยกว่า จุดเริ่มจึงเล็กเกิน

เคสที่เล็กที่สุดที่สองเวอร์ชันตอบไม่ตรงกัน มีแค่ 2 เป้า ผมไล่ทุกการเรียงของ N เท่ากับ 2 และ 3 ทุกค่า R ตั้งแต่ 2N ถึง 4N แล้วเจอชุดนี้เป็นชุดแรก

INPUT
2 4
3
1
4
2

กระดานนี้ยืนเป้าไหนก็จบที่เป้า 1 เหมือนกันทั้งสองจุด โจทย์จึงให้ตอบจุดเริ่มที่มากที่สุด คือ 2 ผู้เข้าชิงสองตัวคือ u = −3 จากจุดเริ่ม 1 กับ u = −1 จากจุดเริ่ม 2 ทั้งคู่ให้ f = 1 เท่ากัน เวอร์ชันที่ผิดเก็บ u = −3 ไว้เพราะเจอก่อน แล้ว lastLE(−3) ก็คืนจุดเริ่ม 1 ออกมา ทางแก้คือให้เงื่อนไขรับกรณีเสมอด้วย f == bestF && u > bestU ซึ่งเก็บ u ที่มากกว่าไว้

ตัวอย่างในโจทย์ทั้งสองชุดตอบ 3 กับ 2 เท่ากันทั้งสองเวอร์ชัน ผมจึงไม่มีทางรู้จากตรงนั้นเลยว่าพลาด

การตรวจก่อนเชื่อทำสี่ชั้น ชั้นแรกเทียบสูตรการไหลกับการเดินกระดานจริงทุกช่องทุกรอบ สามหมื่นหกพันจุด ชั้นที่สองเทียบคำตอบทั้งข้อกับตัวจำลองคนจริงที่ N ไม่เกิน 8 จำนวนสี่พันชุด ชั้นที่สามเทียบที่ N ระหว่าง 9 ถึง 40 อีกสี่ร้อยชุด บวกชุดที่จงใจจัดแถวให้โหด (เรียงขึ้น เรียงลง คนแข็งกองท้าย เราเป็นลำดับสุดขั้ว) อีกสี่ร้อยชุด ชั้นที่สี่เทียบที่ N ระหว่าง 60 ถึง 220 อีกหกสิบชุด ตรงกันทั้งหมด

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

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

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

และท่าที่ควรติดมือที่สุดคือท่าที่ตอนที่ 7 สอน คือตอนที่ตัวตรวจสอบบอกว่ามีอะไรผิดปกติ อย่าเพิ่งตัดสินว่าไม่สำคัญ ผมมีหลักฐานว่าช่วงกว้างเท่ากับ N อยู่ตรงหน้าตั้งแต่วันแรก แล้วอ่านผ่านไป มันกลับมาเล่นงานตอนเขียนโค้ดจริง เจอสัญญาณแปลก ๆ จากตัวตรวจครั้งหน้า ให้หยุดอ่านมันให้จบก่อนเดินหน้า

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

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

แหล่งที่มา

  1. โจทย์ Archery บน programming.in.th ข้อ 2032 programming.in.th/tasks/2032 (สืบค้น 6 กันยายน 2026)
  2. ต้นทางของโจทย์คือ 21st International Olympiad in Informatics วันแข่งที่หนึ่ง จัดที่เมืองพลอฟดิฟ ประเทศบัลแกเรีย 8 ถึง 15 สิงหาคม 2009