在大单词序列中查找前K个常见单词的最有效方法


85

输入:正整数K和大文本。文本实际上可以视为单词序列。因此,我们不必担心如何将其分解为单词序列。
输出:文本中最常见的K字。

我的想法是这样的。

  1. 使用哈希表记录遍历整个单词序列时所有单词的频率。在此阶段,键是“单词”,值是“单词频率”。这需要O(n)时间。

  2. 对(单词,单词频率)对进行排序;关键是“单词频率”。使用常规排序算法,这需要O(n * lg(n))时间。

  3. 排序后,我们只取前K个字。这需要O(K)时间。

总而言之,总时间为O(n + n lg(n)+ K),因为K肯定小于N,所以实际上为O(n lg(n))。

我们可以改善这一点。实际上,我们只想要前K个字。换句话说,频率与我们无关。因此,我们可以使用“部分堆排序”。对于步骤2)和3),我们不只是进行排序。相反,我们将其更改为

2')以“ word-frequency”为关键字构建(word,word-frequency)对的堆。构建堆需要O(n)时间;

3')从堆中提取前K个字。每次提取为O(lg(n))。因此,总时间为O(k * lg(n))。

总而言之,该解决方案花费时间O(n + k * lg(n))。

这只是我的想法。我还没有找到改善步骤1)的方法。
我希望一些信息检索专家可以对这个问题有更多的了解。


您是否将合并排序或快速排序用于O(n * logn)排序?
committedandroider

1
对于实际用途,Aaron Maenpaa依靠样品的答案是最好的。并非最常用的单词会从您的样本中隐藏。对于您的复杂性极客,因为样本的大小是固定的,所以它是O(1)。您没有得到确切的计数,但是您也没有要求它们。
Nikana Reklawyks 2015年

如果您想要查看一下复杂性分析,那么我最好提一下:如果n是您文本中单词的数量,而m不同单词(类型,我们称其为单词)的数量,则步骤1为O(n),但步骤2是O(m .lg(m)),并且m << n(您可能有数十亿个单词,但没有达到一百万个类型,请尝试一下)。因此,即使使用虚拟算法,它仍然是O(n + m lg(m))= O(n)。
Nikana Reklawyks,2015年

1
请向这个问题添加一个假设,即我们有足够的主内存来容纳大文本的所有单词。看到从10GB文件中找到k = 100个单词的方法会很有趣(即所有单词都不适合4GB RAM)!
KGhatak '16

@KGhatak如果超出RAM大小,我们将如何处理?
user7098526

Answers:


66

这可以在O(n)时间内完成

解决方案1:

脚步:

  1. 数词并对其进行哈希处理,最终将变成这样的结构

    var hash = {
      "I" : 13,
      "like" : 3,
      "meow" : 3,
      "geek" : 3,
      "burger" : 2,
      "cat" : 1,
      "foo" : 100,
      ...
      ...
    
  2. 遍历哈希并找到最常用的单词(在本例中为“ foo” 100),然后创建该大小的数组

  3. 然后,我们可以再次遍历哈希,并使用出现的单词数作为数组索引,如果索引中没有任何内容,则创建一个数组,然后将其追加到数组中。然后,我们得到一个像这样的数组:

      0   1      2            3                  100
    [[ ],[cat],[burger],[like, meow, geek],[]...[foo]]
    
  4. 然后只需从末尾遍历数组,并收集k个单词。

解决方案2:

脚步:

  1. 同上
  2. 使用最小堆并将最小堆的大小保持为k,对于哈希中的每个单词,我们将单词的出现次数与最小值进行比较:1)如果它大于最小值,则删除最小值(如果最小值的大小堆等于k),然后在最小堆中插入数字。2)休息简单的条件。
  3. 遍历数组后,我们仅将min堆转换为array并返回该数组。

16
您的解决方案(1)是O(n)桶排序,替换了标准O(n lg n)比较排序。您的方法需要用于存储桶结构的额外空间,但是可以进行比较排序。您的解决方案(2)的运行时间为O(n lg k)-也就是说,O(n)遍历所有单词,而O(lg k)则将每个单词添加到堆中。
stackoverflowuser2010 2014年

