ปูพื้นฐาน

เซกเมนต์ทรีกับการค้นหาคำตอบแบบไบนารี: คู่หูที่ปลดล็อกโจทย์ระดับยากได้ทั้งชั้น

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

บทปูพื้นฐาน ★★☆☆☆ segment treebinary searchพื้นฐาน อ่าน 31 นาที 7 กันยายน 2026

ปัญหาที่บทนี้แก้

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

แกะคำศัพท์

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

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

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

ลองเอง · ตอบค่าน้อยสุดหลังบวกทั้งช่วง

บวกทีละช่วง แล้วถามค่าน้อยสุด ลองไล่ด้วยตาก่อน

คำถามตอนนี้

อ่านคำถามแล้วพิมพ์ค่าน้อยสุดของช่วงที่ถูกไฮไลต์

พิมพ์ไว้ - · ตอบถูกแล้ว 0 จาก 0 ข้อ

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

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

ส่วนที่ 1 เซกเมนต์ทรีแบบมีป้ายค้าง

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

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

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

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

กดถัดไปเพื่อเดินคำถามทีละปม

5 [0] 2 [1] 2 0..1 9 [2] 1 [3] 1 2..3 1 0..3 7 [4] 3 [5] 3 4..5 8 [6] 4 [7] 4 6..7 3 4..7 1 0..7

คำถามคือค่าน้อยสุดของช่อง 3 ถึง 6 · เขียวคือปมที่ตอบได้ทั้งก้อน ทองคือปมที่ต้องเดินผ่านลงไป เทาเข้มคือปมที่ถูกตัดทิ้งทั้งกิ่ง

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

แกะคำศัพท์

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

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

อาเรย์ 200,000 ช่อง คำสั่ง 200,000 ครั้ง
แนวคิดต้นทุนต่อหนึ่งคำสั่งรวม
แก้ทีละช่อง ถามทีละช่องn ต่อหนึ่งคำสั่ง40,000,000,000
เซกเมนต์ทรีแบบมีป้ายค้างราว 2 log n ต่อหนึ่งคำสั่ง7,043,856
ต่างกันราว 5,679 เท่า และนี่คือเหตุผลเดียวที่เราต้องยอมเขียนโค้ดยาวขึ้นสี่สิบบรรทัด

ระวัง

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

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

โจทย์ฝึกข้อที่ 1 · บวกทั้งช่วง ถามค่าน้อยสุดของช่วง

ให้อาเรย์ยาว n แล้วมีคำสั่ง q ครั้ง แบบ 1 l r v คือบวก v ให้ทุกช่องในช่วง และแบบ 2 l r คือถามค่าน้อยสุดในช่วง

EXAMPLE
InputOutput
8 5
5 2 9 1 7 3 8 4
2 0 7
1 2 5 -3
2 3 6
1 0 3 4
2 0 4
1
-2
2

ใบ้

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

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

เฉลยข้อที่ 1

ที่มาของท่านี้ · กับดักที่ผู้เขียนตกมาแล้วจริง

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

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

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

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

lazy_min.cpp
// เซกเมนต์ทรีแบบบวกทั้งช่วง ถามค่าน้อยสุดของช่วง
// คำสั่ง "1 l r v" คือบวก v ให้ทุกช่องในช่วง  "2 l r" คือถามค่าน้อยสุดในช่วง
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
int SZ; vector<ll> mn, lz;
void build(int n) {
    SZ = 1; while (SZ < n) SZ <<= 1;
    mn.assign(2 * SZ, 0); lz.assign(2 * SZ, 0);
    for (int i = n; i < SZ; i++) mn[SZ + i] = LLONG_MAX / 4;   // ใบส่วนเกินต้องเป็นอนันต์
    for (int i = SZ - 1; i >= 1; i--) mn[i] = min(mn[2 * i], mn[2 * i + 1]);
}
void add(int node, int l, int r, int ql, int qr, ll v) {
    if (qr < l || r < ql) return;
    if (ql <= l && r <= qr) { mn[node] += v; lz[node] += v; return; }
    int m = (l + r) / 2;
    add(2 * node, l, m, ql, qr, v); add(2 * node + 1, m + 1, r, ql, qr, v);
    mn[node] = min(mn[2 * node], mn[2 * node + 1]) + lz[node];
}
ll query(int node, int l, int r, int ql, int qr) {
    if (qr < l || r < ql) return LLONG_MAX / 4;
    if (ql <= l && r <= qr) return mn[node];
    int m = (l + r) / 2;
    return min(query(2 * node, l, m, ql, qr), query(2 * node + 1, m + 1, r, ql, qr)) + lz[node];
}
int main() {
    int n, q; scanf("%d %d", &n, &q);
    build(n);
    for (int i = 0; i < n; i++) { ll v; scanf("%lld", &v); add(1, 0, SZ - 1, i, i, v); }
    while (q--) {
        int t, l, r; scanf("%d %d %d", &t, &l, &r);
        if (t == 1) { ll v; scanf("%lld", &v); add(1, 0, SZ - 1, l, r, v); }
        else printf("%lld\n", query(1, 0, SZ - 1, l, r));
    }
}
ไล่คำสั่งทีละครั้งบนตัวอย่าง
ครั้งที่คำสั่งอาเรย์หลังทำตอบ
1 ถามค่าน้อยสุดของช่อง 0 ถึง 7 5 2 9 1 7 3 8 4 1
2 บวก -3 ให้ช่อง 2 ถึง 5 5 2 6 -2 4 0 8 4
3 ถามค่าน้อยสุดของช่อง 3 ถึง 6 5 2 6 -2 4 0 8 4 -2
4 บวก 4 ให้ช่อง 0 ถึง 3 9 6 10 2 4 0 8 4
5 ถามค่าน้อยสุดของช่อง 0 ถึง 4 9 6 10 2 4 0 8 4 2
คอลัมน์อาเรย์คือสถานะจริงหลังทำคำสั่งนั้น ซึ่งเซกเมนต์ทรีไม่เคยสร้างขึ้นมาทั้งใบเลย มันเก็บแค่คำตอบของช่วงกับป้ายค้าง แล้วประกอบร่างคำตอบตอนถูกถาม

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

ส่วนที่ 2 ค้นหาคำตอบแบบไบนารี

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

ขั้นตอนคิดที่ใช้ได้ทุกครั้งมีสามข้อ หนึ่ง เขียนฟังก์ชัน ok(x) ที่ตอบว่าค่านี้ทำได้ไหม สอง พิสูจน์กับตัวเองว่ามันเรียงทางเดียวจริง สาม หาขอบบนที่ปลอดภัย

ระวัง

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

โจทย์ฝึกข้อที่ 2 · เครื่องจักรกับเวลาที่น้อยที่สุด

มีเครื่องจักร n เครื่อง เครื่องที่ i ผลิตได้หนึ่งชิ้นทุก t หน่วยเวลา ทุกเครื่องทำงานพร้อมกัน ถามว่าอย่างน้อยต้องใช้เวลาเท่าไรถึงจะได้ของครบ m ชิ้น

EXAMPLE
InputOutput
3 10
3 5 7
15

ใบ้

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

เฉลยข้อที่ 2

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

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

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

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

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

