Mảng Cộng Dồn 1D (Prefix Sum)

Biến bài toán tính tổng một đoạn bất kỳ từ việc phải lặp qua từng phần tử (chậm chạp) thành một phép trừ duy nhất, nhanh như chớp mắt.

Ý tưởng chủ đạo

Tiền xử lý tính sẵn tổng từ đầu mảng đến mọi vị trí i. Lưu vào mảng P. Khi cần tính tổng đoạn [L, R], chỉ việc lấy tổng đến R trừ đi phần thừa trước L (tổng đến L-1).

Dùng khi nào?

Khi có nhiều truy vấn hỏi tổng/XOR/nhân của một đoạn con trên mảng, và mảng đó không bị thay đổi giá trị (tĩnh) trong suốt quá trình truy vấn. Dữ liệu thường cỡ N, Q ≤ 106.

Điểm mạnh & Điểm yếu

Mạnh: Cực kỳ dễ cài đặt, truy vấn O(1) tối ưu tuyệt đối.
Yếu: Nếu mảng bị cập nhật (ví dụ: đổi a3 = 5), ta phải tính lại toàn bộ mảng P mất O(N).

So sánh các cấu trúc dữ liệu tính tổng đoạn

Kỹ thuật Khởi tạo Truy vấn đoạn Cập nhật 1 điểm Độ khó cài Khi nào ưu tiên?
Vòng lặp ngây thơ O(1) O(N) O(1) Rất dễ Mảng nhỏ, truy vấn cực ít.
Mảng Tiền Tố (Bài này) O(N) O(1) O(N) Dễ Mảng tĩnh (không cập nhật), truy vấn nhiều.
Fenwick Tree (BIT) O(N) O(log N) O(log N) Vừa Có cập nhật điểm, chỉ cần tính tổng đoạn.
Segment Tree O(N) O(log N) O(log N) Khó Truy vấn phức tạp (Max/Min), cập nhật cả đoạn.

Bài toán & Động lực

Cho một mảng số nguyên a gồm N phần tử đánh số từ 1 đến N. Có Q câu hỏi (truy vấn), mỗi câu cho hai số LR, yêu cầu in ra tổng các phần tử từ aL đến aR.

Cách ngây thơ: Với mỗi truy vấn, dùng một vòng lặp for (i = L → R) cộng dồn lại. Nếu N = 105Q = 105, trong trường hợp xấu nhất (L=1, R=N), mỗi truy vấn mất O(N) phép tính. Tổng số phép tính là Q × N = 1010. Máy tính chấm thi thường chạy ~108 phép tính/giây, nên cách này sẽ quá thời gian (Time Limit Exceeded).

Ý tưởng cứu vãn: Ta cần một cách nào đó để tính tổng trong O(1). Nếu ta biết trước số tiền của mình vào cuối mỗi ngày, ta có thể dễ dàng biết được từ ngày L đến ngày R mình đã kiếm được bao nhiêu, bằng cách lấy (Tổng tài sản ngày R) trừ đi (Tổng tài sản ngày L-1).

Lý thuyết cốt lõi

Ta định nghĩa một mảng mới P gọi là mảng tổng tiền tố. Trong đó Pi lưu tổng của các phần tử từ vị trí 1 đến vị trí i.

Pi = a1 + a2 + ... + ai

Để xây dựng mảng P nhanh chóng trong O(N), thay vì lặp lại từ đầu, ta tận dụng kết quả của bước trước:

Pi = Pi-1 + ai

Bây giờ, muốn tính tổng đoạn [L, R] tức là aL + aL+1 + ... + aR, ta nhận thấy:

Rõ ràng, lấy phương trình trên trừ phương trình dưới, phần a1 đến aL-1 triệt tiêu lẫn nhau, ta còn lại đúng phần cần tìm! Do đó:

Tổng đoạn [L, R] = PR - PL-1

Vì sao cần P0 = 0 ? Nếu L = 1 (tính tổng từ đầu mảng), công thức yêu cầu ta gọi P0. Để công thức luôn đúng mà không cần viết lệnh if (L == 1), ta khởi tạo sẵn P0 = 0.

Mô phỏng 1: Xây dựng mảng tiền tố

Lab 1: Chạy vòng lặp tạo mảng P

Mảng a:
Mảng P:
Chậm Nhanh
long long P[7];
P[0] = 0;
for (int i = 1; i <= 6; i++) {
    P[i] = P[i - 1] + a[i];
}
i-
P[i-1]-
a[i]-
Phép tính0 / 6
Nhấn 'Tiến Bước' để bắt đầu khởi tạo mảng tiền tố.

Mô phỏng 2: Truy vấn Tổng Đoạn [L, R]

Lab 2: Phép trừ O(1)

