← Cấu trúc dữ liệu & Giải thuật← Data Structures & Algorithms
Cấu trúc dữ liệu & Giải thuậtData Structures & Algorithms7 Th8, 2026Aug 7, 202627 phút đọc24 min read

Cấu trúc dữ liệu câyTree Data Structures

Mục lục
  1. Tổng quan
  2. Kiến thức nền tảng
  3. Thuật ngữ
  4. Biểu diễn tree trong code
  5. Binary tree
  6. Các hình dạng của binary tree
  7. Khái niệm chính
  8. Tree traversal
  9. Traversal dạng lặp
  10. Level-order traversal (BFS)
  11. DFS so với BFS trên tree
  12. Các phép tính thường gặp trên tree
  13. Binary search tree
  14. Vì sao BST không cân bằng suy biến về O(n)
  15. BST so với hash table
  16. Best Practices
  17. Tài liệu tham khảo
Table of contents
  1. Overview
  2. Fundamentals
  3. Terminology
  4. Representing a tree in code
  5. Binary trees
  6. Shapes of binary trees
  7. Key Concepts
  8. Tree traversal
  9. Iterative traversals
  10. Level-order traversal (BFS)
  11. DFS versus BFS on a tree
  12. Common tree computations
  13. Binary search trees
  14. Why an unbalanced BST degenerates to O(n)
  15. BST versus hash table
  16. Best Practices
  17. References

Thuộc bộ kiến thức Data Structures & Algorithms Roadmap.

Tổng quan

Mọi cấu trúc đã đi qua từ đầu tới giờ — array, linked list, stack, queue, hash table — đều là tuyến tính: các phần tử nằm thành một dãy, và “phần tử kế tiếp” chỉ có đúng một nghĩa. Tree là cấu trúc phi tuyến tính đầu tiên. Mỗi node có thể trỏ tới nhiều child, nên “kế tiếp” bị phân nhánh, và kết quả là một cấu trúc phân cấp: một hình dạng mô hình hoá rất tự nhiên quan hệ chứa đựng, phân loại và phân rã.

Cấu trúc phân cấp có mặt ở khắp nơi trong ngành, nên tree cũng vậy:

Nhưng lý do tree xứng đáng có một chương riêng không chỉ nằm ở khả năng mô hình hoá. Nó nằm ở O(log n). Cấu trúc tuyến tính bắt bạn phải đi qua k phần tử để tới phần tử thứ k. Tree với branching factor bằng 2 cho phép bạn cắt đôi số ứng viên còn lại ở mỗi bước, nên n phần tử được tiếp cận trong khoảng log₂ n bước. Một triệu phần tử là 20 bước; một tỷ là 30 bước. Đó cũng chính là mẹo mà binary search dùng trên mảng đã sắp xếp (xem ./09-search-algorithms.md), khác ở chỗ tree giữ được tính chất đó trong khi vẫn hỗ trợ insert và delete rẻ, điều mà mảng đã sắp xếp không làm được.

Điểm gài — và cũng là toàn bộ nội dung của note kế tiếp — là O(log n) chỉ đúng khi cây còn xoè rộng. Một binary search tree bị nạp dữ liệu đã sắp xếp sẽ co lại thành một linked list và mọi thao tác suy biến về O(n). Note này xây dựng thuật ngữ, các phép traversal, và binary search tree thuần; note kế tiếp sẽ sửa vấn đề suy biến.

Kiến thức nền tảng

Thuật ngữ

Tree là một tập các node nối với nhau bằng edge, có đúng một node được chọn làm root, và mọi node không phải root đều có đúng một parent. Nói cách khác: tree là một connected acyclic graph có gốc. Tập hợp nhiều tree rời nhau gọi là forest.

                  (A)          <- root, depth 0
                 /   \
              (B)     (C)      <- B và C là sibling; đều là child của A
             /   \       \
          (D)    (E)     (F)   <- depth 2
                 /
              (G)              <- leaf, depth 3
Thuật ngữÝ nghĩaTrong sơ đồ
RootNode duy nhất không có parentA
ParentNode ở mức trên liền kềparent của EB
ChildNode ở mức dưới liền kềchild của BD, E
SiblingCác node chung parentBC; DE
Leaf (external node)Node không có childD, G, F
Internal nodeNode có ít nhất một childA, B, C, E
EdgeLiên kết parent→childA–B
PathDãy edge nối hai nodeA → B → E → G
AncestorBất kỳ node nào trên đường đi lên tới rootancestor của G: E, B, A
DescendantBất kỳ node nào đi xuống tới đượcdescendant của B: D, E, G
SubtreeMột node cùng toàn bộ descendant của nósubtree gốc B = {B, D, E, G}
Degree của nodeSố childdegree của A = 2, của C = 1
Depth của nodeSố edge từ root đi xuống tới nódepth của G = 3
Height của nodeSố edge trên đường dài nhất đi xuống tới một leafheight của B = 2
Height của treeHeight của root3
LevelTất cả node có cùng depthlevel 2 = {D, E, F}

Có hai quy ước gây nhầm lẫn triền miên, nên cần chốt rõ:

Hai sự kiện về cấu trúc đáng nhớ: tree có n node thì có đúng n − 1 edge (mọi node trừ root đều đóng góp đúng một edge lên parent của nó), và giữa hai node bất kỳ chỉ có đúng một simple path.

Biểu diễn tree trong code

Trường hợp tổng quát (n-ary) là một node giữ một giá trị và một danh sách các child:

class TreeNode:
    """Node của tree n-ary tổng quát: một value, số child tuỳ ý."""
    __slots__ = ("value", "children")

    def __init__(self, value, children=None):
        self.value = value
        self.children = children if children is not None else []

    def add(self, child):
        self.children.append(child)
        return child


