9.1 静态查找表
- 查找操作的性能分析
平均查找长度(Average Search Length)。其中为查找表中第个记录出现的概率,为在查找到第个记录之前访问过的记录数。一般我们可以根据给定的设计出合理的查找顺序以使最小。
9.1.1 顺序表的查找
顺序查找(Sequential Search):省略,没什么好说的。
9.1.2 有序表的查找
- 折半查找/二分查找(Binary Search)
- 折半查找(二分查找)的性能分析
描述查找过程的二叉树称为判定树。判定树一般不为满二叉树,但是根据二分法的原理,其叶子节点所在层次之差不超过1(不大于1)。所以具有个结点的判定树的深度和具有个结点的完全二叉树的深度相同。则具有n个结点的判定树的深度为。
为方便讨论,假设有序查找表的长度,则描述折半查找(二分查找)的判定树是深度为的满二叉树。
当查找表中每个记录的查找概率相等且均为时(),查找成功的平均查找长度为。
由此可见,折半查找的效率比顺序查找的效率高,但是折半查找只适用于有序表,并且仅适用于顺序存储结构,对于链式存储结构的线性链表无法有效进行折半查找。
- 折半查找(二分查找)的性能分析
- Fibonacci查找
- 黄金分割查找(参见《运筹学教程》)
- 黄金分割搜索是针对单峰函数的,可以用于关键字的值先升后降(或先降后升)的有序查找表的查找。
- 插值查找:对于关键字的值分布较为均匀的查找表(记录的结合),我们也可以采用插值查找。即根据当前查找范围(scope)中关键字的值的上下限预先估计出在当前查找范围中开始查找的下标。
9.1.3 静态树表的查找
当有序表中各记录的查找概率不等时,应如何构造判定树使得查找性能最优?
若只考虑查找成功的情况,使得的值取得最小值的判定树为查找性能最佳的判定树,称为静态最优查找树(Static Optimal Search Tree)。其中为有序查找表的长度,同时也是判定树上结点的个数;结点的权与结点的查找概率成正比或直接取(考虑到计算机的整数运算性能较高,也可以适当选择缩放因子使得所有的都为整数);为第个结点在二叉树上的层数(应该从0开始还是从1开始?这个好像无所谓,因为影响只是使得值相差了,这个对于静态查找表是常量)。
次优查找树(Nearly Optimal Search Tree)的构造方法
//todo
9.1.4 索引顺序表的查找
分块查找又称为索引顺序查找,是顺序查找的一种改进方法。除查找表本身以外,尚需要建立一个“索引表”。将查找表分成最大记录数相同的若干子表(最大记录数为预先设定),或将查找表均匀地等分为给定数量的子表。对每个子表(块)建立一个索引项,其中包括两项内容:关键字项(其值为该子表内关键字的最大值)和指针项(指向该子表中第一个记录在表中的位置)。
其实,分块查找如何进行是不言自明的。索引表中的指针项,对于顺序存储结构的查找表可以存储其这一子表第一个元素在主查找表中的下标(偏移量),对于链式存储结构则可以存储指向这一子表的第一个结点的指针。有一个数据结构就利用了多级索引表,这就是跳表,redis的实现中用到了跳表。
9.2 动态查找表
动态查找表结构本身是在查找过程中动态生成的。
9.2.1 二叉排序树(Binary Search Tree)和平衡二叉树
- 二叉排序树及其查找过程
二叉排序树(Binary Search Tree)定义省略,二叉排序树又称为二叉查找树。 - 二叉排序树的插入和删除
插入过程相当显然,但是删除过程就不易理解。以待删除结点的直接前驱(直接后继)代替这一待删除结点,而后从待删除结点的左子树(右子树)中删去这一直接前驱(直接后继)。
namespace binary_sort_tree
{
typedef int DataType;//在C++中这个可以用重载代替
typedef struct Node_s
{
DataType data;
struct Node_s *left, *right;
} Node_t;
//中序遍历输出以root为根的数的值
inline void display(Node_t *root,std::ostream &outputStream)
{
if (root == NULL) return;
if (root->left != NULL) display(root->left,outputStream);
outputStream << root->data<<"\t";
if (root->right != NULL) display(root->right, outputStream);
}
inline void insert(const DataType &data, Node_t *&root)
{
if (root == NULL)
{
//生成新的结点
root = reinterpret_cast(malloc(sizeof(Node_t)));
root->data = data;
root->left = root->right = NULL;
return;
}
else if (data == root->data)
{//如果等于,那么就用新的替换旧的
root->data = data;
return;
}
else
{
//否则就在其左子树或者右子树上
if (data < root->data)
{//要插入到左结点的
if (root->left != NULL) insert(data, root->left);
else
{
//生成新的结点
root->left = reinterpret_cast(malloc(sizeof(Node_t)));
root->left->data = data;
root->left->left = root->left->right = NULL;
}
}
if (data > root->data)
{//要插入到右结点的
if (root->right != NULL) insert(data, root->right);
else
{
//生成新的结点
root->right = reinterpret_cast(malloc(sizeof(Node_t)));
root->right->data = data;
root->right->left = root->right->right = NULL;
}
}
}
}
//在以root为根的排序二叉树中
//尝试找到该结点并删除之
inline void remove(const DataType &data,Node_t *&root)
{
if (root == NULL) return;
if (data < root->data)//说明可能在左子树中
{
if (root->left != NULL) remove(data, root->left);
else return;
}
if (data > root->data)//说明可能在右子树中
{
if (root->right != NULL) remove(data, root->right);
else return;
}
if (data == root->data)
{
//当前root指针所指的结点即为
if (root->left == NULL || root->right == NULL)
{
if (root->left == NULL && root->right == NULL)
{
free(root);
root = NULL;
}
else
{
if (root->left != NULL && root->right == NULL)
{
Node_t *newRoot = root->left;
free(root);
root = newRoot;
}
if (root->left == NULL && root->right != NULL)
{
Node_t *newRoot = root->right;
free(root);
root = newRoot;
}
}
}
else
{
//两边子树都非空
//这就是最复杂的情况了
//方法1:找到前驱结点
if (root->left->right == NULL)
{
//此时前驱结点即为root->left
root->left->right = root->right;
free(root);
root = root->left;
}
else
{
Node_t *p, *q;
for (p = root->left,q = root->left->right;
q->right != NULL; p = q, q = q->right);
//此时前驱结点即为q,前驱结点的双亲结点即为p
p = q->left;//此时就从树中摘除了前驱结点q
//但是q的内存区域尚未释放,事实上,将要作为新的结点
q->left = root->left;
q->right = root->right;
root = q;
}
//方法2:找到后继结点
}
}
}
inline void test()
{
//插入结点
Node_t *tree = NULL;
insert(45, tree);
insert(12, tree);
insert(53, tree);
insert(3, tree);
insert(37, tree);
insert(100, tree);
insert(24, tree);
insert(61, tree);
insert(90, tree);
insert(78, tree);
display(tree, std::cout);
std::cout << std::endl;
remove(90, tree);
display(tree, std::cout);
std::cout << std::flush;
}
}
- 二叉排序树的查找分析
- 平衡二叉树
平衡二叉树(Balanced Binary Tree或Height-Balanced Tree)或是一棵空树,或其左右子树的深度差不超过1,且其左右子树均为平衡二叉树。平衡二叉树又称为AVL树。
结点的平衡因子(Balance Factor,BF):该结点左子树与右子树的深度之差。则平衡二叉树上所有结点的平衡因子只能为-1或0或1。
- 左旋与右旋
首先定义针对一棵树的左旋与右旋操作,设指针a
指向一棵树的根节点。- 左旋:在左旋之后,新的根节点为
*(a->right)
,设b = a->right
为指向旋转之后新的根节点的指针(旋转操作所需要进行的指针的修改此处不表)。*b
的右子树为旋转操作前*a
的右子树的右子树;*b
的左子树为以旋转前的*a
为根,以旋转前*a
的左子树作为左子树,以旋转前*a
的右子树的左子树作为右子树的一颗二叉树。如下表所示。旋转前 旋转后 根节点 a
a->right
左子树 a->left
ROOT: a
;
LEFT:a->left
;RIGHT:a->right->left
右子树 a->right
a->right->right
- 右旋:在右旋之后,新的根节点为
*(a->left)
,设b = a->left
为指向旋转之后新的根节点的指针(旋转操作所需要进行的指针的修改此处不表)。*b
的左子树为旋转操作前*a
的左子树的左子树;*b
的右子树为以旋转前的*a
为根,以旋转前*a
的左子树的右子树作为左子树,以旋转前*a
的右子树作为右子树的一颗二叉树。如下表所示。旋转前 旋转后 根节点 a
a->left
左子树 a->left
a->left->left
右子树 a->right
ROOT: a
;
LEFT:a->left->right
;RIGHT:a->right
*b
为根的树仍符合排序二叉树的定义,依然是一颗排序二叉树。 - 左旋:在左旋之后,新的根节点为
- 如何使得二叉排序树为平衡二叉树呢?需要对不平衡的二叉树进行“旋转”。在初始时,二叉树为空树,也是平衡的排序二叉树。在插入结点使得排序二叉树失去平衡时,设指针
a
指向离新插入结点最近且不平衡(平衡因子绝对值超过1)的祖先节点。可以根据新插入结点位于*a
的左子树或右子树进行如下分类讨论。插入结点位置 *a
的BF变化*a
的左子树*a
的右子树- 当新插入的结点位于
*a
的左子树且导致以*a
作为根的二叉树失去平衡时,*a
的BF值由变为。此时应当尝试右旋以使二叉树再平衡。
在进行右旋之后得到的以*b
为根的排序二叉树是否平衡呢?我们需要讨论以*b
为根的树的左右子树深度之差。
由于此时*a
的BF值从1变为2,我们可以设插入这一结点之前*a
的左子树的深度为,*a
的右子树的深度为。插入这一结点之后*a
的左子树的深度增加到了。且由于a
指向离新插入结点最近且不平衡(平衡因子绝对值超过1)的祖先节点,则*a
的左右子树都是平衡的。
根据右旋操作的定义,旋转之后得到的排序二叉树(Binary Sort Tree)的右子树的深度不仅取决于旋转之前*(a->right)
的深度(),也取决于旋转之前*a
的左子树的右子树*(a->left->right)
;左子树即为旋转之前左子树的左子树*(a->left->left)
。因此,为讨论右旋之后得到的以*b
为根的排序二叉树的左右子树深度之差(以考察该树是否平衡),我们需要对新插入的这一结点位于*a
的左子树或是右子树中进行分类讨论。- 若新的结点插入到了
*a
的左子树的左子树中,并使得*a
左子树的深度由增加到了。则说明插入这一结点使得*a
的左子树的左子树的深度从增加到了,且*a
的左子树的右子树的深度为(根据指针a
的定义,*a
的左子树*(a->left)
在插入新结点前后都是平衡的)。深度 a->left->left
a->left->right
a->right
*b
的左子树(旋转之前*a
的左子树的左子树)的深度为;*b
的右子树的左子树(旋转之前*a
的左子树的右子树)的深度为,而*b
的右子树的右子树(旋转之前*a
的右子树)的深度为,故*b
的右子树的深度为。旋转后 深度 *b
的左子树a->left->left
*b
的右子树ROOT: a
;
LEFT:a->left->right
;
RIGHT:a->right
*b
为根的排序二叉树的值为,是平衡排序二叉树。 - 若新的结点插入到了
*a
的左子树的右子树中,并使得*a
左子树的深度由增加到了。则说明插入这一结点使得*a
的左子树的右子树的深度从增加到了,且*a
的左子树的左子树的深度为(根据指针a
的定义,*a
的左子树*(a->left)
在插入新结点前后都是平衡的)。深度 a->left->left
a->left->right
a->right
*b
的左子树(旋转之前*a
的左子树的左子树)的深度为;*b
的右子树的左子树(旋转之前*a
的左子树的右子树)的深度为,而*b
的右子树的右子树(旋转之前*a
的右子树)的深度为,故*b
的右子树的深度为。旋转后 深度 *b
的左子树a->left->left
*b
的右子树ROOT: a
;
LEFT:a->left->right
;
RIGHT:a->right
*b
为根的排序二叉树的值为。该二叉树不平衡,我们需要进行进一步的处理。
根据上文分析得到的插入新的结点之后,右旋之前各个子树的深度。将新结点插入到 *(a->left->left)
将新结点插入到 *(a->left->right)
a->left->left
a->left->right
a->right
*a
的左子树的右子树之后,若能进行某种操作使得以*(a->left->left)
为根的子树的深度从变为,使得以*(a->left->right)
为根的子树的深度从变为,在此之后就与上述将新的结点插入到*a
的左子树的左子树的情况相同。(对于将排序二叉树再平衡的操作,只需维持排序二叉树的性质,新插入结点的位置无需关心)
容易证明,在对以*a
为根的树进行右旋之前,对*a
的左子树进行左旋即可满足上述要求。(具体怎么样证明我实在不想写了,向上面那样讨论其与左旋有关的各个子树即可)
- 若新的结点插入到了
- 当新插入的结点位于
*a
的右子树且导致以*a
作为根的二叉树失去平衡时,*a
的BF值由变为。此时应当尝试左旋以使二叉树再平衡。
- 当新插入的结点位于
9.2.2 B-树和B+树
- B-树及其查找
B-树是一种平衡的多路查找树,其在文件系统中很有用。- 一棵m阶的B-树,或为空树,或为满足下列条件的m叉树:
- 树中每个结点至多有m棵子树
- 根结点至少有两颗子树(可能为空树)
- 除根结点之外的所有非空结点至少有(其中函数为向下取整)棵子树。
- 所有非空结点包含如下信息数据
其中序列为有序关键字序列,为指向子树根节点的指针,且满足,均有大于所指向子树中的所有关键字,且小于所指向子树中的所有关键字。为本结点中关键字数量(为本结点中子树数量)。
空树被视为查找失败的结点(外部结点),事实上这些结点不存在,指向这些空树的指针为空。
- 一棵m阶的B-树,或为空树,或为满足下列条件的m叉树:
- B-树查找分析
- B-树的插入和删除结点
- 在最底层某个结点中添加一个关键字,以及与相邻关键字间隔的空子树。添加完成后若该结点的关键字个数达到,即该结点的字数数量达到了。既达到了该结点容量的上限(无法插入更多关键字),也不再符合B-树“每个结点至多有m棵子树”的要求,故将要产生结点的“分裂”。
- 在删除关键字时,若被删除关键字所在的结点的关键字数量少于,则应当与兄弟节点进行合并。
- B+树
我看教材上没怎么介绍,而且已经不符合树的定义,在此就省略吧。
9.2.3 键树(数字查找树,Digital Search Trees)
键树是一颗多叉树,其中每一个结点中存储的是组成关键字的符号。由此可见,键树往往不是通用的,而是domain-specified的。并且在具体应用时,可能需要设定特定的叶子结点作为关键字(值)的结束,这样的叶子节点中可能需要一定的标志位,亦或是约定的(不可能作为关键字的一部分的)符号;这样的末尾叶子结点中可能还会带有对应于该关键字(的值)的记录,亦或是指向该记录的指针。
- 通常,键树可有两种存储结构
设组成关键字的符号数量为(包括一个代表关键字结束的符号),即所有关键字的可行值均可由个互不相同的符号组成。- 以树的孩子兄弟链表表示
- 以树的多重链表表示(此时键树也可称为Trie树)
每个结点中应包含有d个指针域…………Trie树是很直观也很容易设计的。
9.2 Hash表
9.3.1 什么是Hash表
Hash函数的定义从略。
对不同的关键字可能得到同一Hash地址,这种现象称为冲突(collision)。在一般情况下冲突只能尽可能少,而不能完全避免,因为从实用的角度考虑,Hash函数是一个从关键字集合到地址集合的压缩映射。因此建立哈希表(散列)时不仅要设定一个尽可能避免冲突且产生的地址尽可能短的“好”的Hash函数,也要设定一种处理冲突的方法。
均匀的(Uniform)的Hash函数:通俗地说,对于给定的关键字集合,经Hash函数产生的各个地址值的概率相等。
9.3.2 Hash函数的构造方法
- 直接定址法:取关键字或关键字的某个一次函数值为Hash地址
显然,由于Hash地址值是关键字的线性映射,所以不会发生冲突。当然这样做意义不大,实际应用中不常用。 - 数字分析法(这种方法需要针对关键字集合具体分析,得到Hash函数)
- 平方取中法:取关键字平方后的中间几位(由地址表长确定)为Hash地址
- 折叠法(folding)
将关键字分割成位数相同的几部分(最后一部分的位数可以不同),然后取这几部分的叠加和(无论进位考虑与否均可)作为Hash地址。 - 除留余数法
取关键字被某个不大于Hash表表长的数p
整除后所得的余数为Hash地址,即key % p
。值得注意的是对p
的选择很重要。