ไล่การค้นหาแบบไบนารีของตัวอย่าง (ต้องการ 10 ชิ้น)
ช่วงล่างช่วงบนลองเวลา ผลิตได้พอไหม
1 30 15 10 พอ
1 15 8 4 ไม่พอ
9 15 12 7 ไม่พอ
13 15 14 8 ไม่พอ
คอลัมน์ผลิตได้ไม่เคยลดลงเมื่อเวลาเพิ่ม ซึ่งเป็นเงื่อนไขเดียวที่ท่านี้ต้องการ ใช้ 4 รอบก็ได้คำตอบ 15 จากช่วงที่กว้าง 30
binary_answer.cpp
// เครื่องจักร n เครื่อง เครื่องที่ i ผลิตได้หนึ่งชิ้นทุก t[i] หน่วยเวลา
// ถามว่าอย่างน้อยต้องใช้เวลาเท่าไรถึงจะได้ครบ m ชิ้น
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
int n; ll m;
vector<ll> t;
// ภายในเวลา T เครื่องที่ i ผลิตได้ T / t[i] ชิ้น  ฟังก์ชันนี้ไม่มีทางลดลงเมื่อ T เพิ่ม
bool enough(ll T) {
    ll made = 0;
    for (ll x : t) {
        made += T / x;
        if (made >= m) return true;              // ตัดตั้งแต่พอ กันค่าล้น
    }
    return false;
}
int main() {
    scanf("%d %lld", &n, &m);
    t.resize(n);
    for (auto& v : t) scanf("%lld", &v);
    ll lo = 1, hi = *min_element(t.begin(), t.end()) * m;   // เพดานคือใช้เครื่องเร็วสุดเครื่องเดียว
    while (lo < hi) { ll md = lo + (hi - lo) / 2; if (enough(md)) hi = md; else lo = md + 1; }
    printf("%lld\n", lo);
}

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

ผมสุ่มชุดเครื่องจักรเล็ก ๆ 1,500 ชุดเทียบกับการไล่เวลาทีละหน่วย ตรงกันหมด

อีกรูปหนึ่งของต้นไม้เดียวกัน · แบบวนซ้ำ ที่ไม่เรียกตัวเองเลย

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

กุญแจอยู่ที่การเปลี่ยนวิธีเก็บต้นไม้ ขยายขนาดขึ้นเป็นกำลังสองที่ใกล้ที่สุดเรียกว่า sz แล้วเก็บทั้งต้นไม้ในอาเรย์เดียวขนาด 2 · sz โดยให้ ใบที่ i อยู่ที่ตำแหน่ง sz + i พอวางแบบนี้ ความสัมพันธ์ทั้งหมด กลายเป็นเลขคณิตของบิต คือพ่อของ p คือ p / 2 และลูกของ p คือ 2p กับ 2p + 1 เราจึงเดินขึ้นลงได้โดยไม่ต้องเรียกซ้ำ

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

ระวัง กับดักเดิมที่กลับมาอีกรูป

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

iterative_segtree.cpp
// เซกเมนต์ทรีแบบวนซ้ำ (iterative) หาค่าน้อยสุดของช่วง กับแก้ทีละช่อง
// ไม่มีการเรียกซ้ำเลย ต้นไม้เก็บในอาเรย์เดียว ใบเริ่มที่ตำแหน่ง sz
// อินพุต: n q, อาเรย์, แล้ว q บรรทัด  1 i v = ตั้ง a[i]=v, 2 l r = ถามค่าน้อยสุดช่วง l..r
#include <bits/stdc++.h>
using namespace std;

int main() {
    int n, q;
    if (scanf("%d %d", &n, &q) != 2) return 0;
    int sz = 1;
    while (sz < n) sz <<= 1;
    const long long INF = (long long)4e18;
    vector<long long> t(2 * sz, INF);           // ใบส่วนเกินต้องเป็น INF ไม่ใช่ 0
    for (int i = 0; i < n; i++) scanf("%lld", &t[sz + i]);
    for (int i = sz - 1; i >= 1; i--) t[i] = min(t[2 * i], t[2 * i + 1]);

    for (int k = 0; k < q; k++) {
        int op; scanf("%d", &op);
        if (op == 1) {
            int i; long long v; scanf("%d %lld", &i, &v);
            int p = sz + i;
            t[p] = v;
            for (p >>= 1; p >= 1; p >>= 1) t[p] = min(t[2 * p], t[2 * p + 1]);
        } else {
            int l, r; scanf("%d %d", &l, &r);
            // ไต่จากใบขึ้นไป เก็บค่าของฝั่งที่หลุดออกจากคู่ของตัวเอง
            long long res = INF;
            int a = l + sz, b = r + sz + 1;
            while (a < b) {
                if (a & 1) res = min(res, t[a++]);
                if (b & 1) res = min(res, t[--b]);
                a >>= 1;
                b >>= 1;
            }
            printf("%lld\n", res);
        }
    }
    return 0;
}
ตัวตรวจที่ทำให้กล้าเชื่อโค้ดข้างบน

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

brute_range_min.cpp
// ตัวตรวจอิสระของเซกเมนต์ทรีแบบวนซ้ำ: กวาดหาค่าน้อยสุดทีละช่อง
// ไม่มีต้นไม้ ไม่มีการไต่บิต จึงไม่ได้ทดสอบแค่การพิมพ์
#include <bits/stdc++.h>
using namespace std;

int main() {
    int n, q;
    if (scanf("%d %d", &n, &q) != 2) return 0;
    vector<long long> a(n);
    for (int i = 0; i < n; i++) scanf("%lld", &a[i]);
    for (int k = 0; k < q; k++) {
        int op; scanf("%d", &op);
        if (op == 1) {
            int i; long long v; scanf("%d %lld", &i, &v);
            a[i] = v;
        } else {
            int l, r; scanf("%d %d", &l, &r);
            long long res = a[l];
            for (int i = l; i <= r; i++) res = min(res, a[i]);
            printf("%lld\n", res);
        }
    }
    return 0;
}

สุ่มเทียบ 700 รอบ ตรงกันทุกรอบ โดยสุ่ม n ให้กระจายทั้งค่าที่เป็น กำลังสองพอดีและค่าที่ไม่ใช่ เพราะกรณีที่ n ไม่เป็นกำลังสองคือกรณีที่มีใบส่วนเกินจริง ที่ขอบเขตจริง n และคำถาม 200,000 ครั้ง โค้ดนี้ใช้เวลา 0.10 วินาที และหน่วยความจำ 7.3 เมกะไบต์


ส่วนที่ 3 เปลี่ยนของที่เก็บในปม แล้วต้นไม้ต้นเดิมตอบคำถามใหม่ได้

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

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

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

ค่ามากสุด และจำนวนของมัน

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

max_count.cpp
// เซกเมนต์ทรีที่ปมเก็บ "ค่ามากสุด และมีกี่ตัวที่เท่ากับค่ามากสุด"
// อินพุต: n q, อาเรย์, แล้ว q บรรทัด  1 i v = ตั้ง a[i]=v, 2 l r = ถามค่ามากสุดและจำนวนของมันในช่วง
#include <bits/stdc++.h>
using namespace std;

struct Node { long long mx; int cnt; };

// รวมลูกสองตัวเป็นพ่อ ถ้าค่ามากสุดเท่ากันต้องบวกจำนวนของทั้งสองฝั่ง
Node join(const Node& a, const Node& b) {
    if (a.mx > b.mx) return a;
    if (b.mx > a.mx) return b;
    return { a.mx, a.cnt + b.cnt };
}

int n;
vector<Node> t;

void build(const vector<long long>& a, int v, int l, int r) {
    if (l == r) { t[v] = { a[l], 1 }; return; }
    int m = (l + r) / 2;
    build(a, 2 * v, l, m);
    build(a, 2 * v + 1, m + 1, r);
    t[v] = join(t[2 * v], t[2 * v + 1]);
}
void update(int v, int l, int r, int pos, long long val) {
    if (l == r) { t[v] = { val, 1 }; return; }
    int m = (l + r) / 2;
    if (pos <= m) update(2 * v, l, m, pos, val);
    else update(2 * v + 1, m + 1, r, pos, val);
    t[v] = join(t[2 * v], t[2 * v + 1]);
}
Node query(int v, int l, int r, int ql, int qr) {
    if (ql <= l && r <= qr) return t[v];
    int m = (l + r) / 2;
    if (qr <= m) return query(2 * v, l, m, ql, qr);
    if (ql > m) return query(2 * v + 1, m + 1, r, ql, qr);
    return join(query(2 * v, l, m, ql, qr), query(2 * v + 1, m + 1, r, ql, qr));
}

