← 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, 202630 phút đọc27 min read

Cây cân bằng & cây nhiều nhánhBalanced & Multiway Trees

Mục lục
  1. Tổng quan
  2. Kiến thức nền tảng
  3. Vì sao cân bằng đáng để trả giá
  4. Rotation — thao tác nguyên thuỷ
  5. Khái niệm chính
  6. AVL tree
  7. Red-black tree
  8. 2-3 tree
  9. B-tree
  10. B+ tree
  11. Skip list
  12. So sánh
  13. Best Practices
  14. Tài liệu tham khảo
Table of contents
  1. Overview
  2. Fundamentals
  3. Why balance is worth paying for
  4. Rotations — the primitive operation
  5. Key Concepts
  6. AVL trees
  7. Red-black trees
  8. 2-3 trees
  9. B-trees
  10. B+ trees
  11. Skip lists
  12. Comparison
  13. Best Practices
  14. References

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

Tổng quan

Note trước kết thúc bằng một vấn đề. Binary search tree cho search, insert và delete O(log n) — nhưng chỉ khi height của nó còn gần log n, và không có gì trong thuật toán BST thuần bắt buộc điều đó. Nạp vào 1, 2, 3, 4, 5 và bạn nhận được một linked list kèm một pointer thừa mỗi node, mọi thao tác thành O(n). Vì dữ liệu đã sắp xếp (ID tự tăng, timestamp, bản export ORDER BY) là hình dạng phổ biến nhất của dữ liệu thật, BST thuần là một cấu trúc mà worst case cũng chính là trường hợp dễ xảy ra nhất.

Cách sửa là khôi phục cân bằng như một phần của mỗi lần thay đổi. Cây tự cân bằng phát hiện khi một lần insert hoặc delete làm một subtree quá cao so với sibling của nó rồi sửa lại — trong O(log n) hoặc tốt hơn — nên O(log n) trở thành đảm bảo chứ không còn là trung bình. Không có bữa trưa miễn phí: bạn trả thêm metadata mỗi node (một height, một bit màu, một số đếm child) và thêm việc mỗi lần ghi. Cái giá đó gần như luôn xứng đáng, và đó là lý do gần như mọi bản cài đặt ordered map bạn dùng trong production đều là cây tự cân bằng.

Note này bao quát bốn họ cấu trúc và một lựa chọn thay thế không phải cây:

Dạng ứng dụng của tất cả những thứ này — cách database thực sự biến các cấu trúc đó thành index — được triển khai trong ./18-indexing.md, và riêng với PostgreSQL thì ở ../../postgresql-dba/vi/08-indexing-strategies.md.

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

Vì sao cân bằng đáng để trả giá

Mọi thao tác BST đều tốn O(h). Toàn bộ cuộc chơi là giữ h ở mức Θ(log n).

nHeight tốt nhất có thểHeight degenerateTỷ lệ chi phí search
1.000~10999100×
1.000.000~20999.99950.000×
1.000.000.000~30~10⁹~33.000.000×

Ở mức một tỷ key, khác biệt giữa cây cân bằng và cây degenerate là 30 phép so sánh so với một tỷ. Đó không phải hằng số nhân; đó là khác biệt giữa một hệ thống chạy được và một hệ thống treo.

Có hai cách để có được đảm bảo đó:

  1. Cân bằng lại tất định — sau mỗi lần thay đổi, kiểm tra một bất biến cấu trúc và sửa lại (AVL, red-black, B-tree). Worst case được đảm bảo.
  2. Ngẫu nhiên hoá — làm cho hình dạng của cấu trúc phụ thuộc vào việc tung đồng xu thay vì thứ tự input, nên không input nào có thể là adversarial (skip list, treap, randomized BST). O(log n) kỳ vọng, còn worst case chỉ là cực kỳ khó xảy ra chứ không phải bất khả.

Cả hai đều xuất hiện trong production. Hướng tất định thống trị ordered map trong bộ nhớ và database; hướng ngẫu nhiên thắng khi code cần đơn giản, lock-free, hoặc concurrent.

Rotation — thao tác nguyên thuỷ

Rotation là một phép tái cấu trúc cục bộ, thời gian hằng số, làm thay đổi height của một subtree trong khi giữ nguyên thứ tự in-order (thứ tự BST). Đây là nguyên thuỷ duy nhất mà AVL, red-black và splay tree được xây dựng từ đó.

   Right rotation quanh y              Left rotation quanh x
   (left child x của y đi lên)         (right child y của x đi lên)

        y                                   x
       / \        rotate_right(y)          / \       rotate_left(x)
      x   T3      -------------->         T1  y      <--------------
     / \          <--------------            / \
    T1  T2         rotate_left(x)           T2  T3

   In-order trước:  T1 x T2 y T3
   In-order sau:    T1 x T2 y T3     <- không đổi, đó chính là điểm mấu chốt

Đọc dãy in-order trên cả hai sơ đồ: đều là T1 x T2 y T3. Tính bất biến đó là lý do rotation an toàn — nó đổi hình dạng mà không đổi thứ tự sắp xếp, nên tính chất BST vẫn còn. Chú ý T2 — subtree “ở giữa” — là cái duy nhất đổi parent; mọi thứ khác chỉ là đổi nhãn.

def rotate_right(y):
    """Left child của y trở thành gốc mới của subtree. O(1)."""
    x = y.left
    t2 = x.right
    x.right = y
    y.left = t2
    update_height(y)      # y giờ nằm thấp hơn, cập nhật nó trước
    update_height(x)
    return x              # gốc subtree mới — caller phải nối lại


def rotate_left(x):
    """Right child của x trở thành gốc mới của subtree. O(1)."""
    y = x.right
    t2 = y.left
    y.left = x
    x.right = t2
    update_height(x)
    update_height(y)
    return y

Quy ước “trả về gốc subtree mới và để caller nối lại” là thứ khiến code insert đệ quy bên dưới chạy được mà không cần parent pointer. Nó đáng để áp dụng ngay cả trong ngôn ngữ mà parent pointer rẻ, vì nó loại bỏ cả một lớp bug dangling pointer.

Khái niệm chính

AVL tree

Đặt tên theo Adelson-Velsky và Landis (1962), BST tự cân bằng đầu tiên. Bất biến rất chặt:

Với mọi node, balance factorheight(left) − height(right) — nằm trong {−1, 0, +1}.

Mỗi node lưu height của nó (hoặc, trong các bản cài đặt tiết kiệm hơn, chỉ hai bit balance factor). Ở đây field height dùng quy ước node: subtree rỗng là 0, một node là 1, đó là điều khiến update_height gọn thành 1 + max(...).

