programming.in.th · ข้อ 2013

ฐานปิรามิด: ตารางล้านคูณล้านที่ไม่ต้องแตะสักช่อง

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

★★★★★ binary searchsweep linesegment tree อ่าน 13 นาที 7 กันยายน 2026

โจทย์ · หาที่วางฐานปิรามิดให้ใหญ่ที่สุดในงบที่มี

พื้นที่สำรวจเป็นตารางขนาด M × N ช่อง ฐานปิรามิดต้องเป็นจัตุรัสที่ด้านขนานกับเส้นตาราง บนพื้นที่มีสิ่งกีดขวาง P ก้อน แต่ละก้อนเป็นสี่เหลี่ยมในตาราง (ทับซ้อนกันได้) การทำลายก้อนที่ i เสียค่าใช้จ่าย C และต้องทำลายทั้งก้อน จะทุบแค่ส่วนที่ขวางไม่ได้

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

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

EXAMPLE
InputOutput
6 9
42
5
4 1 6 3 12
3 6 5 6 9
1 3 3 8 24
3 8 6 9 21
5 1 6 2 20
4
13 5
0
8
8 4 10 4 1
4 3 4 4 1
10 2 12 2 2
8 2 8 4 3
2 4 6 4 5
10 3 10 4 8
12 3 12 4 13
2 2 4 2 21
3

ใบ้

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

ลองถามว่า ถ้ารู้ว่าจัตุรัสด้าน s วางได้ แล้วจัตุรัสด้าน s − 1 วางได้ไหม และถ้าเราคิดกลับด้าน มองว่ามุมล่างซ้ายของจัตุรัสวางได้ที่ไหนบ้าง สิ่งกีดขวางหนึ่งก้อนจะห้ามมุมล่างซ้ายไว้ที่บริเวณรูปอะไร

1 2 3 4 5 6 1 2 3 4 5 6 7 8 9 12 9 24 21 20 ฐานด้าน 4 มุม (1, 4) รื้อ 33 งบ 42 ทองคือกีดขวาง แดงคือก้อนที่ต้องรื้อ
ผังของตัวอย่างที่ 1 กว้าง 6 ยาว 9 งบรื้อ 42 สี่เหลี่ยมทองคือสิ่งกีดขวาง 5 ก้อน ตัวเลขในก้อนคือค่ารื้อของก้อนนั้น กรอบเขียวคือฐานจัตุรัสด้านยาว 4 ที่ใหญ่ที่สุดเท่าที่งบไหว วางที่มุมล่างซ้าย (1, 4) ค่ารื้อรวมของก้อนที่ทับกรอบนี้คือ 33 ซึ่งไม่เกินงบ ลองไล่ดูจะเห็นว่าด้าน 5 วางที่ไหนก็เกินงบทุกจุด (วาดประกอบโดยผู้เขียน)

ลองเอง · หาที่วางฐานให้ใหญ่ที่สุดในงบ

ตารางเดียวกับตัวอย่างแรกของโจทย์ งบ 42

ด้านยาว 1

คลิกช่องเพื่อวางมุมล่างซ้ายของฐาน แล้วปรับด้านยาว ตัวเลขในช่องคือค่ารื้อของก้อนนั้น

ต้องจ่าย 0 จากงบ 0 · ใหญ่ที่สุดที่วางได้แล้ว 0

ก้อนหนึ่งก้อนคิดเงินครั้งเดียว ไม่ว่าฐานจะทับมันกี่ช่องก็ตาม

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

เฉลย · ค้นหาคำตอบแบบไบนารี แล้วกวาดเส้นหาจุดที่ถูกที่สุด

ที่มา และสามรอบที่ยังผิด

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

บักจริงที่เกิดขึ้นอยู่ในเซกเมนต์ทรี ผมขยายขนาดมันให้เป็นกำลังสองตามปกติ แล้วปล่อยใบส่วนเกินไว้ที่ศูนย์ ค่าที่ mn[1] จึงเป็นศูนย์ตลอดกาล โปรแกรมเลยตอบว่าวางได้ทุกขนาดที่ถาม และมันตอบตัวอย่างในโจทย์ถูกทั้งสองชุด ตัวสุ่มเทียบจับได้ เคสแรกที่ปริ๊นต์ออกมาคือ M = 9, N = 6, B = 10 ของจริงตอบ 3 แต่โค้ดตอบ 4 ผมสุ่มตารางไม่เกินเก้าคูณเก้า ก้อนไม่เกินหกก้อน รวม 1,500 ชุด

