programming.in.th · ข้อ 2015
เสาสูงไม่เท่ากัน แต่ละต้นต้องติดใบตามจำนวน ใบที่อยู่ระดับเดียวกันบังลมกัน จะจัดยังไงให้เสียน้อยที่สุด ไล่จากวิธีนับทุกแบบไปจนถึงโค้ดที่ทันทุกขนาด
เรือโจรสลัดลำใหม่มีเสา N ต้น เรียงจากหัวเรือไปท้ายเรือ เสาต้นที่ i สูง
H ช่อง และต้องติดใบเรือ K ใบ หนึ่งช่องติดได้ไม่เกินหนึ่งใบ
เราเลือกเองได้ว่าจะติดใบไหนที่ระดับไหนของเสาต้นนั้น
ใบเรือที่อยู่ข้างหน้าจะบังลมใบที่อยู่ข้างหลังในระดับเดียวกัน โจทย์นิยาม ความไร้ประสิทธิภาพ ของใบหนึ่งใบว่าคือจำนวนใบที่อยู่ข้างหน้ามันในระดับเดียวกัน คือจำนวนใบที่บังลมมัน
งานของเราคือจัดใบเรือให้ผลรวมความไร้ประสิทธิภาพของทุกใบน้อยที่สุด
สังเกตจุดหนึ่งที่ทำให้โจทย์ง่ายลงทันที ถ้าระดับหนึ่งมีใบเรืออยู่ c ใบ ผลรวมของระดับนั้นคือ
จำนวนคู่ที่จับได้จาก c ใบ คือ c × (c − 1) / 2
สองใบในระดับเดียวกันย่อมมีใบหนึ่งอยู่หน้าอีกใบ จึงนับเป็นหนึ่งคู่พอดี ตัวเลขนี้ขึ้นกับจำนวนใบของระดับอย่างเดียว ลำดับหัวท้ายของเสาหายไปจากโจทย์
คำถามจึงเหลือแค่ แจกใบเรือลงระดับต่าง ๆ โดยเสาต้นที่ i แจกได้เฉพาะระดับ 1 ถึง
H ระดับละไม่เกินหนึ่งใบ ทำยังไงให้ผลรวมของ c × (c − 1) / 2 ทุกระดับน้อยที่สุด
อินพุต / ขอบเขต / เอาต์พุต
N จากนั้น N บรรทัด แต่ละบรรทัดคือ
H และ K ของเสาต้นนั้น เรียงจากหัวเรือไปท้ายเรือ
2 ≤ N ≤ 100,000, 1 ≤ H ≤ 100,000,
1 ≤ K ≤ H เวลา 1 วินาที หน่วยความจำ 64 เมกะไบต์
| Input | Output |
|---|---|
| 6 3 2 5 3 4 1 2 1 4 3 3 2 | 10 |
ใบ้
ลองคิดว่าใบที่ c + 1 ของระดับหนึ่งทำให้แต้มเพิ่มขึ้นเท่าไร
ถ้าเราจะไล่ใส่เสาทีละต้น ลำดับของเสามีผลกับแต้มไหม และตอนที่หลายระดับมีจำนวนใบเท่ากันเป๊ะ จะเลือกระดับไหน
สามเสาเตี้ย ๆ พอให้จับทางว่าอะไรทำให้เสียแต้ม
คลิกช่องบนเสาเพื่อแขวนใบเรือ แต่ละเสาต้องแขวนให้ครบตามจำนวนที่โจทย์กำหนด
แต้มตอนนี้ 0 · แขวนแล้ว 0 จาก 0 ใบ
แต้มของหนึ่งระดับคือจำนวนคู่ของใบเรือที่อยู่ระดับนั้น สองใบเสียหนึ่งแต้ม สามใบเสียสามแต้ม
ลองเทียบสองการจัดที่ต่างกันแค่ใบเดียว แล้วดูว่าแต้มเปลี่ยนไปเท่าไร
ที่มาของแนวคิดนี้
ผมถามคำถามที่ง่ายกว่าก่อน ถ้าเสาสูงเท่ากันหมดจะทำยังไง คำตอบคือกระจายใบให้แต่ละระดับมีจำนวนเท่ากันที่สุด
ที่ทำให้โจทย์จริงยากกว่าคือเสาแต่ละต้นเอื้อมได้ไม่เท่ากัน พอเห็นแบบนั้น กฎ "ให้คนที่มีทางเลือกน้อยที่สุดได้เลือกก่อน" ก็ตามมา
ทำไมต้องเท่ากันที่สุด: ย้ายหนึ่งใบจากระดับที่มี p ใบไประดับที่มี q ใบ
แต้มลด p − 1 (ใบนั้นเลิกจับคู่กับอีก p − 1 ใบ) และเพิ่ม q (มันจับคู่ใหม่กับ q ใบ)
การย้ายคุ้มทุกครั้งที่ q น้อยกว่า p − 1
เสาเตี้ยเลือกได้แค่ระดับล่าง เสาสูงเลือกได้ทุกระดับ ถ้าให้เสาสูงเลือกก่อน มันอาจหยิบระดับล่างที่เสาเตี้ยต้องใช้ ชุดที่ 3 ในเกมไล่ตามลำดับที่ให้มา (เสาสูง 5 ก่อน) เสีย 6 แต้ม ไล่เตี้ยไปสูงเสีย 5 แต้ม
ท่าที่ใช้
K ใบลง K ระดับในช่วง 1 ถึง H ที่ตอนนี้มีใบน้อยที่สุดภาพข้างล่างคือผลของท่านี้บนตัวอย่าง
จำนวนใบจากระดับ 1 ขึ้นไปคือ 3 3 3 2 1 เรียงจากมากไปน้อย
ท่านี้รักษารูปนี้ไว้ทุกขั้นตั้งแต่เสาต้นแรก และโค้ดที่เร็วพึ่งรูปนี้ เพราะมันค้นแบบไบนารีบนอาเรย์จำนวนใบ
เรียกระดับที่อยู่ติดกันและมีจำนวนใบเท่ากันว่า บล็อก
เช่นหลังใส่เสาต้นที่ 5 ของตัวอย่าง จำนวนใบคือ 3 2 2 2 0
ระดับ 2 ถึง 4 เป็นบล็อกของค่า 2
ผมแก้ถูกตั้งแต่แรก แต่เข้าใจเหตุผลผิดอยู่นาน
โค้ดรอบแรกของผมตอบ 15 แทน 10 เพราะหยิบท้ายบล็อก พอเปลี่ยนเป็นหัวบล็อกก็ได้ 10 ตอนนั้นผมสรุปว่ากฎเสมอผิดทำให้คำตอบผิด
ตารางเทียบสองกฎด้วยท่าโลภแบบนับทีละใบออกมา 10 ทั้งสองฝั่ง สุ่มเพิ่มอีก 200,000 ชุดก็หาชุดที่ค่ารวมต่างกันไม่เจอ
เหตุผล: เสาที่มาทีหลังสูงกว่าหรือเท่ากัน จึงเอื้อมถึงทุกระดับในบล็อก หยิบระดับไหนในบล็อกก็ได้จำนวนใบชุดเดียวกัน ต่างแค่ตำแหน่ง
ที่พังจริงคือโค้ดเติมเป็นช่วง ซึ่งค้นแบบไบนารีโดยถือว่าอาเรย์เรียงจากมากไปน้อย หยิบท้ายบล็อกทำให้อาเรย์เลิกเรียง และการค้นในก้าวถัดมาชี้ผิดที่
ทำไมหยิบหัวบล็อกแล้วอาเรย์ยังเรียงอยู่ ก่อนใส่ใบของเสาต้นหนึ่ง ช่วง 1 ถึง H แบ่งได้เป็นสามส่วน
ระดับที่มีใบมากกว่า v บล็อกของค่า v (ระดับ l ถึง r)
และระดับ r + 1 ถึง H ที่มีใบน้อยกว่า v
r + 1 ถึง H ได้คนละใบ แต่ละระดับยังไม่เกิน vv + 1l − 1 มีอย่างน้อย v + 1 อยู่แล้ว จึงไม่น้อยกว่าระดับ lv + 1) อยู่ล่าง ส่วนที่ไม่ได้ (v) อยู่บนr จบที่ v หรือ v + 1 ส่วนระดับ r + 1 จบที่ไม่เกิน vv + 1 ทับระดับล่างที่ยังเป็น v อาเรย์เลิกเรียงทันที| เสาต้นที่ | หยิบหัวบล็อก | เรียงอยู่ | หยิบท้ายบล็อก | เรียงอยู่ |
|---|---|---|---|---|
| 1 | 1 0 0 0 0 | ใช่ | 0 1 0 0 0 | ไม่ |
| 2 | 1 1 1 0 0 | ใช่ | 0 2 1 0 0 | ไม่ |
| 3 | 2 2 1 0 0 | ใช่ | 0 3 2 0 0 | ไม่ |
| 4 | 2 2 1 1 0 | ใช่ | 0 3 2 1 0 | ไม่ |
| 5 | 3 2 2 2 0 | ใช่ | 0 4 3 2 0 | ไม่ |
| 6 | 3 3 3 2 1 | ใช่ | 0 4 4 3 1 | ไม่ |
| ค่ารวม | 10 | 15 |
0 1 0 0 0) และจบที่ 15
ทั้งสองฝั่งมาจากแบบจำลองของโค้ด C++ ทีละบรรทัด รวมการค้นแบบไบนารี
ทีนี้เขียนเป็นโค้ด ให้ a[1..H] คือจำนวนใบต่อระดับ ใบ K ใบของเสาต้นนี้
ต้องลง K ระดับที่มีใบน้อยที่สุด ซึ่งในอาเรย์ที่เรียงแล้วคือ K ระดับบนสุดของช่วง
v = a[H − K + 1] คือค่าที่มากที่สุดในกลุ่มนั้นl คือระดับแรกที่ค่าไม่เกิน v และ r คือระดับสุดท้ายที่ค่าอย่างน้อย v หาด้วยการค้นแบบไบนารีอย่างละครั้งr + 1 ถึง H ได้คนละใบ รวม H − r ใบK − (H − r) ใบ ลงระดับ l เป็นต้นไปใบของเสาหนึ่งต้นจึงลงเป็นช่วงติดกันไม่เกินสองช่วง
บวกทั้งช่วงด้วยต้นไม้เฟนวิก
d แทน a: บวก 1 ทั้งช่วง [x, y] คือ d[x] += 1 กับ d[y + 1] −= 1i คือผลรวม d[1] ถึง d[i]H ครึ่งได้จนเหลือ 1 ราว 17 ที่ H = 100,000)
ในโค้ด range คือบวกทั้งช่วง และ at(i) คืนจำนวนใบที่ระดับ i
ถ้ายังไม่คุ้น อ่าน ผลรวมสะสมกับต้นไม้เฟนวิก ก่อนได้
long long v = at(H - K + 1); // ค่าที่ระดับตัวที่ K นับจากท้ายช่วง [1..H]
// [l, r] = บล็อกของระดับที่มีค่าเท่ากับ v พอดี หาแบบไบนารีได้เพราะอาเรย์ไม่มีทางเพิ่มขึ้น
int below = H - r; // ระดับที่ค่าน้อยกว่า v อยู่ท้ายสุด ใส่ก่อนทั้งหมด
range(r + 1, H, 1);
int k2 = K - below; // ที่เหลือหยิบจาก "หัว" ของบล็อก ไม่ใช่ท้าย
if (k2 > 0) range(l, l + k2 - 1, 1); กดถัดไปเพื่อใส่ใบของเสาต้นถัดไป หรือกดเล่นให้มันเดินเอง
| ระดับ | 1 | 2 | 3 | 4 | 5 | ค่ารวม |
|---|---|---|---|---|---|---|
| จำนวนใบ | · | · | · | · | · | 0 |
ชุดทดสอบย่อยจำกัดจำนวนวิธีจัดใบเรือทั้งหมดไว้ที่หนึ่งล้านแบบ จึงไล่ทุกแบบตรง ๆ ได้
เสาต้นที่ i เลือกระดับได้ C(H, K) แบบ (จำนวนวิธีเลือก K ระดับจาก H ระดับ)
คูณกันทุกต้นแล้วไม่เกินหนึ่งล้าน
ข้อนี้มีสามวิธีที่ไล่จากช้าไปเร็ว การ์ดข้างล่างนับงานตามที่โค้ดของแต่ละวิธีทำ W คือจำนวนวิธีจัด และผลรวม K คือจำนวนใบเรือทั้งหมดของทุกเสา
วิธีที่ 1
ราว W × (H สูงสุด + 3 × ผลรวม H) ก้าว
ทุกวิธีจัดรวมแต้มทุกระดับหนึ่งรอบ (H สูงสุด ก้าว) และระหว่างทางแต่ละเสาวนช่องของมันสามรอบ (เลือกรูปถัดไป เพิ่มจำนวนใบ ถอนจำนวนใบ) วัดได้ 0.02 วินาทีบนชุดที่มี 762,048 วิธี (เสาสูงสุด 10)
ชุดย่อย 25 คะแนน เมื่อเสาไม่สูงมาก
วิธีที่ 2
ราว 2 × ผลรวม K × log H ก้าว
ใบละหยิบออกหนึ่งครั้งใส่กลับหนึ่งครั้ง ผลรวม K มากสุดคือ N × H = 1010
รวมราว 340,000 ล้านก้าว วัดได้ 1.62 วินาทีที่ผลรวม K = 25,032,928 ซึ่งเกินลิมิตแล้ว
ใช้ได้เมื่อใบรวมไม่เกินราวสิบล้านใบ
วิธีเต็ม
ราว N × (2(log H)2 + 5 log H) + N log N + H log H ≈ 70 ล้านก้าว
ต่อเสา: ค้นแบบไบนารีสองรอบ รอบละ log H ครั้ง ครั้งละถาม BIT หนึ่งครั้ง (log H ก้าว)
บวกถาม v หนึ่งครั้งกับบวกสองช่วง (สี่ครั้ง) แล้วบวกการเรียงเสากับลูปรวมแต้มท้าย
วัดได้ราว 80 มิลลิวินาที หน่วยความจำราว 5 MB
ทันที่ขอบเขตเต็ม
| วิธี | ชุดย่อย 25 คะแนน | ขอบเขตเต็ม |
|---|---|---|
| ไล่ทุกวิธีจัด | ได้ ถ้าเสาไม่สูงมาก | ไม่ทัน |
| ท่าโลภ + คิว | ขึ้นกับผลรวม K (ชุดนี้จำกัดแค่จำนวนวิธีจัด) | ไม่ทัน |
| ท่าโลภ + BIT | ได้ | ได้ |
K = H มีวิธีเดียวแต่สูงได้ถึง 100,000
สองวิธีแรกจึงอาจช้าในชุดนี้
เสามีได้ถึง 100,000 ต้น แต่ละต้นเติมได้ถึง 100,000 ใบ ไล่เติมทีละระดับจึงช้าเกิน ใบของเสาหนึ่งต้นลงไม่เกินสองช่วง โค้ดเต็มจึงเติมเป็นช่วงด้วย BIT ตามที่เล่าไว้ในเฉลย
ตรวจอะไรไปแล้วบ้าง
N = 100,000 ความสูงสุ่มถึง 100,000 ใช้ราว 80 มิลลิวินาที
สูงสุดราว 5 MB เทียบลิมิต 1 วินาที 64 MB
ทั้งหมดนี้คือการทดสอบ ไม่ได้พิสูจน์ และยังไม่ได้ส่งเกรดเดอร์จริง
ข้อโต้แย้งแลกเปลี่ยน (exchange argument) คือการดูคำตอบที่ดีที่สุดสักอัน แล้วลองย้ายการตัดสินใจหนึ่งครั้ง ถ้าการย้ายแบบหนึ่งลดแต้มได้เสมอ คำตอบที่ดีที่สุดย่อมไม่มีจุดแบบนั้น และเรารู้รูปของมันมากขึ้น
ใบที่ c + 1 ของระดับหนึ่งจับคู่ใหม่กับ c ใบที่อยู่ก่อน แต้มจึงเพิ่ม c
ลองย้ายใบหนึ่งจากระดับ p ลงระดับ q ที่อยู่ต่ำกว่า
แต้มลด cnt[p] − 1 (ใบนั้นเลิกจับคู่กับใบอื่นในระดับ p)
และเพิ่ม cnt[q] (มันจับคู่ใหม่กับทุกใบในระดับ q)
แต้มสุทธิเปลี่ยน cnt[q] − (cnt[p] − 1) ค่านี้ติดลบเมื่อ cnt[q] ≤ cnt[p] − 2
และเพราะเป็นจำนวนเต็ม แต้มจึงลดอย่างน้อย 1 นี่คือเหตุที่ใช้ cnt[p] − 2 เป็นเงื่อนไข
p มี cnt[p] ต้น ในนั้นมีใบที่ q ด้วยได้ไม่เกิน cnt[q] ต้น
จึงมีอย่างน้อยหนึ่งต้นที่มีใบที่ p แต่ระดับ q ยังว่าง
q แน่ เพราะ q อยู่ต่ำกว่า pคำตอบที่ดีที่สุดจึงมีระดับบนมากกว่าระดับล่างได้ไม่เกินหนึ่งใบ ท่าโลภสร้างรูปที่ผ่านเงื่อนไขนี้ เพราะระดับบนของมันไม่เคยมากกว่าระดับล่างเลย
เงื่อนไขนี้จำเป็น แต่ยังไม่พอบอกว่าท่าโลภได้ค่าที่น้อยที่สุดเสมอ ส่วนนั้นอาศัยการทดสอบในกรอบตรวจของหัวข้อโค้ด
คิวลำดับความสำคัญ (priority queue) คือที่เก็บของที่หยิบตัวที่ค่าน้อยที่สุดออกได้ครั้งละราว log ก้าว
ท่าโลภเขียนตรง ๆ ได้ด้วยมัน เก็บคู่ (จำนวนใบ, ระดับ) ไว้ในคิว จำนวนใบน้อยขึ้นก่อน เท่ากันเอาระดับล่าง
opened คือระดับสูงสุดที่ใส่ลงคิวแล้ว เสาที่สูงกว่านั้นเปิดระดับ opened + 1 ถึง H เพิ่มด้วยค่า 0k คู่ บวกจำนวนใบของระดับนั้น แล้วใส่กลับcntk ตัวก่อนแล้วค่อยใส่กลับ
ถ้าใส่กลับทันที ระดับเดิมอาจยังมีใบน้อยที่สุด แล้วเสาต้นเดียวได้สองใบในระดับเดียว
โค้ดนี้ทำงานใบละครั้ง จึงช้าเมื่อใบรวมถึงหลายสิบล้าน (ตัวเลขอยู่ในการ์ดเทียบวิธี)
// ท่าโลภของโจทย์ใบเรือ เขียนด้วยคิวลำดับความสำคัญ
// ไล่เสาจากเตี้ยไปสูง แต่ละเสาหย่อนใบลงระดับที่ "ว่างที่สุด" ในระยะที่มันเอื้อมถึง
// อินพุต: n แล้ว n บรรทัด h k เอาต์พุต: แต้มที่เสียน้อยที่สุด
#include <bits/stdc++.h>
using namespace std;
int main() {
int n;
if (scanf("%d", &n) != 1) return 0;
vector<pair<int, int>> m(n);
int H = 0;
for (int i = 0; i < n; i++) { scanf("%d %d", &m[i].first, &m[i].second); H = max(H, m[i].first); }
sort(m.begin(), m.end()); // เตี้ยก่อน เพราะเสาเตี้ยมีตัวเลือกน้อยกว่า
vector<int> cnt(H + 1, 0); // cnt[l] = จำนวนใบที่ระดับ l (ระดับนับ 1..H)
// คิวเก็บ (จำนวนใบตอนนี้, ระดับ) โดยเอาน้อยสุดขึ้นก่อน ค่าเท่ากันเอาระดับล่างก่อน
priority_queue<pair<int, int>, vector<pair<int, int>>, greater<pair<int, int>>> pq;
int opened = 0; // ระดับที่ปล่อยให้ใช้ได้แล้ว
for (auto [h, k] : m) {
while (opened < h) { ++opened; pq.push({cnt[opened], opened}); }
// หยิบ k ระดับที่มีใบน้อยที่สุดออกมา ตอนเริ่มเสาแต่ละต้น ทุกระดับที่เปิดแล้วมีในคิวพอดีหนึ่งรายการ ค่าตรงกับ cnt
vector<int> picked;
picked.reserve(k);
for (int t = 0; t < k; t++) {
auto [c, lv] = pq.top();
pq.pop();
cnt[lv] = c + 1;
picked.push_back(lv);
}
// ใส่กลับหลังหยิบครบ k ตัว ถ้าใส่กลับทันที เสาต้นเดียวอาจหยิบระดับเดิมซ้ำ
for (int lv : picked) pq.push({cnt[lv], lv});
}
long long cost = 0;
for (int l = 1; l <= H; l++) cost += (long long)cnt[l] * (cnt[l] - 1) / 2;
printf("%lld\n", cost);
return 0;
} c ใบคือ c × (c − 1) / 2บทเรียนที่ยกไปใช้ได้: กฎตอนเสมอที่ไม่เปลี่ยนคำตอบ อาจยังเป็นสิ่งที่โค้ดชั้นในพึ่งพาอยู่
ในหน้านี้