# 第九章 平衡搜索

> 译者：[Abel-Huang](https://github.com/Abel-Huang)

我们之前已经看到二叉搜索树有一个弱点：左右子树趋向于不平衡，因此无法将它们所表示的数据集划分成两个同样大小的部分。我们考虑一下我们可以对此做些什么改进。

当然，我们总是可以通过简单地将所有节点按顺序进行排序，然后重新插入它们以生成一个新的平衡二叉树。然而这种操作需要花费树节点数线性规模的复杂度，并且可以看出在 插入N个元素很难避免退化为$\Theta(N^2)$的复杂度。相比之下，如果数据碰巧以保持树遍历的顺序，则进行N次插入仅需要$\Theta(N\lgN)$的时间。因此，让我们首先看看重新平衡树(或保持平衡)的操作，而不将其拆开并重新构建。

## 9.1 平衡构造：B-树
保持搜索树平衡的另一种方法是始终小心"在一个正确的位置插入新的树节点"，以便树通过构造器保持平衡。数据库社区长期以来一直使用完全符合这个要求的数据结构：*B-tree*。我们将在这里抽象地描述这种数据结构和相关操作，而不是直接给出代码，因为这种数据结构用于实践中大量用于提高速度的存储设备。  
*m阶B-树*是具有以下属性的多叉树：  
   1. 每个节点孩子数最多为m个。
   2. 除根之外的所有节点至少有m/2个子节点(我们也可以说除根节点外的每个节点至少包含⌈m/2⌉个子节点)。
   3. 一个节点的孩子$C_0$, $C_1$, ..., $C_(n-1)$用键$K_1$, ..., $K_(n-1)$标记(认为$K_i$在$C_(i-1)$和$C_i$之间)，其中$k_1$<$k_2$<...<$k_(n-1)$；
   4. B-树是一种搜索树：对于任何一个节点，以$C_i$为根的子树中所有键都严格地小于$K_(i+1)$，并且(对于i > 0)严格地大于$K_i$。
   5. 所有空孩子都出现在树的同一层。

图9.1包含一个4阶排序树的示例。在实际实现中，B-树一般用在在二级存储（如磁盘等）上，根据实际需要读入它们的节点。我们选择m阶树(多叉数)的目的是尽可能快地从二级存储器传输数据。对于磁盘操作而言更关注的是单次磁盘访问所需要时间越小越好，
因此m的值范围越大，从不同节点中读取的时间差异越小。在这种情况下，**m**取的值过小很明显不是一个很好的主意。

我们将用一个被称为**BTreeNode**的结构来表示B-树的节点，并且有如下的定义：  
**B.child(i)**：B-树节点**B**的第*i*个孩子，**0 <= i < m**。  
**B.key(i)**：B-树节点**B**的第*i*个关键字Key，**1 <= i < m**。  
**B.parent()**：B-树节点**B**的父节点。  
**B.index()**：B-树节点**B**的索引*i*，**B == B.parent().child(i)**。  
**B.arity()**：B-树节点**B**的孩子数。

然后，整个B-树由指向根的指针组成，可能还包含一些额外的有用信息，例如B-树当前的大小。

基于属性2和属性5，一个拥有N个关键字的B-树一定有$\Theta(\log_(m/2)N)$层节点。基于属性1，搜索单个节点的关键字需要O(1)时间(保证m为固定值)。因此通过下面的递归算法搜索B-树是一个$\Theta(\lgN)$级别的操作。

```java
    boolean search (BTreeNode B, Key X) {
        if (B is the empty tree)
            return false;
        else {
            Find largest c such that B.key(i) ≤ X, for all 1 ≤ i ≤ c.
            if (c > 0 && X.equals (B.key (c)))
                return true;
            else
                return search (B.child (c), K);
        }
```
![figure_9_1](https://github.com/Abel-Huang/cs61b-textbook-zh/blob/master/zh/img/figure_9_1.png)    

图9.1:  键为整型的4阶B-树示例。圆圈代表空节点，它们出现在树的同一层级。每个节点拥有2到4个子节点，每个节点用于1到3个关键字。每个键都大于其左侧子节点中的所有键，并且小于右侧子节点中的所有键。

### 9.1.1 插入
最初，我们插入B树的底部，就像二叉搜索树一样。但是我们通过节点填充和分离来避免树的深度扩展，防止出现"scrawny"
的情形，即每层树节点过少。这个想法很简单：我们在树的底部找到一个合适的位置来插入给定的键，并执行插入 操作(还添加一个额外的空孩子)。如果这使得节点太大(因为它有**m**个键和**m+1**(空)子节点)，我们会对节点进行分裂，具体的代码在图9.2中。图9.3说明了该过程。

### 9.1.2 删除
从B-树中删除通常比插入操作更复杂，但也不是太糟糕。和之前一样，在真实的产品实现中，为了提升速度会引入更多复杂的内容。为了简单起见，我将描述一种简单，理想化的方法。从插入的工作方式中获得启发，我们首先将待删除的键移动到树的底部(删除很简单)。然后，如果删除后使得原始节点太小，我们将其与兄弟节点合并，拉下用于将两者与父节点分开的关键字。图9.4中的伪代码描述了该过程，如图9.5所示。

![figure_9_2](https://github.com/Abel-Huang/cs61b-textbook-zh/blob/master/zh/img/figure_9_2.jpg)  

图9.2：B-树节点分裂。图9.3是过程示意图。

![figure_9_3](https://github.com/Abel-Huang/cs61b-textbook-zh/blob/master/zh/img/figure_9_3.jpg) 

图9.3：B-树插入操作。在图9.1所示的B-树上进行插入操作，分别插入15，145和35三个关键字。

![figure_9_4](https://github.com/Abel-Huang/cs61b-textbook-zh/blob/master/zh/img/figure_9_4.jpg) 

图9.4：B-树删除操作。图9.5是过程示意图。

![figure_9_5](https://github.com/Abel-Huang/cs61b-textbook-zh/blob/master/zh/img/figure_9_5.jpg) 

图9.5：B-树删除操作。这个示例基于图9.3中的(c)部分。在(a)中，我们删除了关键字25。首先，移动25到底部并与其子节点合并。然后，删除关键字25，如果合并后的节点过大，我们需要进行分裂，并将关键字15提升为父节点。接下来，(b)在(a)删除的基础上，删除关键字10。从底部节点删除10会使该节点变小小，因此我们合并它，从父节点向下移动关键字15。这样会导致父节点变小，因此我们继续进行合并，将关键字35从根节点往下移动，得到最终的树。

### 9.1.3 红黑树：二分查找的(2, 4)树
一般B-树使用的节点实际上是关键字的有序数组。有序4阶B-树(也称为(2,4)树)，这种我们一般也称为红黑树。每一个(2,4)树会被映射成一个特定的二叉树，使得每个(2,4)树节点对应于1-3个二叉搜索树节点组成的小集群。因此，二叉搜索树大致平衡，从根节点到叶子节点的所有路径的长度最多相差2倍。图9.6显示了每个可能的(2,4)节点的表示。在一棵完整树中，我们可以仅在每个群集的根节点中设置布尔值来确定群集的边界。但是，习惯上我们将此布尔值为true的节点（以及空叶节点）描述为“黑色”，将其他节点描述为“红色”。

通过考虑图9.6和（2,4）树的结构，你可以推导出红黑树是二叉搜索树，它还遵循以下约束(在红黑树的标准处理中作为它们的定义)：

A. 根节点和所有（null）叶子节点都是黑色的。

B. 红色节点的子节点都是黑色节点；

C. 从根节点到叶子节点的任何路径都遍历相同数量的黑色节点。

同样，属性B和C表明红黑树是“bushy”的，即红黑树是平衡的。

当然，红黑树的搜索过程与普通的二叉树搜索相同。由于图中所示的(2,4)树和红黑树之间的映射，插入操作和删除操作的算法可以从用于4阶B-树的算法中推导出。红黑树的常用操作的实现通常不直接使用此对应关系，对于基本的操作视作普通的二叉搜索树操作，然后通过旋转重新平衡(参见§9.3)，根据当前节点及其相邻节点的颜色的重新着色。我们不会在这里详述。

## 9.2 字典树
一般来说，在一个包含N个关键字的平衡的二叉搜索树中查找某个特定的关键字所需时间为$\Theta(\lgN)$。当然这并不是完全正确的，因为可能忽略了关键字之间进行比较所花费的时间。例如，比较两个字符串所需要的时间取决于较短的那个串的长度。因此，我再此之前说到的"$\Theta(\lgN)$"都是指$\Theta(L\lgN)$，其中L是指关键字所包含的字节数。
在大多数应用中，这并不重要，因为L相对于N，L的增长速度而言几乎可以忽略。尽管如此，我们想到一个有趣的问题：
显然，在这里我们不能轻易忽略L这个因素的影响(具体取决于你正在查找的这个关键字)，但是我们是否可以摆脱$\lgN$的影响吗？


![figure_9_6](https://github.com/Abel-Huang/cs61b-textbook-zh/blob/master/zh/img/figure_9_6.jpg)     
图9.6：带有"红黑"节点的二叉树(2,4)节点的表示。左边是单个(2,4)节点的三种情形(1到3个关键字，或者2到4个子节点)。在右边是其对应的二叉查找树。在每一种情形下，顶部的二叉节点被涂成黑色，其他的节点均为红色。


### 9.2.1 字典树: 基本属性和算法
这个结果说明我们可以通过字典树*trie*这种数据结构来避免$\lgN$的影响。一个纯粹的字典树是一种树，由一些固定大小的字符组成的字符串表示，A = {$a_0$，$a_1$，...，$a_(M-1)$}。其中一个字符是只出现在单词末尾的特殊分隔符，'□'。例如，A可能是一组可打印的ASCII字符，'□'表示一个不能打印的字符，例如'\000'(NUL)。一棵字典树T，可以被下面的方式抽象的递归定义。      
   1. 空；  
   2. 一个叶子节点包含一个字符串；  
   3. 一个内部结点包含M个孩子也是字典树。通向这些孩子的边界用字母表中的字符标记，$a_i$，像这样：$C_(a_0)$，$C_(a_1)$，... $C_(a_(m-1))$。    
 
 我们可以认为字典树是叶子节点由字符串组成的树。除此外我们还附加了另外一个条件：  
    * 如从字典树的根开始，并且接下来的边标记为$s_0$，$s_1$，...，$s_(h-1)$，我们访问到的每一个字符串为$s_0$$s_1$$s_(h-1)$。
    
因此，可以将字典树的每个内部节点看作是它下面叶中所有字符串的前缀：具体地说，k级的内部节点代表其下每个字符串的前k个字符。

如果字典树T中从T的根节点开始，并且接下来的0个或者更多的边使用$s_0$，$s_1$，...，$s_(h-1)$标记，一个字符串S=$s_0$$s_1$$s_(m-1)，我们得到了这个字符串S。我们将假设T中的所有字符串都以'□'结尾，它只作为字符串的最后一个字符出现。  
    
![figure_9_7](https://github.com/Abel-Huang/cs61b-textbook-zh/blob/master/zh/img/figure_9_7.jpg)   
图9.7：包含一些列字符串的字典树{a, abase, abash, abate，abbas， axe，axolotl，fabric，facet}。内部节点被标记为显示它们对应的字符串前缀。

![figure_9_8](https://github.com/Abel-Huang/cs61b-textbook-zh/blob/master/zh/img/figure_9_8.jpg)   
 图9.8：图9.7中字符串"bat"和"faceplate"插入后的结果。
 
 图9.7表示了一个一组小规模字符串的字典树。为了查看字符串是否在集合中，我们从字典树的根开始，沿着用我们要查找的字符串中的连续字符标记的边(连接接到子节点),包括末尾的虚拟字符'□'。如果我们沿着这条路径成功地找到了一个字符串，它等于我们要搜索的字符串，那么我们要搜索的字符串就在字典树中，否则我们要找的这个字符串就不在其中。对于每个单词，我们只需要内部节点，因为存储的多个单词以遍历到该点的字符开头。以特殊字符结束一切事物的惯例允许我们区分三个词包含两个词的情况，一个词是另一个词的前缀（如“a”和“abate”），而三个词只包含一个长词。
 
 从字典树用户的角度来看，它看起来像一种带有字符串标签的树：
 
```java
public abstract class Trie {
    /** The empty Trie. */
    public static final Trie EMPTY = new EmptyTrie();
    
    /** The label at this node. Defined only on leaves. */
    abstract public String label();
    
    /** True if X is in this Trie. */
    public boolean isIn(String x) ...
    
    /** The result of inserting X into this Trie, if it is not
     * already there, and returning this. This trie is
     * unchanged if X is in it already. */
    public Trie insert(String x) ...
    
    /** The result of removing X from this Trie, if it is present.
     * The trie is unchanged if X is not present. */
    public Trie remove(String x) ...
    
    /** True if this Trie is a leaf (containing a single String). */
    abstract public boolean isLeaf();
    
    /** True if this Trie is empty */
    abstract public boolean isEmpty();
    
    /** The child numbered with character K. Requires that this node
     * not be empty. Child 0 corresponds to ✷. */
    abstract public Trie child(int k);
    
    /** Set the child numbered with character K to C. Requires that
     * this node not be empty. (Intended only for internal use. */
    abstract protected void setChild(int k, Trie C);
}
```

接下来的算法描述了字典树的搜索过程：

```java
    /** True if X is in this Trie. */
    public boolean isIn(String x) {
        Trie P = longestPrefix(x, 0);
        return P.isLeaf() && x.equals(P.label());
    }
    
    /** The node representing the longest prefix of X.substring(K) that
     * matches a String in this trie. */
    private Trie longestPrefix(String x, int k) {
        if (isEmpty() || isLeaf())
            return this;
        int c = nth(x, k);
        if (child(c).isEmpty())
            return this;
        else
            return child(c).longestPrefix(x, k+1);
    }
    
    /** Character K of X, or ✷ if K is off the end of X. */
    static char nth(String x, int k) {
        if (k >= x.length())
            return (char) 0;
        else
            return x.charAt(k);
}
```

从以下步骤可以清楚地看出，寻找关键字所需的时间与关键字的长度成正比。事实上，需要遍历的字典树的数量级别可以大大小于键的长度，特别是在存储的关键字的数量很少的情况下。但是，如果字符串在字典树中，则必须查看其所有字符，因此*isIn*方法的最坏时间复杂度为$\Theta(x.length)$。

为了在字典树中插入关键字X，我们再次在字典树中查找X的最长前缀，它对应于某个节点P。然后，如果P是一个叶子，我们插入足够多的内部节点来区分X和*P.label()*。否则，我们可以在P节点的适当子元素中插入X的叶子节点。图9.8表示字符串"bat"和"faceplate"插入图9.7后的结果。添加"bat"只需要向现有节点添加一个叶子。添加"faceplate"需要先插入两个新节点。

下面的方法是字典树的插入方法*insert()*。
```java
    /** The result of inserting X into this Trie, if it is not
    *  already there, and returning this. This trie is
    * unchanged if X is in it already. */
    public Trie insert(String X){
        return insert(X, 0);
    }
    
    /** Assumes this is a level L node in some Trie. Returns the
    * result of inserting X into this Trie. Has no effect (returns
    * this) if X is already in this Trie. */
    private Trie insert(String X, int L) {
        if (isEmpty())
            return new LeafTrie(X);
        int c = nth(X, L);
        if (isLeaf()) {
            if (X.equals(label()))
                return this;
            else if (c == label().charAt(L))
                return new InnerTrie(c, insert(X, L+1));
            else {
                Trie newNode = new InnerTrie(c, new LeafTrie(X));
                newNode.child(label().charAt(L), this);
                return newNode;
            }
        } else {
            child(c, child(c).insert(X, L+1));
            return this;
        }
    }
```

这里是**InnerTrie(c, T)** 的构造器，用于提供一个其中**child(c)** 为T,其他子节点为空的字典树。

从字典树中删除一个节点使该过程的逆过程。每当一个字典树节点被减少到包含一个叶子时，它就可以被那个叶子代替。以下程序表示该过程：

```java
    public Trie remove(String x){
        return remove(x, 0);
    }
    
/** Remove x from this Trie, which is assumed to be level L, and
* return the result. */
    private Trie remove(String x, int L){
        if (isEmpty())
            return this;
        if (isLeaf(T)) {
            if (x.equals(label()))
                return EMPTY;
            else
                return this;
        }
        int c = nth(x, L);
        child(c, child(c).remove(x, L+1));
        int d = onlyMember();
        if (d >= 0)
            return child(d);
        return this;
    }
    
    /** If this Trie contains a single string, which is in
    * child(K), return K. Otherwise returns -1. */
    private int onlyMember() { 
        /* Left to the reader. */ 
    }
```

### 9.2.2 字典树: 表示
我们剩下的问题是如何表示这些字典树。当然，主要的困难在于节点所包含的子节点数量是可变的。如果每个节中子节点的数量较少，那么可以使用5.2节中描述链表树。但是，为了快速访问，传统上使用数组来保存节点的子节点，并通过标记边缘的字符进行索引。

这会导致如下情况：

```java
class EmptyTrie extends Trie {
    public boolean isEmpty() { return true; }
    public boolean isLeaf() { return false; }
    public String label() { throw new Error(...); }
public Trie child(int c) { throw new Error(...); }
    protected void child(int c, Trie T) { throw new Error(...); }
}

class LeafTrie extends Trie {
    private String L;
    
    /** A Trie containing just the string S. */
    LeafTrie(String s) { L = s; }
    
    public boolean isEmpty() { return false; }
    public boolean isLeaf() { return true; }
    public String label() { return L; }
    public Trie child(int c) { return EMPTY; }
    protected void child(int c, Trie T) { throw new Error(...); }
}

class InnerTrie extends Trie {
    // ALPHABETSIZE has to be defined somewhere */
    private Trie[] kids = new kids[ALPHABETSIZE];
    
    /** A Trie with child(K) == T and all other children empty. */
    InnerTrie(int k, Trie T) {
        for (int i = 0; i < kids.length; i += 1)
        kids[i] = EMPTY;
        child(k, T);
    }
    
    public boolean isEmpty() { return false; }
    public boolean isLeaf() { return false; }
    public String label() { throw new Error(...); }
    public Trie child(int c) { return kids[c]; }
    protected void child(int c, Trie T) { kids[c] = T; }
}
```

### 9.2.3 表压缩
实际上，我们的字母表中可能有一些“漏洞”，这些编码的延伸部分与我们插入的字符串中出现的任何字符都不对应。
我们可以通过执行字符到压缩编码的初步映射来减少内部节点(子数组)的大小。例如，如果字符串中的字符是仅仅是数字0–9，则可以按如下方式重构**InnerTrie** ：
```java
class InnerTrie extends Trie {
    private static char[] charMap = new char[’9’+1];
    static {
        charMap[0] = 0;
        charMap[’0’] = 1; charMap[’1’] = 1; ...
    }
    
    public Trie child(int c) { return kids[charMap[c]]; }
    protected void child(int c, Trie T) { kids[charMap[c]] = T; }
}
```
这是有帮助的，但即便如此，可以用键中所有有效字符索引的数组可能相对较大(对于树节点)，例如m=60字节的顺序，即使对于只能包含数字的节点(假设每个指针有4个字节，每个对象有4个字节的开销，数组中的长度字段有4个字节)。如果所有键中总共有N个字符，那么所需的空间将以大约NM/2为界。只有在高度相似的情况下才能达边界(其中字典树只包含两个非常长的字符串，除了最后一个字符外，这些字符串是相同的)。然而，在字典树中的数组可能依然非常稀疏。

解决这个问题的一种方法是压缩表。这尤其适用于在容纳一些初始化字符串集后，插入很少的情况。顺便说一下，下面描述的技术通常适用于任何这样的稀疏数组，而不仅仅是字典树。

其基本思想是，稀疏数组(即那些主要包含空或“空”项的数组)可以通过确保其中一个非空项落在另一个空项的顶部而相互覆盖。我们将所有数组分配到一个大数组中，并在每个条目中存储额外的信息，这样我们就可以知道该条目所属的重叠数组中的哪一个。图9.9显示了适当的替代数据结构。

我们的想法是，当我们将每个节点的孩子数组存储在一起，并存储一个边缘标签，告诉我们每个字符应该对应哪个节点。这使得我们能够区分不同节点之间对应的字符。我们通过确保第0个子节点（对应于）始终满来安排每个节点的Me字段是唯一的。

作为一个例子，图9.10显示了图9.8中重叠在一起的字典树的十个内部节点。如图所示，这种表示可以非常紧凑。
右侧所需的额外空条目数量（防止索引数组越界）限制为M−1，因此当数组足够大时，可以忽略不计。(旁白：当希望以这种方式处理一组压缩的数组时，最好先分配一个满数组(最少稀疏)。)

如此精密的封装是有代价的：这将使插入操作变得很复杂。当向现有节点添加新的子节点时，所需的槽可能已被其他数组使用，首先从打包的存储区域中删除非空条目，从而有必要将节点移动到新位置。找到另一个点并将其条目移动到该点，最后更新指向父节点中正在移动的节点的指针。有一些方法可以消除这种情况，但我们不会在这里讨论它们。                                                   

## 9.3 旋转自平衡树

### 9.3.1 AVL树

## 9.4 伸展树

### 9.4.1 分析

## 9.5 跳表