พอถูกแล้วยังเหลือเรื่องเวลา รอบแรกจับเวลาได้ 8.1 วินาที ขณะที่ลิมิตคือห้าวินาที พอไล่ดูว่าเวลาไปอยู่ตรงไหน ตัวการคือการเรียงข้อมูลใหม่ทุกรอบของการค้นหาแบบไบนารี ทั้งเรียงพิกัดที่บีบแล้วและเรียงเหตุการณ์ ยี่สิบรอบก็คือเรียงยี่สิบครั้ง แก้โดยเรียงดัชนีล่วงหน้าครั้งเดียวสี่ชุด (ตาม X1, X2, Y1, Y2) แล้วผสานสองสายที่เรียงมาแล้ว เหลือ 6.6 วินาที ยังไม่พอ จึงเปลี่ยนเซกเมนต์ทรีจากแบบเรียกซ้ำเป็นแบบไล่จากใบขึ้นราก จบที่ 3.8 วินาที

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

ขั้นที่ 1 ทำไมค้นหาแบบไบนารีได้

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

โครงชั้นนอก
// s ใหญ่ขึ้น ค่าใช้จ่ายก็ไม่มีทางถูกลง เพราะจัตุรัสเล็กที่ซ้อนอยู่ข้างในย่อมชนก้อนไม่มากกว่า
// เงื่อนไข "มีที่วางได้" จึงเป็นจริงเรียงติดกันแล้วเท็จตลอด ค้นหาแบบไบนารีได้
ll lo = 0, hi = min(M, N);
while (lo < hi) {
    ll md = (lo + hi + 1) / 2;
    if (ok(md)) lo = md; else hi = md - 1;
}
ตัวอย่างที่ 1 · ค่าใช้จ่ายต่ำสุดของแต่ละขนาด (งบ 42)
ด้านยาวค่าใช้จ่ายต่ำสุดมุมล่างซ้ายที่ถูกที่สุดอยู่ในงบไหม
1 0 (1, 1) ได้
2 0 (1, 1) ได้
3 9 (4, 4) ได้
4 33 (1, 4) ได้
5 45 (1, 3) ไม่ได้
6 54 (1, 4) ไม่ได้
คอลัมน์ค่าใช้จ่ายไม่มีทางลดลงเมื่อด้านยาวขึ้น คำตอบจึงเป็น 4 คือขนาดสุดท้ายที่ยังอยู่ในงบ ตัวเลขทุกตัวคำนวณจากการไล่ทุกตำแหน่งจริง

ขั้นที่ 2 พลิกจากพื้นที่ฐานไปเป็นมุมล่างซ้าย

ตรึง s ไว้แล้วถามว่ามุมล่างซ้ายวางตรงไหนได้บ้าง จัตุรัสที่มุมอยู่ที่ (x, y) ชนก้อนที่ i ก็ต่อเมื่อช่วงของมันเหลื่อมกับช่วงของก้อนทั้งสองแกน ซึ่งเขียนออกมาได้ว่า

x ต้องอยู่ระหว่าง X1 − s + 1 ถึง X2 และ y ต้องอยู่ระหว่าง Y1 − s + 1 ถึง Y2

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

ขั้นที่ 3 กวาดเส้นตามแกน x

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

พิกัดใหญ่ถึงล้าน จึงต้องบีบพิกัดแกน y ให้เหลือเฉพาะจุดที่ค่าเปลี่ยนจริง ซึ่งมีไม่เกิน 2P จุด และเช่นเดียวกัน ตำแหน่ง x ที่ต้องถามก็มีแค่จุดที่มีเหตุการณ์เกิด

ระวัง

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

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

อีกมุมหนึ่ง: ชุดแรก 35 คะแนน ไม่ต้องใช้เซกเมนต์ทรีเลย

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

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

ดูโค้ดของชุดทดสอบย่อยชุดแรก
pyramid_sub.cpp
// เฉลยชุดทดสอบย่อยชุดแรก งบเป็นศูนย์ และสิ่งกีดขวางไม่เกิน 1,000 ก้อน (35 คะแนน)
// งบเป็นศูนย์แปลว่าห้ามชนก้อนไหนเลย คำถามจึงเหลือแค่ "มีที่ว่างขนาดนี้ไหม"
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
int P; ll M, N;
vector<ll> x1_, y1_, x2_, y2_;

