高并发和大数据下的高级算法与数据结构:如何快速获取给定年龄区间的微信用户数量或快速获取美团中购买量前k的品类

在技术领域有一句经典话:程序=算法+数据结构。这意味着一个好的程序员往往要在算法与数据结构上有扎实的功底。这也是为何各个国内外大厂在面试时一定会考核这个领域。随着时代的发展,算法与数据结构的定义也在发生演变,早期的算法与数据结构主要针对于单机情况,例如经典的哈希,二叉树,折半查找等。但在互联网和大数据时代,有很多对应问题,经典算法已经解决不了,因为对于经典算法而言,它有一个潜在的要求就是数据能全部装入内存,但在大数据情况下,数据根本不能一次性装入内存,因此仅仅掌握经典算法是通不过互联网大厂的面试考核的。

如果你有过大厂面试经验,你会发现他们很多算法面试题的基本前提就是高并发和海量数据,如果你在设计解题思路时没有考虑这两点,那么通过的机会就很渺茫。在多年的发展积累后,在海量数据和高并发要求下,一套相应的,不同与经典的算法与数据结构体系已经诞生,两者之间的区别就如同经典牛顿力学和量子力学的区别。

由此我想利用一个专题,把我所了解的,用于高并发和海量数据的算法和数据结构讲解出来,这样能跟各位同行一起探讨学习以便共同进步。我们看一个具体的应用问题,那就是如何快速获得给定年龄区间的微信用户数量,例如给定[25,35],我们如何把处于这个年龄段的微信用户找出来。最简单的当然是把所有用户遍历一遍,然后遇到落入给定区间的用户就把计数加1,问题在于微信的用户量有十亿以上,单单过一遍都需要很长的时间,而且用户资料不可能全部存放在内存中,因此遍历时需要多次读取磁盘,于是又会进一步降低统计速度。

解决这个问题的一种有效算法和数据结构叫Count-Min Sketch。它最早用于在海量数据中快速获取数量个数位于前k的数据点,例如获取亚马逊网站上购买量前十的书籍或是在美团中购买量位于前十的食品品类。这类问题可以抽象为所谓的heavy hitters问题:给定一个参数k,对于包含N个元素的数组(N的量级在亿以上),如何快速找出重复次数超过N/k的元素。经典算法下的处理办法就是使用哈希表,每拿到一个元素就将其哈希到给定位置,将给定位置里的计数加一。但假设数值有十亿个不同的数,那么我们就需要将他们全部存储在内存中,如此一来我们解决问题的成本就会非常高。

在海量数据和高并发下,基本的处理思路就是不求精确解,但求近似解,只要我们把结果的误差控制在合理范围内即可,在后面你会看到,几乎所有用于高并发和海量数据的算法都是这个特征。对heavy hitter问题,我们会给定一组参数(\epsilon,k),要求找到的元素,其重复次数至少有N/k - \epsilonN,我们看看如何使用Count-min sketch算法来满足这个要求。

算法支持两种操作,分别是update和estimate, 假设在时间t,我们收到一组元素(_{}a_{t},c_{t}),a_{t}表示时间t时出现的元素,c_{t}对应它的计数,update操作就会讲元素a_{t}的总出现次数加上c_{t}.那么estimate操作将给出元素a_{t}到时刻t为止出现的总次数。estimate返回给定元素的出现次数只会多不会少,在给定概率内,多出的数量不会超过\epsilonN,我们看看其对应的数据结构。

Count-min sketch在数据结构上对应一个二维矩阵,它有d行和w列,其中每个元素都初始化为0.然后选取d个不同的哈希函数h_{1}h_{2},...,h_{d},,每个哈希函数的输出范围在[0...w-1],每个哈希函数分别对应二维数组的一行。在执行update操作时,我们分别用每个哈希函数对元素进行运算得到输出结果,将输出结果做为下标在给定行的特定位置,加上对应计数c_{t},对应python 伪码如下:

CMS_update(a_t, c_t):
    for j in range(0, d):
        CMS[j][h_j(a_t)] += c_t

 从代码我们可以发现一些问题,两个不同元素,他们的哈希结果可能相同,这种情况就会给计数的准确性带来影响,后面我们会看到如何将误差控制在给定范围。在执行estimate操作时,思路是给定元素a_t,我们分别用d个哈希函数计算,然后分别取出每一行对应的结果,最后在所有结果中选取值最小的那个,用伪码描述为:

