programming.in.th · ข้อ 2028

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

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

★★★★★ greedybinary lifting อ่าน 13 นาที เผยแพร่ 9 กันยายน 2026 อัปเดต 20 ตุลาคม 2026

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

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

ชุดที่ได้จำนวนมากที่สุดมักมีหลายชุด ฝ่ายการตลาดเลยเพิ่มกติกา: เขียนหมายเลขบริษัทของแต่ละชุดเรียงจากน้อยไปมาก แล้วเลือกชุดที่เล็กที่สุดตามลำดับพจนานุกรม (lexicographic order)

ลำดับพจนานุกรมคือเทียบตัวแรกก่อน ถ้าเท่ากันค่อยเทียบตัวที่สอง แบบเดียวกับการเรียงคำในพจนานุกรม

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

EXAMPLE
InputOutput
4
4 9
9 11
13 19
10 17
2
1 3
45678910111213141516171819 #1 4 ถึง 9 #2 9 ถึง 11 #3 13 ถึง 19 #4 10 ถึง 17
ตัวอย่างในโจทย์วาดเป็นเส้นเวลา แท่งละบริษัท ตัวเลขด้านบนคือวันที่ 4 ถึง 19 สองแท่งที่ซ้อนวันเดียวกันแม้แค่วันเดียวรับพร้อมกันไม่ได้

