More Related Content Similar to Erlang抽象数据结构简介 (12) More from Xiaozhe Wang (6) Erlang抽象数据结构简介1. Erlang 数据结构模块
array - 动态数组
dict - 动态散列表实现的字典
sets - 动态散列表实现的集合
queue - 双向队列
gb_trees - 平衡二叉查找树,可作为有序字典使用
gb_sets - 平衡二叉查找树实现的有序集合
orddict - 列表实现的有序字典
ordsets - 列表实现的有序集合
2. array
考虑到函数式语言数据不可变的特性,array 使用基数为 10 的 tuple tree 形式实现,将元素更改发生时需要复制的数据限制在很小的范围内,以便提高效率。
为了平衡存储空间和动态性,tuple tree 是惰性展开的,仅在某个下标被使用时才会创建出对应的 tuple 结构进行存储,。到 R14B02 版本为止的实现里,tuple tree 都是
只增大不减小的,对 array 进行 resize/2 操作时仅仅是更新了其 size 属性而已;
array 的下标是从 0 开始的,同 tuple、list 等结构起始下标不同!
array 有两种遍历方式:普通遍历和稀疏遍历,区别在于稀疏遍历会跳过所有未定义(取值为默认值)的元素,而普通遍历则不会跳过。
3. array
array 内部结构如下图所示:
其中记录字段含义为:
size - 当前数组中已被用户存储过数据的元素个数;
max - 当前数组中能够保存的最大元素个数;
default - 未存储数据元素的默认值,未指定时默认为 undefined;
elements - 保存数据的 tuple tree
5. dict
dict 基于论文 The Design and Implementation of Dynamic Hashing for Sets and Tables in Icon 论文中提出的动态散列技术构建,通过在散列 bucket 数量同
存储元素数量之间维持一定比例,实现较高的内存使用率及访问效率。
同 array 结构采用的策略相似,整个散列 bucket 数组也被划分为若干个固定长度的区段(segments),以便在更新特定 bucket 内容时不需要整体复制数据。目前每个
segment 的大小设定为 16,是反复测试后选择的最佳值。
dict 的扩张和紧缩都依赖于一定的元素数量阈值触发,该阈值基于当前活动的 bucket 数量按比例计算得出,目前设定为元素数量超过 bucket_no*5 时触发扩张操作,低于
bucket_no*3 时触发紧缩操作。可能导致元素数量减少的操作都会在操作完成后尝试紧缩 bucket 区段;可能导致元素数量增加的操作都会在操作进行前尝试扩张 bucket 区
段;
bucket 中的多个键值对以无序列表形式存储,对 dict 的访问操作最终都会对特定的 bucket 进行遍历。只读操作的遍历是尾递归形式,bucket 较大时附加开销很小;但更新
操作的遍历是普通递归形式,附加开销较大,所幸通常情况下 dict 的算法可以保证 bucket 不会很长。
dict 中的键值对本身以 improper list 形式保存,主要是希望以相同的代码高效处理 K-V(1对1) 和 K-Bag(1对多) 两种数据形式。
7. dict
其中字段含义为:
size - 散列表中已存元素数量,初始为 0;
n - 散列表中活动 bucket 数量,初始为 16;
maxn - 散列表中当前允许的最大 bucket 数量,扩张操作需要据此判断是否要增加新的 bucket 区段,初始为 16;
bso - 动态散列算法中计算一个 bucket 及其伙伴 bucket 的分隔下标,以此为界将 bucket 数组划分为对称的两半,初始为 8;
exp_size - 触发扩张操作的元素数量阈值,根据活动 bucket 数量和加载因子计算得出,初始为 16*5=80;
con_size - 触发紧缩操作的元素数量阈值,根据活动 bucket 数量和加载因子计算得出,初始为 16*3=48;
empty - 用于快速创建新 bucket 区段的区段模板,为 16 元 tuple,每个元素都是空列表;
segs - 散列 bucket 区段组,其下保存了所有的元素数据;
8. dict
紧缩发生条件:
操作完成后元素数量小于 dict 紧缩阈值 dict.con_size
满足前者的条件下,dict 活动 bucket 数量 dict.n 大于 16
紧缩过程:每次紧缩掉一个 bucket,bucket 数量足够少时减半 bucket 区段
计算出最后一个 bucket(dict.n) X 的伙伴 bucket Y 的位置(dict.n-dict.bso);
将 X 和 Y 的列表合并,替换掉 Y 列表,并清空 X 列表;
活动 bucket 数量 dict.n 减 1,同时根据加载因子重新计算紧缩与扩充阈值;
若活动 bucket 数量等于分隔下标 dict.bso,则将 bucket 区段、最大 bucket 数量 dict.maxn 和分隔下标 dict.bso 都减半。
9. dict
扩张发生条件:
操作进行前后元素数量大于 dict 扩张阈值 dict.exp_size
扩张过程:每次增加一个 bucket,bucket 数量增长到最大值时倍增 bucket 区段
若活动 bucket 数量 dict.n 等于最大 bucket 数量 dict.maxn,则将 bucket 区段、最大 bucket 数量 dict.maxn 和分隔下标 dict.bso 都倍增;
活动 bucket 数量 dict.n 加 1,作为新增 bucket X 的下标,同时根据加载因子重新计算紧缩与扩充阈值;
计算出新增 bucket X 的伙伴 bucket Y 的位置(dict.n-dict.bso);
将 Y 的列表重新散列,分配到 X 和 Y 两个 bucket 里;
11. queue
为了实现快速的双向入队、出队操作,queue 基于论文 Purely Functional Data Structures 中描述的算法构建;
queue 被表示为 2 元 tuple,第一个元素是反向排列的尾部元素列表,第二个元素是正向排列的头部元素列表,二者合在一起就是完整的队列;
queue 内部结构如下图所示:
12. orddict
orddict 用按 key 排序的列表表示字典结构,数据量较大时访问开销很大,只适用于数据量较小的情况;
例如:向 orddict 顺序插入 z->1、a->2、c->3 后,orddict 结构为:[{a,2}, {c,3}, {z,1}]
13. ordsets
ordsets 同 orddict 结构一样,只是存储的是 key 而不是键值对;
例如:向 ordsets 顺序插入 z、a、c 后,ordsets 结构为:[a, c, z]
14. gb_trees
gb_trees 是一种平衡二叉查找树结构,基于论文 General Balanced Trees 中描述的算法构建,它可以在不额外在结点上记录数据的情况下,通过简单的平衡策略达到平均
状况下接近 AVL 树或红黑树的访问效率;
原文中每个 GB 树需要记录 2 个全局数据:树中结点个数 |T| 和自上次全局重平衡后的结点删除次数 d(T)。基于这 2 个数据可定义重平衡策略如下:
发生结点插入时,若新增结点 v 的高度 h(v) 满足条件 h(v) > ceil(c*log[2](|T|+d(T))),则沿插入路径回溯寻找高度最小的结点 u 使得 h(u) > ceil(c*log[2]
(|u|)),然后对以 u 为根的子树进行局部重平衡操作,最后增加全局计数 |T|;
发生结点删除时,增加全局计数 d(T),若条件 d(T) >= (2^(b/c)-1)*|T| 满足,则对整个树进行全局重平衡,并重置 d(T) 为 0。
这里 c、b 为常数且满足条件 c > 1、b > 0,使用这种重平衡策略后树的最大高度为 max(h(T)) = ceil(c*log[2](|T|)+b)
15. gb_trees
Erlang 的 gb_trees 并没有完全按照论文的方式实现,而是进行了若干折中处理:
因为结点删除不会增加树的高度,故删除操作不进行自动重平衡,以降低删除开销。在大规模删除结点后,为了让查找效率最优化,开发人员可以显式调用 balance/1 方法对整
棵树进行重平衡;
由于不进行自动重平衡,gb_trees 不需要记录删除次数 d(T),只有一个全局数据 |T| 需要记录;
局部重平衡子树的根结点查找策略从原来的 h(u) > ceil(c*log[2](|u|)) 改为 2^h(u) > |u|^c,以提高计算效率。虽然变换前后的条件不完全相同,但实测差异不大;
16. gb_trees
gb_trees 内部结构如下图所示:
其中字段含义为:
Size - 树中结点总数 |T|;
Tree - 保存整棵树结构;
Key - 结点键;
Val - 结点值;
Smaller - 左子树结构,其中所有结点键都小于当前结点键;
Bigger - 右子树结构,其中所有结点键都大于当前结点键;
19. gb_sets
gb_sets 进行集合间操作时,总是会将元素数量较少的集合 X 转换为有序列表,并根据另一个集合 Y 的元素数量使用不同的方法进行遍历操作:
若 |Y| < 10,则集合 Y 也被转换为有序列表,X 和 Y 之间的集合操作通过列表归并进行,结果列表再转换为 gb_sets 结构;
若 |Y|/|X| < c*log[2](|Y|),则仍然将 Y 转换为有序列表处理,这里 c 设为 1,且因 math 模块中没有以 2 为底的对数函数,条件被改为 |Y| < |X|*c1*ln(|Y|),其中
c1=c/ln(2)=1.46;
以上条件都不满足时,保持 Y 为树形结构不变进行遍历;
20. 性能评测
数据结构 写1w次时间(us) 读1w次时间(us)
array 5398 2808
dict 19081 3393
sets 15267 3009
gb_trees 42864 5061
gb_sets 46527 4902
orddict 2812805 1248887
ordsets 1699518 964396
22. 总结
要求遍历的顺序性时,尽量避免使用效率低下的 orddict 和 ordsets,用 gb_trees 和 gb_sets 代替它们;
不要求遍历的顺序性时,尽量使用 dict 和 sets,因它们比 gb_trees 和 gb_sets 的效率高;
平均来看 queue 的入队出队操作基本上同列表头部操作开销一样,效率很高,只需要双向队列访问模式的地方推荐使用;