# Mô hình hoá một filesystem nhỏ
root = TreeNode("/")
usr = root.add(TreeNode("usr"))
etc = root.add(TreeNode("etc"))
usr.add(TreeNode("bin"))
usr.add(TreeNode("lib"))
etc.add(TreeNode("hosts"))

Chú ý là ở đây không có parent pointer. Thêm nó vào (self.parent) làm cho việc đi ngược lên trở thành O(1) và là bắt buộc cho các thao tác kiểu “tìm đường quay về root” hoặc “tìm node in-order kế tiếp mà không cần stack”, nhưng nó tốn thêm một word mỗi node và tạo ra reference cycle mà một reference-counting collector ngây thơ sẽ để rò rỉ. Hãy thêm khi thật sự cần, đừng mặc định thêm.

Binary tree

Binary tree giới hạn mỗi node có tối đa hai child, đặt tên là leftright. Thứ tự có ý nghĩa: node chỉ có left child là một cây khác với node chỉ có right child, dù cả hai đều có một child.

class Node:
    """Node của binary tree."""
    __slots__ = ("val", "left", "right")

    def __init__(self, val, left=None, right=None):
        self.val = val
        self.left = left
        self.right = right

Tại sao lại giới hạn ở hai? Vì hai là branching factor nhỏ nhất mà vẫn chia đôi được bài toán, và vì một quyết định hai nhánh (nhỏ hơn / lớn hơn) chính là đầu ra tự nhiên của một phép so sánh. Fan-out lớn hơn cũng hữu ích, nhưng vì một lý do khác — giảm thiểu số lần đọc đĩa thay vì số phép so sánh — và đó chính xác là điều B-tree làm.

Các hình dạng của binary tree

Những cái tên dưới đây thường bị dùng lỏng lẻo ngoài thực tế; đây là các định nghĩa chuẩn.

    full (mọi node có 0 hoặc 2 child)          complete (mọi level đầy trừ có thể
              (1)                               level cuối, điền từ trái sang phải)
             /   \                                        (1)
          (2)     (3)                                    /   \
                 /   \                                (2)     (3)
              (4)     (5)                            /   \    /
                                                  (4)   (5) (6)

    perfect (full VÀ mọi leaf cùng depth)      degenerate (mỗi node có đúng một child —
              (1)                                          thực chất là một linked list)
             /   \                                       (1)
          (2)     (3)                                       \
         /  \    /  \                                        (2)
       (4) (5) (6)  (7)                                         \
                                                                 (3)
                                                                    \
                                                                     (4)
Hình dạngĐịnh nghĩaHeight với n node
Full (strict/proper)Mọi node có 0 hoặc 2 child — không bao giờ đúng 1Không chặn (vẫn có thể rất cao)
CompleteMọi level đầy trừ có thể level cuối, điền từ trái sang phảiĐúng bằng ⌊log₂ n⌋
PerfectFull mọi leaf ở cùng depthĐúng bằng log₂(n + 1) − 1
BalancedHeight là O(log n); thường hiểu là “height hai subtree lệch nhau ≤ 1 tại mọi node”O(log n)
Degenerate (pathological)Mọi internal node có đúng một childn − 1

Vài phép tính hữu ích rút thẳng ra từ các định nghĩa này:

Complete binary tree có một siêu năng lực riêng: vì không có lỗ hổng nào, nó có thể được lưu trong một mảng phẳng hoàn toàn không cần pointer, chỉ dùng phép tính chỉ số — child của node i nằm ở 2i+12i+2. Đó chính xác là cách binary heap được cài đặt, được triển khai trong ./12-heaps-and-priority-queues.md. Với tree tổng quát (không complete), cách lưu mảng này lãng phí O(2^h) ô cho một hình dạng degenerate, nên nó không phải cách biểu diễn dùng chung được.

Khái niệm chính

Tree traversal

Traversal là phép duyệt qua mọi node đúng một lần theo một thứ tự xác định. Có bốn thứ tự chuẩn, và ba cái đầu chỉ là cùng một phép đệ quy với câu lệnh “visit” được dời sang vị trí khác:

TraversalThứ tựGhi nhớTrên BST cho ra
Pre-orderNode, Left, RightN-L-RRoot trước — tốt để copy/serialize
In-orderLeft, Node, RightL-N-RTăng dần đã sắp xếp
Post-orderLeft, Right, NodeL-R-NChild trước parent — tốt để xoá/tính giá trị
Level-order (BFS)Theo từng level, trái sang phảiHình dạng theo depth
def preorder(node, out):
    """N-L-R: xử lý node trước khi đi xuống."""
    if node is None:
        return
    out.append(node.val)          # visit
    preorder(node.left, out)
    preorder(node.right, out)


def inorder(node, out):
    """L-N-R: trên BST sẽ phát ra các key theo thứ tự tăng dần."""
    if node is None:
        return
    inorder(node.left, out)
    out.append(node.val)          # visit
    inorder(node.right, out)


def postorder(node, out):
    """L-R-N: cả hai child được xử lý xong trước khi tới lượt node."""
    if node is None:
        return
    postorder(node.left, out)
    postorder(node.right, out)
    out.append(node.val)          # visit

Với cây dưới đây:

              (F)
             /   \
          (B)     (G)
         /   \       \
      (A)    (D)     (I)
            /   \    /
          (C)   (E)(H)
TraversalKết quả
Pre-orderF B A D C E G I H
In-orderA B C D E F G H I
Post-orderA C E D B H I G F
Level-orderF B G A D I C E H

Để ý kết quả in-order đúng thứ tự bảng chữ cái — cây đó là một BST.

Khi nào dùng cái nào — đây mới là phần thật sự đáng nhớ:

Traversal dạng lặp

