programming.in.th · ข้อ 2015

ใบเรือโจรสลัด: แจกใบเรือลงระดับให้บังลมกันน้อยที่สุด

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

★★★★☆ greedyBITbinary search อ่าน 12 นาที เผยแพร่ 7 กันยายน 2026 อัปเดต 20 ตุลาคม 2026

โจทย์ · ใบเรือที่บังลมกันเอง

เรือโจรสลัดลำใหม่มีเสา N ต้น เรียงจากหัวเรือไปท้ายเรือ เสาต้นที่ i สูง H ช่อง และต้องติดใบเรือ K ใบ หนึ่งช่องติดได้ไม่เกินหนึ่งใบ เราเลือกเองได้ว่าจะติดใบไหนที่ระดับไหนของเสาต้นนั้น

ใบเรือที่อยู่ข้างหน้าจะบังลมใบที่อยู่ข้างหลังในระดับเดียวกัน โจทย์นิยาม ความไร้ประสิทธิภาพ ของใบหนึ่งใบว่าคือจำนวนใบที่อยู่ข้างหน้ามันในระดับเดียวกัน คือจำนวนใบที่บังลมมัน

งานของเราคือจัดใบเรือให้ผลรวมความไร้ประสิทธิภาพของทุกใบน้อยที่สุด

สังเกตจุดหนึ่งที่ทำให้โจทย์ง่ายลงทันที ถ้าระดับหนึ่งมีใบเรืออยู่ c ใบ ผลรวมของระดับนั้นคือ จำนวนคู่ที่จับได้จาก c ใบ คือ c × (c − 1) / 2

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

คำถามจึงเหลือแค่ แจกใบเรือลงระดับต่าง ๆ โดยเสาต้นที่ i แจกได้เฉพาะระดับ 1 ถึง H ระดับละไม่เกินหนึ่งใบ ทำยังไงให้ผลรวมของ c × (c − 1) / 2 ทุกระดับน้อยที่สุด

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

EXAMPLE
InputOutput
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 2 3 ใบ · 3 3 3 ใบ · 3 4 2 ใบ · 1 5 1 ใบ · 0 รวม 10
ผลหลังวางใบเรือครบทุกเสาของตัวอย่าง ไล่เสาสูง 2, 3, 3, 4, 4, 5 ที่ต้องการใบ 1, 2, 2, 1, 3, 3 ใบตามลำดับ ช่องเขียวคือใบเรือที่ระดับนั้นรับไป ตัวเลขขวามือคือจำนวนใบกับแต้มที่เสียของระดับนั้น รวม 10 แต้ม (วาดประกอบโดยผู้เขียน)

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

กฎตอนค่าเสมอกัน

เรียกระดับที่อยู่ติดกันและมีจำนวนใบเท่ากันว่า บล็อก เช่นหลังใส่เสาต้นที่ 5 ของตัวอย่าง จำนวนใบคือ 3 2 2 2 0 ระดับ 2 ถึง 4 เป็นบล็อกของค่า 2

  • หัวบล็อก คือระดับล่างสุดของบล็อก (ระดับ 2)
  • ท้ายบล็อก คือระดับบนสุดของบล็อก (ระดับ 4)

ผมแก้ถูกตั้งแต่แรก แต่เข้าใจเหตุผลผิดอยู่นาน

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

ตารางเทียบสองกฎด้วยท่าโลภแบบนับทีละใบออกมา 10 ทั้งสองฝั่ง สุ่มเพิ่มอีก 200,000 ชุดก็หาชุดที่ค่ารวมต่างกันไม่เจอ

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

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

