ปูพื้นฐาน

ผลรวมสะสมกับต้นไม้เฟนวิก: ตอบคำถามช่วงได้ทันที แม้ข้อมูลจะถูกแก้ระหว่างทาง

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

บทปูพื้นฐาน ★☆☆☆☆ prefix sumBITพื้นฐาน อ่าน 25 นาที 6 กันยายน 2026

อาการ · คำถามเดียวกันที่ถูกถามสองแสนครั้ง

โจทย์ชุดหนึ่งที่โผล่บ่อยมากหน้าตาเป็นแบบนี้ ให้อาเรย์ยาว n ตัว แล้วมีคำถามตามมาอีก q ข้อ แต่ละข้อถามว่า "ผลรวมของช่องที่ l ถึงช่องที่ r เป็นเท่าไร" ตอบให้ครบทุกข้อ

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

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

ลองเอง · ตอบผลรวมของช่วงให้ทัน

ชุดนี้ถามอย่างเดียว ไม่มีการแก้ค่าเลย

คำถามตอนนี้

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

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

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

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

ท่าที่หนึ่ง · ผลรวมสะสม

แทนที่จะตอบคำถามตอนถูกถาม ให้ทำการบ้านไว้ก่อนหนึ่งรอบ สร้างอาเรย์ใหม่ชื่อ pre ที่ pre[i] เก็บผลรวมตั้งแต่ช่องแรกจนถึงช่องที่ i เรียกมันว่า ผลรวมสะสม (prefix sum) คำว่า prefix อ่านว่า "พรีฟิกซ์" แปลว่าส่วนที่นำหน้า เป็นคำเดียวกับคำนำหน้าคำในภาษาอังกฤษ ที่นี่หมายถึงท่อนที่เริ่มจากหัวแถวเสมอ

พอมีตารางนี้แล้ว ผลรวมของช่วงใด ๆ ก็หักลบกันออกมาได้ในการคำนวณครั้งเดียว

เอาอาเรย์ตัวอย่าง 3 1 4 1 5 9 2 6 มาไล่ให้เห็นชัด ๆ

ผลรวมสะสมของ 3 1 4 1 5 9 2 6
i 012345678
a[i] · 31415926
pre[i] 0348914232531
ช่อง 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 เหลือบิตเดียวได้ยังไง

i & −i ที่ i = 12 เขียนบน 5 บิต
แถว 168421
i 01100
กลับบิตทุกตัว 10011
บวกหนึ่ง คือ −i 10100
i & −i 00100

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

ช่องไหนดูแลช่วงไหน · อาเรย์ 3 1 4 1 5 9 2 6
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 2 3 4 5 6 7 8 t[1] 3 t[2] 4 t[3] 4 t[4] 9 t[5] 5 t[6] 14 t[7] 2 t[8] 31 ตำแหน่ง 3 1 4 1 5 9 2 6
ช่องเลขคี่ดูแลตัวเองตัวเดียว ช่องที่หารสองลงตัวดูแลกว้างขึ้นเรื่อย ๆ จนช่องที่เป็นกำลังของสองดูแลตั้งแต่หัวแถว ความยาวที่ไม่เท่ากันนี่แหละที่ทำให้ทุกช่วงประกอบขึ้นจากไม่กี่ก้อน (วาดประกอบโดยผู้เขียน)

ลองเอง · ต่อก้อนให้เต็มช่วง

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

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

แตะปุ่มช่องของต้นไม้ แล้วดูแถบข้างบนเปลี่ยนสี

ผ่านแล้ว 0 จาก 4 ชุด

ในชุดที่ต่อไม่ได้ ให้สังเกตว่าช่องที่คลุมตำแหน่งซ้ายสุดของช่วง มันคลุมอะไรพ่วงมาด้วยเสมอ

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

ถามผลรวม · ตัดทีละก้อนจากขวาเข้าซ้าย

อยากได้ผลรวมตั้งแต่ต้นถึงตำแหน่ง 7 ก็เริ่มที่ช่อง t[7] ซึ่งดูแลช่วงท้ายสุดมาก้อนหนึ่ง แล้วกระโดดถอยไปยังช่องที่ดูแลก้อนถัดไป การกระโดดคือ i -= i & -i ตรง ๆ ทำซ้ำจนถึงศูนย์

กดถัดไปเพื่อไล่ทีละก้อน

ถามผลรวม 1 ถึง 7
ช่องที่แวะก้อนที่ได้ค่าสะสม
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
ช่องที่ต้องแก้ช่วงที่มันดูแลคลุมตำแหน่ง 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] เก็บไว้เองด้วย