Bản đệ quy ngắn gọn và rõ ràng, và trong Python production thường đó chính là thứ bạn nên dùng — cho tới khi cây trở nên quá sâu. Giới hạn đệ quy mặc định của CPython là 1000 frame, nên một cây degenerate 10.000 node sẽ ném RecursionError. Bản lặp dùng stack tường minh trên heap và không bị giới hạn đó.

def preorder_iterative(root):
    """Stack tường minh. Push right trước left để left được pop ra trước."""
    if root is None:
        return []
    out, stack = [], [root]
    while stack:
        node = stack.pop()
        out.append(node.val)
        if node.right:
            stack.append(node.right)
        if node.left:
            stack.append(node.left)
    return out


def inorder_iterative(root):
    """Đi hết sang trái và push vào stack; rồi pop, visit, và rẽ phải."""
    out, stack, cur = [], [], root
    while cur is not None or stack:
        while cur is not None:
            stack.append(cur)
            cur = cur.left
        cur = stack.pop()
        out.append(cur.val)       # visit
        cur = cur.right
    return out


def postorder_iterative(root):
    """Mẹo: N-R-L chính là pre-order với hai child đảo chỗ; đảo ngược nó ra L-R-N."""
    if root is None:
        return []
    out, stack = [], [root]
    while stack:
        node = stack.pop()
        out.append(node.val)
        if node.left:
            stack.append(node.left)
        if node.right:
            stack.append(node.right)
    out.reverse()
    return out

Mẹo post-order đáng để hiểu chứ không nên học thuộc: đảo ngược N-R-L sẽ ra L-R-N. Bản post-order “thật thà” một lượt cần thêm con trỏ last_visited hoặc cặp (node, visited_flag) trên stack, và đó mới là bản cần viết nếu bạn phải xử lý node ngay lúc pop thay vì gom thành list.

Level-order traversal (BFS)

Level-order là traversal duy nhất không phải là một phép đệ quy được sắp xếp lại. Nó cần queue thay vì stack (xem ./06-stacks-and-queues.md):

from collections import deque

def level_order(root):
    """Trả về danh sách phẳng các giá trị, theo từng level, trái sang phải."""
    if root is None:
        return []
    out, q = [], deque([root])
    while q:
        node = q.popleft()        # popleft() là O(1) trên deque; list.pop(0) là O(n)
        out.append(node.val)
        if node.left:
            q.append(node.left)
        if node.right:
            q.append(node.right)
    return out


def level_order_grouped(root):
    """Cùng phép duyệt, nhưng trả về một list cho mỗi level — dạng thường dùng thật sự."""
    if root is None:
        return []
    levels, q = [], deque([root])
    while q:
        level_size = len(q)       # chụp nhanh: đúng bằng số node ở depth hiện tại
        level = []
        for _ in range(level_size):
            node = q.popleft()
            level.append(node.val)
            if node.left:
                q.append(node.left)
            if node.right:
                q.append(node.right)
        levels.append(level)
    return levels

Việc chụp level_size = len(q) ở đầu vòng lặp là idiom chuẩn để tách các level: tại thời điểm đó queue chứa đúng các node của một depth, nên tiêu thụ đúng chừng ấy node là xử lý xong đúng một level.

DFS so với BFS trên tree

Cả hai đều duyệt hết mọi node, cả hai đều O(n). Khác biệt nằm ở bộ nhớthứ tự phát hiện.

DFS (pre/in/post-order)BFS (level-order)
Cấu trúc phụ trợStack (tường minh hoặc call stack)Queue
Bộ nhớ phụ đỉnh điểmO(h) — chiều caoO(w) — bề rộng lớn nhất
Trên cây balancedO(log n)O(n) — riêng level cuối đã ~n/2 node
Trên cây degenerateO(n)O(1)
Tìm ra kết quả nông nhất trướcKhông
Hợp với”Khám phá hết nhánh này”, tổng hợp theo subtree”Cách root bao xa”, số bước ngắn nhất
Thân thiện với đệ quyKhông (cần queue tường minh)

Quy tắc thực dụng: DFS rẻ hơn về bộ nhớ trên loại cây bạn thường gặp (tương đối cân bằng, nên O(log n) so với O(n)), nhưng BFS là cách duy nhất tìm ra đáp án nông nhất trước. Nếu câu hỏi là “cần tối thiểu bao nhiêu bước để tới X”, hãy dùng BFS. Điều này mở rộng thẳng sang graph, nơi BFS trên unweighted graph cho ra shortest path — xem ./13-graph-data-structures.md./14-shortest-path-algorithms.md.

Một lưu ý thực tế về bộ nhớ của BFS: trên perfect binary tree n node, level sâu nhất chứa khoảng n/2 node, tất cả đều nằm trong queue cùng lúc. BFS trên cây rộng có thể ngốn nhiều bộ nhớ hơn cả bản thân cái cây. DFS thì không bao giờ giữ nhiều hơn một đường root-tới-leaf.

Các phép tính thường gặp trên tree

Gần như mọi câu hỏi về tree đều là một phép post-order traversal có trả về giá trị thay vì append vào list:

def height(node):
    """Height tính bằng EDGE. Cây rỗng trả -1 để cây một node là 0. O(n) thời gian, O(h) bộ nhớ."""
    if node is None:
        return -1
    return 1 + max(height(node.left), height(node.right))


def size(node):
    """Số node. O(n)."""
    if node is None:
        return 0
    return 1 + size(node.left) + size(node.right)


def diameter(root):
    """Đường dài nhất (theo edge) giữa hai node bất kỳ. O(n) — một lượt, không phải O(n^2)."""
    best = 0

    def depth(node):
        nonlocal best
        if node is None:
            return -1
        left = depth(node.left)
        right = depth(node.right)
        # Đường dài nhất ĐI QUA node này, làm ứng viên cho kết quả toàn cục
        best = max(best, left + right + 2)
        return 1 + max(left, right)

    depth(root)
    return best