class AVLNode:
    __slots__ = ("key", "left", "right", "height")

    def __init__(self, key):
        self.key = key
        self.left = None
        self.right = None
        self.height = 1        # quy ước node: leaf có height 1, rỗng là 0


def height(node):
    return node.height if node else 0


def balance_factor(node):
    return height(node.left) - height(node.right) if node else 0


def update_height(node):
    node.height = 1 + max(height(node.left), height(node.right))

Bốn trường hợp rotation. Sau một lần insert, tối đa một node trên đường đi ngược lên bị mất cân bằng (|bf| = 2). Chọn cách sửa nào phụ thuộc vào hình dạng của đường gây lỗi — hai edge, mỗi edge rẽ trái hoặc phải:

LL — insert vào subtree TRÁI của left child          sửa: một right rotation
        (30)                        (20)
        /                           /  \
     (20)          ==>           (10)  (30)
     /
  (10)

RR — insert vào subtree PHẢI của right child         sửa: một left rotation
     (10)                           (20)
        \                           /  \
        (20)       ==>           (10)  (30)
           \
           (30)

LR — insert vào subtree PHẢI của left child          sửa: left trên child, rồi right
        (30)              (30)                   (20)
        /                 /                      /  \
     (10)      ==>     (20)         ==>       (10)  (30)
        \              /
        (20)        (10)

RL — insert vào subtree TRÁI của right child         sửa: right trên child, rồi left
     (30)             (30)                     (40)
        \                \                     /  \
        (50)   ==>       (40)      ==>      (30)  (50)
        /                   \
     (40)                   (50)

Cách nhớ: nếu hai edge cùng chiều, một rotation; nếu chúng zigzag, hai rotation. Bước đầu của double rotation biến zigzag thành đường thẳng, và bước thứ hai chính là trường hợp đơn giản.

def avl_insert(node, key):
    """Insert AVL chuẩn. Trả về gốc subtree (có thể mới). O(log n)."""
    # 1. Insert BST thông thường
    if node is None:
        return AVLNode(key)
    if key < node.key:
        node.left = avl_insert(node.left, key)
    elif key > node.key:
        node.right = avl_insert(node.right, key)
    else:
        return node                       # key trùng: không đổi gì

    # 2. Cập nhật height của node này trên đường quay lên
    update_height(node)

    # 3. Cân bằng lại nếu node này đã vượt khoảng cho phép
    bf = balance_factor(node)

    if bf > 1 and key < node.left.key:    # Left-Left
        return rotate_right(node)
    if bf < -1 and key > node.right.key:  # Right-Right
        return rotate_left(node)
    if bf > 1 and key > node.left.key:    # Left-Right
        node.left = rotate_left(node.left)
        return rotate_right(node)
    if bf < -1 and key < node.right.key:  # Right-Left
        node.right = rotate_right(node.right)
        return rotate_left(node)

    return node

Ví dụ chạy tay — insert 10, 20, 30, 40, 50, 25:

insert 10:   (10)                                        cân bằng

insert 20:   (10)                                        bf(10) = -1, OK
                \
                (20)

insert 30:   (10)  bf = -2, case RR      (20)            một left rotation quanh 10
                \                        /  \
                (20)      ======>     (10)  (30)
                   \
                   (30)

insert 40:      (20)                                     bf(30) = -1, bf(20) = -1, OK
               /    \
            (10)    (30)
                       \
                       (40)

insert 50:      (20)                        (20)         bf(30) = -2, case RR
               /    \                      /    \        left rotation quanh 30
            (10)    (30)     ======>    (10)    (40)
                       \                        /  \
                       (40)                  (30)  (50)
                          \
                          (50)

insert 25:      (20)  bf = -2, và 25 đi vào subtree TRÁI của right child
               /    \                            -> case RL
            (10)    (40)
                    /  \
                 (30)  (50)
                 /
              (25)

   bước 1: rotate_right(left của 40 = 30)     bước 2: rotate_left(20)

        (20)                                       (30)
       /    \                                     /    \
    (10)    (40)                               (20)    (40)
            /  \                               /  \       \
         (25)  (50)                         (10) (25)     (50)
            \
            (30)                            cây cuối, height 2, mọi |bf| <= 1

Delete hoạt động theo cùng cách — làm phép delete BST thông thường (ba trường hợp trong ./10-tree-data-structures.md), rồi cân bằng lại trên đường quay lên. Một khác biệt quan trọng: insert cần tối đa một lần cân bằng lại (đơn hoặc kép) để khôi phục bất biến, còn delete có thể cần tới O(log n) lần, vì bỏ một node có thể làm subtree thấp đi và lan mất cân bằng lên tận root. Đây là lý do chính khiến delete trên AVL đắt hơn insert.

Chặn trên của height. Gọi N(h) là số node tối thiểu của một AVL tree height h. Khi đó N(h) = 1 + N(h−1) + N(h−2) — một hệ thức truy hồi Fibonacci — cho ra h < 1.44 · log₂(n + 2) − 0.328. Vậy AVL tree không bao giờ cao hơn cây cân bằng hoàn hảo quá khoảng 44%. Đó là một chặn rất chặt, và là lý do AVL thắng ở workload nặng về lookup.

Red-black tree

Red-black tree là một BST mà mỗi node mang thêm một bit màu, tuân theo năm quy tắc:

  1. Mỗi node hoặc đỏ hoặc đen.
  2. Root là đen.
  3. Mọi leaf (sentinel NIL) là đen.
  4. Child của node đỏ đều đen — không bao giờ có hai node đỏ liên tiếp trên bất kỳ đường nào.
  5. Mọi đường từ một node đi xuống tới bất kỳ leaf NIL nào chứa cùng một số node đen (black-height của node đó).

Quy tắc 4 và 5 cộng lại chặn height: đường root-tới-leaf dài nhất có thể xen kẽ đỏ-đen, còn đường ngắn nhất có thể toàn đen, nên đường dài nhất tối đa gấp đôi đường ngắn nhất. Do đó h ≤ 2 · log₂(n + 1).

Đó là chặn lỏng hơn so với 1.44 log₂ n của AVL, và hệ quả thực tế đúng như bạn dự đoán: red-black tree cao hơn một chút nên lookup cần thêm vài phép so sánh, nhưng cần tái cấu trúc ít hơn nhiều mỗi lần ghi.

AVLRed-black
Chặn height≤ 1.44 log₂ n≤ 2 log₂ n
Rotation mỗi insert≤ 2≤ 2
Rotation mỗi deleteO(log n)≤ 3
Đổi màu mỗi thao táckhông cóO(log n), nhưng rẻ hơn rotation rất nhiều
Metadata mỗi nodeheight (hoặc 2 bit)1 bit
Hợp nhất vớiNặng đọc: lookup chiếm ưu thếNặng ghi hoặc hỗn hợp

