---
marp: true
theme: kurzus
paginate: true
footer: 数据结构 · 第 15 周 · 二叉搜索树、堆、总结
---

<!-- _class: cim -->
<!-- _paginate: false -->
<!-- _footer: "" -->

# 数据结构

## 第 15 周 – 二叉搜索树、平衡、堆；课程总结

Koczka Ferenc 博士 · 埃斯特哈齐·卡罗伊大学 · 2026

---

## 复习

- <strong>二叉树：</strong>至多两个孩子；链式表示和数组表示
- <strong>遍历：</strong>先序、中序、后序（递归），层序（用队列）
- <strong>二分查找</strong>（第 2 周）：在有序数组中 O(log n) – 但插入是 O(n)

### 今天的问题

- 怎样让查找<strong>和</strong>插入都很快？
- 为什么树<strong>平衡</strong>很重要？
- <strong>堆</strong>怎样提供快速的优先队列？

<!--
时间安排（讲课）：二叉搜索树 40' · 平衡 15' · 堆 25' · 期末笔试复习 10'
实践课的后半部分：整个课程的总结，以及为第 16 周考试准备的练习。
-->

---

<!-- _class: fejezet -->

# 二叉搜索树

---

## 二叉搜索树

<strong>二叉搜索树</strong>（BST,<em>binary search tree</em>）：对<strong>每一个</strong>结点都满足以下条件的二叉树：

- 它的<strong>左</strong>子树中所有的键都<strong>小于</strong>该结点的键，
- 它的<strong>右</strong>子树中所有的键都<strong>大于</strong>该结点的键。

<div class="doboz">

<strong>重要推论：</strong><strong>中序遍历</strong>按<strong>递增顺序</strong>给出所有的键！
例：依次插入 8、3、10、1、6、14、4、7、13 之后，中序遍历为：<strong>1 3 4 6 7 8 10 13 14</strong>

</div>

<div class="doboz figyelem">

只是每个结点大于/小于它的<strong>直接</strong>孩子还不够 – 条件针对的是<strong>整棵</strong>子树！

</div>

---

## 查找

![w:1000](kepek/h15_bst_kereses.svg)

---

<!-- _class: kicsi -->

## 查找 – 算法

<div class="ket-hasab">
<div>

<span class="cimke pszeudo">迭代实现</span>

```text
Function Search(Root, x)
  P ← Root
  While P ≠ Nil and Elem[P].Key ≠ x
    If x < Elem[P].Key then
      P ← Elem[P].Left
    else
      P ← Elem[P].Right
    End if
  End while
  Return P             // 未找到时为 Nil
End function
```

<strong>最小值：</strong>一直向左，直到不能再走
<strong>最大值：</strong>一直向右，直到不能再走

</div>
<div>

<span class="cimke java">Java</span>

```java
boolean contains(int x) {
    Node p = root;
    while (p != null && p.key != x)
        p = (x < p.key) ? p.left : p.right;
    return p != null;
}
```

<span class="cimke go">Go</span>

```go
func (t *BST) Contains(x int) bool {
    p := t.Root
    for p != nil && p.Key != x {
        if x < p.Key {
            p = p.Left
        } else {
            p = p.Right
        }
    }
    return p != nil
}
```

</div>
</div>

---

## 插入

![w:1000](kepek/h15_bst_beszuras.svg)

---

<!-- _class: kicsi -->

## 插入 – 算法

<div class="ket-hasab">
<div>

```text
Function Insert(P, x)        // 返回新的子树
  If P = Nil then
    Reserve(Q) ; Elem[Q].Key ← x
    Elem[Q].Left ← Nil ; Elem[Q].Right ← Nil
    Return Q
  If x < Elem[P].Key then
    Elem[P].Left ← Insert(Elem[P].Left, x)
  else if x > Elem[P].Key then
    Elem[P].Right ← Insert(Elem[P].Right, x)
  Return P
End function
```

</div>
<div>

```java
Node insert(Node p, int x) {
    if (p == null) return new Node(x);
    if (x < p.key)
        p.left = insert(p.left, x);
    else if (x > p.key)
        p.right = insert(p.right, x);
    return p;
}
```

调用：`root = insert(root, x);`

</div>
</div>

---

## 删除 – 三种情况

![w:1000](kepek/h15_bst_torles.svg)