ทำไมหยิบหัวบล็อกแล้วอาเรย์ยังเรียงอยู่ ก่อนใส่ใบของเสาต้นหนึ่ง ช่วง 1 ถึง H แบ่งได้เป็นสามส่วน ระดับที่มีใบมากกว่า v บล็อกของค่า v (ระดับ l ถึง r) และระดับ r + 1 ถึง H ที่มีใบน้อยกว่า v

  • ระดับ r + 1 ถึง H ได้คนละใบ แต่ละระดับยังไม่เกิน v
  • ใบที่เหลือลงหัวบล็อก ระดับเหล่านั้นกลายเป็น v + 1
  • รอยต่อล่าง: ระดับ l − 1 มีอย่างน้อย v + 1 อยู่แล้ว จึงไม่น้อยกว่าระดับ l
  • ในบล็อก: ส่วนที่ได้ใบ (v + 1) อยู่ล่าง ส่วนที่ไม่ได้ (v) อยู่บน
  • รอยต่อบน: ระดับ r จบที่ v หรือ v + 1 ส่วนระดับ r + 1 จบที่ไม่เกิน v
  • ถ้าหยิบท้ายบล็อกแทน ระดับบนของบล็อกเป็น v + 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
แต่ละช่องคือจำนวนใบของระดับ 1 ถึง 5 จากซ้ายไปขวา "เรียงอยู่" คือเรียงจากมากไปน้อย ฝั่งขวาเลิกเรียงตั้งแต่เสาต้นที่ 1 (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] −= 1
  • จำนวนใบที่ระดับ i คือผลรวม d[1] ถึง d[i]
  • ต้นไม้เฟนวิก (Fenwick tree หรือ BIT) ทำทั้งสองอย่างได้ครั้งละราว log H ก้าว (log H คือจำนวนครั้งที่หาร 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);

ไล่ทีละเสาบนตัวอย่าง

กดถัดไปเพื่อใส่ใบของเสาต้นถัดไป หรือกดเล่นให้มันเดินเอง

จำนวนใบเรือของแต่ละระดับ หลังใส่เสาต้นที่กำลังดูอยู่
ระดับ 12345 ค่ารวม
จำนวนใบ ····· 0

อีกมุมหนึ่ง: ไล่ทุกวิธีจัด (ชุดทดสอบย่อย 25 คะแนน)

ชุดทดสอบย่อยจำกัดจำนวนวิธีจัดใบเรือทั้งหมดไว้ที่หนึ่งล้านแบบ จึงไล่ทุกแบบตรง ๆ ได้ เสาต้นที่ i เลือกระดับได้ C(H, K) แบบ (จำนวนวิธีเลือก K ระดับจาก H ระดับ) คูณกันทุกต้นแล้วไม่เกินหนึ่งล้าน

ดูโค้ดของชุดทดสอบย่อย
sails_sub.cpp
// เฉลยชุดทดสอบย่อย จำนวนวิธีจัดใบเรือทั้งหมดไม่เกินหนึ่งล้านแบบ (25 คะแนน)
// ไล่ทุกวิธีจัดจริง ๆ แล้วเก็บค่าที่น้อยที่สุด ไม่ต้องรู้อะไรเรื่องความโลภเลย
#include <bits/stdc++.h>
using namespace std;
int n, MX;
vector<int> H, K, cnt;
long long best = LLONG_MAX;

void rec(int i) {
    if (i == n) {
        long long v = 0;
        for (int c : cnt) v += 1LL * c * (c - 1) / 2;      // คู่ที่อยู่ระดับเดียวกัน
        best = min(best, v);
        return;
    }
    vector<int> pick(H[i], 0);
    fill(pick.begin(), pick.begin() + K[i], 1);
    sort(pick.begin(), pick.end());                        // เริ่มจากรูปแบบที่เล็กที่สุดของ next_permutation
    do {
        for (int h = 0; h < H[i]; h++) if (pick[h]) cnt[h]++;
        rec(i + 1);
        for (int h = 0; h < H[i]; h++) if (pick[h]) cnt[h]--;
    } while (next_permutation(pick.begin(), pick.end()));
}

int main() {
    scanf("%d", &n);
    H.resize(n); K.resize(n); MX = 0;
    for (int i = 0; i < n; i++) { scanf("%d %d", &H[i], &K[i]); MX = max(MX, H[i]); }
    cnt.assign(MX, 0);
    rec(0);
    printf("%lld\n", best);
}