Vì sao red-black là lựa chọn phổ biến hơn trong thư viện. Yếu tố quyết định là đường delete. O(log n) rotation mỗi lần delete của AVL, mỗi rotation chạm vào vài node và làm bẩn vài cache line; còn đảm bảo ≤ 3 rotation mỗi delete của red-black thay phần lớn công việc đó bằng các thao tác đổi màu, vốn chỉ là ghi một bit. Kết hợp với footprint nhỏ hơn mỗi node (một bit thay vì cả một field height), red-black cho độ trễ ghi dễ dự đoán hơn, đổi lại vài phép so sánh thêm khi đọc. Với một container đa dụng không biết trước workload sẽ ra sao, đó là mặc định an toàn hơn.

Nơi bạn thực sự sẽ gặp nó:

Cài đặt insert/delete red-black cho đúng là một bài tập thật sự rối rắm (riêng delete có sáu trường hợp), và không phải thứ nên viết lại từ đầu trong production. Hãy hiểu năm bất biến, hiểu chặn height mà chúng tạo ra, rồi dùng thư viện.

2-3 tree

2-3 tree là cầu nối khái niệm giữa binary tree và B-tree, và hiểu nó khiến red-black tree trở nên hiển nhiên. Mỗi internal node hoặc là:

Và quan trọng nhất: mọi leaf nằm ở đúng cùng một depth. Đây là bất biến cân bằng hoàn hảo, mạnh hơn AVL.

                 [ 20 | 40 ]                <- một 3-node: hai key, ba child
                /     |     \
          [10]    [25 | 30]    [50]

Làm sao insert giữ được depth hoàn hảo? Bằng cách mọc lên trên thay vì mọc xuống dưới. Insert luôn rơi vào một leaf:

insert 35 vào [25 | 30]:

     [ 20 | 40 ]                     [ 20 | 30 | 40 ]           [ 30 ]
    /     |     \                   /    |    |     \          /      \
 [10]  [25|30]  [50]   ==>       [10]  [25]  [35]  [50]  ==>  [20]    [40]
                                                              /  \    /   \
        [25|30|35] tràn;              parent tràn;         [10] [25] [35] [50]
        đẩy 30 lên, split thành       đẩy 30 lên tiếp
        [25] và [35]                                        height tăng 1,
                                                            mọi leaf vẫn ngang nhau

2-3 tree với n key có height nằm giữa log₃ nlog₂ n, nên mọi thao tác đều O(log n) trong worst case, hoàn toàn không cần rotation. Cơ chế split-và-đẩy-lên chính là cơ chế mà B-tree dùng, chỉ khác fan-out là 3 thay vì vài trăm.

Mối liên hệ với red-black tree: red-black tree chính là một 2-3-4 tree (2-3 tree tổng quát hoá để cho phép 4-node) được mã hoá dưới dạng nhị phân. Một 3-node được biểu diễn thành một node đen với một red child; một 4-node thành một node đen với hai red child. Quy tắc 5 (black-height bằng nhau trên mọi đường) chính xác là bất biến “mọi leaf cùng depth” của 2-3-4 tree, nhìn qua cách mã hoá đó. Đây là lý do hai cấu trúc có cùng độ phức tạp tiệm cận.

B-tree

B-tree là một 2-3 tree với fan-out được đẩy lên — từ 3 lên hàng trăm hoặc hàng nghìn. Mục tiêu thiết kế khác với mọi cấu trúc từ đầu tới giờ: thay vì tối thiểu hoá số phép so sánh, B-tree tối thiểu hoá số lần đọc block.

Lý do nằm ở phân cấp bộ nhớ. Đọc từ SSD tốn cỡ 100 micro giây; từ đĩa quay, 10 mili giây; từ RAM, ~100 nano giây; từ L1 cache, ~1 nano giây. Và bạn không bao giờ đọc một byte — đĩa trả về nguyên một page 4 KB hoặc 8 KB, RAM trả về nguyên một cache line 64 byte, dù bạn có xin hay không. Nên mô hình chi phí thay đổi: số page bị chạm tới mới là thứ quan trọng, còn việc làm bên trong một page đã nạp rồi thì gần như miễn phí.

B-tree với minimum degree t tuân theo:

                        [ 100 | 200 | 300 ]                    <- root, một page
                       /      |     |      \
                      /       |     |       \
        [10|40|70]  [120|150|180]  [220|260]  [340|370|390]    <- mỗi node một page
        /  |  |  \   ...

  Key < 100 đi vào child 0; 100 < key < 200 vào child 1; và cứ thế.

Tính cụ thể: nếu một node lấp đầy một page 8 KB và mỗi entry key-kèm-pointer tốn 32 byte, node chứa được khoảng 250 key, tức fan-out ~250. Khi đó:

HeightSố key địa chỉ hoá được (fan-out 250)
1 (chỉ root)250
2~62.500
3~15.600.000
4~3.900.000.000

Bốn lần đọc đĩa tiếp cận được bốn tỷ key. Và trong thực tế root cùng level ngay dưới nó nằm thường trực trong buffer cache, nên một lần lookup thường chỉ tốn một hoặc hai lần đọc thật. So với một cây nhị phân cân bằng trên 4 tỷ key: height ~32, và mỗi trong 32 lần thăm node đó là một truy cập bộ nhớ hoặc đĩa có thể ngẫu nhiên. Cùng O(log n), nhưng cơ số logarit là 250 thay vì 2, và cơ số đó chính là toàn bộ vấn đề.

class BTreeNode:
    def __init__(self, leaf=True):
        self.keys = []          # đã sắp xếp
        self.children = []      # len(children) == len(keys) + 1 khi không phải leaf
        self.leaf = leaf


