【数据结构】二叉搜索树(BST)

何为二叉搜索树?

二叉搜索树 \(\text{(Binary Searching Tree)}\) 是一种通过把数据以树形组织起来,从而更快速的完成插入、删除和询问等操作的数据结构。

它满足以下三个性质:

  1. 空树是二叉搜索树。
  2. 任意一个二叉搜索树左子树上全部节点的权值均小于此树根节点权值,右子树上全部节点权值均大于此树根节点权值。(若存在)
  3. 若某二叉搜索树存在左/右子树,则其左/右子树均为二叉搜索树。

由于 BST 上的操作均基于树上的 DFS,因此在 BST 上操作的复杂度最好为 \(\Theta(\log n)\). 但由于操作可能导致树的结构变化,这个值也有可能退化为 \(\Theta(n)\).(详见二叉平衡树)

实现原理

存储

为了维护树形结构,每个节点应该包含:权值 val ,左儿子、右儿子编号 lsrs,该权值出现次数 cnt 和子树大小 sz(包含重复出现的) 这五个数据。sz 可以方便求某元素第一次出现位置和求第 \(k\) 小元素位置。

此外,我们还需要记录当前有几个节点 idx,以及当前 BST 根节点编号 root

int idx, root;
struct node{
    int val, ls, rs, cnt, sz;
}t[N];

插入

插入操作的核心逻辑是,把插入值和当前节点权值相比,如果 \(x < t[u].val\) 当前就进入左子树继续插入,如果等于就将本节点计数器加一,如果 \(x > t[u].val\) 就进入右子树。进入空节点就创建一个节点,并返回(这表明已经找到了该插入的位置)。回溯时维护数据。

看这张图:

如果我们要插入 \(7\),根据 BST 的性质,我们从根节点出发,发现 \(7\) 比根节点 \(4\) 大,那么 \(7\) 应该在根的右子树中,走到 \(9\),再和 \(9\) 比较,小于 \(9\) 所以向左走到 \(5\)\(7\) 大于 \(5\) 因此向右走。但它没有右子树啊,所以直接把新建的节点挂在 \(5\) 的右子树就可以了。注意回来的路上要一路更新 sz 值。

inline void push_up(int u){ // 维护节点 u 的 sz 值
    node &v = t[u];
    v.sz = t[v.ls].sz + t[v.rs].sz + v.cnt;
    // 这个节点的 sz 值应该是左右子树 sz 值之和,再加上本节点出现次数
    return;
}
inline int insert(int u, int x){
    if(!u){ // 走到了空节点,说明父亲是叶子,在这里创建一个节点
        idx++;
        t[idx] = {x, 0, 0, 1, 1};
        return idx;
    }
    if(x < t[u].val)
        t[u].ls = insert(t[u].ls, x); // 不是叶子的话不发生改动,是叶子就会把这个叶子的孩子改成新节点
    else if(x == t[u].val)
        t[u].cnt++;
    else
        t[u].rs = insert(t[u].rs, x);
    push_up(u);
    return u;
}

于是树就变成了这样。

代码中 insert(u, x) 函数返回的是“在以 \(u\) 为根的树中插入元素 \(x\) 后这个树的根节点编号”。在 \(u\) 不是空节点的时候,它返回 \(u\) 本身,因此 t[u].ls = insert(t[u].ls, x);t[u].rs = insert(t[u].rs, x); 不产生改动;而在从空节点返回时,会把原叶子的孩子改成它。在这个例子中,从 \(7\) 回溯时会把 \(t[5].rs\) 改成 \(7\).

查询某元素是否存在及其位置

从根节点开始查找,流程如下:若 \(x < t[u].val\),进入左子树查找,\(x > t[u].val\) 进入右子树;如果相等说明找到了。找到最后如果进了一个空节点,说明这个元素不存在,返回 \(0\)

inline int search(int u, int x){
    if(!u)
        return 0;
    if(x == t[u].val)
        return u;
    if(x < t[u].val)
        return search(t[u].ls, x);
    else
        return search(t[u].rs, x);
}

查询最小/最大值

以最小值为例,从根节点出发一直向左走,知道节点没有左子树,这里就是最小元素的位置。

inline int query_min(int u){
    while(t[u].ls)
        u = t[u].ls;
    return u;
}

删除节点

这个是比较复杂的一个操作,分两部分:第一是找到删除的节点 \(u\),第二是维护树结构。