4
第一个解决方案确实需要更多的空间,但必须强调的是,它实际上是时间O(n)。1:以单词O(n)为键的哈希频率;2:遍历频率哈希,创建第二个以频率为关键字的哈希。使用O(n)遍历哈希,使用O(1)以该频率将单词添加到单词列表中。3:从最大频率向下遍历哈希,直到达到k。最多为O(n)。总数= 3 * O(n)= O(n)。
BringMyCakeBack 2014年

3
通常,在对单词进行计数时,解决方案1中的存储桶数被高估了(因为第一个最频繁的单词比第二个和第三个最好的单词频繁得多),因此您的数组稀疏且效率低下。
Nikana Reklawyks,2015年

当k(频繁出现的单词数)小于最频繁出现的单词的出现数(即在这种情况下为100)时,您的解决方案#1无效,当然,这在实践中可能不会发生,但是应该不承担!
一三三

@OneTwoThree提出的解决方案只是一个例子。数量将根据需求而定。
于洪雄

22

通常,您不会获得比所描述的解决方案更好的运行时。您必须至少进行O(n)个工作来评估所有单词,然后再进行O(k)个额外工作才能找到前k个词。

如果您的问题确实很大,则可以使用诸如map / reduce之类的分布式解决方案。让n名地图工作者分别对文本的1 / n进行频率计数,然后将每个单词发送给根据单词的哈希计算得出的m个reducer之一。然后,减速器对计数求和。合并归约化输出的排序将按照流行程度为您提供最受欢迎的词。


13

如果我们不关心排名靠前的K,则您的解决方案上的一个小变化将产生O(n)算法,如果我们这样做,则会产生O(n + k * lg(k))解决方案。我相信这两个界限在恒定因子内都是最优的。

在我们遍历列表并将其插入哈希表之后,再次进行优化。我们可以使用中位数的中位数算法在列表中选择第K个最大元素。该算法证明是O(n)。

选择第K个最小元素后,我们像在quicksort中一样在该元素周围划分列表。这显然也是O(n)。枢轴“左侧”上的所有内容都在我们的K个元素组中,因此我们完成了(我们可以简单地丢弃其他所有内容)。

所以这个策略是:

  1. 遍历每个单词并将其插入哈希表:O(n)
  2. 选择第K个最小元素:O(n)
  3. 围绕该元素进行分区:O(n)

如果要对K个元素进行排名,只需使用O(k * lg(k))时间中的任何有效比较排序对它们进行排序,即可得出O(n + k * lg(k))的总运行时间。

O(n)时限在恒定因子内是最佳的,因为我们必须检查每个单词至少一次。

O(n + k * lg(k))时限也是最佳的,因为没有一种基于比较的方法可以在少于k * lg(k)的时间内对k个元素进行排序。


当我们选择第K个最小元素时,选择的是第K个最小哈希键。这是没有必要的,有确切K字第3步中的左分区
普拉卡什·穆拉利

2
您将无法在哈希表上运行“中位数中位数”,因为它确实会交换。您必须将数据从哈希表复制到临时数组。因此,将需要O(n)存储。
user674669

我不明白如何选择O(n)中的第K个最小元素?
Michael Ho Chum 2015年

查看此算法以查找O(n)中第K个最小元素的算法-wikiwand.com/en/Median_of_medians
Piyush

即使使用哈希表+最小堆,复杂度也相同。我没有看到任何优化。
Vinay

8

如果您的“大单词列表”足够大,您可以简单地采样并获得估计。否则,我喜欢哈希聚合。

编辑

通过示例,我的意思是选择页面的一些子集并计算这些页面中最常出现的单词。如果您以合理的方式选择页面并选择具有统计意义的样本,则您对最常用单词的估计应该是合理的。

仅当您拥有如此多的数据以至于处理所有这些只是种愚蠢的方法时,这种方法才真正合理。如果您只有几兆,那么您应该能够撕裂数据并计算出准确的答案而不会费力,而不必费心去计算估算值。


有时,您必须多次进行此操作,例如,如果您要获取每个网站或每个主题的常用单词列表。在那种情况下,“不费吹灰之力”并不能真正消除它。您仍然需要找到一种方法来尽可能有效地做到这一点。
itsadok

