programming.in.th · ข้อ 2028

ห้องประชุมเมืองสิรุเสรี: จำนวนมากที่สุดเป็นแค่ครึ่งเดียวของโจทย์

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

★★★★★ greedybinary lifting อ่าน 14 นาที 9 กันยายน 2026

โจทย์ · ให้เช่าห้องประชุมให้ได้จำนวนบริษัทมากที่สุด

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

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

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

EXAMPLE
InputOutput
4
4 9
9 11
13 19
10 17
2
1 3

ชุดที่ใหญ่ที่สุดของตัวอย่างนี้มีสามชุด คือ (1, 3), (2, 3) และ (1, 4) เรียงตามพจนานุกรมแล้ว (1, 3) มาก่อน (1, 4) และ (1, 4) มาก่อน (2, 3) คำตอบจึงเป็น (1, 3)

ใบ้

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

ครึ่งหลังหนักกว่ามาก เพราะเราต้องได้ชุดที่หมายเลขเล็กที่สุด ท่าที่อยากทำคือ ไล่บริษัทที่ 1, 2, 3 ไปเรื่อย ๆ แล้วรับถ้ารับได้ คำถามคือรับได้แปลว่าอะไร และตรวจมันด้วยราคาเท่าไร

ลองเอง · หาชุดที่ใหญ่ที่สุดและหมายเลขเล็กที่สุด

ชุดนี้วางได้มากที่สุดสองงาน แต่มีหลายวิธี คำถามคือชุดไหนที่หมายเลขเรียงแล้วเล็กที่สุด

1234567891011121314151617181920 #1 4 ถึง 9 #2 9 ถึง 11 #3 13 ถึง 19 #4 10 ถึง 17

กดที่แท่งเพื่อรับบริษัทนั้น แท่งที่ทับกันรับพร้อมกันไม่ได้

รับไว้ 0 บริษัท

เป้าหมายมีสองชั้น ชั้นแรกคือจำนวนต้องมากที่สุด ชั้นที่สองคือถ้าจำนวนเท่ากัน หมายเลขต้องเล็กที่สุด

ถ้าลองชุดที่สองจะเห็นตัวล่อชัด ๆ คือบริษัทที่เริ่มวันแรกสุดแต่ยาวจนกินที่ของอีกสองราย รับมันแล้วจำนวนรวมลดลงทันที ทั้งที่หมายเลขมันเล็กที่สุด

เฉลย ตอนที่ 1 · ครึ่งแรกคือท่าโลภที่รู้กันอยู่แล้ว

ที่มาของแนวคิดนี้

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

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

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

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

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

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

เฉลย ตอนที่ 2 · ถามว่า "ช่วงนี้วางได้กี่งาน" ให้ได้ในเวลาลอการิทึม

ต้องมีฟังก์ชัน cnt(l, r) ที่บอกว่าช่วงวัน [l, r] วางงานที่ไม่ทับกันได้มากที่สุดกี่งาน โดยนับเฉพาะงานที่อยู่ในช่วงนั้นทั้งก้อน

ท่าโลภบอกไว้แล้วว่าจากตำแหน่ง p เราควรหยิบงานที่เริ่มไม่ก่อน p และจบเร็วที่สุด เรียกวันจบของมันว่า f(p) แล้วตำแหน่งถัดไปคือ f(p) + 1 การนับจึงเป็นการกระโดดตามฟังก์ชันเดิมซ้ำ ๆ จนกว่าจะเลยขอบขวา

การกระโดดทีละก้าวยังช้า จึงใช้ตารางกระโดดสองยกกำลัง คือเก็บไว้ว่าจากตำแหน่งนี้ กระโดด 1 ครั้งไปไหน 2 ครั้งไปไหน 4 ครั้งไปไหน ไปจนถึง N ครั้ง แล้วนับจำนวนก้าวด้วยการไล่จากก้าวใหญ่ลงมา เหมือนเขียนเลขเป็นฐานสอง

หัวใจของทั้งข้อ
// รับบริษัทนี้ได้ก็ต่อเมื่อจำนวนรวมของช่องว่างที่มันตกอยู่ ไม่ลดลง
// ซ้ายของมัน บวกตัวมันเอง บวกขวาของมัน ต้องเท่ากับที่ช่องว่างนี้เคยวางได้
if(cnt(gl, s[i] - 1) + 1 + cnt(e[i] + 1, gr) != cnt(gl, gr)) continue;
123456789101112 #2 #8 #1 #3 #5 #4 #7 #6 #2 #3 #5 #7
ชุดคำขอเดียวกันนี้เรียงตามวันจบ แท่งเขียวคือสิ่งที่ท่าโลภหยิบเมื่อถามช่วงวัน 1 ถึง 12 แถวล่างสุดใต้เส้นคั่นคือลำดับที่หยิบจริง เรียงต่อกันโดยไม่ทับกัน ลูกศรทองคือการกระโดดจากวันจบของงานที่เพิ่งหยิบ ไปยังงานถัดไปที่หยิบได้ จำนวนแท่งในแถวนั้นคือค่าของ cnt ของช่วงนี้ ซึ่งเท่ากับ 4

