Bài 7: Mảng, Stack, Prefix Sum và Difference Array¶
Tác giả: FPTOJ Team
Nội dung tham khảo từ: VNOI Wiki — Mảng và danh sách liên kết, Stack, Mảng cộng dồn và mảng hiệu
Mảng vs Danh sách liên kết¶
Bản chất vấn đề¶
Cấu trúc dữ liệu lưu trữ dãy phần tử, nhưng khác biệt cốt lõi ở cách bộ nhớ được cấp phát:
- Mảng (Array): Bộ nhớ liên tục — biết địa chỉ đầu, truy cập bất kỳ vị trí nào trong \(O(1)\).
- Danh sách liên kết (Linked List): Bộ nhớ rời rạc — mỗi node biết "node tiếp theo là ai", muốn đến vị trí \(i\) phải đi từ đầu.
Tư duy cốt lõi¶
Chọn cấu trúc nào phụ thuộc vào thao tác ưu tiên:
| Thao tác | Mảng | Danh sách liên kết |
|---|---|---|
| Truy cập phần tử thứ \(i\) | \(O(1)\) | \(O(N)\) |
| Thêm / xóa ở đầu | \(O(N)\) | \(O(1)\) |
| Thêm / xóa ở cuối | \(O(1)\) | \(O(1)\) (nếu có con trỏ cuối) |
| Thêm / xóa ở giữa | \(O(N)\) | \(O(1)\) (nếu đã có con trỏ) |
Phân tích tính đúng đắn¶
Mảng truy cập \(O(1)\) vì địa chỉ phần tử thứ \(i\) được tính trực tiếp: base + i * sizeof(element). Linked list không có công thức này nên phải duyệt từ đầu.
Đánh giá độ phức tạp¶
| Phép toán | Mảng | Danh sách liên kết |
|---|---|---|
| Truy cập ngẫu nhiên | \(O(1)\) | \(O(N)\) |
| Tìm kiếm | \(O(N)\) | \(O(N)\) |
| Chèn đầu / xóa đầu | \(O(N)\) / \(O(N)\) | \(O(1)\) / \(O(1)\) |
| Bộ nhớ | Ít hơn (không lưu con trỏ) | Nhiều hơn (mỗi node thêm 1 con trỏ) |
Stack (Ngăn xếp)¶
Bản chất vấn đề¶
Nhiều bài toán yêu cầu xử lý phần tử theo thứ tự vào sau, ra trước — ví dụ kiểm tra ngoặc hợp lệ, biểu thức toán học, duyệt cây. Stack là cấu trúc dữ liệu giải quyết chính xác nhu cầu này.
Tư duy cốt lõi¶
LIFO — Last In, First Out: phần tử được thêm vào sau cùng sẽ được lấy ra đầu tiên.
| Thao tác | Ý nghĩa | Độ phức tạp |
|---|---|---|
push(x) |
Thêm \(x\) vào đỉnh stack | \(O(1)\) |
pop() |
Loại bỏ phần tử ở đỉnh | \(O(1)\) |
top() |
Xem phần tử ở đỉnh | \(O(1)\) |
empty() |
Kiểm tra stack rỗng | \(O(1)\) |
Phân tích tính đúng đắn¶
Ứng dụng 1: Kiểm tra dãy ngoặc đúng
Ý tưởng: gặp dấu ( thì push, gặp ) thì pop. Nếu stack rỗng khi cần pop → sai. Cuối cùng stack phải rỗng.
Ứng dụng 2: Stack đơn điệu — Tìm phần tử lớn hơn bên trái
Bài toán: với mỗi phần tử \(a[i]\), tìm phần tử lớn hơn gần nhất bên trái.
Tư duy: Duy trì stack giảm dần. Khi xét \(a[i]\), mọi phần tử trên stack nhỏ hơn \(a[i]\) không bao giờ là đáp án cho bất kỳ \(a[j]\) nào (\(j \ge i\)), vì \(a[i]\) to hơn mà lại gần hơn → an tâm pop ra.
Đánh giá độ phức tạp¶
Mỗi phần tử được push đúng 1 lần và pop tối đa 1 lần → tổng thao tác \(\le 2N\) → \(O(N)\).
Mảng cộng dồn (Prefix Sum)¶
Bản chất vấn đề¶
Cho mảng \(a[0], a[1], \ldots, a[N-1]\), trả lời nhanh truy vấn: tổng đoạn \([l, r]\) là bao nhiêu?
Nếu tính trực tiếp mỗi truy vấn mất \(O(N)\), với \(Q\) truy vấn tổng là \(O(NQ)\). Prefix sum giảm xuống \(O(N + Q)\).
Tư duy cốt lõi¶
Tổng quãng đường: Bạn đi từ A → B → C → D. Ghi nhớ tổng quãng đường từ đầu đến mỗi điểm. Muốn biết quãng đường B → C? Lấy tổng đến C trừ tổng đến B.
Công thức:
Tổng đoạn \([1, 3] = \text{prefix}[4] - \text{prefix}[1] = 17 - 5 = 12\).
Phân tích tính đúng đắn¶
Prefix sum hoạt động dựa trên tính chất hiệu hai tổng liên tiếp:
Đây là phép trừ trực tiếp — không cần duyệt lại đoạn \([l, r]\).
Đánh giá độ phức tạp¶
| Giai đoạn | Độ phức tạp |
|---|---|
| Dựng mảng prefix | \(O(N)\) |
| Mỗi truy vấn | \(O(1)\) |
| Tổng \(Q\) truy vấn | \(O(N + Q)\) |
Prefix Sum 2D (Tổng hình chữ nhật)¶
Bản chất vấn đề¶
Cho lưới \(N \times M\), trả lời nhanh: tổng các phần tử trong hình chữ nhật từ \((r_1, c_1)\) đến \((r_2, c_2)\) là bao nhiêu?
Tư duy cốt lõi¶
Dùng nguyên lý bao gồm — loại trừ (inclusion-exclusion). Tổng hình chữ nhật từ \((0,0)\) đến \((i-1, j-1)\) được tính bằng:
Truy vấn hình chữ nhật \((r_1, c_1)\) đến \((r_2, c_2)\):
Phân tích tính đúng đắn¶
Phần tử \((r_1, c_1)\) bị trừ 2 lần khi trừ hàng và cột, nên phải cộng lại 1 lần. Đây chính là nguyên lý inclusion-exclusion cho 2 tập hợp.
Đánh giá độ phức tạp¶
| Giai đoạn | Độ phức tạp |
|---|---|
| Dựng prefix 2D | \(O(N \times M)\) |
| Mỗi truy vấn | \(O(1)\) |
Ứng dụng: Đếm ô màu đen trong hình chữ nhật con, tính tổng pixel (integral image trong xử lý ảnh).
Tìm đoạn con có tổng lớn nhất (Kadane's Algorithm)¶
Bản chất vấn đề¶
Cho mảng \(a[0 \ldots N-1]\), tìm đoạn con liên tiếp có tổng lớn nhất.
Tư duy cốt lõi¶
Với mỗi vị trí \(i\), hỏi: "nếu buộc phải kết thúc tại \(i\), đoạn tốt nhất bắt đầu từ đâu?" Nếu tổng tích lũy \(\text{curSum} < 0\) thì bỏ hết trước đó, bắt đầu lại từ \(a[i]\).
Phân tích tính đúng đắn¶
Khi \(\text{curSum} < 0\), mọi đoạn bắt đầu trước vị trí \(i\) đều có tổng nhỏ hơn \(a[i]\) đơn độc. Do đó "bỏ hết" là tối ưu. Thuật toán duyệt 1 lần, mỗi bước quyết định "tiếp tục" hoặc "bắt đầu lại" — đảm bảo không bỏ sót đoạn tối ưu.
Đánh giá độ phức tạp¶
| Phép toán | Độ phức tạp |
|---|---|
| Duyệt mảng | \(O(N)\) |
| Không gian | \(O(1)\) |
Mảng hiệu (Difference Array)¶
Bản chất vấn đề¶
Cho mảng \(a[0 \ldots N-1]\), thực hiện nhiều truy vấn: cộng \(k\) vào tất cả phần tử trong đoạn \([l, r]\). Sau đó khôi phục mảng kết quả.
Nếu cập nhật trực tiếp mỗi truy vấn mất \(O(N)\), với \(Q\) truy vấn tổng là \(O(NQ)\). Difference array giảm xuống \(O(N + Q)\).
Tư duy cốt lõi¶
Thay vì cộng \(k\) vào từng phần tử \(a[l], a[l+1], \ldots, a[r]\), chỉ cần:
- \(\text{diff}[l] \mathrel{+}= k\) (bắt đầu cộng từ đây)
- \(\text{diff}[r+1] \mathrel{-}= k\) (ngừng cộng từ đây)
Sau tất cả truy vấn, tính prefix sum trên \(\text{diff}\) để khôi phục mảng gốc.
Phân tích tính đúng đắn¶
Sau khi cộng \(k\) vào \(\text{diff}[l]\) và trừ \(k\) khỏi \(\text{diff}[r+1]\), prefix sum tại vị trí \(i\):
- Nếu \(i < l\): không bị ảnh hưởng → đúng.
- Nếu \(l \le i \le r\): được cộng \(k\) (từ \(\text{diff}[l]\)) → đúng.
- Nếu \(i > r\): được cộng \(k\) rồi trừ \(k\) (từ \(\text{diff}[r+1]\)) → không đổi → đúng.
Đánh giá độ phức tạp¶
| Giai đoạn | Độ phức tạp |
|---|---|
| Mỗi truy vấn cập nhật | \(O(1)\) |
| Khôi phục mảng | \(O(N)\) |
| Tổng \(Q\) truy vấn | \(O(N + Q)\) |
Bài tập minh họa: Karen and Coffee (CF 816B)¶
Bản chất vấn đề¶
Có \(n\) truy vấn "tăng nhiệt độ đoạn \([l, r]\) thêm 1". Hỏi: có bao nhiêu vị trí có giá trị \(\ge k\)?
Tư duy cốt lõi¶
Dùng difference array để xử lý \(n\) cập nhật trong \(O(N)\), rồi dùng prefix sum trên kết quả để trả lời truy vấn đếm.
Phân tích tính đúng đắn¶
Sau khi khôi phục, mảng \(a[i]\) = số lần vị trí \(i\) được "tưới". Chuyển sang mảng nhị phân: \(b[i] = 1\) nếu \(a[i] \ge k\), rồi prefix sum trên \(b\) để đếm nhanh.
Đánh giá độ phức tạp¶
| Giai đoạn | Độ phức tạp |
|---|---|
| Xử lý \(n\) cập nhật | \(O(N)\) |
| Khôi phục + xây prefix | \(O(N)\) |
| Trả lời \(q\) truy vấn | \(O(Q)\) |
| Tổng | \(O(N + Q)\) |
Cạm bẫy thường gặp¶
Prefix Sum: 1-indexed vs 0-indexed¶
| Cách | Công thức | Tổng \([l, r]\) |
|---|---|---|
| \(\text{prefix}[0] = 0\) (khuyến khích) | \(\text{prefix}[i] = \sum_{k=0}^{i-1} a[k]\) | \(\text{prefix}[r+1] - \text{prefix}[l]\) |
| \(\text{prefix}[i] = \sum_{k=0}^{i} a[k]\) | \(\text{prefix}[0] = a[0]\) | \(\text{prefix}[r] - \text{prefix}[l-1]\) (cẩn thận \(l=0\)) |
Lỗi thường gặp: Quên xét \(l = 0\) ở cách 2 → truy cập \(\text{prefix}[-1]\).
Overflow khi tính Prefix Sum¶
Nếu \(a[i]\) có thể đến \(10^9\) và \(N\) đến \(10^5\) → tổng lớn nhất \(\approx 10^{14}\) → phải dùng long long.
Difference Array: Quên xét \(r+1\) ra ngoài mảng¶
Phải kiểm tra \(r + 1 < N\) trước khi truy cập \(\text{diff}[r+1]\), nếu không sẽ truy cập ngoài mảng → Runtime Error.
Difference Array: Quên khôi phục¶
Sau khi cập nhật \(\text{diff}[]\), phải tính prefix sum để khôi phục mảng gốc. Dùng \(\text{diff}[]\) trực tiếp sẽ cho kết quả sai.
Stack: Quên kiểm tra rỗng¶
Gọi pop() hoặc top() trên stack rỗng → Runtime Error. Phải kiểm tra empty() trước.
Kadane's Algorithm: Mảng toàn số âm¶
Nếu mảng toàn số âm, Kadane's trả về phần tử lớn nhất (vẫn âm). Nếu muốn cho phép đoạn con rỗng (tổng = 0):
Bài tập luyện tập¶
| Bài | Nền tảng | Độ khó | Chủ đề |
|---|---|---|---|
| CSES — Static Range Sum Queries | CSES | ⭐ | Prefix Sum |
| CSES — Forest Queries | CSES | ⭐⭐ | Prefix Sum 2D |
| LeetCode — Range Sum Query | LeetCode | ⭐ | Prefix Sum |
| LeetCode — Subarray Sum Equals K | LeetCode | ⭐⭐ | Prefix Sum + HashMap |
| LeetCode — Product of Array Except Self | LeetCode | ⭐⭐ | Prefix / Suffix Sum |
| Codeforces 816B — Karen and Coffee | CF | ⭐⭐ | Difference Array |
| VNOJ — QSUM | VNOJ | ⭐⭐ | Prefix Sum |
Tài liệu tham khảo¶
- VNOI Wiki — Mảng và danh sách liên kết
- VNOI Wiki — Stack
- VNOI Wiki — Mảng cộng dồn và mảng hiệu
- GeeksforGeeks — Prefix Sum Array
- YouTube — Prefix Sum (takeuforward)
Bài tiếp theo: Heap (Hàng đợi ưu tiên) →