如何估计不重复元素的个数

文章来源: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 背后的想法很简单,但对它的分析却有些复杂。 本文提供的是一种替代算法,该算法:

  1. 具有更简单的分析,并且
  2. 完全不依赖哈希。

这篇论文提供了关于算法正确性的证明,所以我就去 通过推导的方式解释它是如何工作的; 这个算法的一个可爱之处在于,我们可以非常自然地建立它。

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

  1. 通过更新变量 ,确保将来的元素受新过滤器的约束,以及p
  2. 确保旧元素受到新过滤器的约束,迫使它们赢得额外的抛硬币 超越他们之前赢得的胜利。
    由于已经“获胜”并包含在 按照这个新标准,我们必须做一个灭霸快照,让他们每个人都赢得一个 额外的抛硬币以留在套装中。seen

归根结底,我们仍然有一个包含具有概率的元素的集合,因此我们 可以将其大小除以得到真实的估计值。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 还有一些额外的 不错的属性(例如,由于草图,它分布得非常好 是可合并的),我不清楚它们是否保留在这里。

当然,问这个问题有点没有抓住重点, 因为就像作者强调的那样,这个算法的吸引力在于它的简单性,对我来说, 它存在的惊喜——我实际上不知道这是可能的 没有哈希的高效计数-非重复,但事实证明它是!

你可能感兴趣的:(学习,笔记)