Nhập môn

Mảng cộng dồn (Prefix Sum) và mảng hiệu: giải thích dễ hiểu, code C++, Python

Mảng cộng dồn (prefix sum, hay tổng tiền tố) là kỹ thuật tính sẵn tổng các phần tử từ đầu mảng đến từng vị trí i chỉ bằng một vòng lặp O(n). Nhờ mảng tính sẵn này, mọi truy vấn tính tổng các phần tử trên đoạn bất kỳ từ l đến r đều được trả lời ngay lập tức bằng một phép trừ duy nhất trong thời gian O(1).

Ý chính
  • Khai báo mảng cộng dồn pref có kích thước n + 1 với pref[0] = 0 giúp công thức tổng đoạn [l, r] luôn là pref[r + 1] - pref[l], không cần viết if (l == 0) riêng.
  • Tiền xử lý mất O(n), mỗi truy vấn tổng đoạn mất O(1); tổng thời gian cho q truy vấn giảm từ O(n × q) xuống O(n + q).
  • Ngược lại với truy vấn tổng đoạn, khi cần cộng thêm một giá trị vào nhiều đoạn [l, r], ta dùng mảng hiệu (Difference Array) để cập nhật mỗi đoạn trong O(1).

Bài toán mở đầu: Vì sao cộng bằng vòng lặp bị quá thời gian?

Giả sử bạn có một mảng a gồm n = 100.000 phần tử ghi doanh thu mỗi ngày của cửa hàng, và cần trả lời q = 100.000 câu hỏi dạng: "Tổng doanh thu từ ngày thứ l đến ngày thứ r là bao nhiêu?".

Nếu với mỗi câu hỏi bạn đều chạy vòng lặp for từ l đến r để cộng từng số:

  • Một câu hỏi hỏi từ đầu mảng đến cuối mảng tốn n phép cộng.
  • Thực hiện q câu hỏi như vậy sẽ tốn khoảng n × q = 10¹⁰ phép tính. Như đã phân tích trong bài Độ phức tạp thuật toán Big-O, 10 tỷ phép tính mất hàng chục giây và chắc chắn bị báo lỗi chạy quá thời gian (TLE).

Nguyên nhân gây lãng phí là những đoạn giao nhau bị cộng đi cộng lại hàng chục nghìn lần. Mảng cộng dồn (Prefix Sum) giải quyết triệt để việc này bằng cách cộng dồn một lần duy nhất ngay từ đầu.

Ý tưởng và công thức chuẩn không sợ lệch chỉ số

Xét mảng a có n phần tử đánh chỉ số từ 0 đến n - 1. Thay vì tạo mảng cộng dồn có cùng độ dài n, cách viết sạch nhất và ít lỗi nhất là tạo mảng pref có độ dài n + 1, trong đó:

  • pref[0] = 0 (tổng của 0 phần tử đầu tiên).
  • pref[k] là tổng của k phần tử đầu tiên trong mảng a (tức là a[0] + a[1] + ... + a[k - 1]).
  • Công thức xây dựng nối tiếp: pref[i + 1] = pref[i] + a[i].

Khi cần tính tổng các phần tử từ vị trí l đến vị trí r (tức a[l] + a[l + 1] + ... + a[r]), ta chỉ việc lấy tổng từ đầu mảng đến hết vị trí r (chính là pref[r + 1]), rồi bớt đi phần dư ở phía trước vị trí l (là a[0] + ... + a[l - 1], tức pref[l]):

Tổng đoạn [l..r] = pref[r + 1] − pref[l]

Hãy xem ví dụ với mảng 7 phần tử a = [3, 1, 4, 1, 5, 9, 2] khi cần lấy tổng đoạn từ l = 2 đến r = 5:

0 1 l = 2 3 4 r = 5 6 Mảng a 3 1 4 1 5 9 2 pref[6] = 23 Tổng 6 phần tử đầu (a[0] .. a[5]) = 3 + 1 + 4 + 1 + 5 + 9 = 23 − pref[2] = 4 a[0] + a[1] = 4 = Đoạn [2..5] pref[6] − pref[2] = 23 − 4 = 19
0 1 l=2 3 4 r=5 6 3 1 4 1 5 9 2 pref[6] = a[0]..a[5] = 23 − pref[2] = 4 pref[6] − pref[2] = 19
Hình 1. Lấy tổng đoạn [2..5] bằng cách lấy tổng 6 phần tử đầu (pref[6] = 23) trừ đi tổng 2 phần tử đầu (pref[2] = 4).
Chỉ số k 0 1 2 3 4 5 6 7
Phần tử a[k] 3 1 4 1 5 9 2 —
pref[k] 0 3 4 8 9 14 23 25
  • Tổng đoạn [2, 5] (4 + 1 + 5 + 9): pref[5 + 1] - pref[2] = pref[6] - pref[2] = 23 - 4 = 19.
  • Tổng đoạn [0, 3] (3 + 1 + 4 + 1): pref[3 + 1] - pref[0] = pref[4] - pref[0] = 9 - 0 = 9. Nhờ có pref[0] = 0, ngay cả khi l = 0 công thức vẫn đúng tuyệt đối mà không cần rẽ nhánh if.

