ปูพื้นฐาน
เครื่องมือที่โจทย์ "ถามผลรวมของช่วง" ทุกข้อเรียกใช้ บทนี้ให้กฎตัดสินว่าเมื่อไรผลรวมสะสมพอ เมื่อไรต้องยกต้นไม้มาใช้ และเมื่อไรที่แค่เรียงข้อมูลก็จบโดยไม่ต้องใช้ทั้งคู่ มีเกมต่อก้อนให้เล่นจนเห็นว่าทำไมเลขฐานสองใช้แบ่งช่วงได้ ปิดท้ายด้วยข้อจำกัดที่ทำให้มันตอบ min ไม่ได้ และห้าเทคนิคที่ต่อยอดจากสองลูปเดิม
โจทย์ชุดหนึ่งที่โผล่บ่อยมากหน้าตาเป็นแบบนี้ ให้อาเรย์ยาว n ตัว แล้วมีคำถามตามมาอีก q ข้อ
แต่ละข้อถามว่า "ผลรวมของช่องที่ l ถึงช่องที่ r เป็นเท่าไร" ตอบให้ครบทุกข้อ
ท่าแรกที่ทุกคนเขียนคือวนบวกไปตามช่วงที่เขาถาม ซึ่งถูกต้องร้อยเปอร์เซ็นต์และตกทุกครั้ง
เมื่อ n กับ q โตขึ้นไปถึงระดับสองแสน เพราะคำถามข้อเดียวที่ถามช่วงยาวทั้งแถวก็กินสองแสนครั้งแล้ว
ถามสองแสนข้อคือสี่หมื่นล้านครั้ง
บทนี้ไล่จากท่าที่ง่ายที่สุดไปหาท่าที่ยืดหยุ่นที่สุด แล้วจบด้วยคำถามที่สำคัญกว่าทั้งสองท่า คือเมื่อไรควรใช้อันไหน เพราะท่าที่แรงกว่าไม่ได้แปลว่าท่าที่ควรหยิบ
ชุดนี้ถามอย่างเดียว ไม่มีการแก้ค่าเลย
คำถามตอนนี้
อ่านคำถามแล้วพิมพ์ผลรวมของช่วงที่ถูกไฮไลต์
พิมพ์ไว้ - · ตอบถูกแล้ว 0 จาก 0 ข้อ
ในชุดที่มีการแก้ค่า สังเกตว่าหลังแก้หนึ่งครั้ง อะไรที่คำนวณไว้ล่วงหน้าต้องถูกทิ้งบ้าง
ถ้ารู้สึกว่าชุดแรกตอบง่ายกว่าชุดที่สองมาก นั่นแหละคือความต่างระหว่างข้อมูลที่นิ่งกับข้อมูลที่เปลี่ยนได้
แทนที่จะตอบคำถามตอนถูกถาม ให้ทำการบ้านไว้ก่อนหนึ่งรอบ สร้างอาเรย์ใหม่ชื่อ pre ที่
pre[i] เก็บผลรวมตั้งแต่ช่องแรกจนถึงช่องที่ i เรียกมันว่า
ผลรวมสะสม (prefix sum) คำว่า prefix อ่านว่า "พรีฟิกซ์" แปลว่าส่วนที่นำหน้า เป็นคำเดียวกับคำนำหน้าคำในภาษาอังกฤษ
ที่นี่หมายถึงท่อนที่เริ่มจากหัวแถวเสมอ
พอมีตารางนี้แล้ว ผลรวมของช่วงใด ๆ ก็หักลบกันออกมาได้ในการคำนวณครั้งเดียว
เอาอาเรย์ตัวอย่าง 3 1 4 1 5 9 2 6 มาไล่ให้เห็นชัด ๆ
| i | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|---|---|
| a[i] | · | 3 | 1 | 4 | 1 | 5 | 9 | 2 | 6 |
| pre[i] | 0 | 3 | 4 | 8 | 9 | 14 | 23 | 25 | 31 |
pre[0] ที่เป็น 0 ไม่ใช่ของประดับ มันคือสิ่งที่ทำให้ช่วงที่เริ่มต้นที่ช่อง 1 ไม่ต้องเขียนเงื่อนไขพิเศษ
เช่นผลรวมช่วง 1 ถึง 4 คือ 9 − 0 = 9 ใช้สูตรเดียวกับทุกช่วง
ท่านี้จบเร็วและเขียนสั้นมาก ปัญหาเดียวของมันโผล่ตอนโจทย์เติมประโยคเดียวเข้ามาว่า
"ระหว่างทางมีการแก้ค่าในช่องด้วย" เพราะการแก้ค่าช่องที่ 3 ทำให้ pre[3] จนถึง
pre[n] ผิดหมดทุกช่อง ต้องสร้างใหม่ทั้งแถว
// ผลรวมสะสม สร้างครั้งเดียวแล้วตอบทุกช่วงในเวลาคงที่
// pre[0] = 0 เสมอ เพื่อให้ช่วงที่เริ่มที่ 1 ไม่ต้องเขียนเงื่อนไขพิเศษ
vector<long long> pre(n + 1, 0);
for (int i = 1; i <= n; i++) pre[i] = pre[i - 1] + a[i];
long long sum = pre[r] - pre[l - 1]; // ผลรวมของ a[l..r] | ท่า | สร้างตอนต้น | ถามหนึ่งครั้ง | แก้ค่าหนึ่งครั้ง | รวม |
|---|---|---|---|---|
| ไล่บวกทีละช่องตอนถูกถาม | 0 | ยาวช่วง สูงสุด n | 1 | 20.0 พันล้าน |
| ผลรวมสะสม | n | 2 | สร้างใหม่ทั้งแถว n | 20.0 พันล้าน |
| ต้นไม้เฟนวิก | n log n | 2 log n | log n | 9.0 ล้าน |
n = 200,000 และ q = 200,000 โดยสมมติว่าครึ่งหนึ่งของคำถามเป็นการถามผลรวม
อีกครึ่งเป็นการแก้ค่า ทั้งสามแถวนับหน่วยเดียวกัน ถ้าโจทย์ไม่มีการแก้ค่าเลย แถวกลางคือ 600,000 ครั้ง
ซึ่งชนะแถวล่างขาดลอย
คิดก่อนอ่านต่อ
ผลรวมสะสมเร็วเพราะมันเก็บก้อนเดียวที่ใหญ่ที่สุด คือตั้งแต่หัวแถวถึงตำแหน่งนั้น ซึ่งทำให้แก้ค่าทีเดียวพังยาว ส่วนอาเรย์ดิบเก็บทีละช่อง แก้ง่ายแต่ถามช้า ถ้าอยากได้ทั้งสองอย่าง เราน่าจะต้องเก็บก้อนที่ใหญ่กำลังดี คำถามคือใหญ่แค่ไหนถึงเรียกว่ากำลังดี และแต่ละช่องควรดูแลช่วงไหน?
โครงสร้างที่ตอบคำถามข้างบนชื่อ ต้นไม้เฟนวิก (Fenwick tree) อ่านว่า "เฟนวิก" ตั้งตามชื่อ Peter Fenwick นักวิทยาศาสตร์คอมพิวเตอร์ชาวนิวซีแลนด์ที่เขียนถึงมันในปี 1994 อีกชื่อที่เจอบ่อยพอกันคือ Binary Indexed Tree (ย่อว่า BIT) แปลตรงตัวว่าต้นไม้ที่ใช้เลขฐานสองเป็นดัชนี ซึ่งเป็นชื่อที่บอกกลไกได้ตรงกว่า
ที่มาของแนวคิดนี้
สองท่าที่ผ่านมาเกิดจากการสังเกตอย่างเดียว คือคำถามที่ถูกถามซ้ำ ๆ มีส่วนที่คำนวณเหมือนกันทุกครั้ง แล้วเราทิ้งมันหลังตอบเสร็จ ผลรวมสะสมคือการย้ายงานนั้นมาทำรอบเดียวตอนต้น คำถามที่ตัดสินว่าจะใช้ท่าไหนจึงมีข้อเดียว คือ ของที่เตรียมไว้ ยังใช้ได้อยู่ไหมหลังข้อมูลถูกแก้
ถ้าไม่มีการแก้ค่าเลย คำตอบคือใช้ได้ตลอด ผลรวมสะสมจึงจบงาน แต่ถ้ามีการแก้ค่าแม้แต่ช่องเดียว
pre ตั้งแต่ช่องนั้นไปจนสุดแถวเสียหมด การสร้างใหม่ราคาเท่ากับความยาวอาเรย์
และตรงนี้คือจุดที่ต้องคูณเลขออกมาดู แก้สองแสนครั้ง ครั้งละสร้างใหม่สองแสนช่อง
ก็คือ สี่หมื่นล้านครั้ง เท่ากับท่าวนบวกดิบ ๆ ที่เราเพิ่งหนีมาเป๊ะ ๆ
ผลรวมสะสมไม่ได้ช่วยอะไรเลยในสถานการณ์นั้น
ต้นไม้เฟนวิกเกิดมาเพื่อช่องว่างตรงนั้นช่องเดียว มันยอมให้การถามช้าลงจากทันทีเป็น log n ก้าว
เพื่อแลกกับการที่การแก้ค่าก็เป็น log n ก้าวเหมือนกัน แทนที่จะเป็นสองแสน
บทเรียนที่ยกไปข้ออื่นได้คือ ก่อนหยิบโครงสร้างที่แรงกว่า ให้คูณราคาของท่าที่ง่ายกว่าออกมาก่อน บางทีมันผ่านสบาย และบางทีมันแพงเท่ากับท่าดิบที่เราคิดว่าหนีพ้นแล้ว
กติกามีข้อเดียว ช่องที่ i ของต้นไม้ดูแลช่วงที่ยาวเท่ากับบิตต่ำสุดที่ยังเป็น 1 ของ i
และช่วงนั้นจบลงที่ i พอดี เขียนบิตต่ำสุดนั้นเป็นสูตรได้ว่า i & -i
เหตุผลที่ i & -i ให้บิตต่ำสุดออกมา อยู่ที่วิธีที่คอมพิวเตอร์เก็บเลขติดลบ ค่า -i
คือการกลับบิตทุกตัวของ i แล้วบวกหนึ่ง ผลคือบิตทางซ้ายของบิตต่ำสุดกลับด้านกันหมด และบิตต่ำสุดเองยังตรงกัน
การ and กันจึงเหลือรอดแค่บิตเดียวนั้น
กดถัดไปเพื่อไล่ดูว่า i & -i ของ 12 เหลือบิตเดียวได้ยังไง
| แถว | 16 | 8 | 4 | 2 | 1 |
|---|---|---|---|---|---|
| i | 0 | 1 | 1 | 0 | 0 |
| กลับบิตทุกตัว | 1 | 0 | 0 | 1 | 1 |
| บวกหนึ่ง คือ −i | 1 | 0 | 1 | 0 | 0 |
| i & −i | 0 | 0 | 1 | 0 | 0 |
หัวตารางคือค่าประจำหลัก ไม่ใช่ลำดับหลัก จะได้อ่านคำตอบของแถวล่างสุดออกมาเป็นเลขได้เลย
| i | i ฐานสอง | i & -i | ช่วงที่ดูแล | t[i] |
|---|---|---|---|---|
| 1 | 0001 | 1 | a[1..1] ยาว 1 | 3 |
| 2 | 0010 | 2 | a[1..2] ยาว 2 | 4 |
| 3 | 0011 | 1 | a[3..3] ยาว 1 | 4 |
| 4 | 0100 | 4 | a[1..4] ยาว 4 | 9 |
| 5 | 0101 | 1 | a[5..5] ยาว 1 | 5 |
| 6 | 0110 | 2 | a[5..6] ยาว 2 | 14 |
| 7 | 0111 | 1 | a[7..7] ยาว 1 | 2 |
| 8 | 1000 | 8 | a[1..8] ยาว 8 | 31 |
t[6] ควรเท่ากับ
a[5] + a[6] คือ 5 + 9 = 14 ก่อนอ่านสูตรของการถามและการแก้ ลองต่อก้อนด้วยมือดูก่อน แถบล่างคือตำแหน่ง 1 ถึง 16 ปุ่มแต่ละปุ่มคือหนึ่งช่องของต้นไม้ พร้อมเลขฐานสองของดัชนีมันกำกับไว้ให้ เป้าหมายคือทำให้ช่วงที่โจทย์ขอเป็นสีเขียวพอดี ไม่ทับกันและไม่มีรู
เลือกช่องของต้นไม้ให้แถบข้างล่างเป็นสีเขียวพอดีทั้งช่วง 1 ถึง 7 ห้ามมีช่องไหนถูกคลุมสองครั้ง และห้ามล้นออกนอกช่วง
แตะปุ่มช่องของต้นไม้ แล้วดูแถบข้างบนเปลี่ยนสี
ผ่านแล้ว 0 จาก 4 ชุด
ในชุดที่ต่อไม่ได้ ให้สังเกตว่าช่องที่คลุมตำแหน่งซ้ายสุดของช่วง มันคลุมอะไรพ่วงมาด้วยเสมอ
ชุดที่สี่มีอยู่เพื่อบอกเรื่องเดียว คือช่องของต้นไม้ต่อกันได้เฉพาะช่วงที่เริ่มจากหัวแถว ซึ่งเป็นข้อจำกัดที่จะย้อนมามีผลใหญ่มากในหัวข้อท้ายบท
อยากได้ผลรวมตั้งแต่ต้นถึงตำแหน่ง 7 ก็เริ่มที่ช่อง t[7] ซึ่งดูแลช่วงท้ายสุดมาก้อนหนึ่ง
แล้วกระโดดถอยไปยังช่องที่ดูแลก้อนถัดไป การกระโดดคือ i -= i & -i ตรง ๆ ทำซ้ำจนถึงศูนย์
กดถัดไปเพื่อไล่ทีละก้อน
| ช่องที่แวะ | ก้อนที่ได้ | ค่า | สะสม |
|---|---|---|---|
| t[7] | a[7..7] | 2 | 2 |
| t[6] | a[5..6] | 14 | 16 |
| t[4] | a[1..4] | 9 | 25 |
สามก้อนนี้ต่อกันพอดีเป็นช่วง a[1..7] ไม่ทับกันและไม่มีรู ผลรวมที่ได้คือ 25
ตรงกับ pre[7] ในตารางแรก ที่มันแวะแค่ 3 ช่องเพราะเลข 7 เขียนฐานสองได้
111 ซึ่งมีเลขหนึ่งอยู่ 3 ตัว
และการกระโดดหนึ่งครั้งลบเลขหนึ่งออกไปหนึ่งตัวเสมอ
ถ้าค่าที่ตำแหน่ง 3 เปลี่ยนไป ช่องของต้นไม้ที่ต้องแก้ตามคือทุกช่องที่ช่วงของมันคลุมตำแหน่ง 3 อยู่
ซึ่งหาได้ด้วยการกระโดดไปทางตรงข้ามคือ i += i & -i
กดถัดไปเพื่อไล่ขึ้นทีละชั้น
| ช่องที่ต้องแก้ | ช่วงที่มันดูแล | คลุมตำแหน่ง 3 ไหม |
|---|---|---|
| t[3] | a[3..3] | คลุม |
| t[4] | a[1..4] | คลุม |
| t[8] | a[1..8] | คลุม |
ช่องอื่นที่เหลือทั้งหมดไม่มีตำแหน่ง 3 อยู่ในช่วงของมัน จึงไม่ต้องแตะเลย จาก 8 ช่องเหลือแก้แค่ 3 ช่อง
และพอ n เป็นสองแสน ตัวเลขนี้ก็ยังไม่เกิน 18
ระวัง
สองข้อที่ทำให้โค้ดเงียบ ๆ ผิด ข้อแรก ดัชนีต้องเริ่มที่ 1 เพราะ 0 & -0 เป็นศูนย์
ลูป i += i & -i จึงไม่ขยับและวนไม่รู้จบ ข้อสอง ฟังก์ชัน add รับส่วนต่าง
ไม่ใช่ค่าใหม่ ถ้าโจทย์บอกว่า "เปลี่ยนช่อง i ให้เป็น v" ต้องส่ง v - a[i] เข้าไป
แล้วอย่าลืมอัปเดต a[i] เก็บไว้เองด้วย
ทั้งโครงสร้างยาวไม่ถึงยี่สิบบรรทัด และสองลูปในนั้นต่างกันแค่เครื่องหมายบวกกับลบเท่านั้น
#include <bits/stdc++.h>
using namespace std;
// ต้นไม้เฟนวิก ดัชนีเริ่มที่ 1 เสมอ เพราะสูตร i & -i ใช้กับ 0 ไม่ได้ (วนไม่รู้จบ)
struct BIT {
int n;
vector<long long> t;
BIT(int n_) : n(n_), t(n_ + 1, 0) {}
// บวก v เข้าไปที่ตำแหน่ง i แล้วไล่ขึ้นไปแก้ทุกช่องที่ครอบ i อยู่
void add(int i, long long v) {
for (; i <= n; i += i & -i) t[i] += v;
}
// ผลรวมตั้งแต่ตำแหน่ง 1 ถึง i โดยไล่ตัดทีละก้อนจากขวาเข้าซ้าย
long long pre(int i) {
long long s = 0;
for (; i > 0; i -= i & -i) s += t[i];
return s;
}
long long range(int l, int r) { return pre(r) - pre(l - 1); }
};
int main() {
int n, q;
scanf("%d %d", &n, &q);
BIT b(n);
vector<long long> a(n + 1, 0);
for (int i = 1; i <= n; i++) { scanf("%lld", &a[i]); b.add(i, a[i]); }
while (q--) {
int op;
scanf("%d", &op);
if (op == 1) { // แก้ค่าที่ตำแหน่ง i ให้เป็น v
int i; long long v;
scanf("%d %lld", &i, &v);
b.add(i, v - a[i]); // ส่ง "ส่วนต่าง" เข้าไป ไม่ใช่ค่าใหม่
a[i] = v;
} else { // ถามผลรวมของช่วง [l, r]
int l, r;
scanf("%d %d", &l, &r);
printf("%lld\n", b.range(l, r));
}
}
return 0;
}
ผมสุ่มเทียบกับตัวไล่บวกทีละช่องแบบซื่อ ๆ 400 เทส แต่ละเทสสุ่มทั้งการถามและการแก้ค่าสลับกันไปมา
รวมทั้งกรณี n = 1 และค่าติดลบ ตรงกันทั้งหมด
ชุดที่สี่ของเกมข้างบนต่อไม่ได้ และเหตุผลอยู่ที่กติกาข้อเดียวของโครงสร้างนี้ ทุกช่วงที่ช่องหนึ่งดูแล
จบลงที่ดัชนีของตัวเองเสมอ ช่องที่คลุมตำแหน่ง 3 ได้จึงมีแค่ t[3]
ซึ่งคลุมแค่ตำแหน่ง 3 เอง ส่วนช่องที่ใหญ่ขึ้นมาคือ t[4] ลากย้อนไปถึงหัวแถว
พาตำแหน่ง 1 กับ 2 ที่เราไม่ได้ขอมาด้วยทุกครั้ง
ทางออกที่บทนี้ใช้ตั้งแต่ต้นคือไม่ต่อช่วงกลางตาราง แต่เอาสองช่วงหัวแถวมาหักกัน
คือ range(l, r) = pre(r) − pre(l − 1) ซึ่งเป็นบรรทัดที่ดูไม่มีอะไรเลยในโค้ด
แต่มันคือที่ที่ข้อจำกัดทั้งหมดของเฟนวิกซ่อนอยู่ เพราะการหักกันได้แปลว่าตัวดำเนินการที่ใช้ต้องมี
ตัวผกผัน (inverse อ่านว่า "อินเวิร์ส" แปลว่าตัวที่ย้อนของเดิมกลับ
เหมือนการลบที่ย้อนการบวก)
ที่ต้นไม้เฟนวิกทำไม่ได้
ผลรวมมีตัวผกผันคือการลบ จึงหักกันได้ การคูณมอดุโลกับ xor ก็มี เฟนวิกจึงรับได้ทั้งสามอย่าง
แต่ min กับ max ไม่มีตัวผกผัน รู้ว่าค่าน้อยสุดของช่วง 1 ถึง 7
คือ 1 และค่าน้อยสุดของช่วง 1 ถึง 2 คือ 1 ไม่ได้บอกอะไรเลยเกี่ยวกับช่วง 3 ถึง 7
เพราะไม่มีการดำเนินการที่ "เอา min ออก" ได้
อาการนี้มีชื่อทางพีชคณิตว่าเซตกับตัวดำเนินการนั้นไม่เป็นกรุป (group อ่านว่า "กรุป" คือเซตที่มีการดำเนินการซึ่งมีตัวเอกลักษณ์และมีตัวผกผันครบทุกตัว) ทางแก้ในสนามแข่งคือย้ายไปใช้เซกเมนต์ทรี ซึ่งเก็บผลของสองครึ่งไว้จริง จึงรวมช่วงกลางตารางได้โดยไม่ต้องหักอะไรออก และเป็นเหตุผลที่ทั้งสองโครงสร้างยังอยู่คู่กันในคลังของทุกคน ไม่ใช่ว่าอันหนึ่งแทนอีกอันได้หมด
ถ้าการแก้ค่ามีทางเดียว เช่นค่าลดลงได้เท่านั้น ต้นไม้เฟนวิกที่เก็บ min
ก็ใช้ได้อยู่ เพราะไม่มีใครต้องถอนค่าเก่าออก ข้อจำกัดคือคำว่าถอน ไม่ใช่คำว่า min
สี่ท่าถัดไปเป็นของที่ต่อยอดจากสองลูปเดิมโดยไม่ต้องเปลี่ยนโครงสร้างเลย ผมยกมาจาก หน้า Fenwick Tree ของ cp-algorithms ซึ่งเป็นแหล่งอ้างอิงที่ครบที่สุดของหัวข้อนี้ แล้วเขียนโค้ดใหม่ในสไตล์เดียวกับบทนี้ ทุกชิ้นคอมไพล์แล้วสุ่มเทียบกับตัวไล่ตรง ๆ 3 000 รอบ รอบละสุ่มทั้งขนาดอาเรย์ คำสั่ง และค่าติดลบ ตรงกันทุกรอบ
โค้ดหลักของบทนี้สร้างต้นไม้ด้วยการเรียก add ทีละช่อง n ครั้ง
ซึ่งเป็น n log n ทั้งที่มันเป็นงานเชิงเส้นได้ ท่าที่เร็วกว่าคือยกยอด
เอาอาเรย์ต้นฉบับมาเป็นต้นไม้เลย แล้วเดินไปข้างหน้าครั้งเดียว ช่องไหนเสร็จแล้วก็โยนยอดรวมของตัวเอง
ขึ้นไปให้ช่องที่ครอบมันอยู่ ทุกช่องจึงถูกแตะสองครั้งเท่านั้น
ทำไมโค้ดในหนังสือบางเล่มเขียน i | (i + 1)
ท่านี้เขียนสวยที่สุดตอนดัชนีเริ่มที่ศูนย์ ซึ่งเป็นตอนที่สูตรเปลี่ยนหน้าตาไปเลย
แบบเริ่มที่หนึ่งใช้ i − (i & −i) เพื่อถอยและ i + (i & −i) เพื่อขึ้น
แบบเริ่มที่ศูนย์ใช้ (i & (i + 1)) − 1 เพื่อถอยและ i | (i + 1) เพื่อขึ้น
สองแบบนี้คือโครงสร้างเดียวกันที่เลื่อนดัชนีไปหนึ่ง ไม่ใช่สองอย่าง ถ้าเจอโค้ดที่หน้าตาไม่เหมือนบทนี้
ให้มองหาว่ามันนับดัชนีจากไหนก่อน
// สร้างต้นไม้ในเวลา O(n) แทนที่จะเรียก add ทีละช่อง n log n ครั้ง
// เวอร์ชันนี้เขียนแบบฐานศูนย์ เพราะสูตรที่ใช้คือ i | (i+1) ไม่ใช่ i & -i
// (ทั้งสองแบบคือของอย่างเดียวกัน แค่เลื่อนดัชนีไปหนึ่ง)
vector<long long> b = a; // ยืมอาเรย์ต้นฉบับมาเป็นต้นไม้เลย
for (int i = 0; i < n; i++) {
int r = i | (i + 1); // ช่องถัดไปที่ครอบ i อยู่
if (r < n) b[r] += b[i]; // ยกยอดของช่องนี้ขึ้นไปให้ช่องที่ครอบ
}
// ถามผลรวม 0..i ของเวอร์ชันฐานศูนย์ ตัดก้อนด้วย (i & (i+1)) - 1
long long pre(int i) {
long long s = 0;
for (; i >= 0; i = (i & (i + 1)) - 1) s += b[i];
return s;
}
โจทย์ที่กลับด้านกับบทนี้ก็มี คือ "บวก v ให้ทุกช่องในช่วง l ถึง r"
แล้วถามค่าของช่องเดียว ท่าที่ใช้คือเลิกเก็บตัวค่า แล้วเก็บส่วนต่างระหว่างช่องกับช่องก่อนหน้า
การบวกทั้งช่วงจึงเหลือแค่แก้สองจุด คือจุดที่ระดับขึ้นและจุดที่ระดับกลับลง
ส่วนค่าของช่องเดียวคือผลรวมของส่วนต่างตั้งแต่หัวแถว ซึ่งเป็นงานที่ต้นไม้ทำอยู่แล้ว
// แก้ค่าเป็นช่วง ถามค่าเป็นจุด ใช้ต้นไม้ใบเดียว
// กลไกคือเก็บ "ส่วนต่างระหว่างช่องกับช่องก่อนหน้า" แทนที่จะเก็บตัวค่า
// พอถามจุด i ก็คือบวกส่วนต่างทั้งหมดตั้งแต่หัวแถว ซึ่งเป็นงานที่ต้นไม้ทำอยู่แล้ว
void addRange(int l, int r, long long v) {
add(l, v); // ตั้งแต่ช่อง l เป็นต้นไป สูงขึ้น v
if (r + 1 <= n) add(r + 1, -v); // พ้นช่อง r แล้ว หักคืน v ให้กลับเป็นเดิม
}
long long at(int i) { return pre(i); } // ค่าที่ตำแหน่ง i คือผลรวมของส่วนต่าง
ท่าที่ 2 ตอบจุดเดียวได้แต่ตอบช่วงไม่ได้ ถ้าอยากได้ทั้งสองอย่างต้องใช้ต้นไม้สองใบ
ใบแรกเก็บส่วนต่างเหมือนเดิม ปัญหาคือ b1.pre(i) * i คิดเหมือนว่าของที่เพิ่มเข้ามา
เริ่มนับตั้งแต่ช่องแรก ซึ่งเกินความจริงไปเท่ากับ v * (l − 1) ทุกครั้ง
ต้นไม้ใบที่สองจึงมีหน้าที่เดียวคือจดส่วนเกินนั้นไว้ให้หักคืน
ตรวจสามช่วงด้วยมือจะเห็นว่ามันลงตัว ตำแหน่งก่อน l ทั้งสองพจน์เป็นศูนย์
ตำแหน่งในช่วงได้ v คูณจำนวนช่องที่นับมาแล้ว และตำแหน่งหลัง r
ได้ v คูณความยาวช่วงเต็มพอดี
// แก้เป็นช่วง ถามเป็นช่วง ใช้ต้นไม้สองใบ
// b1 เก็บส่วนต่างเหมือนหัวข้อก่อน ส่วน b2 เก็บ "ส่วนเกินที่ b1 นับล่วงหน้าไป"
// เพราะ b1.pre(i) * i คิดเหมือนว่าของที่เพิ่มเริ่มตั้งแต่ช่องแรก ซึ่งเกินจริงไปเท่ากับ v*(l-1)
BIT b1(n), b2(n);
void addRange(int l, int r, long long v) {
b1.add(l, v);
if (r + 1 <= n) b1.add(r + 1, -v);
b2.add(l, v * (l - 1));
if (r + 1 <= n) b2.add(r + 1, -v * r);
}
long long pre(int i) { return b1.pre(i) * i - b2.pre(i); }
long long range(int l, int r) { return pre(r) - pre(l - 1); }
ท่าที่คนเขียนบ่อยที่สุดเวลาต้องหา "ตำแหน่งแรกที่ผลรวมสะสมถึง k" คือค้นทวิภาค
แล้วเรียก pre ทุกครั้งที่เดา ซึ่งเป็น log²n แต่ต้นไม้เฟนวิกทำได้ในรอบเดียว
เพราะตัวมันเองเก็บก้อนที่ยาวเป็นกำลังของสองอยู่แล้ว วิธีคือไล่บิตจากใหญ่ลงเล็ก แล้วก้าวเมื่อก้าวได้
เหลือ log n ก้าวเท่ากับการถามหนึ่งครั้ง
ท่านี้ใช้ได้เมื่อค่าในต้นไม้ไม่ติดลบ เพราะตอนนั้นผลรวมสะสมโตทางเดียว ถ้ามีค่าติดลบปนอยู่ ลำดับก็ไม่เรียงแล้ว การไล่บิตลงจะเดินผิดทางเงียบ ๆ โดยไม่มีอะไรฟ้อง โจทย์ที่ใช้ท่านี้เต็ม ๆ อยู่ในคลังนี้แล้ว คือ สายดีเอ็นเอตัวที่ k
// หาตำแหน่งแรกที่ผลรวมสะสมถึง k โดยไม่ต้องไล่ทีละช่อง
// ใช้ได้เมื่อค่าในต้นไม้ไม่ติดลบ เพราะตอนนั้นผลรวมสะสมเพิ่มขึ้นทางเดียว
// วิธีคือไล่บิตจากใหญ่ลงเล็ก แล้วก้าวเมื่อก้าวได้ ซึ่งอ่านตารางของต้นไม้ตรง ๆ
int kth(long long k) {
int pos = 0;
for (int pw = 1 << (31 - __builtin_clz(n)); pw > 0; pw >>= 1) {
if (pos + pw <= n && t[pos + pw] < k) {
pos += pw;
k -= t[pos]; // ก้อนนี้กินไปแล้ว เหลือให้หาอีก k
}
}
return pos + 1; // ช่องถัดจากที่ก้าวมาไม่ถึง คือช่องที่ทำให้ถึง k
}
ต้นไม้เฟนวิกซ้อนกันได้ตรง ๆ คือให้แต่ละช่องของต้นไม้แกนแรกเก็บต้นไม้อีกต้นของแกนที่สอง
ทั้งการถามและการแก้กลายเป็นลูปสองชั้นที่กระโดดด้วยสูตรเดิมทั้งคู่ ราคาจึงเป็น log²n
ต่อคำสั่ง และเป็น logᵈn ที่ d มิติ โค้ดยาวขึ้นราวสี่บรรทัด
ท่านี้ทำงานอยู่ในข้อ คู่ที่ได้ยินกัน แล้ว จึงไม่ยกโค้ดมาซ้ำที่นี่
สามข้อนี้ตอบคนละคำถาม ข้อแรกถามว่า "ท่าผลรวมสะสมขยายไปสองมิติยังไง" ข้อสองถามว่า "เอาต้นไม้ไปนับอย่างอื่นที่ไม่ใช่ผลรวมได้ไหม" ข้อสามถามว่า "โจทย์ที่ดูเหมือนต้องใช้ต้นไม้ จำเป็นต้องใช้จริงหรือเปล่า" ลองคิดเองก่อนแล้วค่อยแตะดูเฉลยครับ
โจทย์กำหนด
ให้ตารางตัวเลขขนาด R คูณ C และคำถาม q ข้อ แต่ละข้อให้มุมซ้ายบน
(r₁, c₁) กับมุมขวาล่าง (r₂, c₂) แล้วถามผลรวมของทุกช่องในสี่เหลี่ยมนั้น
ไม่มีการแก้ค่าระหว่างทาง โดย R, C ≤ 1 000 และ q ≤ 200 000
| Input | Output |
|---|---|
| 3 4 1
3 1 4 2 5 9 2 6 1 8 7 3 2 2 3 3 | 26 |
อ่านตัวอย่างนี้ยังไง
บรรทัดแรกคือจำนวนแถว จำนวนคอลัมน์ และจำนวนคำถาม จากนั้นคือตารางทีละแถว
แล้วคำถามบรรทัดละหนึ่งข้อ ในรูป r₁ c₁ r₂ c₂ เลขแถวและคอลัมน์เริ่มนับที่ 1
และสี่เหลี่ยมนี้รวมขอบทั้งสองด้าน
คำถามข้อนี้ขอสี่เหลี่ยมตั้งแต่แถว 2 คอลัมน์ 2 ถึงแถว 3 คอลัมน์ 3 ซึ่งครอบ 4 ช่อง บวกกันตรง ๆ ได้ 26 นั่นคือเอาต์พุต ส่วนที่โจทย์กำลังท้าคือ ถ้ามีคำถามสองแสนข้อ จะบวกทีละช่องแบบนี้ไม่ไหว
คำใบ้
ในหนึ่งมิติ เราเก็บ "ตั้งแต่หัวแถวถึงตรงนี้" ในสองมิติก็เก็บ "ตั้งแต่มุมซ้ายบนถึงตรงนี้" ที่เหลือคือวาดสี่เหลี่ยมสี่รูปทับกันบนกระดาษแล้วดูว่าตรงไหนถูกลบเกินไป
ที่มาของแนวคิดนี้
จุดที่ต้องคิดจริงในข้อนี้มีจุดเดียว คือรูปที่เราเก็บไว้ล่วงหน้าได้ ต้องเป็นรูปที่มีมุมติดอยู่กับ (1,1) เพราะถ้าเก็บสี่เหลี่ยมลอย ๆ ที่เริ่มตรงไหนก็ได้ จำนวนสี่เหลี่ยมที่ต้องเก็บคือสี่ตัวเลือกคูณกัน ที่ตาราง 1 000 คูณ 1 000 ก็คือหลายแสนล้านช่อง เก็บไม่ไหวตั้งแต่ยังไม่เริ่ม
พอบังคับว่ามุมหนึ่งต้องอยู่ที่ (1,1) จำนวนรูปที่ต้องเก็บเหลือเท่ากับจำนวนช่องในตารางพอดี คำถามถัดมาคือสี่เหลี่ยมที่โจทย์ถามซึ่งลอยอยู่กลางตาราง ประกอบขึ้นจากรูปมุมติด (1,1) ได้ไหม ท่าที่ใช้คือวาดสี่เหลี่ยมใหญ่ที่คลุมถึงมุมขวาล่างของมัน แล้วตัดแถบบนกับแถบซ้ายออก ตอนวาดจริงบนกระดาษจะเห็นทันทีว่ามุมซ้ายบนถูกตัดสองครั้ง จึงต้องคืนกลับหนึ่งครั้ง
บทเรียนที่ยกไปข้ออื่นได้คือ เวลาจะเตรียมคำตอบไว้ล่วงหน้า ให้เลือกรูปที่มีจุดยึดตายตัว จำนวนของที่ต้องเก็บจะยุบจากคูณกันเหลือเท่าจำนวนช่อง แล้วค่อยหาวิธีประกอบรูปที่โจทย์ถามจากรูปที่ยึดไว้
ให้ s[i][j] เป็นผลรวมของสี่เหลี่ยมตั้งแต่ (1,1) ถึง (i,j)
เวลาตอบคำถาม เราเอาก้อนใหญ่มาลบก้อนบนกับก้อนซ้ายออก แต่มุมซ้ายบนถูกลบไปสองครั้ง
จึงต้องบวกคืนหนึ่งครั้ง
ตอนสร้างตารางก็ใช้เหตุผลเดียวกันเป๊ะ แค่กลับทิศ คือบวกก้อนบนกับก้อนซ้ายแล้วลบส่วนที่ทับกันออก ทั้งข้อจึงเป็น O(RC + q)
#include <bits/stdc++.h>
using namespace std;
int main() {
int R, C, q;
scanf("%d %d %d", &R, &C, &q);
// s[i][j] = ผลรวมของสี่เหลี่ยมตั้งแต่มุมซ้ายบน (1,1) ถึง (i,j)
vector<vector<long long>> s(R + 1, vector<long long>(C + 1, 0));
for (int i = 1; i <= R; i++)
for (int j = 1; j <= C; j++) {
long long v;
scanf("%lld", &v);
// แถวบนกับคอลัมน์ซ้ายทับกันตรงมุมซ้ายบน จึงต้องบวกคืนหนึ่งครั้ง
s[i][j] = v + s[i - 1][j] + s[i][j - 1] - s[i - 1][j - 1];
}
while (q--) {
int r1, c1, r2, c2;
scanf("%d %d %d %d", &r1, &c1, &r2, &c2);
printf("%lld\n", s[r2][c2] - s[r1 - 1][c2] - s[r2][c1 - 1] + s[r1 - 1][c1 - 1]);
}
return 0;
} ผมสุ่มเทียบกับตัวไล่บวกทีละช่อง 400 เทส โดยจงใจให้มีทั้งช่วงที่ชิดขอบและช่วงที่กว้างช่องเดียว ตรงกันหมด ท่านี้คือกระดูกสันหลังของข้อ คู่ที่ได้ยินกัน ในส่วนที่เป็นกระดานสามมิติ ซึ่งขยายเป็นแปดพจน์แทนที่จะเป็นสี่
โจทย์กำหนด
ให้อาเรย์ยาว n ≤ 200 000 นับว่ามีคู่ (i, j) กี่คู่ที่ i < j
แต่ a[i] > a[j] ค่าในอาเรย์เป็นจำนวนเต็มอะไรก็ได้ ซ้ำกันได้
| Input | Output |
|---|---|
| 6 5 2 6 1 4 3 | 9 |
อ่านตัวอย่างนี้ยังไง
อินพุตคือความยาวแล้วตามด้วยอาเรย์ เอาต์พุตคือจำนวนคู่ ไม่ใช่จำนวนครั้งที่ต้องสลับ แม้ว่าสองอย่างนี้จะเท่ากันพอดีก็ตาม
คู่ที่นับคือคู่ที่เรียงผิดคิว คือตัวที่อยู่ทางซ้ายมีค่ามากกว่าตัวที่อยู่ทางขวา ชุดนี้มีอยู่ 9 คู่ วิธีนับที่ต่อยอดเป็นโค้ดได้คือ ไล่ทีละตำแหน่งจากซ้ายไปขวา แล้วถามว่าตัวที่ผ่านมาแล้วมีกี่ตัวที่ค่ามากกว่าตัวนี้ เอาทุกคำตอบมาบวกกัน
คำใบ้
ต้นไม้เฟนวิกไม่ได้ผูกกับคำว่า "ผลรวม" มันบวกอะไรก็ได้ ลองใส่เลข 1 ลงไปที่ตำแหน่งของค่าแทนที่จะเป็น
ตำแหน่งในอาเรย์ แล้วถามว่าตอนนี้มีของที่ค่าน้อยกว่า a[i] อยู่กี่ตัว
ที่มาของแนวคิดนี้
ตัวที่บังคับรูปของคำตอบข้อนี้คือเงื่อนไขสองอย่างที่พูดถึงของสองแกนพร้อมกัน
คือ i < j พูดถึงตำแหน่ง และ a[i] > a[j] พูดถึงค่า
เงื่อนไขคู่แบบนี้คิดตรง ๆ ไม่ได้ เพราะต้องดูทุกคู่ ซึ่งที่ n สองแสนคือสองหมื่นล้านคู่
ท่าที่ใช้กับเงื่อนไขสองแกนคือจับแกนหนึ่งให้นิ่งด้วยลำดับที่เราเดิน แล้วเหลือให้ถามแค่แกนเดียว ตรงนี้มีสองทางเลือก เดินตามตำแหน่งแล้วถามเรื่องค่า หรือเดินตามค่าแล้วถามเรื่องตำแหน่ง ทางแรกง่ายกว่า เพราะถ้าเราเดินจากขวาไปซ้าย ของทุกตัวที่ใส่ต้นไม้ไปแล้วอยู่ทางขวาของตัวที่กำลังยืนอยู่โดยอัตโนมัติ เงื่อนไขตำแหน่งจึงหายไปเองโดยไม่ต้องเช็ก เหลือคำถามเดียวว่ามีของที่ค่าน้อยกว่าอยู่กี่ตัว
พอคำถามเหลือ "นับของที่ค่าน้อยกว่านี้" ก็เห็นชัดว่ามันคือผลรวมของช่วงหัวแถว ถ้าเราใส่เลข 1 ไว้ที่ตำแหน่งของค่าแทนที่จะเป็นตำแหน่งในอาเรย์ บทเรียนที่ยกไปข้ออื่นได้คือ ต้นไม้ไม่จำเป็นต้องมีดัชนีเป็นตำแหน่งในอาเรย์ อะไรที่เรียงลำดับได้ก็เป็นดัชนีได้
เดินจากขวาไปซ้าย พอถึงตำแหน่ง i ทุกตัวที่ใส่ลงต้นไม้ไปแล้วคือตัวที่อยู่ทางขวาของมัน
คำถามว่ามีกี่ตัวที่เล็กกว่า a[i] จึงเป็นการถามผลรวมตั้งแต่ต้นถึงอันดับของ a[i] ลบหนึ่ง
ซึ่งคือ pre ตรง ๆ แล้วค่อยใส่ตัวเองลงไป ทั้งข้อเป็น O(n log n)
ค่าในอาเรย์อาจเป็นล้านหรือติดลบ ต้นไม้จึงสร้างตามค่าตรง ๆ ไม่ได้ ต้องย่อค่าให้เป็นอันดับ
(coordinate compression) ก่อน คือเรียงค่าทั้งหมด ตัดตัวซ้ำออก แล้วแทนแต่ละค่าด้วยลำดับที่ของมัน
ต้นไม้จะได้มีขนาดเท่ากับ n พอดี
#include <bits/stdc++.h>
using namespace std;
int n;
vector<int> t;
void add(int i) { for (; i <= n; i += i & -i) t[i]++; }
int pre(int i) { int s = 0; for (; i > 0; i -= i & -i) s += t[i]; return s; }
int main() {
scanf("%d", &n);
vector<int> a(n);
for (auto& x : a) scanf("%d", &x);
// ค่าอาจใหญ่หรือติดลบ ย่อให้เป็นอันดับ 1..n ก่อน ต้นไม้จะได้มีขนาดเท่า n
vector<int> v = a;
sort(v.begin(), v.end());
v.erase(unique(v.begin(), v.end()), v.end());
t.assign(n + 1, 0);
long long ans = 0;
for (int i = n - 1; i >= 0; i--) { // เดินจากขวาไปซ้าย
int r = lower_bound(v.begin(), v.end(), a[i]) - v.begin() + 1;
ans += pre(r - 1); // ของที่อยู่ขวาแล้วเล็กกว่า a[i]
add(r);
}
printf("%lld\n", ans);
return 0;
} สุ่มเทียบกับตัวไล่ทุกคู่ 400 เทส โดยจงใจให้ค่าซ้ำกันเยอะ (สุ่มจากช่วงแคบ ๆ) เพื่อกดดันจุดที่คนพลาดบ่อยที่สุด คือการเผลอนับคู่ที่ค่าเท่ากันเข้าไปด้วย ตรงกันทั้งหมด
โจทย์กำหนด
ให้อาเรย์ยาว n ≤ 200 000 กับเลข D นับว่ามีคู่กี่คู่ที่ค่าต่างกันไม่เกิน D
ตำแหน่งในอาเรย์ไม่มีความหมาย สนใจแค่ค่า
| Input | Output |
|---|---|
| 6 2 8 1 4 7 3 9 | 5 |
อ่านตัวอย่างนี้ยังไง
บรรทัดแรกคือความยาวกับค่า D บรรทัดที่สองคืออาเรย์ เอาต์พุตคือจำนวนคู่ที่ค่าต่างกัน
ไม่เกิน D คือเท่ากับ D พอดีก็นับ
ประโยคสำคัญของโจทย์คือตำแหน่งในอาเรย์ไม่มีความหมาย แปลว่าเรียงใหม่ได้ พอเรียงแล้วชุดนี้กลายเป็น 1 3 4 7 8 9 และคำว่า "ต่างกันไม่เกิน 2" ก็กลายเป็น "อยู่ในหน้าต่างเดียวกัน" ซึ่งเลื่อนหน้าต่างรอบเดียวก็นับครบ ได้ 5 คู่
คำใบ้
ข้อนี้ใส่ต้นไม้เฟนวิกลงไปแล้วจบได้จริง แต่ก่อนจะเขียน ลองถามหนึ่งคำถามก่อน ตำแหน่งในอาเรย์ไม่มีความหมายเลย แปลว่าเราทำอะไรกับลำดับของมันก็ได้ แล้วถ้าเรียงมันเสียก่อน คำว่า "ต่างกันไม่เกิน D" จะกลายเป็นอะไร?
ที่มาของแนวคิดนี้
ข้อนี้วางไว้ต่อจากข้อ 2 เพราะมันหน้าตาเหมือนกันจนหลงได้ง่าย ๆ คือเป็นการนับคู่ที่มีเงื่อนไขเรื่องค่า และท่าต้นไม้จากข้อ 2 ก็ใช้ได้จริง แต่ก่อนเขียนมีประโยคหนึ่งในโจทย์ที่ต่างจากข้อ 2 คนละเรื่อง คือ ตำแหน่งในอาเรย์ไม่มีความหมาย ข้อ 2 นับคู่ที่ตำแหน่งซ้ายขวาสำคัญ ข้อนี้ไม่สำคัญเลย
ประโยคนั้นแปลว่าเราสลับลำดับข้อมูลได้ตามใจโดยคำตอบไม่เปลี่ยน ซึ่งเป็นอิสระที่ข้อ 2 ไม่มี และของฟรีที่ตามมาคือเรียงมันเสียก่อน พอเรียงแล้ว คำว่า "ค่าต่างกันไม่เกิน D" กลายเป็น "อยู่ห่างกันไม่เกินเท่าไรในแถวที่เรียงแล้ว" ซึ่งเป็นเรื่องของช่วงติดกัน ไม่ใช่การนับข้ามแถวอีก
บทเรียนที่ยกไปข้ออื่นได้คือ ก่อนหยิบโครงสร้างมาใช้ ให้อ่านหาก่อนว่าโจทย์แจกอิสระอะไรมาให้เปล่า ๆ ประโยคที่บอกว่าอะไรไม่สำคัญ มักมีค่ามากกว่าประโยคที่บอกว่าอะไรสำคัญ
พอเรียงแล้ว คู่ที่ค่าต่างกันไม่เกิน D กลายเป็นคู่ที่อยู่ในหน้าต่างเดียวกัน
คือสำหรับตัวขวาสุดที่ตำแหน่ง r ตัวที่จับคู่กับมันได้คือทุกตัวตั้งแต่ l ถึง r - 1
โดย l คือตัวแรกที่ a[r] - a[l] ≤ D และเมื่อ r เดินไปทางขวา
ค่า l ก็ไม่มีทางถอยกลับ เพราะอาเรย์เรียงแล้ว
ตัวชี้สองตัวที่เดินหน้าอย่างเดียวจึงกวาดจบในรอบเดียว งานทั้งหมดคือการเรียงหนึ่งครั้ง เป็น O(n log n) เท่ากับท่าต้นไม้ แต่โค้ดสั้นกว่าครึ่ง ไม่ต้องย่อค่า ไม่ต้องระวังดัชนีเริ่มที่หนึ่ง และไม่มีอะไรให้พลาด
#include <bits/stdc++.h>
using namespace std;
int main() {
int n; long long D;
scanf("%d %lld", &n, &D);
vector<long long> a(n);
for (auto& x : a) scanf("%lld", &x);
sort(a.begin(), a.end());
long long ans = 0;
int l = 0;
for (int r = 0; r < n; r++) {
while (a[r] - a[l] > D) l++; // ตัวชี้ซ้ายเดินหน้าอย่างเดียว ไม่เคยถอย
ans += r - l; // ทุกตัวใน [l, r-1] จับคู่กับ r ได้
}
printf("%lld\n", ans);
return 0;
} บทเรียนที่แพงที่สุดของบทนี้
ต้นไม้เฟนวิกจำเป็นก็ต่อเมื่อข้อมูลเปลี่ยนระหว่างทาง หรือลำดับที่ถูกถามมีความหมาย เท่านั้น โจทย์ที่ตอบได้ทีเดียวหลังเรียงเสร็จ ไม่ต้องใช้มัน การหยิบเครื่องมือที่แรงกว่ามาใช้ไม่ได้ทำให้ผิด แต่มันเพิ่มที่ให้พลาดโดยไม่ได้อะไรกลับมา
สุ่มเทียบกับตัวไล่ทุกคู่ 400 เทส รวมกรณี D = 0 (นับเฉพาะคู่ที่ค่าเท่ากันเป๊ะ)
และกรณีที่ทุกตัวเท่ากันหมด ซึ่งเป็นกรณีที่คำตอบโตเร็วที่สุดและ int ล้นถ้าไม่ระวัง ตรงกันทั้งหมด
สองท่าที่ผ่านมาห่างกันคนละโลก ผลรวมสะสมตอบเร็วแต่แก้ไม่ได้ เฟนวิกทำได้ทั้งสองอย่างแต่ต้องเข้าใจ เรื่องบิตก่อน ระหว่างสองอันนั้นมีท่าที่สามที่คิดออกได้เองโดยไม่ต้องรู้จักอะไรเลย และคุ้มพอที่จะผ่านโจทย์จำนวนมาก คือการแบ่งราก (sqrt decomposition)
แนวคิดมีบรรทัดเดียว คือหั่นอาเรย์เป็นก้อนละ √n ช่อง แล้วจดผลรวมของแต่ละก้อนไว้
ตอนแก้ค่าช่องหนึ่ง เราแก้ทั้งช่องนั้นและผลรวมของก้อนที่มันอยู่ ซึ่งเป็นงานคงที่
ตอนถามช่วง เราเดินทีละช่องเฉพาะเศษหัวกับเศษท้าย ส่วนกลางที่เป็นก้อนเต็ม
เราอ่านจากผลรวมของก้อนตรง ๆ
ทำไมต้อง √n พอดี เพราะมีสองอย่างที่ดันกันอยู่ ถ้าก้อนใหญ่ เศษหัวเศษท้ายจะยาว
ถ้าก้อนเล็ก จำนวนก้อนกลางจะมาก งานตอนถามคือ ขนาดก้อน บวก จำนวนก้อน
ซึ่งน้อยที่สุดตอนสองค่านั้นเท่ากัน นั่นคือที่ √n ที่ n = 200,000
ขนาดก้อนจึงเป็น 447 และงานต่อคำถามเหลือราว 2√n คือ
894 ครั้ง
| แนวคิด | งานต่อหนึ่งคำสั่ง | รวม |
|---|---|---|
| ไม่ทำอะไรเลย ถามก็บวกทีละช่อง | ถาม n · แก้ 1 | 40,000,000,000 |
| แบ่งราก | ถาม 2√n · แก้ 1 | 178,800,000 |
| ต้นไม้เฟนวิก | ถาม log n · แก้ log n | 7,200,000 |
log n เล็กกว่า
√n คนละชั้น ผมรันโค้ดข้างล่างที่ขอบเขตนี้จริง ได้ 0.08 วินาที
และหน่วยความจำราว 4.7 เมกะไบต์
// แบ่งราก (sqrt decomposition): ผลรวมของช่วง กับแก้ทีละช่อง
// อินพุต: n q, อาเรย์ n ตัว, แล้ว q บรรทัด
// 1 i v -> ตั้ง a[i] = v (i เริ่มจาก 0)
// 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;
vector<long long> a(n);
for (int i = 0; i < n; i++) scanf("%lld", &a[i]);
int B = max(1, (int)sqrt((double)n)); // ขนาดของก้อน
int nb = (n + B - 1) / B;
vector<long long> blk(nb, 0);
for (int i = 0; i < n; i++) blk[i / B] += a[i];
for (int t = 0; t < q; t++) {
int op; scanf("%d", &op);
if (op == 1) {
int i; long long v; scanf("%d %lld", &i, &v);
blk[i / B] += v - a[i]; // ก้อนที่ i อยู่ ขยับตามส่วนต่าง
a[i] = v;
} else {
int l, r; scanf("%d %d", &l, &r);
long long sum = 0;
int bl = l / B, br = r / B;
if (bl == br) { // อยู่ก้อนเดียวกัน เดินทีละช่อง
for (int i = l; i <= r; i++) sum += a[i];
} else {
for (int i = l; i < (bl + 1) * B; i++) sum += a[i]; // เศษหัว
for (int b = bl + 1; b < br; b++) sum += blk[b]; // ก้อนเต็มกลาง
for (int i = br * B; i <= r; i++) sum += a[i]; // เศษท้าย
}
printf("%lld\n", sum);
}
}
return 0;
} ท่านี้คุ้มที่จะรู้ไว้ทั้งที่มันแพงกว่าเฟนวิก เพราะมันไม่ต้องการคุณสมบัติอะไรจากตัวดำเนินการเลย เฟนวิกใช้ได้เพราะผลรวมหักลบกันได้ แต่พอโจทย์เปลี่ยนคำถามเป็นอย่างอื่น ที่หักลบไม่ได้ เฟนวิกก็เริ่มใช้ไม่ได้ ส่วนแบ่งรากยังทำงานเหมือนเดิม เพราะมันแค่รวมคำตอบของก้อนที่คิดไว้แล้ว จะรวมด้วยอะไรก็ได้ (มีบทเซกเมนต์ทรีอยู่ในคลังนี้สำหรับตอนที่ต้องการทั้งความเร็วและความยืดหยุ่นพร้อมกัน)
ถ้าโจทย์ไม่แก้ค่าระหว่างทาง ผลรวมสะสมจบงานได้เร็วที่สุดและสั้นที่สุด ถ้ามันแก้ค่า
ให้เก็บก้อนที่ยาวเท่ากับบิตต่ำสุดของดัชนี แล้วทั้งการถามและการแก้จะเหลือไม่เกิน log n ก้าวเท่ากัน
และก่อนหยิบต้นไม้ทุกครั้ง ให้ถามก่อนหนึ่งคำถามว่าเรียงข้อมูลแล้วปัญหาหายไปเลยหรือเปล่า
ข้อที่ใช้ของในบทนี้เต็ม ๆ คือ คู่ที่ได้ยินกัน ซึ่งเอาทั้งสามท่ามารวมกัน คือผลรวมสะสมสำหรับกระดานที่ไม่เปลี่ยน ต้นไม้เฟนวิกสำหรับตอนกวาดเส้น และการเรียงเพื่อทำให้เงื่อนไขสองแกนแยกกันได้
ในหน้านี้