寻找前10个搜索词的算法

120

我目前正在准备一次面试,这让我想起了之前一次面试中被问到的一个问题,大致如下:

“你被要求设计一些软件,以持续显示 Google 上前 10 个搜索词。你可以使用提供无限实时流的数据源,该数据源提供当前正在 Google 上进行搜索的搜索词。请描述您将使用哪些算法和数据结构来实现此功能。您需要设计两个变体:

(i) 显示自开始阅读流以来的所有时间内排名前 10 的搜索词。

(ii) 显示过去一个月内每小时更新的前 10 个搜索词。

您可以使用近似值来获得前 10 名列表,但必须证明您的选择。”
我在这个面试中失败了,至今仍不知道如何实现。

第一部分要求在一个无限增长的子序列中找出前 10 个最频繁的元素。我研究了一些选择算法,但找不到任何在线版本来解决这个问题。

第二部分使用有限的列表,但由于处理的数据量很大,您无法真正将整个月的搜索词存储在内存中并每小时计算一次直方图。

由于前十列表一直在不断更新,所以问题变得更加困难,您需要通过滑动窗口计算出前十名的结果。

有什么想法吗?


11
@BlueRaja - 这不是一个愚蠢的面试问题,而是OP对问题的理解出了问题。它并不是在询问无限列表中最常见的项,而是在询问无限列表的有限子序列中最常见的项。继续您的类比,你序列中子序列[2; 2; 3; 3; 3; 4; 4; 4; 4; 5; 5]中最频繁的项是什么? - IVlad
3
“@BlueRaja - 这确实是一个困难的问题,但我不明白为什么它会被认为是愚蠢的 - 它似乎代表了那些拥有大量数据集的公司通常面临的问题。 @IVlad - 根据您的建议进行了修正,我的措辞不好!” - del
16个回答

阿里云服务器只需要99元/年,新老用户同享,点击查看详情
59

频率估计概述

有一些著名算法可以使用固定数量的存储空间为此类流提供频率估计。其中之一是 Misra 和 Gries(1982)的 Frequent 算法。从一个包含 n 个项目的列表中,它使用 k-1 个计数器查找所有出现超过 n / k 次的项目。这是 Boyer 和 Moore 的 Majority 算法(Fischer-Salzberg,1982)的推广,其中 k 为 2。Manku 和 Motwani(2002)的 LossyCounting 和 Metwally 的 SpaceSaving(2005)算法具有类似的空间要求,但在某些条件下可以提供更准确的估计。

需要记住的重要事情是这些算法只能提供频率估计。具体而言,Misra-Gries 估计可能会将实际频率少计算 (n / k) 个项目。

假设您有一个仅当某个项目出现超过50%的时间时才能确定其身份的算法。将此算法馈送一个包含 N 个不同项目的流,然后添加另外 N-1 个项目,x,使得总共有 2N-1 个项目。如果算法告诉您 x 超过了总数的50%,那么它必须在第一个流中;否则,x 不在初始流中。为了让算法做出这个决定,它必须存储初始流(或者与其长度成比例的某些摘要)!因此,我们可以证明这样一个 "精确 "算法所需的空间为 Ω(N)。

相反,这里描述的频率算法提供了一个估计值,识别超过阈值的任何条目以及一些略低于阈值的条目。例如,多数 算法使用单个计数器,将始终给出结果;如果任何项目超过流的50%,它将被找到。但它也可能给你一个仅出现一次的项目。没有进行第二遍数据扫描(再次使用单个计数器,但仅查找该项),你无法确定。

频繁算法

这里是Misra-Gries 频繁 算法的简单描述。Demaine(2002)和其他人对算法进行了优化,但这给出了算法的要点。