Code mảng cộng dồn 1 chiều bằng C++ và Python

Lưu ý trong C++, dù các phần tử a[i] vừa trong kiểu int (tới 109), tổng của nhiều phần tử cộng lại rất dễ vượt quá 2 × 109, nên mảng pref luôn phải khai báo kiểu long long:

#include <iostream>
#include <vector>
using namespace std;

// Xây dựng mảng cộng dồn độ dài n + 1 (pref[0] = 0)
vector<long long> taoMangCongDon(const vector<int>& a) {
    int n = (int)a.size();
    vector<long long> pref(n + 1, 0);
    for (int i = 0; i < n; ++i) {
        pref[i + 1] = pref[i] + a[i];
    }
    return pref;
}

// Trả về tổng đoạn [l, r] (chỉ số 0-based) trong O(1)
long long tongDoan(const vector<long long>& pref, int l, int r) {
    return pref[r + 1] - pref[l];
}

int main() {
    vector<int> a = {3, 1, 4, 1, 5, 9, 2};
    vector<long long> pref = taoMangCongDon(a);
    cout << tongDoan(pref, 2, 5) << "\n";  // 19 (4 + 1 + 5 + 9)
    cout << tongDoan(pref, 0, 3) << "\n";  // 9  (3 + 1 + 4 + 1)
    return 0;
}
Học cùng giảng viên

Chủ đề này nằm trong Lecture 03 - Prefix Sum (Mảng cộng dồn) của Khóa Blue 2.0. Xem đề cương

Bài toán ngược: Cập nhật nhiều đoạn nhanh bằng Mảng hiệu (Difference Array)

Mảng cộng dồn giúp lấy tổng đoạn trong O(1). Nhưng nếu đề bài đổi ngược lại: cho mảng ban đầu toàn số 0, thực hiện q thao tác cộng thêm giá trị v vào mọi phần tử thuộc đoạn [l, r], rồi sau khi thực hiện xong tất cả các thao tác mới hỏi mảng cuối cùng là gì?

Lúc này ta dùng phép toán ngược của mảng cộng dồn, gọi là mảng hiệu (Difference Array) diff:

  • Muốn cộng thêm v vào đoạn [l, r], ta chỉ sửa đúng 2 vị trí trong O(1):
    • diff[l] += v (bắt đầu từ vị trí l, giá trị cộng dồn tăng thêm v).
    • diff[r + 1] -= v (từ vị trí r + 1 trở đi, triệt tiêu lượng tăng thêm v đó).
  • Sau khi xử lý xong toàn bộ q thao tác cập nhật, ta chỉ cần cộng dồn mảng diff một lần duy nhất từ trái sang phải trong O(n) là thu được giá trị cuối cùng của mọi phần tử!
#include <iostream>
#include <vector>
using namespace std;

int main() {
    int n = 5;
    vector<long long> diff(n + 1, 0);

    // Cộng một giá trị v vào mọi phần tử thuộc đoạn [l, r] trong O(1)
    auto congDoan = [&](int l, int r, int v) {
        diff[l] += v;
        diff[r + 1] -= v;
    };

    congDoan(1, 3, 5);   // [0, 5, 5, 5, 0]
    congDoan(2, 4, 10);  // [0, 5, 15, 15, 10]
    congDoan(0, 2, 2);   // [2, 7, 17, 15, 10]

    // Cộng dồn mảng diff một lần để khôi phục mảng kết quả
    vector<long long> a(n);
    long long cur = 0;
    for (int i = 0; i < n; ++i) {
        cur += diff[i];
        a[i] = cur;
        cout << a[i] << (i + 1 == n ? "\n" : " ");  // 2 7 17 15 10
    }
    return 0;
}

Mở rộng sang Mảng cộng dồn 2 chiều (trên bảng lưới)

Khi dữ liệu là một bảng hình chữ nhật m hàng, n cột, ta định nghĩa pref[i][j] là tổng tất cả các ô nằm trong góc hình chữ nhật từ ô đầu bảng đến hết hàng i, cột j. Theo nguyên lý bao hàm — loại trừ:

  • Khi xây dựng bảng: pref[i][j] = pref[i - 1][j] + pref[i][j - 1] - pref[i - 1][j - 1] + a[i - 1][j - 1] (vì phần góc trên bên trái pref[i - 1][j - 1] được cộng hai lần nên phải trừ bớt một lần).
  • Khi tính tổng hình chữ nhật con có góc trên trái (r1, c1) và góc dưới phải (r2, c2) (0-based): Tổng = pref[r2 + 1][c2 + 1] - pref[r1][c2 + 1] - pref[r2 + 1][c1] + pref[r1][c1].