class BTree:
    """B-tree kiểu CLRS. t là minimum degree: mỗi node giữ t-1..2t-1 key."""

    def __init__(self, t=3):
        self.t = t
        self.root = BTreeNode(leaf=True)

    def search(self, key, node=None):
        """O(log_t n) lần thăm node; mỗi lần quét tối đa 2t-1 key đã sắp xếp."""
        node = self.root if node is None else node
        i = 0
        while i < len(node.keys) and key > node.keys[i]:
            i += 1
        if i < len(node.keys) and node.keys[i] == key:
            return (node, i)
        if node.leaf:
            return None
        return self.search(key, node.children[i])

    def insert(self, key):
        root = self.root
        if len(root.keys) == 2 * self.t - 1:
            # Root đã đầy: cho cây mọc LÊN TRÊN một level. Đây là cách duy nhất
            # height của B-tree tăng lên, và nó giữ cho mọi leaf ngang nhau.
            new_root = BTreeNode(leaf=False)
            new_root.children.append(root)
            self._split_child(new_root, 0)
            self.root = new_root
        self._insert_non_full(self.root, key)

    def _split_child(self, parent, i):
        """Split parent.children[i] (đang đầy) quanh key trung vị của nó."""
        t = self.t
        full = parent.children[i]
        right = BTreeNode(leaf=full.leaf)
        mid_key = full.keys[t - 1]              # trung vị đi lên parent
        right.keys = full.keys[t:]              # nửa trên ở lại node mới
        full.keys = full.keys[:t - 1]           # nửa dưới ở lại node cũ
        if not full.leaf:
            right.children = full.children[t:]
            full.children = full.children[:t]
        parent.keys.insert(i, mid_key)
        parent.children.insert(i + 1, right)

    def _insert_non_full(self, node, key):
        """Split phòng ngừa: ta chỉ đi xuống vào node còn chỗ trống."""
        i = len(node.keys) - 1
        if node.leaf:
            node.keys.append(None)
            while i >= 0 and key < node.keys[i]:
                node.keys[i + 1] = node.keys[i]
                i -= 1
            node.keys[i + 1] = key
        else:
            while i >= 0 and key < node.keys[i]:
                i -= 1
            i += 1
            if len(node.children[i].keys) == 2 * self.t - 1:
                self._split_child(node, i)
                if key > node.keys[i]:
                    i += 1
            self._insert_non_full(node.children[i], key)

Chi tiết thiết kế đáng chú ý là split phòng ngừa: _insert_non_full split một child đầy ngay trên đường đi xuống, nên nó không bao giờ phải quay ngược lên để lan truyền tràn. Điều đó biến insert thành một lượt đi xuống duy nhất, cực kỳ quan trọng khi mỗi level là một lần đọc đĩa mà nếu không thì bạn phải lặp lại.

B+ tree