CMS_estimate(a_t):
    val = CMS[0][h_1(a_t)]
    for j in range(1, d):
        val = min(CMS[j][h_j(a_t)], val)

    return val 

可以看到算法的逻辑很简单,为何这么简单的做法能应用在高并发和海量数据场景呢,我们需要一些数学上的描述,假设N为所有元素出现次数的总和,参数\epsilon用于控制误差的范围,\delta用来控制差错的概率,假设元素x的真实重复次数为f_{x}, estimate返回的结果为f_{est},那么这两个值满足如下公式:

f_{x} <= f_{est} <= f_{x}+\epsilon N

的概率为1-\delta,如果我们取 \delta为0.01,那么我们就能保证上面公司在99%的情况下成立。如果\epsilon取值越小,那么误差范围也就越小,\delta越小,误差范围成立的可能性就越高,当然这两个值越小就会使得d, w取值越大,也就是我们需要的内存就越多。我们看看这四个参数在数学上的关系。

假设元素的总重复次数之和为N,对于特定元素x,我们假设它对应的重复次数为f_x,同时h_j(x)=a_x, 其中0<=a_x<=w,如果我们把总重复次数之和看做N个小球,先把其中f_x个小球放入到盒子a_x,然后将剩下的小球随机丢入w个盒子中,那么落入到盒子a_x小球的数量就构成了误差。由于小球是随机丢入w个盒子,于是当把剩下的N - f_x个小球随机丢完后,每个格子预计的小球个数就是(N-f_x)/w <= N/w,于就是说最终落入盒子a_x的小球个数有可能多出差不多N/w个,如果我们想让多出的个数不超过\epsilonN,那么我们只要让格子的数量等于\frac{1}{\epsilon }即可。

对于将N个球随机丢进w个格子,我们用X作为某个格子最终获得球个数的随机变量,那么丢完N个球后,某个特定格子包含的球数为E[X],根据马尔科夫不等式有:

Pr(X >= c*E[X]) <=\frac{1}{c}

其中c是大于1的常量。如果我们把c取值为e,那么就有:

Pr(X>=e*E[X]])<=\frac{1}{e}

还记得在estimate中的操作吗,我们分别获取给定元素在每一行的计数,然后从中选出最小的那个。如果在这种情况下得到的结果还是超出了f_{x} + \epsilon N,那意味着每一行都必须要超出这个值,这种情况的概率就是:

Pr(每一行计数值都超出f_{x} + \epsilon N)<= (\frac{1}{e})^{d}

于是只要使得二维数组的列数w=\frac{e}{\epsilon },d=ln(\frac{1}{\delta }),我们就能保证f_{x} <= f_{est} <= f_{x}+\epsilon N满足的概率大于1-\delta,下面我们看看算法的基本实现:

import numpy as np
import mmh3
from math import  log, e, ceil
import random

class CountMinSketch:
    def __init__(self, epsilon, delta):
        self.epsilon = epsilon
        self.delta = delta
        self.w = int(ceil(e/epsilon)) #二维数组的宽度
        self.d = int(ceil(log(1./delta)))  #二维数组的高度
        self.sketch = np.zeros((self.d, self.w))

    def update(self, item, freq = 1): #将给定对象哈希到某一行的位置后将其计数增加
        for i in range (self.d):
            #在每一行选取不同的哈希函数去计算当前行对应的下标
            index = mmh3.hash(item, i) % self.w
            self.sketch[i][index] += freq

    def estimate(self, item):
        return min(self.sketch[i][mmh3.hash(item, i) % self.w] for i in range(self.d))

#epsilon=0.0001用于控制误差范围, delta=0.01 用于控制出错率,也就是技术超出误差范围的概率,
epsilon = 0.0001
delta = 0.01
cms = CountMinSketch(epsilon, delta)
for i in range(100000):
    cms.update(f'{i}', 1)

for i in range(10):
    elem = random.randrange(0, 100000)
    print(f"counter of element {elem} is {cms.estimate(f'{elem}')}")

 在上面代码中我们设定元素的总个数为100000,\epsilon=0.0001, 于是计数的误差范围为100000 * 0.0001 = 10,也就是每个元素计数误差范围不大于10,同时\delta设置为0.01,也就是元素计算误差不超过10的概率为99%也就是(1-0.01)。代码最后先让每个元素的计数设置为1,然后随机选择10个元素查看其计数,代码运行结果如下:

counter of element 17863 is 6.0
counter of element 83437 is 3.0
counter of element 36237 is 2.0
counter of element 50514 is 2.0
counter of element 94903 is 4.0
counter of element 89229 is 2.0
counter of element 70101 is 2.0
counter of element 4771 is 1.0
counter of element 67202 is 2.0
counter of element 62425 is 3.0

 我们可以看到有不少元素计数并不准确,但是他的误差都不超过10,我们可以尝试将\epsilon设置为0.00001,让计数误差不超过1,然后运行结果如下:

counter of element 29478 is 1.0
counter of element 70436 is 1.0
counter of element 60846 is 1.0
counter of element 68600 is 1.0
counter of element 84541 is 1.0
counter of element 75513 is 1.0
counter of element 66859 is 1.0
counter of element 37516 is 1.0
counter of element 72955 is 1.0
counter of element 92195 is 1.0

 从实验结果可以看到,算法使得误差的精度控制还是相当精确的。

count-min sketch算法一个精彩应用在于区间查询。很多情景下我们需要查找位于某个区间内元素的数量,例如给定年龄区间然后查找微信用户位于该区间的人数。我们当然可以遍历一次所有元素,查看其是否在给定区间内,但这种做法明显弊端就是消耗内存,要记得我们面对问题的基本假设是海量数据,目前微信用户有10亿以上,我们每增加一个查询区间,内存消耗就得增加10亿以上,因此传统做法无法应对海量数据的场景,而count-min sketch算法就能使用一定的误差率来减少内存的损耗。

算法针对区间查询的问题上设计得非常精巧。首先我们先了解一个叫“二元区间”的概念。给定数值例如16,获取它对应的二元区间方法如下,首先获取[1...16]作为一个区间,然后把它分成两部分,第一部分为[1...8],第二部分为[9....16],接着将其分为4部分,对应区间为[1..4],[5...8],[9...12],[13...16],接着继续切分,这次将其分为8部分,每部分只包含2个元素,分别为[1,2],[3,4],[5,6],[7,8],[9,10],[11,12],[13,14],[15,16],,继续切分,这次分割成16部分,这时每部分只包含1个元素,也就是[1].[2]....[16],到这一步后我们无法继续切分,那么当前所得的所有区间的集合就叫“二元区间”.

这里有一个很重要的性质就是,对于最大值不超过16的任意区间,它都可以分成若干个二元区间内元素的组合,例如区间[5,14]可以对应[5,8], [9,12],[13,14]。区间[2,16]可以对应[2],[3,4],[5,8],[9,16].区间[9,13]可以对应[9,12],[13]。可以看到给定一个上限后,其可能产生的子区间可以有很多种,但是其二元区间却是有限的,任何一个子区间都可以从有限个二元区间的集合中选取若干个元素组合而成,我们把二元区间中的元素作为我们前面统计计数对应的元素,于是给定任何区间,我们找出能覆盖其的二元区间元素的组合,然后依次把组合中区间对应的计数加总就能得到给定区间中元素的计数。

每个二元区间中元素对应的计数我们可以用前面描述的方法解决,现在问题在于给定一个任意区间,我们如何找出其对应的二元区间元素组合,也就是给定[5,14]后,我们怎么找到[5,8],[9,12],[13,14]。我们使用二叉树来完成此功能,我们看到第一次分成一个区间,第二次分成两个区间,第三次分成8个区间,以此类推,由此给定上限为n,那么可以分割最多lg(n)次,于是二叉树的根节点对应第一个次分割,第二层节点对应第二次分割,最后的叶子节点对应最后一次分割构成的区间。

给定任意区间,我们先从叶子节点开始遍历,如果两个叶子节点都位于给定区间内,那么就将其设置为true,如果两个节点有同一个父亲节点,那么把父亲节点设置为true,并将两节点设置为false,这个步骤一直进行,最终那些设置为true的节点对应的区间就是能覆盖目前区间的二元区间元素。我们看一个具体例子,假设目前区间为[1,5],那么我们首先在叶子节点查找,于是落入这个区间的叶子节点就是[1], [2],[3],[4],[5],此时分别将他们设置为true,其中[1],[2]有共同父节点就是[1,2]。[3],[4]也有共同父节点就是[3,4],于是分别将[1],[2],[3],[4]设置为false,然后将[1,2],[3,4]设置为true,此时节点[1,2],[3,4]有共同父节点也就是[1..4],也是我们将该节点设置为true,把[1,2],[3,4]设置为false,当前设置为true的节点只有[1...4],[5],他们没有共同父节点,于是算法结束,当前能覆盖区间[1,4]的二元区间元素就是[1...4]和[5]。