Mọi truy vấn tổng hình chữ nhật con có kích thước bất kỳ đều chỉ tốn đúng 3 phép cộng trừ trong O(1).

Khi nào dùng Mảng cộng dồn, khi nào cần cấu trúc dữ liệu khác?

Yêu cầu của đề bài Công cụ phù hợp nhất Độ phức tạp
Mảng cố định không đổi, hỏi tổng đoạn [l, r] nhiều lần Mảng cộng dồn (Prefix Sum) Tiền xử lý O(n), mỗi truy vấn O(1)
Cập nhật cộng thêm vào nhiều đoạn [l, r], chỉ lấy mảng kết quả ở cuối cùng Mảng hiệu (Difference Array) Mỗi cập nhật O(1), khôi phục cuối O(n)
Tìm đoạn con có tổng bằng k trên mảng toàn số dương Kỹ thuật hai con trỏ / Cửa sổ trượt O(n) thời gian, O(1) bộ nhớ
Vừa thay đổi giá trị phần tử, vừa hỏi tổng đoạn xen kẽ nhau liên tục Cây Fenwick (BIT) hoặc Cây phân đoạn (Segment Tree) O(log n) cho cả cập nhật và truy vấn

Lỗi hay gặp

Cẩn thận
  • Tràn số nguyên 32-bit (int) trong C++. Đây là lỗi phổ biến số một khi làm bài mảng cộng dồn. Mảng 10^5 phần tử mà mỗi phần tử bằng 10^5 thì tổng đã lên tới 10^10 (vượt quá giới hạn 2.14 × 10^9 của int). Hãy luôn dùng vector<long long> pref.
  • Truy cập pref[l - 1] khi l = 0 trên mảng 0-based. Nếu bạn dùng mảng pref có cùng độ dài n và viết pref[r] - pref[l - 1], khi l = 0 chỉ số trở thành -1 (truy cập ngoài vùng nhớ trong C++, hoặc lấy nhầm phần tử cuối mảng trong Python!). Hãy dùng mảng pref độ dài n + 1 với công thức pref[r + 1] - pref[l].
  • Dùng mảng cộng dồn khi mảng bị sửa đổi xen kẽ với truy vấn. Mỗi khi một phần tử a[i] thay đổi, mọi giá trị pref[k] phía sau i đều phải tính lại mất O(n). Với bài toán vừa sửa vừa hỏi xen kẽ, mảng cộng dồn không còn nhanh hơn vòng lặp thường.

Luyện ngay trên Big-O Coding

Các bài tập dưới đây trên hệ thống Big-O Coding sử dụng trực tiếp mảng cộng dồn 1 chiều, 2 chiều và mảng hiệu:

Câu hỏi thường gặp

Mảng cộng dồn có dùng được khi mảng có số âm không?

Hoàn toàn dùng được. Phép cộng và phép trừ pref[r + 1] - pref[l] đúng với mọi số nguyên âm, dương hay số 0. Lưu ý duy nhất là khi có số âm, mảng pref không còn tăng dần đơn điệu nữa nên bạn không thể áp dụng tìm kiếm nhị phân trực tiếp trên mảng pref.

Có thể dùng ý tưởng cộng dồn cho phép nhân hoặc phép XOR không?

Được với phép XOR (vì x ^ x = 0, nên XOR đoạn [l, r] bằng prefXor[r + 1] ^ prefXor[l]). Với phép nhân, bạn chỉ chia ngược được khi trên đoạn không có số 0 và các giá trị không bị tràn số (hoặc dùng nghịch đảo modulo). Còn với phép tìm min / max trên đoạn, bạn không thể "trừ ngược" được mà phải dùng Bảng thưa (Sparse Table) hoặc Cây phân đoạn.

Đếm số phần tử chẵn trong đoạn [l, r] bằng mảng cộng dồn như thế nào?

Rất đơn giản: bạn coi mỗi số chẵn có giá trị đóng góp là 1, mỗi số lẻ có giá trị đóng góp là 0, rồi lập mảng cộng dồn trên dãy 0/1 đó. Số lượng số chẵn trong đoạn [l, r] chính là prefChan[r + 1] - prefChan[l].

Muốn học bài bản cùng giảng viên?

Luyện phỏng vấn Google, Meta, Amazon theo Interview Patterns: 16 buổi online live, lớp 7–15 người, C++/Python/Java. Học thử buổi 1 miễn phí.

Xem Khóa Blue 2.0

Nguồn tham khảo

Chia sẻ: Facebook LinkedIn
Nhắn Zalo tư vấnZalo Gọi 0937 401 483