返回上一级
2892 字
14 分钟
判断 BST 与删除节点

p35(15.9 分钟)、p36(17.8 分钟)、p37(17.3 分钟)

判断是否为 BST#

这是道著名的编程面试题。

BST 的递归定义#

二叉树:每个节点最多两个子节点
BST:对「每个」节点,其左子树所有值更小、右子树所有值更大
若允许重复值,左子树可为「小于或等于」
左子树、右子树「自身也必须是 BST」—— 不仅仅是根节点

反例#

拿 A/B/C/D 四棵树看:A 和 C 是 BST;B 和 D 不是。

B:根值 10,但左子树中有 11(比 10 大)→ 违反「左子树全部更小」
D:根值 5,左子树有 1、右子树有 8、9、12 → 对根节点来说没问题;
但值为 8 的节点,其左边有个 9 → 不是 BST

只检查根节点是不够的,必须对每个节点都成立。D 这个反例最容易漏。

方法一:检查子树#

这是最容易想到、但效率不高的办法。两个辅助函数:

isSubtreeLesser(root, value) → 子树中所有元素都 ≤ value 则返回真
isSubtreeGreater(root, value) → 子树中所有元素都 > value 则返回真

主函数需要同时满足 4 个条件:

① isSubtreeLesser(root->left, root->data)
② isSubtreeGreater(root->right, root->data)
③ isBinarySearchTree(root->left)
④ isBinarySearchTree(root->right)

基本情况:根为空 → 返回真。

替代写法:不写这两个函数,改成 FindMax(左子树) 与 FindMin(右子树) 比较,也行得通。

代价是:每个节点都要去看其子树中的所有节点,大量节点被反复遍历、反复读取比较 → 时间复杂度 O(n²)。

方法二:给每个节点定一个「允许范围」#

思路:

根节点范围 (−∞, +∞)
向左走 → 收紧上限(新上限 = 当前节点数据)
向右走 → 收紧下限(新下限 = 当前节点数据)

检查变成常数时间:只需判断 root->data > min && root->data < max,不再遍历子树。

int isBSTUtil(struct node *root, int min, int max) {
if (root == NULL) return 1;
if (root->data > min && root->data < max
&& isBSTUtil(root->left, min, root->data)
&& isBSTUtil(root->right, root->data, max))
return 1;
else return 0;
}
int isBinarySearchTree(struct node *root) {
return isBSTUtil(root, INT_MIN, INT_MAX);
}

为什么要包一层:递归部分抽成工具函数 isBSTUtil,外面再包一层只收根地址的 isBinarySearchTree,因为调用者只想传根地址。

复杂度:每个节点只访问一次,节点处判断是常数时间 → O(n)。

这段代码没有处理重复值(要求左子树严格更小、右子树严格更大)。

方法三:中序遍历#

对二叉树做中序遍历,若是 BST,读到的数据必然有序
技巧:遍历时记录前一个读过的节点
任何时刻「当前节点数据必须大于前一个节点数据」

这个思路没给出实现代码。

三种方法对比#

方法思路复杂度有代码吗
一、检查子树每个节点验证其子树所有值O(n²)给了
二、传上下界每个节点有允许区间O(n)给了 ✓
三、中序遍历记录前值,必须递增O(n)只讲思路

删除节点#

在大多数数据结构中删除操作都很棘手,BST 也一样。

删除后必须保留 BST 性质:每个节点左小右大。

删除一个节点要做两件事:

① 从父节点移除对该节点的引用(切断链接)
② 回收该节点的内存(C++ 用 delete,C 用 free)

三种情况#

情形一:被删节点是叶子(无子节点)。最容易。切断父节点对它的引用,再从内存中清除。

情形二:被删节点只有一个子节点。把父节点与该唯一子节点直接相连;子节点及其以下整棵子树都保持连在树上,只有被删节点脱离。

情形三:被删节点有两个子节点。不能简单连一个子节点(会丢掉另一半子树)。做法:

① 在「右子树中找最小值」
② 把该最小值「复制/填入」被删节点
③ 再从右子树中删除那个「最小值节点」
(它必无左孩子,于是情形三降级为情形一或情形二)

为什么是「右子树的最小值」#

它来自右子树 → 一定大于被删节点
又是右子树最小值 → 右侧其余元素都 ≥ 它
左侧元素本就都更小
→ BST 性质保持

关键性质:

树/子树中具有最小值的节点一定没有左孩子(有左孩子就会有更小的值),但可能有右孩子
→ 若有右孩子 → 降级为情形二
→ 若无右孩子 → 降级为情形一

对称方案:也可以找左子树中的最大值来替换。

左子树最大值 ≥ 左子树所有值,且因为来自左子树所以 < 被删值
该节点一定没有右孩子(有右孩子就有更大值),可能有左孩子

代码里用的是「右子树中的最小值」(FindMin(root->right)),没有出现「中序后继」这个词。两者本质等价。

代码#

struct node *Delete(struct node *root, int data) {
if (root == NULL) return root; /* 空树直接返回 */
else if (data < root->data)
root->left = Delete(root->left, data); /* 去左子树删 */
else if (data > root->data)
root->right = Delete(root->right, data); /* 去右子树删 */
else { /* 找到了 */
/* 情形一:无子节点 */
if (root->left == NULL && root->right == NULL) {
delete root; /* C 用 free(root) */
root = NULL;
}
/* 情形二:只有一个子节点 */
else if (root->left == NULL) { /* 只有右孩子 */
struct node *temp = root;
root = root->right;
delete temp;
}
else if (root->right == NULL) { /* 只有左孩子 */
struct node *temp = root;
root = root->left;
delete temp;
}
/* 情形三:两个子节点 */
else {
struct node *temp = FindMin(root->right); /* 右子树最小值 */
root->data = temp->data; /* 复制值 */
root->right = Delete(root->right, temp->data); /* 删掉那个副本 */
}
}
return root; /* 统一在所有分支之后返回 */
}

几个要点:

  • 删除函数必须返回根节点指针,因为删除后根可能改变;传进来的只是根地址的本地副本,地址变了就得返回回去
  • 各分支的 return root 合并到最后统一一条
  • 情形一删完后 root 成为悬空指针(堆对象已释放但 root 仍持有地址),所以置为 NULL 再返回
  • 父节点中的引用会在递归展开时被修正(root->left = Delete(...) / root->right = Delete(...))

示例树与具体数字#

示例树:根 5;左 3(左孩子 1);右 12;12 的左孩子 7(7 的右孩子 9)
另有 15(左 13、右 17)、19 等
叶子节点(4 个): 1、9、13、19
只有一个子节点(2 个):3(只有左孩子 1)、7(只有右孩子 9)
删 19 → 父节点 17 的子节点置空
删 7 → 把 9 设为 5 的右孩子(9 及其以下整棵子树接到 5 右侧)
删 3 → 把 1 设为 5 的左孩子
删 15(两个子节点)→ 右子树最小值 = 17 → 用 17 覆盖 15 的位置,
再删掉原 17 节点(17 只有一个子节点 19 → 情形二),最终树仍合法

若 9 下面还有节点:9 的左孩子值必须满足「< 9、> 7、> 5、< 12」,只剩一个选择,只能用 8。

用左子树最大值方案时:15 的左子树最大值为 14,复制 14,而 14 的节点没有左孩子 → 降级为情形一,直接删掉。

中序后继#

定义#

中序后继(inorder successor):在中序遍历序列中紧跟给定节点之后的那个节点。

在 BST 中它就是树里下一个更大的值。

中序遍历回顾#

示例 BST 的中序序列:

6, 8, 10, 11, 12, 15, 16, 17, 20, 25, 27

恰好是排好序的整数序列。

访问顺序里有个规律:

从某节点的左孩子返回该节点时,该节点尚未被访问
从右孩子返回父节点时,父节点已被访问过

两种情形#