- <strong>叶子：</strong>直接删除
- <strong>一个孩子：</strong>孩子（连同它的整棵子树）顶替被删除的结点
- <strong>两个孩子：</strong>把右子树中<strong>最小</strong>的元素（<strong>中序后继</strong>）复制到它的位置，然后删除那个元素 – 它至多有一个孩子

---

<!-- _class: kicsi tomor -->

## 删除 – 代码

<div class="ket-hasab">
<div>

<span class="cimke java">Java</span>

```java
Node delete(Node p, int x) {
    if (p == null) return null;
    if (x < p.key) p.left = delete(p.left, x);
    else if (x > p.key) p.right = delete(p.right, x);
    else {
        if (p.left == null) return p.right;   // 0 或 1 个孩子
        if (p.right == null) return p.left;
        Node s = p.right;                     // 2 个孩子:
        while (s.left != null) s = s.left;    // 中序后继
        p.key = s.key;
        p.right = delete(p.right, s.key);
    }
    return p;
}
```

</div>
<div>

<span class="cimke go">Go – 两个孩子的情况</span>

```go
default:
    if p.Left == nil {
        return p.Right
    }
    if p.Right == nil {
        return p.Left
    }
    s := p.Right            // 中序后继
    for s.Left != nil {
        s = s.Left
    }
    p.Key = s.Key
    p.Right = del(p.Right, s.Key)
```

完整代码：`w15/bst`

</div>
</div>

---

<!-- _class: fejezet -->

# 平衡

---

## 二叉搜索树的效率取决于高度

![w:1000](kepek/h15_degeneralt.svg)

- 查找、插入、删除：<strong>O(h)</strong>，其中 <em>h</em> 是树的高度
- <strong>平衡</strong>的树：h ≈ log₂ n → <strong>O(log n)</strong> · <strong>退化</strong>的树：h = n − 1 → <strong>O(n)</strong>（就是链表！）
- <strong>实测：</strong>按有序顺序插入 1000 个元素 → 高度 <strong>999</strong> · 按随机顺序 → 高度 <strong>18</strong>

---

## 平衡二叉搜索树

<strong>平衡树：</strong>在每个结点上，左右子树的高度之差<strong>至多为 1</strong>（AVL 条件）。

![w:880](kepek/h15_forgatas.svg)

- 插入和删除之后用<strong>旋转</strong>恢复平衡 – 一次旋转是 O(1)
- <strong>AVL 树</strong>（1962）、<strong>红黑树</strong>（Java `TreeMap`、`TreeSet`）、<strong>B 树</strong>（数据库、文件系统）
- 保证查找、插入、删除都是 <strong>O(log n)</strong>

---

## 二叉搜索树、有序数组还是哈希表？

| 操作 | 有序数组 | 哈希表 | 平衡二叉搜索树 |
|---|---|---|---|
| 查找 | O(log n) | 平均 <strong>O(1)</strong> | O(log n) |
| 插入、删除 | O(n) | 平均 <strong>O(1)</strong> | O(log n) |
| 最小值、最大值 | <strong>O(1)</strong> | O(n) | O(log n) |
| 有序遍历 | O(n) | O(n log n) | O(n) |
| 区间 [a, b] | O(log n + k) | O(n) | O(log n + k) |

<div class="doboz tipp">

如果只需要查找：<strong>哈希表</strong>。如果<strong>顺序</strong>也很重要（最小值、下一个、区间）：<strong>二叉搜索树</strong>。

</div>

---

<!-- _class: fejezet -->

# 堆

---

## 堆

![w:1000](kepek/h15_kupac.svg)

- <strong>完全</strong>二叉树，<strong>用数组</strong>存放（第 14 周：孩子 2i 和 2i + 1，父结点 i div 2）
- <strong>堆性质：</strong>每个父结点 ≥ 它的孩子（大顶堆） – 或 ≤（小顶堆）
- 兄弟之间<strong>没有</strong>顺序 – 堆<strong>不是</strong>二叉搜索树！

---

## 插入：上浮

![w:1000](kepek/h15_kupac_beszuras.svg)

- 新元素放在数组的<strong>末尾</strong>（树的下一个空位）
- 只要它大于父结点，就和父结点<strong>交换</strong> → 至多 <em>h</em> = log₂ n 次交换 → <strong>O(log n)</strong>

---

<!-- _class: kicsi -->

## 取出最大值：下沉

<div class="ket-hasab">
<div>