def is_balanced(root):
    """Cân bằng theo nghĩa AVL: |height(L) - height(R)| <= 1 ở mọi nơi. O(n)."""
    def check(node):
        # Trả về height, hoặc None làm sentinel nghĩa là "phía dưới đã mất cân bằng"
        if node is None:
            return -1
        left = check(node.left)
        if left is None:
            return None
        right = check(node.right)
        if right is None:
            return None
        if abs(left - right) > 1:
            return None
        return 1 + max(left, right)

    return check(root) is not None

Hai hàm diameteris_balanced minh hoạ một pattern đáng thấm: bản ngây thơ gọi height() tại mọi node, cho ra O(n²) (hoặc O(n log n) trên cây cân bằng). Trả height kèm theo đáp án từ một lượt post-order duy nhất biến nó thành O(n). Mỗi khi một lời giải trên tree trông có vẻ bậc hai, hãy tự hỏi phép đệ quy có thể trả cái gì lên trên để khỏi tính lại.

Binary search tree

Binary search tree (BST) là binary tree có thêm một ràng buộc về thứ tự:

Với mọi node x: mọi key trong subtree trái của x đều < x.key, và mọi key trong subtree phải đều > x.key.

Ràng buộc phải đúng tại mọi node, không chỉ ở root — một lỗi phổ biến là chỉ kiểm tra child trực tiếp, khiến những cây như 50 → left 30 → right 60 được chấp nhận (60 nằm trong subtree trái của 50 nhưng lại lớn hơn 50, nên đây không phải BST dù 30 < 5030 < 60).

Hai hệ quả khiến BST trở nên hữu dụng:

  1. Search là một chuỗi quyết định hai nhánh, nên tốn O(h) — logarit khi cây cân bằng.
  2. In-order traversal phát ra các key theo thứ tự đã sắp xếp, miễn phí, trong O(n). Đây là thứ BST cho bạn mà hash table không bao giờ cho: duyệt có thứ tự, range query, predecessor/successor, min/max.
class BST:
    """Binary search tree thuần, không tự cân bằng, key phân biệt."""

    def __init__(self):
        self.root = None

    # ---- search -------------------------------------------------------
    def search(self, key):
        """Dạng lặp — không lo giới hạn đệ quy. O(h)."""
        node = self.root
        while node is not None:
            if key == node.val:
                return node
            node = node.left if key < node.val else node.right
        return None

    def min_node(self, node):
        """Node trái nhất của một subtree. O(h)."""
        while node.left is not None:
            node = node.left
        return node

    # ---- insert -------------------------------------------------------
    def insert(self, key):
        """Key mới luôn trở thành leaf — đường search CHÍNH LÀ đường insert."""
        self.root = self._insert(self.root, key)

    def _insert(self, node, key):
        if node is None:
            return Node(key)
        if key < node.val:
            node.left = self._insert(node.left, key)
        elif key > node.val:
            node.right = self._insert(node.right, key)
        # key == node.val: trùng, bỏ qua (hoặc giữ thêm một field đếm)
        return node

    # ---- delete -------------------------------------------------------
    def delete(self, key):
        self.root = self._delete(self.root, key)

    def _delete(self, node, key):
        if node is None:
            return None
        if key < node.val:
            node.left = self._delete(node.left, key)
        elif key > node.val:
            node.right = self._delete(node.right, key)
        else:
            # Case 1 & 2: không có hoặc có một child — kéo child lên thế chỗ.
            if node.left is None:
                return node.right     # bao luôn trường hợp "không child" (trả None)
            if node.right is None:
                return node.left
            # Case 3: hai child. Thay key của node này bằng in-order successor
            # (key nhỏ nhất trong subtree phải), rồi xoá successor đó khỏi
            # subtree phải — nơi nó có tối đa một child.
            succ = self.min_node(node.right)
            node.val = succ.val
            node.right = self._delete(node.right, succ.val)
        return node

    def inorder(self):
        out = []
        inorder(self.root, out)
        return out

Ba trường hợp delete, viết ra cụ thể vì đây là chỗ các bản cài đặt BST hay sai:

Case 1 — leaf (không child): chỉ việc bỏ đi.

      (50)                 (50)
      /  \        =>       /  \
   (30)  (70)           (30)  (70)
        /                    /
     (60)   xoá 60         -

Case 2 — một child: kéo child lên đúng vị trí node bị xoá.

      (50)                 (50)
      /  \        =>       /  \
   (30)  (70)           (30)  (60)
         /
      (60)   xoá 70

Case 3 — hai child: chép key của in-order successor vào đây,
         rồi xoá successor đó khỏi subtree phải.

      (50)                 (60)
      /  \        =>       /  \
   (30)  (70)           (30)  (70)
         /  \                    \
      (60)  (80)                 (80)
        xoá 50

Successor ở case 3 là node trái nhất của subtree phải — key nhỏ nhất mà vẫn lớn hơn key bị xoá. Vì nó trái nhất nên nó không có left child, nên việc xoá nó luôn rơi vào case 1 hoặc case 2, và phép đệ quy kết thúc. Dùng in-order predecessor (node phải nhất của subtree trái) cũng đúng như vậy; luôn chọn cùng một bên là nguyên nhân đã biết gây lệch cây về dài hạn, và luân phiên hai bên là cách giảm thiểu rẻ tiền cho một BST thuần.

Vì sao BST không cân bằng suy biến về O(n)

Mọi thao tác BST đều tốn O(h), với h là height. Cái O(log n) bạn được hứa chỉ đúng khi h ≈ log n. Không có gì trong thuật toán insert của BST bắt buộc điều đó.

Insert 1, 2, 3, 4, 5 vào một BST rỗng theo đúng thứ tự đó:

insert 1:  (1)

insert 2:  (1)          2 > 1, rẽ phải
              \
              (2)