指定阈值分数,1 / k ;出现次数超过 n / k 的任何项都将被找到。创建一个空映射(如红黑树);键将是搜索术语,值将是该术语的计数器。

  1. 查看流中的每个项。
  2. 如果映射中存在该项,则增加关联计数器。
  3. 否则,如果映射条目少于 k-1 ,则将该项添加到映射中并设置计数器为1。
  4. 但是,如果映射已经有 k-1 个条目,则减小每个条目中的计数器。在此过程中,如果任何计数器达到零,请将其从映射中删除。

请注意,您可以使用固定大小的映射处理无限量的数据。所需的存储量仅取决于感兴趣的阈值,而流的大小并不重要。

计数搜索

在这种情况下,你可能需要缓冲一小时的搜索数据,并对该小时的数据进行处理。如果你能对这个小时的搜索日志进行第二次检查,那么就可以精确计算首次检索中识别出的前几位“候选项”的出现次数。或者,也可以只进行单次检查,报告所有的候选项,知道所有应该有的内容都包含在里面,并且任何多余的内容(在下一个小时中)都会消失。 任何真正超过感兴趣阈值的候选项都会被存储为摘要。保留一个月的这些摘要,每小时删除最旧的一份,然后就可以得到最常见的搜索词的良好近似值。

1
@del - 请记住,此算法用于定位超过指定阈值频率的术语,而不一定是为了找到最常见的术语。如果最常见的术语低于指定的阈值,它们通常不会被找到。您对于“过快”删除新术语的担忧可能与此情况有关。看待这个问题的一种方式是,在受欢迎程度方面存在真正的“信号”,它们将明显突出于“噪音”之中。但有时候,找不到任何信号,只有随机搜索静态。 - erickson
1
@erickson,虽然_uniform_分布不是必需的,但我想知道在更现实的分布(幂律、Zipf)中如何运作。假设我们有N个不同的单词,并保持容量为K的红黑树,希望它最终包含K个最常见的术语。如果(N-K)个单词的累积频率大于K个最常见单词的累积频率,则最终的树肯定包含垃圾。你同意吗? - Dimitris Andreou
@erickson,感谢您提供这么详细的解释。但是,我有一个问题无法理解。 “例如,如果将地图限制在99个条目以内,则保证可以找到任何出现次数超过1 /(1 + 99)(1%)的术语。” 我们如何使用公式1 /(1 + X),其背后的理论是什么? - q0987
@erickson,这种方法需要进行第二次遍历以在生成的映射中找到实际计数。如果您想进行第二次遍历,我们需要存储到目前为止的所有元素。这正确吗? - derek
@deryk,我不确定你在问什么。你需要保留整个日志或者你正在处理的任何东西。但是在内存中,你只需要保留第一次筛选出来的项目。在第二次筛选中,你需要为每个(可能的)顶部项目增加一个计数器,但忽略第一次筛选中未被选择的所有项目。这回答了你的问题吗? - erickson
显示剩余7条评论

49

看起来这是大量的数据,存储所有频率可能成本过高。当数据量非常大以至于我们无法全部存储时,我们就进入了数据流算法领域。

在这个领域中,有一本有用的书: Muthukrishnan - "Data Streams: Algorithms and Applications"

从上述内容中我选择的与问题密切相关的参考文献: Manku, Motwani - "Approximate Frequency Counts over Data Streams" [pdf]

顺便说一下,斯坦福的Motwani博士是著名书籍"Randomized Algorithms"的作者之一。 该书的第11章涉及到了这个问题。 编辑:对不起,参考文献错误,那个特定的章节是关于另一个问题的。经过核实,我建议你阅读在线可用的Muthukrishnan书中的5.1.2节

嘿,不错的面试问题。