โน้ต · ทำไมต้องบีบพิกัดวัน

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

เฉลย ตอนที่ 3 · ช่องว่างที่เป็นอิสระต่อกัน

งานที่รับไปแล้วซอยเวลาออกเป็นช่องว่าง ซึ่งเก็บไว้ในเซตที่เรียงตามจุดเริ่ม พอถึงคิวบริษัทที่ i เราหาว่ามันตกอยู่ในช่องว่างไหน ถ้ามันคร่อมงานที่รับไปแล้ว ก็ปฏิเสธทันที ถ้าอยู่ในช่องว่างเดียวพอดี ก็เทียบสามตัวเลข

  • cnt(gl, gr) คือช่องว่างนี้เคยวางได้กี่งาน
  • cnt(gl, s - 1) คือถ้ารับตัวนี้ ฝั่งซ้ายของมันยังวางได้กี่งาน
  • cnt(e + 1, gr) คือฝั่งขวายังวางได้กี่งาน

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

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

เดินตารางให้ดูหนึ่งชุด

ไล่ทีละบริษัทบนชุด 8 ราย ซึ่งวางได้มากที่สุด 4 งาน

ไล่ตามหมายเลขบริษัท
บริษัทขอวันที่ ช่องว่างเดิมวางได้ถ้ารับจะได้ผล
1 2 ถึง 5 4 3 ไม่รับ
2 1 ถึง 3 4 4 รับ
3 4 ถึง 6 3 3 รับ
4 6 ถึง 9 · · ไม่รับ
5 7 ถึง 8 2 2 รับ
6 10 ถึง 12 1 1 รับ
7 9 ถึง 11 · · ไม่รับ
8 3 ถึง 4 · · ไม่รับ

ชุดนี้วางได้มากที่สุด 4 งาน และชุดที่หมายเลขเล็กที่สุดคือ 2, 3, 5, 6 ตัวเลขทุกช่องคำนวณตอนสร้างหน้า และถูกเทียบกับการลองทุกเซตย่อยแล้วว่าตรงกัน

โค้ด

ดูโค้ดเต็ม
convention.cpp
#include <bits/stdc++.h>
using namespace std;
int n, m_, LOG;
vector<int> xs;                       // พิกัดวันที่ถูกบีบแล้ว
vector<vector<int>> up;               // up[k][i] = ดัชนีพิกัดหลังกระโดด 2^k ครั้ง
const int NONE = -1;
inline int idxOf(int v){ return (int)(lower_bound(xs.begin(), xs.end(), v) - xs.begin()); }

// จำนวนงานที่ไม่ทับกันมากที่สุด ที่วางได้ทั้งก้อนในช่วงวัน [l, r]
int cnt(int l, int r){
    if(l > r) return 0;
    int cur = idxOf(l);               // พิกัดแรกที่ไม่ก่อน l
    if(cur >= m_) return 0;
    int res = 0;
    for(int k = LOG - 1; k >= 0; k--){
        int q = up[k][cur];
        if(q != NONE && xs[q] <= r + 1){ res += 1 << k; cur = q; }
    }
    return res;
}