insert 3:  (1)          3 > 1 -> phải; 3 > 2 -> phải
              \
              (2)
                \
                (3)

insert 5:  (1)
              \
              (2)
                \
                (3)
                  \
                  (4)
                    \
                    (5)          height = 4 = n - 1

Cây trở thành một linked list kèm thêm chi phí bộ nhớ. search(5) phải đi qua mọi node: O(n). Tất cả cấu trúc đó chỉ mua cho bạn một left pointer bỏ không trên mỗi node.

Và đây không phải một worst case gượng ép — dữ liệu đã sắp xếp là hình dạng phổ biến nhất của dữ liệu thật. ID tự tăng, timestamp, một table nạp từ bản export đã sắp xếp, một log replay theo thứ tự: mỗi cái đều dựng nên một BST suy biến tối đa.

Thao tácBST cân bằngBST degenerateVì sao
SearchO(log n)O(n)Chi phí là O(h); hlog n so với n − 1
InsertO(log n)O(n)Insert = search vị trí + nối link O(1)
DeleteO(log n)O(n)Search + tìm successor, cả hai đều O(h)
Min / maxO(log n)O(n)Đi tới leaf trái nhất/phải nhất
Predecessor / successorO(log n)O(n)O(h)
In-order traversalO(n)O(n)Dù sao cũng phải thăm mọi node
Bộ nhớO(n)O(n)Hai pointer mỗi node ở cả hai trường hợp
Bộ nhớ phụ khi traversalO(log n)O(n)Độ sâu stack = height

Thứ tự insert ngẫu nhiên cho height kỳ vọng khoảng 2 ln n ≈ 1.39 log₂ n, tức là ổn — nên BST được nạp key thật sự ngẫu nhiên hoạt động tốt trung bình. Nhưng bạn không thể trông cậy input của mình là ngẫu nhiên, và một kẻ tấn công (hoặc một bản export ORDER BY id) sẽ trao cho bạn đúng worst case. Cách sửa là dùng cây tự cân bằng lại sau mỗi lần thay đổi: AVL tree, red-black tree, B-tree, hoặc skip list. Đó là toàn bộ nội dung của ./11-balanced-and-multiway-trees.md.

BST so với hash table

Đáng nêu rõ, vì lựa chọn này xuất hiện liên tục:

Hash table (./07-hash-tables.md)BST cân bằng
Lookup / insert / deleteO(1) trung bình, O(n) worst caseO(log n) đảm bảo
Duyệt có thứ tựKhông (hoặc chỉ theo thứ tự insert)Có, O(n) in-order
Range query [a, b]O(n) — phải quét hếtO(log n + k)
Min / max / successorO(n)O(log n)
Overhead bộ nhớPhần dư theo load factor + bucket2 pointer + metadata cân bằng mỗi node
Độ tin cậy của worst caseKém (collision, khựng khi rehash)Tốt — không có đỉnh nhọn do amortization

Dùng hash table khi bạn chỉ bao giờ hỏi “key chính xác này có tồn tại không”. Dùng BST cân bằng (Python không có sẵn; sortedcontainers.SortedDict hoặc bisect trên list là hai thay thế thực dụng) khi bạn cần thứ tự — range scan, “cuộc hẹn kế tiếp sau 3 giờ chiều”, bảng xếp hạng, hoặc bất cứ thứ gì có BETWEEN. Đây cũng chính xác là lý do database index bằng B+ tree thay vì hash table theo mặc định: WHERE created_at BETWEEN ... AND ...ORDER BY đều là thao tác theo range.

Best Practices

Tài liệu tham khảo

Part of the Data Structures & Algorithms Roadmap knowledge base.

Overview

Every structure covered so far — arrays, linked lists, stacks, queues, hash tables — is linear: elements sit in a sequence, and “next” means exactly one thing. A tree is the first genuinely non-linear structure. Each node can point at several children, so “next” branches, and the result is a hierarchy: a shape that naturally models containment, classification, and decomposition.

Hierarchies are everywhere in computing, which is why trees are everywhere too:

The reason trees earn a chapter of their own is not just modelling, though. It is the O(log n). A linear structure forces you to walk past k elements to reach the k-th one. A tree with a branching factor of 2 lets you halve the remaining candidates at every step, so n elements are reachable in about log₂ n steps. A million items is 20 steps; a billion is 30. That is the same trick binary search plays on a sorted array (see ./09-search-algorithms.md), except a tree keeps the property while supporting cheap insertion and deletion, which a sorted array does not.

The catch — and it is the whole subject of the next note — is that the O(log n) only holds while the tree stays bushy. A binary search tree that has been fed sorted data collapses into a linked list and every operation degrades to O(n). This note builds the vocabulary, the traversals, and the plain binary search tree; the next one fixes the degeneracy.

Fundamentals

Terminology

A tree is a set of nodes connected by edges, with exactly one node designated the root, and with the constraint that every non-root node has exactly one parent. Equivalently: a tree is a connected acyclic graph with a distinguished root. A collection of disjoint trees is a forest.

                  (A)          <- root, depth 0
                 /   \
              (B)     (C)      <- B and C are siblings; children of A
             /   \       \
          (D)    (E)     (F)   <- depth 2
                 /
              (G)              <- leaf, depth 3
TermMeaningIn the diagram
RootThe single node with no parentA
ParentThe node one level upparent of E is B
ChildA node one level downchildren of B are D, E
SiblingsNodes sharing a parentB and C; D and E
Leaf (external node)A node with no childrenD, G, F
Internal nodeA node with at least one childA, B, C, E
EdgeA parent→child linkA–B
PathA sequence of edges between two nodesA → B → E → G
AncestorAny node on the path up to the rootancestors of G: E, B, A
DescendantAny node reachable going downdescendants of B: D, E, G
SubtreeA node plus all its descendantssubtree rooted at B = {B, D, E, G}
Degree of a nodeNumber of childrendegree of A = 2, of C = 1
Depth of a nodeNumber of edges from the root down to itdepth of G = 3
Height of a nodeNumber of edges on the longest path down to a leafheight of B = 2
Height of the treeHeight of the root3
LevelAll nodes at the same depthlevel 2 = {D, E, F}

