二叉搜索树从入门到实践:性能瓶颈与平衡树进阶
2026/9/15 6:09:18 网站建设 项目流程

有次线上问题排查让我印象很深:一个接口在数据量过了十万之后越来越慢,翻代码发现同事用数组维护了一份按时间排好的数据,每次插入都要把后面的元素整体往后挪。我跟他说,这种“动态维护 + 有序查询”的场景应该上二叉搜索树(BST)这类动态有序结构。他反问了一句:BST 跟有序数组差别到底有多大?这个问题看着基础,其实把查找效率、插入代价、性能瓶颈全串起来了。

今天这篇就围绕二叉搜索树,把概念、操作逻辑和性能瓶颈三个部分一次讲透。后面还会带上几个高频热词里有人常搜的做法:二叉搜索树中的众数、最优二叉搜索树、不同的二叉搜索树分别怎么解。不论你是刚准备面试,还是工作中第一次碰到“为什么这里不用数组,要用树”的疑问,这篇文章都能给你一个能直接落地的答案。

1. 先搞清楚:二叉搜索树到底解决了什么痛点

1.1 从有序数组的瓶颈说起

如果你面前有一份排好序的数组,查找一个元素可以二分,O(log n) 很快;插入一个元素呢?得先二分找到位置,然后把后续元素全部右移,最坏 O(n)。删除也是同理,移动元素很疼。

当数据是静态的,比如一个配置表加载后不再变化,有序数组是非常优秀的方案。但现实业务里数据往往在不断新增、删除,你还要随时按顺序遍历,这时候“既要动态,又要有序”就成了核心矛盾。

二叉搜索树就是为这个矛盾生的。它不要求物理上连续存储,而是通过节点之间的指针关系,把逻辑上的有序性表达出来。插入和删除只需要改指针,单次操作的代价能降到 O(log n) 级别,同时中序遍历还能保持有序输出。

1.2 那些用到 BST 思想的真实场景

BST 本身作为教科书结构,直接手写的机会不多,但它却是很多工业级组件的底层基础。

  • Java 的 TreeMap、TreeSet 底层是红黑树,红黑树就是 BST 的平衡变体。
  • C++ 的 std::map、std::set 同样基于红黑树。
  • 数据库索引里常见的 B+ 树,本质上是“多路二叉搜索树”的扩展,核心的“按 key 有序查找、范围查找”思想一脉相承。
  • 很多需要动态维护有序集合的算法题,比如求滑动窗口中位数、求数据流的中位数,也都可以用 BST 家族结构解决。

理解了 BST,你不只是掌握一个数据结构,而是拿到了理解 TreeMap、数据库索引、各种平衡树的一把钥匙。

1.3 这篇文章适合谁读

如果你是刚开始学数据结构,这篇文章把操作逻辑和边界情况拆得很细,照着代码敲一遍就能跑通;如果你在准备面试,第五章的三个实战问题都是高频考点,会从代码和复杂度两个角度帮你把思路理顺;如果你已经在写业务代码,第四章的性能瓶颈分析会让你明白“为什么市面上几乎没有裸 BST,全是平衡树”的原因。

2. BST 的结构设计,藏着哪些细节

2.1 节点三要素:key、left、right

BST 的基本单位是节点,每个节点有三样东西:键值、左孩子指针、右孩子指针。C 语言里定义长这样:

typedef struct BSTNode { int key; // 排序用的键 int value; // 实际载荷,可以没有,也可以放复杂对象 struct BSTNode *left; // 左孩子 struct BSTNode *right; // 右孩子 } BSTNode;

工程实现里,key 不一定就是存储的全部内容。比如一个用户表,key 是用户 ID,value 可能是昵称、等级、最近登录时间。比较大小只用 key,找到节点后再取 value。这个“key-value 分离”的思路在 TreeMap 和数据库索引里都是一致的。

2.2 核心不变量:左子树都小,右子树都大

二叉搜索树的定义听起来很简单:

  • 如果左子树不为空,那么左子树上所有节点的 key 都小于根节点的 key。
  • 如果右子树不为空,那么右子树上所有节点的 key 都大于根节点的 key。
  • 左右子树本身也分别是二叉搜索树。

注意这里说的是“所有节点”,不只是根节点的直接左孩子、右孩子。这个全局有序性,是 BST 所有操作的基础。

你可以把 BST 看成“二分查找的树形展开”:每次从根出发,比较 key 的大小,小于就走左,大于就走右。因为左子树全体小于根、右子树全体大于根,所以每走一步,搜索范围就缩小到原来的左半部分或右半部分,跟二分查找的思想完全一样。

2.3 中序遍历的有序性,是 BST 的最佳解码器

二叉树有四种基本遍历:前序、中序、后序、层序。对于 BST 来说,中序遍历有一个极其重要的性质:它恰好输出一个升序序列。

原因是中序遍历的顺序是“左子树 -> 根 -> 右子树”,而左子树所有节点都小于根,右子树所有节点都大于根。把整棵树按这个顺序访问一遍,自然就是由小到大。

这个性质有非常实用的价值。最典型的是“判断一棵二叉树是不是 BST”:不用递归比较上下界的值,直接中序遍历,检查输出序列是否严格递增就行。我在实际排查树相关 Bug 的时候,也常用中序打印来验证一棵树的结构是否被破坏,比肉眼盯指针快得多。

3. 操作逻辑拆解:查找、插入、删除、遍历

3.1 查找:每一层都像二分

查找的目标是给定一个 key,在树里找到对应节点。逻辑和二分查找高度一致:从根开始,相等就返回;小于当前节点走左边;大于当前节点走右边。

迭代写法比递归更省栈空间,实际项目里我一般用迭代:

BSTNode* bst_search(BSTNode* root, int key) { while (root != NULL && root->key != key) { if (key < root->key) { root = root->left; } else { root = root->right; } } return root; // 要么是目标节点,要么就是 NULL }

这段代码的亮点是处理了“找不到”的情况:循环退出时 root 为 NULL,直接返回 NULL 就行。为什么每次比较都能排除一半可能?因为 BST 的不变量保证,如果 key 小于当前节点,那目标只可能出现在左子树,右子树和当前节点都不用看了。

平均复杂度 O(log n),最坏 O(n),触发最坏情况的原因第四章详细讲。

3.2 插入:找失败的位置,挂上新节点

插入的本质是“执行一次失败的查找”。从根开始往下走,走到一个空位时,这个空位就是新节点应该在的地方。

BSTNode* bst_insert(BSTNode* root, int key) { if (root == NULL) { BSTNode* node = (BSTNode*)malloc(sizeof(BSTNode)); node->key = key; node->left = node->right = NULL; return node; } if (key < root->key) { root->left = bst_insert(root->left, key); } else if (key > root->key) { root->right = bst_insert(root->right, key); } // key 已经存在时,不同业务有不同处理: // 最常见是更新 value,也可以直接忽略 return root; }

这里有个新手容易纠结的问题:key 相等怎么办?标准教材里很多默认“不允许重复 key”。工程上如果允许重复,我强烈建议做一个自动约定:始终把相等的 key 插到右子树。千万不要一会插左边一会插右边,否则删除操作会非常痛苦,因为你没法保证相等节点聚集在哪里。当然,最干净的做法是每个节点额外存一个 count 字段,遇到重复 key 只把 count 加一,这是最优解。

递归写法为了返回新的子树根,所以每个递归层都要接收返回值并挂到对应的 left 或 right 上。如果你不习惯递归,插入也可以写成迭代,额外用一个 parent 指针记录当前节点的父节点,找到空位后把新节点挂到 parent 下面。

3.3 删除:最考验细节的情况分级处理

删除是 BST 里最容易写错的函数,没有之一。

先分类:

  • 待删除节点是叶子节点:直接删掉,父节点对应指针置空。
  • 待删除节点只有一个孩子:让孩子节点顶替自己的位置。
  • 待删除节点有两个孩子:情况最复杂,不能直接删,要用前驱或后继节点的值覆盖它,然后删掉那个前驱/后继。

为什么双孩子情况不能直接删?因为一个节点下面挂着两个子树,无论让左孩子顶替还是右孩子顶替,都会丢掉另一棵子树。你可能会问:能不能把左右子树合并后再挂上去?也行,但重新拼接很麻烦,而且很容易破坏 BST 的有序性。经典做法是曲线救国:用中序后继(右子树中最小的那个节点)或中序前驱(左子树中最大的那个节点)的值覆盖当前节点,再递归删除那个后继或前驱。

中序后继一定至多只有一个孩子。想一下:如果后继有左孩子,那左孩子比后继小,又比当前节点大,那么中序顺序里左孩子会插到当前节点和后继之间,这样后继就不是后继了。所以后继要么没有孩子,要么只有右孩子。删除一个只有右孩子或没有孩子的节点,就回到了前面两种简单情况。

C 语言里要真正修改调用者的指针,用二级指针比较直接:

void bst_delete(BSTNode** root, int key) { if (*root == NULL) { return; } if (key < (*root)->key) { bst_delete(&(*root)->left, key); } else if (key > (*root)->key) { bst_delete(&(*root)->right, key); } else { // 情况一:没有左孩子,直接把右孩子提上来 if ((*root)->left == NULL) { BSTNode* tmp = *root; *root = (*root)->right; free(tmp); } // 情况二:没有右孩子,直接把左孩子提上来 else if ((*root)->right == NULL) { BSTNode* tmp = *root; *root = (*root)->left; free(tmp); } // 情况三:左右孩子都有,找中序后继替换 else { BSTNode* succ = (*root)->right; while (succ->left != NULL) { succ = succ->left; } (*root)->key = succ->key; // 用后继的值覆盖当前节点 bst_delete(&(*root)->right, succ->key); // 删除右子树里的后继 } } }

注意两个细节。第一,覆盖值之后再递归删除后继,必须从(*root)->right开始往下找,因为后继一定在当前节点右子树的最左边,这个方向是确定的。第二,递归删除后继时,传入的是&(*root)->right,这样能正确修改父节点的指针,最终把后继节点从树上摘下来。

如果你用传递引用的语言比如 C++,可以少写一层取地址,但核心逻辑一模一样。很多面试官会让你手写删除,建议上述三种情况各自画一棵小树走一遍,比死记代码有效。

3.4 遍历:四种顺序怎么选

遍历常用于调试和业务输出,BST 场景下四种遍历各有用途:

  • 前序(根、左、右):可以用来做树的序列化,把树结构保存下来,之后再恢复。
  • 中序(左、根、右):输出有序序列,判 BST,还能配合众数、TopK 一类问题。
  • 后序(左、右、根):孩子先于父节点处理,适合释放整棵树内存、计算子树高度。
  • 层序(按层从左到右):广度优先,适合按层级打印、统计每层节点数。

以中序为例,判断 BST 的算法可以这么写:维护一个 prev 变量,中序遍历时如果当前节点值小于等于 prev,说明违反严格升序,不是 BST。这里要特别留意“等于”的情况:如果题目允许重复值,那<=可能误判,所以做题前先确认清楚允许什么口径。

4. 性能瓶颈分析:BST 为什么最怕有序数据

4.1 高度决定复杂度,而输入顺序决定高度

BST 所有操作的耗时都跟树的高度直接相关。查找一个节点,最多比较 height + 1 次。插入和删除同理,也是沿着一条从根到叶子的路径走。

理想情况下,一棵包含 n 个节点的 BST 高度是 O(log n)。原因很简单:如果树是平衡的,每一层节点数翻倍,那么 n 个节点就只撑起 log₂n 层上下。但 BST 并没有强制平衡,它只约束“左小右大”,没有约束左右子树的节点数要差不多。这就给退化留下了一个大口子。

有人统计过随机顺序插入得到的 BST 平均高度也是 O(log n),这个结论看似给了信心,但对生产环境没用。生产数据常常不是随机的,恰恰是有序的、接近有序的、带强规律性的,而这些情况正好全是退化的重灾区。

4.2 有序插入如何让 BST 退化成链表

按 1, 2, 3, 4, 5 的顺序依次插入空 BST:

1 \ 2 \ 3 \ 4 \ 5

每次新节点都插在最右边,整棵树形成一条斜线,高度等于节点数 n。这时候查找第 5 个节点需要走 5 步,查找第 100000 个节点要 100000 步,O(log n) 的好处消失殆尽,只剩 O(n),跟链表的顺序扫描没区别。

更隐蔽的是“近似有序”数据。比如订单流水按用户 ID 从小到大批量导入,Redis 的跳跃表、TreeMap 的实现里都有类似问题:如果设计不处理插入顺序,一旦出现这种局部有序,性能马上劣化。BST 没有自平衡能力,这是它作为教科书结构之外的第一个硬伤。

4.3 横向对比:BST、哈希表与有序数组

把 BST 放进更大背景里看,它的位置就很清晰了:

操作有序数组哈希表BST(平均)BST(最坏)
查找O(log n)O(1) 平均O(log n)O(n)
插入O(n)O(1) 平均O(log n)O(n)
删除O(n)O(1) 平均O(log n)O(n)
有序遍历O(n)需要额外排序 O(n log n)O(n) 天然有序O(n) 仍有序
范围查询O(log n + k)困难O(log n + k)O(n)

看到重点了吗?BST 的单点操作在平均情况下不算最极致,哈希表单点更快;它的核心价值在于“动态维护 + 有序遍历 + 范围查询”这三件事可以同时做到。哈希表做了有序排序后也需要额外开销,而 BST 天然有序。

范围查询尤其明显。你要找“key 在 [lo, hi] 之间的所有节点”,BST 只沿路径走到 lo 和 hi 之间的边界,然后中序遍历子树就能拿到天然有序的结果。哈希表想支持这种查询,只能全表扫描或者引入额外的有序索引,本质上又是造一棵树。

4.4 破局思路:AVL、红黑树和 B 树

既然裸 BST 会退化,工业界的解法就是加平衡约束。

  • AVL 树:严格平衡,任意节点的左右子树高度差不能超过 1,查询性能稳定,但插入删除后的旋转调整频繁,适合查询远多于修改的场景。
  • 红黑树:近似平衡,最长路径不超过最短路径的两倍,插入删除时旋转次数更少,Java TreeMap、C++ std::map 都选它,适合增删频繁的通用场景。
  • B 树 / B+ 树:多路搜索树,一个节点存多个 key,降低树高,减少磁盘 IO,数据库索引用它再合适不过。

理解 BST 的性能瓶颈之后,再看这些平衡树就会觉得格外顺理成章。AVL 的理念是“强制左右高度接近”,红黑树是用颜色标记和局部旋转控制平衡,B+ 树是拓宽节点扇出、用更矮的树换更少的磁盘访问。它们全都是在解决同一个问题:不要让树长成一条线。

5. 三连实战:众数、最优 BST 与卡特兰数

5.1 二叉搜索树中的众数:Java 中序遍历解法

这个题对应 LeetCode 501,给定一棵含重复值的 BST,找出所有出现次数最多的值。难点在于:如果直接用一个 HashMap 统计整棵树,空间 O(n) 太浪费,而且没利用 BST 有序性;如果不用额外空间,怎么知道当前值是众数?

BST 中序遍历有序,这意味着相同的值一定连续出现。只要在中序遍历过程中看“当前值和上一个值是否相等”,就能统计每个值连续出现的次数。

Java 实现我写成这样,注意处理初始值和测试用例变量残留的问题:

class Solution { private int curVal; private int curCount; private int maxCount; private boolean first = true; private List<Integer> result = new ArrayList<>(); public int[] findMode(TreeNode root) { curVal = 0; curCount = 0; maxCount = 0; first = true; result.clear(); inorder(root); int[] ans = new int[result.size()]; for (int i = 0; i < ans.length; i++) { ans[i] = result.get(i); } return ans; } private void inorder(TreeNode node) { if (node == null) { return; } inorder(node.left); if (first) { // 第一个节点特殊处理,避免初始值和节点实际值冲突 curVal = node.val; curCount = 1; first = false; } else if (node.val == curVal) { curCount++; } else { curVal = node.val; curCount = 1; } if (curCount > maxCount) { maxCount = curCount; result.clear(); result.add(curVal); } else if (curCount == maxCount) { result.add(curVal); } inorder(node.right); } }

踩过的坑有两个。第一个是 LeetCode 的判题环境会在同一个实例上跑多个测试用例,如果不重置resultmaxCount,第二批数据会被上一批污染,所以我干脆在方法入口全部重置。第二个是首节点处理:如果用curVal = node.val; curCount = 1写在比较逻辑之前,会让第一个值的计数出错,所以我加了一个first标志单独走首节点分支。

这个解法的空间复杂度还能再压到 O(1) 递归栈之外,用 Morris 中序遍历,但理解递归版本之后再优化会容易得多。实际面试先写出递归版本拿分,再说“可以优化到 O(1) 空间”,方向反而是加分项。

5.2 最优二叉搜索树:C 语言动态规划实现

“最优二叉搜索树”是一个经典动态规划问题:给定 n 个有序 key 和它们各自的查找概率,构造一棵期望查找代价最小的 BST。

为什么有序 key 还需要“最优”?因为不同的树结构,查找代价完全不同。一个概率大的 key 如果埋得很深,整体代价就差。最优 BST 的目标是让“概率大的节点尽量靠上”。

状态定义这样想:用dp[i][j]表示只考虑 key[i] 到 key[j] 构成的子树的最优期望查找代价。假设在这个区间选 key[k] 做根,那么左子树是 key[i] 到 key[k-1],右子树是 key[k+1] 到 key[j],关键转移式是:

dp[i][j] = min(dp[i][k-1] + dp[k+1][j] + sum(prob[i..j]))

为什么要加sum(prob[i..j])?因为所有节点在子树里都比原来多深了一层,每个节点的查找次数都会加一次,所以整体期望代价要增加区间内全部概率之和。只看公式有点抽象,拿一棵只有两个 key 的树手动推一遍就清楚了。

C 语言实现用前缀和快速算区间概率和,外层按区间长度从小到大枚举,保证大区间依赖的小区间先被算好:

#include <stdio.h> #include <string.h> #define MAXN 100 #define INF 1e9 // p[1..n] 是每个 key 的查找概率,下标从 1 开始 double optBST(double p[], int n) { double dp[MAXN][MAXN]; double prefix[MAXN + 1]; memset(dp, 0, sizeof(dp)); memset(prefix, 0, sizeof(prefix)); // 概率前缀和,用于快速求区间和 for (int i = 1; i <= n; i++) { prefix[i] = prefix[i - 1] + p[i]; } // 初始化单个节点的最优代价,就是它自己的概率 for (int i = 1; i <= n; i++) { dp[i][i] = p[i]; } // len 是区间长度,从小到大计算 for (int len = 2; len <= n; len++) { for (int i = 1; i + len - 1 <= n; i++) { int j = i + len - 1; dp[i][j] = INF; for (int k = i; k <= j; k++) { double leftCost = (k > i) ? dp[i][k - 1] : 0; double rightCost = (k < j) ? dp[k + 1][j] : 0; double total = leftCost + rightCost + (prefix[j] - prefix[i - 1]); if (total < dp[i][j]) { dp[i][j] = total; } } } } return dp[1][n]; }

代码里有三个细节值得注意。第一,左子树或右子树为空时,代价要按 0 处理,所以我在计算leftCostrightCost前判断了k == ik == j的边界。第二,外层循环必须是区间长度由短到长,因为长度为 3 的区间会依赖长度为 1 的最优值,这个依赖顺序不能反过来。第三,这里只计算了期望代价,如果要真正重建最优树,还需要额外用root[i][j]数组记录每个区间选中的根 k,然后在递归里用这个信息建树。

5.3 不同的二叉搜索树:卡特兰数的实际意义

“不同的二叉搜索树”问的是:给定 n 个值互不相同的节点,能构造出多少种不同结构的 BST?

比如 n = 3 时就有 5 种:根为 1、根为 2(左右各一个)、根为 3 三种极端对称结构,还有两种嵌套结构,总共 5。

状态转移非常好懂:选一个节点当根,左子树有 i 个节点,右子树就有 n-1-i 个节点。左右子树的形态数是独立的,所以总个数等于所有划分方式下左右形态数的乘积之和:

dp[n] = sum(dp[i] * dp[n-1-i]) for i in 0..n-1

Java 实现:

public int numTrees(int n) { int[] dp = new int[n + 1]; dp[0] = 1; // 空树也是一种形态 for (int i = 1; i <= n; i++) { for (int k = 0; k < i; k++) { dp[i] += dp[k] * dp[i - 1 - k]; } } return dp[n]; }

这里的dp[0] = 1特别重要,左子树或右子树为空时,它的形态数应该是 1,而不是 0,否则乘积全变成 0。这个递推的结果就是卡特兰数,通项公式为:

Catalan(n) = C(2n, n) / (n + 1)

卡特兰数增长非常快,n = 10 时已经有 16796 种,n = 15 直接冲到 9694845。很多刚学递归的时候会写“暴力枚举所有 BST 结构”的解法,对 n 小还行,稍微大一点就原地爆炸。理解了这个数量级,你才会明白动态规划在这里不是炫技,而是唯一可行的思路。

6. 常见问题避坑与个人心得

6.1 高频翻车问题速查表

常见问题现象根因解决方案
树退化成链表插个 10 万条有序数据后查询越来越慢输入有序,每次都挂在同一侧换成 AVL、红黑树,或改用 Treap
删除双孩子后丢节点删完节点数不对,树不完整直接让一个孩子顶替,丢了另一棵子树用前驱/后继值替换,再递归删后继
插入递归过深n 较大时爆栈树高接近 n,递归层数失控改成迭代插入,或先平衡化
中序判 BST 误判比如 [5,5,6] 被判错相等值需要统一口径先确认题目允不允许重复,再决定<=还是<
Java 众数首节点计数错第一个节点的 count 变成 2初始值和首节点撞上first标志单独处理首个节点

6.2 几个帮助绕坑的实操建议

写 BST 代码前,先画一棵 7 个节点的平衡树,把每种操作走一遍,尤其是删除的三种情况。我见过有人把删除双孩子的代码背了五六遍,一到白板就卡壳,根因是把“后继到底有几个孩子”这个推导过程背丢了,只背了结论。记住结论背后的逻辑,写起来反而更顺。

调试 BST 时多利用中序打印。一段inorder(root)输出后如果不是升序,树结构一定有问题,不用看任何复杂调试器。这个方法陪我抓出过好几次指针接错、父节点没更新的低级 Bug。

处理大规模数据时,我会写一个随机小工具,生成十万条乱序 key 和十万条有序 key 分别插入,然后对比平均查找耗时。这一步跑完,退化的现象一眼可见,比背复杂度结论更有说服力。

最后再分享一个习惯:凡是代码里需要动态维护有序集合,我先问自己一句,插入顺序是随机的吗?如果不是,裸 BST 就应该直接出局,换平衡树甚至 B+ 树。很多人写代码出问题,不是因为他们不会背 BST 的时间复杂度,而是没有把“性能瓶颈”当做一个设计问题来对待。能把这一步想清楚,BST 这门基础课才算真正过关了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询