SlideShare a Scribd company logo
Erlang 数据结构模块
 array - 动态数组
 dict - 动态散列表实现的字典
 sets - 动态散列表实现的集合
 queue - 双向队���
 gb_trees - 平衡二叉查找树,可作为有序字典使用
 gb_sets - 平衡二叉查找树实现的有序集合
 orddict - 列表实现的有序字典
 ordsets - 列表实现的有序集合
array
  考虑到函数式语言数据不可变的特性,array 使用基数为 10 的 tuple tree 形式实现,将元素更改发生时需要复制的数据限制在很小的范围内,以便提高效率。
  为了平衡存储空间和动态性,tuple tree 是惰性展开的,仅在某个下标被使用时才会创建出对应的 tuple 结构进行存储,。到 R14B02 版本为止的实现里,tuple tree 都是
  只增大不减小的,对 array 进行 resize/2 操作时仅仅是更新了其 size 属性而已;
  array 的下标是从 0 开始的,同 tuple、list 等结构起始下标不同!
  array 有两种遍历方式:普通遍历和稀疏遍历,区别在于稀疏遍历会跳过所有未定义(取值为默认值)的元素,而普通遍历则不会跳过。
array
  array 内部结构如下图所示:




  其中记录字段含义为:
    size - 当前数组中已被用户存储过数据的元素个数;
    max - 当前数组中能够保存的最大元素个数;
    default - 未存储数据元素的默认值,未指定时默认为 undefined;
    elements - 保存数据的 tuple tree
array
  例子:顺序向一个空 array 的 9、0、100 下标处分别存储 a、b、c 数据,array 结构变化如下:
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对多) 两种数据形式。
dict
  dict 内部结构如下图所示:
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 区段组,其下保存了所有的元素数据;
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 都减半。
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 里;
sets
sets 同 dict 所用实现方法一致,只是存储元素从键值对变为了键本身,这里不再赘述。
queue
  为了实现快速的双向入队、出队操作,queue 基于论文 Purely Functional Data Structures 中描述的算法构建;
  queue 被表示为 2 元 tuple,第一个元素是反向排列的尾部元素列表,第二个元素是正向排列的头部元素列表,二者合在一起就是完整的队列;
  queue 内部结构如下图所示:
orddict
  orddict 用按 key 排序的列表表示字典结构,数据量较大时访问开销很大,只适用于数据量较小的情况;
  例如:向 orddict 顺序插入 z->1、a->2、c->3 后,orddict 结构为:[{a,2}, {c,3}, {z,1}]
ordsets
  ordsets 同 orddict 结构一样,只是存储的是 key 而不是键值对;
  例如:向 ordsets 顺序插入 z、a、c 后,ordsets 结构为:[a, c, z]
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)
gb_trees
Erlang 的 gb_trees 并没有完全按照论文的方式实现,而是进行了若干折中处理:

  因为结点删除不会增加树的高度,故删除操作不进行自动重平衡,以降低删除开销。在大规模删除结点后,为了让查找效率最优化,开发人员可以显式调用 balance/1 方法对整
  棵树进行重平衡;
  由于不进行自动重平衡,gb_trees 不需要记录删除次数 d(T),只有一个全局数据 |T| 需要记录;
  局部重平衡子树的根结点查找策略从原来的 h(u) > ceil(c*log[2](|u|)) 改为 2^h(u) > |u|^c,以提高计算效率。虽然变换前后的条件不完全相同,但实测差异不大;
gb_trees
  gb_trees 内部结构如下图所示:




  其中字段含义为:
    Size - 树中结点总数 |T|;
    Tree - 保存整棵树结构;
    Key - 结点键;
    Val - 结点值;
    Smaller - 左子树结构,其中所有结点键都小于当前结点键;
    Bigger - 右子树结构,其中所有结点键都大于当前结点键;
gb_trees
  smallest/1、largest/1、is_defined/2、lookup/2、get/2 都是尾递归操作,附加空间开销很低;
  所有对树进行变更的操作都是普通递归操作,数据集较大时附加空间开销很大;
  iterator/1 创建的迭代器是一个列表,记录了树中序遍历调用栈,next/2 只是基于迭代器列表进行简单的弹栈、压栈操作而已;
gb_sets
  gb_sets 同 gb_trees 结构基本一致,只是结点上只存储键,不再存储值。
  gb_sets 内部结构如下图所示:
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 为树形结构不变进行遍历;
性能评测

 数据结构 写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
性能评测

queue头部入队1w次时间(us) queue头部查看1w次时间(us) queue头部出队1w次时间(us)
               940                906               1395
queue尾部入队1w次时间(us) queue尾部查看1w次时间(us) queue尾部出队1w次时间(us)
              1036                809               1169
总结
 要求遍历的顺序性时,尽量避免使用效率低下的 orddict 和 ordsets,用 gb_trees 和 gb_sets 代替它们;
 不要求遍历的顺序性时,尽量使用 dict 和 sets,因它们比 gb_trees 和 gb_sets 的效率高;
 平均来看 queue 的入队出队操作基本上同列表头部操作开销一样,效率很高,只需要双向队列访问模式的地方推荐使用;
Q&A

More Related Content

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
  • 4. array 例子:顺序向一个空 array 的 9、0、100 下标处分别存储 a、b、c 数据,array 结构变化如下:
  • 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对多) 两种数据形式。
  • 6. dict dict 内部结构如下图所示:
  • 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 里;
  • 10. sets sets 同 dict 所用实现方法一致,只是存储元素从键值对变为了键本身,这里不再赘述。
  • 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 - 右子树结构,其中所有结点键都大于当前结点键;
  • 17. gb_trees smallest/1、largest/1、is_defined/2、lookup/2、get/2 都是尾递归操作,附加空间开销很低; 所有对树进行变更的操作都是普通递归操作,数据集较大时附加空间开销很大; iterator/1 创建的迭代器是一个列表,记录了树中序遍历调用栈,next/2 只是基于迭代器列表进行简单的弹栈、压栈操作而已;
  • 18. gb_sets gb_sets 同 gb_trees 结构基本一致,只是结点上只存储键,不再存储值。 gb_sets 内部结构如下图所示:
  • 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
  • 21. 性能评测 queue头部入队1w次时间(us) queue头部查看1w次时间(us) queue头部出队1w次时间(us) 940 906 1395 queue尾部入队1w次时间(us) queue尾部查看1w次时间(us) queue尾部出队1w次时间(us) 1036 809 1169
  • 22. 总结 要求遍历的顺序性时,尽量避免使用效率低下的 orddict 和 ordsets,用 gb_trees 和 gb_sets 代替它们; 不要求遍历的顺序性时,尽量使用 dict 和 sets,因它们比 gb_trees 和 gb_sets 的效率高; 平均来看 queue 的入队出队操作基本上同列表头部操作开销一样,效率很高,只需要双向队列访问模式的地方推荐使用;
  • 23. Q&A