Bài 5 chúng ta trả lời câu hỏi "2 node có cùng nhóm không?" cực nhanh bằng Union-Find. Bài này giải một bài toán truy vấn khác cũng rất phổ biến: tổng của một đoạn con trong mảng — nhưng mảng đó liên tục bị cập nhật giá trị theo thời gian thực.

1. Bài toán: Truy vấn tổng đoạn con động

Cho mảng $n$ số, bạn cần trả lời liên tục 2 loại truy vấn xen kẽ: "tổng các phần tử từ chỉ số $l$ đến $r$ là bao nhiêu?" và "cập nhật giá trị tại chỉ số $i$ thành $v$". Nếu dùng mảng tổng tiền tố (prefix sum) quen thuộc, truy vấn tổng đoạn chỉ tốn $O(1)$ — cực nhanh. Nhưng mỗi lần cập nhật một phần tử, toàn bộ mảng tổng tiền tố phía sau nó phải tính lại — tốn $O(n)$. Với hàng chục nghìn lượt cập nhật xen kẽ hàng chục nghìn truy vấn, cách này quá chậm.

Segment TreeFenwick Tree giải quyết đúng vấn đề này: cả truy vấn cập nhật đều chỉ tốn $O(\log n)$ — đánh đổi một chút so với $O(1)$ của prefix sum để đổi lấy khả năng cập nhật nhanh.

2. Segment Tree: Cây nhị phân chia đôi đoạn

Segment Tree biểu diễn mảng dưới dạng cây nhị phân: node gốc đại diện cho toàn bộ đoạn $[0, n{-}1]$, mỗi node con chia đôi đoạn của node cha, tới khi mỗi lá chỉ còn 1 phần tử. Mỗi node lưu sẵn tổng của đoạn nó đại diện:

segment-tree-build.js
class SegmentTree {
  constructor(arr) {
    this.n = arr.length;
    this.tree = new Array(4 * this.n).fill(0); // đủ chỗ cho mọi node
    this.build(arr, 1, 0, this.n - 1);
  }

  build(arr, node, l, r) {
    if (l === r) {
      this.tree[node] = arr[l];
      return;
    }
    const mid = Math.floor((l + r) / 2);
    this.build(arr, 2 * node, l, mid);       // con trái: nửa đầu đoạn
    this.build(arr, 2 * node + 1, mid + 1, r); // con phải: nửa sau đoạn
    this.tree[node] = this.tree[2 * node] + this.tree[2 * node + 1];
  }
}

Truy vấn tổng đoạn $[ql, qr]$ chỉ cần xét node nào chồng lấn với đoạn cần hỏi:

segment-tree-query.js
SegmentTree.prototype.query = function (node, l, r, ql, qr) {
  if (qr < l || r < ql) return 0; // không chồng lấn — bỏ qua nhánh này

  if (ql <= l && r <= qr) return this.tree[node]; // node nằm HOÀN TOÀN trong đoạn hỏi

  // chồng lấn một phần — phải xét cả 2 con
  const mid = Math.floor((l + r) / 2);
  return (
    this.query(2 * node, l, mid, ql, qr) +
    this.query(2 * node + 1, mid + 1, r, ql, qr)
  );
};
🕳️ Cạm bẫy thường gặp: Sai điều kiện biên chồng lấn
Lỗi phổ biến nhất khi cài Segment Tree là viết sai 1 trong 3 điều kiện biên của query(): (1) không chồng lấnqr < l || r < ql; (2) chồng lấn hoàn toànql <= l && r <= qr; (3) chồng lấn một phần — trường hợp còn lại, phải đệ quy cả 2 con. Đảo ngược dấu </<= ở bất kỳ điều kiện nào cũng khiến kết quả sai lệch hoặc bỏ sót phần tử ở biên đoạn.

Cập nhật giá trị tại 1 chỉ số chỉ cần đi theo đúng 1 đường từ gốc xuống lá rồi cập nhật ngược lên:

segment-tree-update.js
SegmentTree.prototype.update = function (node, l, r, idx, val) {
  if (l === r) {
    this.tree[node] = val; // tới đúng lá cần sửa
    return;
  }
  const mid = Math.floor((l + r) / 2);
  if (idx <= mid) this.update(2 * node, l, mid, idx, val);
  else this.update(2 * node + 1, mid + 1, r, idx, val);

  // cập nhật lại tổng của node cha sau khi con đã đổi
  this.tree[node] = this.tree[2 * node] + this.tree[2 * node + 1];
};

3. Fenwick Tree (Binary Indexed Tree): Nén gọn bằng bit

Fenwick Tree (Peter Fenwick, 1994) làm đúng việc tương tự nhưng chỉ cần một mảng kích thước $n{+}1$ — không cần cây tường minh. Bí quyết nằm ở hàm lowbit(x) = x \& (-x), tách ra bit "1" thấp nhất của $x$ trong biểu diễn nhị phân, dùng để nhảy giữa các node cha/con ngầm định:

$$\text{lowbit}(x) = x \mathbin{\&} (-x)$$
fenwick-tree.js
class FenwickTree {
  constructor(n) {
    this.n = n;
    this.tree = new Array(n + 1).fill(0); // 1-indexed
  }

  // Cộng thêm delta vào chỉ số i (1-indexed)
  update(i, delta) {
    for (; i <= this.n; i += i & -i) { // nhảy TỚI node cha chứa i
      this.tree[i] += delta;
    }
  }

  // Tổng tiền tố từ 1 tới i
  prefixSum(i) {
    let sum = 0;
    for (; i > 0; i -= i & -i) { // nhảy XUỐNG các đoạn con hợp thành
      sum += this.tree[i];
    }
    return sum;
  }

  // Tổng đoạn [l, r] (1-indexed)
  rangeSum(l, r) {
    return this.prefixSum(r) - this.prefixSum(l - 1);
  }
}
ℹ️ Lưu ý: Fenwick Tree dùng update kiểu "cộng thêm", không phải "gán"
Khác với Segment Tree (gán trực tiếp giá trị mới), Fenwick Tree ở trên nhận delta (mức thay đổi) chứ không phải giá trị tuyệt đối. Muốn "gán" phần tử tại vị trí $i$ thành giá trị $v$ mới, phải tính delta = v - giá_trị_cũ rồi gọi update(i, delta).
🔬 Đào sâu: Vì sao Fenwick Tree gọn và nhanh hơn Segment Tree?
Segment Tree cần mảng kích thước tới $4n$ (do cách đánh chỉ số nhị phân không đầy đủ ở các node lá lẻ), còn Fenwick Tree chỉ cần đúng $n{+}1$ — tiết kiệm bộ nhớ đáng kể. Fenwick cũng có cache locality tốt hơn vì chỉ thao tác trên 1 mảng phẳng bằng phép toán bit đơn giản, không cần đệ quy qua lại giữa các node như Segment Tree. Đánh đổi: Fenwick Tree chỉ hoạt động tự nhiên với các phép toán có "nghịch đảo" (cộng/trừ, XOR) — khó áp dụng trực tiếp cho min/max (vốn không thể "trừ ngược" để hoàn tác).

4. So sánh Segment Tree, Fenwick Tree và Prefix Sum

Tiêu chí Prefix Sum Segment Tree Fenwick Tree
Truy vấn tổng đoạn $O(1)$ $O(\log n)$ $O(\log n)$
Cập nhật 1 phần tử $O(n)$ (tính lại) $O(\log n)$ $O(\log n)$
Bộ nhớ $O(n)$ $O(4n)$ $O(n)$
Hỗ trợ min/max/gcd? Không Có (tuỳ hàm kết hợp) Khó (chỉ hợp phép có nghịch đảo)
💡 Mẹo: Khi nào chọn cấu trúc nào?
Nếu dữ liệu không bao giờ thay đổi sau khi xây dựng, Prefix Sum đơn giản và nhanh nhất. Nếu cần cập nhật thường xuyên nhưng chỉ cần tổng/XOR, Fenwick Tree gọn nhẹ hơn và thường được ưu tiên trong thi đấu lập trình. Nếu cần hỗ trợ min/max/GCD hoặc range update phức tạp (lazy propagation), Segment Tree linh hoạt hơn dù tốn bộ nhớ hơn một chút.

Sân chơi tương tác: Segment Tree Visualizer

Cập nhật một ô trong mảng để xem đường đi từ lá lên gốc được tô sáng và giá trị các node cha tự cập nhật lại, hoặc truy vấn một đoạn để xem chính xác những node nào được kết hợp để ra kết quả.

🌳 Sân chơi tương tác: Segment Tree

Cập nhật giá trị

Truy vấn đoạn con

Kết quả truy vấn-
Số node đã thăm0

Nhật ký

segment-tree-live.js

Trắc nghiệm ôn tập

Câu 1: Vì sao mảng Prefix Sum không phù hợp khi dữ liệu cần cập nhật (update) thường xuyên?

Trắc nghiệm ôn tập

Câu 2: Trong hàm query(node, l, r, ql, qr) của Segment Tree, điều kiện ql <= l && r <= qr đại diện cho trường hợp nào?

Trắc nghiệm ôn tập

Câu 3: Vì sao Fenwick Tree khó áp dụng trực tiếp cho truy vấn min/max trên đoạn con, dù nó hoạt động rất tốt cho tổng (sum)?

📖 Tài liệu tham khảo / References

Bài viết liên quan trong series

Bài 7: Quy Hoạch Động Trực Quan Bài 5: Union-Find / Disjoint Set Quay lại Lộ trình Series DSA Bài 1: Xoay Cây AVL & Red-Black (cấu trúc cây nhị phân)