Chào mừng bạn đến với bài học đầu tiên trong series Cấu Trúc Dữ Liệu & Giải Thuật Trực Quan. Trước khi đi vào chi tiết phép xoay, chúng ta cần trả lời một câu hỏi cốt lõi: tại sao một cây nhị phân tìm kiếm (Binary Search Tree — BST) bình thường lại chưa đủ tốt, và cây tự cân bằng giải quyết vấn đề đó ra sao.

1. Vì sao BST thường cần phải tự cân bằng?

Một cây nhị phân tìm kiếm đảm bảo: mọi node ở nhánh trái nhỏ hơn node cha, mọi node ở nhánh phải lớn hơn. Tra cứu, chèn, xoá đều có độ phức tạp lý thuyết $O(\log n)$ — nhưng chỉ khi cây cân đối. Vấn đề là: nếu bạn chèn dữ liệu theo thứ tự đã sắp xếp sẵn (ví dụ 1, 2, 3, 4, 5…), BST suy biến thành một danh sách liên kết một chiều.

worst-case.js
// Chèn tuần tự 1,2,3,4,5 vào BST thường
// KHÔNG có cơ chế tự cân bằng
insert(1); insert(2); insert(3); insert(4); insert(5);

// Kết quả: cây suy biến thành chuỗi lệch phải hoàn toàn
//   1
//    \
//     2
//      \
//       3
//        \
//         4
//          \
//           5
// Tìm kiếm giá trị 5 giờ tốn O(n) thay vì O(log n)!

Đây chính là lúc invariant phát huy tác dụng: nếu ta ép buộc cây luôn thoả một điều kiện cân bằng nhất định sau mỗi lần chèn/xoá, chiều cao cây sẽ luôn ở mức $O(\log n)$ bất kể thứ tự dữ liệu đầu vào.

ℹ️ Lưu ý: BST thường vs cây tự cân bằng
BST thường (không tự cân bằng) vẫn đúng về mặt logic thứ tự trái/phải — nó chỉ không đảm bảo về hình dạng. AVL và Red-Black là hai cách phổ biến nhất để ràng buộc thêm hình dạng đó mà không phá vỡ tính chất tìm kiếm nhị phân.

2. Hệ số cân bằng & Bất biến của cây AVL

Cây AVL (đặt theo tên hai nhà phát minh Adelson-Velsky và Landis, 1962) định nghĩa hệ số cân bằng (Balance Factor — BF) của một node là:

$$BF(x) = height(x.\text{left}) - height(x.\text{right})$$

Với quy ước $height(\text{null}) = 0$. Bất biến AVL yêu cầu: tại mọi node, $|BF(x)| \le 1$. Nếu sau một thao tác chèn/xoá mà $|BF(x)| = 2$ tại node nào đó, ta phải thực hiện phép xoay để đưa cây về lại bất biến.

balance-factor.js
function height(node) {
  return node ? node.height : 0;
}

function getBalance(node) {
  if (!node) return 0;
  return height(node.left) - height(node.right);
}

function updateHeight(node) {
  node.height = 1 + Math.max(height(node.left), height(node.right));
}
🔬 Đào sâu: Vì sao chiều cao AVL luôn là O(log n)?
Gọi $N(h)$ là số node tối thiểu của một cây AVL cao $h$. Ta có công thức truy hồi $N(h) = 1 + N(h-1) + N(h-2)$ — giống hệt dãy Fibonacci dịch chuyển. Vì dãy Fibonacci tăng theo hàm mũ với cơ số $\varphi \approx 1.618$ (tỉ lệ vàng), nên chiều cao $h$ của cây AVL với $n$ node luôn bị chặn bởi $h < 1.44 \log_2(n+2)$ — tức luôn nằm trong $O(\log n)$, không phụ thuộc thứ tự chèn.

3. Bốn trường hợp mất cân bằng & phép xoay tương ứng

Khi $|BF(x)| = 2$ tại node $x$ vừa được cập nhật, chỉ có đúng 4 tình huống có thể xảy ra, phân loại theo "hướng" mất cân bằng của 2 cấp liên tiếp:

  • LL (Left-Left): lệch trái tại x, lệch trái tại con trái → xoay phải đơn.
  • RR (Right-Right): lệch phải tại x, lệch phải tại con phải → xoay trái đơn.
  • LR (Left-Right): lệch trái tại x, lệch phải tại con trái → xoay trái tại con trái, rồi xoay phải tại x (xoay kép).
  • RL (Right-Left): lệch phải tại x, lệch trái tại con phải → xoay phải tại con phải, rồi xoay trái tại x (xoay kép).

Cài đặt hai phép xoay đơn — nền tảng cho cả 4 trường hợp:

rotations.js
// Xoay phải quanh y — dùng khi mất cân bằng lệch trái (LL)
function rotateRight(y) {
  const x = y.left;
  const T2 = x.right;

  x.right = y;
  y.left = T2;

  updateHeight(y); // cập nhật node cũ TRƯỚC
  updateHeight(x); // rồi mới đến node gốc mới

  return x; // x trở thành gốc subtree mới
}

// Xoay trái quanh x — dùng khi mất cân bằng lệch phải (RR)
function rotateLeft(x) {
  const y = x.right;
  const T2 = y.left;

  y.left = x;
  x.right = T2;

  updateHeight(x);
  updateHeight(y);

  return y;
}
🕳️ Cạm bẫy thường gặp: Thứ tự cập nhật height
Lỗi phổ biến nhất khi cài AVL là cập nhật height sai thứ tự sau khi xoay — nhiều người cập nhật height của node gốc mới (x) trước, trong khi x.height phụ thuộc vào y.height vừa được xoay xuống làm con. Luôn cập nhật node (node vừa "chìm xuống" làm con) trước, sau đó mới đến node mới lên làm gốc.