```text
Function RemoveMax(A, n)
  max ← A[1]
  A[1] ← A[n] ; n ← n − 1
  i ← 1
  While 2·i ≤ n
    c ← 2·i                       // 左孩子
    If c + 1 ≤ n and A[c + 1] > A[c] then
      c ← c + 1                   // 较大的孩子
    If A[i] ≥ A[c] then exit
    swap(A[i], A[c]) ; i ← c
  End while
  Return max
End function
```

</div>
<div>

1. <strong>最大值</strong>在根上 – 把它返回
2. 把<strong>最后一个</strong>元素放到根上
3. 只要它小于较大的孩子，就<strong>向下交换</strong>

<strong>O(log n)</strong>

| 优先队列 | 插入 | 取出最大值 |
|---|---|---|
| 无序数组 | O(1) | O(n) |
| 有序数组 | O(n) | O(1) |
| <strong>堆</strong> | <strong>O(log n)</strong> | <strong>O(log n)</strong> |

Java `PriorityQueue` · Go `container/heap`

</div>
</div>

---

## 堆排序

1. <strong>建堆：</strong>在数组上原地建立大顶堆 – O(n)
2. 重复 <em>n</em> − 1 次：把根（最大值）<strong>与最后一个元素交换</strong>，把堆看作少了一个元素，然后让根<strong>下沉</strong>

```text
[40, 70, 20, 90, 50, 80, 60, 10]   初始
[90, 70, 80, 40, 50, 20, 60, 10]   堆
[10, 20, 40, 50, 60, 70, 80, 90]   有序
```

| 性质 | 堆排序 |
|---|---|
| 步数 | <strong>O(n log n)</strong> – 在所有情况下 |
| 额外空间 | <strong>O(1)</strong> – 原地排序 |
| 稳定 | 否 |

---

<!-- _class: fejezet -->

# 课程总结

---

<!-- _class: kicsi -->

## 数据结构 – 总览

| 数据结构 | 访问 | 查找 | 插入 | 删除 | 周 |
|---|---|---|---|---|---|
| 数组 | 按下标 <strong>O(1)</strong> | O(n) / 有序时 O(log n) | O(n) | O(n) | 3 |
| 动态数组 | O(1) | O(n) | 在末尾 O(1)* | O(n) | 3 |
| 链表 | O(n) | O(n) | 已知位置 <strong>O(1)</strong> | 已知位置 <strong>O(1)</strong> | 9–10 |
| 栈、队列 | 只能访问端点 | – | O(1) | O(1) | 7、8、11 |
| 集合（位向量） | – | O(1) | O(1) | O(1) | 6 |
| 哈希表 | – | 平均 <strong>O(1)</strong> | 平均 <strong>O(1)</strong> | 平均 <strong>O(1)</strong> | 11 |
| 二叉搜索树（平衡） | – | O(log n) | O(log n) | O(log n) | 15 |
| 堆 | 最大值：O(1) | O(n) | O(log n) | 最大值：O(log n) | 15 |

<span class="halvany">* 均摊</span>

---

## 算法 – 总览

| 问题 | 算法 | 步数 |
|---|---|---|
| 查找 | 顺序 · 哨兵 · 二分 | O(n) · O(n) · O(log n) |
| 排序（简单） | 冒泡 · 选择 · 插入 | O(n²) |
| 排序（高效） | 归并 · 快速 · 堆 | O(n log n) |
| 集合运算 | 有序数组的归并 | O(n + m) |
| 表达式 | 用栈计算后缀表达式 | O(n) |
| 模式匹配 | 朴素算法 | O(n · m) |
| 树的遍历 | 先序、中序、后序 · 层序 | O(n) |

---

## 准备第 16 周的考试

<strong>期末笔试</strong>（60 分钟，纸笔）

- 关于概念和效率的判断题和选择题
- 代码阅读：栈或队列输出什么？
- 伪代码填空（缺少的表达式、关系运算符）：链表、带哨兵的链表和二叉搜索树的操作
- 在纸上执行的算法：排序、二分查找、遍历、二叉搜索树的插入

<strong>上机考试</strong>（在计算机上）

- 用 <strong>Java 或 Go</strong> 实现并使用一个数据结构

<div class="doboz tipp">

复习每一周的<strong>复习题</strong>和<strong>练习</strong> – 笔试的题目与它们类似！

</div>

---

## 小结