int main() {
    int q;
    if (scanf("%d %d", &n, &q) != 2) return 0;
    vector<long long> a(n);
    for (auto& x : a) scanf("%lld", &x);
    t.assign(4 * n, { LLONG_MIN, 0 });
    build(a, 1, 0, n - 1);
    while (q--) {
        int op; scanf("%d", &op);
        if (op == 1) {
            int i; long long v; scanf("%d %lld", &i, &v);
            update(1, 0, n - 1, i, v);
        } else {
            int l, r; scanf("%d %d", &l, &r);
            Node res = query(1, 0, n - 1, l, r);
            printf("%lld %d\n", res.mx, res.cnt);
        }
    }
    return 0;
}

ห.ร.ม. ของช่วง

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

gcd_seg.cpp
// ห.ร.ม. ของช่วง ต่างจากผลรวมแค่ฟังก์ชันที่ใช้รวมลูกสองตัวเท่านั้น
// อินพุต: n q, อาเรย์, แล้ว q บรรทัด  1 i v = ตั้ง a[i]=v, 2 l r = ถาม ห.ร.ม. ของช่วง
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

// ค่ากลางของ ห.ร.ม. คือศูนย์ เพราะ gcd(x, 0) เท่ากับ x
// ถ้าเผลอใช้หนึ่งเป็นค่ากลางแบบเดียวกับการคูณ คำตอบจะกลายเป็นหนึ่งทั้งหมด
const ll NEUTRAL = 0;
ll join(ll a, ll b) { return __gcd(a, b); }

int n;
vector<ll> t;

void build(const vector<ll>& a, int v, int l, int r) {
    if (l == r) { t[v] = a[l]; return; }
    int m = (l + r) / 2;
    build(a, 2 * v, l, m);
    build(a, 2 * v + 1, m + 1, r);
    t[v] = join(t[2 * v], t[2 * v + 1]);
}
void update(int v, int l, int r, int pos, ll val) {
    if (l == r) { t[v] = val; return; }
    int m = (l + r) / 2;
    if (pos <= m) update(2 * v, l, m, pos, val);
    else update(2 * v + 1, m + 1, r, pos, val);
    t[v] = join(t[2 * v], t[2 * v + 1]);
}
ll query(int v, int l, int r, int ql, int qr) {
    if (qr < l || r < ql) return NEUTRAL;
    if (ql <= l && r <= qr) return t[v];
    int m = (l + r) / 2;
    return join(query(2 * v, l, m, ql, qr), query(2 * v + 1, m + 1, r, ql, qr));
}

int main() {
    int q;
    if (scanf("%d %d", &n, &q) != 2) return 0;
    vector<ll> a(n);
    for (auto& x : a) scanf("%lld", &x);
    t.assign(4 * n, NEUTRAL);
    build(a, 1, 0, n - 1);
    while (q--) {
        int op; scanf("%d", &op);
        if (op == 1) {
            int i; ll v; scanf("%d %lld", &i, &v);
            update(1, 0, n - 1, i, v);
        } else {
            int l, r; scanf("%d %d", &l, &r);
            printf("%lld\n", query(1, 0, n - 1, l, r));
        }
    }
    return 0;
}

ผลรวมของช่วงย่อยที่มากที่สุด

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

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

maxsub.cpp
// เซกเมนต์ทรีที่ปมเก็บสี่ค่า เพื่อตอบ "ผลรวมของช่วงย่อยที่มากที่สุด" ในช่วงที่ถาม
// สี่ค่านั้นคือ ผลรวมทั้งก้อน, ผลรวมที่ดีที่สุดเมื่อเริ่มจากขอบซ้าย, เมื่อจบที่ขอบขวา, และที่ดีที่สุดโดยรวม
// อินพุต: n q, อาเรย์, แล้ว q บรรทัด  1 i v = ตั้ง a[i]=v, 2 l r = ถามผลรวมช่วงย่อยที่มากที่สุด (ห้ามว่าง)
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

struct Node { ll sum, pre, suf, best; };

// หัวใจอยู่ตรงนี้ ช่วงย่อยที่ดีที่สุดของพ่อ มีได้แค่สามแบบ
// อยู่ในลูกซ้ายล้วน อยู่ในลูกขวาล้วน หรือคร่อมรอยต่อ ซึ่งคือหางขวาของซ้าย บวกหัวซ้ายของขวา
Node join(const Node& a, const Node& b) {
    Node c;
    c.sum  = a.sum + b.sum;
    c.pre  = max(a.pre, a.sum + b.pre);
    c.suf  = max(b.suf, b.sum + a.suf);
    c.best = max(max(a.best, b.best), a.suf + b.pre);
    return c;
}
Node leafOf(ll v) { return { v, v, v, v }; }

int n;
vector<Node> t;

void build(const vector<ll>& a, int v, int l, int r) {
    if (l == r) { t[v] = leafOf(a[l]); return; }
    int m = (l + r) / 2;
    build(a, 2 * v, l, m);
    build(a, 2 * v + 1, m + 1, r);
    t[v] = join(t[2 * v], t[2 * v + 1]);
}
void update(int v, int l, int r, int pos, ll val) {
    if (l == r) { t[v] = leafOf(val); return; }
    int m = (l + r) / 2;
    if (pos <= m) update(2 * v, l, m, pos, val);
    else update(2 * v + 1, m + 1, r, pos, val);
    t[v] = join(t[2 * v], t[2 * v + 1]);
}
Node query(int v, int l, int r, int ql, int qr) {
    if (ql <= l && r <= qr) return t[v];
    int m = (l + r) / 2;
    if (qr <= m) return query(2 * v, l, m, ql, qr);
    if (ql > m) return query(2 * v + 1, m + 1, r, ql, qr);
    return join(query(2 * v, l, m, ql, qr), query(2 * v + 1, m + 1, r, ql, qr));
}

int main() {
    int q;
    if (scanf("%d %d", &n, &q) != 2) return 0;
    vector<ll> a(n);
    for (auto& x : a) scanf("%lld", &x);
    t.assign(4 * n, leafOf(0));
    build(a, 1, 0, n - 1);
    while (q--) {
        int op; scanf("%d", &op);
        if (op == 1) {
            int i; ll v; scanf("%d %lld", &i, &v);
            update(1, 0, n - 1, i, v);
        } else {
            int l, r; scanf("%d %d", &l, &r);
            printf("%lld\n", query(1, 0, n - 1, l, r).best);
        }
    }
    return 0;
}

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


ส่วนที่ 4 เดินลงต้นไม้ครั้งเดียว แทนการค้นหาแบบไบนารีทับต้นไม้

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

แต่ในหลายกรณีเราทำได้ดีกว่านั้น เพราะต้นไม้เองก็เป็นการแบ่งครึ่งอยู่แล้ว แทนที่จะเดาค่าจากข้างนอกแล้วถามเข้าไป เรายืนที่รากแล้วถามลูกซ้ายว่า "คำตอบอยู่ในฝั่งเธอไหม" ถ้าอยู่ก็ลงซ้าย ถ้าไม่อยู่ก็ลงขวา ทำแบบนี้ไปจนถึงใบ ได้คำตอบใน log n ครั้งเดียว ท่านี้เรียกว่าการเดินลงต้นไม้ (descent อ่านว่า "ดีเซนต์" แปลว่า การไต่ลง)

