最高频K项问题的在线和离线方法 Top K

这一类问题在海量数据类面试题中出现频率最高。问题的形式通常如下:

找到一个大文件或者数据流中出现频率最高的 K 项

这个问题的难点在于,如果条件不一样,解决的办法是完全不一样的,比如:

是否需要精确的 Top K 结果?即,是否允许小概率出错。
数据是离线的还是在线的?即是一个大文件的形式计算一次得到一个结果,还是数据流的形式实时返回结果。
接下来我们来看一个比较简单的算法题,一来让你切身地感受条件不同、解法不同的现象,更重要地是为之后解决最高频K 项问题做一个铺垫。

在一个整数数组中,找最大的 K 个整数
这个问题可以分为离线和在线两种,两种类型的解法完全不同。

这个问题,我们也可以直接使用 Quick Select 算法(百度一下,很好的方法),在 O(N) 的时间内找到数组的第K大的数。然后对前k大的数进行排序,时间复杂度是O(n + k log k)。两种算法都是基于快速排序算法的,十分值得学习。

有了最大K项 问题的铺垫之后,接下来我们求解最高频K 项 的问题,就有了直观的解法:

离线做法

hash表加最小堆:hash表统计次数,堆取topk

在线做法

数据流中不断流过一些单词,提供一个接口,返回当前出现过的单词中,频次最高的 Top K 个单词。数值K 在最开始便已经给出。
即:对于给定的K,是想两个接口:
add(word)
添加一个单词到集合中
topk()
返回集合中的 Top K 的高频词
一般来说,数据流(Data Stream)问题就是我们所说的在线问题。数据流问题的特点是:你没有第二次从头访问数据的机会。
因此在离线算法中,先通过哈希表(HashMap)计数,再通过堆(Heap)来统计Top K的方法就行不通了。
类似于标准离线算法,这里我们给出标准在线算法,思路是:一边计数的同时,一边比较Top K

在这里插入图片描述
我们来讨论一下标准在线算法的时空复杂度:

add 的时间复杂度是 O(logK) 的,因为最坏情况下,就是 pop 掉一个单词,push进去一个新的单词。由于 hashheap 的大小最多是 K,那么复杂度为 O(logK)
topk 的时间复杂度是 O(KlogK)。
这个算法的空间复杂度,为计数所用的哈希表的空间复杂度。为数据流中到当前时刻为止的单词总个数。


前面我们讨论了标准在线算法。空间复杂度与数据流中流过的数据大小总和有关,也就是用于计数的那个哈希表的耗费。如果你需要设计一个统计 Google 热门搜索的系统,那么这个数据量是很恐怖的。根据 Google 官方公布的数据,一天有35亿次搜索,假设每条搜索记录 20 个字节,那么会耗费 70G 的空间至少。通常哈希表的耗费要比实际存储的数据大小要大几倍才能保证效率,那么可想而知这个内存耗费是多么的恐怖。

根据这么多年的发展经验,想要寻找一个在线的,精确的,省空间的 Top K 高频项算法是不可能的。正如我们在数据库中,无法同时满足 “实时性”,“可用性”和“一致性”一样。

因此我们必须损失掉一个因子,这个因子就是准确性,换而言之就是用损失精度换空间的方法。
精确性是说,比如我求得了 Top 10 的查询,那么这10个查询中,可能会存在一个查询,他的实际排名在 Top 10 之后。又或者 Top 10 的相对排名并不正确,原本第一名的跑去了第二名。幸运的是,在实际的系统应用中,人们对精确性的要求是不高的。比如你会去验证微博热门搜索中的那些搜索真的是Top 10 的热门搜索么?你也无从验证。用户是无法感知这个不精确性的。这就留给了我们优化空间的余地。

已经有很多的科研学者们研究过精度换空间的方法,并发明了一些较为成熟的算法:

Lossy Counting
Sticky Sample
Space Saving
Efficient Count
Hash Count

接下来我们来介绍一个比较容易掌握的,在标准在线的算法的基础上改进最小的:Hash Count。
在这里插入图片描述
上面这个算法中,和标准在线算法相比,唯一的区别在于,我们将原本记录所有单词的出现次数的哈希表,换成了一个根据内存大小能开多大开多大的数组。这个数组的每个下标存储了“某些”单词的出现次数。我们使用了哈希函数(hashfunc),对每个单词计算他的哈希值(hashcode),将这个值模整个hashcount数组的大小得到一个下标(index),然后用这个下标对应的计数来代表这个单词的出现次数。有了这个单词的出现次数之后,再拿去 hash heap 里进行比较就可以了。

