《算法导论》笔记
1 散列表(hash table)
散列表是实现字典操作的一种有效数据结构,查找一个元素的时间最坏情况是O(n),而在合理假设下,查找性能都是极好的,平均时间可达O(1)
散列表是普通数组的推广,寻址可以类比数组的O(1)时间内访问任意位置
散列表是根据关键字计算相应下标,通过链接(chaining)方法解决冲突(collision),冲突就是多个关键字映射到数组的同一个下标
直接寻址表:使用数组表示动态集合,数组每个位置称为槽(slot),把对象存放在槽中
使用散列函数根据关键字k计算出槽的位置,将全域U映射到散列表的槽中,其中散列表的槽占用内存远远小于全域U,但是存在两个关键字同时映射到同一个槽中,从而引起冲突
散列函数实现映射,需要满足给定输入始终产生相同的结果,冲突解决方法包括:链接法和开放寻址法
链接法
链接法是把散列到同一槽中的所有元素放到一个链表中
插入元素x如果为了防止重复存储需要进行遍历操作,花费额外复杂度
删除元素x(这里x是指向对象的指针,而这个对象存放的是关键字key和链表指针),如果对于采用单向链接,需要遍历链表,找到元素x指向对象的前驱元素的next属性让它指向元素x的后继元素,如此操作需要额外时间;如果对于采用双向链接,通过元素x就可以知道其指向对象中的prev和next指针,从而以O(1)时间让prev对象的next指向元素x的next对象即可,即删除操作变得十分简单(详见https://blog.csdn.net/yuanbohx/article/details/6664855)
note:上述说法存在漏洞,就是对于单向链表,可以将元素x的next对象的key赋值到元素x指向的对象,删除next对象即可,当然如果元素x指向对象是链表最后的节点则直接删除即可。这种做法实际上不是释放元素x对应内存,但也能以O(1)时间实现删除操作
链接法散列分析
装载因子(load factor):一个散列表具有m个槽位,能存放n个元素,则n/m为装载因子,装载因子可以大于、等于或小于1
如果散列表所有关键字都映射到同一个槽中,则散列表等同于一个链表,其查找性能为O(n)
散列方法的平均性能依赖于选取的散列函数,目标是将所有关键字能够均匀映射到槽位上
对于简单均匀散列,采用链接法解决冲突,一次查找的平均时间为O(1+α),其中O(1)是散列函数计算时间,α是槽中链表长度,O(α)是查找时间
因此,散列表的槽数要与所需要存放的元素个数成正比,即n=O(m),一个数量级,则查找的平均操作能达到常数时间,插入和删除的最坏情况也是常数时间
散列函数
好的散列函数应近似满足简单均匀散列假设,将关键字均匀映射到槽位
使用启发式方法来构造性能好的散列函数
-
映射到自然数集
多数散列函数都假定关键字的全域是自然数集,例如字符串可以根据ASCII字符进行转换,然后进行相应的数值计算进行转化 -
除法散列法
通过取k除以m的余数来确定槽位,m不能是2的幂,不然会导致余数实际上就是关键字的最后几位数字
h ( k ) = k m o d m h(k) = k mod m h(k)=kmodm -
乘法散列法
将关键字k乘以常数A,然后提取小数部分,再用m乘以这个值,再向下取整
h ( k ) = f l o o r ( m ( k A m o d 1 ) ) h(k) = floor(m(kA mod 1)) h(k)=floor(m(kAmod1))
乘法散列法对m的选择不敏感
A的取值近似于
(
(
5
)
−
1
)
/
2
(\sqrt(5)-1)/2
((5)−1)/2是比较理想的值
- 全域散列法
随机选取散列函数
开放寻址法
开放寻址法的装载因子不超过1,不采样链表,而是将所有元素都放在散列表中
有的元素都存放在散列表里,每个表项或包含动态集合的一个元素或者NIL。当查找某个元素时,要系统的检查所有表项,直到找到所有的元素或者最终查明元素不在表中。为了使用开放寻址法插入一个元素,需要连续的检查散列表,或称为探查(probe),直到找到一个空槽来放置待插入的关键字为止。检查的顺序不一定是0,1,2…m的顺序序列,而是依赖于待插入的关键字。
二叉搜索树
基本情况
二叉搜树的基本操作花费时间与树的高度成正比,最坏运行时间为O(lg n),但如果构建的树及其不平衡,甚至退化为链表,则最坏情况可能是O(n)
二叉搜索树,每个节点包含key和指针p、left和right,分别指向父节点、左孩子和有孩子,重要性质是左子树的节点关键字不大于其当前节点的关键字,右子树的关键字不小于其当前节点的关键字
二叉搜索树可以采用简单的递归进行遍历,包含中序遍历、先序遍历和后续遍历,具体实现可查看https://blog.csdn.net/baidu_35231778/article/details/118785115,
遍历过程都是线性时间
查询二叉搜索树
利用二叉搜索树性质,可以方便查找某一关键字所对应的节点:
TreeNode search(node, k){
if (k == node->key){
return node;
}
else if (k < node->key){
return search(node->left, k);
}
else{
return search(node->right, k);
}
}
或者使用while循环
TreeNode search(node, k){
while (node != nullptr and k != node->key){
if (k < node->key){
node = node -> left;
}
else{
node = node -> right;
}
}
return node;
}
查询最小值和最大值就比较容易了,最小值对应的是一直沿着left孩子指针搜寻,直到遇到NULL,前一个节点即为关键字最小的节点,最大值搜寻也类似,只不过是沿着righ孩子指针搜寻
有了关键字,可以通过中序遍历来查询其子节点
这些查询的操作都与树的高度有关,也就是在O(h)时间内完成
插入和删除
插入节点,就是根据节点的关键字从树跟开始查询,如果小于当前树节点的关键字,则继续查询其左节点,否则查询右节点,直至遇到null节点,就是插入节点的位置,而其父节点也即是插入节点的父节点
void insert(Tree T, TreeNode z){
TreeNode y;
TreeNode x = T.root;
while (x != nullptr){
y = x; // 记录父节点
if (z.key < x.key){
x = x.left;
}
else{
x = x.right;
}
}
z.p = y;
if (y == nullptr){
T.root = z; // tree T was empty
}
else if (z.key < y.key){
y.left = z;
}
else{
y.right = z;
}
}
删除操作就比较复杂了,删除节点z分成三种情况
- z没有子节点
则直接删除,修改其父节点的指针指向null即可 - z只有一个孩子节点
则将这个孩子节点提升到z的位置,并修改其父节点的指针指向这个新的节点 - 如果z有两个孩子
则从z的右子树中找z的后继y,并让y占据z的位置,这个y需没有左节点,而其父节点可以直接指向y的右节点
插入和删除操作的时间复杂度也是O(h)