bool ok(ll s) {
    if (M - s + 1 < 1 || N - s + 1 < 1) return false;
    const ll XH = M - s + 1, YH = N - s + 1;
    // มุมล่างซ้ายที่น่าลองมีแค่ 1 กับขอบซ้ายของแต่ละก้อนที่เลื่อนมา s-1 ช่อง
    vector<ll> xs{1};
    for (int i = 0; i < P; i++) { ll v = x2_[i] + 1; if (v <= XH) xs.push_back(v); }
    sort(xs.begin(), xs.end()); xs.erase(unique(xs.begin(), xs.end()), xs.end());

    vector<pair<ll,ll>> bad;
    for (ll x : xs) {
        bad.clear();
        for (int i = 0; i < P; i++) {
            if (x + s - 1 < x1_[i] || x > x2_[i]) continue;          // ก้อนนี้ไม่ขวางที่คอลัมน์นี้
            ll lo = max(1LL, y1_[i] - s + 1), hi = min(YH, y2_[i]);
            if (lo <= hi) bad.push_back({lo, hi});
        }
        sort(bad.begin(), bad.end());
        ll cur = 1;                                                  // ไล่หาช่องว่างแรกที่ไม่มีใครทับ
        for (auto& [lo, hi] : bad) {
            if (lo > cur) break;
            cur = max(cur, hi + 1);
        }
        if (cur <= YH) return true;
    }
    return false;
}

int main() {
    ll B;
    scanf("%lld %lld %lld %d", &M, &N, &B, &P);
    x1_.resize(P); y1_.resize(P); x2_.resize(P); y2_.resize(P);
    for (int i = 0; i < P; i++) { ll c; scanf("%lld %lld %lld %lld %lld", &x1_[i], &y1_[i], &x2_[i], &y2_[i], &c); }
    ll lo = 0, hi = min(M, N);
    while (lo < hi) { ll md = (lo + hi + 1) / 2; if (ok(md)) lo = md; else hi = md - 1; }
    printf("%lld\n", lo);
}

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

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

บิลค่าใช้จ่าย · เทียบสองทางด้วยหน่วยเดียวกัน
ทางขอบเขตที่มันไหวงานคร่าว ๆ
ไล่ช่วง y ที่ถูกห้าม ต่อหนึ่งขนาด P หลักพัน และ B = 0 20,000,000 ครั้ง
ค้นหาไบนารีคู่กับเซกเมนต์ทรี P ถึง 400,000 152,000,000 ครั้ง
ทั้งสองแถวคูณจำนวนรอบของการค้นหาแบบไบนารีไว้แล้ว จึงเทียบกันได้ตรง ๆ แต่ตัวเลขไม่ใช่ประเด็นเดียว ทางบนตอบได้แค่คำถามว่ามีช่องว่างไหม ส่วนทางล่างตอบคำถามว่าผลรวมต่ำสุดเท่าไร ซึ่งเป็นคำถามที่แพงกว่าและครอบคำถามแรกอยู่ในตัว ชุดทดสอบที่มีงบจริงจึงใช้ทางบนไม่ได้เลยแม้ P จะเล็ก

โค้ด C++

เวอร์ชันแรกที่ผมเขียนใช้เวลา 8.1 วินาที ซึ่งเกินลิมิต 5 วินาที ตัวการคือการเรียงข้อมูลใหม่ทุกรอบ ของการค้นหาแบบไบนารี ทั้งเรียงพิกัดที่บีบแล้วและเรียงเหตุการณ์ รวมยี่สิบรอบก็เป็นการเรียงยี่สิบครั้ง

ทางแก้คือเรียงดัชนีไว้ล่วงหน้าครั้งเดียวสี่ชุด (ตาม X1, X2, Y1, Y2) เพราะค่าที่ต้องใช้ในแต่ละรอบเป็นฟังก์ชันไม่ลดของค่าเหล่านั้น ลำดับจึงไม่เปลี่ยน แค่ผสานสองสายที่เรียงมาแล้วก็พอ เหลือ 6.6 วินาที

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