情形一:该节点有右子树。

中序后继 = 右子树中的最左侧节点
= 右子树中具有最小值的节点
做法:只要能向左就一直向左走

情形二:该节点没有右子树。

需要「从根节点走到该节点」
找「最近的祖先节点,使得给定节点处于其左子树中」

逻辑:

若从左边回到某祖先,该祖先尚未被访问 → 它就是后继
若从右边回到某祖先,该祖先已访问过 → 继续往上回退

具体例子#

10 → 11 10 有右子树,右子树最小是 11
8 → 10 8 无右子树,从左边回到 10
12 → 15 12 无右子树;先到 10,但 12 在 10 的右边,排除;
再到 15,12 在 15 的左边 → 15 是后继
6 → 8 从根走到 6 的路径上,6 在其左子树中的最深的那个祖先是 8
15 → 16、16 → 17、17 → 20、20 → 25、25 → 27
27(最大值)→ 没有后继,返回 NULL

12 → 15 这个例子最能说明「祖先 10 被排除、15 才符合」。

为什么不直接中序遍历一遍#

中序遍历 O(n) 代价太高
BST 的插入/删除/搜索都是 O(h),所以希望在 O(h) 内找到后继
平衡二叉树高度 = log₂N,O(log n) 几乎是最佳运行时间

父指针方案#

问题:怎么从一个节点走到它的父节点?

方案:把节点定义从 3 个字段扩成 4 个,多存一个父节点的地址,就能靠父链接向上遍历找祖先。

但若不存在父指针,就退化为「从根节点走到给定节点,记录沿途祖先」。

代码#

struct node *Getsuccessor(struct node *root, int data) {
struct node *current = Find(root, data); /* 先找到该节点 */
if (current == NULL) return NULL; /* 树中不存在 */
if (current->right != NULL) { /* 情形一:有右子树 */
return FindMin(current->right); /* 右子树最小值(最左节点) */
}
else { /* 情形二:无右子树 */
struct node *successor = NULL;
struct node *ancestor = root;
while (ancestor != current) {
if (current->data < ancestor->data) { /* current 在祖先左侧 */
successor = ancestor; /* 这个祖先「可能是」后继 */
ancestor = ancestor->left; /* 继续向左,找更深的 */
}
else { /* current 在祖先右侧 */
ancestor = ancestor->right; /* 只向右走,不更新 successor */
}
}
return successor; /* 可能为 NULL */
}
}

情形二里只有在向左走时才更新 successor,也就是「最后一个左拐的祖先」。

接口设计上,也可以直接把数据作为参数(本实现就是),或者传当前节点指针以省掉一次搜索。

复杂度#

Find 找节点:O(h)
FindMin:O(h)
从根走到某节点:O(h)
→ 整个 Getsuccessor 的总时间复杂度 = O(h)

平衡树时就是 O(log n)。

复杂度汇总#

判断是否为 BST(传上下界法) O(n)
判断是否为 BST(检查子树法) O(n²)
删除节点 O(h)
找中序后继 O(h)

坑#

  • 判断 BST 不能只看根和直接子节点,反例 D 就是根合法但子树里有问题;检查子树法是 O(n²),传上下界法才是 O(n)。
  • 传上下界法用的是严格不等式,不处理重复值。
  • 删除有两个孩子的节点用「右子树最小值」(= 中序后继),也可以对称地用「左子树最大值」;最小值节点必无左孩子,最大值节点必无右孩子,这是降级的关键。
  • 删除函数必须返回根指针,且情形一删完要把 root 置 NULL,否则是悬空指针。
  • 中序后继情形二只更新「向左走」时的祖先,向右走不更新;最大值节点没有后继,返回 NULL。找后继不要用中序遍历(O(n)),要利用 BST 性质做到 O(h)。
判断 BST 与删除节点
https://me.622168.xyz/posts/data-structure/16-bst-validate-delete/
作者
Shaw 的小屋
发布于
2026-09-18
许可协议
CC BY-NC-SA 4.0