设计一个实时保持前k个频繁单词的系统。

14
假设我们需要一个系统来保持在最近一小时内推文中出现的前k个高频词。如何设计它?
我可以想到使用哈希表、堆、日志或MapReduce,但我找不到一种非常有效的方法来实现这个功能。
实际上这是一个面试题。首先我使用哈希表来计算每个单词的频率。同时我保持了一个日志,这样随着时间的推移,我可以统计出最老的单词频率。
然后我保持了一个长度为K(Top K数组)的入口数组和一个数字N,该数字是数组中最小的计数数字。
每当一个新单词出现时,我就更新计数哈希表并获取这个新单词的计数数字。如果它大于N,我会查找这个单词是否在数组中。如果是,我就更新数组中的相应条目。如果不是,我就删除数组中最小的条目并将这个新单词插入到数组中。(相应地更新N)
这里的问题是,我的方法无法处理删除操作。我可能需要遍历整个计数哈希表来找到新的前K个高频词。
此外,正如面试官所说,系统应该能够快速获得结果。我考虑让几台机器一起工作,每台机器负责一些单词。然而,如何组合结果也成为了一个问题。

你的方法有什么不够高效的地方吗? - Mitch Wheat
堆中的条目更新很困难。 - Lampard
啊,是的,你说得对。 - Mitch Wheat
在哈希表中保留原始计数,并在堆(精确地说,是固定大小的优先队列)中保留前K个。这是关键。这样,如果您找到另一个“a”,则可以在摊销O(1)的时间内查找并更新其在哈希表中的先前计数。如果单词尚未在优先队列中,则尝试将其插入其中(仅当单词计数大于队列中最小计数时才会插入)。如果单词已经在队列中,则只需删除并重新插入它(仍然在O(log n)中)。 - ffriend
可能是在大型单词序列中查找前K个频繁单词的最有效方法的重复问题。 - craftsmannadeem
显示剩余2条评论
5个回答

