programming.in.th · ข้อ 2015

ใบเรือโจรสลัด: ฟังก์ชันนูนบอกคำตอบไปแล้ว เหลือแค่ใครได้เลือกก่อน

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

★★★★☆ greedyBITbinary search อ่าน 11 นาที 7 กันยายน 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 × (c − 1) / 2 มีคุณสมบัติหนึ่งที่ตัดสินทั้งข้อ คือส่วนต่างของมันเวลาเพิ่มขึ้นทีละหนึ่ง เท่ากับ c พอดี พูดง่าย ๆ คือยิ่งกองไหนสูง การเติมใบลงกองนั้นยิ่งแพง

แล้วถ้าเราจะไล่ใส่เสาทีละต้น ควรไล่จากเสาต้นไหนก่อน และตอนที่หลายระดับมีจำนวนใบเท่ากันเป๊ะ การเลือกระดับล่างกับระดับบนต่างกันไหม

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

ลองเอง · วางใบเรือให้เสียแต้มน้อยที่สุด

สามเสาเตี้ย ๆ พอให้จับทางว่าอะไรทำให้เสียแต้ม

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

แต้มตอนนี้ 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);

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

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

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

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

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

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

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

ดูโค้ดของชุดทดสอบย่อย
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);
}

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

บิลค่าใช้จ่าย · เทียบสองทางด้วยหน่วยเดียวกัน
ทางขอบเขตที่มันไหวงานคร่าว ๆ
ไล่ทุกวิธีจัด จำนวนวิธีรวมไม่เกินหนึ่งล้าน 1,000,000 แบบ
ท่าโลภกับคิวลำดับความสำคัญ N ถึง 100,000 1,700,000 ครั้ง
แถวบนนับจำนวนวิธีจัดที่ต้องกางออกมาดูทั้งหมด แถวล่างนับจำนวนครั้งที่แตะคิว ซึ่งคือจำนวนใบเรือคูณลอการิทึมของความสูง สิ่งที่ทำให้สองแถวนี้ต่างกันไม่ใช่ความเร็วของเครื่อง แต่เป็นข้อโต้แย้งแลกเปลี่ยนในหัวข้อข้างล่าง ที่อนุญาตให้เราไม่ต้องดูวิธีจัดอื่นเลย

โค้ด C++

เสามีได้ถึง 100,000 ต้น และแต่ละต้นต้องเติมใบได้ถึง 100,000 ใบ จะไล่เติมทีละระดับไม่ไหว แต่เพราะเราพิสูจน์แล้วว่าเติมเป็นช่วงติดกันสองช่วงเสมอ (ช่วงท้ายที่ค่าน้อยกว่า กับหัวของบล็อกที่ค่าเท่ากัน) จึงใช้ต้นไม้เฟนวิกแบบบวกทั้งช่วงถามทีละจุดได้พอดี

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

ดูโค้ดเต็ม
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);
}
ดูโค้ดที่ผมเขียนผิดรอบแรก และเคสที่เล็กที่สุดที่มันแตกต่าง

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

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 ซึ่งเพิ่มขึ้นแล้ว พอเสาต้นที่สองมาถึง การค้นแบบไบนารีที่หาขอบบล็อก ตั้งอยู่บนข้อสมมติที่พังไปแล้ว มันจึงชี้ไปที่ระดับที่มีใบอยู่ และซ้อนใบที่สองทับลงไปตรงนั้น คำตอบจริงคือ 0 เวอร์ชันที่ผิดตอบ 1

ตัวเลข 0 มาจากตัวไล่วางทุกแบบข้างบน ไม่ได้มาจากท่าโลภที่กำลังตรวจ และตัวอย่างในโจทย์เองก็จับบักตัวนี้ได้ เวอร์ชันที่ผิดตอบ 15 แทนที่จะเป็น 10 ซึ่งเป็นเลขที่ทำให้ผมรู้ตัวตั้งแต่รอบแรก

ไล่ทุกชุดที่มีเสาไม่เกิน 4 ต้นและสูงไม่เกิน 5 ทั้งหมด 3,875 ชุด เวอร์ชันที่ถูกตรงกับตัวไล่ทุกแบบทั้งหมด ส่วนเวอร์ชันที่ผิดหลุด 2,299 ชุด

เวลาที่วัดได้บนเครื่องผม เรือ 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 ตัวต่อเสา

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

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 ระดับที่ว่างที่สุดออกมา แล้วใส่กลับพร้อมค่าที่เพิ่มขึ้น
        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;
}
ตัวตรวจที่ทำให้กล้าเชื่อทั้งข้ออ้างและโค้ด

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

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;
}

สุ่มเสา 1 ถึง 5 ต้น สูงไม่เกิน 4 แล้วเทียบ 500 รอบ ตรงกันทุกรอบ ชุดเล็กเพราะการไล่ทุกวิธีจัดโตเร็วมาก แต่เล็กพอที่จะครอบกรณีที่กฎเสมอกันมีผลจริง คือตอนที่มีหลายระดับว่างเท่ากันและเลือกผิดตัวจะแพงขึ้น

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

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

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

แหล่งที่มา

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