3
非常有趣的东西,网站上应该有一种标签来标记“待阅读”的内容。谢谢分享。 - Ramadheer Singh
+1. 流算法正是这里的主题,而穆图的书(据我所知,迄今为止唯一的一本书)非常棒。 - ShreevatsaR
1
+1. 相关链接:http://en.wikipedia.org/wiki/Online_algorithm。顺便提一下,Motwani最近去世了,所以也许“曾经是作者”更准确一些。 - Aryabhatta
非常奇怪。我是从书中知道他的,但他一定因为这个更有名:“Motwani是PageRank算法早期论文的合著者之一(与Larry Page、Sergey Brin和Terry Winograd),该算法是Google搜索技术的基础。”(http://en.wikipedia.org/wiki/Rajeev_Motwani) - Dimitris Andreou
@Dimitris - 感谢您提供的参考资料。在周一的面试之前,我可能没有时间阅读这些内容,但无论如何,这似乎是一个有趣的领域。 - del
显示剩余2条评论

19
这是我目前正在进行的研究项目之一。需求几乎与你们的一样,我们已经开发出了优秀的算法来解决这个问题。 输入 输入是一个无限流的英语单词或短语(我们称其为“令牌”)。 输出 1.输出迄今为止我们看到的前N个令牌(从我们看到的所有令牌中!) 2.输出过去一天或一周内历史窗口中的前N个令牌。 该研究的一个应用是在Twitter或Facebook中查找热门主题或趋势。我们有一个爬虫在网站上爬行,生成一个单词流,将其馈送到系统中。然后系统将以整体或历史频率的方式输出单词或短语。想象一下,在过去几周中,“世界杯”一词会在Twitter上出现很多次。保罗章鱼也是如此。 :) 将字符串转换为整数 系统为每个单词分配一个整数ID。虽然互联网上几乎有无限可能的单词,但是在累积大量单词之后,找到新单词的可能性越来越低。我们已经找到了400万个不同的单词,并为每个单词分配了一个唯一的ID。整个数据集可以作为哈希表加载到内存中,大约需要消耗300MB的内存。(我们已经实现了自己的哈希表。Java的实现需要大量的内存开销) 然后,每个短语都可以被识别为整数数组。 这很重要,因为在整数上进行排序和比较比在字符串上要快得多。 归档数据 系统为每个令牌保留归档数据。基本上,它是(Token,Frequency)对。但是,存储数据的表会非常巨大,以至于我们必须在物理上分区表。一个分区方案基于令牌的ngrams。如果令牌是单个单词,则为1gram。如果令牌是两个单词短语,则为2gram。这样就可以了。大约在4gram处,我们有10亿条记录,表大小约为60GB。 处理传入流 系统将吸收传入的句子,直到内存完全利用为止(是的,我们需要MemoryManager)。将N个句子存储在内存中后,系统将暂停,并开始将每个句子标记为单词和短语。计算每个标记(单词或短语)的数量。 对于高频令牌,始终保留在内存中。对于较少频繁的令牌,它们基于ID进行排序(记住我们将字符串转换为整数数组),并序列化为磁盘文件。 (但是,对于您的问题,由于您仅计数单词,则可以仅将所有单词频率映射放入内存中。经过认真设计的数据结构将仅占用300MB内存,适用于400万个不同的单词。一些提示:使用ASCII字符表示字符串),这是可以接受的。 同时,将有另一个进程被激活,一旦发现系统生成的任何磁盘文件,就会开始合并它们。由于磁盘文件已排序,因此合并过程类似于归并排序。在这里也需要注意一些设计,因为我们
   for each record in sorted disk file
        update archive database by increasing frequency
        if rowcount == 0 then put the record into a list
   end for

   for each record in the list of having rowcount == 0
        insert into archive database
   end for

直觉告诉我们,随着时间的推移,插入操作的数量将变得越来越少。更多的操作将仅涉及更新,并且这些更新不会受到索引的惩罚。

希望这整个解释对你有所帮助。 :)


