JOVANA
Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

二叉搜索树与平衡

拿一棵二叉树,给值的去向加上一条规则,查找就从“扫一遍所有东西”坍缩成“每一步砍掉一半”。这篇导读搭建 **[[binary-search-tree|二叉搜索树]]**,说明它*在平衡时*为何能给出 O(log n) 的查找——以及它如何悄悄烂成一个 O(n) 链表的警世故事。然后:自平衡树背后的想法,以及 `std::map` 和 `std::set` 为何正是它。

一条改变一切的规则

一棵普通二叉树只是个形状;它对一个值*该住在哪里*只字未提。[[binary-search-tree|二叉搜索树]](BST)加上一条单一而严格的排序规则,而这条规则就是全部的魔法所在:对每一个节点,它左子树里的一切都更小,右子树里的一切都更大。 就这样。这条规则不仅对一个节点的直接孩子成立,对它下面整棵子树都成立。

              (8)
             /   \
          (3)     (10)
         /   \        \
      (1)    (6)       (14)

  left of 8  -> {3,1,6}  all < 8
  right of 8 -> {10,14}  all > 8
  ...and the same holds at 3, at 10, everywhere.
一棵二叉搜索树。在每个节点处,整棵左子树更小、整棵右子树更大。

查找:每一步都砍掉一半

在 BST 里查找,就是 [[binary-search|二分查找]] 的树版本。从根开始比较。相等?找到了。比这个节点小?规则保证你的目标只可能在左边,于是往左走,把整棵右子树忘掉。更大?往右走。每一次比较都丢掉剩余部分的一整侧——正是让有序数组上的二分查找如此之快的那种“砍半”,如今表现为顺着树往下走。插入走的是一模一样的路径:去查找这个值,在你从底部掉出去(`nullptr`)的地方,新节点就挂在那儿。

// search: returns the node holding key, or nullptr
Node* find(Node* n, int key) {
    while (n != nullptr && n->value != key)
        n = (key < n->value) ? n->left : n->right;
    return n;   // each step drops one whole subtree
}

// insert: walk to the empty slot, hang the new node there
Node* insert(Node* n, int key) {
    if (n == nullptr) return new Node{key};
    if (key < n->value) n->left  = insert(n->left,  key);
    else if (key > n->value) n->right = insert(n->right, key);
    return n;   // key already present -> no change
}
BST 的查找与插入。两者都只走一条从根到叶的路径,所以代价约为每层一次比较。

一共有多少层?如果这棵树是平衡的——每一步左右两边节点数大致相同——那么 n 个节点就装进大约 log₂ n 层里。一次查找每层碰一个节点,所以查找、插入、删除全都花 O(log n)。一百万个元素,约二十次比较就找到。这就是 BST 存在的全部理由。

当树烂成了一条链表

那个 O(log n) 带着三个你绝不能跳过的字:*当它平衡时*。BST 对一串糟糕的插入顺序毫无内建防御。往一棵空 BST 里依次插入 1、2、3、4、5:每个值都比之前所有的大,所以总是往右走。你完全得不到分叉——一棵其实是一条单边链子的树。

insert 1,2,3,4,5 in order:

  (1)
     \
      (2)
         \
          (3)
             \
              (4)
                 \
                  (5)   <- height = n-1, not log n!

  searching for 5 now visits all 5 nodes -> O(n)
一棵退化的 BST。有序输入让树高得像一条链表,查找退化为 O(n)。

自平衡,以及标准库的馈赠

修法是一棵 [[balanced-tree|自平衡树]]:每次插入或删除后,它做一点局部重整(叫作*旋转*),让左右两边大致持平,于是无论数据以什么顺序到来,高度都贴近 log n。你现在还不需要那套机械,只要这个想法和两个有名的名字。AVL 树让两侧的高度相差不超过 1——平衡得很严,所以查找很快。红黑树给节点染上红或黑,并遵守一套染色规则,使它*够好就行*——比 AVL 略不平衡一点,但维护起来更便宜。

下面是实用的头条:你几乎从不亲手写平衡树。C++ 标准库已经备好了。`std::map``std::set` 就是平衡二叉搜索树(实践中是红黑树),所以它们让键保持有序,并保证查找、插入、删除都是 O(log n)——自动完成,没有退化情形可怕。当你需要一个有序、可查找的集合时,伸手去拿它们,把平衡的事交给库去做。

#include <map>
std::map<std::string, int> ages;   // a balanced BST under the hood
ages["Ana"] = 30;                  // O(log n) insert
ages["Bo"]  = 25;
if (ages.count("Ana")) /* O(log n) lookup */ ;
for (auto& [name, age] : ages)     // iterates in SORTED key order
    std::cout << name << ' ' << age << '\n';
// prints Ana then Bo, alphabetically -- for free, like in-order.
std::map 是现成的平衡 BST:O(log n) 操作,且迭代时按键有序输出。