เขียนเป็นโค้ด

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

fenwick.cpp
#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 รอบ รอบละสุ่มทั้งขนาดอาเรย์ คำสั่ง และค่าติดลบ ตรงกันทุกรอบ

ท่าที่ 1 · สร้างต้นไม้ในเวลา O(n)

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

ทำไมโค้ดในหนังสือบางเล่มเขียน i | (i + 1)

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

สร้างในเวลา O(n)
// สร้างต้นไม้ในเวลา 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;
}

ท่าที่ 2 · แก้เป็นช่วง ถามเป็นจุด

โจทย์ที่กลับด้านกับบทนี้ก็มี คือ "บวก 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 คือผลรวมของส่วนต่าง

ท่าที่ 3 · แก้เป็นช่วง ถามเป็นช่วง

ท่าที่ 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); }

ท่าที่ 4 · หาตัวที่ k โดยไม่ต้องค้นซ้ำ

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

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

หาตัวที่ 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
}

ท่าที่ 5 · หลายมิติ

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


โจทย์ฝึก · ไล่จากง่ายไปยาก

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

ฝึกข้อ 1 ★☆☆☆☆ · ผลรวมของสี่เหลี่ยม

โจทย์กำหนด

ให้ตารางตัวเลขขนาด R คูณ C และคำถาม q ข้อ แต่ละข้อให้มุมซ้ายบน (r₁, c₁) กับมุมขวาล่าง (r₂, c₂) แล้วถามผลรวมของทุกช่องในสี่เหลี่ยมนั้น ไม่มีการแก้ค่าระหว่างทาง โดย R, C ≤ 1 000 และ q ≤ 200 000

EXAMPLE
InputOutput
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 นั่นคือเอาต์พุต ส่วนที่โจทย์กำลังท้าคือ ถ้ามีคำถามสองแสนข้อ จะบวกทีละช่องแบบนี้ไม่ไหว

ตาราง 3 คูณ 4 และสี่เหลี่ยมที่ถูกถาม 3 1 4 2 5 9 2 6 1 8 7 3 อ่านตารางสะสมสี่ช่อง 40 ลบ 8 ลบ 9 บวกคืน 3 = 26 ซึ่งคือเอาต์พุต ที่ต้องบวกคืน เพราะมุมนั้นถูกลบสองครั้ง
ช่องสีเขียวคือสี่เหลี่ยมที่คำถามขอ ตัวเลขทางขวาคือวิธีได้ผลรวมนั้นจากตารางสะสมด้วยการอ่านแค่สี่ช่อง ไม่ว่าสี่เหลี่ยมจะใหญ่แค่ไหน

คำใบ้

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

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

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

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

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

ให้ s[i][j] เป็นผลรวมของสี่เหลี่ยมตั้งแต่ (1,1) ถึง (i,j) เวลาตอบคำถาม เราเอาก้อนใหญ่มาลบก้อนบนกับก้อนซ้ายออก แต่มุมซ้ายบนถูกลบไปสองครั้ง จึงต้องบวกคืนหนึ่งครั้ง

ตอนสร้างตารางก็ใช้เหตุผลเดียวกันเป๊ะ แค่กลับทิศ คือบวกก้อนบนกับก้อนซ้ายแล้วลบส่วนที่ทับกันออก ทั้งข้อจึงเป็น O(RC + q)

ฝึกข้อ 1 · ผลรวมสะสมสองมิติ
#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 เทส โดยจงใจให้มีทั้งช่วงที่ชิดขอบและช่วงที่กว้างช่องเดียว ตรงกันหมด ท่านี้คือกระดูกสันหลังของข้อ คู่ที่ได้ยินกัน ในส่วนที่เป็นกระดานสามมิติ ซึ่งขยายเป็นแปดพจน์แทนที่จะเป็นสี่


ฝึกข้อ 2 ★★☆☆☆ · นับคู่ที่สลับที่กันอยู่

โจทย์กำหนด

ให้อาเรย์ยาว n ≤ 200 000 นับว่ามีคู่ (i, j) กี่คู่ที่ i < j แต่ a[i] > a[j] ค่าในอาเรย์เป็นจำนวนเต็มอะไรก็ได้ ซ้ำกันได้

EXAMPLE
InputOutput
6
5 2 6 1 4 3
9

อ่านตัวอย่างนี้ยังไง

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

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

