Cây cân bằng & cây nhiều nhánhBalanced & Multiway Trees
Mục lục
Table of contents
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:
- AVL tree — cân bằng chặt, cân bằng lại bằng rotation. Lookup nhanh nhất, nhiều rotation nhất khi ghi.
- Red-black tree — cân bằng lỏng hơn, ít rotation hơn. Mặc định trong đa số standard library.
- 2-3 tree — cầu nối khái niệm từ binary sang multiway; tổ tiên trực tiếp của cả red-black tree lẫn B-tree.
- B-tree và B+ tree — fan-out cao, hướng theo block, thiết kế quanh disk page thay vì phép so sánh. Thứ mà mọi database và filesystem thực sự dùng.
- Skip list — xác suất, không phải cây, nhưng đạt cùng
O(log n)kỳ vọng với code đơn giản hơn nhiều và concurrency tốt hơn.
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).
n | Height tốt nhất có thể | Height degenerate | Tỷ lệ chi phí search |
|---|---|---|---|
| 1.000 | ~10 | 999 | 100× |
| 1.000.000 | ~20 | 999.999 | 50.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 đó:
- 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.
- 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 factor —
height(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:
- Mỗi node hoặc đỏ hoặc đen.
- Root là đen.
- Mọi leaf (sentinel
NIL) là đen. - 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.
- Mọi đường từ một node đi xuống tới bất kỳ leaf
NILnà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.
| AVL | Red-black | |
|---|---|---|
| Chặn height | ≤ 1.44 log₂ n | ≤ 2 log₂ n |
| Rotation mỗi insert | ≤ 2 | ≤ 2 |
| Rotation mỗi delete | O(log n) | ≤ 3 |
| Đổi màu mỗi thao tác | không có | O(log n), nhưng rẻ hơn rotation rất nhiều |
| Metadata mỗi node | height (hoặc 2 bit) | 1 bit |
| Hợp nhất với | Nặ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++
std::map,std::set,std::multimap,std::multiset— red-black trong mọi bản cài đặt chính thống. - Java
TreeMap,TreeSet, và các bucket đã treeify củaHashMap(chuỗi collision dài chuyển thành red-black tree để chặn worst case của lookup). - Linux kernel —
rbtreeđược dùng bởi CFS scheduler, tra cứu virtual memory area, directory index của ext3, và epoll. - Ruby,
BTreeMapcủa Rust — thật ra Rust chọn B-tree thay vì red-black, vì lý do cache; xem bên dưới.
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à:
- một 2-node: một key, hai child (giống node BST), hoặc
- một 3-node: hai key
a < b, ba child —< a, giữaavàb, và> b.
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:
- Nếu leaf là 2-node, nó thành 3-node. Xong — không depth nào thay đổi.
- Nếu leaf đã là 3-node, nó tạm thời thành “4-node” với ba key, rồi split: key ở giữa được đẩy lên parent, hai key còn lại thành hai 2-node riêng biệt.
- Nếu parent cũng đã đầy, split lan lên trên. Nếu lan tới root, root split và height của cây tăng thêm một — cho mọi leaf cùng lúc, đó chính xác là lý do mọi leaf luôn ngang nhau.
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₃ n và log₂ 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:
- Mọi node trừ root có từ
t − 1tới2t − 1key. - Mọi internal node có
kkey thì có đúngk + 1child. - Mọi leaf ở cùng depth.
- Các key trong một node được sắp xếp, và đóng vai trò phân tách khoảng cho các child.
[ 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 đó:
| Height | Số 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:
- 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.
- 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.
- Vì internal node không mang payload, mỗi node nhét được nhiều key phân tách hơn trong một page, nên fan-out tăng và height giảm. B+ tree trên cùng dữ liệu thường thấp hơn B-tree tương đương.
- Vì các leaf được nối với nhau,
WHERE created_at BETWEEN '2026-01-01' AND '2026-03-31'trở thành: một lần đi xuốngO(log n)tới leaf khớp đầu tiên, rồi một lượt đi tuần tự dọc theo chuỗi leaf cho tới cận trên. Đọc tuần tự là thứ đĩa và prefetcher làm tốt nhất. Trong B-tree thuần, cùng phép quét đó sẽ phải liên tục leo ngược lên internal node để tìm key kế tiếp, tạo ra pattern truy cập rời rạc. - Mọi lần lookup tốn đúng cùng số lần thăm node (mọi dữ liệu ở tầng leaf), nên độ trễ đồng đều và dễ dự đoán chứ không phải “nhanh nếu key tình cờ nằm ở root”.
- Leaf thường được giữ chỉ đầy một phần (mặc định
fillfactorcho B-tree index của PostgreSQL là 90) để các lần insert sau có thể chen vào mà không phải split page.
Đâ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ống | Cấu trúc |
|---|---|
| PostgreSQL, MySQL/InnoDB, Oracle, SQL Server — index mặc định | B+ tree |
| SQLite | B+ tree cho table, B-tree cho index |
NTFS, HFS+, APFS, ext4 (htree), XFS, Btrfs | Biến thể B-tree / B+ tree |
| MongoDB (WiredTiger) | B+ tree |
Rust std::collections::BTreeMap | B-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 và ../../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:
- Code chỉ bằng một phần nhỏ. So sánh ~30 dòng ở trên với một phép delete red-black đúng đắn (sáu trường hợp, rất dễ sai một cách tinh vi). Không rotation, không cân bằng lại, không bất biến màu.
- Concurrency. Insert và delete chỉ chạm vào vài pointer
forwardvà không bao giờ tái cấu trúc subtree, nên skip list lock-free hoặc khoá mịn là hoàn toàn khả thi. Cây cân bằng lock-free thì là một bài báo nghiên cứu. - Input không thể adversarial. Hình dạng phụ thuộc vào bộ sinh số ngẫu nhiên, không phụ thuộc thứ tự insert, nên dữ liệu đã sắp xếp không tệ hơn dữ liệu ngẫu nhiên. (Cây không ngẫu nhiên hoá có worst case khai thác được; worst case của skip list đòi hỏi RNG phải “đồng loã”.)
- Range query đơn giản tới mức tầm thường — tầng đáy vốn đã là một sorted linked list.
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 LevelDB và RocksDB, term dictionary của Apache Lucene, và HBase.
So sánh
| Cấu trúc | Search | Insert | Delete | Bộ nhớ | Đảm bảo cân bằng | Thắng ở đâu |
|---|---|---|---|---|---|---|
| BST thuần | O(log n) TB / O(n) worst | như trên | như trên | O(n) | Không có | Chỉ để dạy học |
| AVL | O(log n) | O(log n), ≤2 rotation | O(log n), O(log n) rotation | O(n) + height/node | h ≤ 1.44 log n | Nặng đọc, trong bộ nhớ |
| Red-black | O(log n) | O(log n), ≤2 rotation | O(log n), ≤3 rotation | O(n) + 1 bit/node | h ≤ 2 log n | Thư viện đa dụng |
| 2-3 tree | O(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-tree | O(log_t n) lần thăm node | O(log_t n) | O(log_t n) | O(n), page đầy một phần | Hoàn hảo | Thiết bị block, cache |
| B+ tree | O(log_t n) | O(log_t n) | O(log_t n) | O(n) | Hoàn hảo | Database, filesystem, range scan |
| Skip list | O(log n) kỳ vọng | O(log n) kỳ vọng | O(log n) kỳ vọng | O(n), ~2 ptr/node | Xác suất | Concurrency, 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
- Dùng thư viện.
std::map,TreeMap,BTreeMap,SortedDictcủasortedcontainers— chúng đúng, đã được test và tinh chỉnh. Hãy cài đặt delete red-black một lần để hiểu, rồi đừng bao giờ đưa bản của mình lên production. - Python không có cây cân bằng dựng sẵn. Các phương án thực dụng là
bisecttrênlistđã sắp xếp (searchO(log n)nhưng insertO(n)— ổn tới vài nghìn phần tử),sortedcontainers.SortedDict/SortedList(cấu trúc kiểu B-tree, thực tế rất nhanh), hoặcheapqkhi bạn chỉ cần phần tử cực trị (xem ./12-heaps-and-priority-queues.md). - Chọn AVL cho workload nặng đọc, red-black cho nặng ghi. Nếu lookup áp đảo các phép thay đổi, height chặt hơn của AVL có lợi. Nếu ghi thường xuyên, chi phí delete có chặn của red-black có lợi. Nếu bạn không mô tả được workload, red-black là mặc định an toàn hơn — chính xác là lý do các thư viện chọn nó.
- Chọn B-tree, không phải binary tree, bất cứ khi nào có ranh giới block. Disk page, SSD page, cache line, network payload. Fan-out 250 biến 30 level thành 4.
- Ưu tiên B+ hơn B khi range scan quan trọng. Nếu pattern truy cập là
BETWEEN,ORDER BY ... LIMIT, so khớp prefix, hoặc “N phần tử kế tiếp sau X”, tầng leaf được nối chính là toàn bộ lý do truy vấn đó nhanh. - Đừng bao giờ dựng search tree bằng cách insert dữ liệu đã sắp xếp vào cấu trúc không tự cân bằng. Nếu buộc phải dùng BST thuần, hãy xáo trộn trước hoặc dựng đệ quy từ phần tử giữa của mảng đã sắp xếp, cách này cho cây cân bằng hoàn hảo trong
O(n). - Cân nhắc skip list khi concurrency hoặc sự đơn giản là ưu tiên. Skip list lock-free là bài toán đã giải; cây cân bằng lock-free thì chưa. Redis và RocksDB đều chọn skip list vì đúng lý do này.
- Đừng nhầm “cân bằng” với “nhanh như mảng đã sắp xếp”. Với dataset tĩnh không bao giờ thay đổi, một mảng đã sắp xếp thuần kèm binary search đánh bại mọi cây ở đây — cùng
O(log n), cache tốt hơn, không tốn pointer. Cây chỉ xứng đáng với overhead của nó khi dữ liệu thay đổi. Xem ./09-search-algorithms.md. - Nhớ chi phí write amplification. Mỗi index là một cây cân bằng phải được cập nhật ở mọi
INSERT/UPDATE/DELETE. Năm index trên một table nghĩa là năm lần cập nhật cây mỗi lần ghi. Đây là mâu thuẫn trung tâm của việc thiết kế index — xem ../../postgresql-dba/vi/08-indexing-strategies.md. - Đo height khi debug một cây chậm. Một cấu trúc lẽ ra
O(log n)mà hành xử nhưO(n)thì gần như luôn có bất biến cân bằng bị hỏng, và height là con số duy nhất phơi bày điều đó.
Tài liệu tham khảo
- roadmap.sh — Data Structures & Algorithms
- AVL tree — Wikipedia
- Red–black tree — Wikipedia
- 2–3 tree — Wikipedia
- B-tree — Wikipedia
- B+ tree — Wikipedia
- Skip list — Wikipedia
- Self-balancing binary search tree — Wikipedia
- CLRS — Introduction to Algorithms, Chapters 13 (Red-Black Trees) and 18 (B-Trees)
- MIT 6.006 — Introduction to Algorithms (OpenCourseWare)
- PostgreSQL Documentation — Index Types
- PostgreSQL Documentation — B-Tree Indexes (internals)
- Redis Documentation — Sorted Sets
- VisuAlgo — AVL and B-tree visualizations
- Big-O Cheat Sheet
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:
- AVL trees — strictly balanced, rebalanced by rotation. Fastest lookups, most rotations on write.
- Red-black trees — loosely balanced, fewer rotations. The default in most standard libraries.
- 2-3 trees — the conceptual bridge from binary to multiway; the direct ancestor of both red-black trees and B-trees.
- B-trees and B+ trees — high fan-out, block-oriented, designed around disk pages rather than comparisons. What every database and filesystem actually uses.
- Skip lists — probabilistic, not a tree at all, but achieves the same expected
O(log n)with far simpler code and better concurrency.
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).
n | Best possible height | Degenerate height | Search cost ratio |
|---|---|---|---|
| 1,000 | ~10 | 999 | 100× |
| 1,000,000 | ~20 | 999,999 | 50,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:
- Deterministic rebalancing — after each mutation, check a structural invariant and repair it (AVL, red-black, B-tree). Worst case guaranteed.
- 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 factor —
height(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:
- Every node is either red or black.
- The root is black.
- Every leaf (the
NILsentinel) is black. - A red node’s children are both black — no two reds in a row on any path.
- Every path from a given node down to any of its descendant
NILleaves 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.
| AVL | Red-black | |
|---|---|---|
| Height bound | ≤ 1.44 log₂ n | ≤ 2 log₂ n |
| Rotations per insert | ≤ 2 | ≤ 2 |
| Rotations per delete | O(log n) | ≤ 3 |
| Colour flips / recolourings per op | n/a | O(log n), but far cheaper than rotations |
| Per-node metadata | height (or 2 bits) | 1 bit |
| Best for | Read-heavy: lookups dominate | Write-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:
- C++
std::map,std::set,std::multimap,std::multiset— red-black in every mainstream implementation. - Java
TreeMap,TreeSet, andHashMap’s treeified buckets (long collision chains convert to red-black trees to bound worst-case lookup). - The Linux kernel —
rbtreeis used by the CFS scheduler, by the virtual memory area lookup, by ext3’s directory index, and by epoll. - Ruby, Rust’s
BTreeMap— Rust actually chose a B-tree instead, for cache reasons; see below.
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:
- a 2-node: one key, two children (like a BST node), or
- a 3-node: two keys
a < b, three children —< a, betweenaandb, and> b.
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:
- If the leaf is a 2-node, it becomes a 3-node. Done — the depth of nothing changed.
- If the leaf is already a 3-node, it temporarily becomes a “4-node” with three keys, then splits: the middle key is pushed up into the parent, and the two remaining keys become two separate 2-nodes.
- If the parent was also full, the split cascades upward. If it reaches the root, the root splits and the tree’s height increases by one — for every leaf at once, which is precisely why all leaves stay level.
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:
- Every node except the root has between
t − 1and2t − 1keys. - Every internal node with
kkeys has exactlyk + 1children. - All leaves are at the same depth.
- Keys within a node are sorted, and act as separators for the child ranges.
[ 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:
| Height | Keys 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:
- All data lives in the leaves. Internal nodes hold only separator keys — no payloads, no row pointers.
- 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.
- Because internal nodes carry no payload, each one fits more separator keys per page, so fan-out goes up and height goes down. A B+ tree over the same data is typically shorter than the equivalent B-tree.
- Because the leaves are linked,
WHERE created_at BETWEEN '2026-01-01' AND '2026-03-31'becomes: oneO(log n)descent to the first matching leaf, then a sequential walk along the leaf chain until the upper bound. Sequential reads are what disks and prefetchers are best at. In a plain B-tree the same scan would have to keep climbing back up into internal nodes to find the next key, producing a scattered access pattern. - Every lookup costs exactly the same number of node visits (all data is at leaf level), so latency is uniform and predictable rather than “fast if the key happens to sit in the root”.
- Leaves are typically kept only partially full (PostgreSQL’s default
fillfactorfor B-tree indexes is 90) so subsequent inserts can land without splitting a page.
This is why the structure is essentially universal in storage systems:
| System | Structure |
|---|---|
| PostgreSQL, MySQL/InnoDB, Oracle, SQL Server — default index | B+ tree |
| SQLite | B+ tree for tables, B-tree for indexes |
NTFS, HFS+, APFS, ext4 (htree), XFS, Btrfs | B-tree / B+ tree variants |
| MongoDB (WiredTiger) | B+ tree |
Rust std::collections::BTreeMap | B-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 code is a fraction of the size. Compare the ~30 lines above with a correct red-black delete (six cases, easy to get subtly wrong). No rotations, no rebalancing, no colour invariants.
- Concurrency. Insert and delete touch only a handful of
forwardpointers and never restructure a subtree, so a lock-free or fine-grained-locking skip list is genuinely practical. A lock-free balanced tree is a research paper. - The input cannot be adversarial. The shape depends on the random number generator, not on insertion order, so sorted input is no worse than random input. (A tree without randomization has an exploitable worst case; a skip list’s worst case requires the RNG to conspire.)
- Range queries are trivial — the bottom level is already a sorted linked list.
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
| Structure | Search | Insert | Delete | Space | Balance guarantee | Where it wins |
|---|---|---|---|---|---|---|
| Plain BST | O(log n) avg / O(n) worst | same | same | O(n) | None | Teaching only |
| AVL | O(log n) | O(log n), ≤2 rotations | O(log n), O(log n) rotations | O(n) + height/node | h ≤ 1.44 log n | Read-heavy in-memory |
| Red-black | O(log n) | O(log n), ≤2 rotations | O(log n), ≤3 rotations | O(n) + 1 bit/node | h ≤ 2 log n | General-purpose libraries |
| 2-3 tree | O(log n) | O(log n) | O(log n) | O(n) | Perfect (all leaves level) | Conceptual foundation |
| B-tree | O(log_t n) node visits | O(log_t n) | O(log_t n) | O(n), pages partly full | Perfect | Block devices, caches |
| B+ tree | O(log_t n) | O(log_t n) | O(log_t n) | O(n) | Perfect | Databases, filesystems, range scans |
| Skip list | O(log n) expected | O(log n) expected | O(log n) expected | O(n), ~2 ptr/node | Probabilistic | Concurrency, 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
- Use the library.
std::map,TreeMap,BTreeMap,SortedDictfromsortedcontainers— these are correct, tested, and tuned. Implement a red-black delete once to understand it, then never ship your own. - Python has no built-in balanced tree. The practical substitutes are
bisecton a sortedlist(O(log n)search butO(n)insert — fine up to a few thousand elements),sortedcontainers.SortedDict/SortedList(a B-tree-like structure that is genuinely fast in practice), orheapqwhen you only need the extreme (see ./12-heaps-and-priority-queues.md). - Choose AVL for read-heavy, red-black for write-heavy. If lookups outnumber mutations by a wide margin, AVL’s tighter height pays. If writes are frequent, red-black’s bounded delete cost pays. If you cannot characterize the workload, red-black is the safer default — which is exactly why libraries pick it.
- Reach for a B-tree, not a binary tree, whenever a block boundary exists. Disk pages, SSD pages, cache lines, network payloads. A fan-out of 250 turns 30 levels into 4.
- Prefer B+ over B when range scans matter. If your access pattern is
BETWEEN,ORDER BY ... LIMIT, prefix matching, or “the next N after X”, the linked leaf level is the whole reason the query is fast. - Never build a search tree by inserting sorted data into a non-balancing structure. If you must use a plain BST, either shuffle first or build from the median of a sorted array recursively, which gives a perfectly balanced tree in
O(n). - Consider a skip list when concurrency or simplicity dominates. Lock-free skip lists are a solved problem; lock-free balanced trees are not. Redis and RocksDB both chose skip lists for exactly this reason.
- Do not confuse “balanced” with “sorted-array fast”. For a static dataset that never changes, a plain sorted array with binary search beats every tree here — same
O(log n), better cache behaviour, zero pointer overhead. Trees earn their overhead only when the data mutates. See ./09-search-algorithms.md. - Remember the write amplification. Every index is a balanced tree that must be updated on every
INSERT/UPDATE/DELETE. Five indexes on a table means five tree updates per write. This is the central tension of index design — see ../../postgresql-dba/en/08-indexing-strategies.md. - Measure the height when debugging a slow tree. A structure that should be
O(log n)and is behaving likeO(n)almost always has a broken balance invariant, and the height is the single number that reveals it.
References
- roadmap.sh — Data Structures & Algorithms
- AVL tree — Wikipedia
- Red–black tree — Wikipedia
- 2–3 tree — Wikipedia
- B-tree — Wikipedia
- B+ tree — Wikipedia
- Skip list — Wikipedia
- Self-balancing binary search tree — Wikipedia
- CLRS — Introduction to Algorithms, Chapters 13 (Red-Black Trees) and 18 (B-Trees)
- MIT 6.006 — Introduction to Algorithms (OpenCourseWare)
- PostgreSQL Documentation — Index Types
- PostgreSQL Documentation — B-Tree Indexes (internals)
- Redis Documentation — Sorted Sets
- VisuAlgo — AVL and B-tree visualizations
- Big-O Cheat Sheet