你应该马上会发现问题,如果有两个单词,他们的 hashcode % hashcount.size 的结果相同,那么他们的计数会被叠加到一起。从而导致计算结果的不精确。比如下面这种情况,求 Top 3 的单词,目前的统计结果是 {word1: 100, word2: 99, word: 98}。这个时候来了一个新的单词 word4,word4从没出现过,计数本应该是0,但是很巧的是,他的 hashcode % hashcount.size 的结果和 word1 是一样的。那么他就会把 word1 的计数当作是自己的计数,从而得到 100 + 1 = 101,成为 Top 1 的单词,并且挤掉了本应在 Top 3 的 word3。

上述问题对精度的影响到底有多大呢?事实上,根据“长尾效应”(Google 一下),在实际数据的统计中,由于 Top K 的 K 相对于整个数据流集合中的不同数据项个数 N 的关系是 K 远远小于 N,而 Top K 的这些数据项的计数又远远大于其他的数据项。因此,Top K 的 hashcode % hashcount.size 扎堆的可能性是非常非常小的。因此这个算法的精确度也就并不会太差。

在详细地学习了最高频K 项问题的解答方法之后,让我们用几个面试真题来实战演练一下吧。

No.1 离地球最近的 K 颗星星

给你 N 个星星的位置坐标,找到离地球最近的 M 颗星星
No.2 最近7天的热门歌曲

问题描述:

计一个听歌统计系统,返回用户 7 天内听的最多 10 首的歌

问题分析:
在解决这个问题之前,我们需要和面试官沟通如下的几个问题条件:

7天和10首歌这个数字是固定的么?有可能一会儿7天一会儿10天,一会儿10首歌一会儿8首歌么?
对实时性要求严格么?即,是否允许一定时间的延迟?比如一首个一分钟内被点爆,是否需要在这1分钟之内在榜单中体现出来?
澄清问题是面试中重要的一个步骤,因为上述问题的答案,稍有不同,则算法的设计,系统的设计就截然不同。
我们先做如下的合理假设:

7天10首歌这两个数字是固定的。
对实时性要求不严格,可以有1小时的误差。

方法1: 离线算法:

这种方法比较简单粗暴,但也非常行之有效。因为通常来说,系统都会进行一些 log。比如用户在什么时候听了什么歌曲,都会被作为一条条的log 记录下来,用于以后的大数据分析用户行为的之用。那么这个时候,我们可以每小时运行一次分析程序,来计算最近7天被听的最多的10首歌。这个分析程序则读取最近 7 天的听歌记录,用前面的 Hash + Heap 的方法进行统计即可。如果这个记录过大,需要加速的话,还可以使用 Map Reduce 来提速。

方法2:在线算法:

这个问题较普通的 Top K 问题的区别和难点在于,有7天这个时间窗口。这个时间窗口意味着几件事情:

新数据来的时候,需要丢弃对应的7天前的旧数据。
7天之内的数据,都应该按照某种带着时间标记的方式被保存下来,而不是只有一个计数。
在线 Hash + Heap 的方法“可能”不再奏效,因为跌出前10名的歌曲,还可能在过短时间后回到前10名。而之前介绍中我们在 Heap 中保存的是前10名,跌出前10名的元素不再有机会回到前10名,则无需保存。
在“方法1”中的算法,存在着如下一些缺点:

每小时都需要进行一次对前7天的数据统计,如果数据量很大,则工作量就很大,如果使用 Map Reduce 则会耗费很多计算资源。
如果系统的实时性要求变高,如5分钟,则该方法很有可能不奏效,因为可能5分钟无法完成对过去7天的听歌记录的统计工作。

针对这两个缺点,这里提出一种基于桶(Bucket)的统计方法:

聚合(Aggregate):将用户的同个记录,按照1小时为单位进行一次聚合(Aggregate),即整合成一个 Hash 表,Key是歌曲的id,Value是歌曲在这1小时之内被播放的次数。这种方法的好处在于,因为很多歌曲,特别是热门歌曲,是被高频率点播的,这个时候没有必要去一条一条的记录点播记录,只需要记录一个1小时的统计即可。这里每个小时就是一个桶(Bucket),比如当前时刻是 1月1日的18点,那么18点之后,19点之前的点播记录,都放在18点的这个桶里,进行聚合统计。

滑动窗口(Sliding Window):7天的话,只需要在内存中保存 7 * 24 = 144 个桶,随着时间轴的推移,旧的桶则可以被删除。每次需要获得 Top 10 的时候,则将这 144 个桶的结果进行合并即可。