ชุดที่ใหญ่ที่สุดของตัวอย่างนี้มีสามชุด คือ (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 ราว 22

วิธีที่ถูกแน่นอนแต่ช้า จะเป็นตัวตรวจของทุกวิธีที่ตามมา

ลองทุกเซตย่อย

  • ไล่ทุกชุดของบริษัท ซึ่งมี 2N ชุด
  • ชุดไหนมีสองบริษัทที่ทับวันกัน ทิ้ง
  • เก็บชุดที่ใหญ่ที่สุด ถ้าใหญ่เท่ากันเก็บชุดที่เล็กกว่าตามพจนานุกรม

ทุก ๆ บริษัทที่เพิ่มขึ้นหนึ่งราย จำนวนชุดเพิ่มเป็นสองเท่า วัดจริงที่ N = 20 ใช้ 0.26 วินาที ที่ N = 22 ใช้ 1.37 วินาที ใกล้ลิมิต 1.5 วินาทีแล้ว

ชุดทดสอบของโจทย์
ส่วนN ไม่เกินลองทุกเซตย่อยผ่านไหม
50% แรก3,000ไม่ผ่าน
อีก 50%200,000ไม่ผ่าน
วิธีนี้ไม่ได้คะแนนเลย แต่ทุกโค้ดในหน้านี้ถูกเทียบกับมันบนชุดเล็ก

เฉลย ตอนที่ 2 · ครึ่งแรก: นับว่าวางได้มากที่สุดกี่งาน

การหาจำนวนมากที่สุดคือโจทย์จัดตารางช่วงเวลา (interval scheduling) ซึ่งมีวิธีตอบที่รู้กันมานาน เรียกว่าวิธีโลภ (greedy อ่านว่า "กรีดดี้" แปลว่าตะกละ) คือทุกครั้งหยิบตัวที่ดูดีที่สุดตรงหน้า แล้วไม่ย้อนกลับมาแก้

กฎการหยิบ

ยืนอยู่ที่วัน p หยิบงานที่เริ่มไม่ก่อน p และจบเร็วที่สุด ถ้างานนั้นจบวัน e ก็ย้ายไปยืนที่วัน e + 1 แล้วทำซ้ำ

ตั้งแต่ตอนนี้จนจบบทใช้ชุดตัวอย่างชุดเดียว (ชุดเดียวกับแท็บที่สามของเกม) มี 8 บริษัท ช่วงวันที่ 1 ถึง 14: #1 (7 ถึง 10), #2 (9 ถึง 10), #3 (11 ถึง 12), #4 (1 ถึง 3), #5 (2 ถึง 4), #6 (2 ถึง 2), #7 (8 ถึง 8), #8 (12 ถึง 12)

เริ่มยืนที่วันที่ 1 แล้วทำตามกฎ

  • ยืนวัน 1 · เริ่มไม่ก่อนวัน 1 และจบเร็วสุดคือ #6 (2 ถึง 2) · ไปยืนวัน 3
  • ยืนวัน 3 · เริ่มไม่ก่อนวัน 3 และจบเร็วสุดคือ #7 (8 ถึง 8) · ไปยืนวัน 9
  • ยืนวัน 9 · เริ่มไม่ก่อนวัน 9 และจบเร็วสุดคือ #2 (9 ถึง 10) · ไปยืนวัน 11
  • ยืนวัน 11 · เริ่มไม่ก่อนวัน 11 และจบเร็วสุดคือ #3 (11 ถึง 12) · ไปยืนวัน 13 (#8 ก็จบวัน 12 หยิบตัวหมายเลขน้อยกว่า)
  • ยืนวัน 13 · ไม่มีงานที่เริ่มตั้งแต่วันนี้และจบไม่เกินวัน 14 · หยุด ได้ 4 งาน

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

1234567891011121314 #1 7 ถึง 10 #2 9 ถึง 10 #3 11 ถึง 12 #4 1 ถึง 3 #5 2 ถึง 4 #6 2 ถึง 2 #7 8 ถึง 8 #8 12 ถึง 12 #6 #7 #2 #3
ชุดตัวอย่าง 8 บริษัท เรียงตามหมายเลข แท่งเขียวคืองานที่วิธีโลภหยิบ แถวล่างใต้เส้นคั่นเรียงงานที่หยิบตามลำดับ ลูกศรทองคือการย้ายไปยืนวันถัดจากวันจบ ที่วันที่ 11 บริษัท 3 กับ 8 จบวัน 12 เท่ากัน ภาพนี้หยิบ 3

ทำไมจบเร็วก่อนถึงได้จำนวนมากที่สุด

เอาชุดที่ดีที่สุดชุดไหนก็ได้มาหนึ่งชุด แล้วสลับงานแรกของมันเป็นงานที่จบเร็วที่สุด งานที่สองของชุดเดิมเริ่มหลังงานแรกเดิมจบ และงานใหม่จบไม่ช้ากว่างานแรกเดิม งานที่สองก็ยังเริ่มหลังงานใหม่จบ

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

วิธีนี้ตอบได้แค่จำนวน ชุดที่มันหยิบมาเรียงแล้วคือ 2, 3, 6, 7 แต่คำตอบของชุดนี้คือ 2, 3, 4, 7 ซึ่งเล็กกว่าตามพจนานุกรม

นิยาม cnt(l, r)

เริ่มยืนที่วัน l แล้วหยิบตามกฎข้างบน โดยนับเฉพาะงานที่จบไม่เกินวัน r ได้กี่งานคือ cnt(l, r) พูดง่าย ๆ คือช่วงวัน l ถึง r วางงานที่ไม่ทับกันได้มากสุดกี่งาน

เช่น cnt(1, 14) = 4 ตามที่เพิ่งเดินไป ถ้า l > r ให้ค่าเป็น 0

เฉลย ตอนที่ 3 · ครึ่งหลัง: ไล่ตามหมายเลข รับเมื่อช่องว่างไม่เสียจำนวน

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

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

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

เริ่มจากเหตุผลว่าทำไมไล่ตามหมายเลขแล้วได้ชุดที่เล็กที่สุด

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

ตัวที่รับไปแล้วยึดวันของมันไว้ วันที่เหลือแตกเป็นท่อนที่ไม่ติดกัน เรียกแต่ละท่อนว่าช่องว่าง (gap)

นึกภาพ

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

สิ่งที่เป็นจริงตลอดการไล่

จำนวนตัวที่รับไว้ + ผลรวมของ cnt ของทุกช่องว่าง = จำนวนมากที่สุดของทั้งโจทย์

ในชุดตัวอย่าง ตัวแรกที่ถูกรับคือ #2 (9 ถึง 10) หลังรับแล้วเหลือช่องว่าง 1 ถึง 8 (วางได้ 2) กับ 11 ถึง 14 (วางได้ 1) รวม 1 + 2 + 1 = 4 เท่ากับจำนวนมากที่สุด

เรียกจำนวนที่ช่องว่างหนึ่งวางได้ว่า c และเก็บไว้คู่กับช่องว่างตั้งแต่ตอนสร้าง บริษัทใหม่ที่อยู่ในช่องว่าง [gl, gr] แตะได้แค่ช่องนี้ ถ้ารับมัน ช่องนี้แตกเป็นฝั่งซ้ายกับฝั่งขวาของมัน

เงื่อนไขรับ

cnt(gl, s - 1) + 1 + cnt(e + 1, gr) == c โดย s, e คือวันเริ่มกับวันจบของบริษัทนี้

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

ย้อนไปตอนเริ่ม ยังไม่มีใครถูกรับ ช่องว่างยังเป็นวันที่ 1 ถึง 14 ทั้งท่อน ภาพข้างล่างเทียบบริษัทสองรายแรก

1234567891011121314 #1 ขอวันที่ 7 ถึง 10 · ช่องว่าง 1 ถึง 14 เดิมได้ 4 · ซ้าย 1 + 1 + ขวา 1 = 3 · ไม่รับ #1 #6 #3 ซ้าย 1 งาน ขวา 1 งาน #2 ขอวันที่ 9 ถึง 10 · ช่องว่าง 1 ถึง 14 เดิมได้ 4 · ซ้าย 2 + 1 + ขวา 1 = 4 · รับ #2 #6 #7 #3 ซ้าย 2 งาน ขวา 1 งาน
ช่องว่างวันที่ 1 ถึง 14 วางได้ 4 งาน แท่งทองคือบริษัทที่กำลังพิจารณา แท่งเขียวคืองานที่วิธีโลภหยิบได้ในฝั่งซ้ายและฝั่งขวาของมัน

แทนตัวเลขลงในเงื่อนไข

  • บริษัท 1 ขอ 7 ถึง 10: cnt(1, 6) + 1 + cnt(11, 14) = 1 + 1 + 1 = 3 น้อยกว่า 4 ไม่รับ
  • บริษัท 2 ขอ 9 ถึง 10: cnt(1, 8) + 1 + cnt(11, 14) = 2 + 1 + 1 = 4 เท่ากับ 4 รับ

ทุกค่า cnt ในสองบรรทัดนี้ตรงกับการลองทุกเซตย่อยของช่วงนั้น (หน้านี้ตรวจตอนสร้างหน้า)

มีอีกสองกรณีที่ไม่ต้องนับ

  • บริษัทที่ทับวันของตัวที่รับไว้แล้ว ไม่ได้อยู่ในช่องว่างไหนทั้งก้อน ข้ามได้ทันที เช่นบริษัท 5 ขอ 2 ถึง 4 ทับบริษัท 4 (1 ถึง 3)
  • พอรับครบจำนวนมากที่สุดแล้วก็หยุดได้ ในชุดตัวอย่างเป้าคือ 4 ซึ่งได้จาก cnt(1, 14) ของตอนที่ 2

ตารางข้างล่างเดินชุดตัวอย่างทีละบริษัท กดถัดไปเพื่อเปิดทีละแถว ทุกบรรทัดใช้รูปแบบเดียวกับที่แทนตัวเลขไปเมื่อกี้

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

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

ชุดที่รับคือ 2, 3, 4, 7 ตรงกับการลองทุกเซตย่อย

เฉลย ตอนที่ 4 · นับแบบไล่ทีละงานได้ 50% แล้ววัดว่าขาดเท่าไร

วิธีนับ cnt(l, r) ที่ตรงที่สุดคือเรียงงานตามวันจบไว้ครั้งเดียว แล้วทุกครั้งที่ถามก็เดินวิธีโลภไปตามรายการนั้น

  • ต่อบริษัทหนึ่งราย นับสองครั้ง (ซ้ายกับขวา) ส่วน c อ่านจากช่องว่างได้เลย
  • แต่ละครั้งไล่รายการได้ถึง N งาน
  • รวมไม่เกิน 2 × N × N ก้าว ไม่ได้ดีกว่าการนับใหม่ทั้งชุด (ราว N × N) เลย
ดูโค้ดวิธีที่ 2 (ได้ 50%)
convention_slow.cpp
// ชุดย่อย N <= 3,000: ตรรกะช่องว่างเหมือนตัวเต็มทุกบรรทัด ต่างกันแค่ cnt ที่ไล่งานทีละตัว
#include <bits/stdc++.h>
using namespace std;

vector<pair<int,int>> byEnd;          // (วันจบ, วันเริ่ม) เรียงตามวันจบ

// ท่าโลภ: ไล่งานตามวันจบ หยิบทุกตัวที่อยู่ในช่วงและเริ่มหลังตัวที่หยิบล่าสุด
int cnt(int l, int r){
    int cur = l - 1, res = 0;
    for(auto [en, st] : byEnd){
        if(en > r) break;
        if(st > cur){ cur = en; res++; }
        // st >= l เป็นจริงเองเมื่อ st > cur เพราะ cur เริ่มที่ l - 1
    }
    return res;
}

int main(){
    int n;
    if(scanf("%d", &n) != 1) return 0;
    vector<int> s(n), e(n);
    for(int i = 0; i < n; i++) if(scanf("%d %d", &s[i], &e[i]) != 2) return 0;
    for(int i = 0; i < n; i++) byEnd.push_back({e[i], s[i]});
    sort(byEnd.begin(), byEnd.end());

    const int LO = 1, HI = 1000000000;
    int total = cnt(LO, HI);
    map<int, pair<int,int>> gaps;     // วันเริ่มช่องว่าง -> (วันจบช่องว่าง, วางได้กี่งาน)
    gaps[LO] = {HI, total};
    vector<int> chosen;
    for(int i = 0; i < n && (int)chosen.size() < total; i++){
        auto it = gaps.upper_bound(s[i]);
        if(it == gaps.begin()) continue;
        --it;
        int gl = it->first, gr = it->second.first, c = it->second.second;
        if(e[i] > gr) continue;                                  // คร่อมงานที่รับไปแล้ว
        int c1 = cnt(gl, s[i] - 1), c2 = cnt(e[i] + 1, gr);
        if(c1 + 1 + c2 != c) continue;                           // รับแล้วช่องว่างนี้เสียจำนวน
        gaps.erase(it);
        if(gl <= s[i] - 1) gaps[gl] = {s[i] - 1, c1};
        if(e[i] + 1 <= gr) gaps[e[i] + 1] = {gr, c2};
        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");
}

วิธีที่ 1

ลองทุกเซตย่อย

ราว 2N ชุด

ถูกแน่นอน ไปได้ถึง N ราว 22

ไม่ได้คะแนน ใช้เป็นตัวตรวจ

วิธีที่ 2

ช่องว่าง + นับแบบไล่ทีละงาน

ไม่เกิน 2 × N × N ก้าว

เงื่อนไขของตอนที่ 3 กับ cnt ที่เดินวิธีโลภตรง ๆ

50% (N ไม่เกิน 3,000)

วิธีเต็ม

ช่องว่าง + ตารางกระโดด

ราว 126 ก้าวต่อบริษัท

เงื่อนไขเดิมทุกบรรทัด เปลี่ยนแค่วิธีนับ (ตอนที่ 5)

100%

10610810101012 ไล่ทีละงาน N = 3,000ไล่ทีละงาน N = 200,000ตารางกระโดด N = 200,000 1.8×107 ทัน8.0×1010 เกินงบ3.6×107 ทัน งบ 1.5 วินาที
จำนวนก้าวสเกลแบบ log (ขีดละร้อยเท่า) เส้นทองคืองบ 1.5 วินาที โดยคิดหยาบ ๆ ว่าเครื่องทำงานง่าย ๆ ได้ราว 10⁸ ก้าวต่อวินาที แท่งไล่ทีละงานนับกรณีเลวสุด 2 × N × N วัดจริงที่ N = 200,000 ช้ากว่าลิมิตราว 15 เท่า แท่งตารางกระโดดนับตามที่ตอนที่ 5 แจกแจง

วัดจริงที่ N = 200,000 วิธีที่ 2 ใช้ 23 วินาที ช้ากว่าลิมิตราว 15 เท่า น้อยกว่าที่กราฟบอก เพราะกราฟนับกรณีที่ทุกการนับไล่ครบทุกงาน และลูปนี้ง่ายจนเครื่องทำได้เกิน 10⁸ ก้าวต่อวินาที

ตัวที่ต้องแก้คือการนับหนึ่งครั้ง เป้าคือให้ใช้ราว log₂ N ก้าวแทน N ก้าว (log₂ N คือจำนวนครั้งที่หาร N ด้วย 2 จนเหลือ 1 ที่ N = 200,000 ได้ราว 18)

เฉลย ตอนที่ 5 · นับช่วงด้วยตารางกระโดด

วิธีโลภของตอนที่ 2 ไม่ได้เลือกอะไรเลย ยืนที่วัน p แล้วก้าวต่อไปถูกกำหนดตายตัว เขียนเป็นฟังก์ชันได้

jump(p)

jump(p) = วันจบของงานที่เริ่มไม่ก่อน p และจบเร็วที่สุด แล้วบวก 1 ส่วน cnt(l, r) คือจำนวนครั้งที่ก้าวจากวัน l ได้ โดยยังยืนไม่เลยวัน r + 1

นึกภาพ

รถไฟสายหนึ่งที่สถานีถัดไปของทุกสถานีตายตัว มีขบวนธรรมดาไปสถานีถัดไป และขบวนด่วนที่ข้ามไปทีละ 2 สถานี 4 สถานี 8 สถานี

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

ตารางที่เก็บว่าจากแต่ละจุด ก้าว 1, 2, 4, 8, … ครั้งแล้วไปถึงไหน เรียกว่าตารางกระโดด (binary lifting อ่านว่า "ไบนารี ลิฟติ้ง" แปลตรง ๆ ว่ายกทีละกำลังสอง)

ช่อง 2 ก้าวได้จากการก้าว 1 ก้าวสองรอบ ช่อง 4 ก้าวได้จากช่อง 2 ก้าวสองรอบ ไล่ขึ้นไปเรื่อย ๆ

บีบพิกัด (coordinate compression)

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

โค้ดเก็บตารางแค่ที่จุดเหล่านี้ บวกจุดปิดท้ายอีกหนึ่งจุดที่วันสุดท้าย + 1 ซึ่งไม่มีงานเริ่มได้ รวมไม่เกิน N + 2 จุด (+2 คือวันที่ 1 กับจุดปิดท้าย)

ในชุดตัวอย่าง วันสุดท้ายคือ 14 (โจทย์จริงคือพันล้าน) จุดปิดท้ายคือวัน 15 แถวของมันเป็น · ทั้งแถว

ตารางกระโดดของชุดตัวอย่าง
ยืนที่วันก้าวแรกหยิบ1 ก้าว2 ก้าว4 ก้าว
1#6 (2 ถึง 2)3913
3#7 (8 ถึง 8)911·
4#7 (8 ถึง 8)911·
5#7 (8 ถึง 8)911·
9#2 (9 ถึง 10)1113·
11#3 (11 ถึง 12) เสมอ #813··
13····
15····
ช่องที่เป็น · คือไปต่อไม่ได้ ถ้ามีงานจบวันเดียวกันหยิบตัวหมายเลขน้อยกว่า ปลายทางเหมือนกันอยู่แล้ว ชุดนี้วางได้อย่างมาก 4 งาน ชั้น 1, 2, 4 ก้าวก็พอ

ทำไมลองจากก้าวใหญ่ลงก้าวเล็กแล้วได้คำตอบพอดี ก้าวยิ่งมากยิ่งไปไกล ไม่มีทางถอยหลัง ถ้า 4 ก้าวเลยขอบ คำตอบต้องน้อยกว่า 4 ถ้า 2 ก้าวไม่เลย คำตอบอย่างน้อย 2

ทุกชั้นตัดสินหลักหนึ่งหลักของคำตอบในเลขฐานสอง ชั้นที่ก้าวคือหลักที่เป็น 1 ลองนับซ้ายของบริษัท 2 จากตอนที่ 3 คือ cnt(1, 8) ห้ามยืนเลยวัน 9

นับ cnt(1, 8) ด้วยการกระโดด

  • ลอง 4 ก้าวจากวัน 1 ไปถึง 13 · เกิน 9 ไม่ก้าว นับเท่าเดิม 0
  • ลอง 2 ก้าวจากวัน 1 ไปถึง 9 · ไม่เกิน 9 ก้าว นับรวมเป็น 2
  • ลอง 1 ก้าวจากวัน 9 ไปถึง 11 · เกิน 9 ไม่ก้าว นับเท่าเดิม 2

ได้ 2 = 0×4 + 1×2 + 0×1 ตรงกับค่าซ้ายในตอนที่ 3

โค้ดจริงต้องรองรับคำตอบถึง 200,000 ซึ่งน้อยกว่า 218 = 262,144 เขียนเป็นเลขฐานสองได้ไม่เกิน 18 หลัก ตาราง 18 ชั้นก็พอ

ส่วนการหาว่าจุดไหนอยู่ตรงวันไหน ใช้การค้นหาแบบแบ่งครึ่ง (binary search) คือหาตำแหน่งในรายการที่เรียงแล้ว โดยตัดครึ่งทีละรอบ รายการยาว 200,002 ใช้ราว 18 รอบ

นับก้าวของโค้ดเต็มที่ N = 200,000

  • นับหนึ่งครั้ง: ค้นแบบแบ่งครึ่งสองครั้ง (จุดเริ่มกับจุดสุดท้ายที่ไม่เกินขอบขวา) 2 × 18 บวกอ่านตาราง 18 ชั้น = 54
  • ต่อบริษัท: นับสองครั้ง บวกหาช่องว่างของมันอีกราว 18 = 126
  • ทุกบริษัท: 200,000 × 126 = 25,200,000
  • เรียงสองรอบ (พิกัด กับงานตามวันเริ่ม): 2 × 200,000 × 18 = 7,200,000
  • สร้างตาราง: 18 × 200,002 = 3,600,036

รวม 36,000,036 ก้าว ราว 3.6×107 ต่ำกว่างบ

เฉลย ตอนที่ 6 · โค้ดเต็ม เวลา และหน่วยความจำ

โค้ดเต็มคือโค้ดวิธีที่ 2 ทุกบรรทัด เปลี่ยนแค่ cnt เป็นการกระโดด แล้วเพิ่มการเตรียมตาราง

ดูโค้ดเต็ม
convention.cpp
#include <bits/stdc++.h>
using namespace std;

const int LOG = 18;                   // 2^18 = 262,144 มากกว่า N ที่มากที่สุด
const int NONE = INT_MAX;             // ไปต่อไม่ได้ ตั้งเป็นค่าใหญ่สุดเพื่อให้ตกการเทียบ q <= lim เอง
vector<int> xs;                       // พิกัดที่การนับไปยืนได้: วันที่ 1 และวันจบ + 1 ของทุกงาน
vector<array<int, LOG>> up;           // up[i][k] = กระโดด 2^k ก้าวจากพิกัด i แล้วไปถึงพิกัดไหน
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), res = 0;
    int lim = (int)(upper_bound(xs.begin(), xs.end(), r + 1) - xs.begin()) - 1;  // พิกัดสุดท้ายที่ไม่เกิน r + 1
    for(int k = LOG - 1; k >= 0; k--){
        int q = up[cur][k];
        if(q <= lim){ res += 1 << k; cur = q; }   // ก้าวนี้ยังไม่เลยขอบขวา ก้าวได้
    }
    return res;
}

