ปูพื้นฐาน
ชิ้นแรกตอบคำถามเรื่องช่วงในขณะที่ข้อมูลยังถูกแก้ทั้งช่วง ชิ้นที่สองเปลี่ยนคำถามว่าดีที่สุดเท่าไรให้เป็นคำถามว่าทำได้ไหม บทนี้ให้ทั้งคู่พร้อมกับดักที่ทำให้โปรแกรมตอบตัวอย่างถูกแต่ตกเทสจริง
บทนี้รวมเครื่องมือสองชิ้นที่มักถูกใช้ด้วยกัน และเป็นคู่ที่ปลดล็อกโจทย์ระดับยากได้หลายข้อ
แกะคำศัพท์
segment อ่านว่า "เซกเมนต์" แปลว่า ท่อน หรือ ช่วงที่ถูกตัดออกมา มาจากคำละตินที่แปลว่า "ชิ้นที่ถูกเฉือน" ส่วน tree คือ ต้นไม้ รวมกันจึงแปลตรงตัวว่า ต้นไม้ของท่อน ซึ่งตรงกับหน้าตาของมันพอดี คือทุกปมในต้นไม้ดูแลข้อมูลอยู่หนึ่งท่อน และท่อนของพ่อคือท่อนของลูกสองตัวต่อกัน
ชื่อไทยที่ใช้กันมีหลายแบบ ทั้งต้นไม้ช่วง ต้นไม้เซกเมนต์ และทับศัพท์ไปเลย บทนี้ใช้คำว่า เซกเมนต์ทรี เพราะเป็นคำที่คนแข่งเขียนโปรแกรมพูดกันจริงเวลาคุยกัน
ที่เอามาไว้ด้วยกันเพราะโจทย์หนักหลายข้อใช้ทั้งคู่พร้อมกัน คือค้นหาคำตอบแบบไบนารีอยู่ชั้นนอก แล้วชั้นในใช้เซกเมนต์ทรีตอบว่า "ค่านี้ทำได้ไหม"
บวกทีละช่วง แล้วถามค่าน้อยสุด ลองไล่ด้วยตาก่อน
คำถามตอนนี้
อ่านคำถามแล้วพิมพ์ค่าน้อยสุดของช่วงที่ถูกไฮไลต์
พิมพ์ไว้ - · ตอบถูกแล้ว 0 จาก 0 ข้อ
การบวกทั้งช่วงจะทำให้ตัวเลขในกระดานเปลี่ยนเองก่อนคำถามถัดไป
ถ้าต้องไล่ดูทีละช่องทุกครั้งที่ถูกถาม ลองคิดว่าจะเก็บอะไรไว้ล่วงหน้า ให้ตอบได้โดยไม่ต้องแตะทุกช่อง
ต้นไม้เฟนวิก เก่งเรื่องผลรวมและแก้ทีละช่อง แต่พอโจทย์เปลี่ยนคำถามเป็น ค่าน้อยสุดของช่วง และเปลี่ยนการแก้เป็นบวกทั้งช่วง เฟนวิกก็เริ่มไม่พอ ค่าน้อยสุดหักลบกันไม่ได้เหมือนผลรวม
เซกเมนต์ทรีแก้ปัญหานี้ด้วยการเก็บคำตอบของช่วงไว้เป็นต้นไม้ ปมหนึ่งปมดูแลช่วงหนึ่งช่วง ปมลูกสองตัวแบ่งครึ่งกัน ส่วนการบวกทั้งช่วงใช้เทคนิคที่เรียกว่าป้ายค้าง (lazy) คือเมื่อคำสั่งครอบคลุมช่วงของปมนั้นพอดี ก็ไม่ต้องลงไปแตะลูก แค่แปะป้ายไว้ว่า "ทั้งช่วงนี้ถูกบวกไปเท่านี้แล้ว"
ภาพข้างบนบอกว่าคำถามหนึ่งครั้งแตะปมกี่ปม แต่ยังไม่ได้บอกว่ามันเดินยังไง เครื่องข้างล่างนี้เดินให้ดูทีละปม ตามลำดับที่โปรแกรมเดินจริง ลองกดถัดไปไปเรื่อย ๆ แล้วสังเกตว่าปมที่ถูกตัดทิ้งนั้นตัดทั้งกิ่ง ไม่ใช่ตัดทีละใบ
กดถัดไปเพื่อเดินคำถามทีละปม
คำถามคือค่าน้อยสุดของช่อง 3 ถึง 6 · เขียวคือปมที่ตอบได้ทั้งก้อน ทองคือปมที่ต้องเดินผ่านลงไป เทาเข้มคือปมที่ถูกตัดทิ้งทั้งกิ่ง
สิ่งที่ควรเห็นจากการเดินคือ ในแต่ละชั้นของต้นไม้ มีปมที่ตอบได้ทั้งก้อนอย่างมากสองปม
เพราะช่วงที่ถามมีขอบแค่สองข้าง ซ้ายกับขวา แต่ละชั้นจึงมีปมที่ถูกขอบเฉือนได้แค่สองปมเท่านั้น
ต้นไม้สูง log n ชั้น งานทั้งหมดต่อหนึ่งคำถามจึงอยู่ในระดับ log n
ข้ออ้างนี้ถูกตรวจตอนสร้างหน้านี้ด้วยการนับจากการเดินจริง ถ้ามีชั้นไหนเกินสองปมขึ้นมา หน้านี้จะไม่ถูกสร้าง
แกะคำศัพท์
lazy propagation อ่านว่า "เลซี โพรพะเกชัน" แปลตรงตัวว่า การกระจายแบบขี้เกียจ ชื่อนี้ตรงกับพฤติกรรมของมันมาก คือรู้ว่ามีงานต้องทำกับลูก แต่ผัดวันไว้ก่อน จนกว่าจะมีใครถามลงไปถึงลูกจริง ๆ ถึงค่อยลงไปทำ งานที่ไม่มีใครถามก็ไม่ต้องทำเลยตลอดกาล
ในโค้ดของบทนี้ผมเลือกเก็บป้ายแบบที่ไม่ต้องดันป้ายลงลูกเลย โดยให้ mn[node]
เป็นค่าน้อยสุดที่รวมป้ายของตัวเองแล้ว เวลาถามก็บวกป้ายที่เจอระหว่างทางลงมาสะสมไป
เขียนสั้นกว่าและพลาดยากกว่าแบบที่ต้องดันป้าย
| แนวคิด | ต้นทุนต่อหนึ่งคำสั่ง | รวม |
|---|---|---|
| แก้ทีละช่อง ถามทีละช่อง | n ต่อหนึ่งคำสั่ง | 40,000,000,000 |
| เซกเมนต์ทรีแบบมีป้ายค้าง | ราว 2 log n ต่อหนึ่งคำสั่ง | 7,043,856 |
ระวัง
กับดักที่ผมตกไปแล้วในโจทย์ฐานปิรามิด เซกเมนต์ทรีมักถูกขยายให้ขนาดเป็นกำลังสอง ทำให้มี ใบส่วนเกินที่ไม่ตรงกับช่องจริงในอาเรย์ ถ้าปล่อยค่าใบพวกนั้นไว้ที่ศูนย์แล้วไปถาม "ค่าน้อยสุดของทั้งอาเรย์" คำตอบจะเป็นศูนย์ตลอดกาล
อาการของบักนี้คือตอบตัวอย่างในโจทย์ถูกหมด แต่ตกเทสจริง ต้องตั้งใบส่วนเกินเป็นอนันต์ ตั้งแต่ตอนสร้างต้นไม้
ให้อาเรย์ยาว n แล้วมีคำสั่ง q ครั้ง แบบ 1 l r v คือบวก
v ให้ทุกช่องในช่วง และแบบ 2 l r คือถามค่าน้อยสุดในช่วง
| Input | Output |
|---|---|
| 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 |
ใบ้
คำสั่งบวกหนึ่งครั้งกินช่วงกว้างแค่ไหนก็ได้ แต่ปมที่ครอบคลุมพอดีมีไม่กี่ปม ลองถามตัวเองว่า ถ้าคำสั่งกินช่วงของปมนั้นครบทั้งก้อนแล้ว เรายังจำเป็นต้องลงไปแก้ลูกของมันไหม หรือแค่จดไว้ที่ปมนั้นก็พอ
แล้วถ้าเลือกจดไว้เฉย ๆ คำถามต่อไปคือ ตอนที่มีคนถามค่าน้อยสุดลงไปถึงลูก เราจะรู้ได้ยังไงว่าค่าที่ลูกเก็บไว้ยังขาดอะไรอยู่บ้าง
ที่มาของท่านี้ · กับดักที่ผู้เขียนตกมาแล้วจริง
เซกเมนต์ทรีมักถูกขยายให้ขนาดเป็นกำลังสองเพื่อให้โครงต้นไม้เต็มพอดี ซึ่งแปลว่าจะมีใบส่วนเกิน ที่ไม่ตรงกับข้อมูลจริงสักตัวโผล่มาด้วย และตรงนี้คือกับดัก ถ้าต้นไม้หาค่าน้อยสุด แล้วปล่อยใบส่วนเกินไว้ที่ศูนย์ ค่าที่รากจะเป็นศูนย์ตลอดกาล
อันนี้ไม่ใช่กับดักในทางทฤษฎี ผมตกมันมาแล้วจริงในหน้า pyramid-base โปรแกรมตอบว่าวางฐานได้ทุกขนาดที่ถาม และมันตอบตัวอย่างในโจทย์ถูกทั้งสองชุด กว่าจะรู้ตัวก็ตอนสุ่มเทียบกับตัวไล่ทุกทาง ค่าเริ่มต้นของใบส่วนเกินต้องเป็นค่ากลางของการดำเนินการ คือค่าอนันต์สำหรับค่าน้อยสุด ไม่ใช่ศูนย์
อีกเรื่องที่ต้องระวังคู่กัน คือการค้นหาคำตอบแบบไบนารีบนเซกเมนต์ทรีที่จะพูดถึงในส่วนถัดไป เงื่อนไขเดียวที่มันต้องการคือความไม่ลด ของสิ่งที่เราไล่หา และเงื่อนไขนั้นต้องตรวจจริง ไม่ใช่เดา
ตัวอย่างที่ทำให้เห็นภาพอยู่ในคลังนี้เอง หน้า archery มีเคสที่คำตอบเรียงออกมาเป็น 4 2 2 4 ซึ่งลงแล้วขึ้น ลำดับแบบนี้ไม่ผ่านเงื่อนไขความไม่ลด การค้นหาแบบไบนารีจึงใช้กับมันไม่ได้เลย ต่อให้โครงสร้างข้อมูลถูกทุกบรรทัดก็ตาม
// เซกเมนต์ทรีแบบบวกทั้งช่วง ถามค่าน้อยสุดของช่วง
// คำสั่ง "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 ชุด เทียบกับตัวที่แก้ทีละช่องแบบซื่อ ๆ ตรงกันหมด ตัวเทียบแบบซื่อ ๆ นี่แหละที่ควรเขียนเป็นอย่างแรกทุกครั้งที่ยกเซกเมนต์ทรีมาใช้
ท่านี้ใช้ได้เมื่อคำถามมีรูป "หาค่าที่ดีที่สุด" และมีคุณสมบัติว่าถ้าค่านี้ทำได้ ค่าที่ง่ายกว่าก็ทำได้ เมื่อนั้นคำตอบของ "ทำได้ไหม" จะเรียงเป็นเท็จติดกันแล้วจริงติดกัน ซึ่งค้นหาแบบไบนารีได้
ขั้นตอนคิดที่ใช้ได้ทุกครั้งมีสามข้อ หนึ่ง เขียนฟังก์ชัน ok(x) ที่ตอบว่าค่านี้ทำได้ไหม
สอง พิสูจน์กับตัวเองว่ามันเรียงทางเดียวจริง สาม หาขอบบนที่ปลอดภัย
ระวัง
ข้อสองคือข้อที่คนข้าม และเป็นข้อที่ทำให้ตอบผิดโดยไม่รู้ตัว ในคลังนี้มีตัวอย่างจริงอยู่ใน โจทย์ยิงธนู ที่ผมเคยเสนอให้ค้นหาแบบไบนารีบนตำแหน่งเริ่มต้น แล้วตัวสุ่มเทียบชี้ว่าคำตอบที่ได้เรียงเป็น 4 2 2 4 ซึ่งไม่ได้ไปทางเดียว การค้นหาแบบไบนารีจึงใช้ไม่ได้กับข้อนั้น
มีเครื่องจักร n เครื่อง เครื่องที่ i ผลิตได้หนึ่งชิ้นทุก t หน่วยเวลา
ทุกเครื่องทำงานพร้อมกัน ถามว่าอย่างน้อยต้องใช้เวลาเท่าไรถึงจะได้ของครบ m ชิ้น
| Input | Output |
|---|---|
| 3 10 3 5 7 | 15 |
ใบ้
การหาเวลาที่น้อยที่สุดโดยตรงยาก แต่คำถามกลับด้านว่า "ภายในเวลา T ผลิตได้กี่ชิ้น"
ตอบได้ในบรรทัดเดียว แล้วขอบบนที่ปลอดภัยควรเป็นเท่าไร
ที่มาของแนวคิดนี้
ท่าแรกที่ผมลองคือคิดตรง ๆ ว่าเครื่องแต่ละเครื่องผลิตเสร็จตอนไหนบ้าง แล้วเอาเวลาทั้งหมดมาเรียง
เพื่อหาว่าชิ้นที่ m เกิดขึ้นตอนไหน ท่านี้ถูกต้อง แต่พอแทนขอบเขตจริงลงไปแล้วตาย
เพราะจำนวนชิ้นที่ต้องผลิตมีได้ถึงหลักล้าน และแต่ละชิ้นต้องคำนวณเวลาของตัวเอง
จุดที่ทำให้เปลี่ยนแนวคิดคือตอนที่ผมเขียนคำถามใหม่เป็นประโยคว่า "ภายในเวลา T
เราผลิตได้กี่ชิ้น" แล้วพบว่าคำถามรูปนี้ตอบได้ด้วยการวนเครื่องรอบเดียว คือ n ครั้ง
ซึ่งเร็วมาก ปัญหาจึงเหลือแค่ว่าต้องลอง T กี่ค่า และตรงนี้เองที่สังเกตได้ว่า
ยิ่งเวลามาก จำนวนชิ้นยิ่งไม่มีทางลดลง เมื่อคำตอบเรียงไปทางเดียวแบบนี้
การค้นหาแบบไบนารีก็ใช้ได้ทันที เหลือแค่ราว 60 ครั้งแทนที่จะเป็นล้านครั้ง
บทเรียนที่เอาไปใช้ต่อได้คือ เวลาที่คำถามว่า "น้อยที่สุดเท่าไร" ตอบยาก ให้ลองเขียนคำถามใหม่เป็น "ค่านี้ทำได้ไหม" ก่อนเสมอ ถ้ารูปใหม่ตอบง่ายและคำตอบเรียงไปทางเดียว แปลว่าเจอทางแล้ว
ภายในเวลา T เครื่องที่ i ผลิตได้ T หารด้วย t ชิ้น (ปัดลง)
รวมทุกเครื่องก็ได้จำนวนชิ้น ซึ่งไม่มีทางลดลงเมื่อ T เพิ่ม จึงค้นหาแบบไบนารีได้
ส่วนขอบบนที่ปลอดภัยคือใช้เครื่องเร็วที่สุดเครื่องเดียวทำให้ครบ ซึ่งแน่นอนว่าพอ
| ช่วงล่าง | ช่วงบน | ลองเวลา | ผลิตได้ | พอไหม |
|---|---|---|---|---|
| 1 | 30 | 15 | 10 | พอ |
| 1 | 15 | 8 | 4 | ไม่พอ |
| 9 | 15 | 12 | 7 | ไม่พอ |
| 13 | 15 | 14 | 8 | ไม่พอ |
// เครื่องจักร 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) หาค่าน้อยสุดของช่วง กับแก้ทีละช่อง
// ไม่มีการเรียกซ้ำเลย ต้นไม้เก็บในอาเรย์เดียว ใบเริ่มที่ตำแหน่ง 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;
} จนถึงตรงนี้ต้นไม้ของเราเก็บค่าน้อยสุด แต่ถ้าดูโครงสร้างจริง ๆ จะเห็นว่าโค้ดทั้งหมดพูดถึงค่าน้อยสุด อยู่แค่บรรทัดเดียว คือบรรทัดที่รวมลูกสองตัวขึ้นมาเป็นพ่อ ถ้าเปลี่ยนบรรทัดนั้น ต้นไม้ต้นเดิมก็ตอบคำถามใหม่ได้ทันที
เงื่อนไขมีข้อเดียว คือวิธีรวมต้องจับกลุ่มยังไงก็ได้ผลเท่ากัน เพราะต้นไม้จะรวม ก แล้วค่อยรวมกับ ข หรือรวม ข ก่อนก็ได้ ขึ้นกับว่าช่วงที่ถามถูกหั่นตรงไหน คุณสมบัตินี้มีชื่อว่า การเปลี่ยนหมู่ (associativity อ่านว่า "แอสโซซิเอทิวิตี") ผลรวม ค่ามากสุด ค่าน้อยสุด และ ห.ร.ม. มีคุณสมบัตินี้หมด ส่วนการลบไม่มี จึงเก็บผลต่างของช่วงไว้ในปมไม่ได้
| คำถามที่อยากตอบ | ปมเก็บอะไร | วิธีรวมลูกสองตัว | ค่ากลาง |
|---|---|---|---|
| ผลรวมของช่วง | ผลรวม | บวกกัน | 0 |
| ค่าน้อยสุด หรือมากสุดของช่วง | ค่านั้น | เลือกตัวที่น้อยกว่า หรือมากกว่า | อนันต์ |
| ค่ามากสุด และมีกี่ตัว | คู่ของค่ามากสุดกับจำนวน | ถ้าเท่ากันให้บวกจำนวน ถ้าไม่เท่าให้เอาฝั่งที่มากกว่า | อนันต์ลบ กับ 0 |
| ห.ร.ม. ของช่วง | ห.ร.ม. | หา ห.ร.ม. ของสองค่า | 0 |
| ผลรวมช่วงย่อยที่มากที่สุด | สี่ค่า ดูหัวข้อถัดไป | สูตรสี่บรรทัด | ไม่มีรูปง่าย |
คำถามแบบ "ค่ามากสุดคือเท่าไร และมีกี่ตัวที่เท่ากับค่านั้น" ตอบได้ด้วยการให้ปมเก็บสองอย่างพร้อมกัน ตอนรวมลูก ถ้าค่ามากสุดของสองฝั่งเท่ากันต้องบวกจำนวนของทั้งคู่ ถ้าไม่เท่ากันก็ทิ้งฝั่งที่น้อยกว่าไปทั้งก้อน
// เซกเมนต์ทรีที่ปมเก็บ "ค่ามากสุด และมีกี่ตัวที่เท่ากับค่ามากสุด"
// อินพุต: 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;
} ต่างจากผลรวมแค่ฟังก์ชันรวมลูกกับค่ากลางเท่านั้น ที่ยกมาเพราะมันเป็นตัวอย่างที่ชัดที่สุดว่า โครงต้นไม้ไม่เกี่ยวกับคำถามเลย
// ห.ร.ม. ของช่วง ต่างจากผลรวมแค่ฟังก์ชันที่ใช้รวมลูกสองตัวเท่านั้น
// อินพุต: 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;
} อันนี้คือตัวอย่างที่สวยที่สุดของหัวข้อนี้ คำถามคือในช่วงที่ให้มา ให้เลือกช่วงย่อยต่อเนื่องหนึ่งช่วง ที่ผลรวมมากที่สุด ปมเดียวเก็บคำตอบอย่างเดียวไม่พอ เพราะช่วงย่อยที่ดีที่สุดของพ่ออาจคร่อมรอยต่อ ของลูกสองตัวอยู่ ซึ่งลูกทั้งสองไม่มีใครรู้เรื่องนั้นเลย
ทางออกคือให้ปมเก็บสี่ค่า คือผลรวมทั้งก้อน ผลรวมที่ดีที่สุดเมื่อเริ่มจากขอบซ้าย ผลรวมที่ดีที่สุดเมื่อจบที่ขอบขวา และคำตอบของช่วงนั้น พอมีสี่ค่านี้แล้ว ช่วงที่คร่อมรอยต่อก็คิดได้ตรง ๆ คือหางขวาที่ดีที่สุดของลูกซ้าย บวกหัวซ้ายที่ดีที่สุดของลูกขวา
// เซกเมนต์ทรีที่ปมเก็บสี่ค่า เพื่อตอบ "ผลรวมของช่วงย่อยที่มากที่สุด" ในช่วงที่ถาม
// สี่ค่านั้นคือ ผลรวมทั้งก้อน, ผลรวมที่ดีที่สุดเมื่อเริ่มจากขอบซ้าย, เมื่อจบที่ขอบขวา, และที่ดีที่สุดโดยรวม
// อินพุต: 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;
} วิธีเลือกว่าปมควรเก็บอะไรเพิ่ม มีคำถามนำอยู่ข้อเดียว คือ "ถ้าฉันรู้แค่คำตอบของลูกสองตัว ฉันตอบของพ่อได้ไหม" ถ้าตอบไม่ได้ ให้ถามต่อว่าขาดข้อมูลอะไร แล้วเก็บสิ่งนั้นเพิ่มเข้าไปในปม
ส่วนที่ 2 บอกว่าคำถามยาก ๆ แปลงเป็นคำถามใช่หรือไม่แล้วค้นหาแบบไบนารีได้ ถ้าเอาท่านั้นมาทับเซกเมนต์ทรี
ตรง ๆ จะได้เวลา log n ยกกำลังสอง เพราะทุกครั้งที่เดาค่าหนึ่งค่า ต้องถามต้นไม้ใหม่ทั้งรอบ
แต่ในหลายกรณีเราทำได้ดีกว่านั้น เพราะต้นไม้เองก็เป็นการแบ่งครึ่งอยู่แล้ว
แทนที่จะเดาค่าจากข้างนอกแล้วถามเข้าไป เรายืนที่รากแล้วถามลูกซ้ายว่า "คำตอบอยู่ในฝั่งเธอไหม"
ถ้าอยู่ก็ลงซ้าย ถ้าไม่อยู่ก็ลงขวา ทำแบบนี้ไปจนถึงใบ ได้คำตอบใน log n ครั้งเดียว
ท่านี้เรียกว่าการเดินลงต้นไม้ (descent อ่านว่า "ดีเซนต์"
แปลว่า การไต่ลง)
อาเรย์ข้างล่างมีแต่ 0 กับ 1 ตัวเลขในปมคือจำนวนศูนย์ในช่วงที่ปมนั้นดูแล โจทย์คือหาว่าศูนย์ตัวที่ k อยู่ช่องไหน โดยห้ามไล่ดูทีละช่อง ให้เลือกลงซ้ายหรือขวาทีละชั้นเท่านั้น
ตัวเลขในปมคือจำนวนศูนย์ในช่วงที่ปมนั้นดูแล เลือกลงซ้ายหรือขวาไปเรื่อย ๆ จนถึงใบ
ค่าจริงในอาเรย์
กดเลือกด่านแล้วเริ่มเดินจากราก
ผ่านแล้ว 0 จาก 0 ข้อ
ที่ปมหนึ่ง ให้ดูจำนวนศูนย์ของลูกซ้าย ถ้ามันมีศูนย์พอที่ k ตัวก็ลงซ้าย ถ้าไม่พอ ศูนย์ตัวที่ต้องการอยู่ทางขวา และ k ที่เหลือต้องหักจำนวนที่ซ้ายมีออกไป
ที่มาของแนวคิดนี้
ตอนแรกผมเขียนท่านี้แบบตรงไปตรงมาที่สุด คือค้นหาแบบไบนารีบนตำแหน่ง แล้วในแต่ละรอบถามต้นไม้ว่า
"ตั้งแต่ต้นถึงตำแหน่งนี้มีศูนย์กี่ตัว" ท่านี้ถูก และเขียนเสร็จภายในไม่กี่นาที
เวลาที่ได้คือ log n ยกกำลังสอง ซึ่งกับ n สองแสนและคำสั่งสองแสนครั้ง
คูณออกมาแล้วราว 60 ล้านครั้ง ยังผ่าน จึงไม่มีแรงกดดันให้คิดต่อ
สิ่งที่ทำให้เปลี่ยนใจคือตอนที่ผมพิมพ์ตำแหน่งที่การค้นหาแบบไบนารีไปเยี่ยมออกมาดู แล้วเห็นว่ามันคือครึ่งซ้ายครึ่งขวาซ้ำ ๆ ซึ่งเป็นรูปเดียวกับที่ต้นไม้แบ่งไว้อยู่แล้วเป๊ะ แปลว่าผมกำลังจ่ายค่าเดินต้นไม้ทั้งรอบ เพื่อถามคำถามที่ปมในเส้นทางเดียวนั้นตอบได้อยู่แล้ว การถามซ้อนออกไปข้างนอกจึงเป็นงานที่ซ้ำกับสิ่งที่ต้นไม้ทำอยู่
บทเรียนที่ติดตัวมาจากตรงนี้คือ ถ้ากำลังจะเอาการค้นหาแบบไบนารีไปครอบโครงสร้างที่ตัวมันเองก็แบ่งครึ่ง ให้หยุดคิดสักครู่ก่อนเสมอ ส่วนใหญ่แล้วสองชั้นนั้นยุบเป็นชั้นเดียวได้
ท่านี้ใช้ได้เมื่อคำถามอยู่ในรูป "หาตำแหน่งแรกที่เงื่อนไขเป็นจริง" และปมบอกได้ว่าคำตอบอยู่ในกิ่งของมันไหม โดยไม่ต้องลงไปดูข้างใน ในกรณีศูนย์ตัวที่ k ปมบอกได้ทันที เพราะมันเก็บจำนวนศูนย์ของทั้งช่วงไว้แล้ว
// เดินลงต้นไม้เพื่อหา "ศูนย์ตัวที่ 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;
} ผมแก้ถูกตั้งแต่แรก แต่เข้าใจเหตุผลผิดอยู่นาน
บรรทัดที่ตรวจว่า t[1] น้อยกว่า k ไหมก่อนเดินลง ผมใส่ไว้ตั้งแต่ต้น
เพราะคิดว่าเป็นการกันพลาดเฉย ๆ แล้วตอนเขียนบทนี้ผมลองลบมันออกเพื่อดูว่ามันจำเป็นจริงไหม
ปรากฏว่าโปรแกรมยังตอบตัวอย่างถูกหมด และผ่านการสุ่มเทียบไปหลายสิบรอบด้วย
มันพังก็ต่อเมื่อมีคนขอศูนย์มากกว่าที่มีอยู่จริง ซึ่งการสุ่มธรรมดาแทบไม่เจอ พอไล่ทุกกรณีเล็ก ๆ ทั้งหมดถึงเจอว่าเคสที่เล็กที่สุดที่ต่างกันคืออาเรย์ยาวช่องเดียวที่มีค่า 0 แล้วถามหาศูนย์ตัวที่ 2 ตัวที่ไม่มีการตรวจตอบว่าอยู่ที่ช่อง 0 ส่วนคำตอบจริงคือไม่มี
สาเหตุคือการเดินลงไม่เคยล้มเหลว มันเดินลงเรื่อย ๆ จนถึงใบสักใบเสมอ แล้วรายงานใบนั้นออกมา โดยไม่เคยตรวจว่าใบนั้นเป็นศูนย์จริงไหม การตรวจที่รากจึงไม่ใช่การกันพลาด แต่เป็นเงื่อนไขที่ทำให้ท่านี้ถูกต้อง
พอเข้าใจรูปนี้แล้ว คำถามอีกหลายแบบก็เป็นท่าเดียวกันหมด ต่างกันแค่ว่าปมเก็บอะไร และคำถามที่ถามลูกซ้ายคืออะไร
| คำถาม | ปมเก็บ | ถามลูกซ้ายว่า |
|---|---|---|
| ศูนย์ตัวที่ k อยู่ตรงไหน | จำนวนศูนย์ | มีศูนย์ครบ k ตัวไหม |
| ช่องแรกที่ค่าไม่น้อยกว่า x | ค่ามากสุด | ค่ามากสุดถึง x ไหม |
| ตำแหน่งแรกที่ผลรวมสะสมถึง x | ผลรวม | ผลรวมของฉันถึง x ไหม |
// หา "ช่องแรกตั้งแต่ตำแหน่ง 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
ป้ายที่ส่วนที่ 1 ใช้เป็นชนิดบวก ซึ่งมีคุณสมบัติพิเศษที่ทำให้ชีวิตง่ายมาก คือป้ายสองใบ ที่ค้างซ้อนกันรวมเป็นใบเดียวได้ด้วยการบวก และไม่ว่าจะบวกก่อนหรือหลังก็ได้ผลเท่ากัน โค้ดของบทนี้จึง ไม่ต้องดันป้ายลงลูกเลย แค่บวกป้ายที่เจอระหว่างทางสะสมลงมาก็พอ
แต่ป้ายชนิดกำหนดค่าทั้งช่วง ไม่ใช่แบบนั้น เพราะมันทับของเดิมทิ้ง ค่าที่ลูกเก็บไว้ กลายเป็นค่าที่ใช้ไม่ได้แล้ว ไม่ใช่แค่ค่าที่ยังขาดอะไรไปนิดหน่อย เราจึงต้องดันป้ายลงลูกจริง ก่อนลงไปแตะลูกทุกครั้ง ท่านี้คือ push ซึ่งอ่านว่า "พุช" แปลว่า ผลัก
กดถัดไปเพื่อดูป้ายเดินลงลูกทีละชั้น
ตัวเลขบนคือผลรวมของช่วงที่ปมนั้นดูแล บรรทัดล่างคือช่วง หรือป้ายที่ค้างอยู่ถ้ามี · ปมทองคือปมที่กำลังทำงาน ปมเขียวคือลูกที่เพิ่งรับป้ายมา
// ป้ายค้างชนิด "กำหนดค่าทั้งช่วง" ซึ่งต่างจากชนิดบวก ตรงที่มันทับของเดิมทิ้ง
// จึงต้องดันป้ายลงลูกจริงก่อนลงไปแตะลูกทุกครั้ง
// อินพุต: 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;
} ระวัง
ป้ายชนิดกำหนดค่าต้องแยก "ป้ายที่มีค่าเป็นศูนย์" ออกจาก "ไม่มีป้าย" ให้ได้
โค้ดข้างบนจึงมีตัวแปร marked แยกต่างหาก ถ้าใช้เลขศูนย์แทนคำว่าไม่มีป้ายเหมือนที่ทำได้กับป้ายชนิดบวก
คำสั่งกำหนดค่าทั้งช่วงให้เป็นศูนย์จะหายไปเงียบ ๆ และอาการของบักนี้คือตอบตัวอย่างถูก แต่ตกเทสที่มีเลขศูนย์
รูปนี้เป็นลูกผสมที่เจอบ่อยที่สุดในการแข่ง และเขียนแบบดันป้ายลงลูกได้ตรง ๆ ยกมาเพราะมันคือรูปมาตรฐานที่โจทย์ส่วนใหญ่ใช้ ต่างจากรูปที่บทนี้ใช้ในเฉลยข้อที่ 1 ซึ่งเลือกไม่ดันป้าย
// บวกทั้งช่วง ถามค่ามากสุดของช่วง เขียนแบบดันป้ายลงลูกจริง เพื่อให้เห็นรูปมาตรฐาน
// (โค้ดหลักของบทนี้ใช้อีกรูปหนึ่งคือไม่ดันป้าย แต่บวกป้ายสะสมระหว่างทางแทน)
// อินพุต: 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;
} จนถึงตอนนี้ปมเก็บค่าสรุปค่าเดียวหรือไม่กี่ค่า แต่ไม่มีกฎอะไรห้ามให้ปมเก็บของหนักกว่านั้น ถ้าให้ทุกปมเก็บตัวเลขทั้งหมดในช่วงของมัน เรียงจากน้อยไปมาก เราจะตอบคำถามอย่าง "ค่าที่น้อยที่สุดที่ยังไม่น้อยกว่า x ในช่วงนี้" ได้ ซึ่งเป็นคำถามที่ค่าสรุปค่าเดียวตอบไม่ได้เลย
โครงนี้เรียกว่า merge sort tree อ่านว่า "เมิร์จ ซอร์ต ทรี" แปลว่า ต้นไม้ของการเรียงแบบผสาน ชื่อมาจากการที่รายการของพ่อคือการผสานรายการของลูกสองตัวเข้าด้วยกัน ซึ่งเป็นขั้นตอนเดียวกับการเรียงแบบแบ่งครึ่งพอดี
// ต้นไม้ที่ทุกปมเก็บ "อาเรย์ย่อยของช่วงนั้น เรียงจากน้อยไปมาก"
// ตอบคำถาม "ค่าที่น้อยที่สุดที่ยังไม่น้อยกว่า 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
อ่านว่า "แฟรกชันนัล แคสเคดดิง" แปลว่า การไหลต่อกันเป็นชั้น ๆ
แนวคิดคือในรายการของพ่อ ให้จำไว้ล่วงหน้าว่าแต่ละตำแหน่งตรงกับตำแหน่งไหนในรายการของลูกทั้งสอง
พอค้นหาแบบไบนารีที่รากเสร็จครั้งเดียว ชั้นถัด ๆ ไปก็แค่อ่านตำแหน่งที่จำไว้
ไม่ต้องค้นหาใหม่ทุกชั้น เทคนิคนี้เขียนยากและใช้ในการแข่งไม่บ่อย จึงยกมาให้รู้ว่ามีอยู่เป็นหลัก
ปกติเวลาเราแก้ค่าในต้นไม้ สภาพเดิมก็หายไป แต่ถ้าเราตั้งกฎใหม่ว่าห้ามแก้ปมเดิมเด็ดขาด เวลาจะแก้ให้สร้างปมใหม่แทน ผลที่ได้คือทุกเวอร์ชันของอาเรย์ยังอยู่ครบ และถามย้อนหลังได้ทั้งหมด โครงนี้เรียกว่า persistent segment tree โดย persistent อ่านว่า "เพอร์ซิสเทนต์" แปลว่า คงอยู่ ไม่หายไป
คำถามแรกที่ทุกคนถามคือ ถ้าห้ามแก้ของเดิมแล้วต้องก๊อปทั้งต้นทุกครั้งหรือเปล่า คำตอบคือไม่ต้อง เพราะการแก้หนึ่งช่องกระทบแค่เส้นทางเดียวจากรากลงใบ ปมที่เหลือทั้งหมดไม่เปลี่ยนค่า จึงชี้กลับไปใช้ปมเดิมได้เลย เครื่องข้างล่างเดินให้ดูทีละปม
กดถัดไปเพื่อสร้างเวอร์ชันใหม่ทีละปม
ตัวเลขบนคือผลรวมของช่วงที่ปมนั้นดูแล · เส้นประคือการชี้กลับไปใช้ปมของเวอร์ชันเก่าโดยไม่ก๊อป
การแก้หนึ่งครั้งจึงกินหน่วยความจำแค่ log n ปม ถ้าแก้ q ครั้ง
ก็เก็บทุกเวอร์ชันไว้ได้ด้วยหน่วยความจำ q log n ซึ่งรับได้สบายในขอบเขตการแข่งทั่วไป
นี่คือโจทย์ที่ทำให้ต้นไม้แบบจำอดีตได้ดังที่สุด และวิธีคิดสวยมาก ให้สร้างต้นไม้ที่นับจำนวนของแต่ละค่า
แล้วใส่ตัวเลขทีละตัวจากซ้ายไปขวา เก็บเวอร์ชันไว้ทุกครั้ง เวอร์ชันที่ i จึงเป็นการนับของ
ช่องแรกถึงช่องที่ i
ทีนี้จำนวนของค่าแต่ละตัวในช่วง l ถึง r ก็คือ เวอร์ชันที่ r
ลบเวอร์ชันที่ l ลบหนึ่ง ทีละปม แล้วเราก็เดินลงต้นไม้ด้วยผลต่างนั้น เหมือนส่วนที่ 4 เป๊ะ
ได้คำตอบใน log n
// ต้นไม้ที่จำอดีตได้ ใช้ตอบ "ค่าที่เล็กเป็นอันดับที่ 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;
}
ถ้าดัชนีของโจทย์ใหญ่ถึงหนึ่งพันล้าน เราสร้างต้นไม้ทั้งต้นไม่ไหวแน่ แต่สังเกตว่าถ้ามีคำสั่งแค่สองแสนครั้ง
ปมที่ถูกแตะจริงก็มีแค่ราวสองแสนคูณ log ปมเท่านั้น ที่เหลือคือความว่างเปล่า
ซึ่งตอบได้ด้วยค่ากลางอยู่แล้ว
ท่านี้เรียกว่า dynamic segment tree หรือ implicit segment tree โดย implicit
อ่านว่า "อิมพลิซิต" แปลว่า มีอยู่โดยไม่ต้องเขียนออกมา คือกิ่งทั้งต้นมีอยู่ในความคิด
แต่ในหน่วยความจำมีเฉพาะกิ่งที่เคยถูกใช้ ความต่างจากเดิมคือลูกไม่ได้อยู่ที่ 2v กับ
2v+1 อีกแล้ว ต้องเก็บหมายเลขลูกไว้ในปมเอง
// ต้นไม้ที่งอกเฉพาะกิ่งที่ถูกใช้ ใช้กับช่วงดัชนีที่ใหญ่จนสร้างทั้งต้นไม่ไหว เช่นถึงหนึ่งพันล้าน
// ปมลูกไม่ได้อยู่ที่ 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 ขยายตัวและย้ายที่ อ้างอิงที่ถืออยู่ก็ชี้ไปยังหน่วยความจำที่ไม่ใช่ของมันแล้ว
อาการคือค่าหายเป็นบางครั้ง ซึ่งดูเหมือนอัลกอริทึมผิดทั้งที่ผิดที่ภาษา โค้ดข้างบนจึงคืนหมายเลขปมกลับไป
ให้พ่อเก็บแทน
ถ้าข้อมูลเป็นตารางแล้วอยากถามผลรวมของสี่เหลี่ยม แนวคิดคือทำต้นไม้ซ้อนต้นไม้ ต้นนอกแบ่งตามแถว
แล้วทุกปมของต้นนอกมีต้นไม้ของตัวเองที่แบ่งตามคอลัมน์ คำถามหนึ่งครั้งจึงแตะปมของต้นนอกราว
log n ปม และในแต่ละปมยังต้องถามต้นในอีก log m รวมเป็น log n
คูณ log m
// ต้นไม้สองมิติ ต้นนอกแบ่งตามแถว ทุกปมของต้นนอกมีต้นไม้ของตัวเองที่แบ่งตามคอลัมน์
// ตอบผลรวมของสี่เหลี่ยมได้ในเวลา 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 แต่แลกกับการที่แก้ค่าไม่ได้อีกแล้ว เพราะรายชื่อคอลัมน์ถูกตรึงไว้ตั้งแต่ตอนสร้าง
โค้ดเซกเมนต์ทรีเกือบทุกที่จองอาเรย์ขนาด 4n ทั้งที่ต้นไม้มีปมจริงแค่ 2n-1 ปม
เหตุผลคือเมื่อ n ไม่ใช่กำลังสองพอดี ต้นไม้จะไม่สมดุล และหมายเลขปมที่ใหญ่ที่สุด
อาจโตเกิน 2n ไปได้ การเผื่อสี่เท่าคือการเลิกคิดเรื่องนี้ทั้งหมด ซึ่งคุ้มในสนามแข่ง
| วิธีจอง | จำนวนช่อง | หน่วยความจำ | ราคาที่ต้องจ่าย |
|---|---|---|---|
| เผื่อไว้สี่เท่า (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) ซึ่งต้องคิดเองทุกครั้ง |
วิธีลดเหลือ 2n พอดีคือเลิกใช้หมายเลข 2v กับ 2v+1
แล้วเปลี่ยนไปเรียงหมายเลขปมตามลำดับที่เดินต้นไม้ ลูกซ้ายคือ v+1 เสมอ
ส่วนลูกขวาอยู่ถัดจากที่ฝั่งซ้ายกินไปทั้งหมด ซึ่งคือ v + 2(mid - lo + 1)
// เซกเมนต์ทรีที่ใช้หน่วยความจำแค่ 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;
} ข้อเสียคือสูตรของลูกขวาต้องคิดใหม่ทุกครั้งที่เรียก และอ่านยากกว่าเดิมพอสมควร ในสนามแข่งจึงยังแนะนำให้เผื่อสี่เท่าไปก่อน แล้วค่อยงัดรูปนี้มาใช้ตอนที่หน่วยความจำไม่พอจริง ๆ
เซกเมนต์ทรีคือการเก็บคำตอบของช่วงไว้ล่วงหน้า พร้อมป้ายที่ผัดงานของลูกไว้จนกว่าจะมีคนถาม ส่วนการค้นหาคำตอบแบบไบนารีคือการเปลี่ยนคำถามที่ตอบยากให้เป็นคำถามใช่หรือไม่ที่ตอบง่าย และเงื่อนไขเดียวที่มันต้องการคือคำตอบต้องเรียงไปทางเดียว ซึ่งเป็นข้อที่ต้องตรวจจริง ไม่ใช่เดา
ส่วนเทคนิคทั้งแปดที่ตามมาหลังจากนั้นเป็นเรื่องเดียวกันหมด คือโครงต้นไม้ไม่เคยเปลี่ยน สิ่งที่เปลี่ยนมีแค่สามอย่าง คือปมเก็บอะไร ป้ายที่ค้างอยู่มีความหมายว่าอะไร และเราเดินต้นไม้จากบนลงล่าง หรือจากล่างขึ้นบน เวลาเจอโจทย์ใหม่ให้ถามสามข้อนี้ก่อนเสมอ แทนที่จะไปหาโครงสร้างใหม่มาทั้งอัน
ในหน้านี้