ข้อนี้มีสามวิธีที่ไล่จากช้าไปเร็ว การ์ดข้างล่างนับงานตามที่โค้ดของแต่ละวิธีทำ 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 ซึ่งเกินลิมิตแล้ว

ใช้ได้เมื่อใบรวมไม่เกินราวสิบล้านใบ

วิธีเต็ม

ท่าโลภ + เติมเป็นช่วงด้วย BIT

ราว 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 สองวิธีแรกจึงอาจช้าในชุดนี้

โค้ด C++

เสามีได้ถึง 100,000 ต้น แต่ละต้นเติมได้ถึง 100,000 ใบ ไล่เติมทีละระดับจึงช้าเกิน ใบของเสาหนึ่งต้นลงไม่เกินสองช่วง โค้ดเต็มจึงเติมเป็นช่วงด้วย BIT ตามที่เล่าไว้ในเฉลย

ดูโค้ดเต็ม
sails.cpp
#include <bits/stdc++.h>
using namespace std;
int MX; vector<long long> bit;                 // BIT แบบ range-update / point-query
void upd(int i,long long v){ for(;i<=MX;i+=i&-i) bit[i]+=v; }
void range(int l,int r,long long v){ if(l>r) return; upd(l,v); upd(r+1,-v); }
long long at(int i){ long long s=0; for(;i>0;i-=i&-i) s+=bit[i]; return s; }
int main(){
    int n; if(scanf("%d",&n)!=1) return 0;
    vector<pair<int,int>> m(n);
    for(auto&p:m) scanf("%d %d",&p.first,&p.second);
    sort(m.begin(),m.end());
    MX=m.back().first; bit.assign(MX+2,0);
    for(auto&[H,K]:m){
        long long v=at(H-K+1);
        // [l,r] = ช่วงใน [1..H] ที่ค่าเท่ากับ v พอดี (อาเรย์ไม่เพิ่ม จึงค้นแบบไบนารีได้)
        int l=1,r=H,lo,hi;
        lo=1; hi=H; int first=H+1;
        while(lo<=hi){ int md=(lo+hi)/2; if(at(md)<=v){ first=md; hi=md-1; } else lo=md+1; }
        l=first;
        lo=1; hi=H; int last=0;
        while(lo<=hi){ int md=(lo+hi)/2; if(at(md)>=v){ last=md; lo=md+1; } else hi=md-1; }
        r=last;
        int below=H-r;                 // ช่องที่ค่าน้อยกว่า v อยู่ท้ายสุด เติมก่อนทั้งหมด
        range(r+1,H,1);
        int k2=K-below;                // ที่เหลือหยิบจาก "หัว" ของบล็อกค่าเท่ากับ v
        if(k2>0) range(l,l+k2-1,1);
    }
    long long ans=0;
    for(int i=1;i<=MX;i++){ long long c=at(i); ans+=c*(c-1)/2; }
    printf("%lld\n",ans);
}
โค้ดส่วนไหนทำขั้นไหน
ส่วนในโค้ดทำอะไร
upd, range, atBIT เก็บผลต่าง: range บวกทั้งช่วง at(i) คืนจำนวนใบที่ระดับ i
sort(m)เรียงเสาเตี้ยก่อน
v = at(H-K+1)ค่าที่มากที่สุดในกลุ่ม K ระดับที่มีใบน้อยที่สุด
ค้นไบนารีรอบแรกl ระดับแรกที่ค่าไม่เกิน v (หัวบล็อก)
ค้นไบนารีรอบสองr ระดับสุดท้ายที่ค่าอย่างน้อย v (ท้ายบล็อก)
range(r+1,H,1)ระดับที่ค่าน้อยกว่า v ได้ใบทุกระดับ
range(l,l+k2-1,1)ใบที่เหลือลงหัวบล็อก (กฎตอนเสมอ)
ลูปท้ายรวม c × (c − 1) / 2 ทุกระดับ
ดูโค้ดที่ผมเขียนผิดรอบแรก และเคสที่เล็กที่สุดที่มันตอบผิด

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