Mảng a:
Mảng P:
Chậm Nhanh
long long query(int L, int R) {
    long long sumR = P[R];
    long long sumL_minus_1 = P[L - 1];
    long long ans = sumR - sumL_minus_1;
    return ans;
}
L-
R-
P[R]-
P[L-1]-
ans-
Thay đổi L, R và nhấn Chạy để xem cách tính O(1).

Thuật toán & Cài đặt

Thuật toán từng bước

  1. Chuẩn bị (Tiền xử lý)
    • Khởi tạo mảng P có kích thước lớn hơn số lượng phần tử một chút (vd: N + 5). Đảm bảo kiểu dữ liệu đủ lớn (64-bit integer, ví dụ long long trong C++) để tránh tràn số khi cộng dồn.
    • Gán P[0] = 0. Bước này thiết lập mốc biên quan trọng.
  2. Xây dựng mảng tiền tố
    • Dùng vòng lặp i chạy từ 1 đến N.
    • Gán P[i] = P[i-1] + a[i]. Trạng thái hiện tại lấy trực tiếp từ trạng thái ngay sát trước cộng thêm phần tử mới.
  3. Trả lời truy vấn
    • Với mỗi cặp L, R nhận được, in ra kết quả của phép toán P[R] - P[L - 1]. Phép toán này chạy độc lập không dùng vòng lặp, nên độ phức tạp là O(1).
#include <iostream>
#include <vector>
using namespace std;

int main() {
    // Tối ưu I/O để đọc/ghi nhanh
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n, q;
    cin >> n >> q;

    vector<long long> a(n + 1);
    for (int i = 1; i <= n; i++) {
        cin >> a[i];
    }

    // Khởi tạo mảng P kích thước n+1, gán sẵn 0
    vector<long long> p(n + 1, 0); 
    p[0] = 0; // Tường minh gán biên

    // Dựng mảng tiền tố O(N)
    for (int i = 1; i <= n; i++) {
        p[i] = p[i - 1] + a[i];
    }

    // Trả lời các truy vấn O(1) mỗi truy vấn
    for (int i = 1; i <= q; i++) {
        int l, r;
        cin >> l >> r;
        // In ra tổng đoạn
        cout << p[r] - p[l - 1] << "\n";
    }

    return 0;
}

Lỗi thường gặp

1. Tràn số nguyên (Integer Overflow)

Nếu mảng có 105 phần tử, mỗi phần tử lớn cỡ 109, thì tổng tối đa có thể lên tới 1014. Con số này vượt qua giới hạn của số nguyên 32-bit (chỉ lưu được cỡ ~2×109).

Khai báo mảng P bằng long long (trong C++) hoặc long (trong Java).

2. Quên P[0] = 0 hoặc mảng chạy từ chỉ số 0

Nhiều bạn quen đọc mảng từ a[0] đến a[N-1]. Khi truy vấn đoạn [0, R], công thức trở thành P[R] - P[-1], gây ra lỗi truy cập ngoài biên (Out of bounds/Segmentation fault).

Luôn luôn dịch mảng đầu vào để bắt đầu từ chỉ số 1 (a[1] đến a[N]). Khởi tạo mảng P có cỡ N+1 và gán P[0] = 0.

Kỹ thuật Mở Rộng: Mảng Hiệu (Difference Array)

10 bài toán bạn đang tìm hiểu thực chất dẫn chúng ta đến một kỹ thuật song sinh với Mảng Tiền Tố: Mảng Hiệu (Difference Array). Nếu Tiền Tố giúp truy vấn tổng đoạn nhanh trên mảng tĩnh, thì Mảng Hiệu giúp cập nhật cộng đoạn nhanh, sau đó mới truy xuất tĩnh.

Nguyên lý vàng: Mảng tiền tố của Mảng Hiệu chính là Mảng Gốc.
Để cộng v vào đoạn [L, R] của mảng A, ta xét mảng hiệu D và chỉ cần thực hiện 2 phép toán O(1): DL += vDR+1 -= v.

Nhóm 1: Mảng Hiệu Cơ Bản & Tần Suất Phủ (Bài 1, 2, 7)

Đây là áp dụng trực tiếp nhất của Mảng Hiệu. Cứ mỗi thao tác cập nhật/đoạn phủ [L, R], ta cắm 2 "cột mốc" ở hai đầu đoạn.

Nhóm 2: Hai lần Tiền Tố & Chuyển hóa (Bài 5, 6, 8, 9, 10)

Sau khi dùng mảng hiệu để tạo mảng gốc siêu tốc, ta có thể áp dụng thuật toán khác trên mảng kết quả.

Nhóm 3: Tư duy ngược & Mảng hiệu Đa cấp (Bài 3, 4)

Trắc nghiệm ôn tập (0/12)