第一部分很简单,\(x < t[u].val\) 向左走,反之就向右走。

第二部分,\(x = t[u].val\) 要分类讨论。

如果这个元素不只出现了一次,计数器减一即可。

不是这样的话,分两种:

  1. 如果 \(u\) 只有小于等于 \(1\) 个儿子,直接用儿子取代它,并 push_up(u)

  2. 如果 \(u\) 有两个儿子,去 \(u\) 右子树中找到最小元素 \(suc\) ,用它替换 \(u\),并移除 \(suc\)(这里也可以找左子树最大值做同样操作)。这样做是为了保证 BST 性质仍然成立。因为 \(suc\) 在原树右子树中,所以一定大于全部左子树元素;它又是右子树中最小的,就小于右子树全部元素,因此它做根是合理的。

例:在这颗 BST 中删去 \(9\)

首先从 \(4\),向右走到 \(9\) 找到节点,

发现它有两个儿子,因此找到右子树中最小的元素,是 \(5\)

\(5\) 代替 \(9\) (图中红色),再删去 \(5\),并处理好新的连边关系(图中蓝线)。

这样,该树仍满足 BST 的性质,我们成功删除了 \(9\)

inline int remove(int u, int x){
    if(!u)
        return 0;
    if(x < t[u].val)
        t[u].ls = remove(t[u].ls, x);
    else if(x > t[u].val)
        t[u].rs = remove(t[u].rs, x);
    else{
        if(t[u].cnt > 1){
            t[u].cnt--;
            push_up(u);
            return u;
        }
        if(!t[u].ls)
            return t[u].rs;
        // 这里的意思是把这个子树的新根设为 u 的右儿子,在回溯的时候也就把 u 父亲的右儿子设成了 u 的右儿子
        // 这就相当于把 u 从这个链里面删去了
        if(!t[u].rs) // 这也是一样的思路
            return t[u].ls;
        int suc = query_min(t[u].rs); // 找到 u 的“继承人”suc(即右子树中最小值)
        t[u].val = t[suc].val, t[u].cnt = t[suc].cnt; // 用 suc 替换 u
        t[suc].cnt = 1; // 重置 suc 的 cnt 值,保证被彻底删除
        t[u].rs = remove(t[u].rs, t[suc].val); // 删除 suc
    }
    push_up(u); // 维护 sz
    return u;
}

在把 \(suc\) 的信息拷贝到 \(u\) 时无需把 \(sz\) 也弄过去,之后 push_up(u) 时会自动更新。注意,一定要把 \(t[suc].cnt\) 重置 为 \(1\),不做这个操作,如果 \(t[suc].cnt > 1\),等会删除的时候就只会删去一个,就没有把它彻底删除。

查询某元素第一次出现的位置

sz 将会在这里派上用场。

求某元素第一次出现的位置,只要从树根开始向目标节点 \(x\) 走,并把路上所有小于它的元素的个数累加在 \(res\) 上,最后再加一(\(u\) 本身也占一个位置)即可。

走到 \(u\) 时,比较 \(t[u].val\)\(x\)

  1. \(x < t[u].val\),说明 \(x\)\(u\) 左子树,向左走,不累加。
  2. \(x > t[u].val\),说明 \(u\) 以及其左子树所有元素权值均小于 \(x\)\(res\) 累加 \(t[t[u].ls].sz + t[u].cnt\),并继续向右走(因为这时候还没有找到 \(x\) 在哪,你只能确定上面那些一定比 \(x\) 小,但是有没有统计完不确定)
  3. \(x = t[u].val\),说明 \(u\) 的左子树所有元素权值均小于 \(x\)\(res\) 累加 \(t[t[u].ls].sz + 1\),并直接返回结果退出。
inline int getrank(int u, int x){
    int res = 0;
    while(u){ // 必须是在存在的节点上走
        if(t[u].val == x){
            res += t[t[u].ls].sz + 1;
            return res;
        }
        if(x < t[u].val)
            u = t[u].ls;
        else{
            res += t[u].cnt + t[t[u].ls].sz;
            u = t[u].rs;
        }
    }
    return -1; // 循环内没找到 x 说明不存在
}

查询第 \(k\) 小元素

思路和上面那个差不多,也是从上到下 DFS。

由 BST 第二条性质,讨论走到节点 \(u\) 时的情况:

记第 \(k\) 小元素为 \(x_k\)\(u\) 左子树大小 \(t[t[u].ls].sz\)\(lft\):

  1. \(k <= lft\),则 \(x_k\) 存在于 \(u\) 左子树,往左走。
  2. \(lft < k <= lft + t[u].cnt\),这说明 \(x_k\) 就在 \(u\),直接返回 \(u\)
  3. \(k > lft + t[u].cnt = lft\),这表明 \(x_k\) 在右子树,问题相当于在 \(u\) 右子树中找第 \(lft - k\) 小元素,更改 \(k\) 并向右走即可。

当进入空节点或者 \(k < 1\) 证明出现问题还没有找到 \(x_k\),返回并报告。

inline int kth(int u, int k){
    if(!u || k < 1)
        return -1;
    while(u){
        int lft = t[t[u].ls].sz;
        if(k <= lft)
            u = t[u].ls;
        else if(k <= lft + t[u].cnt)
            return u;
        else{
            k -= (lft + t[u].cnt);
            u = t[u].rs;
        }
    }
    return -1;
}

整合

代码如下,注意由于插入和删除可能导致树形态(可能是根位置)的改变, \(root\) 要在 insertremove 操作时更新。

#include <bits/stdc++.h>
using namespace std;
const int N = 1e5 + 5;
int q, idx, root;
struct node{
    int val, ls, rs, cnt, sz;
}t[N];
inline void push_up(int u){
    node &v = t[u];
    v.sz = t[v.ls].sz + t[v.rs].sz + v.cnt;
    return;
}
inline int insert(int u, int x){
    if(!u){
        idx++;
        t[idx] = {x, 0, 0, 1, 1};
        return idx;
    }
    if(x < t[u].val)
        t[u].ls = insert(t[u].ls, x);
    else if(x == t[u].val)
        t[u].cnt++;
    else
        t[u].rs = insert(t[u].rs, x);
    push_up(u);
    return u;
}
inline int query_min(int u){
    while(t[u].ls)
        u = t[u].ls;
    return u;
}
inline int remove(int u, int x){
    if(!u)
        return 0;
    if(x < t[u].val)
        t[u].ls = remove(t[u].ls, x);
    else if(x > t[u].val)
        t[u].rs = remove(t[u].rs, x);
    else{
        if(t[u].cnt > 1){
            t[u].cnt--;
            push_up(u);
            return u;
        }
        if(!t[u].ls)
            return t[u].rs;
        if(!t[u].rs)
            return t[u].ls;
        int suc = query_min(t[u].rs);
        t[u].val = t[suc].val, t[u].cnt = t[suc].cnt;
        t[suc].cnt = 1;
        t[u].rs = remove(t[u].rs, t[suc].val);
    }
    push_up(u);
    return u;
}
inline int search(int u, int x){
    if(!u)
        return 0;
    if(x == t[u].val)
        return u;
    if(x < t[u].val)
        return search(t[u].ls, x);
    else
        return search(t[u].rs, x);
}
inline int getrank(int u, int x){
    int res = 0;
    while(u){
        if(t[u].val == x){
            res += t[t[u].ls].sz + 1;
            return res;
        }
        if(x < t[u].val)
            u = t[u].ls;
        else{
            res += t[u].cnt + t[t[u].ls].sz;
            u = t[u].rs;
        }
    }
    return -1;
}
inline void print(int u){
    if(!u)
        return;
    print(t[u].ls);
    printf("%d ", t[u].val);
    print(t[u].rs);
    return;
}
inline int kth(int u, int k){
    if(!u || k < 1)
        return -1;
    while(u){
        int lft = t[t[u].ls].sz;
        if(k <= lft)
            u = t[u].ls;
        else if(k <= lft + t[u].cnt)
            return u;
        else{
            k -= (lft + t[u].cnt);
            u = t[u].rs;
        }
    }
    return -1;
}
int main(){
    scanf("%d", &q);
    while(q--){
        int op, x;
        scanf("%d", &op);
        if(op == 1){
            scanf("%d", &x);
            root = insert(root, x);
        }else if(op == 2){
            scanf("%d", &x);
            root = remove(root, x);
        }else if(op == 3){
            scanf("%d", &x);
            int pos = search(root, x);
            printf("%d\n", pos ? pos : -1);
        }else if(op == 4){
            scanf("%d", &x);
            printf("%d\n", getrank(root, x));
        }else
            print(root), putchar('\n');
    }
    return 0;
}
posted @ 2026-07-23 22:05  Prülystic  阅读(13)  评论(0)    收藏  举报