这种方法的好处在于,如果我们对这个实时性的要求提高了,如提高到了5分钟,则把桶的大小缩小到5分钟即可。

在实际问题中,算法的实现过程中,还可能遇到其它一些问题,也是面试中可能被追问的。

Q1: 如果我们对内存要求很高,每个桶统计所用的哈希表太大,无法放进内存怎么办?

A1:这种情况下,我们必须允许一定程度的结果误差,在每个桶的局部统计中,我们可以删除那些 value 很小的 key。因为根据长尾理论,这些很冷门的歌曲,事实上占据了很大部分的内存空间,而他们最终也不会成为 Top 10 的热门歌曲。

Q2: 桶存在哪儿?如果是内存的话,断电了怎么办?

A2: 桶存同时存在内存和硬盘中。存在内存中的目的,是为了更快的计算 Top 10,存在硬盘中的目的,是断电之后可以重新快速 load 桶的内容进来。另外还有一个保险机制是,用户点播的log依然会存在数据库中,即便桶没有被存储下来,也可以通过这些点播的log重新还原每一个桶里的Hash 表。

Q3: 是每次用户想要查看 Top 10 的时候,都进行一次统计么?

A3: 不是,这个统计只会每小时进行一次。一旦统计结果获得了,就可以存在数据库里,并 cache 在内存中供用户读取。

Q4: 如果实时性要求高,比如需要精确到秒为单位,那样桶所起到的优化效果就很小了,这个时候如何快速获得最近7天的Top10?

A4: 如果是这样,则必须要有很大的内存。在 Hash + Heap 的方法中,Hash 和 Heap 都保存下所有的歌曲和其对应的点播次数。随着时间的推移,一些歌曲点播次数增加,一些歌曲的点播次数减少,然后点播次数有变化的歌曲,都在 Heap 中调整其相应的位置。然后需要获得 Top 10 时,则从 Heap 中获得。

No.3 访问 Baidu 次数最多的 IP

问题描述
给你海量的日志数据,提取出访问百度次数最多的那个 IP。
(为了简化问题,我们假设从每一行日志数据中提取 IP 的工具已经有了)
假设内存 < 4G

问题分析
首先问题说的是 IP 地址,IP 地址的范围转换为整数是在 0 ~ 2^32-1 的。但是问题中也给出了,内存大小 < 4G,因此如果我们需要开辟一个 2^32 的整数数组,是不够的。

初步解决方法
解决这种内存不够的大数据处理问题,通常的办法是:内存不够,就分批次处理。假如内存现在是 2G 的话,可以将这些日志数据分两批处理,IP地址的二进制表示中,先末尾是0的,再统计末尾是1的。各自保存下一个访问次数最多的 IP 地址,最后两者中取较大值。这种方法的缺点需要多次扫描文件。有一些网上的解答中,会说先把文件分割成多个文件之后再进行处理。虽然算法本质上没错,但是实现的时候不能这么实现,因为实现文件分割会产生“写”操作,写操作是比读操作慢很多的,而且也没有必要。所以读两次文件即可,而不需要真的把文件给拆分成小文件。

进一步 Follow up:如何避免多次扫描
上面这个方法很容易想到,但是缺点也很明显。就是需要多次扫描日志文件。当这个日志文件记录非常非常大的时候,每一次扫描都是很大的耗费。有没有只扫描一次就能找到最大出现次数IP的方法呢?会想我们在这一章节中学过的最高频 K 项的在线算法。事实上,这就是一个 K = 1 的特殊情况。而在之前的解决方法中,我们也提到过,可以用 Hash Count 的算法,在极小概率会损失准确性的情况下来获得空间的优化。所以那些能够节省空间的高频项统计算法都是可以解决该问题的。唯一缺点是,有可能会损失准确性,即有可能求出的最高频项不是真正的最高项,但是这个概率很低,就看实际应用场景中对准确性的要求了。

另外也可以使用升级版的 Hash Count,即布隆过滤器(Bloom Filter)来进行统计。一般在面试中说出这个数据结构是有加分的。布隆过滤器的介绍,请看下一章节。

No.4 在 1B 个数中找出最小的 1M 个数

问题描述
Amazon:在 10 亿个数中找最小的 100 万个数。假设内存只能放下 100 万个数。题目来源

解析
使用一个最大堆(Max Heap),保存最小的前 100 万个数。循环每个数的过程中,和 Max Heap 的堆顶比较,看看是否能被加入最小前 100 万个数里。

发布了11 篇原创文章 · 获赞 3 · 访问量 161

猜你喜欢

转载自blog.csdn.net/qq_38574975/article/details/103538003