Skip to content
体验新版
项目
组织
正在加载...
登录
切换导航
打开侧边栏
OpenDocCN
cs61b-textbook-zh
提交
55731bf7
C
cs61b-textbook-zh
项目概览
OpenDocCN
/
cs61b-textbook-zh
通知
3
Star
0
Fork
0
代码
文件
提交
分支
Tags
贡献者
分支图
Diff
Issue
0
列表
看板
标记
里程碑
合并请求
0
Wiki
0
Wiki
分析
仓库
DevOps
项目成员
Pages
C
cs61b-textbook-zh
项目概览
项目概览
详情
发布
仓库
仓库
文件
提交
分支
标签
贡献者
分支图
比较
Issue
0
Issue
0
列表
看板
标记
里程碑
合并请求
0
合并请求
0
Pages
分析
分析
仓库分析
DevOps
Wiki
0
Wiki
成员
成员
收起侧边栏
关闭侧边栏
动态
分支图
创建新Issue
提交
Issue看板
前往新版Gitcode,体验更适合开发者的 AI 搜索 >>
提交
55731bf7
编写于
6月 09, 2019
作者:
A
Abel-Huang
浏览文件
操作
浏览文件
下载
电子邮件补丁
差异文件
finish cp9.1 and cp9.2
上级
4ba1f968
变更
1
隐藏空白更改
内联
并排
Showing
1 changed file
with
127 addition
and
10 deletion
+127
-10
zh/9.md
zh/9.md
+127
-10
未找到文件。
zh/9.md
浏览文件 @
55731bf7
...
...
@@ -79,15 +79,38 @@ C. 从根节点到叶子节点的任何路径都遍历相同数量的黑色节
当然,红黑树的搜索过程与普通的二叉树搜索相同。由于图中所示的(2,4)树和红黑树之间的映射,插入操作和删除操作的算法可以从用于4阶B-树的算法中推导出。红黑树的常用操作的实现通常不直接使用此对应关系,对于基本的操作视作普通的二叉搜索树操作,然后通过旋转重新平衡(参见§9.3),根据当前节点及其相邻节点的颜色的重新着色。我们不会在这里详述。
## 9.2 字典树
![
figure_9_6
](
https://github.com/Abel-Huang/cs61b-textbook-zh/blob/master/zh/img/figure_9_6.jpg
)
图9.6:
一般来说,在一个包含N个关键字的平衡的二叉搜索树中查找某个特定的关键字所需时间为$
\T
heta(
\l
gN)$。当然这并不是完全正确的,因为可能忽略了关键字之间进行比较所花费的时间。例如,比较两个字符串所需要的时间取决于较短的那个串的长度。因此,我再此之前说到的"$
\T
heta(
\l
gN)$"都是指$
\T
heta(L
\l
gN)$,其中L是指关键字所包含的字节数。
在大多数应用中,这并不重要,因为L相对于N,L的增长速度而言几乎可以忽略。尽管如此,我们想到一个有趣的问题:
显然,在这里我们不能轻易忽略L这个因素的影响(具体取决于你正在查找的这个关键字),但是我们是否可以摆脱$
\l
gN$的影响吗?
### 9.2.1 基本属性和算法
![
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个子节点)。在右边是其对应的二叉查找树。在每一种情形下,顶部的二叉节点被涂成黑色,其他的节点均为红色。
![
figure_9_6
](
https://github.com/Abel-Huang/cs61b-textbook-zh/blob/master/zh/img/figure_9_6.jpg
)
图9.6:
### 9.2.1 字典树: 基本属性和算法
这个结果说明我们可以通过字典树
*trie*
这种数据结构来避免$
\l
gN$的影响。一个纯粹的字典树是一种树,由一些固定大小的字符组成的字符串表示,A = {$a_0$,$a_1$,...,$a_(M-1)$}。其中一个字符是只出现在单词末尾的特殊分隔符,'□'。例如,A可能是一组可打印的ASCII字符,'□'表示一个不能打印的字符,例如'
\0
00'(NUL)。一棵字典树T,可以被下面的方式抽象的递归定义。
*
空;
*
一个叶子节点包含一个字符串;
*
一个内部结点包含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. */
...
...
@@ -124,6 +147,8 @@ public abstract class Trie {
}
```
接下来的算法描述了字典树的搜索过程:
```
java
/** True if X is in this Trie. */
public
boolean
isIn
(
String
x
)
{
...
...
@@ -145,13 +170,89 @@ public abstract class Trie {
/** 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
);
if
(
k
>=
x
.
length
())
return
(
char
)
0
;
else
return
x
.
charAt
(
k
);
}
```
### 9.2.2 表示
从以下步骤可以清楚地看出,寻找关键字所需的时间与关键字的长度成正比。事实上,需要遍历的字典树的数量级别可以大大小于键的长度,特别是在存储的关键字的数量很少的情况下。但是,如果字符串在字典树中,则必须查看其所有字符,因此
*isIn*
方法的最坏时间复杂度为$
\T
heta(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
{
...
...
@@ -193,7 +294,10 @@ class InnerTrie extends Trie {
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
];
...
...
@@ -206,6 +310,19 @@ class InnerTrie extends Trie {
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树
...
...
编辑
预览
Markdown
is supported
0%
请重试
或
添加新附件
.
添加附件
取消
You are about to add
0
people
to the discussion. Proceed with caution.
先完成此消息的编辑!
取消
想要评论请
注册
或
登录