ลองเอง · เดินลงต้นไม้หาศูนย์ตัวที่ k

อาเรย์ข้างล่างมีแต่ 0 กับ 1 ตัวเลขในปมคือจำนวนศูนย์ในช่วงที่ปมนั้นดูแล โจทย์คือหาว่าศูนย์ตัวที่ k อยู่ช่องไหน โดยห้ามไล่ดูทีละช่อง ให้เลือกลงซ้ายหรือขวาทีละชั้นเท่านั้น

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

0 0..7 0 0..3 0 4..7 0 0..1 0 2..3 0 4..5 0 6..7 0 [0] 0 [1] 0 [2] 0 [3] 0 [4] 0 [5] 0 [6] 0 [7]

ค่าจริงในอาเรย์

กดเลือกด่านแล้วเริ่มเดินจากราก

ผ่านแล้ว 0 จาก 0 ข้อ

ที่ปมหนึ่ง ให้ดูจำนวนศูนย์ของลูกซ้าย ถ้ามันมีศูนย์พอที่ k ตัวก็ลงซ้าย ถ้าไม่พอ ศูนย์ตัวที่ต้องการอยู่ทางขวา และ k ที่เหลือต้องหักจำนวนที่ซ้ายมีออกไป

เฉลย การเดินลงต้นไม้

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

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

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

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

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

kth_zero.cpp
// เดินลงต้นไม้เพื่อหา "ศูนย์ตัวที่ k" ในเวลา log n ครั้งเดียว ไม่ใช่ log n ยกกำลังสอง
// ปมเก็บจำนวนศูนย์ในช่วงของมัน
// อินพุต: n q, อาเรย์ที่มีแต่ 0 กับ 1, แล้ว q บรรทัด  1 i v = ตั้ง a[i]=v, 2 k = ถามตำแหน่งของศูนย์ตัวที่ k
#include <bits/stdc++.h>
using namespace std;

int n;
vector<int> t;

void build(const vector<int>& a, int v, int l, int r) {
    if (l == r) { t[v] = (a[l] == 0); return; }
    int m = (l + r) / 2;
    build(a, 2 * v, l, m);
    build(a, 2 * v + 1, m + 1, r);
    t[v] = t[2 * v] + t[2 * v + 1];
}
void update(int v, int l, int r, int pos, int val) {
    if (l == r) { t[v] = (val == 0); return; }
    int m = (l + r) / 2;
    if (pos <= m) update(2 * v, l, m, pos, val);
    else update(2 * v + 1, m + 1, r, pos, val);
    t[v] = t[2 * v] + t[2 * v + 1];
}
// ยืนที่ปมหนึ่งแล้วถามลูกซ้ายว่ามีศูนย์พอ k ตัวไหม
// ถ้าพอก็ลงซ้ายด้วย k เท่าเดิม ถ้าไม่พอก็ลงขวาโดยหักจำนวนที่ซ้ายมีออกจาก k
int kthZero(int v, int l, int r, int k) {
    if (l == r) return l;
    int m = (l + r) / 2;
    if (t[2 * v] >= k) return kthZero(2 * v, l, m, k);
    return kthZero(2 * v + 1, m + 1, r, k - t[2 * v]);
}

int main() {
    int q;
    if (scanf("%d %d", &n, &q) != 2) return 0;
    vector<int> a(n);
    for (auto& x : a) scanf("%d", &x);
    t.assign(4 * n, 0);
    build(a, 1, 0, n - 1);
    while (q--) {
        int op; scanf("%d", &op);
        if (op == 1) {
            int i, v; scanf("%d %d", &i, &v);
            update(1, 0, n - 1, i, v);
        } else {
            int k; scanf("%d", &k);
            // ถามที่รากก่อนว่ามีศูนย์ทั้งหมดพอ k ตัวไหม ถ้าไม่พอต้องตอบว่าไม่มี
            // ถ้าข้ามการตรวจนี้ การเดินลงจะไปจบที่ใบสักใบแล้วตอบเป็นตำแหน่งที่ไม่ใช่ศูนย์
            printf("%d\n", t[1] < k ? -1 : kthZero(1, 0, n - 1, k));
        }
    }
    return 0;
}
ตัวตรวจอิสระของโค้ดชิ้นนี้

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

kth_zero_brute.cpp
#include <bits/stdc++.h>
using namespace std;
int main(){int n,q;if(scanf("%d %d",&n,&q)!=2)return 0;vector<int>a(n);
for(auto&x:a)scanf("%d",&x);
while(q--){int op;scanf("%d",&op);
 if(op==1){int i,v;scanf("%d %d",&i,&v);a[i]=v;}
 else{int k;scanf("%d",&k);int seen=0,ans=-1;
  for(int i=0;i<n;i++)if(a[i]==0){seen++;if(seen==k){ans=i;break;}}
  printf("%d\n",ans);}}
return 0;}

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

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

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

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

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

ดูบรรทัดที่พังกับเคสที่เล็กที่สุด
บรรทัดที่พัง
// รูปที่พัง ต่างจากของจริงแค่ตรงที่ไม่ได้ถามรากก่อนว่ามีศูนย์พอ k ตัวไหม
int k; scanf("%d", &k);
printf("%d\n", kthZero(1, 0, n - 1, k));

// รูปที่ถูก ถ้าทั้งอาเรย์มีศูนย์ไม่ถึง k ตัว ต้องตอบว่าไม่มี ก่อนจะเริ่มเดินลง
int k; scanf("%d", &k);
printf("%d\n", t[1] < k ? -1 : kthZero(1, 0, n - 1, k));
เคสเล็กที่สุดที่ต่างกัน
Inputตัวที่ไม่มีการตรวจตอบคำตอบจริง
1 1
0
2 2
0-1

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

รูปอื่นของท่าเดียวกัน

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

คำถามที่เดินลงต้นไม้ได้
คำถามปมเก็บถามลูกซ้ายว่า
ศูนย์ตัวที่ k อยู่ตรงไหนจำนวนศูนย์มีศูนย์ครบ k ตัวไหม
ช่องแรกที่ค่าไม่น้อยกว่า xค่ามากสุดค่ามากสุดถึง x ไหม
ตำแหน่งแรกที่ผลรวมสะสมถึง xผลรวมผลรวมของฉันถึง x ไหม
ทั้งสามแถวคือโครงเดียวกันคำต่อคำ ถ้าลูกซ้ายตอบว่าใช่ก็ลงซ้ายด้วยค่าที่ขอเท่าเดิม ถ้าตอบว่าไม่ใช่ก็ลงขวาโดยหักสิ่งที่ซ้ายมีออกจากค่าที่ขอ
first_ge.cpp
// หา "ช่องแรกตั้งแต่ตำแหน่ง l เป็นต้นไป ที่มีค่าไม่น้อยกว่า x" ในเวลา log n
// ปมเก็บค่ามากสุดของช่วง ถ้าค่ามากสุดของกิ่งไหนยังน้อยกว่า x ก็ไม่ต้องลงไปดูกิ่งนั้นเลย
// อินพุต: n q, อาเรย์, แล้ว q บรรทัด  1 i v = ตั้ง a[i]=v, 2 l x = ถามช่องแรกตั้งแต่ l ที่ค่า >= x
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

int n;
vector<ll> mx;

