← Cấu trúc dữ liệu & Giải thuật

Bài 5 · Nâng cao · 24 phút

Cây nhị phân tìm kiếm

Biên soạn bởi Nguyễn Anh Tuấn

Cây nhị phân tìm kiếm (BST): lưu dữ liệu có thứ tự để tìm/chèn/xoá trung bình O(log n); vì sao cây lệch thành O(n) và ý tưởng cây cân bằng.

Bài Sắp xếp & tìm kiếm kết lại với một nỗi phiền: tìm nhị phân rất nhanh, nhưng chỉ chạy trên mảng đã sắp - mà cứ thêm hay xoá một phần tử là dãy lại lệch, phải sắp lại. Có cách nào vừa giữ thứ tự, vừa cho thêm/xoá nhanh không?

Có: cây nhị phân tìm kiếm (Binary Search Tree, BST). Hãy coi nó là “tìm nhị phân được làm thành một cấu trúc sống”: mỗi node đặt ra một câu hỏi nhỏ hơn tôi thì rẽ trái, lớn hơn thì rẽ phải, và quy tắc đó đúng ở mọi node trong cây.

  • BST giữ dữ liệu có thứ tự mà vẫn cho tìm, chèn, xoá nhanh.
  • Mỗi node là một bước rẽ trái/phải - giống mỗi bước chia đôi của tìm nhị phân.
  • Nó được dựng từ node + con trỏ, đúng các viên gạch ở bài Mảng & danh sách liên kết.

Mỗi node có ba phần: value (giá trị), con left và con right. Điều làm nên BST là bất biến (invariant): với mọi node, toàn bộ cây con bên TRÁI nhỏ hơn nó, toàn bộ cây con bên PHẢI lớn hơn nó. Ví dụ chèn 5, 3, 8, 1, 4, 7, 9:

  • node = { value, left, right } - đúng kiểu node + con trỏ đã quen.
  • Bất biến: trái < node < phải, đúng ở MỌI node (không chỉ con trực tiếp).
  • Node trên cùng là gốc (root); node không có con là lá (leaf).

Vì sao bất biến lại quý

Chính nhờ “trái nhỏ, phải lớn” mà khi đứng ở một node, bạn biết chắc nửa cây nào KHÔNG cần ngó tới - y như tìm nhị phân bỏ đi một nửa mảng. Đó là nguồn gốc của tốc độ O(log n).

Tìm bắt đầu từ gốc: bằng thì xong; nhỏ hơn thì sang con trái; lớn hơn sang con phải - mỗi bước bỏ đi một nửa cây. Chèn đi y như tìm, tới chỗ trống thì gắn node mới vào. Và một món quà: duyệt in-order (trái → gốc → phải) cho ra đúng dãy đã sắp.

bst.ts · chèn, duyệt in-order, và tìm có ghi đường đi

type Node = { value: number; left: Node | null; right: Node | null };

function insert(root: Node | null, value: number): Node {
  if (!root) return { value, left: null, right: null };
  if (value < root.value) return { ...root, left: insert(root.left, value) };
  if (value > root.value) return { ...root, right: insert(root.right, value) };
  return root; // trung thi bo qua
}

function inorder(root: Node | null, out: number[] = []): number[] {
  if (!root) return out;
  inorder(root.left, out); out.push(root.value); inorder(root.right, out);
  return out;
}

function timKiem(root: Node | null, x: number) {
  const path: number[] = [];
  let node = root;
  while (node) {
    path.push(node.value);
    if (x === node.value) return { found: true, path };
    node = x < node.value ? node.left : node.right; // re mot nua
  }
  return { found: false, path };
}

let cay: Node | null = null;
for (const v of [5, 3, 8, 1, 4, 7, 9]) cay = insert(cay, v);

console.log(inorder(cay).join(' ')); // in-order -> da sap
console.log(timKiem(cay, 7));        // tim 7
console.log(timKiem(cay, 6));        // tim 6 (khong co)

Kết quả khi chạy

1 3 4 5 7 8 9
{ found: true, path: [ 5, 8, 7 ] }
{ found: false, path: [ 5, 8, 7 ] }
  • Tìm/chèn: đi từ gốc, mỗi bước so sánh rồi rẽ trái hoặc phải.
  • Số bước = chiều cao cây - cây càng thấp càng nhanh.
  • Duyệt in-order một BST luôn cho dãy đã sắp tăng dần.

Nhập một số rồi bấm Chèn để cây lớn dần; bấm Tìm để xem đường đi tô màu từ gốc xuống. Để ý ô Chiều cao so với lý tưởng:

Cây nhị phân tìm kiếm với gốc 50, nhánh trái nhỏ hơn, nhánh phải lớn hơn; đường đi tìm giá trị 40 được tô màu coral.
Quy tắc BST (trái < nút < phải) và đường đi khi tìm một giá trị (tô màu).
|

5314879
Số node: 7 Chiều cao: 3 Lý tưởng (cân đối): 3

Chèn vài số rồi bấm Tìm để xem đường đi tô màu. So chiều cao thật với lý tưởng: bấm Ví dụ lệch (chèn dãy đã sắp) để thấy cây tụt thành đường thẳng, tìm kiếm rơi về O(n).

  • Đường tìm luôn đi từ gốc xuống, mỗi tầng một bước.
  • Cây cân đối: chiều cao gần bằng “lý tưởng” log n.
  • Bấm “Ví dụ lệch” để thấy cây suy biến thành đường thẳng.

Mọi chi phí của BST đều bằng chiều cao cây. Cây cân đối có chiều cao quanh log n, nên tìm/ chèn/xoá đều O(log n): một triệu node chỉ cao chừng 20 tầng.