Với case xoay kép (LR/RL), ta chỉ cần ghép 2 xoay đơn lại — không cần logic mới:

insert-avl.js
function insert(node, value) {
  // 1. Chèn như BST thường
  if (!node) return { value, left: null, right: null, height: 1 };
  if (value < node.value) node.left = insert(node.left, value);
  else if (value > node.value) node.right = insert(node.right, value);
  else return node; // giá trị đã tồn tại

  // 2. Cập nhật height & tính hệ số cân bằng
  updateHeight(node);
  const balance = getBalance(node);

  // 3. Bốn trường hợp xoay
  if (balance > 1 && value < node.left.value) {
    return rotateRight(node); // LL
  }
  if (balance < -1 && value > node.right.value) {
    return rotateLeft(node); // RR
  }
  if (balance > 1 && value > node.left.value) {
    node.left = rotateLeft(node.left); // LR: xoay trái con trái trước
    return rotateRight(node); //     rồi xoay phải tại node
  }
  if (balance < -1 && value < node.right.value) {
    node.right = rotateRight(node.right); // RL: xoay phải con phải trước
    return rotateLeft(node); //     rồi xoay trái tại node
  }

  return node; // đã cân bằng, không cần xoay
}

4. Cây Red-Black: Luật tô màu thay cho height chính xác

AVL đảm bảo cân bằng "chặt" ($|BF| \le 1$), đổi lại phải xoay khá thường xuyên. Cây Red-Black (Rudolf Bayer, 1972) chấp nhận cân bằng "lỏng" hơn để đổi lấy ít thao tác xoay hơn khi ghi. Thay vì lưu height chính xác, mỗi node được tô đỏ hoặc đen, và phải thoả 5 luật:

  1. Mỗi node là đỏ hoặc đen.
  2. Node gốc (root) luôn là đen.
  3. Mọi lá rỗng (NIL) được coi là đen.
  4. Node đỏ không được có con đỏ (không 2 đỏ liên tiếp).
  5. Mọi đường đi từ 1 node bất kỳ tới các lá NIL của nó phải chứa cùng số lượng node đen ("black-height" bằng nhau).

5 luật này đảm bảo đường đi dài nhất từ gốc không bao giờ dài quá 2 lần đường đi ngắn nhất — tức chiều cao cây vẫn bị chặn trong $O(\log n)$, dù không chặt bằng AVL.

Tiêu chí AVL Tree Red-Black Tree
Độ chặt cân bằng Chặt ($|BF| \le 1$) Lỏng hơn (đường dài ≤ 2× đường ngắn)
Tốc độ tìm kiếm (lookup) Nhanh hơn (cây thấp hơn) Chậm hơn một chút
Số lần xoay khi chèn/xoá Nhiều hơn (có thể xoay lại nhiều cấp) Ít hơn (tối đa vài lần xoay + tô màu lại)
Ứng dụng thực tế điển hình Cơ sở dữ liệu đọc nhiều, ghi ít std::map/std::set C++, TreeMap Java, bộ lập lịch Linux CFS
💡 Mẹo: Khi nào chọn AVL, khi nào chọn Red-Black?
Nếu ứng dụng của bạn đọc nhiều hơn ghi rất nhiều (ví dụ chỉ mục tra cứu ít thay đổi), AVL thắng vì cây thấp hơn giúp tra cứu nhanh hơn. Nếu ứng dụng ghi liên tục (chèn/xoá thường xuyên như trong std::map), Red-Black thắng vì ít thao tác xoay hơn mỗi lần ghi, dù tra cứu chậm hơn một chút.

Sân chơi tương tác: AVL Tree Sandbox

Nhập một giá trị và bấm Chèn hoặc Xoá để xem cây tự động cân bằng lại — mọi phép xoay xảy ra sẽ được ghi log chi tiết bên dưới, kèm hệ số cân bằng (BF) hiển thị cạnh mỗi node.

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

Điều khiển

Số node0
Chiều cao cây0
Số phép xoay0

Nhật ký thao tác

avl-live.js

Trắc nghiệm ôn tập

Câu 1: Một node có chiều cao con trái là 3 và con phải là 1. Hệ số cân bằng (BF) của node đó là bao nhiêu, và cây có còn thoả bất biến AVL không?

Trắc nghiệm ôn tập

Câu 2: Khi chèn giá trị mới vào node X khiến BF(X) = 2, và giá trị mới nhỏ hơn con trái của X (lệch trái tại X, lệch trái tại con trái) — đây là trường hợp mất cân bằng nào và cần xoay gì?

Trắc nghiệm ôn tập

Câu 3: Vì sao std::map trong C++ và TreeMap trong Java thường được cài đặt bằng Red-Black Tree thay vì AVL Tree, dù AVL có tra cứu nhanh hơn?

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

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

Bài 2: Pathfinding Dijkstra & A* Quay lại Lộ trình Series DSA Series C: Cấu Trúc Dữ Liệu & Độ Phức Tạp Big-O Series C: Con Trỏ Chuyên Sâu (nền tảng cấu trúc dạng node) Series C++: Trực Quan Hoá Cây Tìm Kiếm Nhị Phân