Two conventions cause endless confusion, so pin them down:

Two structural facts worth memorizing: a tree with n nodes has exactly n − 1 edges (every node except the root contributes exactly one edge to its parent), and there is exactly one simple path between any two nodes.

Representing a tree in code

The general (n-ary) case is a node holding a value and a list of children:

class TreeNode:
    """A general n-ary tree node: one value, any number of children."""
    __slots__ = ("value", "children")

    def __init__(self, value, children=None):
        self.value = value
        self.children = children if children is not None else []

    def add(self, child):
        self.children.append(child)
        return child


# Model a small filesystem
root = TreeNode("/")
usr = root.add(TreeNode("usr"))
etc = root.add(TreeNode("etc"))
usr.add(TreeNode("bin"))
usr.add(TreeNode("lib"))
etc.add(TreeNode("hosts"))

Note there is no parent pointer here. Adding one (self.parent) makes upward navigation O(1) and is essential for operations like “find the path back to the root” or “find the next in-order node without a stack”, but it costs a word per node and creates a reference cycle that a naive reference-counting collector would leak. Add it when you need it, not by default.

Binary trees

A binary tree restricts every node to at most two children, named left and right. The order matters: a node with only a left child is a different tree from a node with only a right child, even though both have one child.

class Node:
    """A binary tree node."""
    __slots__ = ("val", "left", "right")

    def __init__(self, val, left=None, right=None):
        self.val = val
        self.left = left
        self.right = right

Why restrict to two? Because two is the smallest branching factor that still halves the problem, and because a two-way decision (less / greater) is the natural output of a comparison. Higher fan-outs are useful, but for a different reason — minimizing disk reads rather than comparisons — which is exactly what B-trees do.

Shapes of binary trees

The names below get used loosely in the wild; these are the standard definitions.

    full (every node has 0 or 2 children)      complete (all levels full except
              (1)                               possibly the last, filled left-to-right)
             /   \                                        (1)
          (2)     (3)                                    /   \
                 /   \                                (2)     (3)
              (4)     (5)                            /   \    /
                                                  (4)   (5) (6)

    perfect (full AND all leaves at the        degenerate (each node has one child —
             same depth)                                   effectively a linked list)
              (1)                                        (1)
             /   \                                          \
          (2)     (3)                                        (2)
         /  \    /  \                                           \
       (4) (5) (6)  (7)                                          (3)
                                                                    \
                                                                     (4)
ShapeDefinitionHeight with n nodes
Full (strict/proper)Every node has 0 or 2 children — never exactly 1Unbounded (can still be tall)
CompleteEvery level full except possibly the last, which fills left to rightExactly ⌊log₂ n⌋
PerfectFull and every leaf at the same depthExactly log₂(n + 1) − 1
BalancedHeight is O(log n); usually “subtree heights differ by ≤ 1 at every node”O(log n)
Degenerate (pathological)Every internal node has exactly one childn − 1

Useful arithmetic that falls straight out of these definitions:

Complete binary trees have a special superpower: because there are no gaps, they can be stored in a flat array with no pointers at all, using index arithmetic — node i’s children are at 2i+1 and 2i+2. That is exactly how a binary heap is implemented, and it is developed in ./12-heaps-and-priority-queues.md. For a general (non-complete) tree, this array layout wastes O(2^h) slots on a degenerate shape, so it is not a general-purpose representation.

Key Concepts

Tree traversal

A traversal visits every node exactly once in some defined order. There are four standard orders, and the first three are the same recursive walk with the “visit” statement moved to a different place:

TraversalOrderMnemonicOn a BST it yields
Pre-orderNode, Left, RightN-L-RRoot first — good for copying/serializing
In-orderLeft, Node, RightL-N-RSorted ascending
Post-orderLeft, Right, NodeL-R-NChildren before parent — good for deletion/evaluation
Level-order (BFS)Level by level, left to rightShape by depth
def preorder(node, out):
    """N-L-R: visit the node before descending."""
    if node is None:
        return
    out.append(node.val)          # visit
    preorder(node.left, out)
    preorder(node.right, out)


def inorder(node, out):
    """L-N-R: on a BST this emits the keys in ascending order."""
    if node is None:
        return
    inorder(node.left, out)
    out.append(node.val)          # visit
    inorder(node.right, out)


def postorder(node, out):
    """L-R-N: both children are fully processed before the node itself."""
    if node is None:
        return
    postorder(node.left, out)
    postorder(node.right, out)
    out.append(node.val)          # visit

For the tree below:

              (F)
             /   \
          (B)     (G)
         /   \       \
      (A)    (D)     (I)
            /   \    /
          (C)   (E)(H)
TraversalOutput
Pre-orderF B A D C E G I H
In-orderA B C D E F G H I
Post-orderA C E D B H I G F
Level-orderF B G A D I C E H

Notice the in-order output is alphabetical — that tree is a BST.

When to use which — this is the part that is actually worth remembering:

Iterative traversals

The recursive versions are short and clear, and in production Python they are usually what you want — until the tree is deep. CPython’s default recursion limit is 1000 frames, so a degenerate tree of 10,000 nodes raises RecursionError. Iterative versions use an explicit stack on the heap and have no such limit.

def preorder_iterative(root):
    """Explicit stack. Push right before left so left is popped first."""
    if root is None:
        return []
    out, stack = [], [root]
    while stack:
        node = stack.pop()
        out.append(node.val)
        if node.right:
            stack.append(node.right)
        if node.left:
            stack.append(node.left)
    return out


