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 有右子树,右子树最小是 118 → 10 8 无右子树,从左边回到 1012 → 15 12 无右子树;先到 10,但 12 在 10 的右边,排除; 再到 15,12 在 15 的左边 → 15 是后继6 → 8 从根走到 6 的路径上,6 在其左子树中的最深的那个祖先是 815 → 16、16 → 17、17 → 20、20 → 25、25 → 2727(最大值)→ 没有后继,返回 NULL12 → 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)。
请输入编辑凭据,只有站点所有者可以修改文章。