B+ tree là biến thể mà database và filesystem thực sự triển khai. Hai thay đổi so với B-tree thuần:

  1. Mọi dữ liệu nằm ở leaf. Internal node chỉ giữ key phân tách — không payload, không con trỏ tới row.
  2. Các leaf được nối thành một doubly linked list.
                        [ 100 | 200 ]                       <- internal: chỉ key phân tách
                       /      |      \
            [ 40 | 70 ]   [ 150 ]    [ 250 | 300 ]
             /   |   \      /   \      /    |     \
           ...  ...  ...  ...   ...  ...   ...   ...

   tầng leaf (mọi key đều xuất hiện ở đây, kèm con trỏ dữ liệu):
   [10,20,40] <-> [50,70] <-> [100,120] <-> [150,180] <-> [200,...] <-> ...
        ^                                                                ^
        `------------------ linked list cho range scan ------------------'

Cả hai thay đổi tồn tại vì cùng một lý do: range query.

Đây là lý do cấu trúc này gần như phổ quát trong các hệ thống lưu trữ:

Hệ thốngCấu trúc
PostgreSQL, MySQL/InnoDB, Oracle, SQL Server — index mặc địnhB+ tree
SQLiteB+ tree cho table, B-tree cho index
NTFS, HFS+, APFS, ext4 (htree), XFS, BtrfsBiến thể B-tree / B+ tree
MongoDB (WiredTiger)B+ tree
Rust std::collections::BTreeMapB-tree — chọn thay vì red-black vì cache locality trong RAM

Dòng cuối mới là dòng thú vị. BTreeMap của Rust là cấu trúc trong bộ nhớ, không dính dáng gì tới đĩa, vậy mà vẫn dùng B-tree — vì đúng lập luận đó áp dụng ở một tầng cao hơn trong phân cấp. Cache line của CPU là 64 byte; nhét vài key vào một node nghĩa là một cache miss mang về đủ dữ liệu cho vài phép so sánh, trong khi red-black tree đi theo pointer phải trả một cache miss mỗi level. B-tree không phải “cấu trúc cho đĩa”; nó là “cấu trúc cho mọi phương tiện có block size khác không”, và đó là mọi phương tiện.

Về cách PostgreSQL phơi bày và tinh chỉnh điều này trong thực tế — CREATE INDEX, index-only scan, fillfactor, bloat, REINDEX — xem ../../postgresql-dba/vi/08-indexing-strategies.md../../postgresql-dba/vi/11-storage-internals-and-vacuum.md. Về góc nhìn cấu trúc dữ liệu của indexing nói chung, bao gồm hash index và ISAM, xem ./18-indexing.md.

Skip list

Skip list từ bỏ hoàn toàn cấu trúc cây. Nó là một sorted linked list có làn cao tốc: một list nền chứa mọi phần tử, và bên trên là một chồng các list ngày càng thưa dần, mỗi phần tử được thăng lên level kế tiếp với xác suất p (thường là 0.5).

level 3:  HEAD ---------------------------------> 30 --------------------> NIL
level 2:  HEAD ---------> 10 -------------------> 30 --------> 50 -------> NIL
level 1:  HEAD ---------> 10 ------> 20 -------> 30 --------> 50 -------> NIL
level 0:  HEAD --> 5 --> 10 --> 15 --> 20 --> 25 --> 30 --> 40 --> 50 --> NIL

Tìm 40: bắt đầu từ trên-trái. Ở level 3, 30 < 40 nên nhảy tới 30; kế tiếp là NIL nên
tụt xuống. Ở level 2, sau 30 là 50 > 40, tụt xuống. Ở level 1, tương tự.
Ở level 0, 30 -> 40. Tìm thấy. Bốn level, vài phép so sánh.

Phép search chính xác là ý tưởng binary search cài đặt bằng pointer: ở mỗi level, đi tới trong khi key kế tiếp còn nhỏ hơn target; khi sắp vượt quá, tụt xuống một level. Vì mỗi level có khoảng một nửa số node của level dưới, có O(log n) level và O(1) bước kỳ vọng mỗi level, cho ra search, insert và delete O(log n) kỳ vọng.

import random


class SkipNode:
    __slots__ = ("key", "forward")

    def __init__(self, key, level):
        self.key = key
        self.forward = [None] * (level + 1)     # một pointer kế tiếp cho mỗi level


class SkipList:
    MAX_LEVEL = 16
    P = 0.5

    def __init__(self):
        self.head = SkipNode(None, self.MAX_LEVEL)
        self.level = 0                          # level cao nhất đang dùng

    def _random_level(self):
        """Tung đồng xu: level k với xác suất p^k. Height kỳ vọng O(log n)."""
        lvl = 0
        while random.random() < self.P and lvl < self.MAX_LEVEL:
            lvl += 1
        return lvl

    def search(self, key):
        """O(log n) kỳ vọng. Đi sang phải khi còn được, tụt xuống khi không."""
        node = self.head
        for i in range(self.level, -1, -1):
            while node.forward[i] and node.forward[i].key < key:
                node = node.forward[i]
        node = node.forward[0]
        return node is not None and node.key == key

    def insert(self, key):
        # `update[i]` = node phải nhất ở level i đứng ngay trước vị trí insert
        update = [self.head] * (self.MAX_LEVEL + 1)
        node = self.head
        for i in range(self.level, -1, -1):
            while node.forward[i] and node.forward[i].key < key:
                node = node.forward[i]
            update[i] = node

        if node.forward[0] and node.forward[0].key == key:
            return                              # đã có sẵn

        lvl = self._random_level()
        if lvl > self.level:
            for i in range(self.level + 1, lvl + 1):
                update[i] = self.head
            self.level = lvl

        new = SkipNode(key, lvl)
        for i in range(lvl + 1):                # chen vào, từng level một
            new.forward[i] = update[i].forward[i]
            update[i].forward[i] = new

Vì sao người ta dùng cái này thay vì cây cân bằng:

Cái giá: chặn kỳ vọng chứ không phải đảm bảo; trung bình tốn thêm O(n) pointer (với p = 0.5, khoảng 2 pointer mỗi node); và cache locality kém hơn B-tree vì mỗi level là một lần đuổi theo pointer.

Nơi bạn sẽ gặp chúng: sorted set của Redis (ZSET kết hợp một hash table cho member → score với một skip list cho range query theo score — ZRANGEBYSCORE chính là phép đi dọc chuỗi leaf), memtable của LevelDBRocksDB, term dictionary của Apache Lucene, và HBase.

So sánh

Cấu trúcSearchInsertDeleteBộ nhớĐảm bảo cân bằngThắng ở đâu
BST thuầnO(log n) TB / O(n) worstnhư trênnhư trênO(n)Không cóChỉ để dạy học
AVLO(log n)O(log n), ≤2 rotationO(log n), O(log n) rotationO(n) + height/nodeh ≤ 1.44 log nNặng đọc, trong bộ nhớ
Red-blackO(log n)O(log n), ≤2 rotationO(log n), ≤3 rotationO(n) + 1 bit/nodeh ≤ 2 log nThư viện đa dụng
2-3 treeO(log n)O(log n)O(log n)O(n)Hoàn hảo (mọi leaf ngang)Nền tảng khái niệm
B-treeO(log_t n) lần thăm nodeO(log_t n)O(log_t n)O(n), page đầy một phầnHoàn hảoThiết bị block, cache
B+ treeO(log_t n)O(log_t n)O(log_t n)O(n)Hoàn hảoDatabase, filesystem, range scan
Skip listO(log n) kỳ vọngO(log n) kỳ vọngO(log n) kỳ vọngO(n), ~2 ptr/nodeXác suấtConcurrency, code đơn giản

Chú ý có hai loại cột worst case khác nhau ẩn ở đây. AVL/red-black/B-tree cho worst case đảm bảo. Skip list cho worst case kỳ vọng — xác suất một skip list trên một triệu key có height vượt 60 còn nhỏ hơn xác suất lỗi phần cứng, nên trong thực tế khác biệt này mang tính triết học, nhưng nó có nghĩa là skip list không thể đưa ra một chặn hard real-time.

Best Practices

Tài liệu tham khảo

Part of the Data Structures & Algorithms Roadmap knowledge base.

Overview

The previous note ended on a problem. A binary search tree gives O(log n) search, insert, and delete — but only while its height stays near log n, and nothing in the plain BST algorithm enforces that. Feed it 1, 2, 3, 4, 5 and you get a linked list with a wasted pointer per node, and every operation becomes O(n). Since sorted input (auto-increment IDs, timestamps, an ORDER BY export) is the single most common shape of real data, the plain BST is a structure whose worst case is also its most likely case.

The fix is to restore balance as part of every mutation. A self-balancing tree detects when an insert or delete has made some subtree too tall relative to its sibling and repairs it — in O(log n) or better — so the O(log n) bound becomes a guarantee rather than an average. There is no free lunch: you pay extra metadata per node (a height, a colour bit, a child count) and extra work per write. That trade is almost always worth it, which is why practically every ordered-map implementation you will use in production is a self-balancing tree.

This note covers four families and one non-tree alternative:

The applied form of all this — how a database actually turns these structures into an index — is developed in ./18-indexing.md and, for PostgreSQL specifically, in ../../postgresql-dba/en/08-indexing-strategies.md.

Fundamentals

Why balance is worth paying for

Every BST operation costs O(h). The whole game is keeping h at Θ(log n).

nBest possible heightDegenerate heightSearch cost ratio
1,000~10999100×
1,000,000~20999,99950,000×
1,000,000,000~30~10⁹~33,000,000×

At a billion keys the difference between a balanced tree and a degenerate one is 30 comparisons versus a billion. That is not a constant factor; it is the difference between a working system and a hung one.

There are two ways to get the guarantee:

  1. Deterministic rebalancing — after each mutation, check a structural invariant and repair it (AVL, red-black, B-tree). Worst case guaranteed.
  2. Randomization — make the structure’s shape depend on coin flips rather than on the input order, so no input can be adversarial (skip lists, treaps, randomized BSTs). Expected O(log n), with the worst case merely astronomically unlikely rather than impossible.

Both appear in production. The deterministic route dominates single-threaded in-memory maps and databases; the randomized route wins when the code needs to be simple, lock-free, or concurrent.

Rotations — the primitive operation

A rotation is a local, constant-time restructuring that changes a subtree’s height while preserving the in-order (BST) ordering. It is the single primitive that AVL, red-black, and splay trees are built from.

   Right rotation about y            Left rotation about x
   (y's left child x moves up)       (x's right child y moves up)

        y                                   x
       / \        rotate_right(y)          / \       rotate_left(x)
      x   T3      -------------->         T1  y      <--------------
     / \          <--------------            / \
    T1  T2         rotate_left(x)           T2  T3

   In-order before:  T1 x T2 y T3
   In-order after:   T1 x T2 y T3     <- unchanged, which is the whole point

Read the in-order sequence off both diagrams: T1 x T2 y T3 in each. That invariance is why rotation is safe — it changes the shape without changing the sorted order, so the BST property survives. Note that T2 — the subtree “in the middle” — is the one that changes parents; everything else just gets relabelled.

def rotate_right(y):
    """y's left child becomes the new subtree root. O(1)."""
    x = y.left
    t2 = x.right
    x.right = y
    y.left = t2
    update_height(y)      # y is now lower, update it first
    update_height(x)
    return x              # new subtree root — the caller must reattach it


def rotate_left(x):
    """x's right child becomes the new subtree root. O(1)."""
    y = x.right
    t2 = y.left
    y.left = x
    x.right = t2
    update_height(x)
    update_height(y)
    return y

The “return the new subtree root and let the caller reattach” convention is what makes the recursive insert code below work without parent pointers. It is worth adopting even in languages where parent pointers are cheap, because it removes a whole class of dangling-pointer bugs.

Key Concepts

AVL trees

Named after Adelson-Velsky and Landis (1962), the first self-balancing BST. The invariant is strict:

For every node, the balance factorheight(left) − height(right) — is in {−1, 0, +1}.

Each node stores its height (or, in tighter implementations, just the two-bit balance factor). Here the stored height uses the node convention: an empty subtree is 0, a single node is 1, which is what makes update_height a clean 1 + max(...).

class AVLNode:
    __slots__ = ("key", "left", "right", "height")

    def __init__(self, key):
        self.key = key
        self.left = None
        self.right = None
        self.height = 1        # node convention: a leaf has height 1, empty is 0


def height(node):
    return node.height if node else 0


def balance_factor(node):
    return height(node.left) - height(node.right) if node else 0


def update_height(node):
    node.height = 1 + max(height(node.left), height(node.right))

The four rotation cases. After an insert, at most one node on the path back up becomes unbalanced (|bf| = 2). Which repair to apply depends on the shape of the offending path — two edges, each going left or right:

LL — inserted into the LEFT subtree of the LEFT child      fix: one right rotation
        (30)                        (20)
        /                           /  \
     (20)          ==>           (10)  (30)
     /
  (10)

RR — inserted into the RIGHT subtree of the RIGHT child    fix: one left rotation
     (10)                           (20)
        \                           /  \
        (20)       ==>           (10)  (30)
           \
           (30)

LR — inserted into the RIGHT subtree of the LEFT child     fix: left on child, then right
        (30)              (30)                   (20)
        /                 /                      /  \
     (10)      ==>     (20)         ==>       (10)  (30)
        \              /
        (20)        (10)

RL — inserted into the LEFT subtree of the RIGHT child     fix: right on child, then left
     (30)             (30)                     (40)
        \                \                     /  \
        (50)   ==>       (40)      ==>      (30)  (50)
        /                   \
     (40)                   (50)

The mnemonic: if the two edges point the same way, one rotation; if they zigzag, two. The double rotation’s first step converts the zigzag into a straight line, and the second step is then the simple case.

def avl_insert(node, key):
    """Standard AVL insert. Returns the (possibly new) subtree root. O(log n)."""
    # 1. Ordinary BST insert
    if node is None:
        return AVLNode(key)
    if key < node.key:
        node.left = avl_insert(node.left, key)
    elif key > node.key:
        node.right = avl_insert(node.right, key)
    else:
        return node                       # duplicate key: no change

    # 2. Update this node's height on the way back up
    update_height(node)

    # 3. Rebalance if this node is now out of range
    bf = balance_factor(node)

    if bf > 1 and key < node.left.key:    # Left-Left
        return rotate_right(node)
    if bf < -1 and key > node.right.key:  # Right-Right
        return rotate_left(node)
    if bf > 1 and key > node.left.key:    # Left-Right
        node.left = rotate_left(node.left)
        return rotate_right(node)
    if bf < -1 and key < node.right.key:  # Right-Left
        node.right = rotate_right(node.right)
        return rotate_left(node)

    return node

Worked example — inserting 10, 20, 30, 40, 50, 25:

insert 10:   (10)                                        balanced

insert 20:   (10)                                        bf(10) = -1, OK
                \
                (20)

insert 30:   (10)  bf = -2, RR case      (20)            one left rotation about 10
                \                        /  \
                (20)      ======>     (10)  (30)
                   \
                   (30)

insert 40:      (20)                                     bf(30) = -1, bf(20) = -1, OK
               /    \
            (10)    (30)
                       \
                       (40)

insert 50:      (20)                        (20)         bf(30) = -2, RR case
               /    \                      /    \        left rotation about 30
            (10)    (30)     ======>    (10)    (40)
                       \                        /  \
                       (40)                  (30)  (50)
                          \
                          (50)

insert 25:      (20)  bf = -2, and 25 goes into the LEFT subtree of the right child
               /    \                            -> RL case
            (10)    (40)
                    /  \
                 (30)  (50)
                 /
              (25)

   step 1: rotate_right(40's left = 30)      step 2: rotate_left(20)

        (20)                                       (30)
       /    \                                     /    \
    (10)    (40)                               (20)    (40)
            /  \                               /  \       \
         (25)  (50)                         (10) (25)     (50)
            \
            (30)                            final tree, height 2, all |bf| <= 1

Deletion works the same way — do the ordinary BST delete (the three cases from ./10-tree-data-structures.md), then rebalance on the way back up. The one difference that matters: an insert needs at most one rebalancing (single or double) to restore the invariant, while a delete can need O(log n) of them, because removing a node can shorten a subtree and cascade the imbalance all the way to the root. This is the main reason AVL deletes are more expensive than AVL inserts.

Height bound. Let N(h) be the minimum number of nodes in an AVL tree of height h. Then N(h) = 1 + N(h−1) + N(h−2) — a Fibonacci recurrence — which gives h < 1.44 · log₂(n + 2) − 0.328. So an AVL tree is never more than about 44% taller than a perfectly balanced tree. That is a tight bound, and it is why AVL wins on lookup-heavy workloads.

Red-black trees

A red-black tree is a BST where every node carries one extra bit of colour, subject to five rules:

  1. Every node is either red or black.
  2. The root is black.
  3. Every leaf (the NIL sentinel) is black.
  4. A red node’s children are both black — no two reds in a row on any path.
  5. Every path from a given node down to any of its descendant NIL leaves contains the same number of black nodes (the node’s black-height).

Rules 4 and 5 together bound the height: the longest root-to-leaf path can alternate red-black, and the shortest can be all black, so the longest is at most twice the shortest. Hence h ≤ 2 · log₂(n + 1).

That is a looser bound than AVL’s 1.44 log₂ n, and the practical consequence is exactly what you would expect: red-black trees are slightly taller, so lookups need a few more comparisons, but they need far less restructuring per write.

AVLRed-black
Height bound≤ 1.44 log₂ n≤ 2 log₂ n
Rotations per insert≤ 2≤ 2
Rotations per deleteO(log n)≤ 3
Colour flips / recolourings per opn/aO(log n), but far cheaper than rotations
Per-node metadataheight (or 2 bits)1 bit
Best forRead-heavy: lookups dominateWrite-heavy or mixed

Why red-black is the more common library choice. The deciding factor is the delete path. AVL’s O(log n) rotations per delete each touch several nodes and dirty several cache lines; red-black’s guaranteed ≤ 3 rotations per delete replaces most of that work with recolourings, which are single-bit writes. Combined with a smaller per-node footprint (one bit versus a full height field), red-black gives more predictable write latency at the cost of a handful of extra comparisons on read. For a general-purpose container that has no idea what its workload will be, that is the safer default.

Where you will actually meet one:

Implementing red-black insert/delete correctly is a genuinely fiddly exercise (the delete has six cases), and it is not something to write from scratch in production. Understand the five invariants, understand the height bound they produce, and use the library.

2-3 trees

A 2-3 tree is the conceptual bridge between binary trees and B-trees, and understanding it makes red-black trees obvious. Every internal node is either:

And crucially: all leaves are at exactly the same depth. This is a perfect-balance invariant, stronger than AVL’s.

                 [ 20 | 40 ]                <- a 3-node: two keys, three children
                /     |     \
          [10]    [25 | 30]    [50]

How can insertion preserve perfect depth? By growing upward instead of downward. Insert always lands in a leaf:

insert 35 into [25 | 30]:

     [ 20 | 40 ]                     [ 20 | 30 | 40 ]           [ 30 ]
    /     |     \                   /    |    |     \          /      \
 [10]  [25|30]  [50]   ==>       [10]  [25]  [35]  [50]  ==>  [20]    [40]
                                                              /  \    /   \
        [25|30|35] overflows;         parent overflows;    [10] [25] [35] [50]
        push 30 up, split into        push 30 up again
        [25] and [35]                                       height grew by 1,
                                                            all leaves still level

A 2-3 tree with n keys has height between log₃ n and log₂ n, so all operations are O(log n) in the worst case, with no rotations at all. The split-and-promote mechanism is the same one B-trees use, just with a fan-out of 3 instead of a few hundred.

The connection to red-black trees: a red-black tree is a 2-3-4 tree (the 2-3 tree generalized to allow 4-nodes) encoded in binary form. A 3-node is represented as a black node with one red child; a 4-node as a black node with two red children. Rule 5 (equal black-height on every path) is exactly the 2-3-4 tree’s “all leaves at the same depth” invariant, seen through that encoding. This is why the two structures have identical asymptotics.

B-trees

A B-tree is a 2-3 tree with the fan-out cranked up — from 3 to hundreds or thousands. The design goal is different from every structure so far: instead of minimizing comparisons, a B-tree minimizes block reads.

The reason is the memory hierarchy. Reading from an SSD costs on the order of 100 microseconds; from a spinning disk, 10 milliseconds; from RAM, ~100 nanoseconds; from L1 cache, ~1 nanosecond. And you never read one byte — the disk delivers a whole 4 KB or 8 KB page, and RAM delivers a whole 64-byte cache line, whether you asked for it or not. So the cost model changes: the number of pages touched is what matters, and the work done inside a page you have already loaded is nearly free.

A B-tree of minimum degree t obeys:

                        [ 100 | 200 | 300 ]                    <- root, one page
                       /      |     |      \
                      /       |     |       \
        [10|40|70]  [120|150|180]  [220|260]  [340|370|390]    <- one page each
        /  |  |  \   ...

  A key < 100 goes to child 0; 100 < key < 200 to child 1; and so on.

Sizing it concretely: if a node fills one 8 KB page and each key-plus-pointer entry costs 32 bytes, a node holds about 250 keys, so the fan-out is ~250. Then:

HeightKeys addressable (fan-out 250)
1 (root only)250
2~62,500
3~15,600,000
4~3,900,000,000

Four disk reads reach four billion keys. And in practice the root and the level below it are permanently in the buffer cache, so a lookup typically costs one or two actual reads. Compare with a balanced binary tree over 4 billion keys: height ~32, and each of those 32 node visits is a potentially random memory or disk access. Same O(log n), but the base of the logarithm is 250 instead of 2, and that base is the entire point.

class BTreeNode:
    def __init__(self, leaf=True):
        self.keys = []          # sorted
        self.children = []      # len(children) == len(keys) + 1 when not a leaf
        self.leaf = leaf


class BTree:
    """CLRS-style B-tree. t is the minimum degree: a node holds t-1..2t-1 keys."""

    def __init__(self, t=3):
        self.t = t
        self.root = BTreeNode(leaf=True)

    def search(self, key, node=None):
        """O(log_t n) node visits; each visit scans <= 2t-1 sorted keys."""
        node = self.root if node is None else node
        i = 0
        while i < len(node.keys) and key > node.keys[i]:
            i += 1
        if i < len(node.keys) and node.keys[i] == key:
            return (node, i)
        if node.leaf:
            return None
        return self.search(key, node.children[i])

    def insert(self, key):
        root = self.root
        if len(root.keys) == 2 * self.t - 1:
            # Root is full: grow the tree UPWARD by one level. This is the only
            # way a B-tree's height ever increases, and it keeps all leaves level.
            new_root = BTreeNode(leaf=False)
            new_root.children.append(root)
            self._split_child(new_root, 0)
            self.root = new_root
        self._insert_non_full(self.root, key)

    def _split_child(self, parent, i):
        """Split parent.children[i] (which is full) around its median key."""
        t = self.t
        full = parent.children[i]
        right = BTreeNode(leaf=full.leaf)
        mid_key = full.keys[t - 1]              # the median moves up into the parent
        right.keys = full.keys[t:]              # upper half stays in the new node
        full.keys = full.keys[:t - 1]           # lower half stays in the old node
        if not full.leaf:
            right.children = full.children[t:]
            full.children = full.children[:t]
        parent.keys.insert(i, mid_key)
        parent.children.insert(i + 1, right)

    def _insert_non_full(self, node, key):
        """Pre-emptive splitting: we only ever descend into a node with room."""
        i = len(node.keys) - 1
        if node.leaf:
            node.keys.append(None)
            while i >= 0 and key < node.keys[i]:
                node.keys[i + 1] = node.keys[i]
                i -= 1
            node.keys[i + 1] = key
        else:
            while i >= 0 and key < node.keys[i]:
                i -= 1
            i += 1
            if len(node.children[i].keys) == 2 * self.t - 1:
                self._split_child(node, i)
                if key > node.keys[i]:
                    i += 1
            self._insert_non_full(node.children[i], key)

The design detail worth noticing is pre-emptive splitting: _insert_non_full splits a full child on the way down, so it never has to walk back up to propagate an overflow. That turns insertion into a single downward pass, which matters enormously when each level is a disk read you would otherwise have to repeat.

B+ trees

A B+ tree is the variant that databases and filesystems actually deploy. Two changes from a plain B-tree:

  1. All data lives in the leaves. Internal nodes hold only separator keys — no payloads, no row pointers.
  2. The leaves are chained into a doubly linked list.
                        [ 100 | 200 ]                       <- internal: separators only
                       /      |      \
            [ 40 | 70 ]   [ 150 ]    [ 250 | 300 ]
             /   |   \      /   \      /    |     \
           ...  ...  ...  ...   ...  ...   ...   ...

   leaf level (every key appears here, with its data pointer):
   [10,20,40] <-> [50,70] <-> [100,120] <-> [150,180] <-> [200,...] <-> ...
        ^                                                                ^
        `------------------ linked list for range scans ----------------'