21
如果单词没有被加权(除了权重为0和1),那么可以派生出一个简单的数据结构,按顺序维护单词计数,使用O(N)辅助存储空间,其中N是滑动窗口(例如一个小时)中遇到的唯一单词数。所有操作(添加单词、过期单词、查找最常见的单词)都可以在O(1)时间内执行。由于任何准确的解决方案都需要保留滑动窗口中的所有唯一单词,因此尽管每个单词的常数因子不小,但此解决方案在渐近意义下并不更糟。
该解决方案的关键在于,任何给定单词的计数只能增加或减少1,并且所有计数都是整数。因此,可以维护一个双向链表的计数(有序),其中列表中的每个节点指向一个具有该计数的单词的双向链接列表。此外,单词列表中的每个节点指回适当的计数节点。最后,我们维护一个哈希映射,允许我们找到对应于给定单词的节点。
最后,为了在其生命周期结束时降解单词,我们需要保留从滑动窗口传来的整个数据流,其大小为O(N'),其中N'是在滑动窗口期间遇到的单词的总数。这可以存储为一个单链表,其中每个节点具有时间戳和指向单词列表中唯一单词的指针。
当遇到或过期单词时,需要调整其计数。由于计数只能增加或减少1,因此调整始终包括将单词移动到相邻的计数节点(可能存在,也可能不存在)。由于计数节点存储在排序的链表中,因此可以在O(1)时间内找到或创建相邻节点。此外,最常见的单词(和计数)总是可以通过从最大值向后遍历计数列表以恒定时间跟踪。
Count list      word lists (each node points back to the count node)

  17            a <--> the <--> for
  ^
  |
  v
  12            Wilbur <--> drawing
  ^
  |
  v
  11            feature

现在,假设我们找到了一个Wilbur。这将把它的计数提高到13; 我们可以从这个事实中看出,12的成功不是13,因此需要创建并插入13计数节点到计数列表中。之后,将Wilbur从其当前的单词列表中移除,放入与新计数节点关联的空单词列表中,同时更改Wilbur中的计数指针指向新的计数节点。

然后,假设使用drawing的次数过期,因此它的新计数将为11。我们可以从12的前一个计数是11这个事实中看出,不需要创建新的计数节点;我们只需将drawing从其单词列表中删除并附加到与11相关联的单词列表中,并在这样做时修复其计数指针。现在我们注意到,与12相关联的单词列表为空,因此我们可以从计数列表中删除12计数节点并将其删除。

当单词的计数达到0时,而不是将其附加到不存在的0计数节点上,我们只需删除该单词节点。如果遇到新单词,我们只需将该单词添加到1计数节点中,并在不存在时创建该计数节点。

在最坏的情况下,每个单词都有一个唯一的计数,因此计数列表的大小不能大于唯一单词的数量。此外,所有单词列表的总大小恰好等于唯一单词的数量,因为每个单词仅出现在一个单词列表中,完全过期的单词根本不会出现在单词列表中。

--- 编辑

这个算法需要一些RAM,但它实际上不应该有任何问题来保存一小时的推文。甚至是一天的推文也可以。而且,考虑到缩写和拼写错误,独特单词的数量在几天后不会发生太大变化。即便如此,值得考虑减少内存占用并/或使算法并行化的方法。

要减少内存占用,最简单的方法就是放弃那些几分钟后仍然是独特的单词。这将大大减少独特单词的数量,而不会改变流行单词的计数。实际上,你可以更彻底地修剪而不改变最终结果。

要并行运行算法,可以使用哈希函数生成机器号将单词分配给不同的机器。然后通过合并每台机器的前k个单词来找到前k个单词;哈希分配保证来自每台机器的单词集是不同的。(与用于构建哈希表的哈希函数不同。)


抱歉回复晚了。一个月前我非常忙,几乎忘记了这个问题。我刚刚看了你的解决方案,这是一个很棒的算法,谢谢。 - Lampard
这可以存储为单链表,其中每个节点都有一个时间戳和指向单词列表中唯一单词的指针。当您可以在哈希映射中查找单词时,指针是否必要? - yzernik
1
@yzernik:指针是代替保留单词本身的。因此,节点只有两个指针和一个时间戳。 - rici

2
这组问题被称为数据流算法。在您的特定情况下,有两个适用的算法 - “Lossy Counting”和“Sticky Sampling”。这是解释它们的论文带有图片的论文。这是一个更简化的介绍编辑:(太长了,无法放入评论)
虽然这些流式算法本身不会减少过期数据,但可以运行例如60个滑动窗口,每分钟一个窗口,然后每分钟删除并创建一个新的窗口。顶部的滑动窗口用于查询,其他窗口仅用于更新。这给你1分钟的分辨率。
批评家说,流媒体算法是概率性的,并且不会给出精确的计数,尽管这是真的,但请比较例如Rici的算法,其中一个控制错误频率,并且如果需要,可以将其降低到非常低。随着流的增长,您将希望将其设置为流大小的百分比,而不是绝对值。
流媒体算法在处理实时大型流时非常内存高效。与Rici的精确算法相比,后者需要单个主机将所有数据保留在当前滑动窗口的内存中。它可能无法很好地扩展 - 增加速率100 / s -> 100k / s或时间窗口大小1h -> 7d,您将在单个主机上耗尽内存。
哈希表是Rici算法的基本部分,需要一个连续的内存块,随着越来越大,这变得越来越棘手。

@IgorKatkov:如果你在谈论我,我的名字是rici(大写可选)。Bucket-chain哈希表只需要一个连续的内存块来存储桶头;虽然许多人喜欢短链,但对于这样的问题,几十个单词的链是合理的。此外,内存负载与唯一单词的数量有关,而这个数量增长得不如总流量快。无论如何,我们正在谈论Twitter:平均每秒6k条推文,历史最高143K/秒;日均500M。如果这是个问题,每10分钟删除单例;没有任何损害。 - rici
@Rici 不要误会,我认为如果需要精确计算统计数据,您的算法非常棒。今天早上我在纸上进行了一些估算 - 假设只有15%的唯一单词,看起来每100个单词/秒需要约15MB的RAM。 6k条推文/秒 => 60k个单词/秒 => 约8.8GB,将其扩展到30GB,您就可以应对普通的峰值。这是针对1小时滑动窗口的情况。我的意思是,这可能不是正确的算法。当您建议每10分钟删除单例时,您已经朝着正确的方向迈出了一步。 - Igor Katkov
@IgorKatkov:是的,我不是在抱怨。我怀疑独特词汇的比例甚至少于15%,尤其是在因转发数量而导致的高峰期,这基本上没有添加任何新单词。但是我不再有完整的推文源,所以我不知道。当然,您需要一台具有大量RAM(30GB现在不算很多)的机器,并且修剪长尾会有很大帮助:我认为您可以比仅使用单个元素更加激进。循环不是问题;每秒2百万次迭代甚至无法接近单个核心或RAM b / w。 - rici
@IgorKatkov:编辑以解释如何并行运行 - rici
让我们在聊天中继续这个讨论:http://chat.stackoverflow.com/rooms/47288/discussion-between-igor-katkov-and-rici - Igor Katkov
显示剩余5条评论

0

这里有一个相当高效的算法符合您的要求:

  1. 首先使用字典而不是哈希表存储字符串,因为它提供更好的空间效率。
  2. 将字典中的索引映射到哈希表以获取词频。
  3. 然后维护一个最小堆来存储k个最常见的单词的索引。
  4. 为每个单词添加指针,指示其在堆中的位置(如果不存在则为-1)。
  5. 如果单词频率已更新,则检查它是否存在于堆中,然后使用其堆中的直接位置和指针进行堆化。
  6. 如果单词不存在且频率大于top,则删除top并插入该单词并更新堆中单词的指针。

时间复杂度:

更新 top k:O(logk) 用于堆化、插入、删除操作

更新或搜索单词:O(|W|) 其中 |W| 是单词长度

堆的空间复杂度:O(k)

字典、HashMap、堆指针的空间复杂度: O(N),其中 N 是总单词数。


你似乎没有考虑当一个单词达到一小时阈值时,减少/删除操作的必要性。这会产生与OP中相同的问题:随着时间的推移,随着单词计数变得陈旧,单词会从堆中消失,并且需要对整个数据集进行排序才能找到替换单词。 - rici
@rici 哎呀,我没注意到,你说得对,它没有考虑到那个问题。 - Vikram Bhat

-1
你可以使用TreeMap,它基本上是一个排序的哈希表。在Java中,你可以通过覆盖Comparable接口中的比较方法,使TreeMap按降序列出其条目。在这种情况下,在指定时间段后的前k个条目将给出结果。

1
统计每个单词的频率,我们需要一个map<word,count>,所以条目应该按其值而不是键进行排序。然而,TreeMap只能按键排序。我们该如何解决这个问题? - Lampard
将TreeMap按值进行排序需要每次查找最大值时都执行,并且每次执行需要花费时间O(N log N)(以及需要额外的内存)。这绝对不是解决此问题的有效方法。 - rici
@rici - 我认为你没有仔细阅读。我已经清楚地说明,按值对TreeMap进行排序会破坏TreeMap的范例。天啊...在批评某人之前,请仔细阅读帖子和评论。 - ucsunil
@Sunil:我确实读了,也看了你评论中链接的帖子。原帖中说“系统应该非常快地得到结果”。对整个映射值进行排序是O(N log N),这通常不被认为是“非常快”的(实际上,你只需要在它上面执行快速选择以获取前K个值。但这仍然是O(N))。 - rici
@Lampard 使用 TreeSet + HashMap 可以获得 O(N logk) 的时间复杂度。https://dev59.com/22Ei5IYBdhLWcg3wD4gu#66869979 - maplemaple
显示剩余2条评论

-1

更新:

设计一个数据结构,高效支持add(String word)remove(String word)List<String> currentTopK(int k)

currentTopK返回前k个频繁出现的单词,如果有并列,则按字母顺序排列

类似于设计LRU,可以使用双向链表+哈希表来实现

add()remove()

时间复杂度:O(1);如果存在并列(多个单词具有相同的计数),则时间复杂度为O(log m),其中m是具有相同计数的单词数量,因为我使用TreeSet来保持字母顺序。如果并列不重要,我可以以任何顺序返回相同计数的单词,那么我就不需要使用TreeSet,我们可以获得O(1)的保证。

currentTopK(int k)

时间复杂度:O(k)

public class TopK {
    private class Node {
        int count;
        TreeSet<Item> itemSet = new TreeSet<>();
        Node prev, next;
        Node(int count){
            this.count = count;
        }
    }

    private class Item implements Comparable<Item> {
        String word;
        Node countNode;

        Item(String w, Node node) {
            word = w;
            countNode = node;
        }

        @Override
        public int compareTo(Item o) {
            return this.word.compareTo(o.word);
        }
    }

    private final Node head = new Node(0);
    private Map<String, Item> countMap;

    public TopK(){
        head.next = head;
        head.prev = head;
        countMap = new HashMap<>();
    }

    public void add(String word) {
        Item item = countMap.get(word);
        Node countNode = item == null? null : item.countNode;
        if (countNode == null) {
            if (head.next.count == 1) {
                countNode = head.next;
            } else {
                countNode = new Node(1);
                insertNode(head, countNode);
            }
            item = new Item(word, countNode);
            countMap.put(word, item);
        } else {
            Node oldCountNode = countNode;
            if (oldCountNode.next.count == oldCountNode.count + 1) {
                countNode = oldCountNode.next;
            } else {
                countNode = new Node(oldCountNode.count + 1);
                insertNode(oldCountNode, countNode);
            }
            oldCountNode.itemSet.remove(item);
            if (oldCountNode.itemSet.isEmpty()) removeNode(oldCountNode);
            item.countNode = countNode;
        }
        countNode.itemSet.add(item);

    }

    public void remove(String word) {
        Item item = countMap.get(word);
        if (item == null) return;
        Node countNode = item.countNode;
        if (countNode.count == 1) {
            countNode.itemSet.remove(item);
            countMap.remove(word);
        } else {
            Node oldCountNode = countNode;
            if (oldCountNode.prev.count == oldCountNode.count - 1) {
                countNode = oldCountNode.prev;
            } else {
                countNode = new Node(oldCountNode.count - 1);
                insertNode(oldCountNode.prev, countNode);
            }
            oldCountNode.itemSet.remove(item);
            if (oldCountNode.itemSet.isEmpty()) removeNode(oldCountNode);
            item.countNode = countNode;
            countNode.itemSet.add(item);
        }
    }

    public List<String> currentTopK(int k){
        List<String> res = new ArrayList<>(k);
        Node cur = head.prev;
        while (cur != head) {
            for (Item item : cur.itemSet){
                res.add(item.word);
                if (res.size() == k) return res;
            }
            cur = cur.prev;
        }
        return res;
    }

    private void insertNode(Node prev, Node cur) {
        cur.next = prev.next;
        prev.next.prev = cur;
        prev.next = cur;
        cur.prev = prev;
    }

    private void removeNode(Node cur) {
        Node prev = cur.prev;
        Node next = cur.next;
        prev.next = next;
        next.prev = prev;
        cur.prev = null;
        cur.next = null;
    }
}

旧答案

使用 HashMap + TreeSet 初始化 时间复杂度 O(1)

添加一个新单词: 时间复杂度 O(logk)

输出当前前 K 个单词 时间复杂度 O(k)

处理长度为 N 的数据流: 时间复杂度:O(N log k) 空间复杂度:O(数据流中不同单词的数量) <= O(N)

import java.util.*;

public class TopK {
    private final int k;
    private Map<String, Integer> counts;
    private TreeSet<String> topk;
    private Comparator<String> comp;


    public TopK(int k) {
        this.k = k;
        counts = new HashMap<>();
        comp = (w1, w2) -> {
            int c1 = counts.getOrDefault(w1, 0), c2 = counts.getOrDefault(w2, 0);
            return c1 == c2 ? w2.compareTo(w1) : c1 < c2 ? -1 : 1;
        };
        topk = new TreeSet<>(comp);
    }

    public void add(String word) {
        int newCount = counts.getOrDefault(word, 0) + 1;
        if (topk.size() < k) {
            topk.remove(word);
            counts.put(word, newCount);
            topk.add(word);
        } else {
            if (topk.remove(word)) {
                counts.put(word, newCount);
                topk.add(word);
            } else {
                counts.put(word, newCount);
                if (comp.compare(word, topk.first()) > 0) {
                    topk.pollFirst();
                    topk.add(word);
                }
            }
        }
    }

    public List<String> currentTopK() {
        return new ArrayList<>(topk.descendingSet());
    }
}


该算法存在许多问题,但即使它按照广告所说的正常运行,也不能解决这个特定问题。这里的问题是“在最近一小时内查找出现频率最高的k个单词”。关键是要避免每次查询时都扫描在过去一小时内接收到的所有单词。 - rici
@rici 我已经更新了我的答案。只需要设计一个数据结构来高效地支持 add remove currentTopK(int k)addremove 可以在 O(1) 时间内实现。 - maplemaple
@rici 为了处理数据流,只需维护一个元组(单词,时间戳)的队列。每次读取一个新单词,就将其推入队列。每当队列头超出时间范围时,就从TopK数据结构中弹出并删除该单词。 - maplemaple
好的,现在请解释一下它与我七年前提出的算法有何不同。 - rici

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