二叉树优化查询效率

在实际的数据处理中,数据量可能会非常大,如果每次查询需要暴力搜索所有数据,查询效率将会非常低下,因此我们需要使用一些数据结构来优化查询效率。

我们先模拟生成200万条数据,以文件的形式进行存储到硬盘中。为了使模拟生成的数据更具真实性,这里使用开源框架datafaker,该框架可以模拟产生大部分常用数据类型。

首先,我们需要新建一个Maven项目,引入datafaker依赖:

<dependency>
    <groupId>net.datafaker</groupId>
    <artifactId>datafaker</artifactId>
    <version>1.8.1</version>
</dependency>

定义一组数据包含【名称】,【性别】,【手机号】,【城市】,【生日】五个属性,使用datafaker来生成数据的代码如下:

Faker faker = new Faker(new Locale("zh", "CN"));

String name = faker.name().fullName();
String gender = faker.gender().binaryTypes();
String phoneNumber = faker.phoneNumber().cellPhone();
String cityName = faker.address().cityName();
String birthday = faker.date().birthday("yyyy-MM-dd hh:mm:ss");

上述代码每次返回的数据都是随机生成的,数据类型如下所示:

郜皓轩,Male,15085472975,北京,1986-01-02 04:40:47

但是使用上述代码一次只能生成单条数据,datafaker还可以一次生成多条数据,datafaker提供了两种方式用于批量生成数据:

// 方式一:Collections
Faker faker = new Faker(new Locale("zh", "CN"));
List<String> names = faker.collection(
        () -> faker.name().fullName(),
        () -> faker.phoneNumber().cellPhone())
    .len(3, 5)
    .generate();
// 方式二:Streams
Stream<String> names = faker.stream(
        () -> faker.name().fullName(),
        () -> faker.phoneNumber().cellPhone())
    .len(3, 5)
    .generate();

其中len表示随机生成的数据范围,len(3,5)则表示随机生成3~5条数据,lambda表达式表示随机数据生成的类型,传入多个的话生成数据时会随机选择一个作为生成数据的类型。

datafaker还支持以特定的格式生成随机数据,有两种以特定格式生成数据的方法:

Faker faker = new Faker(new Locale("zh", "CN"));
// 定义一个Transformer, datafaker提供了Csv、Yaml、Xml、Json等多种格式,这里以Csv格式举例
CsvTransformer<Name> transformer = CsvTransformer.<Name>builder().header(true).separator(",").build();
// 生成数据条数
int limit = 2;
// 对于这两种情况,我们都需要一个能够描述字段和数据生成方式的Schema
// 方式一:使用scratch生成数据
Schema<Name, String> fromScratch = Schema.of(
    field("name", () -> faker.name().fullName()), 
    field("phoneNumber", () -> faker.phoneNumber().cellPhone()));
System.out.println(transformer.generate(fromScratch, limit));
// 方式二:先定义好要生成的数据类型, Schema可以从定义的类型中提取一些值, 并以特定的格式返回
Schema<Name, String> schemaForTransformations = Schema.of(
    field("firstName", Name::firstName), 
    field("lastname", Name::lastName));
// 传递Name对象的集合, 并从每个元素中提取名字和姓氏
// 注意: 这里使用的Function返回的类型必须与Schema中使用的类型一致(Name)
System.out.println(transformer.generate(faker.collection(faker::name).maxLen(limit).generate(), schemaForTransformations));

使用Transformer和Schema就可以很方便的生成一组特定格式的数据,但如果把要生成的200万条数据都写入到一个文件中,那么生成数据将会花费大量的时间,所以需要将数据拆分到多个文件中。为了处理方便,给每条数据额外添加一个编号,从0开始逐条递增,然后每个分片文件存储1万条数据,那么一共将产生200个文件。把文件按0~199依次进行编号,生成的数据格式如下所示:

file0.csv
0,鞠文,Male,17363358523,呼和浩特,1984-02-19 12:53:51
1,公西鹏煊,Female,17294214256,马鞍山,1977-08-20 11:46:24
2,从思聪,Male,17151337011,渭南,1983-09-07 06:23:55
3,窦楷瑞,Male,14558660355,牡丹江,2004-10-05 08:17:08
4,钟离子轩,Female,17354196266,张家界,1974-12-01 08:29:56
5,伯静,Male,15989561507,聊城,1965-10-31 12:42:07
6,鲍靖琪,Female,15130222360,肇庆,1983-04-01 07:47:32
7,督思源,Female,17234956373,蓬莱,1987-06-07 01:53:19
8,叶智渊,Male,15794236733,南宁,1981-10-20 02:20:09
9,师振家,Male,17803871130,珠海,1958-07-28 12:17:12
10,闻哲瀚,Male,15744747002,丽水,1977-05-05 12:50:47
……
file1.csv
10000,越鸿涛,Female,14571635492,中山,1962-02-06 04:08:49
10001,浦晓博,Female,15383650852,珠海,1995-10-13 09:14:56
10002,鲁晟睿,Male,17787402521,岳阳,1963-08-21 09:39:16
10003,荆语堂,Female,17868632201,张家口,1969-11-28 09:17:37
10004,沈航,Female,17785031292,舟山,1991-04-04 01:58:46
10005,孟擎苍,Female,15191040164,莱西,1994-12-24 09:09:52
10006,康睿渊,Male,15301774267,攀枝花,1990-10-31 10:28:02
10007,危姣姣,Female,17033279642,杭州,1969-02-03 05:42:04
10008,乐伟,Male,15652077694,大连,1959-12-29 10:53:51
10009,俞明轩,Female,17179540329,哈尔滨,1994-12-03 05:32:39
10010,红博文,Female,14504556349,珠海,1970-04-08 05:05:17
……