sails_wrong.cpp · เฉพาะท่อนที่เลือกระดับในบล็อก
int below = H - r;                     // ระดับที่ค่าน้อยกว่า v อยู่ท้ายสุด ใส่ก่อนทั้งหมด
range(r + 1, H, 1);
int k2 = K - below;
if (k2 > 0) range(r - k2 + 1, r, 1);   // <-- บรรทัดที่ผิด หยิบจากท้ายบล็อก
                                       // ของจริงคือ range(l, l + k2 - 1, 1)

เคสที่เล็กที่สุดที่เวอร์ชันนี้ตอบผิดคือเสาสูง 2 สองต้น ต้นละหนึ่งใบ

INPUT
2
2 1
2 1
  • เสาต้นแรก: ระดับ 1 กับ 2 มีค่าเท่ากัน กฎที่ถูกหยิบระดับล่าง ได้ 1 0 กฎที่ผิดหยิบระดับบน ได้ 0 1 ซึ่งเลิกเรียงแล้ว
  • เสาต้นที่สอง: v = 1 การค้นหาระดับสุดท้ายที่ค่าอย่างน้อย v ดูระดับ 1 ก่อน เจอค่าน้อยกว่า v จึงค้นต่อทางล่างซึ่งไม่มีระดับเหลือ ได้ r = 0
  • below = H − r = 2 โค้ดจึงบวกใบให้ทั้งสองระดับ ส่วน k2 = K − below ติดลบ บรรทัด if (k2 > 0) ข้ามการบวกช่วงที่สองไป (range เองก็คืนทันทีเมื่อ l > r) โปรแกรมจึงทำงานต่อได้ตามปกติ
  • ผลคือ 1 2 รวม 3 ใบ ทั้งที่มีแค่ 2 ใบ

คำตอบจริงคือ 0 (จากตัวไล่ทุกวิธีจัด) เวอร์ชันที่ผิดตอบ 1

ตรวจอะไรไปแล้วบ้าง

  • ตัวอย่างในโจทย์: โค้ดเต็ม โค้ดชุดย่อย โค้ดคิว (หัวข้อถัดไป) และตัวไล่ทุกวิธีจัดตอบ 10 ส่วนโค้ดที่ผิดตอบ 15
  • สุ่ม 500 ชุด เสา 2 ถึง 5 ต้น สูงไม่เกิน 5: โค้ดเต็มและโค้ดคิวตรงกับตัวไล่ทุกวิธีจัดทุกชุด
  • ไล่ครบทุกชุดเสา 2 ถึง 4 ต้น สูงไม่เกิน 5 แบบไม่นับลำดับเสา (ลำดับเสาในอินพุตไม่มีผลกับคำตอบ) รวม 3,860 ชุด: โค้ดเต็มและโค้ดคิวตรงกับตัวไล่ทุกวิธีจัดทุกชุด โค้ดที่ผิดตอบผิด 2,299 ชุด
  • โค้ดชุดย่อยใช้วิธีเดียวกับตัวไล่ทุกวิธีจัด จึงเทียบกับโค้ดเต็มแทนบนชุดเดียวกันนี้ ตรงทุกชุด
  • เวลาและหน่วยความจำ: 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 เป็นเงื่อนไข

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

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

เขียนอีกแบบ · คิวลำดับความสำคัญ

คิวลำดับความสำคัญ (priority queue) คือที่เก็บของที่หยิบตัวที่ค่าน้อยที่สุดออกได้ครั้งละราว log ก้าว

ท่าโลภเขียนตรง ๆ ได้ด้วยมัน เก็บคู่ (จำนวนใบ, ระดับ) ไว้ในคิว จำนวนใบน้อยขึ้นก่อน เท่ากันเอาระดับล่าง

