programming.in.th · ข้อ 2010
ข้อที่แก้ปัญหานับซ้ำได้สวยที่สุดในคลังนี้ และเป็นข้อที่อธิบายว่าทำไมบางครั้งต้องใช้เซกเมนต์ทรีทั้งที่เฟนวิกก็ดูจะพอ คำตอบอยู่ที่มอดุลัสไม่ใช่จำนวนเฉพาะ จึงหารกลับไม่ได้
ในทะเลสาบมีปลา F ตัว แต่ละตัวกลืนอัญมณีไว้หนึ่งชิ้น อัญมณีมี K ประเภท
ปลาหลายตัวกินประเภทเดียวกันได้ ปลาตัวหนึ่งกินปลาอีกตัวได้ก็ต่อเมื่อมันยาวอย่างน้อยสองเท่า
ของตัวที่ถูกกิน และเมื่อกินแล้ว อัญมณีในท้องตัวเล็กจะย้ายมาอยู่ในท้องตัวใหญ่ ส่วนความยาวไม่เปลี่ยน
เราจับปลาได้หนึ่งตัวและได้อัญมณีในท้องมันทั้งหมด คำถามคือชุดของอัญมณีที่เป็นไปได้มีกี่แบบ
โดยชุดนับจากจำนวนของแต่ละประเภท ไม่สนใจลำดับ และอัญมณีประเภทเดียวกันถือว่าเหมือนกันหมด
ตอบเป็นเศษเหลือจากการหารด้วย M
อินพุต / ขอบเขต / เอาต์พุต
F บรรทัดที่ 2 คือ K บรรทัดที่ 3 คือ
M จากนั้น F บรรทัด แต่ละบรรทัดคือความยาวปลาและประเภทอัญมณีที่มันกิน
F ≤ 500,000, K ≤ F, 2 ≤ M ≤ 30,000,
ความยาวปลาไม่เกินหนึ่งพันล้าน และรับประกันว่าอัญมณีมีครบทั้ง K ประเภท
เวลา 3 วินาที หน่วยความจำ 64 เมกะไบต์
MK ไม่เกิน 7,000
และในนั้นมี 25 คะแนนที่ K ไม่เกิน 20
| Input | Output |
|---|---|
| 5 3 7 2 2 5 1 8 3 4 1 2 3 | 4 |
ชุดที่เป็นไปได้มี 11 แบบ คือ [1] [2] [3] [1,2] [1,3] [2,3] [3,3] [1,2,3] [1,3,3] [2,3,3] [1,2,3,3] และ 11 หารเอาเศษด้วย
7 ได้ 4 ตรงกับรายการที่โจทย์ยกมาให้ดูทั้งหมด
ใบ้
ถามก่อนว่า ถ้าเราตั้งใจจะจับปลาตัว X อัญมณีที่ลงไปอยู่ในท้องมันได้มีของใครบ้าง
ปลาที่ X กินตรง ๆ ต้องสั้นไม่เกินครึ่งหนึ่งของ X แล้วปลาที่ถูกกินโดยปลาที่ถูก
X กินอีกทีล่ะ มันสั้นกว่านั้นอีก
พอตอบข้อนั้นได้ ปัญหาจะกลายเป็นการนับชุดตัวเลข ซึ่งง่ายมาก ปัญหาที่เหลือมีข้อเดียวคือ ชุดเดียวกันเกิดได้จากปลาหลายตัว แล้วเราจะนับมันครั้งเดียวได้ยังไง
ปลาสามตัวสองชนิด พอให้เห็นว่าเพดานทำงานยังไง
เลือกชนิดที่จะจับ ตัวที่ยาวที่สุดของชนิดนั้นจะเป็นตัวกำหนดเพดาน
เลือกชนิดที่จะจับก่อน แล้วค่อยปรับจำนวนอัญมณีแต่ละชนิดที่จะเก็บ
เก็บได้ 0 จาก 0 ชุดที่ต่างกัน
ยังไม่ได้เก็บชุดไหนเลย
ถ้าเจอชุดที่เก็บซ้ำ ให้สังเกตว่ามันมาจากการจับคนละชนิดกันได้ นั่นคือจุดที่การนับตรง ๆ จะนับเกิน
ที่มาของแนวคิดนี้
ข้อนี้ผมทำสิ่งที่ควรทำแต่มักขี้เกียจทำ คือไล่สูตรด้วยมือบนตัวอย่างในโจทย์ก่อนแตะคีย์บอร์ด พอกางกรณี ก และ ข ออกมาแล้วบวกกัน ได้ 1 + 2 + 4 + 4 = 11 ซึ่งตรงกับที่โจทย์บอกว่ามี 11 ชุด และตรงกับรายการทั้ง 11 ชุดที่โจทย์ยกมาให้ดูด้วย ตรงนั้นถึงกล้าเปิดเขียนโค้ด ถ้าเลขไม่ตรง สิ่งที่พังคือวิธีนับ ไม่ใช่โค้ด และการรู้แบบนั้นตั้งแต่ก่อนเขียนช่วยประหยัดเวลาไปทั้งคืน
อีกเรื่องที่ต้องตัดสินใจตั้งแต่ต้นคือจะใช้โครงสร้างอะไรมาถามผลคูณของช่วง ตัวแรกที่ทุกคนนึกถึงคือเฟนวิก แต่ข้อนี้ใช้ไม่ได้ เพราะเฟนวิกตอบได้แค่ผลสะสมจากต้นถึงจุดหนึ่ง จะเอาช่วงตรงกลางต้องหักส่วนหน้าออก ซึ่งกับการบวกคือการลบ แต่กับการคูณคือการหาร และมอดุลัสของข้อนี้ไม่ใช่จำนวนเฉพาะ จึงหารกลับไม่ได้ ตรงนี้จบด้วยการตัดเฟนวิกทิ้งตั้งแต่ยังไม่เขียน
ตอนตรวจ ผมสุ่ม 4,000 ชุดเทียบสามอย่างพร้อมกัน คือเฉลยเต็ม เฉลยชุดทดสอบย่อย
และตัวไล่นับที่โยนทุกชุดที่หาได้ลง set ตรง ๆ โดยไม่มีทฤษฎีกันซ้ำเลยสักบรรทัด
ตัวที่สามสำคัญที่สุด เพราะสองตัวแรกใช้เหตุผลเรื่องการกันนับซ้ำชุดเดียวกัน ถ้าเหตุผลนั้นผิด มันจะผิดพร้อมกันทั้งคู่
บทเรียนที่ยกไปข้ออื่นได้คือ เลือกโครงสร้างข้อมูลจากคุณสมบัติของการดำเนินการ ไม่ใช่จากความคุ้นมือ ถามก่อนว่ามันมีตัวผกผันไหม บนมอดุลัสตัวนี้มันมีจริงหรือเปล่า
ปลา X ยาว Lx กินปลาที่ยาวไม่เกิน Lx / 2 ได้ตรง ๆ
ส่วนอัญมณีที่มาทางอ้อม ต้องผ่านปลากลางทางที่ยาวไม่เกิน Lx / 2 อยู่แล้ว
และปลาที่ตัวกลางกินได้ก็ยาวไม่เกิน Lx / 4 ซึ่งก็ยังอยู่ในกลุ่มเดิม
จับปลา X แล้วได้อัญมณีของตัวมันเอง บวกกับชุดย่อยใด ๆ ของปลาที่ยาวไม่เกินครึ่งหนึ่งของมัน
ที่สำคัญคือชุดย่อยใด ๆ จริง ๆ ไม่มีข้อจำกัดเพิ่ม เพราะ X
จะกินตัวไหนในกลุ่มนั้นก็ได้ ทีละตัวตามใจ
ถ้าปลาชนิด t ตัวหนึ่งใช้เป็นตัวจับได้ ปลาชนิด t ที่ตัวใหญ่ที่สุด
ก็ใช้ได้เสมอ เพราะมันกินได้ทุกอย่างที่ตัวเล็กกว่ากินได้ ดังนั้นแทนที่จะคิดทีละตัวปลา
ให้คิดทีละชนิด โดยใช้ตัวใหญ่ที่สุดของชนิดนั้นเป็นตัวแทน
เรียงชนิดตามความยาวของตัวแทนจากน้อยไปมาก จะได้คุณสมบัติที่สวยมากคือกลุ่มที่กินได้ของชนิดหลัง ครอบกลุ่มของชนิดก่อนไว้เสมอ
ชุดหนึ่งชุดอาจสร้างได้จากหลายชนิด ผมจึงกำหนดว่าให้นับชุดนั้นที่ชนิดซึ่งอยู่หลังสุดในลำดับ แล้วถามว่าเงื่อนไขอะไรทำให้ชนิดที่มาทีหลังใช้ไม่ได้
เพราะกลุ่มของชนิดหลังครอบชนิดก่อนไว้ เพดานของทุกประเภทจึงไม่ลดลง มีข้อเดียวที่ลด คือ ประเภทของตัวจับเอง ซึ่งตอนเป็นตัวจับจะได้เพดานบวกหนึ่งพิเศษ แต่พอไม่ได้เป็นตัวจับก็หายไป ดังนั้นชนิดหลังใช้ไม่ได้ ก็ต่อเมื่อ
การนับจึงแตกเป็นสองกรณีที่ไม่ทับกัน
| กรณี | จำนวนอัญมณีชนิดตัวเอง | ชนิดที่มาทีหลัง | จำนวนชุด |
|---|---|---|---|
| (ข) ไม่เต็มเพดาน | 1 ถึง m ของตัวเอง | ต้องเป็นศูนย์ทั้งหมด | m ของตัวเอง × ผลคูณของชนิดก่อนหน้า |
| (ก) เต็มเพดาน | m ของตัวเอง บวกหนึ่ง | ใส่ได้จนถึงชนิดที่กลืนปลาตัว x ได้ | ผลคูณของชนิดก่อนหน้า × ผลคูณช่วง (i, J) |
x คือปลาชนิดตัวเองที่เล็กที่สุดที่ตัวแทนของชนิดตัวเองยังกินไม่ได้
และ J คือชนิดแรกที่อยู่หลัง i ซึ่งกลืน x เข้าไปได้
ชนิดตั้งแต่ J เป็นต้นไปจึงมองเห็นอัญมณีชนิดตัวเองครบเพดาน และแย่งสิทธิ์การนับไปได้
ll own = m[i]; // จำนวนปลาชนิดนี้ที่ตัวโตสุดของชนิดนี้กินได้
ll left = prod(0, i - 1); // ผลคูณของ (จำนวน + 1) ของชนิดที่มาก่อนหน้า
// กรณี (ข) ชนิดตัวเองไม่เต็มเพดาน ชนิดที่มาทีหลังต้องเป็นศูนย์ทั้งหมด
ans = (ans + own % MOD * left) % MOD;
// กรณี (ก) ชนิดตัวเองเต็มเพดาน ชนิดที่มาทีหลังยังใส่ได้จนกว่าจะถึงตัวที่กลืน x ได้
int J = first_index_after_i_whose_prefix_swallows(x);
ans = (ans + left * prod(i + 1, J - 1)) % MOD; กดถัดไปเพื่อไล่ชนิดทีละชนิด
| ลำดับ | ชนิด | ตัวแทนยาว | กินได้กี่ตัวต่อชนิด | เพดานชนิดตัวเอง | x | J | กรณี (ข) | กรณี (ก) |
|---|---|---|---|---|---|---|---|---|
| 1 | 2 | 2 | 0 0 0 | 1 | 2 | 2 | 0 | 1 |
| 2 | 1 | 5 | 1 0 1 | 1 | 4 | 3 | 0 | 2 |
| 3 | 3 | 8 | 1 1 1 | 2 | 8 | 4 | 4 | 4 |
คอลัมน์ "กินได้กี่ตัวต่อชนิด" อ่านตามลำดับที่เรียงไว้ ไม่ใช่ตามหมายเลขชนิดเดิม รวมสองคอลัมน์สุดท้ายได้ 11 ซึ่งตรงกับการไล่นับตรง ๆ ที่ได้ 11 แบบ และ 11 หารเอาเศษด้วย 7 ได้ 4
สังเกตแถวแรก ชนิด 2 มีตัวแทนยาวแค่ 2 จึงกินอะไรไม่ได้เลย เพดานของตัวเองคือ 1
กรณี (ข) จึงเป็นศูนย์ และกรณี (ก) ให้ชุดเดียวคือ [2]
สูตรข้างบนต้องการผลคูณของช่วง ซึ่งเปลี่ยนไปเรื่อย ๆ ระหว่างที่เราไล่ชนิด
ปัญหาคือ M ไม่ใช่จำนวนเฉพาะ เราจึงหารกลับไม่ได้
จะเก็บผลคูณสะสมไว้ตัวเดียวแล้วหารออกก็ไม่ได้
แต่ถ้า K ไม่เกิน 7,000 ก็ไม่ต้องใช้อะไรเลย คูณไล่ตรง ๆ ทุกรอบ เป็น
K² ซึ่งคือ 49,000,000 ครั้ง ผ่านสบายในสามวินาที ได้ 70 คะแนน
พอ K ขึ้นไปถึงห้าแสน ค่อยยกเซกเมนต์ทรีที่เก็บผลคูณมาใช้
มันตอบผลคูณของช่วงได้โดยไม่ต้องหาร ซึ่งเป็นเหตุผลที่ต้องใช้เซกเมนต์ทรีแทนต้นไม้เฟนวิกในข้อนี้
เฟนวิกตอบได้แต่ผลสะสมจากต้นถึงจุดหนึ่ง จะหาช่วงกลางต้องหักลบ ซึ่งกับการคูณคือการหาร
ผมสุ่มทะเลสาบเล็ก ๆ ที่มีปลาไม่เกินเจ็ดตัวและอัญมณีไม่เกินสี่ประเภท จำนวน 4,000 ชุด
แล้วเทียบสามอย่างคือ โค้ดเต็ม โค้ดชุดทดสอบย่อย และตัวไล่นับชุดทุกชุดแบบซื่อ ๆ ตรงกันหมด
ตัวไล่นับตรง ๆ นี่แหละที่ทำให้กล้าเชื่อเรื่องการกันนับซ้ำ เพราะมันใช้ set เก็บชุด
ซึ่งกันซ้ำให้เองโดยไม่ต้องมีทฤษฎีอะไรเลย
| ทาง | ขอบเขตที่มันไหว | งานคร่าว ๆ |
|---|---|---|
| คูณไล่ตรง ๆ ทุกรอบ | K ไม่เกิน 7,000 | 49,000,000 ครั้ง |
| เซกเมนต์ทรีที่เก็บผลคูณ | K ถึง 500,000 | 9,500,000 ครั้ง |
โครงของโค้ดคือ เรียงปลาตามความยาว เรียงชนิดตามตัวแทน แล้วเดินตัวชี้ไปข้างหน้าอย่างเดียว
ทุกครั้งที่ปลาตัวหนึ่งเข้ามาอยู่ในกลุ่มที่กินได้ ก็อัปเดตช่องของชนิดนั้นในเซกเมนต์ทรีหนึ่งครั้ง
ปลาแต่ละตัวเข้ากลุ่มครั้งเดียวตลอดโปรแกรม รวมแล้วจึงเป็น F log K
เวลาที่วัดได้บนเครื่องผม ปลา 500,000 ตัว อัญมณี 200,000 ประเภท ใช้เวลา 464 มิลลิวินาที จากลิมิตสามวินาที
เซกเมนต์ทรีแบบที่ข้อนี้ใช้ รวมถึงเวอร์ชันที่มีการอัปเดตทั้งช่วง อยู่ในบทปูพื้นฐาน เซกเมนต์ทรีกับการค้นหาคำตอบแบบไบนารี
การตัดสินใจในเฉลยนี้เป็นท่าโลภ (greedy) คือเมื่อถึงคิวของอัญมณีชนิดหนึ่ง เราเลือกด้วยข้อมูล เฉพาะหน้าแล้วไม่ย้อนกลับมาแก้ ที่มันถูกได้เพราะเราจัดลำดับการคิดใหม่ให้ผูกกับชนิด ไม่ผูกกับตัวปลา พอเรียงลำดับถูก การเลือกเฉพาะหน้าก็ไม่ปิดทางที่ดีกว่าอีก
นี่คือรูปที่พบบ่อยที่สุดของท่าโลภที่ใช้ได้จริง คือเรียงลำดับให้ถูกก่อน แล้วท่าโลภจึงจะถูก ถ้าท่าโลภของเราตอบผิด ให้กลับไปสงสัยลำดับที่เราไล่ ก่อนจะไปสงสัยตัวการตัดสินใจ มีคำอธิบายเรื่องท่าโลภอยู่ในโจทย์โมบาย และโจทย์ใบเรือ เป็นอีกข้อที่ความถูกต้องของท่าโลภมาจากลำดับที่ไล่
เมื่อชุดคำตอบเกิดได้จากหลายทาง อย่าพยายามหักลบทีหลัง ให้ตั้งกฎว่าชุดนั้นเป็นของใครตั้งแต่แรก ที่นี่คือให้เป็นของชนิดที่อยู่หลังสุดที่สร้างมันได้ แล้วเงื่อนไขที่เหลือก็ยุบเป็นผลคูณของช่วงสองก้อน
ในหน้านี้