def inorder_iterative(root):
    """Walk left as far as possible, pushing; then pop, visit, and go right."""
    out, stack, cur = [], [], root
    while cur is not None or stack:
        while cur is not None:
            stack.append(cur)
            cur = cur.left
        cur = stack.pop()
        out.append(cur.val)       # visit
        cur = cur.right
    return out


def postorder_iterative(root):
    """Trick: N-R-L is pre-order with the children swapped; reverse it to get L-R-N."""
    if root is None:
        return []
    out, stack = [], [root]
    while stack:
        node = stack.pop()
        out.append(node.val)
        if node.left:
            stack.append(node.left)
        if node.right:
            stack.append(node.right)
    out.reverse()
    return out

The post-order trick is worth understanding rather than memorizing: reversing N-R-L gives L-R-N. The “honest” single-pass post-order needs a last_visited pointer or a (node, visited_flag) pair on the stack, and is the version to write if you need to process nodes as you pop them rather than collecting a list.

Level-order traversal (BFS)

Level-order is the one traversal that is not a rearranged recursion. It needs a queue instead of a stack (see ./06-stacks-and-queues.md):

from collections import deque

def level_order(root):
    """Returns a flat list of values, level by level, left to right."""
    if root is None:
        return []
    out, q = [], deque([root])
    while q:
        node = q.popleft()        # popleft() is O(1) on a deque; list.pop(0) is O(n)
        out.append(node.val)
        if node.left:
            q.append(node.left)
        if node.right:
            q.append(node.right)
    return out


def level_order_grouped(root):
    """Same walk, but returns one list per level — the usual form you actually want."""
    if root is None:
        return []
    levels, q = [], deque([root])
    while q:
        level_size = len(q)       # snapshot: exactly the nodes at the current depth
        level = []
        for _ in range(level_size):
            node = q.popleft()
            level.append(node.val)
            if node.left:
                q.append(node.left)
            if node.right:
                q.append(node.right)
        levels.append(level)
    return levels

The level_size = len(q) snapshot at the top of the loop is the standard idiom for separating levels: at that instant the queue contains exactly the nodes of one depth, so consuming that many nodes processes exactly one level.

DFS versus BFS on a tree

Both visit every node, both are O(n) time. The difference is memory and order of discovery.

DFS (pre/in/post-order)BFS (level-order)
Auxiliary structureStack (explicit or the call stack)Queue
Peak auxiliary spaceO(h) — the heightO(w) — the maximum width
On a balanced treeO(log n)O(n) — the last level alone is ~n/2 nodes
On a degenerate treeO(n)O(1)
Finds the shallowest match firstNoYes
Natural fit”Explore this branch fully”, subtree aggregates”How far from the root”, shortest hop count
Recursion-friendlyYesNo (needs an explicit queue)

The rule of thumb: DFS is cheaper in memory on the trees you usually have (balanced-ish, so O(log n) versus O(n)), but BFS is the only one that finds the shallowest answer first. If the question is “what is the minimum number of steps to X”, use BFS. This generalizes directly to graphs, where BFS on an unweighted graph gives shortest paths — see ./13-graph-data-structures.md and ./14-shortest-path-algorithms.md.

One practical caveat about BFS’s space: on a perfect binary tree of n nodes, the deepest level holds about n/2 nodes, all of which are queued simultaneously. A BFS over a wide tree can easily use more memory than the tree itself. DFS never holds more than one root-to-leaf path.

Common tree computations

Almost every tree question is a post-order traversal that returns something instead of appending to a list:

def height(node):
    """Height in EDGES. Empty tree is -1 so a single node is 0. O(n) time, O(h) space."""
    if node is None:
        return -1
    return 1 + max(height(node.left), height(node.right))


def size(node):
    """Number of nodes. O(n)."""
    if node is None:
        return 0
    return 1 + size(node.left) + size(node.right)


def diameter(root):
    """Longest path (in edges) between any two nodes. O(n) — one pass, not O(n^2)."""
    best = 0

    def depth(node):
        nonlocal best
        if node is None:
            return -1
        left = depth(node.left)
        right = depth(node.right)
        # The longest path THROUGH this node, as a candidate for the global best
        best = max(best, left + right + 2)
        return 1 + max(left, right)

    depth(root)
    return best


def is_balanced(root):
    """Height-balanced in the AVL sense: |height(L) - height(R)| <= 1 everywhere. O(n)."""
    def check(node):
        # Returns the height, or None as a sentinel meaning "already unbalanced below"
        if node is None:
            return -1
        left = check(node.left)
        if left is None:
            return None
        right = check(node.right)
        if right is None:
            return None
        if abs(left - right) > 1:
            return None
        return 1 + max(left, right)

    return check(root) is not None

The diameter and is_balanced implementations illustrate a pattern worth internalizing: the naive version calls height() at every node, giving O(n²) (or O(n log n) on a balanced tree). Returning the height along with the answer from a single post-order pass makes it O(n). Whenever a tree solution looks quadratic, ask what the recursion could return upward to avoid recomputation.

Binary search trees

A binary search tree (BST) is a binary tree with an ordering invariant:

For every node x: every key in x’s left subtree is < x.key, and every key in x’s right subtree is > x.key.

The invariant must hold at every node, not just the root — a common bug is checking only the immediate children, which accepts trees like 50 → left 30 → right 60 (60 is in the left subtree of 50 but is larger than 50, so it is not a BST even though 30 < 50 and 30 < 60).

Two consequences make the BST useful:

  1. Search is a sequence of two-way decisions, so it costs O(h) — logarithmic when the tree is balanced.
  2. In-order traversal emits the keys in sorted order, for free, in O(n). This is what a BST gives you that a hash table never will: ordered iteration, range queries, predecessor/successor, min/max.