int main(){
    int n;
    if(scanf("%d", &n) != 1) return 0;
    vector<int> s(n), e(n);
    for(int i = 0; i < n; i++) if(scanf("%d %d", &s[i], &e[i]) != 2) return 0;

    // ---- บีบพิกัด: ไม่ต้องเก็บวันเริ่ม เพราะการนับไม่เคยไปยืนที่วันเริ่ม ----
    xs.reserve(n + 2);
    for(int i = 0; i < n; 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());
    int m = (int)xs.size();

    // ---- ก้าวเดียว: จากพิกัด i หยิบงานที่เริ่มไม่ก่อน xs[i] และจบเร็วที่สุด แล้วไปต่อที่วันจบ + 1 ----
    vector<pair<int,int>> byStart(n);
    for(int i = 0; i < n; i++) byStart[i] = {s[i], e[i]};
    sort(byStart.begin(), byStart.end());
    up.assign(m, {});
    int best = INT_MAX, j = n - 1;
    for(int i = m - 1; i >= 0; i--){
        while(j >= 0 && byStart[j].first >= xs[i]){ best = min(best, byStart[j].second); j--; }
        up[i][0] = (best == INT_MAX) ? NONE : idxOf(best + 1);
    }

    // ---- ตารางกระโดด: 2^k ก้าว = 2^(k-1) ก้าว สองรอบ ----
    for(int k = 1; k < LOG; k++) for(int i = 0; i < m; i++){
        int q = up[i][k - 1];
        up[i][k] = (q == NONE) ? NONE : up[q][k - 1];
    }

    // ---- ไล่ตามหมายเลข: ช่องว่างแต่ละช่องจำไว้ว่าวางได้กี่งาน ----
    const int LO = 1, HI = 1000000000;
    int total = cnt(LO, HI);
    map<int, pair<int,int>> gaps;     // วันเริ่มช่องว่าง -> (วันจบช่องว่าง, วางได้กี่งาน)
    gaps[LO] = {HI, total};
    vector<int> chosen;
    for(int i = 0; i < n && (int)chosen.size() < total; i++){
        auto it = gaps.upper_bound(s[i]);
        if(it == gaps.begin()) continue;
        --it;
        int gl = it->first, gr = it->second.first, c = it->second.second;
        if(e[i] > gr) continue;                                  // คร่อมงานที่รับไปแล้ว
        int c1 = cnt(gl, s[i] - 1), c2 = cnt(e[i] + 1, gr);
        if(c1 + 1 + c2 != c) continue;                           // รับแล้วช่องว่างนี้เสียจำนวน
        gaps.erase(it);
        if(gl <= s[i] - 1) gaps[gl] = {s[i] - 1, c1};
        if(e[i] + 1 <= gr) gaps[e[i] + 1] = {gr, c2};
        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");
}
แต่ละส่วนของโค้ด อธิบายไว้ที่ไหน
ส่วนของโค้ดอธิบายใน
นับ cnt(l, r)ตอนที่ 5 การนับด้วยการกระโดด
บีบพิกัดตอนที่ 5 จุดที่การนับไปยืนได้
ก้าวเดียวตอนที่ 2 กฎการหยิบ และตอนที่ 5 ฟังก์ชัน jump
ตารางกระโดดตอนที่ 5 ช่อง 2 ก้าวได้จาก 1 ก้าวสองรอบ
ไล่ตามหมายเลขตอนที่ 3 เงื่อนไขรับ

ตัวเลขเวลาและหน่วยความจำในส่วนนี้วัดด้วยวิธีเดียวกันทั้งหมด (บอกไว้ท้ายส่วน) และ MB ในหน้านี้หมายถึง 1,048,576 ไบต์

โค้ดรุ่นแรกใช้หน่วยความจำเกือบเต็มลิมิต

โค้ดรุ่นแรกใช้เวลามากสุด 0.92 วินาที และหน่วยความจำสูงสุด 59.0 MB จากลิมิต 64 MB เหลือที่ว่างแค่ 5.0 MB

ตัวกินที่คือตารางกระโดด รุ่นแรกเก็บพิกัดทั้งวันเริ่ม วันจบ และวันจบ + 1 รวม 3N + 2 จุด ตาราง 18 ชั้นหนัก 41.2 MB พอเหลือ N + 2 จุด ตารางเหลือ 13.7 MB ประหยัด 27.5 MB

บนชุดเดียวกัน (งานสั้นกระจายห่าง) หน่วยความจำลดจาก 59.0 เหลือ 31.9 MB ลดไป 27.1 MB ใกล้กับที่ตารางเล็กลง

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

แก้อีกสามจุดพร้อมกัน โดยไม่เปลี่ยนวิธีคิด

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

เวลามากสุดลดเหลือ 0.59 วินาที ไม่ได้แยกวัดว่าแต่ละจุดช่วยเวลาเท่าไร

ดูตัวตรวจที่ใช้เทียบ
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");
}

