文章来源:A Charming Algorithm for Count-Distinct
本文提出一种很有趣的算法,估计一个数列里面不重复元素的个数,关键是它只使用指定大小的内存。
最近看到了一篇名为《流中的不同元素:(文本)书的算法》的论文,作者是Chakraborty、Vinodchandran 和 Meel。
“从书中”一词的使用当然是指 Erdős, 他经常提到一本“书”,上帝在书中保存了任何给定定理的最佳证明。 因此,对于“来自书本”的东西来说,它就是特别优雅的。 我不得不说,我同意他们的评估。 这是一个非常迷人的小算法,我真的很喜欢思考, 所以今天,我就来给大家解释一下。
非重复计数问题是估计流中出现的不同元素的数量。 也就是说,给定一些“对象”的枚举,你可以将其视为你喜欢的任何数据类型, 我们想知道大约有多少个独特的对象。 例如,这个数组:[1,1,2,1,2,3,1,2,1,2,2,2,1,2,3,1,1,1,1]
只有三个不同的对象:,,和 。 很自然地想知道这样的列表中出现了多少个不同的对象。 不幸的是,如果您需要实际数字,基本上只有两个选项:123
这两个选项都需要至少与不同元素的数量成正比的内存, 在某些情况下,它可能与整个流一样大。 对于一些较小的数据大小来说,这可能很好,但如果我们想处理数百万或数十亿个元素, 对于许多用例来说,这是不切实际的。
事实证明,如果我们能容忍一些不精确,而且我们经常可以, 有一些方法可以通过使用近似算法来大大减少我们需要的内存量。
最知名的近似计数非重复算法是 HyperLogLog, 广泛用于各种事物的生产。 虽然 HyperLogLog 背后的想法很简单,但对它的分析却有些复杂。 本文提供的是一种替代算法,该算法:
这篇论文提供了关于算法正确性的证明,所以我就去 通过推导的方式解释它是如何工作的; 这个算法的一个可爱之处在于,我们可以非常自然地建立它。
count-distinct 问题最明显的解决方案是只维护一个 你看到的对象,并在最后发出它的大小:
function countDistinct(list) {
let seen = new Set();
for (let value of list) {
seen.add(value);
}
return seen.size;
}
console.log(countDistinct([
"the", "quick", "brown", "fox", "jumps", "jumps", "over",
"over", "dog", "over", "the", "lazy", "quick", "dog",
]));
// => 8
这将必须存储我们看到的每个元素。 如果我们试图节省内存,一个明显的技巧是不要存储所有内容。
如果我们尝试只存储一半的值, 那么预期大小应该是实际不同元素数的一半, 所以最后我们可以将这个大小乘以 2 得到 不同元素的数量。seen
当我们看到一个元素时,我们抛硬币,并且只有在掷硬币是正面时才存储它:
function countDistinct(list) {
let seen = new Set();
for (let value of list) {
if (Math.random() < 0.5) {
seen.add(value);
}
}
return seen.size * 2;
}
console.log(countDistinct([
"the", "quick", "brown", "fox", "jumps", "jumps", "over",
"over", "dog", "over", "the", "lazy", "quick", "dog",
]));
// => 10
嗯,这实际上是错误的,因为如果我们多次看到相同的元素,我们更有可能 要将其纳入我们的最终代表集:
console.log(countDistinct([
"a", "a", "a", "a", "a", "a", "a",
"a", "a", "a", "a", "a", "a", "a",
"a", "a", "a", "a", "a", "a", "a",
"a", "a", "a", "a", "a", "a", "a",
]));
// => 2 (with very high probability)
元素出现的次数不应影响算法的输出 (我想说,这是 count-distinct 的定义属性)。
不过,有一个简单的解决方法: 当我们看到一个元素时,我们可以在翻转之前将其从集合中删除,所以 唯一真正重要的抛硬币是最后一次(这成功了,因为 每个至少出现一次的元素都恰好有一次最终出现):
function countDistinct(list) {
let seen = new Set();
for (let value of list) {
seen.delete(value);
if (Math.random() < 0.5) {
seen.add(value);
}
}
return seen.size * 2;
}
在此迭代中,每个不同元素的出现概率为 0.5。seen
我们可以进一步提高内存使用率(以牺牲精度为代价) 通过要求每个元素赢得更多的掷硬币 最终套装包括:
function countDistinct(list, p) {
let seen = new Set();
for (let value of list) {
seen.delete(value);
if (Math.random() < p) {
seen.add(value);
}
}
return seen.size / p;
}
console.log(countDistinct([
"the", "quick", "brown", "fox", "jumps", "jumps", "over",
"over", "dog", "over", "the", "lazy", "quick", "dog",
]), 0.125);
现在每个元素都包含在最终的概率集中,所以我们除以得到实际的估计值。pp
我们通过一些恒定因素减少了内存使用量,这也许很好! 但它在渐近上也好不到哪里去,更重要的是, 它不允许我们限制内存使用量: 我不能提前告诉你我将为此使用多少内存。
让我们进入实际算法的最后一个技巧是动态选择。p
也就是说,我们从 1 开始,并有一个阈值来表示“太大”。 如果我们的集合增长超过这个大小,我们就会“升级”,以便元素现在必须 赢得额外的掷硬币,即可包含在最后一盘中。当我们以这种方式升级时,我们必须做两件事:ppp
归根结底,我们仍然有一个包含具有概率的元素的集合,因此我们 可以将其大小除以得到真实的估计值。pp
最终算法如下所示:
function countDistinct(list, thresh) {
let p = 1;
let seen = new Set();
for (let value of list) {
seen.delete(value);
if (Math.random() < p) {
seen.add(value);
}
if (seen.size === thresh) {
// Objects now need to win an extra coin flip to be included
// in the set. Every element in `seen` already won n-1 coin
// flips, so they now have to win one more.
seen = new Set([...seen].filter(() => Math.random() < 0.5));
p *= 1 / 2;
}
}
return seen.size / p;
}
这就是整个算法——这篇论文包含一个实际的分析,以及 为所需级别选择值的指南 精度。thresh
我不太清楚这个算法是否适合实际使用。 论文中明显没有与HyperLogLog进行比较。 我立即怀疑它不是真的,因为 HyperLogLog 还有一些额外的 不错的属性(例如,由于草图,它分布得非常好 是可合并的),我不清楚它们是否保留在这里。
当然,问这个问题有点没有抓住重点, 因为就像作者强调的那样,这个算法的吸引力在于它的简单性,对我来说, 它存在的惊喜——我实际上不知道这是可能的 没有哈希的高效计数-非重复,但事实证明它是!