ดูโค้ดเต็ม
pyramid.cpp
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
int P; ll M,N,B;
vector<ll> X1,Y1,X2,Y2,C;
vector<int> byX1,byX2,byY1,byY2;
int SZ; vector<ll> mn,lz;
const ll INF=(ll)4e18;
void build(int k){
    SZ=1; while(SZ<k) SZ<<=1;
    mn.assign(2*SZ,0); lz.assign(2*SZ,0);
    for(int i=k;i<SZ;i++) mn[SZ+i]=INF;             // ใบส่วนเกินต้องเป็นอนันต์ ไม่ใช่ศูนย์
    for(int i=SZ-1;i>=1;i--) mn[i]=min(mn[2*i],mn[2*i+1]);
}
// บวกทั้งช่วงแบบไล่จากใบขึ้นราก ไม่ใช้การเรียกซ้ำ เพราะรอบนี้ถูกเรียกหลายสิบล้านครั้ง
void add(int l,int r,ll v){
    int L=l+SZ, R=r+SZ+1, l0=L, r0=R-1;
    while(L<R){
        if(L&1){ mn[L]+=v; lz[L]+=v; L++; }
        if(R&1){ --R; mn[R]+=v; lz[R]+=v; }
        L>>=1; R>>=1;
    }
    for(int i=l0>>1;i>=1;i>>=1) mn[i]=min(mn[2*i],mn[2*i+1])+lz[i];
    for(int i=r0>>1;i>=1;i>>=1) mn[i]=min(mn[2*i],mn[2*i+1])+lz[i];
}
vector<ll> cand; vector<int> klOf,krOf;
vector<ll> evX; vector<int> evI; vector<char> evAdd;
bool ok(ll s){
    if(M-s+1<1||N-s+1<1) return false;
    const ll YH=N-s+1, XH=M-s+1;
    // ผู้สมัครแกน y  รวมสองสายที่เรียงอยู่แล้ว จึงไม่ต้องเรียงใหม่ทุกรอบ
    cand.clear(); cand.push_back(1);
    { size_t a=0,b=0; ll va,vb;
      auto nextA=[&](){ while(a<(size_t)P){ int i=byY1[a]; ll lo=max(1LL,Y1[i]-s+1), hi=min(YH,Y2[i]); a++; if(lo<=hi){ va=lo; return true; } } return false; };
      auto nextB=[&](){ while(b<(size_t)P){ int i=byY2[b]; ll lo=max(1LL,Y1[i]-s+1), hi=min(YH,Y2[i]); b++; if(lo<=hi&&hi+1<=YH){ vb=hi+1; return true; } } return false; };
      bool ha=nextA(), hb=nextB();
      while(ha||hb){ ll v; if(!hb||(ha&&va<=vb)){ v=va; ha=nextA(); } else { v=vb; hb=nextB(); }
        if(cand.back()!=v) cand.push_back(v); } }
    int K=(int)cand.size(); build(K);
    // ดัชนีขอบล่าง/ขอบบนของแต่ละก้อน หาแบบสองตัวชี้ เพราะทั้งสองสายเรียงมาแล้ว
    { size_t p=0; for(int t=0;t<P;t++){ int i=byY1[t]; ll lo=max(1LL,Y1[i]-s+1), hi=min(YH,Y2[i]); if(lo>hi){ klOf[i]=-1; continue; }
        while(p+1<cand.size()&&cand[p+1]<=lo) p++; while(cand[p]>lo&&p>0) p--; klOf[i]=(int)p; } }
    { size_t p=0; for(int t=0;t<P;t++){ int i=byY2[t]; ll lo=max(1LL,Y1[i]-s+1), hi=min(YH,Y2[i]); if(lo>hi){ krOf[i]=-1; continue; }
        while(p+1<cand.size()&&cand[p+1]<=hi) p++; krOf[i]=(int)p; } }
    // เหตุการณ์แกน x  สองสายเรียงอยู่แล้วเช่นกัน จึงแค่ผสาน
    evX.clear(); evI.clear(); evAdd.clear();
    { size_t a=0,b=0;
      auto valA=[&](size_t t){ int i=byX1[t]; return max(1LL,X1[i]-s+1); };
      auto valB=[&](size_t t){ int i=byX2[t]; return min(XH,X2[i])+1; };
      auto usable=[&](int i){ ll xl=max(1LL,X1[i]-s+1), xr=min(XH,X2[i]); return klOf[i]>=0 && xl<=xr; };
      while(a<(size_t)P||b<(size_t)P){
        bool takeA;
        if(a>=(size_t)P) takeA=false; else if(b>=(size_t)P) takeA=true; else takeA = valA(a)<=valB(b);
        if(takeA){ int i=byX1[a]; if(usable(i)){ evX.push_back(valA(a)); evI.push_back(i); evAdd.push_back(1);} a++; }
        else { int i=byX2[b]; ll v=valB(b); if(usable(i)&&v<=XH){ evX.push_back(v); evI.push_back(i); evAdd.push_back(0);} b++; }
      } }
    size_t e=0; ll cur=1;
    while(cur<=XH){
        while(e<evX.size()&&evX[e]<=cur){ int i=evI[e]; add(klOf[i],krOf[i], evAdd[e]?C[i]:-C[i]); e++; }
        if(mn[1]<=B) return true;
        cur = (e<evX.size()) ? evX[e] : XH+1;
    }
    return false;
}
int main(){
    if(scanf("%lld %lld",&M,&N)!=2) return 0;
    scanf("%lld",&B); scanf("%d",&P);
    X1.resize(P);Y1.resize(P);X2.resize(P);Y2.resize(P);C.resize(P);
    klOf.assign(P,-1); krOf.assign(P,-1);
    for(int i=0;i<P;i++) scanf("%lld %lld %lld %lld %lld",&X1[i],&Y1[i],&X2[i],&Y2[i],&C[i]);
    byX1.resize(P);byX2.resize(P);byY1.resize(P);byY2.resize(P);
    for(int i=0;i<P;i++) byX1[i]=byX2[i]=byY1[i]=byY2[i]=i;
    sort(byX1.begin(),byX1.end(),[](int a,int b){return X1[a]<X1[b];});
    sort(byX2.begin(),byX2.end(),[](int a,int b){return X2[a]<X2[b];});
    sort(byY1.begin(),byY1.end(),[](int a,int b){return Y1[a]<Y1[b];});
    sort(byY2.begin(),byY2.end(),[](int a,int b){return Y2[a]<Y2[b];});
    ll lo=0,hi=min(M,N);
    while(lo<hi){ ll md=(lo+hi+1)/2; if(ok(md)) lo=md; else hi=md-1; }
    printf("%lld\n",lo);
}

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