我们看看代码实现:

from collections import deque


class Node:
    def __init__(self, lower, upper):
        self.data = (lower, upper)  # 目标区间的上下限
        self.left = None
        self.right = None
        self.marked = False


def interval_to_bst(left, right):  # 将区间对应到节点
    if left == right:  # 区间只包含一个节点
        root = Node(left, right)
        return root
    else:
        root = Node(left, right)
        mid = int((left + right) / 2)
        root.left = interval_to_bst(left, mid)
        root.right = interval_to_bst(mid + 1, right)
        return root


def mark_nodes(root, lower, upper):
    if root is None:
        return
    queue = [root]
    stack = deque()
    while len(queue) > 0:  # 将二叉树的节点根据层级压入堆栈
        stack.append(queue[0])
        node = queue.pop(0)
        if node.left is not None:
            queue.append(node.left)
        if node.right is not None:
            queue.append(node.right)

    while len(stack) > 0:
        i = stack.pop()
        if i.data[0] >= lower and i.data[1] <= upper and i.left is None and i.right is None:
            # 如果节点是叶子节点并且位于区间内部则将其设置为true
            i.marked = True
        if i.left is not None and i.right is not None:  # 节点的孩子节点设置为true,那么他就设置为true
            if i.left.marked is True and i.right.marked is True:
                i.marked = True
                i.left.marked = False
                i.right.marked = False


def inorder_marked(root):  # 中序遍历二叉树,查看每个节点的属性从而找到能够覆盖目标区间的二元区间元素集合
    if root is None:
        return

    inorder_marked(root.left)
    if root.marked:
        print(root.data)
    inorder_marked(root.right)


root = interval_to_bst(1, 16)
mark_nodes(root, 3, 13)  # 获取区间[3,13]对应的二元区间元素集合
inorder_marked(root)

我们在代码中先构造上限为16的二元区间集合,然后查找区间(3,13)所对应的二元区间元素,最后将其打印出来,上面代码运行后所得结果为:

(3, 4)
(5, 8)
(9, 12)
(13, 13)

由此可见我们的实现基本上没问题。最后我们实验看看使用count-min-sketch算法查找给定区间元素个数的效果:

array = []
count = 3921 #设置落入区间(4999,5999)的元素个数
for i in range(0, 10000):
    if count > 0:
        array.append(random.randrange(4999, 5999))
        count -= 1
    else:
        array.append(random.randrange(0, 4998))
random.shuffle(array)


epsilon = 0.0001
delta = 0.01
cms = CountMinSketch(epsilon, delta)
ranges = [(1, 1998), (1999, 2998), (3999, 49998), (4999, 5999)]
for i in range(0, len(array)):
    for j in range(0, len(ranges)):
        if array[i] >= ranges[j][0] and array[i] <= ranges[j][1]:
            cms.update(f"({ranges[j][0]},{ranges[j][1]})", 1)

print(f'count of interfal (4999, 5999) is {cms.estimate("(4999,5999)")}')

可以看到无论我们要查找的区间有多少个,在给定了两个参数后,内存的大小都不会变化,上面代码运行后所得结果如下:

count of interfal (4999, 5999) is 3921.0

需要注意的是算法所需内存由\epsilon \delta这两个参数决定,与要统计的数量无关,也就是说不管微信用户量是1亿还是10亿,但是误差的范围会根据数量发生变化,如果在内存不变的情况下,用户量从1亿升到10亿,那么误差就会在原有基础上增加10倍,要想保证误差范围不变,我们就需要将内存扩大十倍,但无论如何,相比于把所有微信用户一次性加载到内存,算法使用的内存也要少好几个数量级,如果全部数据大小是1个T,那么我们可能使用1个G的内存就能对数据在给定误差范围内进行有效统计。

猜你喜欢

转载自blog.csdn.net/tyler_download/article/details/127716080