面试第1题:海量数据处理技巧

544 阅读6分钟

  在实际业务开发中,经常会遇到海量数据处理的问题。所以在面试的过程中经常会有类似的海量数据处理的考题。不过不用太担心,这类问题也有一些常用的处理技巧。 本期内容主要会讲述到如下的数据处理技巧:

  • Hash
  • BitMap
  • MapReduce
  • 快速排序、堆排序

第一题

某爬虫项目从互联网上爬取到 a、b 两个文件,各存放 100 亿个 url,每个 url 约占 64 字节,内存限制是 4G,如何找出 a、b 文件共同的 url ?
解题思路: Hash算法分而治之
  粗略估计单个文件大小约为640G,远大于内存4G限制,所以不能全部加载到内存中处理。此时就应该分而治之,就是将复杂问题分解成多个子问题,然后对简单的子问题求解,最终合并子问题的结果。
解题过程:

  • 对a文件进行遍历,对url进行hash取余,即 hash(url)%1024,目的是将文件数据切割成1024份,则此时每份数据大约为640M,那么就完全可以放到4G内存中处理。
  • 将上步骤中获取的url值分别存储到小文件里,即为 A0,A1,A2...A1023 文件中。
  • 将b文件进行遍历、取余和存储,得到 B0,B1,B3...B1023 文件中。
  • 比较文件 A0 和 B0,A1 和 B1 ... A1023 和 B1023 中相同的 url。可以先将 A0 中的 url 存储到 HashSet 中,再遍历 B0 的 url,查看是否在 HashSet 中,如果存在就是共同的 url,存到文件里面即可。

第二题

a、b 两个文件,各存放 100 亿个手机号,每个手机号都是11位,内存限制 4G,如何找出 a、b 文件中相同的手机号 ?
解题思路: BitMap建立映射
  如果把手机号当成 String 或者 Long 类型来存储,内存也是放不下的;按Long类型存储,(8byte×100亿)÷10243(8 byte \times 100 亿) \div { 1024^3 } = 75G,很明显内存也是放不下的。
解题过程:

  • 手机号是有规则,现在各大运营商网络识别号大概是: 13x,15x,16x,18x,19x(x 代表0-9某个数字)。
  • BitMap采用 bit 来存储数据的,可以有效的节省内存空间。用 0 表示文件中没有这个手机号,用 1 表示文件中有该手机号,而 1 byte = 8 bit ,那我们是不是用 1 byte 就能验证 8 个手机号了呢 !
  • 手机号都是 1 开头的,所以只需要考虑后10位数字,它的范围在 0 ~ 99,9999,9999 之间,所以可以用这个范围来建立 BitMap ,只需要 99,9999,9999÷(8×10242)99,9999,9999 \div ( 8 \times 1024^2 ) = 1.2G 内存。
  • 所以可直接在内存中建立一个 Bitmap,逐个读取文件 a 的手机号,映射到 bit 位置为 1;读完后再逐个读取文件 b 的手机号,判断对应的 bit 位是否为 1,是的话就输出到结果文件里面。 相同题型:
  • 无序有界int数组查找和排序
  • BitMap在Java中的应用

第三题

某搜索引擎会把用户的 query 记录下来,现在有 1 亿个 query,请你统计最热门的 10 个,内存限制 4G?
解题思路: 先统计频率,再根据频率排序
解题过程:

1. 统计频率的两种做法

  • 哈希表 假设内存能放下这些 query,那么可以直接在内存中建立 HashMap,以 query 作为 key,统计值为 value。循环遍历一遍 query 数据,就可以得到每个 query 出现的次数。

  • 外排序 (1) 当待排序的文件很大,无法将整个文件内容写入到内存中进行排序,只能将文件存放在外存储器(通常是硬盘)上,这种排序方法称为外排序。外排序通常采用排序、归并的策略,将文件拆分成内存可以加载的大小,在内存里面可以用快速排序,结果输出到新的文件里,然后对排序好的文件两两归并,最终得到一个有序的大文件。
    (2) 排完序之后我们再对已经有序的文件进行遍历,统计每个 query 出现的次数,再次写入文件中。

2. 找出 Top N

在上一步中已经统计 query:count 的结构,现在就是要找到 count 最大的 N 个对应的 query 就是我们想要的结果。

  • 快速排序 如果内存中可以把全部结果加载进去,则可以使用快速排序。核心思想就是基于快排:每次将数据分为一大一小两个区域,保证左边区域都不小于右边区域,淘汰掉一半,直到整个数组左边区域的数量为 N。
//统计结果对象
static class Data {
    String query;
    int count;
}

//快速选择算法,执行结束后datas前k个数据就是我们要找的
public void quickSelect(Data[] datas, int k) {
    int i = 0;
    int j = datas.length - 1;

    while(i <= j) {
        int partitionIdx = partition(datas, i, j);

        if((k - 1) == partitionIdx) {
            //左边下标为0~k-1的数据大于右边部分,则可以返回
            return;
        } else if((k - 1) < partitionIdx) {
            //左边的数量大于k,则继续处理左边的数据
            j = partitionIdx - 1;
        } else {
            //左边区域的数量不到k,则处理右边区域剩余的数据
            i = partitionIdx + 1;
        }
    }
    return;
}

//将数据分为两部分,右边区域数据不大于左边区域数据,返回中间下标
public int partition(Data[] datas, int start, int end) {
    if(start == end) {
        return start;
    }

    Data pivot = datas[start];

    while(start < end) {
        while(start < end && datas[end].count <= pivot.count) {
            end--;
        }
        datas[start] = datas[end];

        while(start < end && datas[start].count >= pivot.count) {
            start++;
        }
        datas[end] = datas[start];
    }

    datas[start] = pivot;
    return start;
}
  • 堆排序 (1) 什么是堆?可以把堆看作一个数组,也可以看作一个完全二叉树,通俗来讲堆其实就是利用完全二叉树的结构来维护一维数组
    (2) 堆可以分为 大顶堆 和 小顶堆:
    大顶堆:每个结点的值都大于或等于其左右孩子结点的值
    小顶堆:每个结点的值都小于或等于其左右孩子结点的值
    (3) 如果是排序,升序 用大顶堆,降序 用小顶堆
    (4) 本题中假设内存没办法加载整个统计结果,那么我们可以取统计过的前 10 个 query:count 组合,建立一个大小为 10 的小顶堆,遍历剩余的 query:count 每个元素和堆顶元素比较,如果次数大于堆顶元素,则替换掉,再重新调整小顶堆。代码如下所示:
//统计结果对象
static class Data {
    String query;
    int count;
}
//返回的heap有k个元素,也就是我们要找的query
public PriorityBlockingQueue<Data> topK(int k) {
    PriorityBlockingQueue<Data> heap = new PriorityBlockingQueue<Data>(k, Comparator.comparingInt(Data::getCount));
    readDatas().stream() //这里是伪代码,具体实现要改为从文件读取
        .forEach(d -> {
            if (heap.size() < k || d.count > heap.peek().count) {
                heap.put(d);
            }
            if (heap.size() > k) {
                heap.poll();
            }
        });
    return heap;
}

最终,遍历结束后的小顶堆上的 query,就是我们要找的数据。