1
+1是不解决无关紧要的复杂性问题的实用答案。@itsadok:每次运行:如果足够大,请取样;如果不是,则获取对数因子是无关紧要的。
Nikana Reklawyks,2015年

2

通过使用单词的第一个字母进行分区,然后使用下一个字符对最大的多单词集进行分区,直到您拥有k个单单词集,您可以进一步减少时间。您将使用排序方式为256的树,并在叶子上列出部分/完整单词。您将需要非常小心,不要在任何地方造成字符串拷贝。

此算法为O(m),其中m是字符数。它避免了对k的依赖,这对于大k来说非常好[顺便说一句,您发布的运行时间是错误的,应该为O(n * lg(k)),我不确定这是什么m]。

如果同时运行这两种算法,您将得到我确定是渐近最佳的O(min(m,n * lg(k)))算法,但是我的算法平均而言应该更快,因为它不涉及哈希或排序。


7
您所描述的被称为“ trie”。
尼克·约翰逊

嗨,Strilanc。您能否详细解释分区过程?
Morgan Cheng

1
这如何不涉及排序?一旦找到了特里,如何挑选出频率最高的k个单词。没有任何意义
普通的

2

您的描述中有一个错误:计数花费O(n)时间,但是排序花费O(m * lg(m)),其中m是唯一单词的数量。这通常比单词总数小得多,因此可能应该仅优化哈希的构建方式。



2

如果您要查找的是针对任何实际k和任何自然语言的文本中k个最频繁出现的单词的列表,则算法的复杂性就无关紧要。

只需采样一下,例如,从您的文本中提取几百万个单词,用任何算法在几秒钟内处理该单词,最频繁的计数将非常准确。

附带说明一下,虚拟算法的复杂度(1.全部计数2.对计数排序3.最好)是O(n + m * log(m)),其中m是您的不同单词数文本。log(m)比(n / m)小得多,因此仍为O(n)。

实际上,很长的一步很重要。


2
  1. 利用内存高效的数据结构存储单词
  2. 使用MaxHeap查找前K个常见单词。

这是代码

import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.PriorityQueue;

import com.nadeem.app.dsa.adt.Trie;
import com.nadeem.app.dsa.adt.Trie.TrieEntry;
import com.nadeem.app.dsa.adt.impl.TrieImpl;

public class TopKFrequentItems {

private int maxSize;

private Trie trie = new TrieImpl();
private PriorityQueue<TrieEntry> maxHeap;

public TopKFrequentItems(int k) {
    this.maxSize = k;
    this.maxHeap = new PriorityQueue<TrieEntry>(k, maxHeapComparator());
}

private Comparator<TrieEntry> maxHeapComparator() {
    return new Comparator<TrieEntry>() {
        @Override
        public int compare(TrieEntry o1, TrieEntry o2) {
            return o1.frequency - o2.frequency;
        }           
    };
}

public void add(String word) {
    this.trie.insert(word);
}

public List<TopK> getItems() {

    for (TrieEntry trieEntry : this.trie.getAll()) {
        if (this.maxHeap.size() < this.maxSize) {
            this.maxHeap.add(trieEntry);
        } else if (this.maxHeap.peek().frequency < trieEntry.frequency) {
            this.maxHeap.remove();
            this.maxHeap.add(trieEntry);
        }
    }
    List<TopK> result = new ArrayList<TopK>();
    for (TrieEntry entry : this.maxHeap) {
        result.add(new TopK(entry));
    }       
    return result;
}

public static class TopK {
    public String item;
    public int frequency;

    public TopK(String item, int frequency) {
        this.item = item;
        this.frequency = frequency;
    }
    public TopK(TrieEntry entry) {
        this(entry.word, entry.frequency);
    }
    @Override
    public String toString() {
        return String.format("TopK [item=%s, frequency=%s]", item, frequency);
    }
    @Override
    public int hashCode() {
        final int prime = 31;
        int result = 1;
        result = prime * result + frequency;
        result = prime * result + ((item == null) ? 0 : item.hashCode());
        return result;
    }
    @Override
    public boolean equals(Object obj) {
        if (this == obj)
            return true;
        if (obj == null)
            return false;
        if (getClass() != obj.getClass())
            return false;
        TopK other = (TopK) obj;
        if (frequency != other.frequency)
            return false;
        if (item == null) {
            if (other.item != null)
                return false;
        } else if (!item.equals(other.item))
            return false;
        return true;
    }

}   

}