Both changes exist for the same reason: range queries.

This is why the structure is essentially universal in storage systems:

SystemStructure
PostgreSQL, MySQL/InnoDB, Oracle, SQL Server — default indexB+ tree
SQLiteB+ tree for tables, B-tree for indexes
NTFS, HFS+, APFS, ext4 (htree), XFS, BtrfsB-tree / B+ tree variants
MongoDB (WiredTiger)B+ tree
Rust std::collections::BTreeMapB-tree — chosen over red-black for cache locality in RAM

That last row is the interesting one. Rust’s BTreeMap is an in-memory structure, with no disk in sight, and it still uses a B-tree — because the same argument applies one level up the hierarchy. A CPU cache line is 64 bytes; packing several keys into one node means one cache miss brings in several comparisons’ worth of data, whereas a pointer-chasing red-black tree pays a cache miss per level. B-trees are not “the disk structure”; they are “the structure for any medium with a nonzero block size”, and that is every medium.

For how PostgreSQL exposes and tunes this in practice — CREATE INDEX, index-only scans, fillfactor, bloat, REINDEX — see ../../postgresql-dba/en/08-indexing-strategies.md and ../../postgresql-dba/en/11-storage-internals-and-vacuum.md. For the data-structure view of indexing in general, including hash and ISAM indexes, see ./18-indexing.md.