此外,计算单词频率是Google的MapReduce论文(http://labs.google.com/papers/mapreduce.html)中的第一个示例,只需几行代码即可实现可扩展的解决方案。您甚至可以将数据移动到Google应用引擎并执行此类MapReduce操作(http://code.google.com/p/appengine-mapreduce/)。 - Dimitris Andreou
@Dimitris Andreou:在整数上排序比在字符串上快。这是因为比较两个整数比比较两个字符串要快。 - SiLent SoNG
@Dimitris Andreou:到目前为止,我只考虑了单机排序方法。分布式排序是一个好主意。 - SiLent SoNG
我也很好奇这些数字的含义。你是定义了一个哈希函数将单词转换为数字吗?还是数字只是唯一单词数组中的索引?除了token-frequency hashmap之外,你是否还需要使用hashmap来维护单词和数字之间的映射关系,反之亦然? - del
你如何使用这些哈希表?我的意思是,你从这些哈希表中引用哪些数据。 - Ravinder Payal
显示剩余7条评论

4
你可以使用哈希表结合二叉搜索树。实现一个<搜索词,计数>的字典,告诉你每个搜索词已经被搜索了多少次。 显然,每小时遍历整个哈希表以获取前10名是非常糟糕的。但这是在谈论Google,所以你可以假设前十名都会获得超过10,000次点击(尽管这可能是一个更大的数字)。因此,每当一个搜索词的计数超过10,000时,将其插入BST中。然后,每小时,您只需要从BST中获取前10个,其中应该包含相对较少的条目。 这解决了所有时间的前十名问题。
真正棘手的部分是处理一个术语在月度报告中取代了另一个术语的情况(例如,“堆栈溢出”可能在过去两个月内有50,000次搜索,但在过去一个月中仅有10,000次,而“亚马逊”可能在过去两个月内有40,000次,但在过去一个月中有30,000次。你想在月度报告中让“亚马逊”排在“堆栈溢出”之前)。为此,对于所有主要的(超过10,000次所有时间搜索)搜索词,我将存储一个30天的列表,告诉您该术语在每一天被搜索了多少次。该列表将像FIFO队列一样工作:您删除第一天并在每天(或每小时)插入一个新的(但这样可能需要存储更多信息,这意味着需要更多的内存/空间。如果内存不是问题,请执行,否则采用他们谈到的“近似值”)。

这看起来像是一个很好的开始。然后,您可以担心修剪那些具有大于10,000次点击但长时间没有太多点击的术语等等。


3

情况一)

为所有搜索词维护一个哈希表,以及与哈希表分开的排序前十列表。每当发生搜索时,增加哈希表中相应的项,并检查该项是否应该现在与前十列表中的第十项交换。

对于前十列表,O(1) 的查找,对于哈希表,最大 O(log(n)) 的插入(假设碰撞由自平衡二叉树管理)。

情况二)

不再维护一个巨大的哈希表和一个小列表,而是维护一个哈希表和一个包含所有项的排序列表。每当进行搜索时,增加哈希表中相应的项,并在排序列表中检查该项是否应该与其后面的项交换。自平衡二叉树可能很适合此任务,因为我们还需要能够快速查询它(稍后会详细介绍)。

此外,我们还维护了一个“小时”列表,形式为 FIFO 列表(队列)。每个“小时”元素都包含在该特定小时内完成的所有搜索的列表。例如,我们的小时列表可能如下所示:

Time: 0 hours
      -Search Terms:
          -free stuff: 56
          -funny pics: 321
          -stackoverflow: 1234
Time: 1 hour
      -Search Terms:
          -ebay: 12
          -funny pics: 1
          -stackoverflow: 522
          -BP sucks: 92
然后,每个小时:如果列表至少有720小时(即30天的小时数),则查看列表中的第一个元素,并针对每个搜索词,在哈希表中适当减少该元素。之后,从列表中删除第一个小时元素。 假设我们现在处于第721小时,并且准备查看列表中的第一个小时(如上所示)。我们将在哈希表中将免费物品减少56个,将有趣的图片减少321个等等,并随后完全删除0小时的元素,因为我们再也不需要查看它了。 我们维护一个排序的所有术语列表,以便快速查询的原因是,在过去720个小时后的每个小时中,我们需要确保前十个列表仍然排好序。因此,当我们例如在哈希表中将“免费物品”减少56个时,我们将检查它现在在列表中属于哪个位置。由于它是自平衡二叉树,所有这些操作都可以在O(log(n))时间内很好地完成。 编辑:为了节省空间而牺牲精度... 在第一个列表中实现一个大列表,就像第二个列表一样,可能也很有用。然后,我们可以在两种情况下应用以下空间优化:运行cron作业以仅保留列表中前x个顶级项目。这将使空间要求降低(因此使列表查询更快)。当然,这将导致近似结果,但是这是可以接受的。在部署应用程序之前,可以根据可用内存计算出x,并在可用更多内存时动态调整。