这是单元测试

@Test
public void test() {
    TopKFrequentItems stream = new TopKFrequentItems(2);

    stream.add("hell");
    stream.add("hello");
    stream.add("hello");
    stream.add("hello");
    stream.add("hello");
    stream.add("hello");
    stream.add("hero");
    stream.add("hero");
    stream.add("hero");
    stream.add("hello");
    stream.add("hello");
    stream.add("hello");
    stream.add("home");
    stream.add("go");
    stream.add("go");
    assertThat(stream.getItems()).hasSize(2).contains(new TopK("hero", 3), new TopK("hello", 8));
}

有关更多详细信息,请参考此测试案例


1
  1. 使用哈希表记录遍历整个单词序列时所有单词的频率。在此阶段,键是“单词”,值是“单词频率”。这需要O(n)时间。与上面解释的每个时间相同

  2. 在将自己插入到hashmap中时,请保留大小为10(k = 10)的Treeset(特定于Java,每种语言都有实现),以保留前10个常用单词。耕种尺寸小于10,请继续添加。如果size等于10,则插入的元素大于最小元素,即第一个元素。如果是,请将其删除并插入新元素

要限制树集的大小,请参阅此链接


0

假设我们有一个单词序列“ ad”,“ ad”,“ boy”,“ big”,“ bad”,“ com”,“ come”,“ cold”。并且K = 2。正如您提到的“使用单词的第一个字母进行分区”,我们得到了(“ ad”,“ ad”)(“ boy”,“ big”,“ bad”)(“ com”,“ come”,“ cold”),“ then使用下一个字符对最大的多单词集进行分区,直到有k个单单词集为止。” 它将分区(“ boy”,“ big”,“ bad”)(“ com”,“ come”,“ cold”)),第一个分区(“ ad”,“ ad”)丢失,而“ ad”实际上是最常见的单词。

也许我误会了你的意思。您能详细说明一下有关分区的过程吗?


0

我相信可以通过O(n)算法解决此问题。我们可以即时进行排序。换句话说,那种情况下的排序是传统排序问题的一个子问题,因为每次我们访问哈希表时,只有一个计数器增加一。最初,由于所有计数器均为零,因此对列表进行了排序。当我们在哈希表中保持递增计数器时,我们按顺序保留了另一个按频率排序的哈希值数组。每次增加一个计数器时,我们都会检查其在已排序数组中的索引,并检查其计数是否超过列表中的前一个。如果是这样,我们交换这两个元素。这样,我们得到的解最多为O(n),其中n是原始文本中的单词数。


通常这是一个很好的方向-但它有缺陷。当计数增加时,我们将不只是检查“其前身”,而是需要检查“前身”。例如,数组很有可能是[4,3,1,1,1,1,1,1,1,1,1,1]-1的数目可以很多-这会降低效率因为我们必须回顾所有前任,以找到合适的人来交换。
肖恩

实际上这难道不比O(n)差吗?更像O(n ^ 2),因为它实际上是一种效率很低的排序?
dcarr622