Skip lists

A skip list abandons trees entirely. It is a sorted linked list with express lanes: a base list containing every element, and above it a stack of progressively sparser lists, each element promoted to the next level with probability p (usually 0.5).

level 3:  HEAD ---------------------------------> 30 --------------------> NIL
level 2:  HEAD ---------> 10 -------------------> 30 --------> 50 -------> NIL
level 1:  HEAD ---------> 10 ------> 20 -------> 30 --------> 50 -------> NIL
level 0:  HEAD --> 5 --> 10 --> 15 --> 20 --> 25 --> 30 --> 40 --> 50 --> NIL

Search for 40: start top-left. At level 3, 30 < 40 so move to 30; next is NIL so
drop down. At level 2, next after 30 is 50 > 40, drop down. At level 1, same.
At level 0, 30 -> 40. Found. Four levels, a handful of comparisons.

The search is exactly binary search’s idea implemented with pointers: at each level, walk forward while the next key is smaller than the target; when you would overshoot, drop a level. Since each level has about half the nodes of the one below, there are O(log n) levels and O(1) expected steps per level, giving expected O(log n) search, insert, and delete.

import random


class SkipNode:
    __slots__ = ("key", "forward")

    def __init__(self, key, level):
        self.key = key
        self.forward = [None] * (level + 1)     # one next-pointer per level