ตัวตรวจไล่ทุกเซตย่อยแล้วเทียบตามพจนานุกรมตรง ๆ ไม่มีวิธีโลภหรือช่องว่างอยู่ในนั้นเลย

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

  • ตัวอย่างในโจทย์ ทั้งสามโปรแกรมได้ 2 กับ 1 3
  • สุ่ม 3,000 ชุด N ไม่เกิน 14 วันแคบให้ทับกันเยอะ บางชุดวันกระจายถึงพันล้าน วิธีที่ 2 กับโค้ดเต็มตรงกับตัวตรวจทั้งสองบรรทัด
  • สุ่ม 300 ชุด N ระหว่าง 500 ถึง 3,000 โค้ดเต็มตรงกับวิธีที่ 2
  • ตอนสร้างหน้านี้ โมเดลในหน้ารันเทียบการลองทุกเซตย่อยอีก 400 ชุด

วิธีวัด: WSL Ubuntu, g++ -O2 และ /usr/bin/time บนหกชุดที่ N = 200,000 รันชุดละห้ารอบ ใช้รอบที่เร็วที่สุดของแต่ละชุด แล้วรายงานชุดที่แย่ที่สุด

ตัวเลขเหล่านี้เป็นผลทดสอบบนชุดที่เลือกมา ยังไม่ได้พิสูจน์ว่าทุกอินพุตทันเวลา

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

ถ้ามีชุดที่ใหญ่ที่สุดสักชุดที่มีบริษัท 1 ทำไมต้องรับบริษัท 1 แน่นอน

ชุดที่มีบริษัท 1 ขึ้นต้นด้วย 1 ส่วนชุดที่ไม่มีขึ้นต้นด้วยเลขที่มากกว่า เทียบตัวแรกก็ชนะแล้ว

ในชุดตัวอย่าง ทำไมบริษัท 5 ถูกข้ามโดยไม่ต้องนับ

มันขอ 2 ถึง 4 ซึ่งทับบริษัท 4 (1 ถึง 3) ที่รับไว้แล้ว มันไม่ได้อยู่ในช่องว่างไหนทั้งก้อน

ทำไมก้าวถัดไปเริ่มที่วันจบ + 1 ไม่ใช่วันจบ

วันในโจทย์นับรวมทั้งวันเริ่มและวันจบ งานที่จบวัน e ยังใช้ห้องในวัน e อยู่ ในตัวอย่างของโจทย์ บริษัท 1 กับ 2 ชนกันที่วันที่ 9 วันเดียวก็รับพร้อมกันไม่ได้

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

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

แหล่งที่มา

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