โค้ดนี้ทำงานใบละครั้ง จึงช้าเมื่อใบรวมถึงหลายสิบล้าน (ตัวเลขอยู่ในการ์ดเทียบวิธี)

sails_pq.cpp
// ท่าโลภของโจทย์ใบเรือ เขียนด้วยคิวลำดับความสำคัญ
// ไล่เสาจากเตี้ยไปสูง แต่ละเสาหย่อนใบลงระดับที่ "ว่างที่สุด" ในระยะที่มันเอื้อมถึง
// อินพุต: 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;
}
ตัวตรวจ: ไล่ทุกวิธีจัด

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

brute_all_arrangements.cpp
// ตัวตรวจอิสระของท่าโลภใบเรือ: ไล่ทุกวิธีจัดจริง
// ไม่รู้จักคำว่า "ว่างที่สุด" หรือลำดับเตี้ยไปสูงเลย จึงตรวจข้ออ้างของท่าโลภได้จริง
#include <bits/stdc++.h>
using namespace std;

int n, H;
vector<pair<int, int>> m;
vector<int> cnt;
long long best;

long long costNow() {
    long long c = 0;
    for (int l = 1; l <= H; l++) c += (long long)cnt[l] * (cnt[l] - 1) / 2;
    return c;
}

/** เลือก k ระดับจาก 1..h แบบไล่ทุกชุด */
void choose(int mast, int start, int left) {
    if (left == 0) {
        if (mast + 1 == n) best = min(best, costNow());
        else {
            int h = m[mast + 1].first, k = m[mast + 1].second;
            (void)h;
            choose(mast + 1, 1, k);
        }
        return;
    }
    int h = m[mast].first;
    for (int lv = start; lv <= h; lv++) {
        cnt[lv]++;
        choose(mast, lv + 1, left - 1);
        cnt[lv]--;
    }
}

int main() {
    if (scanf("%d", &n) != 1) return 0;
    m.resize(n);
    H = 0;
    for (int i = 0; i < n; i++) { scanf("%d %d", &m[i].first, &m[i].second); H = max(H, m[i].first); }
    cnt.assign(H + 1, 0);
    best = LLONG_MAX;
    choose(0, 1, m[0].second);
    printf("%lld\n", best);
    return 0;
}

ทดสอบตัวเองและสรุป

เสาสูง 3 สามต้น ต้นละ 2 ใบ คำตอบเท่าไร

ใบรวม 6 ใบ มี 3 ระดับ กระจายให้เท่ากันที่สุดได้ 2 2 2 แต่ละระดับเสีย 1 แต้ม รวม 3

จำนวนใบตอนนี้คือ 3 2 2 2 0 เสาต้นถัดไปสูง 5 ต้องการ 2 ใบ ค่า v, l, r คืออะไร และใบลงระดับไหน

v = a[4] = 2 บล็อกของค่า 2 คือระดับ 2 ถึง 4 ระดับ 5 ถึง 5 ได้คนละใบ (1 ใบ) ที่เหลือ 1 ใบลงหัวบล็อกคือระดับ 2 ได้ 3 3 2 2 1 ซึ่งยังเรียงจากมากไปน้อย

โค้ดคิวลำดับความสำคัญถูก ทำไมไม่ทันที่ขอบเขตเต็ม

มันทำงานใบละครั้ง และใบรวมมีได้ถึง N × H = 1010 ใบ ส่วนโค้ด BIT ทำงานเสาละครั้ง

บทเรียนที่ยกไปใช้ได้: กฎตอนเสมอที่ไม่เปลี่ยนคำตอบ อาจยังเป็นสิ่งที่โค้ดชั้นในพึ่งพาอยู่

แหล่งที่มา

  1. โจทย์ Sails บน programming.in.th ข้อ 2015 programming.in.th/tasks/2015 (สืบค้น 6 กันยายน 2026)
  2. ต้นทางของโจทย์คือ 19th International Olympiad in Informatics ที่เมืองซาเกร็บ ประเทศโครเอเชีย วันแข่งที่ 1