void build(const vector<ll>& a, int v, int l, int r) {
    if (l == r) { mx[v] = a[l]; return; }
    int m = (l + r) / 2;
    build(a, 2 * v, l, m);
    build(a, 2 * v + 1, m + 1, r);
    mx[v] = max(mx[2 * v], mx[2 * v + 1]);
}
void update(int v, int l, int r, int pos, ll val) {
    if (l == r) { mx[v] = val; return; }
    int m = (l + r) / 2;
    if (pos <= m) update(2 * v, l, m, pos, val);
    else update(2 * v + 1, m + 1, r, pos, val);
    mx[v] = max(mx[2 * v], mx[2 * v + 1]);
}
// คืน -1 เมื่อกิ่งนี้ไม่มีคำตอบ
// เงื่อนไขตัดกิ่งอยู่ที่บรรทัดเดียว คือค่ามากสุดของกิ่งน้อยกว่า x แปลว่าทั้งกิ่งไม่มีทางใช้ได้
int firstGe(int v, int l, int r, int from, ll x) {
    if (r < from || mx[v] < x) return -1;
    if (l == r) return l;
    int m = (l + r) / 2;
    int got = firstGe(2 * v, l, m, from, x);
    if (got != -1) return got;
    return firstGe(2 * v + 1, m + 1, r, from, x);
}

int main() {
    int q;
    if (scanf("%d %d", &n, &q) != 2) return 0;
    vector<ll> a(n);
    for (auto& v : a) scanf("%lld", &v);
    mx.assign(4 * n, LLONG_MIN);
    build(a, 1, 0, n - 1);
    while (q--) {
        int op; scanf("%d", &op);
        if (op == 1) {
            int i; ll v; scanf("%d %lld", &i, &v);
            update(1, 0, n - 1, i, v);
        } else {
            int l; ll x; scanf("%d %lld", &l, &x);
            printf("%d\n", firstGe(1, 0, n - 1, l, x));
        }
    }
    return 0;
}

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


ส่วนที่ 5 ป้ายค้างรูปอื่น และการดันป้ายลงลูก

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

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

กดถัดไปเพื่อดูป้ายเดินลงลูกทีละชั้น

0 0..3 0 0..1 0 2..3 0 [0] 0 [1] 0 [2] 0 [3]

ตัวเลขบนคือผลรวมของช่วงที่ปมนั้นดูแล บรรทัดล่างคือช่วง หรือป้ายที่ค้างอยู่ถ้ามี · ปมทองคือปมที่กำลังทำงาน ปมเขียวคือลูกที่เพิ่งรับป้ายมา

assign_sum.cpp
// ป้ายค้างชนิด "กำหนดค่าทั้งช่วง" ซึ่งต่างจากชนิดบวก ตรงที่มันทับของเดิมทิ้ง
// จึงต้องดันป้ายลงลูกจริงก่อนลงไปแตะลูกทุกครั้ง
// อินพุต: n q, อาเรย์, แล้ว q บรรทัด  1 l r v = กำหนดทุกช่องในช่วงเป็น v, 2 l r = ถามผลรวมของช่วง
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

int n;
vector<ll> sum;
vector<ll> mark;      // ค่าที่ถูกกำหนดไว้ทั้งช่วง
vector<char> marked;  // ปมนี้มีป้ายค้างอยู่หรือไม่

void build(const vector<ll>& a, int v, int l, int r) {
    marked[v] = 0;
    if (l == r) { sum[v] = a[l]; return; }
    int m = (l + r) / 2;
    build(a, 2 * v, l, m);
    build(a, 2 * v + 1, m + 1, r);
    sum[v] = sum[2 * v] + sum[2 * v + 1];
}
// ให้ปมรับป้ายค่า val ทั้งช่วง len ช่อง
void apply(int v, int len, ll val) {
    sum[v] = val * (ll)len;
    mark[v] = val;
    marked[v] = 1;
}
// ดันป้ายของ v ลงลูกทั้งสอง แล้วล้างป้ายของตัวเอง
// ถ้าลืมเรียกอันนี้ก่อนลงไปแตะลูก ลูกจะยังถือค่าเก่าที่ถูกทับไปแล้ว
void push(int v, int l, int r) {
    if (!marked[v]) return;
    int m = (l + r) / 2;
    apply(2 * v, m - l + 1, mark[v]);
    apply(2 * v + 1, r - m, mark[v]);
    marked[v] = 0;
}
void assign(int v, int l, int r, int ql, int qr, ll val) {
    if (qr < l || r < ql) return;
    if (ql <= l && r <= qr) { apply(v, r - l + 1, val); return; }
    push(v, l, r);
    int m = (l + r) / 2;
    assign(2 * v, l, m, ql, qr, val);
    assign(2 * v + 1, m + 1, r, ql, qr, val);
    sum[v] = sum[2 * v] + sum[2 * v + 1];
}
ll query(int v, int l, int r, int ql, int qr) {
    if (qr < l || r < ql) return 0;
    if (ql <= l && r <= qr) return sum[v];
    push(v, l, r);
    int m = (l + r) / 2;
    return query(2 * v, l, m, ql, qr) + query(2 * v + 1, m + 1, r, ql, qr);
}

int main() {
    int q;
    if (scanf("%d %d", &n, &q) != 2) return 0;
    vector<ll> a(n);
    for (auto& x : a) scanf("%lld", &x);
    sum.assign(4 * n, 0); mark.assign(4 * n, 0); marked.assign(4 * n, 0);
    build(a, 1, 0, n - 1);
    while (q--) {
        int op; scanf("%d", &op);
        if (op == 1) {
            int l, r; ll v; scanf("%d %d %lld", &l, &r, &v);
            assign(1, 0, n - 1, l, r, v);
        } else {
            int l, r; scanf("%d %d", &l, &r);
            printf("%lld\n", query(1, 0, n - 1, l, r));
        }
    }
    return 0;
}
ตัวตรวจอิสระของโค้ดชิ้นนี้

ตัวตรวจไม่มีต้นไม้ ไม่มีป้าย มันเขียนทับอาเรย์ทีละช่องแล้วบวกทีละช่อง ซึ่งช้ามาก แต่ถูกแน่นอน สองโปรแกรมนี้ถูกสุ่มเทียบกัน 800 รอบ ตรงกันทุกรอบ

assign_sum_brute.cpp
#include <bits/stdc++.h>
using namespace std;
int main(){int n,q;if(scanf("%d %d",&n,&q)!=2)return 0;vector<long long>a(n);
for(auto&x:a)scanf("%lld",&x);
while(q--){int op;scanf("%d",&op);
 if(op==1){int l,r;long long v;scanf("%d %d %lld",&l,&r,&v);for(int i=l;i<=r;i++)a[i]=v;}
 else{int l,r;scanf("%d %d",&l,&r);long long s=0;for(int i=l;i<=r;i++)s+=a[i];
  printf("%lld\n",s);}}
return 0;}

ระวัง

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

บวกทั้งช่วง ถามค่ามากสุดของช่วง

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

add_max.cpp
// บวกทั้งช่วง ถามค่ามากสุดของช่วง เขียนแบบดันป้ายลงลูกจริง เพื่อให้เห็นรูปมาตรฐาน
// (โค้ดหลักของบทนี้ใช้อีกรูปหนึ่งคือไม่ดันป้าย แต่บวกป้ายสะสมระหว่างทางแทน)
// อินพุต: n q, อาเรย์, แล้ว q บรรทัด  1 l r v = บวก v ทั้งช่วง, 2 l r = ถามค่ามากสุดของช่วง
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

int n;
vector<ll> mx, lz;