class BST:
    """A plain, unbalanced binary search tree with distinct keys."""

    def __init__(self):
        self.root = None

    # ---- search -------------------------------------------------------
    def search(self, key):
        """Iterative — no recursion depth risk. O(h)."""
        node = self.root
        while node is not None:
            if key == node.val:
                return node
            node = node.left if key < node.val else node.right
        return None

    def min_node(self, node):
        """Leftmost node of a subtree. O(h)."""
        while node.left is not None:
            node = node.left
        return node

    # ---- insert -------------------------------------------------------
    def insert(self, key):
        """New keys always become leaves — the search path IS the insertion path."""
        self.root = self._insert(self.root, key)

    def _insert(self, node, key):
        if node is None:
            return Node(key)
        if key < node.val:
            node.left = self._insert(node.left, key)
        elif key > node.val:
            node.right = self._insert(node.right, key)
        # key == node.val: duplicate, ignore (or keep a count field)
        return node

    # ---- delete -------------------------------------------------------
    def delete(self, key):
        self.root = self._delete(self.root, key)

    def _delete(self, node, key):
        if node is None:
            return None
        if key < node.val:
            node.left = self._delete(node.left, key)
        elif key > node.val:
            node.right = self._delete(node.right, key)
        else:
            # Case 1 & 2: zero or one child — splice the child up into our place.
            if node.left is None:
                return node.right     # covers "no children" too (returns None)
            if node.right is None:
                return node.left
            # Case 3: two children. Replace this node's key with its in-order
            # successor (smallest key in the right subtree), then delete that
            # successor from the right subtree — where it has at most one child.
            succ = self.min_node(node.right)
            node.val = succ.val
            node.right = self._delete(node.right, succ.val)
        return node

    def inorder(self):
        out = []
        inorder(self.root, out)
        return out

The three delete cases, spelled out because this is where BST implementations go wrong:

Case 1 — leaf (no children): just remove it.

      (50)                 (50)
      /  \        =>       /  \
   (30)  (70)           (30)  (70)
        /                    /
     (60)   delete 60      -

Case 2 — one child: splice the child into the deleted node's position.

      (50)                 (50)
      /  \        =>       /  \
   (30)  (70)           (30)  (60)
         /
      (60)   delete 70

Case 3 — two children: copy the in-order successor's key here,
         then delete the successor from the right subtree.

      (50)                 (60)
      /  \        =>       /  \
   (30)  (70)           (30)  (70)
         /  \                    \
      (60)  (80)                 (80)
        delete 50

Case 3’s successor is the leftmost node of the right subtree — the smallest key strictly greater than the one being removed. Because it is leftmost, it has no left child, so deleting it is always case 1 or case 2, and the recursion terminates. Using the in-order predecessor (rightmost node of the left subtree) works equally well; always picking the same side is a known cause of long-term tree skew, and alternating is a cheap mitigation for a plain BST.

Why an unbalanced BST degenerates to O(n)

Every BST operation costs O(h), where h is the height. The O(log n) you were promised is only true when h ≈ log n. Nothing in the BST insert algorithm enforces that.

Insert 1, 2, 3, 4, 5 into an empty BST in that order:

insert 1:  (1)

insert 2:  (1)          2 > 1, goes right
              \
              (2)

insert 3:  (1)          3 > 1 -> right; 3 > 2 -> right
              \
              (2)
                \
                (3)

insert 5:  (1)
              \
              (2)
                \
                (3)
                  \
                  (4)
                    \
                    (5)          height = 4 = n - 1

The tree is a linked list with extra memory overhead. search(5) visits every node: O(n). All the structure has bought you is a wasted left pointer per node.

And this is not a contrived worst case — sorted input is the single most common shape of real data. Auto-incrementing IDs, timestamps, a table loaded from an already-sorted export, a log replayed in order: every one of them builds a maximally degenerate BST.

OperationBalanced BSTDegenerate BSTWhy
SearchO(log n)O(n)Cost is O(h); h is log n versus n − 1
InsertO(log n)O(n)Insert = search for the position + O(1) link
DeleteO(log n)O(n)Search + successor lookup, both O(h)
Min / maxO(log n)O(n)Walk to the leftmost/rightmost leaf
Predecessor / successorO(log n)O(n)O(h)
In-order traversalO(n)O(n)Must visit every node regardless
SpaceO(n)O(n)Two pointers per node either way
Traversal aux. spaceO(log n)O(n)Stack depth = height

Random insertion order gives an expected height of about 2 ln n ≈ 1.39 log₂ n, which is fine — so a BST fed genuinely random keys behaves well on average. But you cannot rely on your input being random, and an adversary (or a ORDER BY id export) will hand you the worst case. The fix is a tree that rebalances itself on every mutation: AVL trees, red-black trees, B-trees, or a skip list. That is the whole of ./11-balanced-and-multiway-trees.md.

BST versus hash table

Worth stating plainly, because the choice comes up constantly:

Hash table (./07-hash-tables.md)Balanced BST
Lookup / insert / deleteO(1) average, O(n) worstO(log n) guaranteed
Ordered iterationNo (or insertion order only)Yes, O(n) in-order
Range query [a, b]O(n) — must scan everythingO(log n + k)
Min / max / successorO(n)O(log n)
Memory overheadLoad factor slack + buckets2 pointers + balance metadata per node
Worst-case predictabilityPoor (collisions, rehash pauses)Good — no amortization spikes

Use a hash table when you only ever ask “is this exact key present”. Use a balanced BST (Python has no built-in one; sortedcontainers.SortedDict or bisect on a list are the practical stand-ins) when you need order — range scans, “the next appointment after 3pm”, leaderboards, or anything with a BETWEEN. This is also exactly why databases index with B+ trees rather than hash tables by default: WHERE created_at BETWEEN ... AND ... and ORDER BY are range operations.

Best Practices

References