二叉搜索树(BST)算法学习笔记
什么是二叉搜索树?
二叉搜索树(Binary Search Tree,简称 BST),又称为排序二叉树或二叉排序树,是一种特殊的二叉树结构。它通过节点之间的值大小关系来组织数据,使得查找、插入和删除等操作能够高效执行。
核心定义
二叉搜索树满足以下性质:
-
空树是二叉搜索树:当树中没有节点时,它自然满足所有性质。
-
递归性质:
- 对于树中的任意一个节点,其左子树(如果存在)中的所有节点的值都小于该节点的值。
- 其右子树(如果存在)中的所有节点的值都大于该节点的值。
- 左子树和右子树本身也必须是二叉搜索树。
核心特性
- 有序性:BST的中序遍历结果是一个严格递增的序列(假设节点值唯一)。
- 动态性:支持高效地插入和删除节点,无需像数组那样重新分配空间。
- 分治思想:每次操作通过比较当前节点值与目标值,将问题规模减半。
数据结构表示
在JavaScript中,我们可以通过类(Class)来表示二叉搜索树的节点:
class TreeNode {
constructor(val) {
this.val = val; // 节点值
this.left = null; // 左子节点指针
this.right = null; // 右子节点指针
}
}
时间复杂度分析
操作时间复杂度表
| 操作 | 平均时间复杂度 | 最坏时间复杂度 | 条件说明 |
|---|---|---|---|
| 查找 | O(log n) | O(n) | 当树高度为log n时为平均情况;当树退化为链表时为最坏情况 |
| 插入 | O(log n) | O(n) | 同上 |
| 删除 | O(log n) | O(n) | 同上 |
时间复杂度的数学解释
平均情况O(log n):假设节点的插入顺序是随机的,那么树的高度大约为log n。此时,查找、插入和删除操作需要遍历的节点数与树的高度成正比,因此时间复杂度为O(log n)。
最坏情况O(n):当插入的节点值是严格递增或递减的顺序时,树会退化为一条链表(所有节点只有右子树或只有左子树)。此时,树的高度为n,操作的时间复杂度退化为O(n)。
如何避免最坏情况?
在实际应用中,可以通过以下方式避免BST退化为链表:
- 随机化插入顺序:如果插入的数据是随机的,那么树的高度大概率保持在O(log n)。
- 使用平衡二叉搜索树:如AVL树、红黑树等,通过平衡条件强制保持树的高度为O(log n)。
- 重构树结构:定期对树进行重构,如通过中序遍历得到有序数组,再重建平衡树。
基本操作实现(JavaScript)
1. 查找(Search)
查找操作是BST的核心功能之一。它的实现基于以下逻辑:
从根节点开始,依次比较当前节点值与目标值:
- 如果相等,查找成功;
- 如果目标值更小,继续在左子树中查找;
- 如果目标值更大,继续在右子树中查找。
function searchBST(root, target) {
if (!root || root.val === target) {
return root; // 找到或未找到返回当前节点
}
return target < root.val
? searchBST(root.left, target)
: searchBST(root.right, target);
}
关键点:
- 函数返回节点或
null,便于后续使用。 - 递归终止条件是当前节点为
null或值等于目标值。 - 递归调用根据目标值与当前节点值的比较结果决定方向。
2. 插入(Insert)
插入操作需要在保持BST性质不变的前提下将新节点添加到树中。
function insertIntoBST(root, val) {
if (!root) {
return new TreeNode(val); // 空树时创建新根节点
}
if (val < root.val) {
root.left = insertIntoBST(root.left, val); // 递归左子树并更新指针
} else if (val > root.val) {
root.right = insertIntoBST(root.right, val); // 递归右子树并更新指针
}
return root; // 返回当前节点,保持树的结构
}
关键点:
- 递归到底部插入新节点,然后逐层返回。
- 如果值已存在,通常不重复插入(上述代码未处理重复值)。
- 递归调用返回新构建的子树,并重新赋值给父节点的
left或right。
3. 删除(Delete)
删除是BST中最复杂的操作,需要考虑三种情况:
情况一:被删节点是叶子节点(无左右子树)
- 直接删除,返回
null。
情况二:被删节点只有一个子树
- 用其唯一子树"顶替"该节点。
情况三:被删节点有两个子树
- 策略:用左子树的最大值或右子树的最小值替代当前节点值,然后删除那个替代节点。
function deleteNode(root, key) {
if (!root) return null; // 未找到节点
if (root.val === key) {
// 情况1:无子节点
if (!root.left && !root.right) {
return null;
}
// 情况2:只有左子树
if (root.left && !root.right) {
return root.left;
}
// 情况3:只有右子树
if (!root.left && root.right) {
return root.right;
}
// 情况4:左右子树都存在
// 使用右子树的最小值(前驱/后继均可)
const successor = findSuccessor(root);
root.val = successor.val; // 替换值
root.right = deleteNode(root.right, successor.val); // 递归删除后继节点
return root;
} else if (key < root.val) {
root.left = deleteNode(root.left, key); // 递归左子树并更新指针
} else {
root.right = deleteNode(root.right, key); // 递归右子树并更新指针
}
return root;
}
// 寻找右子树最小值(中序后继)
function findSuccessor(node) {
let successor = node.right;
while (successor.left) {
successor = successor.left;
}
return successor;
}
// 寻找左子树最大值(中序前驱)
function findPredecessor(node) {
let predecessor = node.left;
while (predecessor.right) {
predecessor = predecessor.right;
}
return predecessor;
}
关键点:
- 删除操作必须通过递归返回值更新父节点指针。
- 使用中序后继(右子树最小值)或中序前驱(左子树最大值)替代被删除节点,两者等价。
- JavaScript中无需手动释放内存,因为有垃圾回收机制。
4. 辅助函数
寻找左子树最大值:
function findMax(node) {
while (node.right) {
node = node.right;
}
return node;
}
寻找右子树最小值:
function findMin(node) {
while (node.left) {
node = node.left;
}
return node;
}
示例:构建并操作 BST
构建示例树
// 构建示例树
const root = new TreeNode(6);
root.left = new TreeNode(3);
root.right = new TreeNode(8);
root.left.left = new TreeNode(1);
root.left.right = new TreeNode(4);
root.right.left = new TreeNode(7);
root.right.right = new TreeNode(9);
查找操作
// 查找节点
function search(root, target) {
if (!root || root.val === target) {
console.log('找到节点:', root);
return root;
}
return target < root.val
? search(root.left, target)
: search(root.right, target);
}
// 使用示例
search(root, 7); // 输出:找到节点: TreeNode { val: 7, ... }
插入操作
// 插入节点
function insert(root, val) {
if (!root) {
return new TreeNode(val);
}
if (val < root.val) {
root.left = insert(root.left, val);
} else if (val > root.val) {
root.right = insert(root.right, val);
}
return root;
}
// 插入节点5
insert(root, 5);
// 验证插入结果
console.log(root.left.right.right.val); // 输出5
删除操作
// 删除节点
function deleteNode(root, key) {
if (!root) return null;
if (root.val === key) {
// 情况1:无子节点
if (!root.left && !root.right) {
return null;
}
// 情况2:只有左子树
if (root.left && !root.right) {
return root.left;
}
// 情况3:只有右子树
if (!root.left && root.right) {
return root.right;
}
// 情况4:左右子树都存在
// 使用右子树的最小值
const successor = findSuccessor(root);
root.val = successor.val;
root.right = deleteNode(root.right, successor.val);
return root;
} else if (key < root.val) {
root.left = deleteNode(root.left, key);
} else {
root.right = deleteNode(root.right, key);
}
return root;
}
// 删除节点3
deleteNode(root, 3);
// 验证删除结果
console.log(root.left.val); // 输出4
console.log(root.left.left.val); // 输出1
遍历与验证
虽然遍历不是BST的核心操作,但了解遍历方法对于理解BST的性质非常重要。
三种遍历方式
function inorder(root, result = []) {
if (root) {
inorder(root.left, result); // 先左子树
result.push(root.val); // 再根节点
inorder(root.right, result); // 最后右子树
}
return result;
}
function preorder(root, result = []) {
if (root) {
result.push(root.val); // 先根节点
preorder(root.left, result); // 再左子树
preorder(root.right, result); // 最后右子树
}
return result;
}
function postorder(root, result = []) {
if (root) {
postorder(root.left, result); // 先左子树
postorder(root.right, result); // 再右子树
result.push(root.val); // 最后根节点
}
return result;
}
验证是否为 BST
可以通过中序遍历并检查序列是否严格递增来验证一棵树是否为合法 BST:
function isValidBST(root) {
const arr = inorder(root);
for (let i = 1; i < arr.length; i++) {
if (arr[i] <= arr[i - 1]) return false;
}
return true;
}
更严谨的验证方法:在递归过程中传递上下界,避免仅依赖中序数组:
function isValidBST(root, low = -Infinity, high = Infinity) {
if (!root) return true;
if (root.val <= low || root.val >= high) return false;
return isValidBST(root.left, low, root.val)
&& isValidBST(root.right, root.val, high);
}
平衡二叉搜索树简介
BST的局限性
虽然BST在平均情况下性能优秀,但它存在一个致命缺陷:当插入的数据是有序的时,树会退化为链表,导致所有操作的时间复杂度退化为O(n)。
平衡二叉搜索树的解决方案
为了解决这一问题,计算机科学家提出了多种平衡二叉搜索树:
- AVL树:通过强制保持左右子树高度差不超过1来保证平衡。插入和删除操作的时间复杂度始终为O(log n)。
- 红黑树:通过节点颜色属性(红色或黑色)和一些平衡规则来保证树的高度不超过2 log n。它在Linux内核和C++标准库中被广泛使用。
- B树/B+树:为了解决磁盘I/O问题而设计,每个节点可以有多个子节点,适合数据库索引等场景。
平衡树与普通BST的对比
| 特性 | 普通 BST | 平衡 BST (如AVL/红黑树) |
|---|---|---|
| 平均时间复杂度 | O(log n) | O(log n) |
| 最坏时间复杂度 | O(n) | O(log n) |
| 插入/删除开销 | 较小 | 较大(需要平衡操作) |
| 实现复杂度 | 简单 | 复杂 |
实际应用场景
1. 动态集合实现
BST可以用来实现动态集合,如C++中的std::set和std::map。这些数据结构支持以下操作:
- 插入元素
- 删除元素
- 查找元素
- 获取前驱/后继元素
- 计算元素的排名
- 根据排名查找元素
2. 数据库索引
虽然数据库通常使用B树或B+树作为索引结构,但它们的设计思想与BST一脉相承。这些结构支持高效的范围查询和点查询。
3. 路由器路由表
在路由器中,路由表需要高效地查找与IP地址匹配的路由条目。使用BST或其变体可以实现这一功能,查找时间复杂度为O(log n)。
4. 编译器符号表
编译器需要维护变量、函数等符号的信息。使用BST可以高效地管理这些符号,支持快速查找和插入。
总结与扩展
BST的核心优势
- 有序性:天然支持有序操作,如范围查询、前驱后继查找等。
- 动态性:支持高效的插入和删除操作,无需像数组那样重新分配空间。
- 简单性:实现相对简单,是理解更复杂数据结构的基础。
BST的局限性
- 最坏情况退化:当插入顺序有序时,树的高度可达n,导致所有操作的时间复杂度退化为O(n)。
- 不支持高效范围查询:虽然可以实现,但需要遍历子树,效率不如平衡树或B+树。
扩展应用
- 笛卡尔树:一种特殊的二叉搜索树,同时满足二叉搜索树的性质和堆的性质。它在算法竞赛和数据压缩中有广泛应用。
- Treap:结合了二叉搜索树和堆的特性,通过随机化优先级来保持平衡。
- 区间树:用于管理区间数据,支持高效的区间查询。