ดูโค้ดที่ผิด กับเคสที่จับมันได้

รอบแรกผมขยายเซกเมนต์ทรีขึ้นเป็นกำลังสองตามความเคยชิน แล้วเผลอตั้งใบทั้งต้นเป็นศูนย์ในคราวเดียว ใบที่ไม่มีผู้สมัครแกน y จริงจึงถือค่าศูนย์ติดตัวไว้ และค่าน้อยสุดของทั้งต้นก็เป็นศูนย์ไปด้วย บรรทัดเดียวกันในโค้ดที่ส่งจริงเขียนว่า mn[SZ+i]=INF

pyramid_wrong.cpp · ตัดมาเฉพาะส่วนที่ผิด
const ll INF = (ll)4e18;
int SZ; vector<ll> mn, lz;

void build(int k){
    SZ = 1; while (SZ < k) SZ <<= 1;
    mn.assign(2 * SZ, 0); lz.assign(2 * SZ, 0);
    for (int i = k; i < SZ; i++) mn[SZ + i] = 0;   // <-- บักอยู่บรรทัดนี้ ใบส่วนเกินถูกปล่อยไว้ที่ศูนย์
    for (int i = SZ - 1; i >= 1; i--) mn[i] = min(mn[2 * i], mn[2 * i + 1]);
}

// ปลายทางที่บักโผล่ อยู่ในลูปกวาดเส้นของ ok()
if (mn[1] <= B) return true;   // mn[1] ถูกใบศูนย์กดไว้ตลอด บรรทัดนี้จึงตอบ true ทุกครั้ง

ตัวอย่างสองชุดในโจทย์ผ่านทั้งคู่ เพราะคำตอบของทั้งสองชุดบังเอิญตรงกับขนาดที่โค้ดผิดตอบพอดี เคสแรกที่ตัวสุ่มเทียบปริ๊นต์ออกมาคือชุดนี้

อินพุตที่ทำให้ตก · ตาราง 9 × 6 งบ 10
9 6
10
6
2 5 4 5 8
4 4 7 5 3
6 2 9 6 8
4 4 4 6 9
7 6 7 6 5
4 6 7 6 4

ของจริงตอบ 3 โค้ดที่ผิดตอบ 4 ที่ขนาด 4 ตำแหน่งที่ถูกที่สุดยังต้องจ่าย เกินงบ 10 อยู่ แต่ mn[1] รายงานศูนย์ การค้นหาแบบไบนารีจึงเชื่อว่าขนาด 4 วางได้แล้วขยับขึ้นไป

เครื่องมือของข้อนี้อยู่ในบทปูพื้นฐาน เซกเมนต์ทรีกับการค้นหาคำตอบแบบไบนารี

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

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

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

แหล่งที่มา

  1. โจทย์ Pyramid Base บน programming.in.th ข้อ 2013 programming.in.th/tasks/2013 (สืบค้น 6 กันยายน 2026)
  2. ต้นทางของโจทย์คือ 20th International Olympiad in Informatics ที่กรุงไคโร ประเทศอียิปต์ วันแข่งที่ 2