void build(const vector<ll>& a, int v, int l, int r) {
    lz[v] = 0;
    if (l == r) { mx[v] = a[l]; return; }
    int m = (l + r) / 2;
    build(a, 2 * v, l, m);
    build(a, 2 * v + 1, m + 1, r);
    mx[v] = max(mx[2 * v], mx[2 * v + 1]);
}
void apply(int v, ll val) { mx[v] += val; lz[v] += val; }
void push(int v) {
    if (lz[v] == 0) return;
    apply(2 * v, lz[v]);
    apply(2 * v + 1, lz[v]);
    lz[v] = 0;
}
void add(int v, int l, int r, int ql, int qr, ll val) {
    if (qr < l || r < ql) return;
    if (ql <= l && r <= qr) { apply(v, val); return; }
    push(v);
    int m = (l + r) / 2;
    add(2 * v, l, m, ql, qr, val);
    add(2 * v + 1, m + 1, r, ql, qr, val);
    mx[v] = max(mx[2 * v], mx[2 * v + 1]);
}
ll query(int v, int l, int r, int ql, int qr) {
    if (qr < l || r < ql) return LLONG_MIN;
    if (ql <= l && r <= qr) return mx[v];
    push(v);
    int m = (l + r) / 2;
    return max(query(2 * v, l, m, ql, qr), query(2 * v + 1, m + 1, r, ql, qr));
}

int main() {
    int q;
    if (scanf("%d %d", &n, &q) != 2) return 0;
    vector<ll> a(n);
    for (auto& x : a) scanf("%lld", &x);
    mx.assign(4 * n, 0); lz.assign(4 * n, 0);
    build(a, 1, 0, n - 1);
    while (q--) {
        int op; scanf("%d", &op);
        if (op == 1) {
            int l, r; ll v; scanf("%d %d %lld", &l, &r, &v);
            add(1, 0, n - 1, l, r, v);
        } else {
            int l, r; scanf("%d %d", &l, &r);
            printf("%lld\n", query(1, 0, n - 1, l, r));
        }
    }
    return 0;
}

ส่วนที่ 6 เก็บทั้งอาเรย์ย่อยไว้ในปม

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

โครงนี้เรียกว่า merge sort tree อ่านว่า "เมิร์จ ซอร์ต ทรี" แปลว่า ต้นไม้ของการเรียงแบบผสาน ชื่อมาจากการที่รายการของพ่อคือการผสานรายการของลูกสองตัวเข้าด้วยกัน ซึ่งเป็นขั้นตอนเดียวกับการเรียงแบบแบ่งครึ่งพอดี

5 2 2 5 9 1 1 9 1 2 5 9 7 3 3 7 8 4 4 8 3 4 7 8 1 2 3 4 5 7 8 9
ต้นไม้ของอาเรย์ 5, 2, 9, 1, 7, 3, 8, 4 ที่ทุกปมเก็บรายการเรียงของช่วงตัวเอง จำนวนตัวเลขที่ถูกเก็บทั้งต้นคือ 32 ตัว ซึ่งเท่ากับ 8 คูณจำนวนชั้น พอดี เมื่อถามหาค่าที่น้อยที่สุดที่ยังไม่น้อยกว่า 5 ในช่วงช่อง 3 ถึง 6 คำถามแตะปมสีเขียว 3 ปม แล้วค้นหาแบบไบนารีในรายการของแต่ละปม ได้คำตอบ 7 ซึ่งตรงกับการกวาดทีละช่อง (วาดประกอบโดยผู้เขียน)
merge_sort_tree.cpp
// ต้นไม้ที่ทุกปมเก็บ "อาเรย์ย่อยของช่วงนั้น เรียงจากน้อยไปมาก"
// ตอบคำถาม "ค่าที่น้อยที่สุดที่ยังไม่น้อยกว่า x ในช่วง l ถึง r" ได้ในเวลา log n ยกกำลังสอง
// ตัวอาเรย์แก้ไม่ได้ แลกกับความง่าย
// อินพุต: n q, อาเรย์, แล้ว q บรรทัด  l r x
#include <bits/stdc++.h>
using namespace std;

int n;
vector<vector<int>> t;   // t[v] คือรายการที่เรียงแล้วของช่วงที่ปม v ดูแล

void build(const vector<int>& a, int v, int l, int r) {
    if (l == r) { t[v] = { a[l] }; return; }
    int m = (l + r) / 2;
    build(a, 2 * v, l, m);
    build(a, 2 * v + 1, m + 1, r);
    // รวมสองรายการที่เรียงแล้วเข้าด้วยกัน คือขั้นตอน merge ของการเรียงแบบแบ่งครึ่ง
    t[v].resize(t[2 * v].size() + t[2 * v + 1].size());
    merge(t[2 * v].begin(), t[2 * v].end(), t[2 * v + 1].begin(), t[2 * v + 1].end(), t[v].begin());
}
const int NONE = INT_MAX;
int query(int v, int l, int r, int ql, int qr, int x) {
    if (qr < l || r < ql) return NONE;
    if (ql <= l && r <= qr) {
        // ในรายการที่เรียงแล้ว ตัวแรกที่ไม่น้อยกว่า x หาได้ด้วยการค้นหาแบบไบนารีครั้งเดียว
        auto it = lower_bound(t[v].begin(), t[v].end(), x);
        return it == t[v].end() ? NONE : *it;
    }
    int m = (l + r) / 2;
    return min(query(2 * v, l, m, ql, qr, x), query(2 * v + 1, m + 1, r, ql, qr, x));
}

int main() {
    int q;
    if (scanf("%d %d", &n, &q) != 2) return 0;
    vector<int> a(n);
    for (auto& x : a) scanf("%d", &x);
    t.assign(4 * n, {});
    build(a, 1, 0, n - 1);
    while (q--) {
        int l, r, x; scanf("%d %d %d", &l, &r, &x);
        int got = query(1, 0, n - 1, l, r, x);
        printf("%d\n", got == NONE ? -1 : got);
    }
    return 0;
}

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

ถ้าอยากแก้ค่าด้วย และถ้าอยากเร็วกว่านี้

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

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


ส่วนที่ 7 ต้นไม้ที่จำอดีตได้

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

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

กดถัดไปเพื่อสร้างเวอร์ชันใหม่ทีละปม

2 [0] 7 [1] 9 0..1 1 [2] 5 [3] 6 2..3 15 0..3 9 [2] 14 2..3 23 0..3 เวอร์ชัน 0 เวอร์ชัน 1

ตัวเลขบนคือผลรวมของช่วงที่ปมนั้นดูแล · เส้นประคือการชี้กลับไปใช้ปมของเวอร์ชันเก่าโดยไม่ก๊อป

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

ค่าที่เล็กเป็นอันดับที่ k ในช่วง

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

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

persistent_kth.cpp
// ต้นไม้ที่จำอดีตได้ ใช้ตอบ "ค่าที่เล็กเป็นอันดับที่ k ในช่วง l ถึง r"
// แนวคิด: สร้างต้นไม้นับจำนวนของค่าทีละช่อง เก็บไว้ทุกเวอร์ชัน
// จำนวนของค่าในช่วง l ถึง r ก็คือ เวอร์ชันที่ r ลบเวอร์ชันที่ l-1 ทีละปม
// แล้วเดินลงต้นไม้ด้วยผลต่างนั้น
// อินพุต: n q, อาเรย์, แล้ว q บรรทัด  l r k
#include <bits/stdc++.h>
using namespace std;

struct Node { int left, right, cnt; };
vector<Node> t;
vector<int> root;

