hello-algo/docs/chapter_tree/binary_search_tree.md
Yudong Jin 1c8b7ef559
refactor: Replace 结点 with 节点 (#452)
* Replace 结点 with 节点
Update the footnotes in the figures

* Update mindmap

* Reduce the size of the mindmap.png
2023-04-09 04:32:17 +08:00

334 lines
11 KiB
Markdown
Executable file
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 二叉搜索树
「二叉搜索树 Binary Search Tree」满足以下条件
1. 对于根节点,左子树中所有节点的值 $<$ 根节点的值 $<$ 右子树中所有节点的值;
2. 任意节点的左子树和右子树也是二叉搜索树,即也满足条件 `1.`
![二叉搜索树](binary_search_tree.assets/binary_search_tree.png)
## 二叉搜索树的操作
### 查找节点
给定目标节点值 `num` ,可以根据二叉搜索树的性质来查找。我们声明一个节点 `cur` ,从二叉树的根节点 `root` 出发,循环比较节点值 `cur.val``num` 之间的大小关系
-`cur.val < num` ,说明目标节点在 `cur` 的右子树中,因此执行 `cur = cur.right`
-`cur.val > num` ,说明目标节点在 `cur` 的左子树中,因此执行 `cur = cur.left`
-`cur.val = num` ,说明找到目标节点,跳出循环并返回该节点即可;
=== "<1>"
![查找节点步骤](binary_search_tree.assets/bst_search_step1.png)
=== "<2>"
![bst_search_step2](binary_search_tree.assets/bst_search_step2.png)
=== "<3>"
![bst_search_step3](binary_search_tree.assets/bst_search_step3.png)
=== "<4>"
![bst_search_step4](binary_search_tree.assets/bst_search_step4.png)
二叉搜索树的查找操作和二分查找算法如出一辙,也是在每轮排除一半情况。循环次数最多为二叉树的高度,当二叉树平衡时,使用 $O(\log n)$ 时间。
=== "Java"
```java title="binary_search_tree.java"
[class]{BinarySearchTree}-[func]{search}
```
=== "C++"
```cpp title="binary_search_tree.cpp"
[class]{BinarySearchTree}-[func]{search}
```
=== "Python"
```python title="binary_search_tree.py"
[class]{BinarySearchTree}-[func]{search}
```
=== "Go"
```go title="binary_search_tree.go"
[class]{binarySearchTree}-[func]{search}
```
=== "JavaScript"
```javascript title="binary_search_tree.js"
[class]{}-[func]{search}
```
=== "TypeScript"
```typescript title="binary_search_tree.ts"
[class]{}-[func]{search}
```
=== "C"
```c title="binary_search_tree.c"
[class]{binarySearchTree}-[func]{search}
```
=== "C#"
```csharp title="binary_search_tree.cs"
[class]{BinarySearchTree}-[func]{search}
```
=== "Swift"
```swift title="binary_search_tree.swift"
[class]{BinarySearchTree}-[func]{search}
```
=== "Zig"
```zig title="binary_search_tree.zig"
[class]{BinarySearchTree}-[func]{search}
```
### 插入节点
给定一个待插入元素 `num` ,为了保持二叉搜索树“左子树 < 根节点 < 右子树的性质插入操作分为两步
1. **查找插入位置**与查找操作类似我们从根节点出发根据当前节点值和 `num` 的大小关系循环向下搜索直到越过叶节点遍历到 $\text{null}$ 时跳出循环
2. **在该位置插入节点**初始化节点 `num` 将该节点放到 $\text{null}$ 的位置
二叉搜索树不允许存在重复节点否则将会违背其定义因此若待插入节点在树中已经存在则不执行插入直接返回即可
![在二叉搜索树中插入节点](binary_search_tree.assets/bst_insert.png)
=== "Java"
```java title="binary_search_tree.java"
[class]{BinarySearchTree}-[func]{insert}
```
=== "C++"
```cpp title="binary_search_tree.cpp"
[class]{BinarySearchTree}-[func]{insert}
```
=== "Python"
```python title="binary_search_tree.py"
[class]{BinarySearchTree}-[func]{insert}
```
=== "Go"
```go title="binary_search_tree.go"
[class]{binarySearchTree}-[func]{insert}
```
=== "JavaScript"
```javascript title="binary_search_tree.js"
[class]{}-[func]{insert}
```
=== "TypeScript"
```typescript title="binary_search_tree.ts"
[class]{}-[func]{insert}
```
=== "C"
```c title="binary_search_tree.c"
[class]{binarySearchTree}-[func]{insert}
```
=== "C#"
```csharp title="binary_search_tree.cs"
[class]{BinarySearchTree}-[func]{insert}
```
=== "Swift"
```swift title="binary_search_tree.swift"
[class]{BinarySearchTree}-[func]{insert}
```
=== "Zig"
```zig title="binary_search_tree.zig"
[class]{BinarySearchTree}-[func]{insert}
```
为了插入节点需要借助 **辅助节点 `pre`** 保存上一轮循环的节点这样在遍历到 $\text{null}$ 我们也可以获取到其父节点从而完成节点插入操作
与查找节点相同插入节点使用 $O(\log n)$ 时间
### 删除节点
与插入节点一样我们需要在删除操作后维持二叉搜索树的左子树 < 根节点 < 右子树的性质首先我们需要在二叉树中执行查找操作获取待删除节点接下来根据待删除节点的子节点数量删除操作需要分为三种情况
**当待删除节点的子节点数量 $= 0$ 时**表明待删除节点是叶节点直接删除即可
![在二叉搜索树中删除节点(度为 0](binary_search_tree.assets/bst_remove_case1.png)
**当待删除节点的子节点数量 $= 1$ 时**将待删除节点替换为其子节点即可
![在二叉搜索树中删除节点(度为 1](binary_search_tree.assets/bst_remove_case2.png)
**当待删除节点的子节点数量 $= 2$ 时**删除操作分为三步
1. 找到待删除节点在 **中序遍历序列** 中的下一个节点记为 `nex`
2. 在树中递归删除节点 `nex`
3. 使用 `nex` 替换待删除节点
=== "<1>"
![删除节点(度为 2步骤](binary_search_tree.assets/bst_remove_case3_step1.png)
=== "<2>"
![bst_remove_case3_step2](binary_search_tree.assets/bst_remove_case3_step2.png)
=== "<3>"
![bst_remove_case3_step3](binary_search_tree.assets/bst_remove_case3_step3.png)
=== "<4>"
![bst_remove_case3_step4](binary_search_tree.assets/bst_remove_case3_step4.png)
删除节点操作也使用 $O(\log n)$ 时间,其中查找待删除节点 $O(\log n)$ ,获取中序遍历后继节点 $O(\log n)$ 。
=== "Java"
```java title="binary_search_tree.java"
[class]{BinarySearchTree}-[func]{remove}
[class]{BinarySearchTree}-[func]{getInOrderNext}
```
=== "C++"
```cpp title="binary_search_tree.cpp"
[class]{BinarySearchTree}-[func]{remove}
[class]{BinarySearchTree}-[func]{getInOrderNext}
```
=== "Python"
```python title="binary_search_tree.py"
[class]{BinarySearchTree}-[func]{remove}
[class]{BinarySearchTree}-[func]{get_inorder_next}
```
=== "Go"
```go title="binary_search_tree.go"
[class]{binarySearchTree}-[func]{remove}
[class]{binarySearchTree}-[func]{getInOrderNext}
```
=== "JavaScript"
```javascript title="binary_search_tree.js"
[class]{}-[func]{remove}
[class]{}-[func]{getInOrderNext}
```
=== "TypeScript"
```typescript title="binary_search_tree.ts"
[class]{}-[func]{remove}
[class]{}-[func]{getInOrderNext}
```
=== "C"
```c title="binary_search_tree.c"
[class]{binarySearchTree}-[func]{remove}
[class]{binarySearchTree}-[func]{getInOrderNext}
```
=== "C#"
```csharp title="binary_search_tree.cs"
[class]{BinarySearchTree}-[func]{remove}
[class]{BinarySearchTree}-[func]{getInOrderNext}
```
=== "Swift"
```swift title="binary_search_tree.swift"
[class]{BinarySearchTree}-[func]{remove}
[class]{BinarySearchTree}-[func]{getInOrderNext}
```
=== "Zig"
```zig title="binary_search_tree.zig"
[class]{BinarySearchTree}-[func]{remove}
[class]{BinarySearchTree}-[func]{getInOrderNext}
```
### 排序
我们知道,「中序遍历」遵循“左 $\rightarrow$ 根 $\rightarrow$ 右”的遍历优先级,而二叉搜索树遵循“左子节点 $<$ 根节点 $<$ 右子节点”的大小关系。因此,在二叉搜索树中进行中序遍历时,总是会优先遍历下一个最小节点,从而得出一条重要性质:**二叉搜索树的中序遍历序列是升序的**。
借助中序遍历升序的性质,我们在二叉搜索树中获取有序数据仅需 $O(n)$ 时间,而无需额外排序,非常高效。
![二叉搜索树的中序遍历序列](binary_search_tree.assets/bst_inorder_traversal.png)
## 二叉搜索树的效率
假设给定 $n$ 个数字,最常用的存储方式是「数组」,那么对于这串乱序的数字,常见操作的效率为:
- **查找元素**:由于数组是无序的,因此需要遍历数组来确定,使用 $O(n)$ 时间;
- **插入元素**:只需将元素添加至数组尾部即可,使用 $O(1)$ 时间;
- **删除元素**:先查找元素,使用 $O(n)$ 时间,再在数组中删除该元素,使用 $O(n)$ 时间;
- **获取最小 / 最大元素**:需要遍历数组来确定,使用 $O(n)$ 时间;
为了得到先验信息,我们也可以预先将数组元素进行排序,得到一个「排序数组」,此时操作效率为:
- **查找元素**:由于数组已排序,可以使用二分查找,平均使用 $O(\log n)$ 时间;
- **插入元素**:先查找插入位置,使用 $O(\log n)$ 时间,再插入到指定位置,使用 $O(n)$ 时间;
- **删除元素**:先查找元素,使用 $O(\log n)$ 时间,再在数组中删除该元素,使用 $O(n)$ 时间;
- **获取最小 / 最大元素**:数组头部和尾部元素即是最小和最大元素,使用 $O(1)$ 时间;
观察发现,无序数组和有序数组中的各项操作的时间复杂度是“偏科”的,即有的快有的慢;**而二叉搜索树的各项操作的时间复杂度都是对数阶,在数据量 $n$ 很大时有巨大优势**。
<div class="center-table" markdown>
| | 无序数组 | 有序数组 | 二叉搜索树 |
| ------------------- | -------- | ----------- | ----------- |
| 查找指定元素 | $O(n)$ | $O(\log n)$ | $O(\log n)$ |
| 插入元素 | $O(1)$ | $O(n)$ | $O(\log n)$ |
| 删除元素 | $O(n)$ | $O(n)$ | $O(\log n)$ |
| 获取最小 / 最大元素 | $O(n)$ | $O(1)$ | $O(\log n)$ |
</div>
## 二叉搜索树的退化
理想情况下,我们希望二叉搜索树的是“左右平衡”的(详见「平衡二叉树」章节),此时可以在 $\log n$ 轮循环内查找任意节点。
如果我们动态地在二叉搜索树中插入与删除节点,**则可能导致二叉树退化为链表**,此时各种操作的时间复杂度也退化之 $O(n)$ 。
!!! note
在实际应用中,如何保持二叉搜索树的平衡,也是一个需要重要考虑的问题。
![二叉搜索树的平衡与退化](binary_search_tree.assets/bst_degradation.png)
## 二叉搜索树常见应用
- 系统中的多级索引,高效查找、插入、删除操作。
- 各种搜索算法的底层数据结构。
- 存储数据流,保持其已排序。