- <strong>二叉搜索树：</strong>左 < 根 < 右；中序遍历是有序的
- 查找、插入、删除 <strong>O(h)</strong> – 删除有三种情况（叶子、一个孩子、两个孩子）
- <strong>平衡</strong>的树：h = O(log n)；有序插入 → 退化的树
- AVL 树、红黑树：用<strong>旋转</strong>保持树的平衡
- <strong>堆：</strong>用数组存放的完全二叉树，父结点 ≥ 孩子；插入和取出最大值 O(log n)
- <strong>堆排序：</strong>O(n log n)，原地

<strong>下周：</strong>期末笔试和上机考试。祝大家成功！

---

## 复习题

1. 向空的二叉搜索树中依次插入：50、30、70、20、40、60、80、35!画出这棵树，并写出中序遍历！
2. 从第 1 题的树中删除 30!树变成什么样？
3. 判断对错：二叉搜索树总是平衡的二叉树。
4. 判断对错：在二叉搜索树中查找可能和在链表中查找一样慢。
5. 向空的大顶堆中依次插入：15、40、30、50、10!写出每一步之后的数组！
6. 为什么堆不适合快速查找任意一个元素？

<!--
答案：
1. 50 是根；30（左：20，右：40 – 40 的左孩子是 35）；70（左：60，右：80）。中序：20 30 35 40 50 60 70 80
2. 30 有两个孩子 → 中序后继：35;35 放到 30 的位置，40 的左孩子变为空。
3. 错  4. 对（退化的树）
5. [15] → [40,15] → [40,15,30] → [50,40,30,15] → [50,40,30,15,10]
6. 兄弟和子树之间没有顺序；只知道最大值的位置，任意一个元素可能在任何地方 → O(n)。
-->

---

<!-- _class: fejezet -->
<!-- _footer: "" -->

# 实践课

---

<!-- _class: gyak -->

## 示例程序

| 程序 | Java | Go | 演示内容 |
|---|---|---|---|
| 二叉搜索树 | `BST.java` | `w15/bst` | 查找、插入、删除的三种情况、退化的树 |
| 堆 | `MaxHeap.java` | `w15/maxheap` | 上浮、下沉、内置优先队列 |
| 堆排序 | `HeapSort.java` | `w15/heapsort` | 建堆、排序、计时 |

---

<!-- _class: gyak -->

## 练习

1. <strong>是否为二叉搜索树？</strong>判断一棵二叉树是否是二叉搜索树！（注意：只看直接孩子是不够的！）
2. <strong>第 k 小：</strong>找出二叉搜索树中第 <em>k</em> 小的键！
3. <strong>区间：</strong>输出介于 a 和 b 之间的键，只进入必要的子树！
4. <strong>最近公共祖先：</strong>用 O(h) 步找出二叉搜索树中两个键的最近公共祖先！
5. <strong>向下取整：</strong>找出 ≤ <em>x</em> 的最大的键！
6. <strong>前 k 大：</strong>用大小为 <em>k</em> 的小顶堆找出数组中最大的 <em>k</em> 个元素！步数是多少？

参考答案：`Exercises15.java`、`w15/exercises`

实践课的后半部分：<strong>期末笔试练习题</strong>（以各周的复习题为基础）。

<!--
练习课建议的顺序：复习以前的复习题（主要是第 7、9、10、14、15 周），
在黑板上一起完成一道伪代码题（例如哨兵查找、在表头插入），
一道数组表示的树的题目（A[2i]、A[2i+1]）。
-->

---

<!-- _class: gyak -->

## 课后作业（备考）

1. 为 `BST` 类增加一个 `successor(x)` 方法，返回大于 <em>x</em> 的最小的键！
2. 用二叉搜索树实现一个<strong>电话簿</strong>（姓名 → 号码），它还能按字母顺序输出所有条目！
3. 实现<strong>小顶堆</strong>，并用它实现一个简单的<strong>任务调度器</strong>（截止时间最早的任务最先执行）！
4. 复习各周的复习题，并在纸上完成旧试卷中的样题！

<!--
解题思路：
1. 如果有右子树：取它的最小值；否则从根向下走，最后一次向左走时所在的那个结点。
2. 键是姓名（String 的 compareTo），值是号码；中序遍历 = 字母顺序。
3. 与 MaxHeap 相同，只是关系反过来；元素：（截止时间，名称）。
-->