int newNode(int left, int right, int cnt) {
    t.push_back({ left, right, cnt });
    return (int)t.size() - 1;
}
int buildEmpty(int l, int r) {
    if (l == r) return newNode(0, 0, 0);
    int m = (l + r) / 2;
    int a = buildEmpty(l, m), b = buildEmpty(m + 1, r);
    return newNode(a, b, 0);
}
// เพิ่มหนึ่งที่ตำแหน่ง pos โดยไม่แก้ปมเดิมเลย สร้างปมใหม่แค่เส้นทางเดียวจากรากลงใบ
int insert(int prev, int l, int r, int pos) {
    if (l == r) return newNode(0, 0, t[prev].cnt + 1);
    int m = (l + r) / 2;
    if (pos <= m) {
        int nl = insert(t[prev].left, l, m, pos);
        return newNode(nl, t[prev].right, t[nl].cnt + t[t[prev].right].cnt);
    }
    int nr = insert(t[prev].right, m + 1, r, pos);
    return newNode(t[prev].left, nr, t[t[prev].left].cnt + t[nr].cnt);
}
// เดินลงพร้อมกันสองเวอร์ชัน ผลต่างของจำนวนคือจำนวนที่อยู่ในช่วงที่ถามจริง
int kth(int lo, int hi, int l, int r, int k) {
    if (l == r) return l;
    int m = (l + r) / 2;
    int leftCnt = t[t[hi].left].cnt - t[t[lo].left].cnt;
    if (leftCnt >= k) return kth(t[lo].left, t[hi].left, l, m, k);
    return kth(t[lo].right, t[hi].right, m + 1, r, k - leftCnt);
}

int main() {
    int n, q;
    if (scanf("%d %d", &n, &q) != 2) return 0;
    vector<int> a(n);
    for (auto& x : a) scanf("%d", &x);
    // บีบค่าให้เป็นลำดับที่ เพราะต้นไม้นับจำนวนต้องมีขนาดเท่ากับจำนวนค่าที่ต่างกัน
    vector<int> vals = a;
    sort(vals.begin(), vals.end());
    vals.erase(unique(vals.begin(), vals.end()), vals.end());
    int V = (int)vals.size();

    t.clear(); t.push_back({ 0, 0, 0 });
    root.assign(n + 1, 0);
    root[0] = buildEmpty(0, V - 1);
    for (int i = 0; i < n; i++) {
        int id = (int)(lower_bound(vals.begin(), vals.end(), a[i]) - vals.begin());
        root[i + 1] = insert(root[i], 0, V - 1, id);
    }
    while (q--) {
        int l, r, k; scanf("%d %d %d", &l, &r, &k);
        printf("%d\n", vals[kth(root[l], root[r + 1], 0, V - 1, k)]);
    }
    return 0;
}

ส่วนที่ 8 ต้นไม้ที่งอกเฉพาะกิ่งที่ถูกใช้

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

ท่านี้เรียกว่า dynamic segment tree หรือ implicit segment tree โดย implicit อ่านว่า "อิมพลิซิต" แปลว่า มีอยู่โดยไม่ต้องเขียนออกมา คือกิ่งทั้งต้นมีอยู่ในความคิด แต่ในหน่วยความจำมีเฉพาะกิ่งที่เคยถูกใช้ ความต่างจากเดิมคือลูกไม่ได้อยู่ที่ 2v กับ 2v+1 อีกแล้ว ต้องเก็บหมายเลขลูกไว้ในปมเอง

dynamic_seg.cpp
// ต้นไม้ที่งอกเฉพาะกิ่งที่ถูกใช้ ใช้กับช่วงดัชนีที่ใหญ่จนสร้างทั้งต้นไม่ไหว เช่นถึงหนึ่งพันล้าน
// ปมลูกไม่ได้อยู่ที่ 2v กับ 2v+1 อีกแล้ว แต่เก็บเป็นหมายเลขปมที่สร้างเมื่อถูกเรียกใช้ครั้งแรก
// อินพุต: q แล้ว q บรรทัด  1 i v = บวก v ที่ตำแหน่ง i, 2 l r = ถามผลรวมช่วง  (0 <= i < 1e9)
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

const int LIMIT = 1000000000;
struct Node { int left = 0, right = 0; ll sum = 0; };
vector<Node> t(1);   // ปมหมายเลข 0 คือปมว่าง ใช้แทน "ยังไม่มีลูก"

int makeNode() { t.push_back(Node()); return (int)t.size() - 1; }

// คืนหมายเลขปมกลับไปให้พ่อเก็บ ห้ามรับเป็น int& ที่ชี้เข้าไปใน t
// เพราะ makeNode ทำให้ t ขยายตัวและย้ายที่ อ้างอิงเดิมจะชี้ไปที่หน่วยความจำที่ไม่ใช่ของมันแล้ว
int update(int v, int l, int r, int pos, ll val) {
    if (v == 0) v = makeNode();       // งอกตรงนี้ เฉพาะตอนที่จำเป็นจริง
    t[v].sum += val;
    if (l == r) return v;
    int m = l + (r - l) / 2;
    if (pos <= m) { int c = update(t[v].left, l, m, pos, val); t[v].left = c; }
    else { int c = update(t[v].right, m + 1, r, pos, val); t[v].right = c; }
    return v;
}
ll query(int v, int l, int r, int ql, int qr) {
    if (v == 0 || qr < l || r < ql) return 0;   // กิ่งที่ไม่เคยงอก แปลว่าไม่มีค่าอะไรอยู่เลย
    if (ql <= l && r <= qr) return t[v].sum;
    int m = l + (r - l) / 2;
    return query(t[v].left, l, m, ql, qr) + query(t[v].right, m + 1, r, ql, qr);
}

int main() {
    int q;
    if (scanf("%d", &q) != 1) return 0;
    int root = 0;
    while (q--) {
        int op; scanf("%d", &op);
        if (op == 1) {
            int i; ll v; scanf("%d %lld", &i, &v);
            root = update(root, 0, LIMIT, i, v);
        } else {
            int l, r; scanf("%d %d", &l, &r);
            printf("%lld\n", query(root, 0, LIMIT, l, r));
        }
    }
    return 0;
}

กับดักของภาษา ไม่ใช่ของอัลกอริทึม

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


ส่วนที่ 9 ต้นไม้สองมิติ

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

seg2d.cpp
// ต้นไม้สองมิติ ต้นนอกแบ่งตามแถว ทุกปมของต้นนอกมีต้นไม้ของตัวเองที่แบ่งตามคอลัมน์
// ตอบผลรวมของสี่เหลี่ยมได้ในเวลา log n คูณ log m
// อินพุต: n m q, ตาราง n แถว m หลัก, แล้ว q บรรทัด
//   1 r c v = ตั้งช่อง (r,c) เป็น v, 2 r1 c1 r2 c2 = ถามผลรวมของสี่เหลี่ยม
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

int n, m;
vector<vector<ll>> t;   // t[vx][vy]

void buildY(const vector<vector<ll>>& g, int vx, int lx, int rx, int vy, int ly, int ry) {
    if (ly == ry) {
        if (lx == rx) t[vx][vy] = g[lx][ly];
        else t[vx][vy] = t[2 * vx][vy] + t[2 * vx + 1][vy];
        return;
    }
    int my = (ly + ry) / 2;
    buildY(g, vx, lx, rx, 2 * vy, ly, my);
    buildY(g, vx, lx, rx, 2 * vy + 1, my + 1, ry);
    t[vx][vy] = t[vx][2 * vy] + t[vx][2 * vy + 1];
}
void buildX(const vector<vector<ll>>& g, int vx, int lx, int rx) {
    if (lx != rx) {
        int mx = (lx + rx) / 2;
        buildX(g, 2 * vx, lx, mx);
        buildX(g, 2 * vx + 1, mx + 1, rx);
    }
    buildY(g, vx, lx, rx, 1, 0, m - 1);
}
void updateY(int vx, int lx, int rx, int vy, int ly, int ry, int y, ll val) {
    if (ly == ry) {
        if (lx == rx) t[vx][vy] = val;
        else t[vx][vy] = t[2 * vx][vy] + t[2 * vx + 1][vy];
        return;
    }
    int my = (ly + ry) / 2;
    if (y <= my) updateY(vx, lx, rx, 2 * vy, ly, my, y, val);
    else updateY(vx, lx, rx, 2 * vy + 1, my + 1, ry, y, val);
    t[vx][vy] = t[vx][2 * vy] + t[vx][2 * vy + 1];
}
void updateX(int vx, int lx, int rx, int x, int y, ll val) {
    if (lx != rx) {
        int mx = (lx + rx) / 2;
        if (x <= mx) updateX(2 * vx, lx, mx, x, y, val);
        else updateX(2 * vx + 1, mx + 1, rx, x, y, val);
    }
    updateY(vx, lx, rx, 1, 0, m - 1, y, val);
}
ll queryY(int vx, int vy, int ly, int ry, int qy1, int qy2) {
    if (qy2 < ly || ry < qy1) return 0;
    if (qy1 <= ly && ry <= qy2) return t[vx][vy];
    int my = (ly + ry) / 2;
    return queryY(vx, 2 * vy, ly, my, qy1, qy2) + queryY(vx, 2 * vy + 1, my + 1, ry, qy1, qy2);
}
ll queryX(int vx, int lx, int rx, int qx1, int qx2, int qy1, int qy2) {
    if (qx2 < lx || rx < qx1) return 0;
    if (qx1 <= lx && rx <= qx2) return queryY(vx, 1, 0, m - 1, qy1, qy2);
    int mx = (lx + rx) / 2;
    return queryX(2 * vx, lx, mx, qx1, qx2, qy1, qy2)
         + queryX(2 * vx + 1, mx + 1, rx, qx1, qx2, qy1, qy2);
}