嗨,肖恩。是的,我同意你的看法。但是我怀疑您提到的问题是问题的根本。实际上,如果我们不只是保留一个排序的值数组,而是可以继续保留一个(值,索引)对数组,其中索引指向重复元素的首次出现,那么该问题应该可以在O中解决。 (n)时间。例如,[4,3,1,1,1,1,1,1,1,1,1,1]看起来像[(4,0),(3,1),(1,2),(1 ,2),(1,2,...,(1,2)];下标从0开始
阿里Farahat

0

我也为此而苦苦挣扎,并受到@aly的启发。无需进行事后排序,我们可以维护单词的预排序列表(List<Set<String>>),并且该单词将位于位置X的集合中,其中X是单词的当前计数。通常,这是它的工作方式:

  1. 对于每个单词,将其存储为其出现位置的映射:Map<String, Integer>
  2. 然后,基于该计数,将其从先前的计数集中删除,然后将其添加到新的计数集中。

这样做的缺点是列表可能很大-可以使用TreeMap<Integer, Set<String>>-进行优化,但这会增加一些开销。最终,我们可以混合使用HashMap或我们自己的数据结构。

编码

public class WordFrequencyCounter {
    private static final int WORD_SEPARATOR_MAX = 32; // UNICODE 0000-001F: control chars
    Map<String, MutableCounter> counters = new HashMap<String, MutableCounter>();
    List<Set<String>> reverseCounters = new ArrayList<Set<String>>();

    private static class MutableCounter {
        int i = 1;
    }

    public List<String> countMostFrequentWords(String text, int max) {
        int lastPosition = 0;
        int length = text.length();
        for (int i = 0; i < length; i++) {
            char c = text.charAt(i);
            if (c <= WORD_SEPARATOR_MAX) {
                if (i != lastPosition) {
                    String word = text.substring(lastPosition, i);
                    MutableCounter counter = counters.get(word);
                    if (counter == null) {
                        counter = new MutableCounter();
                        counters.put(word, counter);
                    } else {
                        Set<String> strings = reverseCounters.get(counter.i);
                        strings.remove(word);
                        counter.i ++;
                    }
                    addToReverseLookup(counter.i, word);
                }
                lastPosition = i + 1;
            }
        }

        List<String> ret = new ArrayList<String>();
        int count = 0;
        for (int i = reverseCounters.size() - 1; i >= 0; i--) {
            Set<String> strings = reverseCounters.get(i);
            for (String s : strings) {
                ret.add(s);
                System.out.print(s + ":" + i);
                count++;
                if (count == max) break;
            }
            if (count == max) break;
        }
        return ret;
    }

    private void addToReverseLookup(int count, String word) {
        while (count >= reverseCounters.size()) {
            reverseCounters.add(new HashSet<String>());
        }
        Set<String> strings = reverseCounters.get(count);
        strings.add(word);
    }

}

0

我只是找到解决此问题的另一种方法。但是我不确定这是正确的。解:

  1. 使用哈希表记录所有单词的频率T(n)= O(n)
  2. 选择哈希表的前k个元素,并将其还原到一个缓冲区(其空间= k)中。T(n)= O(k)
  3. 每次,首先我们需要找到缓冲区的当前min元素,然后将缓冲区的min元素与哈希表的(n-k)个元素一一比较。如果哈希表的元素大于此缓冲区的min元素,则删除当前缓冲区的min,然后添加哈希表的元素。因此,每次我们在缓冲区中找到最小值1时,需要T(n)= O(k),并且遍历整个哈希表需要T(n)= O(n-k)。因此,此过程的整个时间复杂度为T(n)= O((nk)* k)。
  4. 遍历整个哈希表后,结果在此缓冲区中。
  5. 整个时间复杂度:T(n)= O(n)+ O(k)+ O(kn-k ^ 2)= O(kn + n-k ^ 2 + k)。因为,k通常实际上小于n。因此,对于该解决方案,时间复杂度为T(n)= O(kn)。那是线性时间,此时k确实很小。这样对吗?我真的不确定。

0

尝试考虑特殊的数据结构来解决此类问题。在这种情况下,像特里树这样的特殊树以特定方式存储字符串,非常有效。或者是建立自己的解决方案的第二种方法,例如计算单词数。我想这TB的数据将是英文的,那么我们通常大约有600,000个单词,因此将有可能仅存储这些单词并计算出将重复的字符串,并且此解决方案将需要使用正则表达式来消除某些特殊字符。我敢肯定,第一个解决方案会更快。

http://en.wikipedia.org/wiki/特里



0

最简单的代码来获取最常用单词的出现。

 function strOccurence(str){
    var arr = str.split(" ");
    var length = arr.length,temp = {},max; 
    while(length--){
    if(temp[arr[length]] == undefined && arr[length].trim().length > 0)
    {
        temp[arr[length]] = 1;
    }
    else if(arr[length].trim().length > 0)
    {
        temp[arr[length]] = temp[arr[length]] + 1;

    }
}
    console.log(temp);
    var max = [];
    for(i in temp)
    {
        max[temp[i]] = i;
    }
    console.log(max[max.length])
   //if you want second highest
   console.log(max[max.length - 2])
}

0

在这些情况下,我建议使用Java内置功能。从那以后,它们已经过了良好的测试和稳定。在这个问题中,我通过使用HashMap数据结构找到单词的重复项。然后,我将结果推送到对象数组。我通过Arrays.sort()对对象进行排序,并打印前k个单词及其重复。

import java.io.*;
import java.lang.reflect.Array;
import java.util.*;

public class TopKWordsTextFile {

    static class SortObject implements Comparable<SortObject>{

        private String key;
        private int value;

        public SortObject(String key, int value) {
            super();
            this.key = key;
            this.value = value;
        }

        @Override
        public int compareTo(SortObject o) {
            //descending order
            return o.value - this.value;
        }
    }


    public static void main(String[] args) {
        HashMap<String,Integer> hm = new HashMap<>();
        int k = 1;
        try {
            BufferedReader br = new BufferedReader(new InputStreamReader(new FileInputStream("words.in")));

            String line;
            while ((line = br.readLine()) != null) {
                // process the line.
                //System.out.println(line);
                String[] tokens = line.split(" ");
                for(int i=0; i<tokens.length; i++){
                    if(hm.containsKey(tokens[i])){
                        //If the key already exists
                        Integer prev = hm.get(tokens[i]);
                        hm.put(tokens[i],prev+1);
                    }else{
                        //If the key doesn't exist
                        hm.put(tokens[i],1);
                    }
                }
            }
            //Close the input
            br.close();
            //Print all words with their repetitions. You can use 3 for printing top 3 words.
            k = hm.size();
            // Get a set of the entries
            Set set = hm.entrySet();
            // Get an iterator
            Iterator i = set.iterator();
            int index = 0;
            // Display elements
            SortObject[] objects = new SortObject[hm.size()];
            while(i.hasNext()) {
                Map.Entry e = (Map.Entry)i.next();
                //System.out.print("Key: "+e.getKey() + ": ");
                //System.out.println(" Value: "+e.getValue());
                String tempS = (String) e.getKey();
                int tempI = (int) e.getValue();
                objects[index] = new SortObject(tempS,tempI);
                index++;
            }
            System.out.println();
            //Sort the array
            Arrays.sort(objects);
            //Print top k
            for(int j=0; j<k; j++){
                System.out.println(objects[j].key+":"+objects[j].value);
            }


        } catch (IOException e) {
            e.printStackTrace();
        }
    }

}

有关更多信息,请访问https://github.com/m-vahidalizadeh/foundations/blob/master/src/algorithms/TopKWordsTextFile.java。希望对您有所帮助。


通过哪种方式可以改善问题中概述的方法?(请不要从呈现在SE代码离开了评论。)(I recommend to use Java built-in features就像foreach循环流处理?)

如您所知,设计有效算法的最重要因素之一就是选择正确的数据结构。然后,重要的是您如何解决问题。例如,您需要通过分而治之来解决问题。您需要贪婪地攻击另一个人。如您所知,Oracle公司正在开发Java。他们是世界上最好的科技公司之一。这里有一些最杰出的工程师致力于Java内置功能。因此,这些功能经过了充分的测试和证明。如果我们可以利用它们,我认为最好使用它们。
Mohammad

0
**

C ++ 11以上思想的实现

**

class Solution {
public:
vector<int> topKFrequent(vector<int>& nums, int k) {

    unordered_map<int,int> map;
    for(int num : nums){
        map[num]++;
    }

    vector<int> res;
    // we use the priority queue, like the max-heap , we will keep (size-k) smallest elements in the queue
    // pair<first, second>: first is frequency,  second is number 
    priority_queue<pair<int,int>> pq; 
    for(auto it = map.begin(); it != map.end(); it++){
        pq.push(make_pair(it->second, it->first));

        // onece the size bigger than size-k, we will pop the value, which is the top k frequent element value 

        if(pq.size() > (int)map.size() - k){
            res.push_back(pq.top().second);
            pq.pop();
        }
    }
    return res;

}

};

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.