int main(){
    if(scanf("%d", &n) != 1) return 0;
    vector<int> s(n), e(n);
    for(int i = 0; i < n; i++) scanf("%d %d", &s[i], &e[i]);
    xs.reserve(3 * n + 2);
    for(int i = 0; i < n; i++){ xs.push_back(s[i]); xs.push_back(e[i]); xs.push_back(e[i] + 1); }
    xs.push_back(1); xs.push_back(1000000001);
    sort(xs.begin(), xs.end()); xs.erase(unique(xs.begin(), xs.end()), xs.end());
    m_ = (int)xs.size();

    // f[i] = วันจบที่น้อยที่สุด ในบรรดางานที่เริ่มไม่ก่อนพิกัด xs[i]
    vector<int> f(m_, INT_MAX);
    { vector<vector<int>> byStart(m_);
      for(int i = 0; i < n; i++) byStart[idxOf(s[i])].push_back(e[i]);
      int best = INT_MAX;
      for(int i = m_ - 1; i >= 0; i--){ for(int v : byStart[i]) best = min(best, v); f[i] = best; } }

    LOG = 1; while((1 << LOG) <= n) LOG++;
    up.assign(LOG, vector<int>(m_, NONE));
    for(int i = 0; i < m_; i++) if(f[i] != INT_MAX) up[0][i] = idxOf(f[i] + 1);
    for(int k = 1; k < LOG; k++) for(int i = 0; i < m_; i++){
        int q = up[k-1][i]; up[k][i] = (q == NONE) ? NONE : up[k-1][q]; }

    int LO = 1, HI = 1000000000;
    int total = cnt(LO, HI);

    // ไล่ตามหมายเลขบริษัท รับได้ก็รับ ตราบใดที่จำนวนรวมยังไม่ลด
    set<pair<int,int>> gaps; gaps.insert({LO, HI});
    vector<int> chosen;
    for(int i = 0; i < n && (int)chosen.size() < total; i++){
        auto it = gaps.upper_bound({s[i], INT_MAX});
        if(it == gaps.begin()) continue;
        --it;
        int gl = it->first, gr = it->second;
        if(s[i] < gl || e[i] > gr) continue;                  // คร่อมงานที่รับไปแล้ว
        if(cnt(gl, s[i] - 1) + 1 + cnt(e[i] + 1, gr) != cnt(gl, gr)) continue;
        gaps.erase(it);
        if(gl <= s[i] - 1) gaps.insert({gl, s[i] - 1});
        if(e[i] + 1 <= gr) gaps.insert({e[i] + 1, gr});
        chosen.push_back(i + 1);
    }
    printf("%d\n", total);
    for(size_t i = 0; i < chosen.size(); i++) printf("%d%c", chosen[i], i + 1 == chosen.size() ? '\n' : ' ');
    if(chosen.empty()) printf("\n");
}
ดูตัวตรวจที่ใช้เทียบ
brute_convention.cpp
// ตัวตรวจ ลองทุกเซตย่อย เอาเซตใหญ่ที่สุดที่เรียงแล้วเล็กที่สุดตามพจนานุกรม
// ไม่มีท่าโลภหรือตารางกระโดดอยู่ในนี้เลย
#include <bits/stdc++.h>
using namespace std;
int main(){
    int n; scanf("%d", &n); vector<int> s(n), e(n);
    for(int i = 0; i < n; i++) scanf("%d %d", &s[i], &e[i]);
    vector<int> best;
    for(int m = 0; m < (1 << n); m++){
        vector<int> id; bool ok = true;
        for(int i = 0; i < n; i++) if(m >> i & 1) id.push_back(i);
        for(size_t a = 0; a < id.size() && ok; a++) for(size_t b = a + 1; b < id.size() && ok; b++){
            int x = id[a], y = id[b]; if(!(e[x] < s[y] || e[y] < s[x])) ok = false; }
        if(!ok) continue;
        if(id.size() > best.size() || (id.size() == best.size() && id < best)) best = id;
    }
    printf("%d\n", (int)best.size());
    for(size_t i = 0; i < best.size(); i++) printf("%d%c", best[i] + 1, i + 1 == best.size() ? '\n' : ' ');
    if(best.empty()) printf("\n");
}

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

ผมสุ่มชุดคำขอไม่เกิน 11 บริษัท บนช่วงวันแคบ ๆ เพื่อให้ทับกันเยอะ รวม 1,500 เคส แล้วเทียบทั้งสองบรรทัดของเอาต์พุต ตรงกันหมด หน้าเว็บนี้ก็รันด่านตรวจเดียวกันตอนบิลด์ 400 ชุด

เวลาที่วัดได้บนเครื่องผม ที่ N เท่ากับ 200,000 และวันกระจายเต็มพันล้าน ใช้เวลา 0.46 วินาที ส่วนชุดที่จงใจให้ทับกันหนามาก คือทุกงานยาวไม่เกินสี่วันในช่วงพันวัน ใช้เวลา 0.14 วินาที จากลิมิต 1.5 วินาที

ท่าที่ติดมือกลับไป

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

ทบทวนพื้นฐาน · ทำไมท่าโลภแบบจบเร็วก่อนถึงถูก

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

ข้อควรระวังคือท่านี้ตอบได้แค่จำนวน ไม่ได้ตอบว่าชุดไหน โจทย์ข้อนี้จึงยากขึ้นอีกชั้นตรงที่ถามถึงตัวชุดเอง

บิลค่าใช้จ่าย
ทางขอบเขตที่มันไหวงานคร่าว ๆ
ลองรับทีละตัว แล้วคำนวณจำนวนรวมใหม่ทั้งชุดN ไม่เกินราวสามพัน200,000 คูณ 200,000 คือสี่หมื่นล้าน
ตารางกระโดดสองยกกำลัง แล้วถามสี่ครั้งต่อบริษัทN ถึง 200,000ราว 14.4 ล้านครั้ง
สองแถวนี้นับจำนวนครั้งที่ต้องแตะข้อมูลหนึ่งชิ้นเหมือนกัน ต่างกันที่แถวบนคำนวณคำตอบของทั้งชุดใหม่ทุกครั้งที่พิจารณาบริษัทหนึ่งราย ส่วนแถวล่างถามแค่สี่ครั้งต่อราย ครั้งละราวสิบแปดก้าวของตารางกระโดด

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

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

แหล่งที่มา

  1. โจทย์ Convention Centre บน programming.in.th ข้อ 2028 programming.in.th/tasks/2028 (สืบค้น 9 กันยายน 2026)
  2. ต้นทางของโจทย์คือ Asia-Pacific Informatics Olympiad 2009