2

初步想法...

对于历史前十:

  • 使用哈希集合存储每个术语的计数(对术语进行清理等操作)
  • 一个排序过的数组,其中包含正在进行的前十个术语/计数,当术语的计数变得等于或大于数组中最小计数时,将该术语/计数添加到该数组中

对于每月更新一次的前十名:

  • 使用一个以自开始后经过的小时数取模744(即一个月内的小时数)为索引的数组,该数组条目由哈希集合组成,其中存储了在此时间段内遇到的每个术语的计数。每当小时槽计数器更改时,条目将被重置
  • 需要在当前小时槽计数器更改时(最多每小时一次)收集索引为小时槽的数组中的统计信息,方法是复制和展开此数组的内容

这样说明白了吗?我没有像在现实生活中那样深入思考。

啊,是的,我忘了提到,对于每月统计所需的每小时“复制/展开”实际上可以重用用于历史前十的相同代码,这是一个好的副作用。


2

精确解决方案

首先,有一个保证正确结果的解决方案,但需要大量内存(一个大地图)。

“全时段”变体

维护一个哈希映射表,将查询作为键,将它们的计数作为值。此外,保持一个包含10个最频繁查询和第10个最频繁计数(阈值)的列表f。

随着查询流的不断更新,不断更新映射表。每当计数超过当前阈值时,请执行以下操作:从“Top 10”列表中删除第10个查询,用刚刚更新的查询替换它,并更新阈值。

“过去一个月”变体

保持相同的“Top 10”列表,并以与上述方式相同的方式更新它。还要保留类似的映射表,但这次将30*24 = 720个计数向量(每小时一个)作为值存储。每小时对每个键执行以下操作:从向量中删除最旧的计数器,在末尾添加一个新的计数器(初始化为0)。如果向量全部为零,则从映射表中删除该键。此外,每小时必须从头开始计算“Top 10”列表。

注意:是的,这次我们存储了720个整数而不是一个,但键要少得多(全时段变体有一个非常长的尾巴)。

近似解决方案

这些近似方法不能保证正确的解决方案,但内存消耗较少。

  1. 处理每第N个查询,跳过其余的查询。
  2. (仅适用于全时段变量)在映射表中最多保留M个键值对(M应尽可能大)。这是一种LRU缓存:每当读取不在映射表中的查询时,请删除计数为1的最近未使用查询,并用当前处理的查询替换它。

我喜欢逼近1中的概率方法。但是使用逼近2(LRU高速缓存),如果最初不太受欢迎的术语后来变得受欢迎,会发生什么?它们不会因为它们的计数非常低而被每次添加时丢弃吗? - del
@del 你说得对,第二个近似方法只适用于某些查询流。它的可靠性较低,但同时需要更少的资源。注意:您也可以将两种近似方法结合起来使用。 - Bolo

2

过去一个月的十大搜索词

使用内存高效的索引/数据结构,例如紧密压缩字典树(维基百科上关于字典树的条目)可以大致定义内存需求和词项数n之间的某种关系。

在有足够内存的情况下(假设1),您可以保留精确的每月统计数据,并将其汇总为所有时间的统计数据。

这里还有一个假设,即将“上个月”解释为固定窗口。 但即使月度窗口是滑动的,上述程序也展示了原则(可以用给定大小的固定窗口近似滑动)。