Trung thực

BST cơ bản KHÔNG tự cân bằng. Nếu chèn dãy đã sắp (1, 2, 3, 4, 5...), mỗi số đều rẽ phải và cây tụt thành một đường thẳng - hệt danh sách liên kết, chiều cao bằng số node, tìm kiếm rơi về O(n). Đời thực dùng cây tự cân bằng (AVL, Red-Black) tự xoay để giữ chiều cao quanh log n; phép xoay nằm ngoài phạm vi bài, nhưng ý tưởng là “giữ cây thấp”.
  • Chi phí tìm/chèn/xoá = chiều cao cây.
  • Cân đối: O(log n). Suy biến (chèn dãy đã sắp): O(n).
  • Cây tự cân bằng (AVL, Red-Black) giữ chiều cao ~log n sau mỗi thay đổi.

BST cho ta điều tìm nhị phân thiếu: một cấu trúc vừa có thứ tự, vừa thay đổi nhanh - miễn là giữ cây cân đối. Nó tuyệt khi bạn cần dữ liệu theo thứ tự (liệt kê tăng dần, tìm khoảng, lấy nhỏ nhất/lớn nhất).

Bước tiếp theo

Nhưng nếu mèo con không cần thứ tự, chỉ muốn hỏi “có hay không” và “lấy theo khoá” thật nhanh - nhanh hơn cả O(log n) - thì có một cấu trúc gần như tức thời: Bảng băm.

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

Tìm nhị phân chạy trên một MẢNG đã sắp cố định; thêm/xoá một phần tử là phải sắp lại tốn kém. BST là “tìm nhị phân động”: nó là một CẤU TRÚC luôn giữ thứ tự, nên vừa tìm O(log n) vừa chèn/xoá O(log n) mà không phải sắp lại từ đầu.

Khó hơn chèn một chút vì có ba trường hợp: node lá (xoá thẳng), node có một con (nối con lên thay), node có hai con (thay bằng phần tử nhỏ nhất của cây con phải, rồi xoá phần tử đó). Cả ba đều giữ được bất biến BST và vẫn O(log n) trên cây cân đối.

Vì mỗi số mới đều lớn hơn tất cả số trước, nên luôn rẽ phải - cây tụt thành một đường thẳng chỉ toàn con phải, y hệt một danh sách liên kết. Lúc đó tìm kiếm phải đi qua từng node, thành O(n) thay vì O(log n).

Dùng cây TỰ CÂN BẰNG (self-balancing) như AVL hay Red-Black tree: sau mỗi lần chèn/xoá, chúng xoay vài node để giữ chiều cao luôn quanh log n. Chi tiết phép xoay nằm ngoài bài này, nhưng ý tưởng là “giữ cây thấp thì tìm kiếm nhanh”.

Hiếm khi - thư viện chuẩn thường có sẵn (vd std::map/std::set của C++ là cây cân bằng). Nhưng hiểu BST giúp bạn biết vì sao các cấu trúc “có thứ tự” đó cho thao tác O(log n), và khi nào nên chọn chúng thay vì bảng băm.

Tick những điều em tự tin làm được. Càng lên cao, em càng hiểu sâu.

Tick những điều em tự tin làm được sau khi học bài này. 0/6

Trả lời vài câu để chắc rằng em đã nắm bài.

Câu 1/3 Điểm: 0

Bất biến (invariant) của cây nhị phân tìm kiếm là gì?

  1. 1

    Vẽ cây từ dãy

    Chèn lần lượt 8, 3, 10, 1, 6, 14, 4, 7 vào một BST rỗng và vẽ cây kết quả.

    Hoàn thành khi: Gốc là 8; bên trái có 3 (con 16, dưới 647); bên phải có 10 rồi 14. Mọi node trái nhỏ hơn, phải lớn hơn.

  2. 2

    Lần theo đường tìm

    Trên cây vừa vẽ, viết đường đi (dãy node ghé qua) khi tìm 7 và khi tìm 5.

    Hoàn thành khi: Tìm 7: 8 → 3 → 6 → 7 (thấy). Tìm 5: 8 → 3 → 6 → 4 rồi hết đường (không có).

  3. 3

    In-order ra dãy sắp

    Duyệt in-order (trái → gốc → phải) cây ở bài 1 và viết dãy thu được. Nó có tính chất gì?

    Hoàn thành khi: Ra 1, 3, 4, 6, 7, 8, 10, 14 - một dãy đã sắp tăng dần. Đó là bất biến của BST.

  4. 4

    Cao bao nhiêu là tốt

    Một BST cân đối chứa 1 000 000 node thì chiều cao xấp xỉ bao nhiêu? Tìm một phần tử tốn tối đa bao nhiêu bước?

    Hoàn thành khi: Chiều cao ~log₂(1 000 000) ≈ 20; tìm tối đa ~20 bước (mỗi bước xuống một tầng).

  5. 5

    Khi cây thành đường thẳng

    Chèn 1, 2, 3, 4, 5 theo thứ tự đó vào BST rỗng. Cây trông thế nào và tìm 5 tốn mấy bước?

    Hoàn thành khi: Cây tụt thành đường thẳng toàn con phải (như danh sách liên kết); tìm 5 tốn 5 bước - O(n).

  6. 6

    Tự code chèn

    Viết hàm chen(root, x) bằng TypeScript chèn x vào BST và trả về gốc; bỏ qua nếu x đã có.

    Hoàn thành khi: Nếu cây rỗng tạo node mới; x nhỏ hơn thì chèn vào con trái, lớn hơn vào con phải, bằng thì bỏ qua; chạy đúng trên vài ca thử.