如果要查询第N条数据,就可以通过N / 10000快速得到数据存储的文件编号,把N对10000进行取余数就可以得到数据所在文件的具体行数。比如现在需要查找第999999条数据,根据 999999 / 10000 = 99得知数据所在的文件编号为99,再根据999999 % 10000 = 9999得知该条数据在编号99文件的最后一行(文件和行编号都从0开始)。

但是对于需要查询指定name的数据,必须遍历所有文件才可以得出想要的结果。用代码实现如下所示:

String searchName = "张三";
List<String> results = new ArrayList<>();
for (int i = 0; i < 200; i++) {
    // listByPartition(int) 方法读取指定编号的文件, 并将文件中所有行整合成一个String集合进行返回
    List<String> rows = listByPartition(i);
    for (String row : rows) {
        // 通过Csv模板生成的数据每个字段用逗号分隔, 其中第2个字段为name
        String[] cols = row.split(",");
        if (searchName.equals(cols[1])) {
            results.add(row);
        }
    }
}

毫无疑问使用暴力检索的方式查询数据效率将会非常低下,现在我们考虑如何进行优化。

我们知道数据库会通过给字段添加索引的方式来提高查询的效率,其原理是通过额外的空间为索引字段单独维护一个数据结构,基于该数据结构可以在更低的时间复杂度内查找出所有符合条件的结果,比如Mysql数据的Innodb引擎使用B-Tree数据结构的查询就是O(logN)的时间复杂度。

为了简单起见,我们可以使用二叉树来优化查询效率。具体地,我们可以为数据中的name属性额外维护一个字典树,字典树的叶子结点存储数据的行编号。在查询时,我们可以通过字典树来快速定位指定name的行编号,再通过行编号来筛选出所有对应的数据。

由于name字段是字符类型的,不方便直接生成字典树,我们需要先将字符类型转换为Unicode码,再将Unicode码转换成一个二进制串,然后定义0为字典树的左子结点,1为字典树的右子结点,这样就可以很简单生成字典树。具体地,我们先定义字典树和Node结点:

public class TrieTree {
    Node root;
}

public class Node {
    // 由于名称可能重复, 所以存在多个行编号
    List<Integer> value;
    Node left;
    Node right;
    public Node() {
        this.value = new ArrayList<>();
    }
}

再定义一个添加字符类型结点的方法:

public void addNode(String str, int primaryKey) {
    Node node = root;
    for (int i = 0; i < str.length(); i++) {
        // 将字符转换为Unicode码
        int codePoint = str.charAt(i);
        // 从左到右, 遍历Unicode码的每一位Bit
        for (int j = 31; j >= 0; j--) {
            // 取出当前位Bit的值
            int bit = (codePoint >> j) & 1;
            // bit为0则添加到左子结点
            if (bit == 0) {
                if (node.left == null) {
                    node.left = new Node();
                }
                // 将当前结点指向左子结点
                node = node.left;
            // bit为1则添加到右子结点
            } else {
                if (node.right == null) {
                    node.right = new Node();
                }
                // 将当前结点指向右子结点
                node = node.right;
            }
        }
    }
    // 叶子结点添加行编号的值
    node.value.add(primaryKey);
}

这样我们就可以事先将所有数据的name单独维护出一颗字典树,查询指定name的值时,我们就可以通过字典树以O(logN)的时间复杂度查询出符合条件的数据存在的所有行编号,具体代码实现如下:

public Node search(String str) {
    Node node = root;
    for (int i = 0; i < str.length(); i++) {
        int codePoint = str.charAt(i);
        for (int j = 31; j >= 0; j--) {
            int bit = (codePoint >> j) & 1;
            if (bit == 0) {
                node = node.left;
            } else {
                node = node.right;
            }
            // 无法匹配的场景
            if (node == null) {
                break;
            }
        }
    }

    return node;
}

当然,为了更好地维护字典树,我们需要考虑对于新数据的插入和删除操作。对于新数据的插入,我们可以在插入时将其name属性加入字典树;对于数据的删除,我们需要同时从数据中删除对应的记录以及从字典树中删除对应的name。这样,我们就可以在数据量较大时使用二叉树来优化查询效率了。

完整代码地址:https://github.com/masterliu66/Zero/tree/master/src/main/java/com/masterliu/zero/algorithm/search

文章作者: Hakurei
本文链接:
版权声明: 本站所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 Zero
数据结构与算法 数据结构 Java
喜欢就支持一下吧