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 × (c − 1) / 2 มีคุณสมบัติหนึ่งที่ตัดสินทั้งข้อ คือส่วนต่างของมันเวลาเพิ่มขึ้นทีละหนึ่ง
เท่ากับ c พอดี พูดง่าย ๆ คือยิ่งกองไหนสูง การเติมใบลงกองนั้นยิ่งแพง
แล้วถ้าเราจะไล่ใส่เสาทีละต้น ควรไล่จากเสาต้นไหนก่อน และตอนที่หลายระดับมีจำนวนใบเท่ากันเป๊ะ การเลือกระดับล่างกับระดับบนต่างกันไหม
สามเสาเตี้ย ๆ พอให้จับทางว่าอะไรทำให้เสียแต้ม
คลิกช่องบนเสาเพื่อแขวนใบเรือ แต่ละเสาต้องแขวนให้ครบตามจำนวนที่โจทย์กำหนด
แต้มตอนนี้ 0 · แขวนแล้ว 0 จาก 0 ใบ
แต้มของหนึ่งระดับคือจำนวนคู่ของใบเรือที่อยู่ระดับนั้น สองใบเสียหนึ่งแต้ม สามใบเสียสามแต้ม
ลองสังเกตว่าตอนไหนที่การย้ายใบเรือหนึ่งใบลงไปอีกระดับ ทำให้แต้มลดลง แล้วเงื่อนไขของจังหวะนั้นคืออะไร
ที่มาของแนวคิดนี้
ผมเริ่มจากตัดสิ่งที่ดูเหมือนสำคัญแต่ไม่สำคัญออกก่อน โจทย์พูดถึงหัวเรือท้ายเรือเยอะมาก แต่พอเขียนผลรวมออกมาก็เห็นว่าลำดับหัวท้ายไม่มีผลกับคำตอบเลย เหลือแค่จำนวนใบต่อระดับ ตรงนี้ทำให้โจทย์เล็กลงครึ่งหนึ่งทันที
จากนั้นผมถามคำถามที่ง่ายกว่าก่อน คือถ้าเสาสูงเท่ากันหมดจะทำยังไง ซึ่งตอบได้ทันทีว่ากระจายให้เท่ากันที่สุด แล้วค่อยถามว่าอะไรคือสิ่งเดียวที่ทำให้โจทย์จริงยากกว่านั้น คำตอบคือเสาแต่ละต้นมีสิทธิ์ไม่เท่ากัน พอตั้งคำถามได้แบบนี้ กฎ "ให้คนที่มีทางเลือกน้อยที่สุดได้เลือกก่อน" ก็โผล่มาเอง
เริ่มจากคำถามที่ง่ายกว่า สมมติเสาทุกต้นสูงเท่ากันหมด คำตอบชัดเจนว่าต้องกระจายให้เท่ากันที่สุด
เพราะ c × (c − 1) / 2 เป็นฟังก์ชันนูน การย้ายใบจากกองสูงไปกองต่ำลดผลรวมได้เสมอ
ความยากจริงอยู่ที่เสาสูงไม่เท่ากัน เสาเตี้ยเลือกได้แค่ระดับล่าง ส่วนเสาสูงเลือกได้ทุกระดับ
ดังนั้นเสาเตี้ยมีสิทธิ์น้อยกว่า ต้องได้เลือกก่อน เรียงเสาตามความสูงจากน้อยไปมาก
แล้วไล่ใส่ทีละต้น ต้นละ K ใบ ลงระดับที่ตอนนี้มีใบน้อยที่สุด
ท่านี้ทำให้เกิดคุณสมบัติที่สวยมากอย่างหนึ่งคือ อาเรย์จำนวนใบต่อระดับจะไม่มีทางเพิ่มขึ้นตามระดับ ระดับล่างมีใบมากกว่าหรือเท่ากับระดับบนเสมอ เพราะเสาทุกต้นที่แตะระดับบนได้ ก็แตะระดับล่างได้อยู่แล้ว คุณสมบัตินี้แหละที่ทำให้เราค้นหาแบบไบนารีบนอาเรย์นี้ได้ และเป็นกุญแจของโค้ดที่เร็ว
ผมแก้ถูกตั้งแต่แรก แต่เข้าใจเหตุผลผิดอยู่นาน
โค้ดรอบแรกของผมตอบ 15 แทนที่จะเป็น 10 ผมไล่ดูแล้วเจอว่ามันหยิบท้ายบล็อกที่ค่าเสมอกัน พอเปลี่ยนเป็นหยิบหัวบล็อกก็ได้ 10 ทันที ผมจึงเขียนลงบทความว่า "กฎเสมอผิดทำให้คำตอบผิด" แล้วทำตารางเทียบสองกฎเพื่อโชว์ตัวเลขที่ต่างกัน
ตารางนั้นออกมา 10 เท่ากันทั้งสองฝั่ง ผมงงอยู่พักหนึ่ง แล้วเขียนสคริปต์สุ่มสองแสนชุด เพื่อหาอินพุตสักชุดที่กฎเสมอทำให้ค่ารวมต่างกัน หาไม่เจอสักชุดเดียว เพราะการหยิบระดับไหนในบล็อกที่ค่าเท่ากัน ได้กองชุดเดิม แค่สลับที่กัน ผลรวมจึงเท่ากันเสมอ
ของจริงคือกฎเสมอไปทำลายความเป็นระเบียบของอาเรย์ ซึ่งเป็นสิ่งที่โค้ดชั้นในพึ่งพา คำตอบที่ผิดในโค้ดรอบแรกจึงไม่ได้มาจากการเลือกใบผิด แต่มาจากการค้นแบบไบนารีที่ตอบมั่วในก้าวถัดมา ถ้าผมไม่ได้ลองทำตารางเทียบ ผมคงเผยแพร่คำอธิบายที่ผิดไปแล้ว
สมมติกำลังจะใส่ K ใบลงในช่วง 1 ถึง H ระดับที่ค่าน้อยกว่าค่าอื่นชัดเจนต้องใส่แน่นอน
แต่พอมาถึงบล็อกที่ค่าเท่ากันหมด แล้วเราต้องเลือกใส่แค่บางระดับในบล็อกนั้น จะเลือกอันไหน
ถ้ามองแค่ผลรวมอย่างเดียว การหยิบระดับไหนในบล็อกก็ให้ค่าเท่ากัน เพราะกองที่ได้ออกมาเป็นชุดเดิม แค่สลับที่กัน ประเด็นจึงไม่ได้อยู่ที่ค่า แต่อยู่ที่ความเป็นระเบียบของอาเรย์ หยิบระดับบนเมื่อไร อาเรย์ก็เลิกเป็นแบบไม่เพิ่มขึ้นทันที
และเมื่ออาเรย์เลิกเป็นระเบียบ การค้นแบบไบนารีที่ใช้หาขอบบล็อกในก้าวถัดไปก็ตอบมั่ว บักจึงไม่ได้โผล่ตรงที่เราเขียนผิด แต่ไปโผล่ในก้าวหลัง ๆ ซึ่งเป็นบักประเภทที่หายากที่สุด
| ลำดับ | หยิบหัวบล็อก | เป็นระเบียบ | หยิบท้ายบล็อก | เป็นระเบียบ |
|---|---|---|---|---|
| 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 ซึ่งเพิ่มขึ้นตามระดับ
พอถึงเสาต้นหลัง ๆ การค้นขอบบล็อกก็เลยผิด และค่ารวมจบที่ 15 แทนที่จะเป็น 10
ตัวเลขทั้งสองฝั่งคำนวณสดจากโค้ดชุดเดียวกัน ต่างกันแค่บรรทัดเดียว
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) แบบ คูณกันทุกต้นแล้วไม่เกินหนึ่งล้าน
ค่าของท่านี้ไม่ได้อยู่ที่คะแนน 25 คะแนน แต่อยู่ที่มันเป็นตัวตรวจสอบ เวลาเราคิดท่าโลภได้แล้วแต่ยังไม่แน่ใจว่ากฎเสมอถูกทางไหม การสุ่มอินพุตเล็ก ๆ มาเทียบกับตัวไล่ทุกแบบ คือวิธีที่เร็วที่สุดในการรู้ว่าเราคิดผิด
ผมสุ่มเรือเล็ก ๆ ที่มีเสาไม่เกินสี่ต้น สูงไม่เกินห้าช่อง จำนวน 300 ลำ มาเทียบสองโปรแกรมนี้ ตรงกันหมด ส่วนเวอร์ชันที่ใช้กฎเสมอผิดหลุดตั้งแต่ลำแรก ๆ
| ทาง | ขอบเขตที่มันไหว | งานคร่าว ๆ |
|---|---|---|
| ไล่ทุกวิธีจัด | จำนวนวิธีรวมไม่เกินหนึ่งล้าน | 1,000,000 แบบ |
| ท่าโลภกับคิวลำดับความสำคัญ | N ถึง 100,000 | 1,700,000 ครั้ง |
เสามีได้ถึง 100,000 ต้น และแต่ละต้นต้องเติมใบได้ถึง 100,000 ใบ จะไล่เติมทีละระดับไม่ไหว แต่เพราะเราพิสูจน์แล้วว่าเติมเป็นช่วงติดกันสองช่วงเสมอ (ช่วงท้ายที่ค่าน้อยกว่า กับหัวของบล็อกที่ค่าเท่ากัน) จึงใช้ต้นไม้เฟนวิกแบบบวกทั้งช่วงถามทีละจุดได้พอดี
การหาขอบซ้ายขอบขวาของบล็อกใช้การค้นแบบไบนารีบนอาเรย์ที่ไม่มีทางเพิ่มขึ้น ซึ่งเป็นคุณสมบัติที่ ท่าโลภรักษาไว้ให้เอง ถ้ากฎเสมอเขียนผิด นอกจากคำตอบจะผิดแล้ว การค้นแบบไบนารีก็จะเลิกมีความหมายไปด้วย บักสองชั้นซ้อนกันแบบนี้หายากมากถ้าไม่มีตัวตรวจสอบ
เวลาที่วัดได้บนเครื่องผม เรือ 100,000 ต้น เสาสูงสุ่มถึง 100,000 ใช้เวลา 124 มิลลิวินาที จากลิมิตหนึ่งวินาที
เครื่องมือหลักของข้อนี้คือต้นไม้เฟนวิก ถ้ายังไม่คุ้น ไปอ่าน ผลรวมสะสมกับต้นไม้เฟนวิก ก่อนได้
หัวข้อก่อนหน้าบอกว่าท่าโลภของข้อนี้ได้คำตอบ แต่ยังไม่ได้บอกว่าทำไมมันถึงถูก ซึ่งเป็นคำถามที่ต้องตอบทุกครั้งที่ใช้ท่าโลภ เพราะท่าโลภส่วนใหญ่ผิด การที่ตัวอย่างในโจทย์ผ่านไม่ได้เป็นหลักฐานอะไรเลย
วิธีพิสูจน์ท่าโลภที่ใช้ได้กว้างที่สุดเรียกว่า ข้อโต้แย้งแลกเปลี่ยน (exchange argument) รูปของมันคือ สมมติว่ามีคำตอบที่ดีที่สุดอยู่แล้ว แล้วแสดงว่าเราแก้มันให้กลายเป็นคำตอบของท่าโลภได้ โดยไม่ทำให้แย่ลง ถ้าทำได้ ท่าโลภก็ดีที่สุดด้วย
แต้มที่เสียของระดับหนึ่งคือจำนวนคู่ของใบเรือที่อยู่ระดับเดียวกัน ซึ่งเท่ากับ
c(c−1)/2 เมื่อระดับนั้นมีใบอยู่ c ใบ สิ่งที่ต้องสังเกตคือ
ต้นทุนของการเพิ่มใบที่ c + 1 คือ c พอดี
คือยิ่งระดับนั้นแน่นอยู่แล้ว ใบใหม่ยิ่งแพง เรียกคุณสมบัตินี้ว่าต้นทุนส่วนเพิ่มไม่ลด
ทีนี้สมมติมีคำตอบที่ดีที่สุดอันหนึ่ง ที่ไม่ได้ทำตามท่าโลภ นั่นแปลว่ามีเสาต้นหนึ่ง
ที่วางใบลงระดับ p ทั้งที่มีระดับ q ที่มันเอื้อมถึงและว่างกว่า
(คือ cnt[q] < cnt[p]) ลองย้ายใบนั้นจาก p ไป q
ต้นทุนที่ลดลงคือ cnt[p] − 1 และต้นทุนที่เพิ่มขึ้นคือ cnt[q]
เพราะ cnt[q] ≤ cnt[p] − 1 การย้ายนี้จึงไม่ทำให้แย่ลงเลย
ทำซ้ำไปเรื่อย ๆ ทุกครั้งที่เจอคู่ที่ผิดแบบนั้น จนไม่มีคู่ไหนเหลือ สิ่งที่ได้คือคำตอบที่ทำตามท่าโลภพอดี และมันไม่แย่กว่าคำตอบที่ดีที่สุดที่เราเริ่มด้วย ท่าโลภจึงดีที่สุด ส่วนเหตุผลที่ต้องไล่จากเสาเตี้ยไปเสาสูง อยู่ที่เสาเตี้ยมีตัวเลือกน้อยกว่า ถ้าให้เสาสูงเลือกก่อน มันอาจไปนั่งทับระดับล่างที่เสาเตี้ยจำเป็นต้องใช้ ทั้งที่ตัวมันเองมีทางอื่น
พอรู้ว่ากฎคือ "หย่อนลงระดับที่ว่างที่สุดที่เอื้อมถึง" ก็เขียนได้อีกแบบที่ตรงกับประโยคนั้นคำต่อคำ
คือเก็บระดับที่เปิดใช้ได้แล้วไว้ในคิวลำดับความสำคัญ ที่เอาระดับที่ว่างที่สุดขึ้นก่อน
แล้วหยิบออกมาทีละ k ตัวต่อเสา
มีรายละเอียดหนึ่งที่พลาดง่าย ค่าที่อยู่ในคิวล้าสมัยได้ เพราะเราแก้จำนวนใบของระดับหนึ่ง หลังจากใส่มันลงคิวไปแล้ว ท่าแก้ที่ง่ายที่สุดคือตรวจตอนหยิบ ถ้าค่าที่ติดมากับรายการไม่ตรงกับค่าจริงในตอนนี้ ให้ทิ้งรายการนั้นแล้วหยิบใหม่
// ท่าโลภของโจทย์ใบเรือ เขียนด้วยคิวลำดับความสำคัญ
// ไล่เสาจากเตี้ยไปสูง แต่ละเสาหย่อนใบลงระดับที่ "ว่างที่สุด" ในระยะที่มันเอื้อมถึง
// อินพุต: 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 ระดับที่ว่างที่สุดออกมา แล้วใส่กลับพร้อมค่าที่เพิ่มขึ้น
vector<int> picked;
picked.reserve(k);
for (int t = 0; t < k; t++) {
// ค่าในคิวอาจเก่า ถ้าไม่ตรงกับ cnt จริงให้ทิ้งแล้วหยิบใหม่
while (!pq.empty() && pq.top().first != cnt[pq.top().second]) pq.pop();
auto [c, lv] = pq.top();
pq.pop();
cnt[lv] = c + 1;
picked.push_back(lv);
}
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;
} บทเรียนที่ยกไปใช้ได้คือ ท่าโลภที่ไม่มีข้อโต้แย้งแลกเปลี่ยนรองรับ คือท่าที่ยังไม่ได้พิสูจน์ และคำถามที่ใช้เริ่มพิสูจน์ได้เกือบทุกครั้งคือ "ถ้าสลับสองการตัดสินใจในคำตอบที่ดีที่สุด มันแย่ลงหรือไม่" ถ้าตอบได้ว่าไม่แย่ลง ท่าโลภก็ถูก ถ้าหาคู่ที่สลับแล้วดีขึ้นได้ ท่าโลภผิด และคู่นั้นคืออินพุตที่ทำให้มันตก
เมื่อราคาของกองหนึ่งเป็นฟังก์ชันนูน การกระจายให้เท่ากันที่สุดคือคำตอบเสมอ ที่เหลือคือให้คนที่มีทางเลือกน้อยที่สุดได้เลือกก่อน และตอนเสมอกันให้หยิบฝั่งที่รักษาความเป็นระเบียบของอาเรย์ไว้
ในหน้านี้