class SkipList:
    MAX_LEVEL = 16
    P = 0.5

    def __init__(self):
        self.head = SkipNode(None, self.MAX_LEVEL)
        self.level = 0                          # highest level currently in use

    def _random_level(self):
        """Coin flips: level k with probability p^k. Expected height O(log n)."""
        lvl = 0
        while random.random() < self.P and lvl < self.MAX_LEVEL:
            lvl += 1
        return lvl

    def search(self, key):
        """Expected O(log n). Walk right while you can, drop a level when you cannot."""
        node = self.head
        for i in range(self.level, -1, -1):
            while node.forward[i] and node.forward[i].key < key:
                node = node.forward[i]
        node = node.forward[0]
        return node is not None and node.key == key

    def insert(self, key):
        # `update[i]` = the rightmost node at level i that precedes the insert point
        update = [self.head] * (self.MAX_LEVEL + 1)
        node = self.head
        for i in range(self.level, -1, -1):
            while node.forward[i] and node.forward[i].key < key:
                node = node.forward[i]
            update[i] = node

        if node.forward[0] and node.forward[0].key == key:
            return                              # already present

        lvl = self._random_level()
        if lvl > self.level:
            for i in range(self.level + 1, lvl + 1):
                update[i] = self.head
            self.level = lvl

        new = SkipNode(key, lvl)
        for i in range(lvl + 1):                # splice in, one level at a time
            new.forward[i] = update[i].forward[i]
            update[i].forward[i] = new

Why anyone uses this instead of a balanced tree:

The costs: expected rather than guaranteed bounds; O(n) extra pointers on average (with p = 0.5, about 2 pointers per node); and worse cache locality than a B-tree because every level is a pointer chase.

Where you will find them: Redis sorted sets (ZSET combines a hash table for member → score with a skip list for score-ordered range queries — ZRANGEBYSCORE is the leaf-chain walk), LevelDB and RocksDB memtables, Apache Lucene term dictionaries, and HBase.

Comparison

StructureSearchInsertDeleteSpaceBalance guaranteeWhere it wins
Plain BSTO(log n) avg / O(n) worstsamesameO(n)NoneTeaching only
AVLO(log n)O(log n), ≤2 rotationsO(log n), O(log n) rotationsO(n) + height/nodeh ≤ 1.44 log nRead-heavy in-memory
Red-blackO(log n)O(log n), ≤2 rotationsO(log n), ≤3 rotationsO(n) + 1 bit/nodeh ≤ 2 log nGeneral-purpose libraries
2-3 treeO(log n)O(log n)O(log n)O(n)Perfect (all leaves level)Conceptual foundation
B-treeO(log_t n) node visitsO(log_t n)O(log_t n)O(n), pages partly fullPerfectBlock devices, caches
B+ treeO(log_t n)O(log_t n)O(log_t n)O(n)PerfectDatabases, filesystems, range scans
Skip listO(log n) expectedO(log n) expectedO(log n) expectedO(n), ~2 ptr/nodeProbabilisticConcurrency, simple code

Note the two different worst-case columns hiding here. AVL/red-black/B-tree give guaranteed worst cases. Skip lists give expected ones — the probability that a skip list over a million keys has height above 60 is smaller than the probability of a hardware fault, so in practice the distinction is philosophical, but it does mean a skip list cannot offer a hard real-time bound.

Best Practices

References