这让我想起了循环数据库,唯一的区别是某些统计数据是基于“所有时间”的计算(即不保留所有数据;rrd通过平均、求和或选择最大/最小值来合并时间段,在给定任务中丢失的细节是低频项目的信息,这可能会引入误差)。

假设1

如果我们无法保持整个月的完美统计数据,那么我们应该能够找到一定的时间段P,以便我们能够保持完美的统计数据。 例如,假设我们在某个时间段P上拥有完美的统计数据,该时间段分为n个月。
完美的统计数据定义函数f(search_term) -> search_term_occurance

如果我们可以将所有n个完美统计表格都保存在内存中,则滑动的每月统计数据可按以下方式计算:

  • 添加最新时期的统计数据
  • 删除最早时期的统计数据(因此我们必须保留n个完美的统计表格)

但是,如果仅在聚合级别(每月)上保留前十个搜索词,那么就能够从固定时间段的完整统计数据中丢弃许多数据。这已经提供了一个有工作效果的程序,其内存需求是固定的(假设最大的完美统计表格的上限)。

上述过程的问题在于,如果仅保留滑动窗口(类似于所有时间)中的前10个搜索词的信息,则对于不断涌入的搜索词可能无法看到统计数据,这会影响到正确性。

可以通过保留更多的信息来抵消这一点,例如保留前100个搜索词的信息,希望前10个是正确的。

我认为进一步分析可以将一个条目成为统计数据一部分所需的最小发生次数与最大误差相关联。

在确定哪些条目应成为统计数据的一部分时,还可以监视和跟踪趋势;例如,如果对于每个术语,在每个期间P中出现次数的线性外推告诉您该术语将在一个月或两个月内变得重要,那么您可能已经开始跟踪它。类似的原理也适用于从跟踪池中删除搜索术语。最坏的情况是当您有很多几乎同样频繁的术语并且它们一直在变化时(例如,如果仅跟踪100个术语,则如果前150个术语出现的频率相等,但前50个术语在第一个月更常见,而后来则不常见,则统计数据将不正确)。此外,还可以采用另一种方法,该方法在内存大小上不固定(严格来说,以上方法也是如此),该方法会以每个期间(日、月、年、全部时间)内的出现/周期定义最小显着性,以保证在聚合过程中每个统计数据的最大误差(再次参见轮询)。

2
关于 "时钟页面置换算法"的改编怎么样?(也称为“第二次机会”)如果搜索请求均匀分布,我想它能够非常有效地工作(这意味着最常搜索的单词经常出现,而不是连续5百万次再也不出现)。 下面是该算法的可视化表示:clock page replacement algorithm

1
问题在于当您有固定数量的内存和一个“无限”(想象非常非常大)的令牌流时,它并不是普遍可解的。 一个简单的解释... 为了看清楚原因,请考虑一个令牌流,在输入流中每N个令牌有一个特定的令牌(即单词)T。 此外,假设内存最多可以保存对M个令牌的引用(单词ID和计数)。 在这些条件下,如果N足够大,以至于T之间包含不同的M个令牌,则可能构造出永远不会检测到令牌T的输入流。 这与前N算法的细节无关。它只取决于限制M。 为了看清楚这一点,考虑由两个相同令牌组成的群组成的传入流:
T a1 a2 a3 ... a-M T b1 b2 b3 ... b-M ...

其中a和b都是有效的令牌,不等于T。

注意,在此流中,每个a-i和b-i都会出现两次T。但它出现得不够频繁,以至于可以从系统中清除。

从空内存开始,第一个令牌(T)将占用内存中的一个插槽(受M限制)。然后a1将消耗一个插槽,一直到a-(M-1)当M用尽时。

当a-M到达时,算法必须丢弃一个符号,让它成为T。下一个符号将是b-1,这将导致a-1被刷新,依此类推。

因此,T不会在内存中停留足够长的时间来建立真正的计数。简而言之,任何算法都会错过低局部频率但高全局频率(在流的长度上)的令牌。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,