คู่ที่ตัวซ้ายมากกว่าตัวขวา มีทั้งหมด 9 คู่ 5 0 2 1 6 0 1 3 4 2 3 3 0 + 1 + 0 + 3 + 2 + 3 = 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 พอดี

ฝึกข้อ 2 · นับด้วยต้นไม้
#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 เทส โดยจงใจให้ค่าซ้ำกันเยอะ (สุ่มจากช่วงแคบ ๆ) เพื่อกดดันจุดที่คนพลาดบ่อยที่สุด คือการเผลอนับคู่ที่ค่าเท่ากันเข้าไปด้วย ตรงกันทั้งหมด


ฝึกข้อ 3 ★★★☆☆ · คู่ที่ค่าต่างกันไม่เกิน D

โจทย์กำหนด

ให้อาเรย์ยาว n ≤ 200 000 กับเลข D นับว่ามีคู่กี่คู่ที่ค่าต่างกันไม่เกิน D ตำแหน่งในอาเรย์ไม่มีความหมาย สนใจแค่ค่า

EXAMPLE
InputOutput
6 2
8 1 4 7 3 9
5

อ่านตัวอย่างนี้ยังไง

บรรทัดแรกคือความยาวกับค่า D บรรทัดที่สองคืออาเรย์ เอาต์พุตคือจำนวนคู่ที่ค่าต่างกัน ไม่เกิน D คือเท่ากับ D พอดีก็นับ

ประโยคสำคัญของโจทย์คือตำแหน่งในอาเรย์ไม่มีความหมาย แปลว่าเรียงใหม่ได้ พอเรียงแล้วชุดนี้กลายเป็น 1 3 4 7 8 9 และคำว่า "ต่างกันไม่เกิน 2" ก็กลายเป็น "อยู่ในหน้าต่างเดียวกัน" ซึ่งเลื่อนหน้าต่างรอบเดียวก็นับครบ ได้ 5 คู่

อินพุตตามที่ให้มา 8 1 4 7 3 9 เรียงแล้ว คู่ที่เข้าเงื่อนไขมากองอยู่ติดกัน 1 0 3 1 4 1 7 0 8 1 9 2 0 + 1 + 1 + 0 + 1 + 2 = 5 ซึ่งคือเอาต์พุต
แถวบนคืออินพุตตามที่ให้มา แถวล่างคือชุดเดิมหลังเรียง เส้นโค้งคือคู่ที่ค่าต่างกันไม่เกิน D ซึ่งหลังเรียงแล้วจะกลายเป็นคู่ที่อยู่ใกล้กันเสมอ

คำใบ้

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

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

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

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

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

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

ตัวชี้สองตัวที่เดินหน้าอย่างเดียวจึงกวาดจบในรอบเดียว งานทั้งหมดคือการเรียงหนึ่งครั้ง เป็น O(n log n) เท่ากับท่าต้นไม้ แต่โค้ดสั้นกว่าครึ่ง ไม่ต้องย่อค่า ไม่ต้องระวังดัชนีเริ่มที่หนึ่ง และไม่มีอะไรให้พลาด

ฝึกข้อ 3 · เรียงแล้วเดินสองตัวชี้
#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 = 200,000 และคำถาม 200,000 ครั้ง
แนวคิดงานต่อหนึ่งคำสั่งรวม
ไม่ทำอะไรเลย ถามก็บวกทีละช่อง ถาม n · แก้ 1 40,000,000,000
แบ่งราก ถาม 2√n · แก้ 1 178,800,000
ต้นไม้เฟนวิก ถาม log n · แก้ log n 7,200,000
แบ่งรากลดงานลงจากทางที่ไม่ทำอะไรเลยราว 224 เท่า ซึ่งพอสำหรับโจทย์จำนวนมาก แต่เฟนวิกยังเร็วกว่ามันอีกราว 25 เท่า เพราะ log n เล็กกว่า √n คนละชั้น ผมรันโค้ดข้างล่างที่ขอบเขตนี้จริง ได้ 0.08 วินาที และหน่วยความจำราว 4.7 เมกะไบต์
sqrt_decomp.cpp
// แบ่งราก (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;
}
ตัวตรวจที่ทำให้กล้าเชื่อโค้ดข้างบน

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

brute_range_sum.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 t = 0; t < q; t++) {
        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 sum = 0;
            for (int i = l; i <= r; i++) sum += a[i];
            printf("%lld\n", sum);
        }
    }
    return 0;
}

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

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

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

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

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