summer-trie 一个简单好用的前缀树工具summer-trie 一个简单好用的前缀树工具 这是一个节点支持任意数
summer-trie
项目地址
github github.com/chitucao/su…
gitee gitee.com/chitucao/su…
介绍
这是一个节点支持任意数据类型的前缀树,支持非前缀匹配,支持树查询、聚合查询等多种特殊的查询方式。设计得比较轻量通用,适用于大量列表数据的索引和压缩。不同于有限字符集前缀树实现(每个节点表达的状态是同一类型),主要是设计思想是将数据中多个不同类型的字段作为节点,组合成一颗前缀树,提高这些字段的检索性能;
目前是用于同程旅行盲盒机票、火车票的本地资源预筛选、数据分析以及校验场景下的结果缓存;
如果使用的过程中发现有bug,或者希望添加额外的功能,欢迎提交PR;
适用于以下场景
- 索引,大量列表数据场景下建立前缀树索引,提高检索性能;
- 数据压缩,支持将列表数据转换成树结构,配合字段值字典,既压缩了数据,也提高了查询性能;
关键功能和特性
- 比较通用和易用,只需要编写少量的代码,就可以将某个数据的多个字段组合成前缀树;
- 支持非前缀匹配,支持后续字段反向查询前缀字段(或者说查询和展示字段没有顺序关系,任意层级既可以作为查询条件,也可以作为展示字段);
- 支持多种方式的增删改查;
- 查询条件支持EQUAL、BETWEEN、GTE、LTE、IN、NOT_IN(可以扩展),得益于树结构,等值查询复杂度可以支持到O(1),范围查询可以支持到O(logn),不仅仅是前缀匹配,也可以支持后续条件匹配;
- 支持简单的聚合查询(可以扩展),例如MIN,MAX,时间复杂度可以达到O(logn);
- 树节点不存储实际字段值,而是由一个全局的字典维护字段值,树节点存储的是字段对应的字典索引,可以压缩数据,提高查询性能;
- 支持序列化和反序列化,适用于通信和数据dump分析;
几种核心的查询方式
- 按层查询,可以查询某一层的数据,典型的应用场景是从首层到底层依次查询情况;
- 原始数据查询,属于按层查询的特殊情况,查询的是叶子节点,并且叶子节点存储的是原始数据的情况;
- 树结构查询,指定要查询的多个索引字段,并将查询结果组合成一颗新树返回,支持聚合;
- 列表结构查询,列表是数据最常用的展示方式,查询过程同树结构查询,不过会将树结构平铺成列表返回;
- 字典值查询,字典维护了某个字段的所有有效值,当查询时不知道如何入手时,可以从字典值范围内选取;
核心概念
节点(Node)
- 节点分为根节点、树枝节点、叶子节点,所有的节点都不存实际的字段值,而是存储number类型的字典key;
- 节点可以存储原始数据的某个字段,也可以存储多个字段的组合和映射(如成对出现的日期范围);
- 叶子节点也可以不存储原始数据,适用于树中的字段已经能够满足查询条件的情况,比如一个索引不够想多做几个辅助查询;
- 节点的类型目前有两种,treeMap和hashMap,如果想做范围和比较查询,例如价格,金额,日期等,就用treeMap,如果字段是枚举类型,只用到等值、多值查询,用hashMap足够,其次是增删改性能的差别;
字典(Dict)
- 前缀树后续层级的节点中,同一个字段值可能会出现多次,如果直接将重复的值存储在树节点上,会比较浪费空间,所以想到为每个字段建立一个全局唯一的映射关系,也就是字典,字典的key对应一个number类型的id,字典的value对应实际的值,树节点上只需要存储字典key就可以了,可以节省空间;
- 这个映射关系主要分为两类,一种是类数字类型,比如字段是日期,金额,数字编号等,可以唯一转换成一个number类型的字典key,这样做的好处是可以做范围查询,另一种是数据本身无法对应这种数字类型的id,是由字典去分配一个自增id,这种情况下这个字段是不支持范围查询的;
- 所以树节点上存储的实际上就是字典key,是number类型的,只要是number的子类就行,如果字典范围比较小,可以尽量映射成范围较小的数据结构洁身空间;
- 选用number类型的另外一个好处是一般支持comparable接口,这样节点就可以使用sortedMap,比如treeMap,在例如金额的范围,比较,聚合查询的时候更有优势,对比hashMap,可以降低时间复杂度;
- 字典是和字段绑定的,并且是支持复用的,如果两棵前缀树用到了同一个字段,用同一个字典也行,可以节省空间。典型的应用场景为一份数据建立多个前缀树索引的场景,最后数据节点的字典是可以复用的;
属性(Property)
- 属性描述了选取数据实体的哪个字段作为一个树节点,并指定了这个字段值和字典key的映射关系;
- 属性的类型目前有两种,一种是simpleProperty,适用于枚举类型,不需要比较和范围查询,另一种CustomProperty,可以手动指定和字典key的映射关系,映射成一个数字,可以支持范围和比较查询;
- 注意simpleProperty是不能共享的,simpleProperty内部有一个局部变量(自增id),customProperty支持共享;
配置(Configuration)
- 配置描述了字典树的建立,有哪些字段,字段的顺序关系,是否需要快速删除等等;
- configuration还提供了一个特殊的方法,sortProperties方法,如果对按添加顺序构建节点不满意,可以在这里调整;
快速开始
添加maven坐标
<dependency>
<groupId>top.chitucao.summerframework</groupId>
<artifactId>summer-trie</artifactId>
<version>1.0.1.RELEASE</version>
</dependency>
新建前缀树
1.作为索引,并查询原始数据
-
比如一个对象叫TrainSourceDO,是一个火车票资源地实体,希望为出发城市、抵达城市、价格、坐席类型建立索引,并且能够查询到数据本身,可以按照以下配置;
-
这里的setPropertyMapper是指定希望哪个字段作为要建立索引的字段,setDictKeyMapper指定了索引字段和字典key(树上实际存储的数据)映射关系;
-
这里的价格我们希望是能够支持范围和比较查询的,并且为了提高查询性能,所以指定了用treeMap;
-
最终前缀树节点的顺序是按照添加顺序来的,也就是出发城市作为第一个节点,原始数据作为尾部节点,所以是可以查询原始数据的;
Configuration configuration = new Configuration(); // 出发城市 CustomizedProperty<TrainSourceDO, Integer> depCityIdProperty = new CustomizedProperty<>("depCityId"); depCityIdProperty.setPropertyMapper(TrainSourceDO::getDepartureCityId); depCityIdProperty.setDictKeyMapper(r -> r); configuration.addProperty(depCityIdProperty); // 抵达城市 CustomizedProperty<TrainSourceDO, Integer> arrCityIdProperty = new CustomizedProperty<>("arrCityId"); arrCityIdProperty.setPropertyMapper(TrainSourceDO::getArrivalCityId); arrCityIdProperty.setDictKeyMapper(r -> r); configuration.addProperty(arrCityIdProperty); // 价格 CustomizedProperty<TrainSourceDO, Integer> arrDistrictIdProperty = new CustomizedProperty<>("price", NodeType.TREE_MAP); arrDistrictIdProperty.setPropertyMapper(t -> ((Double) t.getMinRealPrice()).intValue()); arrDistrictIdProperty.setDictKeyMapper(r -> r); configuration.addProperty(arrDistrictIdProperty); // 坐席类型 SimpleProperty<TrainSourceDO, String> seatClassProperty = new SimpleProperty<>("seatClass", DictKeyType.BYTE); seatClassProperty.setPropertyMapper(TrainSourceDO::getSeatClass); configuration.addProperty(seatClassProperty); // 数据 CustomizedProperty<TrainSourceDO, TrainSourceDO> dataProperty = new CustomizedProperty<>("data"); dataProperty.setPropertyMapper(Function.identity()); dataProperty.setDictKeyMapper(TrainSourceDO::getId); configuration.addProperty(dataProperty); // 新建前缀树 MapTrie<TrainSourceDO> trie = new MapTrie<>(configuration);
2.用作索引,只查询索引
-
同上,只是尾部节点不再是数据了;
Configuration configuration = new Configuration(); // 出发城市 CustomizedProperty<TrainSourceDO, Integer> depCityIdProperty = new CustomizedProperty<>("depCityId"); depCityIdProperty.setPropertyMapper(TrainSourceDO::getDepartureCityId); depCityIdProperty.setDictKeyMapper(r -> r); configuration.addProperty(depCityIdProperty); // 抵达城市 CustomizedProperty<TrainSourceDO, Integer> arrCityIdProperty = new CustomizedProperty<>("arrCityId"); arrCityIdProperty.setPropertyMapper(TrainSourceDO::getArrivalCityId); arrCityIdProperty.setDictKeyMapper(r -> r); configuration.addProperty(arrCityIdProperty); // 新建前缀树 MapTrie<TrainSourceDO> trie = new MapTrie<>(configuration);
3.用于压缩
-
将所有字段都作为前缀树的节点,反射构建;
Configuration configuration = new Configuration(); Field[] fields = ReflectUtil.getFields(TrainSourceDO.class); for (Field field : fields) { if (Number.class.isAssignableFrom(field.getType())) { CustomizedProperty customizedProperty = new CustomizedProperty<>(field.getName()); customizedProperty.setPropertyMapper(e -> ReflectUtil.getFieldValue(e, field)); customizedProperty.setDictKeyMapper(r -> r); configuration.addProperty(customizedProperty); } else { SimpleProperty simpleProperty = new SimpleProperty<>(field.getName()); simpleProperty.setPropertyMapper(e -> ReflectUtil.getFieldValue(e, field)); configuration.addProperty(simpleProperty); } } // 新建前缀树 MapTrie<TrainSourceDO> trie = new MapTrie<>(configuration);
-
再利用resultBuilder可以将数据完整的查询出来,反射构建;
ResultBuilder<TrainSourceDO> resultBuilder = new ResultBuilder<>(TrainSourceDO::new); Field[] fields = ReflectUtil.getFields(TrainSourceDO.class); for (Field field : fields) { resultBuilder.addSetter(field.getName(), (t, r) -> ReflectUtil.setFieldValue(t, field, r)); } return resultBuilder;
-
不带虚拟头节点的话,前缀树就是一个梯形结构,所以将选择性比较高的字段排在后面能够更好的压缩数据,可以先建立一次前缀树,拿到所有字段的字典值大小排序后再按照大小重新构建一次;
// 拿到每个字段的字典值大小 Map<String, Integer> dictSizes = trie1.dictSizes(); // 按照字段的大小排序 CollectionUtil.sort(configuration2.getProperties(), Comparator.comparing(e -> dictSizes.get(e.name()))); // 重新构建 MapTrie<TrainSourceDO> trie2 = new MapTrie<>(configuration2); for (TrainSourceDO data : dataList) { trie2.insert(data); }
添加数据
-
直接insert就行,时间复杂度是O(h),h是前缀树高度,也就是建立索引的字段数量
List<TrainSourceDO> dataList = getDataList("train_resource_3000.json"); for (TrainSourceDO data : dataList) { trie.insert(data); }
删除数据
-
1.根据数据本身删除;
-
数据本身包含了所有建立索引的字段,所以删除效率较高,复杂度O(h)
List<TrainSourceDO> dataToErase = RandomUtil.randomEles(dataList, 10); for (TrainSourceDO data : dataToErase) { trie.erase(data); }
-
-
2.根据条件删除
-
尽量给出尽可能多的字段,可以提高删除的效率,给出首部的字段删除效率高一点,直接给出尾部的字段需要循环查找,删除效率低;
Criteria criteria = new Criteria().addCriterion(Condition.BETWEEN, 0, 1005, "depCityId"); trie.erase(criteria);
-
有一种特殊情况,如果希望只根据id删除,然后尽量提高删除的效率的话,可以先根据尾部节点的字典拿到该数据,然后再删除,时间复杂度O(1)+O(h);
// 数据 CustomizedProperty<TrainSourceDO, TrainSourceDO> dataProperty = new CustomizedProperty<>("data", NodeType.TREE_MAP); dataProperty.setPropertyMapper(Function.identity()); dataProperty.setDictKeyMapper(TrainSourceDO::getId); configuration.addProperty(dataProperty); // 先根据字典拿到数据,然后再删除 long id = 143859138L; TrainSourceDO eraseData = (TrainSourceDO) trie.dictValues("data", id).iterator().next(); trie.erase(eraseData);
-
查询数据
1.按层查询
- 比如盲盒场景中,用户都是选择出发地然后随机出发日期,这里出发地是固定的,所以可以以出发地为条件,拿到所有的有效出发日期,然后再拿到有效出发日期下面的抵达地什么的;
List<Integer> queryDepCityList = Lists.newArrayList(144, 145, 146, 900);
List<Integer> indexList1 = dataList.stream().filter(e -> queryDepCityList.contains(e.getDepartureCityId())).map(TrainSourceDO::getArrivalCityId).distinct().sorted()
.collect(Collectors.toList());
Criteria criteria = new Criteria();
criteria.addCriterion(Condition.IN, queryDepCityList, "depCityId");
List<Integer> indexList2 = trie.<Integer> propertySearch(criteria, "arrCityId").stream().sorted().collect(Collectors.toList());
2.原始数据查询
- 一般来说是不会为数据的所有字段建立索引的,如果希望取到到非索引字段做一些操作,可以用这个方法直接查询最后一层原始数据;
Criteria criteria = new Criteria();
criteria.addCriterion(Condition.IN, queryDepCityList, "depCityId");
List<TrainSourceDO> dataList2 = trie.dataSearch(criteria).stream().sorted(Comparator.comparing(TrainSourceDO::getId)).collect(Collectors.toList());
3.树结构查询
-
树结构看起来比较直观,可以指定多个要查询的字段,组合成一颗树返回(这个树结构依然保持了原始数据字段间的关系);
-
支持聚合(对比mysql你可以这样理解,聚合字段前面的字段都是group by条件。支持多个字段的聚合);
-
你可以理解这个返回结果是多个hashmap的嵌套,最后一层是一个arrayList;
Criteria criteria = new Criteria(); criteria.addCriterion(Condition.IN, queryDepCityList, "depCityId"); Aggregations aggregations = new Aggregations(); Object result = trie.treeSearch(criteria, aggregations, "depCityId", "arrCityId", "id");
4.列表结构查询
-
同树结构查询,不过平铺成了列表,列表是最常用的数据返回方式;
-
是支持聚合的,比如例如出发城市、抵达城市、出发日期、价格构建一颗树,然后对价格使用最小值聚合,就用字典树实现了低价日历,以下是伪代码;
时间复杂度是O(1) x O(1) x O(log(d)) x O(log(p)) d是出发抵达地下的有效日期天数,p是有效日期下的价格总数
Trie<FlightUnitVO> trie = inlandFlightTrieIndexManager.getTrie(); Criteria criteria = buildCriteriaByQueryCondition(request.getQueryCondition()); Aggregations aggregations = new Aggregations(); aggregations.addAggregation(Aggregation.MIN, FlightTrieIndexNames.INDEX_PRICE); ResultBuilder<ListSearchResponse> resultBuilder = new ResultBuilder<>(ListSearchResponse::new); resultBuilder.addSetter(FlightTrieIndexNames.INDEX_DEP_CITY_CODE, ListSearchResponse::setDepCityCode); resultBuilder.addSetter(FlightTrieIndexNames.INDEX_ARR_CITY_CODE, ListSearchResponse::setArrCityCode); resultBuilder.addSetter(FlightTrieIndexNames.INDEX_DEP_DATE, ListSearchResponse::setDate); resultBuilder.addSetter(FlightTrieIndexNames.INDEX_PRICE, ListSearchResponse::setMinPrice); List<ListSearchResponse> result = trie.listSearch(criteria, aggregations, resultBuilder); return R.ok(result);
5.字典值查询
-
常见的情况是作为下拉框的options;
List<Integer> dataList2 = trie.<Integer>dictValues("depCityId").stream().sorted().collect(Collectors.toList());
-
下面是字典的一种特殊玩法,如果希望通过id查询,没必要从树上查,可以直接从最后那个数据字典拿,O(1)的复杂度;
long id = 143859138L; TrainSourceDO eraseData = (TrainSourceDO) trie.dictValues("data", id).iterator().next();
序列化和反序列化
-
使用的是protobuf序列化,对比原始json数据,序列化后的大小为原始json的1/5,也适用于数据dump分析;
// 序列化 MapTrie<TrainSourceDO> trie1 = new MapTrie<>(buildConfiguration3()); for (TrainSourceDO data : dataList) { trie1.insert(data); } File dumpFile = new File(RESOUCE_FOLDER + "train_resource_dump.dat"); if (dumpFile.exists()) { dumpFile.delete(); } FileUtil.writeBytes(trie1.serialize(), dumpFile); // 反序列化 MapTrie<TrainSourceDO> trie2 = new MapTrie<>(buildConfiguration3()); trie2.deserialize(FileUtil.readBytes(dumpFile));
性能分析和对比
前缀树和位图
树
- 树中的索引条目和行之间是一对一的关系;
- 适用于大量的增删改查;
- 适用于选择性高的列;
位图
-
位图的一个索引条目对应多条数据,每个bit位对应一行,占用空间非常小,很好地利用了CPU Cache的空间局部性;
-
适用于高度重复且读多写少的数据(低基数,选择性低);
-
适合OR和AND操作;
-
update成本比较高;
-
最早项目中用的就是位图,类似倒排索引,位图存在这样几个问题;
- 位图并不是很适合为价格,日期这种基数比较高的字段建立索引,所以一般针对价格,日期的查询,还需要拿到原始数据再进行一次遍历后查询;
- 如果一个字段,比如出发城市有100个,那么每新增一个城市就需要新增一个位图,这个位图如果不压缩的话,占用空间还是很大的,比如现在有60w的数据,那么每个索引在不压缩的情况下就是600000/8/1024 = 73kb;
- 位图更新的成本比较高,因为涉及到多个索引,直接更新还可能会引入一致性问题,所以一般不会直接更新原始的数据。拿es来举例,删除或者更新会将原始的文档id标记为删除,然后新增,最后在段合并的时候将这些无效的数据清理掉。所以更新的次数多了,位图中就会出现很多无效的占用空间的数据,导致空间占用越来越高,所以还需要在业务低峰期定期重建索引;
前缀树和B+树
相同点
- 都是多路查找树;
- 查询效率都比较稳定;
- 数据都是存在叶子节点上;
- 非叶子结点相当于是叶子结点的索引;
不同点
- B+树是整体有序的,适合于范围和等值查找,层数和和阶数有关,每个节点或者多个节点的数据尽量分布均匀并且放在一起,InnoDB里面Page是存储单位(空间局部性原理和磁盘预读);
- 前缀树是一般是整体无序的(单个节点内可以实现有序),数据并不要求均匀分布在每个节点上,或者将多个节点的数据放在一起组成固定大小。数据存的比较分散的坏处是多了很多的引用(指针),增加了空间复杂度,并且不适用于空间局部性,但这样组织很适合于等值查询。这个项目的实现里,层数是固定的,每个字段按层分配和查找,好处是可以实现非前缀匹配;
- Trie不适用于存储在随机访问比较慢的介质上,当数据非常庞大并且存储在磁盘上时,数据结构的效率更多地取决于磁盘块访问的数量,而不是所有操作的总量。B+ 数据在一个节点(可视为“数据块”)中包含许多记录,因此所需的块访问次数比 Trie 少得多;
- B+树更适合操作系统的文件索引和数据库索引;
- B+树的叶子节点增加了链指针,主要是为了加快检索多个相邻叶节点的效率,可以实现顺序查找;
前缀树的种类和变种
- prefix tree;
- suffix tree;
- radix tree(patricia tree, compact prefix tree);
- crit-bit tree;
- double array trie;
- ternary trie;
- Kart-trie;
转载自:https://juejin.cn/post/7417654149787025458