int main() {
    int q;
    if (scanf("%d %d %d", &n, &m, &q) != 3) return 0;
    vector<vector<ll>> g(n, vector<ll>(m));
    for (int i = 0; i < n; i++) for (int j = 0; j < m; j++) scanf("%lld", &g[i][j]);
    t.assign(4 * n, vector<ll>(4 * m, 0));
    buildX(g, 1, 0, n - 1);
    while (q--) {
        int op; scanf("%d", &op);
        if (op == 1) {
            int r, c; ll v; scanf("%d %d %lld", &r, &c, &v);
            updateX(1, 0, n - 1, r, c, v);
        } else {
            int r1, c1, r2, c2; scanf("%d %d %d %d", &r1, &c1, &r2, &c2);
            printf("%lld\n", queryX(1, 0, n - 1, r1, r2, c1, c2));
        }
    }
    return 0;
}

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


ส่วนที่ 10 หน่วยความจำ ทำไมต้องเผื่อสี่เท่า

โค้ดเซกเมนต์ทรีเกือบทุกที่จองอาเรย์ขนาด 4n ทั้งที่ต้นไม้มีปมจริงแค่ 2n-1 ปม เหตุผลคือเมื่อ n ไม่ใช่กำลังสองพอดี ต้นไม้จะไม่สมดุล และหมายเลขปมที่ใหญ่ที่สุด อาจโตเกิน 2n ไปได้ การเผื่อสี่เท่าคือการเลิกคิดเรื่องนี้ทั้งหมด ซึ่งคุ้มในสนามแข่ง

อาเรย์ 200,000 ช่อง เก็บค่าละแปดไบต์
วิธีจองจำนวนช่องหน่วยความจำราคาที่ต้องจ่าย
เผื่อไว้สี่เท่า (4n) ที่โค้ดส่วนใหญ่ใช้ 800,000 6.1 เมกะไบต์ ง่ายที่สุด ปมลูกของ v คือ 2v และ 2v+1 ตรง ๆ
ขยายให้เป็นกำลังสองก่อน (2 คูณ SZ) 524,288 4.0 เมกะไบต์ ต้องยอมมีใบส่วนเกิน และต้องตั้งค่ากลางให้ถูก
เรียงหมายเลขตามการเดินต้นไม้ (2n) 400,000 3.1 เมกะไบต์ ลูกขวาคือ v + 2 คูณ (mid - lo + 1) ซึ่งต้องคิดเองทุกครั้ง
ทั้งสามแถวนับด้วยหน่วยเดียวกัน คือจำนวนช่องคูณแปดไบต์ ตามชนิดข้อมูลที่โค้ดในหน้านี้ใช้จริง ต่างกันมากที่สุดราว 2 เท่า ซึ่งมีความหมายก็ต่อเมื่อโจทย์จำกัดหน่วยความจำแน่นจริง ๆ

วิธีลดเหลือ 2n พอดีคือเลิกใช้หมายเลข 2v กับ 2v+1 แล้วเปลี่ยนไปเรียงหมายเลขปมตามลำดับที่เดินต้นไม้ ลูกซ้ายคือ v+1 เสมอ ส่วนลูกขวาอยู่ถัดจากที่ฝั่งซ้ายกินไปทั้งหมด ซึ่งคือ v + 2(mid - lo + 1)

mem2n.cpp
// เซกเมนต์ทรีที่ใช้หน่วยความจำแค่ 2n แทนที่จะเผื่อไว้ 4n
// เคล็ดอยู่ที่การเรียงหมายเลขปมตามลำดับที่เดินต้นไม้ ปมซ้ายคือ v+1 เสมอ
// ส่วนปมขวาคือ v + 2 คูณ (จำนวนช่องของฝั่งซ้าย) เพราะฝั่งซ้ายกินหมายเลขไปเท่านั้นพอดี
// อินพุต: n q, อาเรย์, แล้ว q บรรทัด  1 i v = ตั้ง a[i]=v, 2 l r = ถามผลรวมช่วง
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

int n;
vector<ll> t;

void build(const vector<ll>& a, int v, int l, int r) {
    if (l == r) { t[v] = a[l]; return; }
    int m = (l + r) / 2;
    build(a, v + 1, l, m);
    build(a, v + 2 * (m - l + 1), m + 1, r);
    t[v] = t[v + 1] + t[v + 2 * (m - l + 1)];
}
void update(int v, int l, int r, int pos, ll val) {
    if (l == r) { t[v] = val; return; }
    int m = (l + r) / 2;
    if (pos <= m) update(v + 1, l, m, pos, val);
    else update(v + 2 * (m - l + 1), m + 1, r, pos, val);
    t[v] = t[v + 1] + t[v + 2 * (m - l + 1)];
}
ll query(int v, int l, int r, int ql, int qr) {
    if (qr < l || r < ql) return 0;
    if (ql <= l && r <= qr) return t[v];
    int m = (l + r) / 2;
    return query(v + 1, l, m, ql, qr) + query(v + 2 * (m - l + 1), m + 1, r, ql, qr);
}

int main() {
    int q;
    if (scanf("%d %d", &n, &q) != 2) return 0;
    vector<ll> a(n);
    for (auto& x : a) scanf("%lld", &x);
    t.assign(2 * n, 0);            // สองเท่าพอดี ไม่ต้องเผื่อสี่เท่า
    build(a, 0, 0, n - 1);
    while (q--) {
        int op; scanf("%d", &op);
        if (op == 1) {
            int i; ll v; scanf("%d %lld", &i, &v);
            update(0, 0, n - 1, i, v);
        } else {
            int l, r; scanf("%d %d", &l, &r);
            printf("%lld\n", query(0, 0, n - 1, l, r));
        }
    }
    return 0;
}

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

เอาไปใช้ที่ไหนในคลังนี้

อ่านต่อ

  1. cp-algorithms, Segment Tree · cp-algorithms.com/data_structures/segment_tree.html · เป็นแหล่งที่ส่วนที่ 3 ถึง 10 ของบทนี้เดินตามหัวข้อ ที่นั่นมีรายละเอียดเชิงพิสูจน์และรูปแบบอื่นเพิ่มอีก
  2. cp-algorithms, Fenwick Tree · cp-algorithms.com/data_structures/fenwick.html · สำหรับเทียบว่าเมื่อไรควรใช้เฟนวิกซึ่งเขียนสั้นกว่ามาก

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

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

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